diff --git a/apps/web/src/accessibility/RovingTabIndex.tsx b/apps/web/src/accessibility/RovingTabIndex.tsx index 040b6d27be..0c59ddf936 100644 --- a/apps/web/src/accessibility/RovingTabIndex.tsx +++ b/apps/web/src/accessibility/RovingTabIndex.tsx @@ -9,6 +9,8 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { RovingAction, + RovingGridIndexProvider as SharedRovingGridIndexProvider, + type RovingGridIndexProviderProps, RovingTabIndexProvider as SharedRovingTabIndexProvider, type RovingTabIndexProviderProps, } from "@element-hq/web-shared-components"; @@ -56,12 +58,17 @@ const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined = } }; -type IProps = Omit; +type IRovingTabIndexProps = Omit; +type IRovingGridIndexProps = Omit; -export const RovingTabIndexProvider: React.FC = (props) => { +export const RovingTabIndexProvider: React.FC = (props) => { return ; }; +export const RovingGridIndexProvider: React.FC = (props) => { + return ; +}; + // re-export the semantic helper components for simplicity export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper"; export { RovingAccessibleButton } from "./roving/RovingAccessibleButton"; diff --git a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx b/apps/web/src/components/views/emojipicker/EmojiPicker.tsx index c3ad43fd77..31e3d42944 100644 --- a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx +++ b/apps/web/src/components/views/emojipicker/EmojiPicker.tsx @@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details. import React, { type Dispatch } from "react"; import { DATA_BY_CATEGORY, getEmojiFromUnicode, type Emoji as IEmoji } from "@matrix-org/emojibase-bindings"; -import { clamp } from "@element-hq/web-shared-components"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; @@ -24,7 +23,7 @@ import { filterBoolean } from "../../../utils/arrays"; import { type IAction as RovingAction, type IState as RovingState, - RovingTabIndexProvider, + RovingGridIndexProvider, RovingStateActionType, } from "../../../accessibility/RovingTabIndex"; import { Key } from "../../../Keyboard"; @@ -126,83 +125,20 @@ class EmojiPicker extends React.Component { }; // Given a roving emoji button returns the role=row element containing it - private getRow(rovingNode?: Element): Element | undefined { + private readonly getRow = (rovingNode?: Element): Element | undefined => { return this.getGridcell(rovingNode)?.parentElement ?? undefined; - } + }; // Given a roving emoji button returns the role=gridcell element containing it - private getGridcell(rovingNode?: Element): Element | undefined { + private readonly getGridcell = (rovingNode?: Element): Element | undefined => { return rovingNode?.parentElement ?? undefined; - } + }; // Given a role=gridcell node returns the roving emoji button contained within - private getRovingNode(gridcellNode?: Element): Element | undefined { - return gridcellNode?.children[0]; - } - - private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void { - const rowElement = this.getRow(state.activeNode); - const gridcellNode = this.getGridcell(state.activeNode); - if (!rowElement || !gridcellNode || !state.activeNode) return; - - // Index of element within row container - const columnIndex = Array.from(rowElement.children).indexOf(gridcellNode); - // Index of element within the list of roving nodes - const refIndex = state.nodes.indexOf(state.activeNode); - - let focusNode: HTMLElement | undefined; - let newRowElement: Element | undefined; - switch (ev.key) { - case Key.ARROW_LEFT: - focusNode = state.nodes[refIndex - 1]; - newRowElement = this.getRow(focusNode); - break; - - case Key.ARROW_RIGHT: - focusNode = state.nodes[refIndex + 1]; - newRowElement = this.getRow(focusNode); - break; - - case Key.ARROW_UP: - case Key.ARROW_DOWN: { - // For up/down we find the prev/next parent by inspecting the refs either side of our row - const node = - ev.key === Key.ARROW_UP - ? state.nodes[refIndex - columnIndex - 1] - : state.nodes[refIndex - columnIndex + EMOJIS_PER_ROW]; - newRowElement = this.getRow(node); - if (newRowElement) { - const newColumnIndex = clamp(columnIndex, 0, newRowElement.children.length - 1); - const newTarget = this.getRovingNode(newRowElement?.children[newColumnIndex]); - focusNode = state.nodes.find((r) => r === newTarget); - } - break; - } - } - - if (focusNode) { - // Only move actual DOM focus if an emoji already has focus - // If the input has focus, keep using aria-activedescendant for virtual focus - if (document.activeElement !== document.querySelector(".mx_EmojiPicker_search input")) { - focusNode?.focus(); - } - dispatch({ - type: RovingStateActionType.SetFocus, - payload: { node: focusNode }, - }); - - if (rowElement !== newRowElement) { - focusNode?.scrollIntoView({ - behavior: "auto", - block: "center", - inline: "center", - }); - } - } - - ev.preventDefault(); - ev.stopPropagation(); - } + private readonly getRovingNode = (gridcellNode: Element): HTMLElement | undefined => { + const node = gridcellNode.children[0]; + return node instanceof HTMLElement ? node : undefined; + }; private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void => { if (state.activeNode && [Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)) { @@ -220,7 +156,20 @@ class EmojiPicker extends React.Component { ev.stopPropagation(); return; } - this.keyboardNavigation(ev, state, dispatch); + } + }; + + private readonly shouldMoveFocus = (): boolean => { + return document.activeElement !== document.querySelector(".mx_EmojiPicker_search input"); + }; + + private readonly onGridNavigation = (ev: React.KeyboardEvent, focusNode: HTMLElement, state: RovingState): void => { + if (this.getRow(state.activeNode) !== this.getRow(focusNode)) { + focusNode.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "center", + }); } }; @@ -381,7 +330,15 @@ class EmojiPicker extends React.Component { public render(): React.ReactNode { return ( - + {({ onKeyDownHandler }) => { let heightBefore = 0; return ( @@ -440,7 +397,7 @@ class EmojiPicker extends React.Component { ); }} - + ); } } diff --git a/packages/shared-components/src/core/roving/RovingGridIndex.stories.tsx b/packages/shared-components/src/core/roving/RovingGridIndex.stories.tsx new file mode 100644 index 0000000000..2a36ec0c61 --- /dev/null +++ b/packages/shared-components/src/core/roving/RovingGridIndex.stories.tsx @@ -0,0 +1,281 @@ +/* + * 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, { useState, type JSX } from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { withViewDocs } from "../../../.storybook/withViewDocs"; +import { RovingGridIndexProvider, useRovingTabIndex } from "."; + +const gridStyle: React.CSSProperties = { + display: "inline-flex", + flexDirection: "column", + gap: 4, + padding: 12, + border: "1px solid var(--cpd-color-border-interactive-secondary)", + borderRadius: 8, +}; + +const containerStyle: React.CSSProperties = { + display: "inline-flex", + flexDirection: "column", + gap: 8, + alignItems: "flex-start", +}; + +const rowStyle: React.CSSProperties = { + display: "flex", + gap: 4, +}; + +const buttonStyle: React.CSSProperties = { + width: 44, + height: 36, +}; + +const activeCellLabelStyle: React.CSSProperties = { + color: "var(--cpd-color-text-secondary)", + font: "var(--cpd-font-body-sm-regular)", +}; + +const gridLabelStyle: React.CSSProperties = { + color: "var(--cpd-color-text-secondary)", + font: "var(--cpd-font-body-md-semibold)", +}; + +function GridButton({ label }: Readonly<{ label: string }>): JSX.Element { + const [onFocus, isActive, ref] = useRovingTabIndex(); + + return ( + + ); +} + +function GridRows({ rows }: Readonly<{ rows: string[][] }>): JSX.Element { + return ( + <> + {rows.map((row) => ( +
+ {row.map((label) => ( +
+ +
+ ))} +
+ ))} + + ); +} + +function GridExample({ + rows, + handleHomeEnd, + handleInputFields, + handleLoop, + moveFocus = true, + withInput, +}: Readonly<{ + rows: string[][]; + handleHomeEnd?: boolean; + handleInputFields?: boolean; + handleLoop?: boolean; + moveFocus?: React.ComponentProps["moveFocus"]; + withInput?: boolean; +}>): JSX.Element { + const [activeLabel, setActiveLabel] = useState(rows[0]?.[0]); + + return ( + setActiveLabel(target.textContent ?? "")} + > + {({ onKeyDownHandler }) => ( +
+ {withInput && ( + + )} + {withInput && Active cell: {activeLabel}} +
+ +
+
+ )} +
+ ); +} + +function MultipleGridExample({ + grids, + handleHomeEnd, + handleLoop, +}: Readonly<{ + grids: Array<{ + label: string; + rows: string[][]; + }>; + handleHomeEnd?: boolean; + handleLoop?: boolean; +}>): JSX.Element { + return ( + + {({ onKeyDownHandler }) => ( +
+ {grids.map((grid) => ( +
+ + {grid.label} + +
+ +
+
+ ))} +
+ )} +
+ ); +} + +const RovingGridIndexWrapper = withViewDocs(GridExample, RovingGridIndexProvider); + +const meta = { + title: "Core/RovingGridIndex", + component: RovingGridIndexWrapper, + tags: ["autodocs", "skip-test"], + args: { + rows: [], + handleHomeEnd: true, + handleLoop: true, + }, + argTypes: { + handleHomeEnd: { + control: "boolean", + }, + handleInputFields: { + control: false, + table: { + disable: true, + }, + }, + handleLoop: { + control: "boolean", + }, + moveFocus: { + control: false, + table: { + disable: true, + }, + }, + withInput: { + control: false, + table: { + disable: true, + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const DefaultGrid: Story = { + args: { + rows: [ + ["A1", "A2", "A3", "A4"], + ["B1", "B2", "B3", "B4"], + ["C1", "C2", "C3", "C4"], + ], + handleHomeEnd: true, + }, +}; + +export const RaggedRows: Story = { + args: { + rows: [ + ["A1", "A2", "A3", "A4"], + ["B1", "B2"], + ["C1", "C2", "C3"], + ], + handleHomeEnd: true, + }, +}; + +export const VirtualFocus: Story = { + args: { + rows: [ + ["A1", "A2", "A3"], + ["B1", "B2", "B3"], + ], + handleHomeEnd: true, + handleInputFields: true, + moveFocus: false, + withInput: true, + }, +}; + +export const LoopingGrid: Story = { + args: { + rows: [ + ["A1", "A2", "A3"], + ["B1", "B2", "B3"], + ], + handleHomeEnd: true, + handleLoop: true, + }, +}; + +export const MultipleGrids: StoryObj = { + render: (args) => , + args: { + grids: [ + { + label: "Primary grid", + rows: [ + ["A1", "A2", "A3"], + ["B1", "B2", "B3"], + ], + }, + { + label: "Secondary grid", + rows: [ + ["C1", "C2"], + ["D1", "D2", "D3"], + ], + }, + ], + handleHomeEnd: true, + }, +}; diff --git a/packages/shared-components/src/core/roving/RovingGridIndex.test.tsx b/packages/shared-components/src/core/roving/RovingGridIndex.test.tsx new file mode 100644 index 0000000000..bba48331e3 --- /dev/null +++ b/packages/shared-components/src/core/roving/RovingGridIndex.test.tsx @@ -0,0 +1,308 @@ +/* + * 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 ReactNode } from "react"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@test-utils"; +import { describe, expect, it, vi } from "vitest"; + +import { RovingGridIndexProvider, useRovingTabIndex } from "."; + +const offsetParentDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent"); +if (offsetParentDescriptor?.configurable !== false) { + Object.defineProperty(HTMLElement.prototype, "offsetParent", { + configurable: true, + get() { + return this.parentNode; + }, + }); +} + +function GridButton({ label }: Readonly<{ label: string }>): React.ReactElement { + const [onFocus, isActive, ref] = useRovingTabIndex(); + + return ( + + ); +} + +function DefaultGrid({ + rows, + props, + beforeGrid, +}: Readonly<{ + rows: string[][]; + props?: Partial>; + beforeGrid?: ReactNode; +}>): React.ReactElement { + return ( + + {({ onKeyDownHandler }) => ( +
+ {beforeGrid && + React.isValidElement(beforeGrid) && + React.cloneElement(beforeGrid, { + onKeyDown: onKeyDownHandler, + } as Partial>)} +
+ {rows.map((row, rowIndex) => ( +
+ {row.map((label) => ( +
+ +
+ ))} +
+ ))} +
+
+ )} +
+ ); +} + +const getButton = (name: string): HTMLButtonElement => screen.getByRole("button", { name }); + +const expectTabIndexes = (labels: string[], activeLabel: string): void => { + for (const label of labels) { + expect(getButton(label)).toHaveAttribute("tabindex", label === activeLabel ? "0" : "-1"); + } +}; + +describe("RovingGridIndexProvider", () => { + it("moves horizontally through registered grid cells", async () => { + const user = userEvent.setup(); + render(); + + expectTabIndexes(["A1", "A2", "A3"], "A1"); + + await user.tab(); + expect(getButton("A1")).toHaveFocus(); + + await user.keyboard("{ArrowRight}"); + expect(getButton("A2")).toHaveFocus(); + expectTabIndexes(["A1", "A2", "A3"], "A2"); + + await user.keyboard("{ArrowLeft}"); + expect(getButton("A1")).toHaveFocus(); + expectTabIndexes(["A1", "A2", "A3"], "A1"); + }); + + it("moves vertically while preserving the column", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.tab(); + await user.keyboard("{ArrowRight}"); + expect(getButton("A2")).toHaveFocus(); + + await user.keyboard("{ArrowDown}"); + expect(getButton("B2")).toHaveFocus(); + expectTabIndexes(["A2", "B2"], "B2"); + + await user.keyboard("{ArrowDown}"); + expect(getButton("C2")).toHaveFocus(); + + await user.keyboard("{ArrowUp}"); + expect(getButton("B2")).toHaveFocus(); + }); + + it("clamps vertical movement for ragged rows", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.tab(); + await user.keyboard("{ArrowRight}"); + await user.keyboard("{ArrowRight}"); + expect(getButton("A3")).toHaveFocus(); + + await user.keyboard("{ArrowDown}"); + expect(getButton("B2")).toHaveFocus(); + expectTabIndexes(["A3", "B2"], "B2"); + }); + + it("moves vertically across separate grid containers", async () => { + const user = userEvent.setup(); + render( + + {({ onKeyDownHandler }) => ( + <> +
+
+ {["A1", "A2", "A3"].map((label) => ( +
+ +
+ ))} +
+
+
+
+ {["B1", "B2"].map((label) => ( +
+ +
+ ))} +
+
+ + )} +
, + ); + + await user.tab(); + await user.keyboard("{ArrowRight}"); + await user.keyboard("{ArrowRight}"); + expect(getButton("A3")).toHaveFocus(); + + await user.keyboard("{ArrowDown}"); + expect(getButton("B2")).toHaveFocus(); + }); + + it("keeps the active cell unchanged at grid boundaries", async () => { + const user = userEvent.setup(); + render(); + + await user.tab(); + await user.keyboard("{ArrowLeft}"); + expect(getButton("A1")).toHaveFocus(); + expectTabIndexes(["A1", "A2"], "A1"); + }); + + it("can update active state without moving DOM focus", async () => { + const user = userEvent.setup(); + render( + } + />, + ); + + await user.click(screen.getByRole("textbox", { name: "Search" })); + expect(screen.getByRole("textbox", { name: "Search" })).toHaveFocus(); + + await user.keyboard("{ArrowRight}"); + expect(screen.getByRole("textbox", { name: "Search" })).toHaveFocus(); + expectTabIndexes(["A1", "A2"], "A2"); + }); + + it("allows moveFocus to be decided per navigation target", async () => { + const user = userEvent.setup(); + const moveFocus = vi.fn((target: HTMLElement) => target.textContent !== "A2"); + render( + } + />, + ); + + await user.click(screen.getByRole("textbox", { name: "Search" })); + await user.keyboard("{ArrowRight}"); + + expect(moveFocus).toHaveBeenCalledWith(getButton("A2"), expect.anything(), expect.anything()); + expect(screen.getByRole("textbox", { name: "Search" })).toHaveFocus(); + expectTabIndexes(["A1", "A2", "A3"], "A2"); + + await user.keyboard("{ArrowRight}"); + expect(getButton("A3")).toHaveFocus(); + }); + + it("keeps native input arrow-key behaviour unless input handling is enabled", async () => { + const user = userEvent.setup(); + render( + } + />, + ); + + await user.click(screen.getByRole("textbox", { name: "Search" })); + await user.keyboard("{ArrowRight}"); + + expect(screen.getByRole("textbox", { name: "Search" })).toHaveFocus(); + expectTabIndexes(["A1", "A2"], "A1"); + }); + + it("scrolls the target into view when configured", async () => { + const user = userEvent.setup(); + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + render(); + + await user.tab(); + await user.keyboard("{ArrowRight}"); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "center" }); + }); + + it("supports custom grid markup resolvers", async () => { + const user = userEvent.setup(); + render( + node.closest("[data-grid-cell]") ?? undefined} + getRovingNode={(cell) => cell.querySelector("button") ?? undefined} + > + {({ onKeyDownHandler }) => ( +
+
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ )} +
, + ); + + await user.tab(); + await user.keyboard("{ArrowDown}"); + + expect(getButton("B1")).toHaveFocus(); + }); +}); diff --git a/packages/shared-components/src/core/roving/RovingGridIndex.tsx b/packages/shared-components/src/core/roving/RovingGridIndex.tsx new file mode 100644 index 0000000000..cf93a00aa6 --- /dev/null +++ b/packages/shared-components/src/core/roving/RovingGridIndex.tsx @@ -0,0 +1,305 @@ +/* + * 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, { useCallback, type Dispatch, type KeyboardEvent } from "react"; + +import { + checkInputableElement, + findNextSiblingElement, + findPreviousSiblingElement, + type IAction, + type IState, + RovingAction, + RovingStateActionType, + RovingTabIndexProvider, + type RovingTabIndexProviderProps, +} from "./RovingTabIndex"; + +/** + * Resolves the grid cell element that contains a registered roving node. + * + * The default expects the roving node to be a direct child of a `role="gridcell"` + * element. + */ +export type RovingGridCellResolver = (this: void, rovingNode: Element) => Element | undefined; + +/** + * Resolves the row element that contains a registered roving node. + * + * The default expects `rovingNode -> gridcell -> row`, where the row element is + * the grid cell's parent. + */ +export type RovingGridRowResolver = (this: void, rovingNode: Element) => Element | undefined; + +/** + * Resolves the registered roving node contained within a grid cell. + * + * The default expects the roving node to be the first child of the grid cell. + */ +export type RovingGridNodeResolver = (this: void, gridCell: Element) => HTMLElement | undefined; + +/** + * Controls whether grid navigation moves DOM focus to the active cell. + * + * Pass `false` for composite widgets that keep DOM focus elsewhere and expose + * the active grid item through `aria-activedescendant`. + */ +export type RovingGridMoveFocus = + | boolean + | ((this: void, target: HTMLElement, event: KeyboardEvent, state: IState) => boolean); + +/** + * Props for {@link RovingGridIndexProvider}. + */ +export interface RovingGridIndexProviderProps extends Omit< + RovingTabIndexProviderProps, + "handleUpDown" | "handleLeftRight" | "onKeyDown" +> { + /** + * Optional callback invoked before grid handling and before the wrapped + * roving provider performs its own keyboard handling. + * + * Call `preventDefault()` on the event to suppress grid and fallback + * roving behaviour. + */ + onKeyDown?(this: void, ev: KeyboardEvent, state: IState, dispatch: Dispatch): void; + /** + * Whether arrow-key grid navigation should move DOM focus. + * + * Defaults to `true`. When `false`, the provider still updates the active + * roving node and tab stop. + */ + moveFocus?: RovingGridMoveFocus; + /** + * Called after grid navigation resolves a target and updates roving state. + */ + onGridNavigation?( + this: void, + event: KeyboardEvent, + target: HTMLElement, + state: IState, + dispatch: Dispatch, + ): void; + /** + * Resolves the grid cell element for a registered roving node. + * + * Override this when the roving node is not a direct child of the grid cell. + */ + getGridCell?: RovingGridCellResolver; + /** + * Resolves the row element for a registered roving node. + * + * Override this when grid rows are not direct parents of grid cells. + */ + getRow?: RovingGridRowResolver; + /** + * Resolves the registered roving node inside a grid cell. + * + * Override this when the focusable element is not the grid cell's first + * child. + */ + getRovingNode?: RovingGridNodeResolver; +} + +const defaultGetGridCell: RovingGridCellResolver = (rovingNode) => rovingNode.parentElement ?? undefined; + +const defaultGetRovingNode: RovingGridNodeResolver = (gridCell) => { + const child = gridCell.children[0]; + return child instanceof HTMLElement ? child : undefined; +}; + +type RovingGridNavigationAction = + | RovingAction.ArrowLeft + | RovingAction.ArrowRight + | RovingAction.ArrowUp + | RovingAction.ArrowDown; + +const isGridNavigationAction = (action: RovingAction | undefined): action is RovingGridNavigationAction => { + return ( + action === RovingAction.ArrowLeft || + action === RovingAction.ArrowRight || + action === RovingAction.ArrowUp || + action === RovingAction.ArrowDown + ); +}; + +const getHorizontalTarget = ( + action: RovingAction, + state: IState, + handleLoop: boolean | undefined, +): HTMLElement | undefined => { + if (!state.activeNode) return undefined; + + const activeIndex = state.nodes.indexOf(state.activeNode); + if (activeIndex === -1) return undefined; + + if (action === RovingAction.ArrowLeft) { + return findPreviousSiblingElement(state.nodes, activeIndex - 1, handleLoop); + } + + if (action === RovingAction.ArrowRight) { + return findNextSiblingElement(state.nodes, activeIndex + 1, handleLoop); + } +}; + +const getVerticalTarget = ( + action: RovingAction, + state: IState, + getGridCell: RovingGridCellResolver, + getRow: RovingGridRowResolver, + getRovingNode: RovingGridNodeResolver, +): HTMLElement | undefined => { + if (!state.activeNode) return undefined; + + const row = getRow(state.activeNode); + const gridCell = getGridCell(state.activeNode); + if (!row || !gridCell) return undefined; + + const columnIndex = Array.from(row.children).indexOf(gridCell); + const activeIndex = state.nodes.indexOf(state.activeNode); + if (columnIndex === -1 || activeIndex === -1) return undefined; + + const nextRowProbeIndex = + action === RovingAction.ArrowUp + ? activeIndex - columnIndex - 1 + : activeIndex - columnIndex + row.children.length; + const nextRow = getRow(state.nodes[nextRowProbeIndex]); + + if (nextRow) { + if (!(nextRow instanceof HTMLElement) || nextRow.offsetParent === null || nextRow.children.length === 0) { + return undefined; + } + + const nextColumnIndex = Math.min(columnIndex, nextRow.children.length - 1); + const target = getRovingNode(nextRow.children[nextColumnIndex]); + if (target?.offsetParent && state.nodes.includes(target)) { + return target; + } + } +}; + +const shouldMoveFocus = ( + moveFocus: RovingGridMoveFocus | undefined, + target: HTMLElement, + event: KeyboardEvent, + state: IState, +): boolean => { + if (typeof moveFocus === "function") return moveFocus(target, event, state); + return moveFocus ?? true; +}; + +/** + * Provides two-dimensional arrow-key navigation for roving tabindex grids. + * + * `RovingGridIndexProvider` reuses the same registration state as + * {@link RovingTabIndexProvider}. Descendants should still call + * {@link useRovingTabIndex}; this provider only changes how arrow keys resolve + * the next active node. + * + * By default, the provider expects each registered roving node to be rendered as + * the first child of a `role="gridcell"` element, and each grid cell to be a + * direct child of a row element. Override `getGridCell`, `getRow`, and + * `getRovingNode` for different markup. + */ +export const RovingGridIndexProvider: React.FC = ({ + children, + getAction, + getGridCell = defaultGetGridCell, + getRow, + getRovingNode = defaultGetRovingNode, + handleInputFields, + handleLoop, + moveFocus, + onGridNavigation, + onKeyDown, + scrollIntoView, + ...props +}) => { + const resolvedGetRow = useCallback( + (rovingNode) => getRow?.(rovingNode) ?? getGridCell(rovingNode)?.parentElement ?? undefined, + [getGridCell, getRow], + ); + + const onGridKeyDown = useCallback( + (event: KeyboardEvent, state: IState, dispatch: Dispatch): void => { + onKeyDown?.(event, state, dispatch); + if (event.defaultPrevented) return; + + const action = getAction?.(event) ?? getDefaultGridAction(event); + if (!isGridNavigationAction(action) || !state.activeNode) return; + + if (!handleInputFields && event.target instanceof HTMLElement && checkInputableElement(event.target)) { + return; + } + + const target = + action === RovingAction.ArrowUp || action === RovingAction.ArrowDown + ? getVerticalTarget(action, state, getGridCell, resolvedGetRow, getRovingNode) + : getHorizontalTarget(action, state, handleLoop); + + event.preventDefault(); + event.stopPropagation(); + + if (!target) return; + + if (shouldMoveFocus(moveFocus, target, event, state)) { + target.focus(); + } + + dispatch({ + type: RovingStateActionType.SetFocus, + payload: { node: target }, + }); + + if (scrollIntoView) { + target.scrollIntoView(scrollIntoView); + } + + onGridNavigation?.(event, target, state, dispatch); + }, + [ + getAction, + getGridCell, + getRovingNode, + handleInputFields, + handleLoop, + moveFocus, + onGridNavigation, + onKeyDown, + resolvedGetRow, + scrollIntoView, + ], + ); + + return ( + + {children} + + ); +}; + +const getDefaultGridAction = (ev: KeyboardEvent): RovingAction | undefined => { + switch (ev.key) { + case "ArrowLeft": + return RovingAction.ArrowLeft; + case "ArrowUp": + return RovingAction.ArrowUp; + case "ArrowRight": + return RovingAction.ArrowRight; + case "ArrowDown": + return RovingAction.ArrowDown; + default: + return undefined; + } +}; diff --git a/packages/shared-components/src/core/roving/RovingTabIndex.stories.tsx b/packages/shared-components/src/core/roving/RovingTabIndex.stories.tsx new file mode 100644 index 0000000000..90721efceb --- /dev/null +++ b/packages/shared-components/src/core/roving/RovingTabIndex.stories.tsx @@ -0,0 +1,361 @@ +/* + * 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, { useState, type JSX } from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { withViewDocs } from "../../../.storybook/withViewDocs"; +import { + checkInputableElement, + findNextSiblingElement, + findPreviousSiblingElement, + type IAction, + type IState, + RovingAction, + RovingStateActionType, + RovingTabIndexProvider, + useRovingTabIndex, +} from "."; + +const toolbarStyle: React.CSSProperties = { + display: "inline-flex", + gap: 4, + padding: 12, + border: "1px solid var(--cpd-color-border-interactive-secondary)", + borderRadius: 8, +}; + +const containerStyle: React.CSSProperties = { + display: "inline-flex", + flexDirection: "column", + gap: 8, + alignItems: "flex-start", +}; + +const verticalToolbarStyle: React.CSSProperties = { + ...toolbarStyle, + flexDirection: "column", +}; + +const buttonStyle: React.CSSProperties = { + minWidth: 72, + height: 36, +}; + +const activeLabelStyle: React.CSSProperties = { + color: "var(--cpd-color-text-secondary)", + font: "var(--cpd-font-body-sm-regular)", +}; + +const toolbarLabelStyle: React.CSSProperties = { + color: "var(--cpd-color-text-secondary)", + font: "var(--cpd-font-body-md-semibold)", +}; + +const getAction = (event: React.KeyboardEvent): RovingAction | undefined => { + switch (event.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; + default: + return undefined; + } +}; + +const getAdjacentNode = (state: IState, backwards: boolean, loop: boolean | undefined): HTMLElement | undefined => { + if (!state.activeNode) return undefined; + + const currentIndex = state.nodes.indexOf(state.activeNode); + if (currentIndex === -1) return undefined; + + const nextIndex = currentIndex + (backwards ? -1 : 1); + return backwards + ? findPreviousSiblingElement(state.nodes, nextIndex, loop) + : findNextSiblingElement(state.nodes, nextIndex, loop); +}; + +const getTargetNode = ( + event: React.KeyboardEvent, + state: IState, + { + handleHomeEnd, + handleLeftRight, + handleLoop, + handleUpDown, + }: Pick< + React.ComponentProps, + "handleHomeEnd" | "handleLeftRight" | "handleLoop" | "handleUpDown" + >, +): HTMLElement | undefined => { + switch (getAction(event)) { + case RovingAction.Home: + return handleHomeEnd ? findNextSiblingElement(state.nodes, 0) : undefined; + case RovingAction.End: + return handleHomeEnd ? findPreviousSiblingElement(state.nodes, state.nodes.length - 1) : undefined; + case RovingAction.ArrowRight: + return handleLeftRight ? getAdjacentNode(state, false, handleLoop) : undefined; + case RovingAction.ArrowDown: + return handleUpDown ? getAdjacentNode(state, false, handleLoop) : undefined; + case RovingAction.ArrowLeft: + return handleLeftRight ? getAdjacentNode(state, true, handleLoop) : undefined; + case RovingAction.ArrowUp: + return handleUpDown ? getAdjacentNode(state, true, handleLoop) : undefined; + default: + return undefined; + } +}; + +function RovingButton({ + label, + onActiveLabelChange, +}: Readonly<{ label: string; onActiveLabelChange?: (label: string) => void }>): JSX.Element { + const [onFocus, isActive, ref] = useRovingTabIndex(); + + return ( + + ); +} + +function MultipleToolbarExample({ + toolbars, + handleHomeEnd, + handleLeftRight, + handleLoop, +}: Readonly<{ + toolbars: Array<{ + label: string; + items: string[]; + }>; + handleHomeEnd?: boolean; + handleLeftRight?: boolean; + handleLoop?: boolean; +}>): JSX.Element { + return ( + + {({ onKeyDownHandler }) => ( +
+ {toolbars.map((toolbar, index) => ( +
+ + {toolbar.label} + +
+ {toolbar.items.map((label) => ( + + ))} +
+
+ ))} +
+ )} +
+ ); +} + +function RovingTabIndexExample({ + labels, + handleHomeEnd, + handleInputFields, + handleLeftRight, + handleLoop, + handleUpDown, + orientation = "horizontal", + withInput, +}: Readonly<{ + labels: string[]; + handleHomeEnd?: boolean; + handleInputFields?: boolean; + handleLeftRight?: boolean; + handleLoop?: boolean; + handleUpDown?: boolean; + orientation?: "horizontal" | "vertical"; + withInput?: boolean; +}>): JSX.Element { + const [activeLabel, setActiveLabel] = useState(labels[0]); + const onKeyDown = (event: React.KeyboardEvent, state: IState, dispatch: React.Dispatch): void => { + const target = getTargetNode(event, state, { handleHomeEnd, handleLeftRight, handleLoop, handleUpDown }); + if (!target) return; + + setActiveLabel(target.textContent ?? ""); + + if (event.target instanceof HTMLElement && withInput && checkInputableElement(event.target)) { + event.preventDefault(); + event.stopPropagation(); + dispatch({ + type: RovingStateActionType.SetFocus, + payload: { + node: target, + }, + }); + } + }; + + return ( + + {({ onKeyDownHandler }) => ( +
+ {withInput && ( + + )} + {withInput && Active item: {activeLabel}} +
+ {labels.map((label) => ( + + ))} +
+
+ )} +
+ ); +} + +const RovingTabIndexWrapper = withViewDocs(RovingTabIndexExample, RovingTabIndexProvider); + +const meta = { + title: "Core/RovingTabIndex", + component: RovingTabIndexWrapper, + tags: ["autodocs", "skip-test"], + args: { + labels: [], + handleHomeEnd: true, + handleLoop: true, + handleLeftRight: true, + handleUpDown: true, + }, + argTypes: { + handleHomeEnd: { + control: "boolean", + }, + handleInputFields: { + control: false, + table: { + disable: true, + }, + }, + handleLeftRight: { + control: "boolean", + }, + handleLoop: { + control: "boolean", + }, + handleUpDown: { + control: "boolean", + }, + orientation: { + control: false, + table: { + disable: true, + }, + }, + withInput: { + control: false, + table: { + disable: true, + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const HorizontalToolbar: Story = { + args: { + labels: ["One", "Two", "Three", "Four"], + handleHomeEnd: true, + handleLeftRight: true, + }, +}; + +export const LoopingToolbar: Story = { + args: { + labels: ["One", "Two", "Three", "Four"], + handleHomeEnd: true, + handleLeftRight: true, + handleLoop: true, + }, +}; + +export const VerticalToolbar: Story = { + args: { + labels: ["One", "Two", "Three", "Four"], + handleHomeEnd: true, + handleLeftRight: false, + handleUpDown: true, + orientation: "vertical", + }, +}; + +export const WithInput: Story = { + args: { + labels: ["One", "Two", "Three", "Four"], + handleHomeEnd: true, + handleLeftRight: true, + handleInputFields: false, + withInput: true, + }, +}; + +export const MultipleToolbars: StoryObj = { + render: (args) => , + args: { + toolbars: [ + { + label: "Formatting toolbar", + items: ["Bold", "Italic", "Underline"], + }, + { + label: "Insert toolbar", + items: ["Link", "Image", "Table"], + }, + ], + handleHomeEnd: true, + handleLeftRight: true, + }, +}; diff --git a/packages/shared-components/src/core/roving/RovingTabIndex.test.tsx b/packages/shared-components/src/core/roving/RovingTabIndex.test.tsx index 82103f4736..48035582ed 100644 --- a/packages/shared-components/src/core/roving/RovingTabIndex.test.tsx +++ b/packages/shared-components/src/core/roving/RovingTabIndex.test.tsx @@ -426,6 +426,72 @@ describe("RovingTabIndex", () => { checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); }); + it("moves between multiple toolbar containers in one roving group", async () => { + const { container } = render( + + {({ onKeyDownHandler }) => ( + <> +
+ + +
+
+ + +
+ + )} +
, + ); + + const buttons = container.querySelectorAll("button"); + act(() => buttons[1].focus()); + checkTabIndexes(buttons, [-1, 0, -1, -1]); + + await userEvent.keyboard("[ArrowRight]"); + expect(buttons[2]).toHaveFocus(); + checkTabIndexes(buttons, [-1, -1, 0, -1]); + + await userEvent.keyboard("[ArrowLeft]"); + expect(buttons[1]).toHaveFocus(); + checkTabIndexes(buttons, [-1, 0, -1, -1]); + }); + + it("handles Home, End, and loop across multiple toolbar containers", async () => { + const { container } = render( + + {({ onKeyDownHandler }) => ( + <> +
+ + +
+
+ + +
+ + )} +
, + ); + + const buttons = container.querySelectorAll("button"); + act(() => buttons[2].focus()); + checkTabIndexes(buttons, [-1, -1, 0, -1]); + + await userEvent.keyboard("[End]"); + expect(buttons[3]).toHaveFocus(); + checkTabIndexes(buttons, [-1, -1, -1, 0]); + + await userEvent.keyboard("[ArrowRight]"); + expect(buttons[0]).toHaveFocus(); + checkTabIndexes(buttons, [0, -1, -1, -1]); + + await userEvent.keyboard("[Home]"); + expect(buttons[0]).toHaveFocus(); + checkTabIndexes(buttons, [0, -1, -1, -1]); + }); + it("handles Home and End when handleHomeEnd=true", async () => { const { container } = renderToolbar( <> diff --git a/packages/shared-components/src/core/roving/index.ts b/packages/shared-components/src/core/roving/index.ts index 3694df73a4..a7a7ecedf6 100644 --- a/packages/shared-components/src/core/roving/index.ts +++ b/packages/shared-components/src/core/roving/index.ts @@ -8,6 +8,7 @@ export { checkInputableElement, findNextSiblingElement, + findPreviousSiblingElement, RovingAction, RovingStateActionType, RovingTabIndexContext, @@ -15,4 +16,12 @@ export { useRovingTabIndex, } from "./RovingTabIndex"; export type { IAction, IContext, IState, RovingTabIndexProviderProps } from "./RovingTabIndex"; +export { RovingGridIndexProvider } from "./RovingGridIndex"; +export type { + RovingGridCellResolver, + RovingGridIndexProviderProps, + RovingGridMoveFocus, + RovingGridNodeResolver, + RovingGridRowResolver, +} from "./RovingGridIndex"; export { RovingTabIndexWrapper } from "./RovingTabIndexWrapper";