Fix keyboard handling in ReactionPicker/Viewer and their child views

This commit is contained in:
Scott Nonnenberg 2022-09-07 11:29:08 -07:00 committed by Fedor Indutnyy
parent 0bf77fadd3
commit 03b750d072
7 changed files with 162 additions and 72 deletions

View file

@ -1,7 +1,12 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties, MouseEventHandler, ReactNode } from 'react'; import type {
CSSProperties,
KeyboardEventHandler,
MouseEventHandler,
ReactNode,
} from 'react';
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@ -48,6 +53,8 @@ type PropsType = {
} & ( } & (
| { | {
onClick: MouseEventHandler<HTMLButtonElement>; onClick: MouseEventHandler<HTMLButtonElement>;
// TODO: DESKTOP-4121
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
} }
| { | {
type: 'submit'; type: 'submit';

View file

@ -173,6 +173,11 @@ export function CustomizingPreferredReactionsModal({
onClick={() => { onClick={() => {
resetDraftEmoji(); resetDraftEmoji();
}} }}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === 'Space') {
resetDraftEmoji();
}
}}
variant={ButtonVariant.SecondaryAffirmative} variant={ButtonVariant.SecondaryAffirmative}
> >
{i18n('reset')} {i18n('reset')}
@ -182,6 +187,11 @@ export function CustomizingPreferredReactionsModal({
onClick={() => { onClick={() => {
savePreferredReactions(); savePreferredReactions();
}} }}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === 'Space') {
savePreferredReactions();
}
}}
> >
{i18n('save')} {i18n('save')}
</Button> </Button>

View file

@ -35,6 +35,13 @@ export const ReactionPickerPickerEmojiButton = React.forwardRef<
event.stopPropagation(); event.stopPropagation();
onClick(); onClick();
}} }}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === 'Space') {
event.stopPropagation();
event.preventDefault();
onClick();
}
}}
> >
<Emoji size={48} emoji={emoji} title={title} /> <Emoji size={48} emoji={emoji} title={title} />
</button> </button>
@ -54,6 +61,13 @@ export const ReactionPickerPickerMoreButton = ({
event.stopPropagation(); event.stopPropagation();
onClick(); onClick();
}} }}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === 'Space') {
event.stopPropagation();
event.preventDefault();
onClick();
}
}}
tabIndex={0} tabIndex={0}
title={i18n('Reactions--more')} title={i18n('Reactions--more')}
type="button" type="button"

View file

