Add arrow key controls to emoji and reaction pickers (#10637)

* Add arrow key controls to emoji and reaction pickers

* Iterate types

* Switch to using aria-activedescendant

* Add tests

* Fix tests

* Iterate

* Update test

* Tweak header keyboard navigation behaviour

* Also handle scrolling on left/right arrow keys

* Iterate
This commit is contained in:
Michael Telatynski 2023-04-20 15:56:21 +01:00 committed by GitHub
parent 0d9fa0515d
commit 2da52372d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 277 additions and 74 deletions

View file

@ -174,7 +174,7 @@ describe("Threads", () => {
.click({ force: true }); // Cypress has no ability to hover .click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_EmojiPicker").within(() => { cy.get(".mx_EmojiPicker").within(() => {
cy.get('input[type="text"]').type("wave"); cy.get('input[type="text"]').type("wave");
cy.contains('[role="menuitem"]', "👋").click(); cy.contains('[role="gridcell"]', "👋").click();
}); });
cy.get(".mx_ThreadView").within(() => { cy.get(".mx_ThreadView").within(() => {

View file

@ -179,6 +179,14 @@ limitations under the License.
list-style: none; list-style: none;
width: 38px; width: 38px;
cursor: pointer; cursor: pointer;
&:focus-within {
background-color: $focus-bg-color;
}
}
.mx_EmojiPicker_body .mx_EmojiPicker_item_wrapper[tabindex="0"] .mx_EmojiPicker_item {
background-color: $focus-bg-color;
} }
.mx_EmojiPicker_item { .mx_EmojiPicker_item {

View file

@ -61,7 +61,7 @@ export interface IState {
refs: Ref[]; refs: Ref[];
} }
interface IContext { export interface IContext {
state: IState; state: IState;
dispatch: Dispatch<IAction>; dispatch: Dispatch<IAction>;
} }
@ -80,7 +80,7 @@ export enum Type {
SetFocus = "SET_FOCUS", SetFocus = "SET_FOCUS",
} }
interface IAction { export interface IAction {
type: Type; type: Type;
payload: { payload: {
ref: Ref; ref: Ref;
@ -160,7 +160,7 @@ interface IProps {
handleUpDown?: boolean; handleUpDown?: boolean;
handleLeftRight?: boolean; handleLeftRight?: boolean;
children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode; children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode;
onKeyDown?(ev: React.KeyboardEvent, state: IState): void; onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
} }
export const findSiblingElement = ( export const findSiblingElement = (
@ -199,7 +199,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
const onKeyDownHandler = useCallback( const onKeyDownHandler = useCallback(
(ev: React.KeyboardEvent) => { (ev: React.KeyboardEvent) => {
if (onKeyDown) { if (onKeyDown) {
onKeyDown(ev, context.state); onKeyDown(ev, context.state, context.dispatch);
if (ev.defaultPrevented) { if (ev.defaultPrevented) {
return; return;
} }

View file

@ -22,10 +22,17 @@ import { Ref } from "./types";
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "inputRef" | "tabIndex"> { interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "inputRef" | "tabIndex"> {
inputRef?: Ref; inputRef?: Ref;
focusOnMouseOver?: boolean;
} }
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ...props }) => { export const RovingAccessibleButton: React.FC<IProps> = ({
inputRef,
onFocus,
onMouseOver,
focusOnMouseOver,
...props
}) => {
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return ( return (
<AccessibleButton <AccessibleButton
@ -34,6 +41,10 @@ export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ..
onFocusInternal(); onFocusInternal();
onFocus?.(event); onFocus?.(event);
}} }}
onMouseOver={(event: React.MouseEvent) => {
if (focusOnMouseOver) onFocusInternal();
onMouseOver?.(event);
}}
inputRef={ref} inputRef={ref}
tabIndex={isActive ? 0 : -1} tabIndex={isActive ? 0 : -1}
/> />

View file

@ -148,7 +148,7 @@ export default class ContextMenu extends React.PureComponent<React.PropsWithChil
const first = const first =
element.querySelector<HTMLElement>('[role^="menuitem"]') || element.querySelector<HTMLElement>('[role^="menuitem"]') ||
element.querySelector<HTMLElement>("[tab-index]"); element.querySelector<HTMLElement>("[tabindex]");
if (first) { if (first) {
first.focus(); first.focus();

View file

@ -73,6 +73,7 @@ interface IProps<T> {
element?: string; element?: string;
className?: string; className?: string;
role?: string;
} }
interface IState { interface IState {
@ -128,6 +129,7 @@ export default class LazyRenderList<T = any> extends React.Component<IProps<T>,
const elementProps = { const elementProps = {
style: { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` }, style: { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` },
className: this.props.className, className: this.props.className,
role: this.props.role,
}; };
return React.createElement(element, elementProps, renderedItems.map(renderItem)); return React.createElement(element, elementProps, renderedItems.map(renderItem));
} }

View file

@ -21,6 +21,7 @@ import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPic
import LazyRenderList from "../elements/LazyRenderList"; import LazyRenderList from "../elements/LazyRenderList";
import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji"; import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji";
import Emoji from "./Emoji"; import Emoji from "./Emoji";
import { ButtonEvent } from "../elements/AccessibleButton";
const OVERFLOW_ROWS = 3; const OVERFLOW_ROWS = 3;
@ -42,18 +43,31 @@ interface IProps {
heightBefore: number; heightBefore: number;
viewportHeight: number; viewportHeight: number;
scrollTop: number; scrollTop: number;
onClick(emoji: IEmoji): void; onClick(ev: ButtonEvent, emoji: IEmoji): void;
onMouseEnter(emoji: IEmoji): void; onMouseEnter(emoji: IEmoji): void;
onMouseLeave(emoji: IEmoji): void; onMouseLeave(emoji: IEmoji): void;
isEmojiDisabled?: (unicode: string) => boolean; isEmojiDisabled?: (unicode: string) => boolean;
} }
function hexEncode(str: string): string {
let hex: string;
let i: number;
let result = "";
for (i = 0; i < str.length; i++) {
hex = str.charCodeAt(i).toString(16);
result += ("000" + hex).slice(-4);
}
return result;
}
class Category extends React.PureComponent<IProps> { class Category extends React.PureComponent<IProps> {
private renderEmojiRow = (rowIndex: number): JSX.Element => { private renderEmojiRow = (rowIndex: number): JSX.Element => {
const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props; const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8); const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
return ( return (
<div key={rowIndex}> <div key={rowIndex} role="row">
{emojisForRow.map((emoji) => ( {emojisForRow.map((emoji) => (
<Emoji <Emoji
key={emoji.hexcode} key={emoji.hexcode}
@ -63,6 +77,8 @@ class Category extends React.PureComponent<IProps> {
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
disabled={this.props.isEmojiDisabled?.(emoji.unicode)} disabled={this.props.isEmojiDisabled?.(emoji.unicode)}
id={`mx_EmojiPicker_item_${this.props.id}_${hexEncode(emoji.unicode)}`}
role="gridcell"
/> />
))} ))}
</div> </div>
@ -101,7 +117,6 @@ class Category extends React.PureComponent<IProps> {
> >
<h2 className="mx_EmojiPicker_category_label">{name}</h2> <h2 className="mx_EmojiPicker_category_label">{name}</h2>
<LazyRenderList <LazyRenderList
element="ul"
className="mx_EmojiPicker_list" className="mx_EmojiPicker_list"
itemHeight={EMOJI_HEIGHT} itemHeight={EMOJI_HEIGHT}
items={rows} items={rows}
@ -110,6 +125,7 @@ class Category extends React.PureComponent<IProps> {
overflowItems={OVERFLOW_ROWS} overflowItems={OVERFLOW_ROWS}
overflowMargin={0} overflowMargin={0}
renderItem={this.renderEmojiRow} renderItem={this.renderEmojiRow}
role="grid"
/> />
</section> </section>
); );

View file

@ -17,36 +17,40 @@ limitations under the License.
import React from "react"; import React from "react";
import { MenuItem } from "../../structures/ContextMenu";
import { IEmoji } from "../../../emoji"; import { IEmoji } from "../../../emoji";
import { ButtonEvent } from "../elements/AccessibleButton";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
interface IProps { interface IProps {
emoji: IEmoji; emoji: IEmoji;
selectedEmojis?: Set<string>; selectedEmojis?: Set<string>;
onClick(emoji: IEmoji): void; onClick(ev: ButtonEvent, emoji: IEmoji): void;
onMouseEnter(emoji: IEmoji): void; onMouseEnter(emoji: IEmoji): void;
onMouseLeave(emoji: IEmoji): void; onMouseLeave(emoji: IEmoji): void;
disabled?: boolean; disabled?: boolean;
id?: string;
role?: string;
} }
class Emoji extends React.PureComponent<IProps> { class Emoji extends React.PureComponent<IProps> {
public render(): React.ReactNode { public render(): React.ReactNode {
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props; const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode); const isSelected = selectedEmojis?.has(emoji.unicode);
return ( return (
<MenuItem <RovingAccessibleButton
element="li" id={this.props.id}
onClick={() => onClick(emoji)} onClick={(ev) => onClick(ev, emoji)}
onMouseEnter={() => onMouseEnter(emoji)} onMouseEnter={() => onMouseEnter(emoji)}
onMouseLeave={() => onMouseLeave(emoji)} onMouseLeave={() => onMouseLeave(emoji)}
className="mx_EmojiPicker_item_wrapper" className="mx_EmojiPicker_item_wrapper"
label={emoji.unicode}
disabled={this.props.disabled} disabled={this.props.disabled}
role={this.props.role}
focusOnMouseOver
> >
<div className={`mx_EmojiPicker_item ${isSelected ? "mx_EmojiPicker_item_selected" : ""}`}> <div className={`mx_EmojiPicker_item ${isSelected ? "mx_EmojiPicker_item_selected" : ""}`}>
{emoji.unicode} {emoji.unicode}
</div> </div>
</MenuItem> </RovingAccessibleButton>
); );
} }
} }

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { Dispatch } from "react";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import * as recent from "../../../emojipicker/recent"; import * as recent from "../../../emojipicker/recent";
@ -25,8 +25,18 @@ import Header from "./Header";
import Search from "./Search"; import Search from "./Search";
import Preview from "./Preview"; import Preview from "./Preview";
import QuickReactions from "./QuickReactions"; import QuickReactions from "./QuickReactions";
import Category, { ICategory, CategoryKey } from "./Category"; import Category, { CategoryKey, ICategory } from "./Category";
import { filterBoolean } from "../../../utils/arrays"; import { filterBoolean } from "../../../utils/arrays";
import {
IAction as RovingAction,
IState as RovingState,
RovingTabIndexProvider,
Type,
} from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard";
import { clamp } from "../../../utils/numbers";
import { ButtonEvent } from "../elements/AccessibleButton";
import { Ref } from "../../../accessibility/roving/types";
export const CATEGORY_HEADER_HEIGHT = 20; export const CATEGORY_HEADER_HEIGHT = 20;
export const EMOJI_HEIGHT = 35; export const EMOJI_HEIGHT = 35;
@ -37,6 +47,7 @@ const ZERO_WIDTH_JOINER = "\u200D";
interface IProps { interface IProps {
selectedEmojis?: Set<string>; selectedEmojis?: Set<string>;
onChoose(unicode: string): boolean; onChoose(unicode: string): boolean;
onFinished(): void;
isEmojiDisabled?: (unicode: string) => boolean; isEmojiDisabled?: (unicode: string) => boolean;
} }
@ -150,6 +161,68 @@ class EmojiPicker extends React.Component<IProps, IState> {
this.updateVisibility(); this.updateVisibility();
}; };
private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void {
const node = state.activeRef.current;
const parent = node.parentElement;
if (!parent) return;
const rowIndex = Array.from(parent.children).indexOf(node);
const refIndex = state.refs.indexOf(state.activeRef);
let focusRef: Ref | undefined;
let newParent: HTMLElement | undefined;
switch (ev.key) {
case Key.ARROW_LEFT:
focusRef = state.refs[refIndex - 1];
newParent = focusRef?.current?.parentElement;
break;
case Key.ARROW_RIGHT:
focusRef = state.refs[refIndex + 1];
newParent = focusRef?.current?.parentElement;
break;
case Key.ARROW_UP:
case Key.ARROW_DOWN: {
// For up/down we find the prev/next parent by inspecting the refs either side of our row
const ref =
ev.key === Key.ARROW_UP
? state.refs[refIndex - rowIndex - 1]
: state.refs[refIndex - rowIndex + EMOJIS_PER_ROW];
newParent = ref?.current?.parentElement;
const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)];
focusRef = state.refs.find((r) => r.current === newTarget);
break;
}
}
if (focusRef) {
dispatch({
type: Type.SetFocus,
payload: { ref: focusRef },
});
if (parent !== newParent) {
focusRef.current?.scrollIntoView({
behavior: "auto",
block: "center",
inline: "center",
});
}
}
ev.preventDefault();
ev.stopPropagation();
}
private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => {
if (
state.activeRef?.current &&
[Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)
) {
this.keyboardNavigation(ev, state, dispatch);
}
};
private updateVisibility = (): void => { private updateVisibility = (): void => {
const body = this.scrollRef.current?.containerRef.current; const body = this.scrollRef.current?.containerRef.current;
if (!body) return; if (!body) return;
@ -239,11 +312,11 @@ class EmojiPicker extends React.Component<IProps, IState> {
}; };
private onEnterFilter = (): void => { private onEnterFilter = (): void => {
const btn = const btn = this.scrollRef.current?.containerRef.current?.querySelector<HTMLButtonElement>(
this.scrollRef.current?.containerRef.current?.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item"); '.mx_EmojiPicker_item_wrapper[tabindex="0"]',
if (btn) { );
btn.click(); btn?.click();
} this.props.onFinished();
}; };
private onHoverEmoji = (emoji: IEmoji): void => { private onHoverEmoji = (emoji: IEmoji): void => {
@ -258,10 +331,13 @@ class EmojiPicker extends React.Component<IProps, IState> {
}); });
}; };
private onClickEmoji = (emoji: IEmoji): void => { private onClickEmoji = (ev: ButtonEvent, emoji: IEmoji): void => {
if (this.props.onChoose(emoji.unicode) !== false) { if (this.props.onChoose(emoji.unicode) !== false) {
recent.add(emoji.unicode); recent.add(emoji.unicode);
} }
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
this.props.onFinished();
}
}; };
private static categoryHeightForEmojiCount(count: number): number { private static categoryHeightForEmojiCount(count: number): number {
@ -272,41 +348,60 @@ class EmojiPicker extends React.Component<IProps, IState> {
} }
public render(): React.ReactNode { public render(): React.ReactNode {
let heightBefore = 0;
return ( return (
<div className="mx_EmojiPicker" data-testid="mx_EmojiPicker"> <RovingTabIndexProvider onKeyDown={this.onKeyDown}>
<Header categories={this.categories} onAnchorClick={this.scrollToCategory} /> {({ onKeyDownHandler }) => {
<Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} /> let heightBefore = 0;
<AutoHideScrollbar className="mx_EmojiPicker_body" ref={this.scrollRef} onScroll={this.onScroll}> return (
{this.categories.map((category) => { <div className="mx_EmojiPicker" data-testid="mx_EmojiPicker" onKeyDown={onKeyDownHandler}>
const emojis = this.memoizedDataByCategory[category.id]; <Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
const categoryElement = ( <Search
<Category query={this.state.filter}
key={category.id} onChange={this.onChangeFilter}
id={category.id} onEnter={this.onEnterFilter}
name={category.name} onKeyDown={onKeyDownHandler}
heightBefore={heightBefore}
viewportHeight={this.state.viewportHeight}
scrollTop={this.state.scrollTop}
emojis={emojis}
onClick={this.onClickEmoji}
onMouseEnter={this.onHoverEmoji}
onMouseLeave={this.onHoverEmojiEnd}
isEmojiDisabled={this.props.isEmojiDisabled}
selectedEmojis={this.props.selectedEmojis}
/> />
); <AutoHideScrollbar
const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); id="mx_EmojiPicker_body"
heightBefore += height; className="mx_EmojiPicker_body"
return categoryElement; ref={this.scrollRef}
})} onScroll={this.onScroll}
</AutoHideScrollbar> >
{this.state.previewEmoji ? ( {this.categories.map((category) => {
<Preview emoji={this.state.previewEmoji} /> const emojis = this.memoizedDataByCategory[category.id];
) : ( const categoryElement = (
<QuickReactions onClick={this.onClickEmoji} selectedEmojis={this.props.selectedEmojis} /> <Category
)} key={category.id}
</div> id={category.id}
name={category.name}
heightBefore={heightBefore}
viewportHeight={this.state.viewportHeight}
scrollTop={this.state.scrollTop}
emojis={emojis}
onClick={this.onClickEmoji}
onMouseEnter={this.onHoverEmoji}
onMouseLeave={this.onHoverEmojiEnd}
isEmojiDisabled={this.props.isEmojiDisabled}
selectedEmojis={this.props.selectedEmojis}
/>
);
const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length);
heightBefore += height;
return categoryElement;
})}
</AutoHideScrollbar>
{this.state.previewEmoji ? (
<Preview emoji={this.state.previewEmoji} />
) : (
<QuickReactions
onClick={this.onClickEmoji}
selectedEmojis={this.props.selectedEmojis}
/>
)}
</div>
);
}}
</RovingTabIndexProvider>
); );
} }
} }

View file

@ -17,6 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import classNames from "classnames"; import classNames from "classnames";
import { findLastIndex } from "lodash";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { CategoryKey, ICategory } from "./Category"; import { CategoryKey, ICategory } from "./Category";
@ -40,7 +41,14 @@ class Header extends React.PureComponent<IProps> {
} }
private changeCategoryRelative(delta: number): void { private changeCategoryRelative(delta: number): void {
const current = this.props.categories.findIndex((c) => c.visible); let current: number;
// As multiple categories may be visible at once, we want to find the one closest to the relative direction
if (delta < 0) {
current = this.props.categories.findIndex((c) => c.visible);
} else {
// XXX: Switch to Array::findLastIndex once we enable ES2023
current = findLastIndex(this.props.categories, (c) => c.visible);
}
this.changeCategoryAbsolute(current + delta, delta); this.changeCategoryAbsolute(current + delta, delta);
} }

View file

@ -20,6 +20,8 @@ import React from "react";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { getEmojiFromUnicode, IEmoji } from "../../../emoji"; import { getEmojiFromUnicode, IEmoji } from "../../../emoji";
import Emoji from "./Emoji"; import Emoji from "./Emoji";
import { ButtonEvent } from "../elements/AccessibleButton";
import Toolbar from "../../../accessibility/Toolbar";
// We use the variation-selector Heart in Quick Reactions for some reason // We use the variation-selector Heart in Quick Reactions for some reason
const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map((emoji) => { const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map((emoji) => {
@ -32,7 +34,7 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
interface IProps { interface IProps {
selectedEmojis?: Set<string>; selectedEmojis?: Set<string>;
onClick(emoji: IEmoji): void; onClick(ev: ButtonEvent, emoji: IEmoji): void;
} }
interface IState { interface IState {
@ -70,7 +72,7 @@ class QuickReactions extends React.Component<IProps, IState> {
</React.Fragment> </React.Fragment>
)} )}
</h2> </h2>
<ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}> <Toolbar className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}>
{QUICK_REACTIONS.map((emoji) => ( {QUICK_REACTIONS.map((emoji) => (
<Emoji <Emoji
key={emoji.hexcode} key={emoji.hexcode}
@ -81,7 +83,7 @@ class QuickReactions extends React.Component<IProps, IState> {
selectedEmojis={this.props.selectedEmojis} selectedEmojis={this.props.selectedEmojis}
/> />
))} ))}
</ul> </Toolbar>
</section> </section>
); );
} }

View file

@ -135,6 +135,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
<EmojiPicker <EmojiPicker
onChoose={this.onChoose} onChoose={this.onChoose}
isEmojiDisabled={this.isEmojiDisabled} isEmojiDisabled={this.isEmojiDisabled}
onFinished={this.props.onFinished}
selectedEmojis={this.state.selectedEmojis} selectedEmojis={this.state.selectedEmojis}
/> />
); );

View file

@ -20,14 +20,19 @@ import React from "react";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { RovingTabIndexContext } from "../../../accessibility/RovingTabIndex";
interface IProps { interface IProps {
query: string; query: string;
onChange(value: string): void; onChange(value: string): void;
onEnter(): void; onEnter(): void;
onKeyDown(event: React.KeyboardEvent): void;
} }
class Search extends React.PureComponent<IProps> { class Search extends React.PureComponent<IProps> {
public static contextType = RovingTabIndexContext;
public context!: React.ContextType<typeof RovingTabIndexContext>;
private inputRef = React.createRef<HTMLInputElement>(); private inputRef = React.createRef<HTMLInputElement>();
public componentDidMount(): void { public componentDidMount(): void {
@ -43,11 +48,14 @@ class Search extends React.PureComponent<IProps> {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
break; break;
default:
this.props.onKeyDown(ev);
} }
}; };
public render(): React.ReactNode { public render(): React.ReactNode {
let rightButton; let rightButton: JSX.Element;
if (this.props.query) { if (this.props.query) {
rightButton = ( rightButton = (
<button <button
@ -70,6 +78,10 @@ class Search extends React.PureComponent<IProps> {
onChange={(ev) => this.props.onChange(ev.target.value)} onChange={(ev) => this.props.onChange(ev.target.value)}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
ref={this.inputRef} ref={this.inputRef}
aria-activedescendant={this.context.state.activeRef?.current?.id}
aria-controls="mx_EmojiPicker_body"
aria-haspopup="grid"
aria-autocomplete="list"
/> />
{rightButton} {rightButton}
</div> </div>

View file

@ -36,17 +36,14 @@ export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonP
let contextMenu: React.ReactElement | null = null; let contextMenu: React.ReactElement | null = null;
if (menuDisplayed && button.current) { if (menuDisplayed && button.current) {
const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect()); const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect());
const onFinished = (): void => {
closeMenu();
overflowMenuCloser?.();
};
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu {...position} onFinished={onFinished} managed={false}>
{...position} <EmojiPicker onChoose={addEmoji} onFinished={onFinished} />
onFinished={() => {
closeMenu();
overflowMenuCloser?.();
}}
managed={false}
>
<EmojiPicker onChoose={addEmoji} />
</ContextMenu> </ContextMenu>
); );
} }

View file

@ -14,6 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import EmojiPicker from "../../../../src/components/views/emojipicker/EmojiPicker"; import EmojiPicker from "../../../../src/components/views/emojipicker/EmojiPicker";
import { stubClient } from "../../../test-utils"; import { stubClient } from "../../../test-utils";
@ -21,7 +25,7 @@ describe("EmojiPicker", function () {
stubClient(); stubClient();
it("sort emojis by shortcode and size", function () { it("sort emojis by shortcode and size", function () {
const ep = new EmojiPicker({ onChoose: (str: String) => false }); const ep = new EmojiPicker({ onChoose: (str: string) => false, onFinished: jest.fn() });
//@ts-ignore private access //@ts-ignore private access
ep.onChangeFilter("heart"); ep.onChangeFilter("heart");
@ -31,4 +35,47 @@ describe("EmojiPicker", function () {
//@ts-ignore private access //@ts-ignore private access
expect(ep.memoizedDataByCategory["people"][1].shortcodes[0]).toEqual("heartbeat"); expect(ep.memoizedDataByCategory["people"][1].shortcodes[0]).toEqual("heartbeat");
}); });
it("should allow keyboard navigation using arrow keys", async () => {
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
const onChoose = jest.fn();
const onFinished = jest.fn();
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
const input = container.querySelector("input")!;
expect(input).toHaveFocus();
function getEmoji(): string {
const activeDescendant = input.getAttribute("aria-activedescendant");
return container.querySelector("#" + activeDescendant)!.textContent!;
}
expect(getEmoji()).toEqual("😀");
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🙂");
await userEvent.keyboard("[ArrowUp]");
expect(getEmoji()).toEqual("😀");
await userEvent.keyboard("Flag");
await userEvent.keyboard("[ArrowRight]");
await userEvent.keyboard("[ArrowRight]");
expect(getEmoji()).toEqual("📫️");
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🇦🇨");
await userEvent.keyboard("[ArrowLeft]");
expect(getEmoji()).toEqual("📭️");
await userEvent.keyboard("[ArrowUp]");
expect(getEmoji()).toEqual("⛳️");
await userEvent.keyboard("[ArrowRight]");
expect(getEmoji()).toEqual("📫️");
await userEvent.keyboard("[Enter]");
expect(onChoose).toHaveBeenCalledWith("📫️");
expect(onFinished).toHaveBeenCalled();
});
}); });