From 106b9325007559da857cab2ee32a2ca26bc94e1c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 18 Apr 2023 12:04:40 +0100 Subject: [PATCH 01/10] Add arrow key controls to emoji and reaction pickers --- src/accessibility/RovingTabIndex.tsx | 6 +- src/components/views/emojipicker/Category.tsx | 3 +- src/components/views/emojipicker/Emoji.tsx | 7 +- .../views/emojipicker/EmojiPicker.tsx | 137 +++++++++++++----- .../views/emojipicker/QuickReactions.tsx | 3 +- .../views/emojipicker/ReactionPicker.tsx | 1 + src/components/views/rooms/EmojiButton.tsx | 15 +- 7 files changed, 118 insertions(+), 54 deletions(-) diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b449b10710f..e3f3cd1f942 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -159,6 +159,7 @@ interface IProps { handleHomeEnd?: boolean; handleUpDown?: boolean; handleLeftRight?: boolean; + handleInputKeys?: boolean; children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode; onKeyDown?(ev: React.KeyboardEvent, state: IState): void; } @@ -188,6 +189,7 @@ export const RovingTabIndexProvider: React.FC = ({ handleHomeEnd, handleUpDown, handleLeftRight, + handleInputKeys, onKeyDown, }) => { const [state, dispatch] = useReducer>(reducer, { @@ -210,7 +212,7 @@ export const RovingTabIndexProvider: React.FC = ({ let focusRef: RefObject | undefined; // Don't interfere with input default keydown behaviour // but allow people to move focus from it with Tab. - if (checkInputableElement(ev.target as HTMLElement)) { + if (!handleInputKeys && checkInputableElement(ev.target as HTMLElement)) { switch (action) { case KeyBindingAction.Tab: handled = true; @@ -289,7 +291,7 @@ export const RovingTabIndexProvider: React.FC = ({ }); } }, - [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight], + [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight, handleInputKeys], ); return ( diff --git a/src/components/views/emojipicker/Category.tsx b/src/components/views/emojipicker/Category.tsx index f4ffce911b5..a64036b52a5 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,7 +43,7 @@ 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; diff --git a/src/components/views/emojipicker/Emoji.tsx b/src/components/views/emojipicker/Emoji.tsx index 022c29a94a6..7e36814b689 100644 --- a/src/components/views/emojipicker/Emoji.tsx +++ b/src/components/views/emojipicker/Emoji.tsx @@ -19,11 +19,12 @@ import React from "react"; import { MenuItem } from "../../structures/ContextMenu"; import { IEmoji } from "../../../emoji"; +import { ButtonEvent } from "../elements/AccessibleButton"; 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; @@ -32,11 +33,11 @@ interface IProps { 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) => onClick(ev, emoji)} onMouseEnter={() => onMouseEnter(emoji)} onMouseLeave={() => onMouseLeave(emoji)} className="mx_EmojiPicker_item_wrapper" diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index b4a868f474d..30be57b5971 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -25,8 +25,12 @@ 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 { IState as RovingState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; +import { Key } from "../../../Keyboard"; +import { clamp } from "../../../utils/numbers"; +import { ButtonEvent } from "../elements/AccessibleButton"; export const CATEGORY_HEADER_HEIGHT = 20; export const EMOJI_HEIGHT = 35; @@ -37,6 +41,7 @@ const ZERO_WIDTH_JOINER = "\u200D"; interface IProps { selectedEmojis?: Set; onChoose(unicode: string): boolean; + onFinished(): void; isEmojiDisabled?: (unicode: string) => boolean; } @@ -150,6 +155,42 @@ class EmojiPicker extends React.Component { this.updateVisibility(); }; + private onKeyDown = (ev: React.KeyboardEvent, state: RovingState): void => { + if (!state.activeRef?.current) return; + + switch (ev.key) { + case Key.ARROW_UP: + case Key.ARROW_DOWN: { + const node = state.activeRef.current; + const parent = node.parentElement; + const rowIndex = Array.from(parent.children).indexOf(node); + const refIndex = state.refs.indexOf(state.activeRef); + + let newParent: HTMLElement | null | undefined; + if (ev.key === Key.ARROW_UP) { + newParent = state.refs[refIndex - rowIndex - 1]?.current.parentElement; + } else { + newParent = state.refs[refIndex - rowIndex + EMOJIS_PER_ROW]?.current.parentElement; + } + + const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)] as + | HTMLElement + | undefined; + + newTarget?.focus(); + newTarget?.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "center", + }); + + ev.stopPropagation(); + ev.preventDefault(); + break; + } + } + }; + private updateVisibility = (): void => { const body = this.scrollRef.current?.containerRef.current; if (!body) return; @@ -241,9 +282,7 @@ class EmojiPicker extends React.Component { private onEnterFilter = (): void => { const btn = this.scrollRef.current?.containerRef.current?.querySelector(".mx_EmojiPicker_item"); - if (btn) { - btn.click(); - } + btn?.click(); }; private onHoverEmoji = (emoji: IEmoji): void => { @@ -258,10 +297,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 +314,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/QuickReactions.tsx b/src/components/views/emojipicker/QuickReactions.tsx index 6b149069481..a5718a51f3c 100644 --- a/src/components/views/emojipicker/QuickReactions.tsx +++ b/src/components/views/emojipicker/QuickReactions.tsx @@ -20,6 +20,7 @@ import React from "react"; import { _t } from "../../../languageHandler"; import { getEmojiFromUnicode, IEmoji } from "../../../emoji"; import Emoji from "./Emoji"; +import { ButtonEvent } from "../elements/AccessibleButton"; // We use the variation-selector Heart in Quick Reactions for some reason const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map((emoji) => { @@ -32,7 +33,7 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀 interface IProps { selectedEmojis?: Set; - onClick(emoji: IEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji): void; } interface IState { 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/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} - > - + + ); } From 2364dee15371516df1a7dce94e45afe57d4924a0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 18 Apr 2023 12:11:16 +0100 Subject: [PATCH 02/10] Iterate types --- src/components/views/emojipicker/EmojiPicker.tsx | 5 +++-- test/components/views/emojipicker/EmojiPicker-test.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index 30be57b5971..31f79e87e69 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -163,14 +163,15 @@ class EmojiPicker extends React.Component { case Key.ARROW_DOWN: { 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 newParent: HTMLElement | null | undefined; if (ev.key === Key.ARROW_UP) { - newParent = state.refs[refIndex - rowIndex - 1]?.current.parentElement; + newParent = state.refs[refIndex - rowIndex - 1]?.current?.parentElement; } else { - newParent = state.refs[refIndex - rowIndex + EMOJIS_PER_ROW]?.current.parentElement; + newParent = state.refs[refIndex - rowIndex + EMOJIS_PER_ROW]?.current?.parentElement; } const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)] as diff --git a/test/components/views/emojipicker/EmojiPicker-test.tsx b/test/components/views/emojipicker/EmojiPicker-test.tsx index efd09825a9c..100c8f86eb3 100644 --- a/test/components/views/emojipicker/EmojiPicker-test.tsx +++ b/test/components/views/emojipicker/EmojiPicker-test.tsx @@ -21,7 +21,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"); From af23436072fc752ba2b754282a89ae8026267dc5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 19 Apr 2023 10:31:43 +0100 Subject: [PATCH 03/10] Switch to using aria-activedescendant --- res/css/views/emojipicker/_EmojiPicker.pcss | 8 +++++ src/accessibility/RovingTabIndex.tsx | 14 +++++--- .../roving/RovingAccessibleButton.tsx | 13 ++++++- src/components/structures/ContextMenu.tsx | 2 +- .../views/elements/LazyRenderList.tsx | 2 ++ src/components/views/emojipicker/Category.tsx | 19 ++++++++-- src/components/views/emojipicker/Emoji.tsx | 13 ++++--- .../views/emojipicker/EmojiPicker.tsx | 35 ++++++++++++------- .../views/emojipicker/QuickReactions.tsx | 5 +-- src/components/views/emojipicker/Search.tsx | 14 +++++++- 10 files changed, 96 insertions(+), 29 deletions(-) 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 e3f3cd1f942..576e8cfc4bf 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,8 +160,11 @@ interface IProps { handleUpDown?: boolean; handleLeftRight?: boolean; handleInputKeys?: boolean; + // Whether to only dispatch SetFocus on keyboard handling + // useful for aria-activedescendant widgets + onlySetFocus?: 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 = ( @@ -190,6 +193,7 @@ export const RovingTabIndexProvider: React.FC = ({ handleUpDown, handleLeftRight, handleInputKeys, + onlySetFocus, onKeyDown, }) => { const [state, dispatch] = useReducer>(reducer, { @@ -201,7 +205,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; } @@ -281,7 +285,7 @@ export const RovingTabIndexProvider: React.FC = ({ } if (focusRef) { - focusRef.current?.focus(); + if (!onlySetFocus) focusRef.current?.focus(); // programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves dispatch({ type: Type.SetFocus, 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 a64036b52a5..cf662feea39 100644 --- a/src/components/views/emojipicker/Category.tsx +++ b/src/components/views/emojipicker/Category.tsx @@ -49,12 +49,25 @@ interface IProps { 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" /> ))}
@@ -102,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 7e36814b689..62798873034 100644 --- a/src/components/views/emojipicker/Emoji.tsx +++ b/src/components/views/emojipicker/Emoji.tsx @@ -17,9 +17,9 @@ 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; @@ -28,6 +28,8 @@ interface IProps { onMouseEnter(emoji: IEmoji): void; onMouseLeave(emoji: IEmoji): void; disabled?: boolean; + id?: string; + role?: string; } class Emoji extends React.PureComponent { @@ -35,19 +37,20 @@ class Emoji extends React.PureComponent { const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props; const isSelected = selectedEmojis?.has(emoji.unicode); return ( - 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 31f79e87e69..6693e58ea40 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"; @@ -27,7 +27,12 @@ import Preview from "./Preview"; import QuickReactions from "./QuickReactions"; import Category, { CategoryKey, ICategory } from "./Category"; import { filterBoolean } from "../../../utils/arrays"; -import { IState as RovingState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; +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"; @@ -155,7 +160,7 @@ class EmojiPicker extends React.Component { this.updateVisibility(); }; - private onKeyDown = (ev: React.KeyboardEvent, state: RovingState): void => { + private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void => { if (!state.activeRef?.current) return; switch (ev.key) { @@ -178,12 +183,18 @@ class EmojiPicker extends React.Component { | HTMLElement | undefined; - newTarget?.focus(); - newTarget?.scrollIntoView({ - behavior: "auto", - block: "center", - inline: "center", - }); + if (newTarget) { + const ref = state.refs.find((r) => r.current === newTarget); + dispatch({ + type: Type.SetFocus, + payload: { ref }, + }); + newTarget.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "center", + }); + } ev.stopPropagation(); ev.preventDefault(); @@ -316,7 +327,7 @@ class EmojiPicker extends React.Component { public render(): React.ReactNode { return ( - + {({ onKeyDownHandler }) => { let heightBefore = 0; return ( @@ -326,8 +337,10 @@ class EmojiPicker extends React.Component { query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} + onKeyDown={onKeyDownHandler} /> { ) : ( diff --git a/src/components/views/emojipicker/QuickReactions.tsx b/src/components/views/emojipicker/QuickReactions.tsx index a5718a51f3c..a58c6b875fd 100644 --- a/src/components/views/emojipicker/QuickReactions.tsx +++ b/src/components/views/emojipicker/QuickReactions.tsx @@ -21,6 +21,7 @@ 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) => { @@ -71,7 +72,7 @@ class QuickReactions extends React.Component { )} -
    + {QUICK_REACTIONS.map((emoji) => ( { selectedEmojis={this.props.selectedEmojis} /> ))} -
+ ); } 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 = (
From 47dfa56e7d7f560a5828c0797f4c742fb83c92c8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 19 Apr 2023 11:00:53 +0100 Subject: [PATCH 04/10] Add tests --- .../views/emojipicker/EmojiPicker.tsx | 6 ++- .../views/emojipicker/EmojiPicker-test.tsx | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index 6693e58ea40..58681ccd9ff 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -292,9 +292,11 @@ class EmojiPicker extends React.Component { }; private onEnterFilter = (): void => { - const btn = - this.scrollRef.current?.containerRef.current?.querySelector(".mx_EmojiPicker_item"); + const btn = this.scrollRef.current?.containerRef.current?.querySelector( + '.mx_EmojiPicker_item_wrapper[tabindex="0"]', + ); btn?.click(); + this.props.onFinished(); }; private onHoverEmoji = (emoji: IEmoji): void => { diff --git a/test/components/views/emojipicker/EmojiPicker-test.tsx b/test/components/views/emojipicker/EmojiPicker-test.tsx index 100c8f86eb3..c5f4a83bc86 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"; @@ -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.type(input, "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(); + }); }); From 92420baa18fa39232a59eec596e1c04a4c4ca012 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 19 Apr 2023 11:03:45 +0100 Subject: [PATCH 05/10] Fix tests --- src/accessibility/RovingTabIndex.tsx | 2 +- .../views/emojipicker/EmojiPicker.tsx | 20 ++++++----- test/PosthogAnalytics-test.ts | 35 +++++++++++-------- test/utils/MegolmExportEncryption-test.ts | 15 ++++---- 4 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 576e8cfc4bf..7876f34c919 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -295,7 +295,7 @@ export const RovingTabIndexProvider: React.FC = ({ }); } }, - [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight, handleInputKeys], + [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight, handleInputKeys, onlySetFocus], ); return ( diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index 58681ccd9ff..e610e6d1b43 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -185,15 +185,17 @@ class EmojiPicker extends React.Component { if (newTarget) { const ref = state.refs.find((r) => r.current === newTarget); - dispatch({ - type: Type.SetFocus, - payload: { ref }, - }); - newTarget.scrollIntoView({ - behavior: "auto", - block: "center", - inline: "center", - }); + if (ref) { + dispatch({ + type: Type.SetFocus, + payload: { ref }, + }); + newTarget.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "center", + }); + } } ev.stopPropagation(); diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index e0b47e028ed..5bdb8ee6f59 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -52,24 +52,29 @@ describe("PosthogAnalytics", () => { beforeEach(() => { fakePosthog = getFakePosthog(); - window.crypto = { - subtle: { - digest: async (_: AlgorithmIdentifier, encodedMessage: BufferSource) => { - const message = new TextDecoder().decode(encodedMessage); - const hexHash = shaHashes[message]; - const bytes: number[] = []; - for (let c = 0; c < hexHash.length; c += 2) { - bytes.push(parseInt(hexHash.slice(c, c + 2), 16)); - } - return bytes as unknown as ArrayBuffer; - }, - } as unknown as SubtleCrypto, - } as unknown as Crypto; + Object.defineProperty(window, "crypto", { + value: { + subtle: { + digest: async (_: AlgorithmIdentifier, encodedMessage: BufferSource) => { + const message = new TextDecoder().decode(encodedMessage); + const hexHash = shaHashes[message]; + const bytes: number[] = []; + for (let c = 0; c < hexHash.length; c += 2) { + bytes.push(parseInt(hexHash.slice(c, c + 2), 16)); + } + return bytes as unknown as ArrayBuffer; + }, + } as unknown as SubtleCrypto, + }, + writable: true, + }); }); afterEach(() => { - // @ts-ignore - window.crypto = null; + Object.defineProperty(window, "crypto", { + value: undefined, + writable: true, + }); SdkConfig.unset(); // we touch the config, so clean up }); diff --git a/test/utils/MegolmExportEncryption-test.ts b/test/utils/MegolmExportEncryption-test.ts index 69d803073f2..8444d40de50 100644 --- a/test/utils/MegolmExportEncryption-test.ts +++ b/test/utils/MegolmExportEncryption-test.ts @@ -75,13 +75,14 @@ describe("MegolmExportEncryption", function () { let MegolmExportEncryption: typeof MegolmExportEncryptionExport; beforeEach(() => { - window.crypto = { - getRandomValues, - randomUUID: jest.fn().mockReturnValue("not-random-uuid"), - subtle: webCrypto.subtle, - }; - // @ts-ignore for some reason including it in the object above gets ignored - window.crypto.subtle = webCrypto.subtle; + Object.defineProperty(window, "crypto", { + value: { + getRandomValues, + randomUUID: jest.fn().mockReturnValue("not-random-uuid"), + subtle: webCrypto.subtle, + }, + writable: true, + }); MegolmExportEncryption = require("../../src/utils/MegolmExportEncryption"); }); From 6a99974488178d318e5fa5989aabbd1681b2b619 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 19 Apr 2023 11:09:08 +0100 Subject: [PATCH 06/10] Iterate --- test/components/views/emojipicker/EmojiPicker-test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/components/views/emojipicker/EmojiPicker-test.tsx b/test/components/views/emojipicker/EmojiPicker-test.tsx index c5f4a83bc86..4f8c091bb7c 100644 --- a/test/components/views/emojipicker/EmojiPicker-test.tsx +++ b/test/components/views/emojipicker/EmojiPicker-test.tsx @@ -48,12 +48,12 @@ describe("EmojiPicker", function () { const onFinished = jest.fn(); const { container } = render(); - const input = container.querySelector("input"); + const input = container.querySelector("input")!; expect(input).toHaveFocus(); function getEmoji(): string { const activeDescendant = input.getAttribute("aria-activedescendant"); - return container.querySelector("#" + activeDescendant).textContent; + return container.querySelector("#" + activeDescendant)!.textContent!; } expect(getEmoji()).toEqual("😀"); @@ -61,7 +61,7 @@ describe("EmojiPicker", function () { expect(getEmoji()).toEqual("🙂"); await userEvent.keyboard("[ArrowUp]"); expect(getEmoji()).toEqual("😀"); - await userEvent.type(input, "Flag"); + await userEvent.keyboard("Flag"); await userEvent.keyboard("[ArrowRight]"); await userEvent.keyboard("[ArrowRight]"); expect(getEmoji()).toEqual("📫️"); From 1e30b6d82d59d45d1bcd8dd333e0d7c1d106ffb8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 19 Apr 2023 11:42:09 +0100 Subject: [PATCH 07/10] Update test --- cypress/e2e/threads/threads.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index ee1fd78d082..552eefc4c6c 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -172,7 +172,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(() => { From 5945a24ccff74786d7ca207d94ac2998693ab2da Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 19 Apr 2023 12:36:57 +0100 Subject: [PATCH 08/10] Tweak header keyboard navigation behaviour --- src/components/views/emojipicker/Header.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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); } From 9e018e59a8976b7be0166d9acd153b0570e928e6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 20 Apr 2023 13:54:58 +0100 Subject: [PATCH 09/10] Also handle scrolling on left/right arrow keys --- src/accessibility/RovingTabIndex.tsx | 12 +-- .../views/emojipicker/EmojiPicker.tsx | 95 +++++++++++-------- 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 7876f34c919..7b8cb7ede58 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -159,10 +159,6 @@ interface IProps { handleHomeEnd?: boolean; handleUpDown?: boolean; handleLeftRight?: boolean; - handleInputKeys?: boolean; - // Whether to only dispatch SetFocus on keyboard handling - // useful for aria-activedescendant widgets - onlySetFocus?: boolean; children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode; onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch): void; } @@ -192,8 +188,6 @@ export const RovingTabIndexProvider: React.FC = ({ handleHomeEnd, handleUpDown, handleLeftRight, - handleInputKeys, - onlySetFocus, onKeyDown, }) => { const [state, dispatch] = useReducer>(reducer, { @@ -216,7 +210,7 @@ export const RovingTabIndexProvider: React.FC = ({ let focusRef: RefObject | undefined; // Don't interfere with input default keydown behaviour // but allow people to move focus from it with Tab. - if (!handleInputKeys && checkInputableElement(ev.target as HTMLElement)) { + if (checkInputableElement(ev.target as HTMLElement)) { switch (action) { case KeyBindingAction.Tab: handled = true; @@ -285,7 +279,7 @@ export const RovingTabIndexProvider: React.FC = ({ } if (focusRef) { - if (!onlySetFocus) focusRef.current?.focus(); + focusRef.current?.focus(); // programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves dispatch({ type: Type.SetFocus, @@ -295,7 +289,7 @@ export const RovingTabIndexProvider: React.FC = ({ }); } }, - [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight, handleInputKeys, onlySetFocus], + [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight], ); return ( diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index e610e6d1b43..a19c0ed1857 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -36,6 +36,7 @@ import { 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; @@ -160,49 +161,69 @@ class EmojiPicker extends React.Component { this.updateVisibility(); }; - private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void => { - if (!state.activeRef?.current) return; + private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void { + let ref: Ref | undefined; + + 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 newParent: HTMLElement | undefined; + let newTarget: Element | undefined; switch (ev.key) { - case Key.ARROW_UP: - case Key.ARROW_DOWN: { - 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 newParent: HTMLElement | null | undefined; - if (ev.key === Key.ARROW_UP) { - newParent = state.refs[refIndex - rowIndex - 1]?.current?.parentElement; - } else { - newParent = state.refs[refIndex - rowIndex + EMOJIS_PER_ROW]?.current?.parentElement; - } + case Key.ARROW_LEFT: + newTarget = state.refs[refIndex - 1]?.current; + newParent = newTarget?.parentElement; + break; - const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)] as - | HTMLElement - | undefined; - - if (newTarget) { - const ref = state.refs.find((r) => r.current === newTarget); - if (ref) { - dispatch({ - type: Type.SetFocus, - payload: { ref }, - }); - newTarget.scrollIntoView({ - behavior: "auto", - block: "center", - inline: "center", - }); - } - } + case Key.ARROW_RIGHT: + newTarget = state.refs[refIndex + 1]?.current; + newParent = newTarget?.parentElement; + break; - ev.stopPropagation(); - ev.preventDefault(); + case Key.ARROW_UP: + newParent = state.refs[refIndex - rowIndex - 1]?.current?.parentElement; + newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)]; break; + + case Key.ARROW_DOWN: + newParent = state.refs[refIndex - rowIndex + EMOJIS_PER_ROW]?.current?.parentElement; + newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)]; + break; + } + + if (newTarget) { + ref = state.refs.find((r) => r.current === newTarget); + } + + if (ref) { + dispatch({ + type: Type.SetFocus, + payload: { ref }, + }); + + if (parent !== newParent) { + ref.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 => { @@ -331,7 +352,7 @@ class EmojiPicker extends React.Component { public render(): React.ReactNode { return ( - + {({ onKeyDownHandler }) => { let heightBefore = 0; return ( From 5b659345a261ce7ae307db7edb205fd3a8402dc9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 20 Apr 2023 14:03:02 +0100 Subject: [PATCH 10/10] Iterate --- .../views/emojipicker/EmojiPicker.tsx | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index a19c0ed1857..7a62c4dd079 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -162,50 +162,47 @@ class EmojiPicker extends React.Component { }; private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void { - let ref: Ref | undefined; - 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; - let newTarget: Element | undefined; switch (ev.key) { case Key.ARROW_LEFT: - newTarget = state.refs[refIndex - 1]?.current; - newParent = newTarget?.parentElement; + focusRef = state.refs[refIndex - 1]; + newParent = focusRef?.current?.parentElement; break; case Key.ARROW_RIGHT: - newTarget = state.refs[refIndex + 1]?.current; - newParent = newTarget?.parentElement; + focusRef = state.refs[refIndex + 1]; + newParent = focusRef?.current?.parentElement; break; case Key.ARROW_UP: - newParent = state.refs[refIndex - rowIndex - 1]?.current?.parentElement; - newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)]; - break; - - case Key.ARROW_DOWN: - newParent = state.refs[refIndex - rowIndex + EMOJIS_PER_ROW]?.current?.parentElement; - newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)]; + 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 (newTarget) { - ref = state.refs.find((r) => r.current === newTarget); - } - - if (ref) { + if (focusRef) { dispatch({ type: Type.SetFocus, - payload: { ref }, + payload: { ref: focusRef }, }); if (parent !== newParent) { - ref.current?.scrollIntoView({ + focusRef.current?.scrollIntoView({ behavior: "auto", block: "center", inline: "center",