@ -495,9 +495,9 @@ export class Message extends React.PureComponent<Props, State> {
}; };
public handleFocus = (): void => { public handleFocus = (): void => {
const { interactionMode } = this.props; const { interactionMode, isSelected } = this.props;
if (interactionMode === 'keyboard') { if (interactionMode === 'keyboard' && !isSelected) {
this.setSelected(); this.setSelected();
} }
}; };
@ -1979,32 +1979,30 @@ export class Message extends React.PureComponent<Props, State> {
</div> </div>
{reactionPickerRoot && {reactionPickerRoot &&
createPortal( createPortal(
<StopPropagation> <Popper
<Popper placement="top"
placement="top" modifiers={[
modifiers={[ offsetDistanceModifier(4),
offsetDistanceModifier(4), this.popperPreventOverflowModifier(),
this.popperPreventOverflowModifier(), ]}
]} >
> {({ ref, style }) =>
{({ ref, style }) => renderReactionPicker({
renderReactionPicker({ ref,
ref, style,
style, selected: selectedReaction,
selected: selectedReaction, onClose: this.toggleReactionPicker,
onClose: this.toggleReactionPicker, onPick: emoji => {
onPick: emoji => { this.toggleReactionPicker(true);
this.toggleReactionPicker(true); reactToMessage(id, {
reactToMessage(id, { emoji,
emoji, remove: emoji === selectedReaction,
remove: emoji === selectedReaction, });
}); },
}, renderEmojiPicker,
renderEmojiPicker, })
}) }
} </Popper>,
</Popper>
</StopPropagation>,
reactionPickerRoot reactionPickerRoot
)} )}
</Manager> </Manager>
@ -2613,28 +2611,26 @@ export class Message extends React.PureComponent<Props, State> {
</Reference> </Reference>
{reactionViewerRoot && {reactionViewerRoot &&
createPortal( createPortal(
<StopPropagation> <Popper
<Popper placement={popperPlacement}
placement={popperPlacement} strategy="fixed"
strategy="fixed" modifiers={[this.popperPreventOverflowModifier()]}
modifiers={[this.popperPreventOverflowModifier()]} >
> {({ ref, style }) => (
{({ ref, style }) => ( <ReactionViewer
<ReactionViewer ref={ref}
ref={ref} style={{
style={{ ...style,
...style, zIndex: 2,
zIndex: 2, }}
}} getPreferredBadge={getPreferredBadge}
getPreferredBadge={getPreferredBadge} reactions={reactions}
reactions={reactions} i18n={i18n}
i18n={i18n} onClose={this.toggleReactionViewer}
onClose={this.toggleReactionViewer} theme={theme}
theme={theme} />
/> )}
)} </Popper>,
</Popper>
</StopPropagation>,
reactionViewerRoot reactionViewerRoot
)} )}
</Manager> </Manager>

View file

@ -4,7 +4,7 @@
import * as React from 'react'; import * as React from 'react';
import { convertShortName } from '../emoji/lib'; import { convertShortName } from '../emoji/lib';
import type { Props as EmojiPickerProps } from '../emoji/EmojiPicker'; import type { Props as EmojiPickerProps } from '../emoji/EmojiPicker';
import { useRestoreFocus } from '../../hooks/useRestoreFocus'; import { useDelayedRestoreFocus } from '../../hooks/useRestoreFocus';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { import {
ReactionPickerPicker, ReactionPickerPicker,
@ -75,7 +75,7 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
); );
// Focus first button and restore focus on unmount // Focus first button and restore focus on unmount
const [focusRef] = useRestoreFocus(); const [focusRef] = useDelayedRestoreFocus();
if (pickingOther) { if (pickingOther) {
return renderEmojiPicker({ return renderEmojiPicker({

View file

@ -193,6 +193,13 @@ export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
event.stopPropagation(); event.stopPropagation();
setSelectedReactionCategory(id); setSelectedReactionCategory(id);
}} }}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === 'Space') {
event.stopPropagation();
event.preventDefault();
setSelectedReactionCategory(id);
}
}}
> >
{isAll ? ( {isAll ? (
<span className="module-reaction-viewer__header__button__all"> <span className="module-reaction-viewer__header__button__all">

View file

@ -89,8 +89,14 @@ export const EmojiPicker = React.memo(
const [selectedTone, setSelectedTone] = React.useState(skinTone); const [selectedTone, setSelectedTone] = React.useState(skinTone);
const handleToggleSearch = React.useCallback( const handleToggleSearch = React.useCallback(
(e: React.MouseEvent) => { (
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
setSearchText(''); setSearchText('');
setSelectedCategory(categories[0]); setSelectedCategory(categories[0]);
setSearchMode(m => !m); setSearchMode(m => !m);
@ -115,7 +121,11 @@ export const EmojiPicker = React.memo(
); );
const handlePickTone = React.useCallback( const handlePickTone = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => { (
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -135,19 +145,24 @@ export const EmojiPicker = React.memo(
| React.MouseEvent<HTMLButtonElement> | React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
) => { ) => {
const { shortName } = e.currentTarget.dataset;
if ('key' in e) { if ('key' in e) {
if (e.key === 'Enter' && doSend) { if (e.key === 'Enter') {
e.stopPropagation(); if (doSend) {
e.preventDefault(); doSend();
doSend(); e.stopPropagation();
} e.preventDefault();
} else { }
const { shortName } = e.currentTarget.dataset; if (shortName) {
if (shortName) { onPickEmoji({ skinTone: selectedTone, shortName });
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
onPickEmoji({ skinTone: selectedTone, shortName }); }
} }
} else if (shortName) {
e.stopPropagation();
e.preventDefault();
onPickEmoji({ skinTone: selectedTone, shortName });
} }
}, },
[doSend, onPickEmoji, selectedTone] [doSend, onPickEmoji, selectedTone]
@ -158,14 +173,16 @@ export const EmojiPicker = React.memo(
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
if (searchMode) { if (searchMode) {
event.preventDefault();
event.stopPropagation();
setScrollToRow(0); setScrollToRow(0);
setSearchText(''); setSearchText('');
setSearchMode(false); setSearchMode(false);
} else { } else if (onClose) {
onClose?.(); event.preventDefault();
event.stopPropagation();
onClose();
} }
event.preventDefault();
event.stopPropagation();
} else if (!searchMode && !event.ctrlKey && !event.metaKey) { } else if (!searchMode && !event.ctrlKey && !event.metaKey) {
if ( if (
[ [
@ -251,8 +268,14 @@ export const EmojiPicker = React.memo(
); );
const handleSelectCategory = React.useCallback( const handleSelectCategory = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => { (
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
const { category } = e.currentTarget.dataset; const { category } = e.currentTarget.dataset;
if (category) { if (category) {
setSelectedCategory(category); setSelectedCategory(category);
@ -326,6 +349,11 @@ export const EmojiPicker = React.memo(
<button <button
type="button" type="button"
onClick={handleToggleSearch} onClick={handleToggleSearch}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === 'Select') {
handleToggleSearch(event);
}
}}
title={i18n('EmojiPicker--search-placeholder')} title={i18n('EmojiPicker--search-placeholder')}
className={classNames( className={classNames(
'module-emoji-picker__button', 'module-emoji-picker__button',
@ -354,6 +382,11 @@ export const EmojiPicker = React.memo(
data-category={cat} data-category={cat}
title={cat} title={cat}
onClick={handleSelectCategory} onClick={handleSelectCategory}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === 'Space') {
handleSelectCategory(event);
}
}}
className={classNames( className={classNames(
'module-emoji-picker__button', 'module-emoji-picker__button',
'module-emoji-picker__button--icon', 'module-emoji-picker__button--icon',
@ -412,7 +445,25 @@ export const EmojiPicker = React.memo(
<button <button
aria-label={i18n('CustomizingPreferredReactions__title')} aria-label={i18n('CustomizingPreferredReactions__title')}
className="module-emoji-picker__button module-emoji-picker__button--footer module-emoji-picker__button--settings" className="module-emoji-picker__button module-emoji-picker__button--footer module-emoji-picker__button--settings"
onClick={onClickSettings} onClick={event => {
if (onClickSettings) {
event.preventDefault();
event.stopPropagation();
onClickSettings();
}
}}
onKeyDown={event => {
if (
onClickSettings &&
(event.key === 'Enter' || event.key === 'Space')
) {
event.preventDefault();
event.stopPropagation();
onClickSettings();
}
}}
title={i18n('CustomizingPreferredReactions__title')} title={i18n('CustomizingPreferredReactions__title')}
type="button" type="button"
/> />
@ -425,6 +476,11 @@ export const EmojiPicker = React.memo(
key={tone} key={tone}
data-tone={tone} data-tone={tone}
onClick={handlePickTone} onClick={handlePickTone}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === 'Space') {
handlePickTone(event);
}
}}
title={i18n('EmojiPicker--skin-tone', [`${tone}`])} title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
className={classNames( className={classNames(
'module-emoji-picker__button', 'module-emoji-picker__button',