diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 1d36cbdd850..f93d9990c84 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -174,7 +174,7 @@ describe("Threads", () => { .click({ force: true }); // Cypress has no ability to hover cy.get(".mx_EmojiPicker").within(() => { cy.get('input[type="text"]').type("wave"); - cy.contains('[role="menuitem"]', "👋").click(); + cy.contains('[role="gridcell"]', "👋").click(); }); cy.get(".mx_ThreadView").within(() => { diff --git a/res/css/views/emojipicker/_EmojiPicker.pcss b/res/css/views/emojipicker/_EmojiPicker.pcss index c9169dbe7d8..8e78061a11b 100644 --- a/res/css/views/emojipicker/_EmojiPicker.pcss +++ b/res/css/views/emojipicker/_EmojiPicker.pcss @@ -179,6 +179,14 @@ limitations under the License. list-style: none; width: 38px; 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 { diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b449b10710f..7b8cb7ede58 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -61,7 +61,7 @@ export interface IState { refs: Ref[]; } -interface IContext { +export interface IContext { state: IState; dispatch: Dispatch; } @@ -80,7 +80,7 @@ export enum Type { SetFocus = "SET_FOCUS", } -interface IAction { +export interface IAction { type: Type; payload: { ref: Ref; @@ -160,7 +160,7 @@ interface IProps { handleUpDown?: boolean; handleLeftRight?: boolean; children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode; - onKeyDown?(ev: React.KeyboardEvent, state: IState): void; + onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch): void; } export const findSiblingElement = ( @@ -199,7 +199,7 @@ export const RovingTabIndexProvider: React.FC = ({ const onKeyDownHandler = useCallback( (ev: React.KeyboardEvent) => { if (onKeyDown) { - onKeyDown(ev, context.state); + onKeyDown(ev, context.state, context.dispatch); if (ev.defaultPrevented) { return; } diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index 71818c6cda1..28748de73fb 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -22,10 +22,17 @@ import { Ref } from "./types"; interface IProps extends Omit, "inputRef" | "tabIndex"> { inputRef?: Ref; + focusOnMouseOver?: boolean; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton: React.FC = ({ inputRef, onFocus, ...props }) => { +export const RovingAccessibleButton: React.FC = ({ + inputRef, + onFocus, + onMouseOver, + focusOnMouseOver, + ...props +}) => { const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); return ( = ({ inputRef, onFocus, .. onFocusInternal(); onFocus?.(event); }} + onMouseOver={(event: React.MouseEvent) => { + if (focusOnMouseOver) onFocusInternal(); + onMouseOver?.(event); + }} inputRef={ref} tabIndex={isActive ? 0 : -1} /> diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 270a0b0a072..8691c6c25d0 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -148,7 +148,7 @@ export default class ContextMenu extends React.PureComponent('[role^="menuitem"]') || - element.querySelector("[tab-index]"); + element.querySelector("[tabindex]"); if (first) { first.focus(); diff --git a/src/components/views/elements/LazyRenderList.tsx b/src/components/views/elements/LazyRenderList.tsx index 0a041730339..802e60ca196 100644 --- a/src/components/views/elements/LazyRenderList.tsx +++ b/src/components/views/elements/LazyRenderList.tsx @@ -73,6 +73,7 @@ interface IProps { element?: string; className?: string; + role?: string; } interface IState { @@ -128,6 +129,7 @@ export default class LazyRenderList extends React.Component, const elementProps = { style: { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` }, className: this.props.className, + role: this.props.role, }; return React.createElement(element, elementProps, renderedItems.map(renderItem)); } diff --git a/src/components/views/emojipicker/Category.tsx b/src/components/views/emojipicker/Category.tsx index f4ffce911b5..cf662feea39 100644 --- a/src/components/views/emojipicker/Category.tsx +++ b/src/components/views/emojipicker/Category.tsx @@ -21,6 +21,7 @@ import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPic import LazyRenderList from "../elements/LazyRenderList"; import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji"; import Emoji from "./Emoji"; +import { ButtonEvent } from "../elements/AccessibleButton"; const OVERFLOW_ROWS = 3; @@ -42,18 +43,31 @@ interface IProps { heightBefore: number; viewportHeight: number; scrollTop: number; - onClick(emoji: IEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji): void; onMouseEnter(emoji: IEmoji): void; onMouseLeave(emoji: IEmoji): void; 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 { private renderEmojiRow = (rowIndex: number): JSX.Element => { const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props; const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8); return ( -
+
{emojisForRow.map((emoji) => ( { onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} disabled={this.props.isEmojiDisabled?.(emoji.unicode)} + id={`mx_EmojiPicker_item_${this.props.id}_${hexEncode(emoji.unicode)}`} + role="gridcell" /> ))}
@@ -101,7 +117,6 @@ class Category extends React.PureComponent { >

{name}

{ overflowItems={OVERFLOW_ROWS} overflowMargin={0} renderItem={this.renderEmojiRow} + role="grid" /> ); diff --git a/src/components/views/emojipicker/Emoji.tsx b/src/components/views/emojipicker/Emoji.tsx index 022c29a94a6..62798873034 100644 --- a/src/components/views/emojipicker/Emoji.tsx +++ b/src/components/views/emojipicker/Emoji.tsx @@ -17,36 +17,40 @@ limitations under the License. import React from "react"; -import { MenuItem } from "../../structures/ContextMenu"; import { IEmoji } from "../../../emoji"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; interface IProps { emoji: IEmoji; selectedEmojis?: Set; - onClick(emoji: IEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji): void; onMouseEnter(emoji: IEmoji): void; onMouseLeave(emoji: IEmoji): void; disabled?: boolean; + id?: string; + role?: string; } class Emoji extends React.PureComponent { public render(): React.ReactNode { const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props; - const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode); + const isSelected = selectedEmojis?.has(emoji.unicode); return ( - onClick(emoji)} + onClick(ev, emoji)} onMouseEnter={() => onMouseEnter(emoji)} onMouseLeave={() => onMouseLeave(emoji)} className="mx_EmojiPicker_item_wrapper" - label={emoji.unicode} disabled={this.props.disabled} + role={this.props.role} + focusOnMouseOver >
{emoji.unicode}
-
+ ); } } diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index b4a868f474d..7a62c4dd079 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { Dispatch } from "react"; import { _t } from "../../../languageHandler"; import * as recent from "../../../emojipicker/recent"; @@ -25,8 +25,18 @@ import Header from "./Header"; import Search from "./Search"; import Preview from "./Preview"; import QuickReactions from "./QuickReactions"; -import Category, { ICategory, CategoryKey } from "./Category"; +import Category, { CategoryKey, ICategory } from "./Category"; 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 EMOJI_HEIGHT = 35; @@ -37,6 +47,7 @@ const ZERO_WIDTH_JOINER = "\u200D"; interface IProps { selectedEmojis?: Set; onChoose(unicode: string): boolean; + onFinished(): void; isEmojiDisabled?: (unicode: string) => boolean; } @@ -150,6 +161,68 @@ class EmojiPicker extends React.Component { this.updateVisibility(); }; + private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): 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): 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 => { const body = this.scrollRef.current?.containerRef.current; if (!body) return; @@ -239,11 +312,11 @@ class EmojiPicker extends React.Component { }; private onEnterFilter = (): void => { - const btn = - this.scrollRef.current?.containerRef.current?.querySelector(".mx_EmojiPicker_item"); - if (btn) { - btn.click(); - } + const btn = this.scrollRef.current?.containerRef.current?.querySelector( + '.mx_EmojiPicker_item_wrapper[tabindex="0"]', + ); + btn?.click(); + this.props.onFinished(); }; private onHoverEmoji = (emoji: IEmoji): void => { @@ -258,10 +331,13 @@ class EmojiPicker extends React.Component { }); }; - private onClickEmoji = (emoji: IEmoji): void => { + private onClickEmoji = (ev: ButtonEvent, emoji: IEmoji): void => { if (this.props.onChoose(emoji.unicode) !== false) { recent.add(emoji.unicode); } + if ((ev as React.KeyboardEvent).key === Key.ENTER) { + this.props.onFinished(); + } }; private static categoryHeightForEmojiCount(count: number): number { @@ -272,41 +348,60 @@ class EmojiPicker extends React.Component { } public render(): React.ReactNode { - let heightBefore = 0; return ( -
-
- - - {this.categories.map((category) => { - const emojis = this.memoizedDataByCategory[category.id]; - const categoryElement = ( - + {({ onKeyDownHandler }) => { + let heightBefore = 0; + return ( +
+
+ - ); - const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); - heightBefore += height; - return categoryElement; - })} - - {this.state.previewEmoji ? ( - - ) : ( - - )} -
+ + {this.categories.map((category) => { + const emojis = this.memoizedDataByCategory[category.id]; + const categoryElement = ( + + ); + const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); + heightBefore += height; + return categoryElement; + })} + + {this.state.previewEmoji ? ( + + ) : ( + + )} +
+ ); + }} + ); } } diff --git a/src/components/views/emojipicker/Header.tsx b/src/components/views/emojipicker/Header.tsx index 9a7005d6324..c3643f6e2a9 100644 --- a/src/components/views/emojipicker/Header.tsx +++ b/src/components/views/emojipicker/Header.tsx @@ -17,6 +17,7 @@ limitations under the License. import React from "react"; import classNames from "classnames"; +import { findLastIndex } from "lodash"; import { _t } from "../../../languageHandler"; import { CategoryKey, ICategory } from "./Category"; @@ -40,7 +41,14 @@ class Header extends React.PureComponent { } 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); } diff --git a/src/components/views/emojipicker/QuickReactions.tsx b/src/components/views/emojipicker/QuickReactions.tsx index 6b149069481..a58c6b875fd 100644 --- a/src/components/views/emojipicker/QuickReactions.tsx +++ b/src/components/views/emojipicker/QuickReactions.tsx @@ -20,6 +20,8 @@ import React from "react"; import { _t } from "../../../languageHandler"; import { getEmojiFromUnicode, IEmoji } 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 const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map((emoji) => { @@ -32,7 +34,7 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀 interface IProps { selectedEmojis?: Set; - onClick(emoji: IEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji): void; } interface IState { @@ -70,7 +72,7 @@ class QuickReactions extends React.Component { )} -
    + {QUICK_REACTIONS.map((emoji) => ( { selectedEmojis={this.props.selectedEmojis} /> ))} -
+ ); } diff --git a/src/components/views/emojipicker/ReactionPicker.tsx b/src/components/views/emojipicker/ReactionPicker.tsx index 6b13c768231..97222740f88 100644 --- a/src/components/views/emojipicker/ReactionPicker.tsx +++ b/src/components/views/emojipicker/ReactionPicker.tsx @@ -135,6 +135,7 @@ class ReactionPicker extends React.Component { ); diff --git a/src/components/views/emojipicker/Search.tsx b/src/components/views/emojipicker/Search.tsx index edd6b2c4fca..a34a14cbafd 100644 --- a/src/components/views/emojipicker/Search.tsx +++ b/src/components/views/emojipicker/Search.tsx @@ -20,14 +20,19 @@ import React from "react"; import { _t } from "../../../languageHandler"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { RovingTabIndexContext } from "../../../accessibility/RovingTabIndex"; interface IProps { query: string; onChange(value: string): void; onEnter(): void; + onKeyDown(event: React.KeyboardEvent): void; } class Search extends React.PureComponent { + public static contextType = RovingTabIndexContext; + public context!: React.ContextType; + private inputRef = React.createRef(); public componentDidMount(): void { @@ -43,11 +48,14 @@ class Search extends React.PureComponent { ev.stopPropagation(); ev.preventDefault(); break; + + default: + this.props.onKeyDown(ev); } }; public render(): React.ReactNode { - let rightButton; + let rightButton: JSX.Element; if (this.props.query) { rightButton = (
diff --git a/src/components/views/rooms/EmojiButton.tsx b/src/components/views/rooms/EmojiButton.tsx index db7accb62c5..b35aa2aef55 100644 --- a/src/components/views/rooms/EmojiButton.tsx +++ b/src/components/views/rooms/EmojiButton.tsx @@ -36,17 +36,14 @@ export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonP let contextMenu: React.ReactElement | null = null; if (menuDisplayed && button.current) { const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect()); + const onFinished = (): void => { + closeMenu(); + overflowMenuCloser?.(); + }; contextMenu = ( - { - closeMenu(); - overflowMenuCloser?.(); - }} - managed={false} - > - + + ); } diff --git a/test/components/views/emojipicker/EmojiPicker-test.tsx b/test/components/views/emojipicker/EmojiPicker-test.tsx index efd09825a9c..4f8c091bb7c 100644 --- a/test/components/views/emojipicker/EmojiPicker-test.tsx +++ b/test/components/views/emojipicker/EmojiPicker-test.tsx @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and 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 { stubClient } from "../../../test-utils"; @@ -21,7 +25,7 @@ describe("EmojiPicker", function () { stubClient(); 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 ep.onChangeFilter("heart"); @@ -31,4 +35,47 @@ describe("EmojiPicker", function () { //@ts-ignore private access 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(); + + 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(); + }); });