diff --git a/apps/web/src/accessibility/RovingTabIndex.tsx b/apps/web/src/accessibility/RovingTabIndex.tsx index 947024661e4..040b6d27be8 100644 --- a/apps/web/src/accessibility/RovingTabIndex.tsx +++ b/apps/web/src/accessibility/RovingTabIndex.tsx @@ -6,23 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { - createContext, - useCallback, - useContext, - useMemo, - useRef, - useReducer, - type Reducer, - type Dispatch, - type RefObject, - type ReactNode, - type RefCallback, -} from "react"; +import React from "react"; +import { + RovingAction, + RovingTabIndexProvider as SharedRovingTabIndexProvider, + type RovingTabIndexProviderProps, +} from "@element-hq/web-shared-components"; import { getKeyBindingsManager } from "../KeyBindingsManager"; import { KeyBindingAction } from "./KeyboardShortcuts"; -import { type FocusHandler } from "./roving/types"; + +export { findNextSiblingElement, RovingTabIndexContext } from "@element-hq/web-shared-components"; +export { checkInputableElement } from "@element-hq/web-shared-components"; +export { RovingStateActionType } from "@element-hq/web-shared-components"; +export { useRovingTabIndex } from "@element-hq/web-shared-components"; +export type { IAction, IState } from "@element-hq/web-shared-components"; /** * Module to simplify implementing the Roving TabIndex accessibility technique @@ -37,370 +35,31 @@ import { type FocusHandler } from "./roving/types"; * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex */ -// Check for form elements which utilize the arrow keys for native functions -// like many of the text input varieties. -// -// i.e. it's ok to press the down arrow on a radio button to move to the next -// radio. But it's not ok to press the down arrow on a to -// move away because the down arrow should move the cursor to the end of the -// input. -export function checkInputableElement(el: HTMLElement): boolean { - return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]'); -} - -export interface IState { - activeNode?: HTMLElement; - nodes: HTMLElement[]; -} - -export interface IContext { - state: IState; - dispatch: Dispatch; -} - -export const RovingTabIndexContext = createContext({ - state: { - nodes: [], // list of nodes in DOM order - }, - dispatch: () => {}, -}); -RovingTabIndexContext.displayName = "RovingTabIndexContext"; - -export enum Type { - Register = "REGISTER", - Unregister = "UNREGISTER", - SetFocus = "SET_FOCUS", - Update = "UPDATE", -} - -export interface IAction { - type: Exclude; - payload: { - node: HTMLElement; - }; -} - -interface UpdateAction { - type: Type.Update; - payload?: undefined; -} - -type Action = IAction | UpdateAction; - -const nodeSorter = (a: HTMLElement, b: HTMLElement): number => { - if (a === b) { - return 0; - } - - const position = a.compareDocumentPosition(b); - - if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) { - return -1; - } else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) { - return 1; - } else { - return 0; - } -}; - -export const reducer: Reducer = (state: IState, action: Action) => { - switch (action.type) { - case Type.Register: { - if (!state.activeNode) { - // Our list of nodes was empty, set activeNode to this first item - state.activeNode = action.payload.node; - } - - if (state.nodes.includes(action.payload.node)) return state; - - // Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert - state.nodes.push(action.payload.node); - state.nodes.sort(nodeSorter); - - return { ...state }; - } - - case Type.Unregister: { - const oldIndex = state.nodes.findIndex((r) => r === action.payload.node); - - if (oldIndex === -1) { - return state; // already removed, this should not happen - } - - if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) { - // we just removed the active node, need to replace it - // pick the node closest to the index the old node was in - if (oldIndex >= state.nodes.length) { - state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true); - } else { - state.activeNode = - findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true); - } - if (document.activeElement === document.body) { - // if the focus got reverted to the body then the user was likely focused on the unmounted element - setTimeout(() => state.activeNode?.focus(), 0); - } - } - - // update the nodes list - return { ...state }; - } - - case Type.SetFocus: { - // if the node doesn't change just return the same object reference to skip a re-render - if (state.activeNode === action.payload.node) return state; - // update active node - state.activeNode = action.payload.node; - return { ...state }; - } - - case Type.Update: { - state.nodes.sort(nodeSorter); - return { ...state }; - } - +const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => { + switch (getKeyBindingsManager().getAccessibilityAction(ev)) { + case KeyBindingAction.Home: + return RovingAction.Home; + case KeyBindingAction.End: + return RovingAction.End; + case KeyBindingAction.ArrowLeft: + return RovingAction.ArrowLeft; + case KeyBindingAction.ArrowUp: + return RovingAction.ArrowUp; + case KeyBindingAction.ArrowRight: + return RovingAction.ArrowRight; + case KeyBindingAction.ArrowDown: + return RovingAction.ArrowDown; + case KeyBindingAction.Tab: + return RovingAction.Tab; default: - return state; + return undefined; } }; -interface IProps { - handleLoop?: boolean; - handleHomeEnd?: boolean; - handleUpDown?: boolean; - handleLeftRight?: boolean; - handleInputFields?: boolean; - scrollIntoView?: boolean | ScrollIntoViewOptions; - children( - this: void, - renderProps: { - onKeyDownHandler(this: void, ev: React.KeyboardEvent): void; - onDragEndHandler(this: void): void; - }, - ): ReactNode; - onKeyDown?(this: void, ev: React.KeyboardEvent, state: IState, dispatch: Dispatch): void; -} - -export const findSiblingElement = ( - nodes: HTMLElement[], - startIndex: number, - backwards = false, - loop = false, -): HTMLElement | undefined => { - if (backwards) { - for (let i = startIndex; i < nodes.length && i >= 0; i--) { - if (nodes[i]?.offsetParent !== null) { - return nodes[i]; - } - } - if (loop) { - return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false); - } - } else { - for (let i = startIndex; i < nodes.length && i >= 0; i++) { - if (nodes[i]?.offsetParent !== null) { - return nodes[i]; - } - } - if (loop) { - return findSiblingElement(nodes.slice(0, startIndex), 0, false, false); - } - } -}; - -export const RovingTabIndexProvider: React.FC = ({ - children, - handleHomeEnd, - handleUpDown, - handleLeftRight, - handleLoop, - handleInputFields, - scrollIntoView, - onKeyDown, -}) => { - const [state, dispatch] = useReducer(reducer, { - nodes: [], - }); - - const context = useMemo(() => ({ state, dispatch }), [state]); - - const onKeyDownHandler = useCallback( - (ev: React.KeyboardEvent) => { - if (onKeyDown) { - onKeyDown(ev, context.state, context.dispatch); - if (ev.defaultPrevented) { - return; - } - } - - let handled = false; - const action = getKeyBindingsManager().getAccessibilityAction(ev); - let focusNode: HTMLElement | undefined; - // Don't interfere with input default keydown behaviour - // but allow people to move focus from it with Tab. - if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) { - switch (action) { - case KeyBindingAction.Tab: - handled = true; - if (context.state.nodes.length > 0) { - const idx = context.state.nodes.indexOf(context.state.activeNode!); - focusNode = findSiblingElement( - context.state.nodes, - idx + (ev.shiftKey ? -1 : 1), - ev.shiftKey, - ); - } - break; - } - } else { - // check if we actually have any items - switch (action) { - case KeyBindingAction.Home: - if (handleHomeEnd) { - handled = true; - // move focus to first (visible) item - focusNode = findSiblingElement(context.state.nodes, 0); - } - break; - - case KeyBindingAction.End: - if (handleHomeEnd) { - handled = true; - // move focus to last (visible) item - focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true); - } - break; - - case KeyBindingAction.ArrowDown: - case KeyBindingAction.ArrowRight: - if ( - (action === KeyBindingAction.ArrowDown && handleUpDown) || - (action === KeyBindingAction.ArrowRight && handleLeftRight) - ) { - handled = true; - if (context.state.nodes.length > 0) { - const idx = context.state.nodes.indexOf(context.state.activeNode!); - focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop); - } - } - break; - - case KeyBindingAction.ArrowUp: - case KeyBindingAction.ArrowLeft: - if ( - (action === KeyBindingAction.ArrowUp && handleUpDown) || - (action === KeyBindingAction.ArrowLeft && handleLeftRight) - ) { - handled = true; - if (context.state.nodes.length > 0) { - const idx = context.state.nodes.indexOf(context.state.activeNode!); - focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop); - } - } - break; - } - } - - if (handled) { - ev.preventDefault(); - ev.stopPropagation(); - } - - if (focusNode) { - focusNode?.focus(); - // programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves - dispatch({ - type: Type.SetFocus, - payload: { - node: focusNode, - }, - }); - if (scrollIntoView) { - focusNode?.scrollIntoView(scrollIntoView); - } - } - }, - [ - context, - onKeyDown, - handleHomeEnd, - handleUpDown, - handleLeftRight, - handleLoop, - handleInputFields, - scrollIntoView, - ], - ); - - const onDragEndHandler = useCallback(() => { - dispatch({ - type: Type.Update, - }); - }, []); - - return ( - - {children({ onKeyDownHandler, onDragEndHandler })} - - ); -}; - -/** - * Hook to register a roving tab index. - * - * inputRef is an optional argument; when passed this ref points to the DOM element - * to which the callback ref is attached. - * - * Returns: - * onFocus should be called when the index gained focus in any manner. - * isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`. - * ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition. - * nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached. - * - * nodeRef = inputRef when inputRef argument is provided. - */ -export const useRovingTabIndex = ( - inputRef?: RefObject, -): [FocusHandler, boolean, RefCallback, RefObject] => { - const context = useContext(RovingTabIndexContext); - - let nodeRef = useRef(null); - - if (inputRef) { - // if we are given a ref, use it instead of ours - nodeRef = inputRef; - } - - const ref = useCallback((node: T | null) => { - if (node) { - nodeRef.current = node; - context.dispatch({ - type: Type.Register, - payload: { node }, - }); - } else { - context.dispatch({ - type: Type.Unregister, - payload: { node: nodeRef.current! }, - }); - nodeRef.current = null; - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const onFocus = useCallback(() => { - if (!nodeRef.current) { - console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!"); - return; - } - context.dispatch({ - type: Type.SetFocus, - payload: { node: nodeRef.current }, - }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps +type IProps = Omit; - // eslint-disable-next-line react-compiler/react-compiler - const isActive = context.state.activeNode === nodeRef.current; - return [onFocus, isActive, ref, nodeRef]; +export const RovingTabIndexProvider: React.FC = (props) => { + return ; }; // re-export the semantic helper components for simplicity diff --git a/apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx b/apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx index 5d2f001241a..b7f7a2f9a35 100644 --- a/apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx +++ b/apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx @@ -6,26 +6,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type ReactElement, type RefCallback, type RefObject } from "react"; - -import type React from "react"; -import { useRovingTabIndex } from "../RovingTabIndex"; -import { type FocusHandler } from "./types"; - -interface IProps { - inputRef?: RefObject; - children( - this: void, - renderProps: { - onFocus: FocusHandler; - isActive: boolean; - ref: RefCallback; - }, - ): ReactElement; -} - -// Wrapper to allow use of useRovingTabIndex outside of React Functional Components. -export const RovingTabIndexWrapper: React.FC = ({ children, inputRef }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return children({ onFocus, isActive, ref }); -}; +export { RovingTabIndexWrapper } from "@element-hq/web-shared-components"; diff --git a/apps/web/src/accessibility/roving/types.ts b/apps/web/src/accessibility/roving/types.ts deleted file mode 100644 index f06c15cf7ac..00000000000 --- a/apps/web/src/accessibility/roving/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -export type FocusHandler = () => void; diff --git a/apps/web/src/components/views/dialogs/ForwardDialog.tsx b/apps/web/src/components/views/dialogs/ForwardDialog.tsx index a6a6954bc5f..6417fd985c5 100644 --- a/apps/web/src/components/views/dialogs/ForwardDialog.tsx +++ b/apps/web/src/components/views/dialogs/ForwardDialog.tsx @@ -50,9 +50,9 @@ import { RoomContextDetails } from "../rooms/RoomContextDetails"; import { filterBoolean } from "../../../utils/arrays"; import { type IState, + RovingStateActionType, RovingTabIndexContext, RovingTabIndexProvider, - Type, useRovingTabIndex, } from "../../../accessibility/RovingTabIndex"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; @@ -368,7 +368,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr const node = context.state.nodes[0]; if (node) { context.dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node }, }); node?.scrollIntoView?.({ diff --git a/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index de68c42ee40..d8874a4d95a 100644 --- a/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -44,10 +44,10 @@ import { import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; import { - findSiblingElement, + findNextSiblingElement, + RovingStateActionType, RovingTabIndexContext, RovingTabIndexProvider, - Type, } from "../../../../accessibility/RovingTabIndex"; import { mediaFromMxc } from "../../../../customisations/Media"; import { Action } from "../../../../dispatcher/actions"; @@ -537,7 +537,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n const node = rovingContext.state.nodes[0]; if (node) { rovingContext.dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node }, }); node?.scrollIntoView?.({ @@ -1181,7 +1181,10 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } const idx = nodes.indexOf(rovingContext.state.activeNode); - node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1)); + node = findNextSiblingElement( + nodes, + idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1), + ); } break; @@ -1201,7 +1204,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed); const idx = nodes.indexOf(rovingContext.state.activeNode); - node = findSiblingElement( + node = findNextSiblingElement( nodes, idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1), ); @@ -1211,7 +1214,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n if (node) { rovingContext.dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node }, }); node?.scrollIntoView({ diff --git a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx b/apps/web/src/components/views/emojipicker/EmojiPicker.tsx index 192a40b782c..c3ad43fd777 100644 --- a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx +++ b/apps/web/src/components/views/emojipicker/EmojiPicker.tsx @@ -25,7 +25,7 @@ import { type IAction as RovingAction, type IState as RovingState, RovingTabIndexProvider, - Type, + RovingStateActionType, } from "../../../accessibility/RovingTabIndex"; import { Key } from "../../../Keyboard"; import { type ButtonEvent } from "../elements/AccessibleButton"; @@ -187,7 +187,7 @@ class EmojiPicker extends React.Component { focusNode?.focus(); } dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node: focusNode }, }); @@ -212,7 +212,7 @@ class EmojiPicker extends React.Component { // Reset to first emoji when showing highlight for the first time (or after it was hidden) if (state.nodes.length > 0) { dispatch({ - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node: state.nodes[0] }, }); } diff --git a/apps/web/test/unit-tests/accessibility/RovingTabIndexAdapter-test.tsx b/apps/web/test/unit-tests/accessibility/RovingTabIndexAdapter-test.tsx new file mode 100644 index 00000000000..e6ed27628a9 --- /dev/null +++ b/apps/web/test/unit-tests/accessibility/RovingTabIndexAdapter-test.tsx @@ -0,0 +1,96 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { render } from "jest-matrix-react"; +import { RovingAction, type RovingTabIndexProviderProps } from "@element-hq/web-shared-components"; + +import * as KeyBindingsManagerModule from "../../../src/KeyBindingsManager"; +import { KeyBindingAction } from "../../../src/accessibility/KeyboardShortcuts"; +import { RovingTabIndexProvider } from "../../../src/accessibility/RovingTabIndex"; + +jest.mock("@element-hq/web-shared-components", () => { + const actual = jest.requireActual("@element-hq/web-shared-components"); + const mockSharedRovingTabIndexProvider = jest.fn(({ children }: RovingTabIndexProviderProps) => { + return <>{children({ onDragEndHandler: jest.fn(), onKeyDownHandler: jest.fn() })}; + }); + + return { + __mockSharedRovingTabIndexProvider: mockSharedRovingTabIndexProvider, + ...actual, + RovingTabIndexProvider: mockSharedRovingTabIndexProvider, + }; +}); + +const getMockSharedRovingTabIndexProvider = (): jest.Mock => { + return jest.requireMock("@element-hq/web-shared-components").__mockSharedRovingTabIndexProvider as jest.Mock; +}; + +const getInjectedGetAction = (): NonNullable => { + const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider(); + expect(mockSharedRovingTabIndexProvider).toHaveBeenCalled(); + const getAction = (mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps).getAction; + expect(getAction).toBeDefined(); + return getAction!; +}; + +describe("RovingTabIndex adapter", () => { + beforeEach(() => { + const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider(); + mockSharedRovingTabIndexProvider.mockClear(); + jest.restoreAllMocks(); + }); + + it.each([ + [KeyBindingAction.ArrowDown, RovingAction.ArrowDown], + [KeyBindingAction.ArrowUp, RovingAction.ArrowUp], + [KeyBindingAction.ArrowRight, RovingAction.ArrowRight], + [KeyBindingAction.ArrowLeft, RovingAction.ArrowLeft], + [KeyBindingAction.Home, RovingAction.Home], + [KeyBindingAction.End, RovingAction.End], + [KeyBindingAction.Tab, RovingAction.Tab], + ])("maps %s to %s", (accessibilityAction, expectedRovingAction) => { + const manager = new KeyBindingsManagerModule.KeyBindingsManager(); + jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager); + jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(accessibilityAction); + + render({() => null}); + + const getAction = getInjectedGetAction(); + expect(getAction({ key: "irrelevant" } as React.KeyboardEvent)).toBe(expectedRovingAction); + }); + + it("returns undefined when there is no matching accessibility action", () => { + const manager = new KeyBindingsManagerModule.KeyBindingsManager(); + jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager); + jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(undefined); + + render({() => null}); + + const getAction = getInjectedGetAction(); + expect(getAction({ key: "x" } as React.KeyboardEvent)).toBeUndefined(); + }); + + it("forwards provider props to shared-components", () => { + const onKeyDown = jest.fn(); + + render( + + {() => null} + , + ); + + const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider(); + const props = mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps; + expect(props.handleHomeEnd).toBe(true); + expect(props.handleLoop).toBe(true); + expect(props.handleUpDown).toBe(true); + expect(props.onKeyDown).toBe(onKeyDown); + expect(props.scrollIntoView).toBe(true); + expect(props.getAction).toEqual(expect.any(Function)); + }); +}); diff --git a/apps/web/test/unit-tests/accessibility/RovingTabIndex-test.tsx b/packages/shared-components/src/core/roving/RovingTabIndex.test.tsx similarity index 54% rename from apps/web/test/unit-tests/accessibility/RovingTabIndex-test.tsx rename to packages/shared-components/src/core/roving/RovingTabIndex.test.tsx index 00c0c679137..82103f47369 100644 --- a/apps/web/test/unit-tests/accessibility/RovingTabIndex-test.tsx +++ b/packages/shared-components/src/core/roving/RovingTabIndex.test.tsx @@ -1,30 +1,31 @@ /* -Copyright 2024 New Vector Ltd. -Copyright 2020, 2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ import React, { type HTMLAttributes } from "react"; -import { act, render } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; +import { act, fireEvent, render } from "@test-utils"; +import { describe, expect, it, vi } from "vitest"; import { - type IState, - reducer, + RovingAction, + RovingStateActionType, RovingTabIndexProvider, RovingTabIndexWrapper, - Type, useRovingTabIndex, -} from "../../../src/accessibility/RovingTabIndex"; +} from "."; +import type { IState } from "."; +import { reducer } from "./RovingTabIndex"; -const Button = (props: HTMLAttributes) => { +const Button = (props: HTMLAttributes): React.JSX.Element => { const [onFocus, isActive, ref] = useRovingTabIndex(); return ; const button2 = ; const button3 = ; const button4 = ; -// mock offsetParent Object.defineProperty(HTMLElement.prototype, "offsetParent", { get() { return this.parentNode; @@ -48,7 +62,7 @@ Object.defineProperty(HTMLElement.prototype, "offsetParent", { }); describe("RovingTabIndex", () => { - it("RovingTabIndexProvider renders children as expected", () => { + it("renders children as expected", () => { const { container } = render( {() => ( @@ -62,88 +76,81 @@ describe("RovingTabIndex", () => { expect(container.innerHTML).toBe("
Test
"); }); - it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => { + it("works as expected with useRovingTabIndex", () => { const { container, rerender } = render( {() => ( - + <> {button1} {button2} {button3} - + )} , ); - // should begin with 0th being active checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); - // focus on 2nd button and test it is the only active one act(() => container.querySelectorAll("button")[2].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); - // focus on 1st button and test it is the only active one act(() => container.querySelectorAll("button")[1].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); - // check that the active button does not change even on an explicit blur event act(() => container.querySelectorAll("button")[1].blur()); checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); - // update the children, it should remain on the same button rerender( {() => ( - + <> {button1} {button4} {button2} {button3} - + )} , ); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0, -1]); - // update the children, remove the active button, it should move to the next one rerender( {() => ( - + <> {button1} {button4} {button3} - + )} , ); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); }); - it("RovingTabIndexProvider provides a ref to the dom element", () => { + it("provides a ref to the dom element", () => { const nodeRef = React.createRef(); - const MyButton = (props: HTMLAttributes) => { + const MyButton = (props: HTMLAttributes): React.JSX.Element => { const [onFocus, isActive, ref] = useRovingTabIndex(nodeRef); return )} - + )}
, ); - // should begin with 0th being active checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); - // focus on 2nd button and test it is the only active one act(() => container.querySelectorAll("button")[2].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); }); @@ -177,7 +182,7 @@ describe("RovingTabIndex", () => { nodes: [node1, node2], }, { - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node: node2, }, @@ -190,49 +195,49 @@ describe("RovingTabIndex", () => { }); it("Unregister works as expected", () => { - const button1 = createButtonElement("Button 1"); - const button2 = createButtonElement("Button 2"); - const button3 = createButtonElement("Button 3"); - const button4 = createButtonElement("Button 4"); + const unregisterButton1 = createButtonElement("Button 1"); + const unregisterButton2 = createButtonElement("Button 2"); + const unregisterButton3 = createButtonElement("Button 3"); + const unregisterButton4 = createButtonElement("Button 4"); let state: IState = { - nodes: [button1, button2, button3, button4], + nodes: [unregisterButton1, unregisterButton2, unregisterButton3, unregisterButton4], }; state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { - node: button2, + node: unregisterButton2, }, }); expect(state).toStrictEqual({ - nodes: [button1, button3, button4], + nodes: [unregisterButton1, unregisterButton3, unregisterButton4], }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { - node: button3, + node: unregisterButton3, }, }); expect(state).toStrictEqual({ - nodes: [button1, button4], + nodes: [unregisterButton1, unregisterButton4], }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { - node: button4, + node: unregisterButton4, }, }); expect(state).toStrictEqual({ - nodes: [button1], + nodes: [unregisterButton1], }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { - node: button1, + node: unregisterButton1, }, }); expect(state).toStrictEqual({ @@ -247,12 +252,12 @@ describe("RovingTabIndex", () => { const ref4 = React.createRef(); render( - + <> - , + , ); let state: IState = { @@ -260,7 +265,7 @@ describe("RovingTabIndex", () => { }; state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref1.current!, }, @@ -271,7 +276,7 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref2.current!, }, @@ -282,7 +287,7 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref3.current!, }, @@ -293,7 +298,7 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref4.current!, }, @@ -303,9 +308,8 @@ describe("RovingTabIndex", () => { nodes: [ref1.current, ref2.current, ref3.current, ref4.current], }); - // test that the automatic focus switch works for unmounting state = reducer(state, { - type: Type.SetFocus, + type: RovingStateActionType.SetFocus, payload: { node: ref2.current!, }, @@ -316,7 +320,7 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { node: ref2.current!, }, @@ -326,9 +330,8 @@ describe("RovingTabIndex", () => { nodes: [ref1.current, ref3.current, ref4.current], }); - // test that the insert into the middle works as expected state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref2.current!, }, @@ -338,15 +341,14 @@ describe("RovingTabIndex", () => { nodes: [ref1.current, ref2.current, ref3.current, ref4.current], }); - // test that insertion at the edges works state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { node: ref1.current!, }, }); state = reducer(state, { - type: Type.Unregister, + type: RovingStateActionType.Unregister, payload: { node: ref4.current!, }, @@ -357,14 +359,14 @@ describe("RovingTabIndex", () => { }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref1.current!, }, }); state = reducer(state, { - type: Type.Register, + type: RovingStateActionType.Register, payload: { node: ref4.current!, }, @@ -376,18 +378,15 @@ describe("RovingTabIndex", () => { }); }); - describe("handles arrow keys", () => { - it("should handle up/down arrow keys work when handleUpDown=true", async () => { - const { container } = render( - - {({ onKeyDownHandler }) => ( -
- {button1} - {button2} - {button3} -
- )} -
, + describe("handles keyboard navigation", () => { + it("handles up/down arrow keys when handleUpDown=true", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true }, ); act(() => container.querySelectorAll("button")[0].focus()); @@ -405,29 +404,160 @@ describe("RovingTabIndex", () => { await userEvent.keyboard("[ArrowUp]"); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); - // Does not loop without await userEvent.keyboard("[ArrowUp]"); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); }); - it("should call scrollIntoView if specified", async () => { - const { container } = render( - - {({ onKeyDownHandler }) => ( -
- {button1} - {button2} - {button3} -
- )} -
, + it("handles left/right arrow keys when handleLeftRight=true", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleLeftRight: true }, + ); + + act(() => container.querySelectorAll("button")[0].focus()); + await userEvent.keyboard("[ArrowRight]"); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); + + await userEvent.keyboard("[ArrowLeft]"); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + }); + + it("handles Home and End when handleHomeEnd=true", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleHomeEnd: true }, + ); + + act(() => container.querySelectorAll("button")[1].focus()); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); + + await userEvent.keyboard("[End]"); + checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); + + await userEvent.keyboard("[Home]"); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + }); + + it("loops when handleLoop=true", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true, handleLoop: true }, + ); + + act(() => container.querySelectorAll("button")[2].focus()); + await userEvent.keyboard("[ArrowDown]"); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + + await userEvent.keyboard("[ArrowUp]"); + checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); + }); + + it("uses a custom getAction mapper", async () => { + const getAction = vi.fn((ev: React.KeyboardEvent): RovingAction | undefined => { + if (ev.key === "j") { + return RovingAction.ArrowDown; + } + + return undefined; + }); + + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true, getAction }, + ); + + act(() => container.querySelectorAll("button")[0].focus()); + await userEvent.keyboard("j"); + + expect(getAction).toHaveBeenCalled(); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); + }); + + it("handles input fields when handleInputFields=true", () => { + const { container, getByRole } = renderToolbar( + <> + {button1} + + {button2} + , + { handleUpDown: true, handleInputFields: true }, + ); + + act(() => container.querySelectorAll("button")[0].focus()); + const input = getByRole("textbox", { name: "Search input" }); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0]); + }); + + it("moves from an input field with Tab when handleInputFields=false", () => { + const { container, getByRole } = renderToolbar( + <> + {button1} + + {button2} + , + ); + + act(() => container.querySelectorAll("button")[0].focus()); + const input = getByRole("textbox", { name: "Search input" }); + act(() => (input as HTMLElement).focus()); + + fireEvent.keyDown(input, { key: "Tab" }); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0]); + }); + + it("stops provider processing when onKeyDown prevents default", () => { + const onKeyDown = vi.fn((event: React.KeyboardEvent): void => { + event.preventDefault(); + }); + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true, onKeyDown }, + ); + + act(() => container.querySelectorAll("button")[0].focus()); + fireEvent.keyDown(container.querySelector('[role="toolbar"]')!, { key: "ArrowDown" }); + + expect(onKeyDown).toHaveBeenCalled(); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + }); + + it("calls scrollIntoView if specified", async () => { + const { container } = renderToolbar( + <> + {button1} + {button2} + {button3} + , + { handleUpDown: true, scrollIntoView: true }, ); act(() => container.querySelectorAll("button")[0].focus()); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); const button = container.querySelectorAll("button")[1]; - const mock = jest.spyOn(button, "scrollIntoView"); + const mock = vi.spyOn(button, "scrollIntoView"); await userEvent.keyboard("[ArrowDown]"); expect(mock).toHaveBeenCalled(); }); diff --git a/packages/shared-components/src/core/roving/RovingTabIndex.tsx b/packages/shared-components/src/core/roving/RovingTabIndex.tsx new file mode 100644 index 00000000000..42055714a3c --- /dev/null +++ b/packages/shared-components/src/core/roving/RovingTabIndex.tsx @@ -0,0 +1,611 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useReducer, + type Dispatch, + type KeyboardEvent, + type ReactNode, + type Reducer, + type RefCallback, + type RefObject, +} from "react"; + +/** + * Returns whether an element should keep native arrow-key behaviour instead of + * being intercepted by roving focus navigation. + * + * This excludes radio buttons and checkboxes, which commonly participate in + * directional navigation patterns. + * + * @param el - The element being evaluated for native input behaviour. + * @returns `true` when the element should keep its own arrow-key handling. + */ +export function checkInputableElement(el: HTMLElement): boolean { + return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]'); +} + +/** + * The current state of a roving tabindex group. + */ +export interface IState { + /** + * The element that currently owns the active tab stop. + */ + activeNode?: HTMLElement; + /** + * Registered elements in DOM order. + */ + nodes: HTMLElement[]; +} + +/** + * The value exposed by {@link RovingTabIndexContext}. + */ +export interface IContext { + state: IState; + dispatch: Dispatch; +} + +/** + * React context used by roving tabindex participants to register themselves and + * update the active item. + */ +export const RovingTabIndexContext = createContext({ + state: { + nodes: [], // list of nodes in DOM order + }, + dispatch: () => {}, +}); +RovingTabIndexContext.displayName = "RovingTabIndexContext"; + +/** + * Internal reducer action kinds used by the roving tabindex state machine. + */ +export enum RovingStateActionType { + Register = "REGISTER", + Unregister = "UNREGISTER", + SetFocus = "SET_FOCUS", + Update = "UPDATE", +} + +/** + * An action dispatched to the roving tabindex reducer for node registration and + * focus updates. + */ +export interface IAction { + /** + * The reducer action kind. + */ + type: Exclude; + /** + * Action payload carrying the target node. + */ + payload: { + /** + * The DOM node affected by the action. + */ + node: HTMLElement; + }; +} + +interface UpdateAction { + type: RovingStateActionType.Update; + payload?: never; +} + +type Action = IAction | UpdateAction; + +/** + * Normalized navigation intents understood by the shared roving provider. + */ +export enum RovingAction { + Home = "HOME", + End = "END", + ArrowLeft = "ARROW_LEFT", + ArrowUp = "ARROW_UP", + ArrowRight = "ARROW_RIGHT", + ArrowDown = "ARROW_DOWN", + Tab = "TAB", +} + +/** + * Props for {@link RovingTabIndexProvider}. + */ +export interface RovingTabIndexProviderProps { + /** + * Whether directional navigation should wrap from the last item to the first + * and vice versa. + */ + handleLoop?: boolean; + /** + * Whether `Home` and `End` should move focus to the first and last item. + */ + handleHomeEnd?: boolean; + /** + * Whether vertical arrow keys should move focus within the group. + */ + handleUpDown?: boolean; + /** + * Whether horizontal arrow keys should move focus within the group. + */ + handleLeftRight?: boolean; + /** + * Whether text inputs and similar controls should participate in roving + * keyboard handling instead of keeping their native arrow-key behaviour. + */ + handleInputFields?: boolean; + /** + * Whether newly focused items should be scrolled into view. + * + * Pass `true` to use the browser default, or a scroll options object to + * control alignment and behaviour. + */ + scrollIntoView?: boolean | ScrollIntoViewOptions; + /** + * Render prop receiving keyboard and drag-end handlers for the roving + * container. + */ + children( + this: void, + renderProps: { + /** + * Handles keyboard navigation for the roving container. + */ + onKeyDownHandler(this: void, ev: KeyboardEvent): void; + /** + * Re-sorts registered elements after DOM reordering, such as drag and + * drop. + */ + onDragEndHandler(this: void): void; + }, + ): ReactNode; + /** + * Optional callback invoked before the provider performs its own keyboard + * handling. + * + * Call `preventDefault()` on the event to suppress the built-in behaviour. + */ + onKeyDown?(this: void, ev: KeyboardEvent, state: IState, dispatch: Dispatch): void; + /** + * Optional action resolver used to map keyboard events to + * {@link RovingAction} values. + * + * When omitted, a default mapping based on `KeyboardEvent.key` is used. + */ + getAction?(this: void, ev: KeyboardEvent): RovingAction | undefined; +} + +const nodeSorter = (a: HTMLElement, b: HTMLElement): number => { + if (a === b) { + return 0; + } + + const position = a.compareDocumentPosition(b); + + if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) { + return -1; + } else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) { + return 1; + } else { + return 0; + } +}; + +const getReplacementActiveNode = (nodes: HTMLElement[], removedIndex: number): HTMLElement | undefined => { + if (removedIndex >= nodes.length) { + return findPreviousSiblingElement(nodes, nodes.length - 1); + } + + return findNextSiblingElement(nodes, removedIndex) || findPreviousSiblingElement(nodes, removedIndex); +}; + +const handleRemovedActiveNode = (state: IState, removedIndex: number): void => { + state.activeNode = getReplacementActiveNode(state.nodes, removedIndex); + + if (document.activeElement === document.body) { + // if the focus got reverted to the body then the user was likely focused on the unmounted element + setTimeout(() => state.activeNode?.focus(), 0); + } +}; + +/** + * Reducer that tracks registered nodes and the currently active roving tab + * stop. + */ +export const reducer: Reducer = (state: IState, action: Action) => { + switch (action.type) { + case RovingStateActionType.Register: { + // Our list of nodes was empty, set activeNode to this first item + state.activeNode ??= action.payload.node; + + if (state.nodes.includes(action.payload.node)) return state; + + // Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert + state.nodes.push(action.payload.node); + state.nodes.sort(nodeSorter); + + return { ...state }; + } + + case RovingStateActionType.Unregister: { + const oldIndex = state.nodes.indexOf(action.payload.node); + + if (oldIndex === -1) { + return state; // already removed, this should not happen + } + + if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) { + handleRemovedActiveNode(state, oldIndex); + } + + return { ...state }; + } + + case RovingStateActionType.SetFocus: { + if (state.activeNode === action.payload.node) return state; + state.activeNode = action.payload.node; + return { ...state }; + } + + case RovingStateActionType.Update: { + state.nodes.sort(nodeSorter); + return { ...state }; + } + + default: + return state; + } +}; + +const findSiblingElementInRange = ( + nodes: HTMLElement[], + startIndex: number, + endIndex: number, + step: 1 | -1, +): HTMLElement | undefined => { + if (step === 1) { + for (let i = startIndex; i < endIndex; i += step) { + if (nodes[i]?.offsetParent !== null) { + return nodes[i]; + } + } + } else { + for (let i = startIndex; i > endIndex; i += step) { + if (nodes[i]?.offsetParent !== null) { + return nodes[i]; + } + } + } +}; + +/** + * Finds the next visible sibling element starting from a given index. + * + * @param nodes - Registered roving nodes in DOM order. + * @param startIndex - The index to begin searching from. + * @param loop - Whether to wrap around when no visible sibling is found. + * @returns The next visible sibling element, if one exists. + */ +export const findNextSiblingElement = ( + nodes: HTMLElement[], + startIndex: number, + loop = false, +): HTMLElement | undefined => { + const sibling = findSiblingElementInRange(nodes, startIndex, nodes.length, 1); + + if (sibling || !loop) { + return sibling; + } + + return findSiblingElementInRange(nodes.slice(0, startIndex), 0, startIndex, 1); +}; + +/** + * Finds the previous visible sibling element starting from a given index. + * + * @param nodes - Registered roving nodes in DOM order. + * @param startIndex - The index to begin searching from. + * @param loop - Whether to wrap around when no visible sibling is found. + * @returns The previous visible sibling element, if one exists. + */ +export const findPreviousSiblingElement = ( + nodes: HTMLElement[], + startIndex: number, + loop = false, +): HTMLElement | undefined => { + const sibling = findSiblingElementInRange(nodes, startIndex, -1, -1); + + if (sibling || !loop) { + return sibling; + } + + const loopNodes = nodes.slice(startIndex + 1); + return findSiblingElementInRange(loopNodes, loopNodes.length - 1, -1, -1); +}; + +const getDefaultAction = (ev: KeyboardEvent): RovingAction | undefined => { + switch (ev.key) { + case "Home": + return RovingAction.Home; + case "End": + return RovingAction.End; + case "ArrowLeft": + return RovingAction.ArrowLeft; + case "ArrowUp": + return RovingAction.ArrowUp; + case "ArrowRight": + return RovingAction.ArrowRight; + case "ArrowDown": + return RovingAction.ArrowDown; + case "Tab": + return RovingAction.Tab; + default: + return undefined; + } +}; + +interface NavigationResult { + handled: boolean; + focusNode?: HTMLElement; +} + +interface StandardNavigationConfig { + enabled: boolean; + getFocusNode(state: IState): HTMLElement | undefined; +} + +const getAdjacentFocusNode = ( + nodes: HTMLElement[], + activeNode: HTMLElement | undefined, + backwards: boolean, + loop = false, +): HTMLElement | undefined => { + if (nodes.length === 0 || !activeNode) { + return undefined; + } + + const currentIndex = nodes.indexOf(activeNode); + const nextIndex = currentIndex + (backwards ? -1 : 1); + + return backwards + ? findPreviousSiblingElement(nodes, nextIndex, loop) + : findNextSiblingElement(nodes, nextIndex, loop); +}; + +const getInputNavigationResult = ( + action: RovingAction | undefined, + nodes: HTMLElement[], + activeNode: HTMLElement | undefined, + shiftKey: boolean, +): NavigationResult => { + if (action !== RovingAction.Tab) { + return { handled: false }; + } + + return { + handled: true, + focusNode: getAdjacentFocusNode(nodes, activeNode, shiftKey), + }; +}; + +const buildStandardNavigationConfig = ( + state: IState, + handleHomeEnd: boolean, + handleUpDown: boolean, + handleLeftRight: boolean, + handleLoop: boolean, +): Record => ({ + [RovingAction.Home]: { + enabled: handleHomeEnd, + getFocusNode: (currentState) => findNextSiblingElement(currentState.nodes, 0), + }, + [RovingAction.End]: { + enabled: handleHomeEnd, + getFocusNode: (currentState) => findPreviousSiblingElement(currentState.nodes, currentState.nodes.length - 1), + }, + [RovingAction.ArrowDown]: { + enabled: handleUpDown, + getFocusNode: (currentState) => + getAdjacentFocusNode(currentState.nodes, currentState.activeNode, false, handleLoop), + }, + [RovingAction.ArrowRight]: { + enabled: handleLeftRight, + getFocusNode: (currentState) => + getAdjacentFocusNode(currentState.nodes, currentState.activeNode, false, handleLoop), + }, + [RovingAction.ArrowUp]: { + enabled: handleUpDown, + getFocusNode: (currentState) => + getAdjacentFocusNode(currentState.nodes, currentState.activeNode, true, handleLoop), + }, + [RovingAction.ArrowLeft]: { + enabled: handleLeftRight, + getFocusNode: (currentState) => + getAdjacentFocusNode(currentState.nodes, currentState.activeNode, true, handleLoop), + }, + [RovingAction.Tab]: { + enabled: false, + getFocusNode: () => undefined, + }, +}); + +const getStandardNavigationResult = ( + action: RovingAction | undefined, + state: IState, + handleHomeEnd: boolean, + handleUpDown: boolean, + handleLeftRight: boolean, + handleLoop: boolean, +): NavigationResult => { + if (!action) { + return { handled: false }; + } + + const config = buildStandardNavigationConfig(state, handleHomeEnd, handleUpDown, handleLeftRight, handleLoop)[ + action + ]; + + if (!config?.enabled) { + return { handled: false }; + } + + return { + handled: true, + focusNode: config.getFocusNode(state), + }; +}; + +/** + * Provides shared roving tabindex state and keyboard handling for a group of + * focusable descendants. + */ +export const RovingTabIndexProvider: React.FC = ({ + children, + handleHomeEnd, + handleUpDown, + handleLeftRight, + handleLoop, + handleInputFields, + scrollIntoView, + onKeyDown, + getAction = getDefaultAction, +}) => { + const [state, dispatch] = useReducer(reducer, { + nodes: [], + }); + + const context = useMemo(() => ({ state, dispatch }), [state]); + + const onKeyDownHandler = useCallback( + (ev: KeyboardEvent) => { + if (onKeyDown) { + onKeyDown(ev, context.state, context.dispatch); + if (ev.defaultPrevented) { + return; + } + } + + const action = getAction(ev); + // Don't interfere with input default keydown behaviour + // but allow people to move focus from it with Tab. + const isInputTarget = !handleInputFields && checkInputableElement(ev.target as HTMLElement); + const { handled, focusNode } = isInputTarget + ? getInputNavigationResult(action, context.state.nodes, context.state.activeNode, ev.shiftKey) + : getStandardNavigationResult( + action, + context.state, + handleHomeEnd ?? false, + handleUpDown ?? false, + handleLeftRight ?? false, + handleLoop ?? false, + ); + + if (handled) { + ev.preventDefault(); + ev.stopPropagation(); + } + + if (focusNode) { + focusNode.focus(); + // programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves + dispatch({ + type: RovingStateActionType.SetFocus, + payload: { + node: focusNode, + }, + }); + if (scrollIntoView) { + focusNode.scrollIntoView(scrollIntoView); + } + } + }, + [ + context, + getAction, + onKeyDown, + handleHomeEnd, + handleUpDown, + handleLeftRight, + handleLoop, + handleInputFields, + scrollIntoView, + ], + ); + + const onDragEndHandler = useCallback(() => { + dispatch({ + type: RovingStateActionType.Update, + }); + }, []); + + return ( + + {children({ onKeyDownHandler, onDragEndHandler })} + + ); +}; + +/** + * Registers a focusable element with the nearest + * {@link RovingTabIndexContext}. + * + * @param inputRef - Optional ref to reuse for the registered DOM node. + * @returns A tuple containing: + * `onFocus` to mark the item active, + * `isActive` to drive `tabIndex`, + * `ref` to register the DOM node, + * and `nodeRef` pointing at the registered node. + */ +export const useRovingTabIndex = ( + inputRef?: RefObject, +): [() => void, boolean, RefCallback, RefObject] => { + const context = useContext(RovingTabIndexContext); + + let nodeRef = useRef(null); + + if (inputRef) { + // if we are given a ref, use it instead of ours + nodeRef = inputRef; + } + + const ref = useCallback((node: T | null) => { + if (node) { + nodeRef.current = node; + context.dispatch({ + type: RovingStateActionType.Register, + payload: { node }, + }); + } else { + context.dispatch({ + type: RovingStateActionType.Unregister, + payload: { node: nodeRef.current! }, + }); + nodeRef.current = null; + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const onFocus = useCallback(() => { + if (!nodeRef.current) { + console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!"); + return; + } + context.dispatch({ + type: RovingStateActionType.SetFocus, + payload: { node: nodeRef.current }, + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // eslint-disable-next-line react-compiler/react-compiler + const isActive = context.state.activeNode === nodeRef.current; + return [onFocus, isActive, ref, nodeRef]; +}; diff --git a/packages/shared-components/src/core/roving/RovingTabIndexWrapper.tsx b/packages/shared-components/src/core/roving/RovingTabIndexWrapper.tsx new file mode 100644 index 00000000000..a17dcb4be9c --- /dev/null +++ b/packages/shared-components/src/core/roving/RovingTabIndexWrapper.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type ReactElement, type RefCallback, type RefObject } from "react"; + +import type React from "react"; +import { useRovingTabIndex } from "./RovingTabIndex"; + +interface IProps { + inputRef?: RefObject; + children( + this: void, + renderProps: { + onFocus: () => void; + isActive: boolean; + ref: RefCallback; + }, + ): ReactElement; +} + +/** + * Render-prop wrapper around {@link useRovingTabIndex} for class components and + * other places where hooks cannot be called directly. + */ +export const RovingTabIndexWrapper: React.FC = ({ children, inputRef }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + return children({ onFocus, isActive, ref }); +}; diff --git a/packages/shared-components/src/core/roving/index.ts b/packages/shared-components/src/core/roving/index.ts new file mode 100644 index 00000000000..3694df73a4c --- /dev/null +++ b/packages/shared-components/src/core/roving/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { + checkInputableElement, + findNextSiblingElement, + RovingAction, + RovingStateActionType, + RovingTabIndexContext, + RovingTabIndexProvider, + useRovingTabIndex, +} from "./RovingTabIndex"; +export type { IAction, IContext, IState, RovingTabIndexProviderProps } from "./RovingTabIndex"; +export { RovingTabIndexWrapper } from "./RovingTabIndexWrapper"; diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 081ccf0d831..2fe4de8d6db 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -10,6 +10,7 @@ export * from "./audio/Clock"; export * from "./audio/PlayPauseButton"; export * from "./audio/SeekBar"; export * from "./core/AvatarWithDetails"; +export * from "./core/roving"; export * from "./room/composer/Banner"; export * from "./crypto/SasEmoji"; export * from "./event-tiles/UrlPreviewGroupView"; diff --git a/packages/shared-components/src/room/timeline/event-tile/actions/ActionBarView/ActionBarButton.tsx b/packages/shared-components/src/room/timeline/event-tile/actions/ActionBarView/ActionBarButton.tsx index f7bf79c3f22..d29f062b02f 100644 --- a/packages/shared-components/src/room/timeline/event-tile/actions/ActionBarView/ActionBarButton.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/actions/ActionBarView/ActionBarButton.tsx @@ -5,9 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, useLayoutEffect, useRef } from "react"; +import { useMergeRefs } from "react-merge-refs"; import { Button, Tooltip } from "@vector-im/compound-web"; +import { useRovingTabIndex } from "../../../../../core/roving"; import styles from "./ActionBarView.module.css"; interface ActionBarButtonProps { @@ -36,6 +38,16 @@ export function ActionBarButton({ tooltipCaption, }: Readonly): JSX.Element { const iconOnly = presentation === "icon"; + const [onFocus, isActive, rovingRef] = useRovingTabIndex(); + const localRef = useRef(null); + const ref = useMergeRefs([buttonRef, localRef, disabled ? null : rovingRef]); + const tabIndex = disabled || !isActive ? -1 : 0; + + useLayoutEffect(() => { + if (!localRef.current) return; + + localRef.current.tabIndex = tabIndex; + }, [tabIndex]); const handleContextMenu = (event: React.MouseEvent): void => { event.preventDefault(); @@ -47,7 +59,7 @@ export function ActionBarButton({