Extracting RovingGridIndex from EmojiPicker and move to shared components (#33650)

* Add shared roving grid navigation provider

* Add RovingTabIndex stories and update RovingGridIndex stories

* Handle multiple toolbars/grids in on roving group

* Refactor EmojiPicker to use shared roving grid navigation

* Exclude the roving stories from playwright tests

* Fix Sonar issues
This commit is contained in:
rbondesson
2026-05-29 10:54:58 +02:00
committed by GitHub
parent 05e3f1bd6f
commit 7ff2bc3e40
8 changed files with 1372 additions and 78 deletions
@@ -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<RovingTabIndexProviderProps, "getAction">;
type IRovingTabIndexProps = Omit<RovingTabIndexProviderProps, "getAction">;
type IRovingGridIndexProps = Omit<RovingGridIndexProviderProps, "getAction">;
export const RovingTabIndexProvider: React.FC<IProps> = (props) => {
export const RovingTabIndexProvider: React.FC<IRovingTabIndexProps> = (props) => {
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
};
export const RovingGridIndexProvider: React.FC<IRovingGridIndexProps> = (props) => {
return <SharedRovingGridIndexProvider {...props} getAction={getWebRovingAction} />;
};
// re-export the semantic helper components for simplicity
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
@@ -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<IProps, IState> {
};
// 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<RovingAction>): 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<RovingAction>): 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<IProps, IState> {
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<IProps, IState> {
public render(): React.ReactNode {
return (
<RovingTabIndexProvider onKeyDown={this.onKeyDown}>
<RovingGridIndexProvider
getGridCell={this.getGridcell}
getRow={this.getRow}
getRovingNode={this.getRovingNode}
handleInputFields
moveFocus={this.shouldMoveFocus}
onGridNavigation={this.onGridNavigation}
onKeyDown={this.onKeyDown}
>
{({ onKeyDownHandler }) => {
let heightBefore = 0;
return (
@@ -440,7 +397,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
</section>
);
}}
</RovingTabIndexProvider>
</RovingGridIndexProvider>
);
}
}
@@ -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<HTMLButtonElement>();
return (
<button
ref={ref}
id={`grid-button-${label}`}
style={buttonStyle}
tabIndex={isActive ? 0 : -1}
onFocus={onFocus}
>
{label}
</button>
);
}
function GridRows({ rows }: Readonly<{ rows: string[][] }>): JSX.Element {
return (
<>
{rows.map((row) => (
<div role="row" style={rowStyle} key={row.join("|")}>
{row.map((label) => (
<div role="gridcell" key={label}>
<GridButton label={label} />
</div>
))}
</div>
))}
</>
);
}
function GridExample({
rows,
handleHomeEnd,
handleInputFields,
handleLoop,
moveFocus = true,
withInput,
}: Readonly<{
rows: string[][];
handleHomeEnd?: boolean;
handleInputFields?: boolean;
handleLoop?: boolean;
moveFocus?: React.ComponentProps<typeof RovingGridIndexProvider>["moveFocus"];
withInput?: boolean;
}>): JSX.Element {
const [activeLabel, setActiveLabel] = useState(rows[0]?.[0]);
return (
<RovingGridIndexProvider
handleHomeEnd={handleHomeEnd}
handleInputFields={handleInputFields}
handleLoop={handleLoop}
moveFocus={moveFocus}
onGridNavigation={(_event, target) => setActiveLabel(target.textContent ?? "")}
>
{({ onKeyDownHandler }) => (
<div style={containerStyle}>
{withInput && (
<input
aria-label="Search"
aria-activedescendant={activeLabel ? `grid-button-${activeLabel}` : undefined}
onKeyDown={onKeyDownHandler}
placeholder="Focus stays here; use arrow keys"
/>
)}
{withInput && <span style={activeCellLabelStyle}>Active cell: {activeLabel}</span>}
<div
role="grid"
aria-label="Example roving grid"
onKeyDown={onKeyDownHandler}
style={gridStyle}
tabIndex={-1}
>
<GridRows rows={rows} />
</div>
</div>
)}
</RovingGridIndexProvider>
);
}
function MultipleGridExample({
grids,
handleHomeEnd,
handleLoop,
}: Readonly<{
grids: Array<{
label: string;
rows: string[][];
}>;
handleHomeEnd?: boolean;
handleLoop?: boolean;
}>): JSX.Element {
return (
<RovingGridIndexProvider handleHomeEnd={handleHomeEnd} handleLoop={handleLoop}>
{({ onKeyDownHandler }) => (
<div style={containerStyle}>
{grids.map((grid) => (
<section style={containerStyle} key={grid.label}>
<span id={`grid-label-${grid.label}`} style={gridLabelStyle}>
{grid.label}
</span>
<div
role="grid"
aria-labelledby={`grid-label-${grid.label}`}
onKeyDown={onKeyDownHandler}
style={gridStyle}
tabIndex={-1}
>
<GridRows rows={grid.rows} />
</div>
</section>
))}
</div>
)}
</RovingGridIndexProvider>
);
}
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<typeof RovingGridIndexWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
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<typeof MultipleGridExample> = {
render: (args) => <MultipleGridExample {...args} />,
args: {
grids: [
{
label: "Primary grid",
rows: [
["A1", "A2", "A3"],
["B1", "B2", "B3"],
],
},
{
label: "Secondary grid",
rows: [
["C1", "C2"],
["D1", "D2", "D3"],
],
},
],
handleHomeEnd: true,
},
};
@@ -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<HTMLButtonElement>();
return (
<button ref={ref} tabIndex={isActive ? 0 : -1} onFocus={onFocus}>
{label}
</button>
);
}
function DefaultGrid({
rows,
props,
beforeGrid,
}: Readonly<{
rows: string[][];
props?: Partial<React.ComponentProps<typeof RovingGridIndexProvider>>;
beforeGrid?: ReactNode;
}>): React.ReactElement {
return (
<RovingGridIndexProvider {...props}>
{({ onKeyDownHandler }) => (
<div>
{beforeGrid &&
React.isValidElement(beforeGrid) &&
React.cloneElement(beforeGrid, {
onKeyDown: onKeyDownHandler,
} as Partial<React.HTMLAttributes<HTMLElement>>)}
<div role="grid" aria-label="Roving grid" onKeyDown={onKeyDownHandler} tabIndex={-1}>
{rows.map((row, rowIndex) => (
<div role="row" key={rowIndex}>
{row.map((label) => (
<div role="gridcell" key={label}>
<GridButton label={label} />
</div>
))}
</div>
))}
</div>
</div>
)}
</RovingGridIndexProvider>
);
}
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(<DefaultGrid rows={[["A1", "A2", "A3"]]} />);
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(
<DefaultGrid
rows={[
["A1", "A2", "A3"],
["B1", "B2", "B3"],
["C1", "C2", "C3"],
]}
/>,
);
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(
<DefaultGrid
rows={[
["A1", "A2", "A3"],
["B1", "B2"],
]}
/>,
);
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(
<RovingGridIndexProvider>
{({ onKeyDownHandler }) => (
<>
<div role="grid" aria-label="First roving grid" onKeyDown={onKeyDownHandler} tabIndex={-1}>
<div role="row">
{["A1", "A2", "A3"].map((label) => (
<div role="gridcell" key={label}>
<GridButton label={label} />
</div>
))}
</div>
</div>
<div role="grid" aria-label="Second roving grid" onKeyDown={onKeyDownHandler} tabIndex={-1}>
<div role="row">
{["B1", "B2"].map((label) => (
<div role="gridcell" key={label}>
<GridButton label={label} />
</div>
))}
</div>
</div>
</>
)}
</RovingGridIndexProvider>,
);
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(<DefaultGrid rows={[["A1", "A2"]]} />);
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(
<DefaultGrid
rows={[["A1", "A2"]]}
props={{ handleInputFields: true, moveFocus: false }}
beforeGrid={<input aria-label="Search" />}
/>,
);
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(
<DefaultGrid
rows={[["A1", "A2", "A3"]]}
props={{ handleInputFields: true, moveFocus }}
beforeGrid={<input aria-label="Search" />}
/>,
);
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(
<DefaultGrid
rows={[["A1", "A2"]]}
props={{ moveFocus: false }}
beforeGrid={<input aria-label="Search" />}
/>,
);
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(<DefaultGrid rows={[["A1", "A2"]]} props={{ scrollIntoView: { block: "center" } }} />);
await user.tab();
await user.keyboard("{ArrowRight}");
expect(scrollIntoView).toHaveBeenCalledWith({ block: "center" });
});
it("supports custom grid markup resolvers", async () => {
const user = userEvent.setup();
render(
<RovingGridIndexProvider
getGridCell={(node) => node.closest("[data-grid-cell]") ?? undefined}
getRovingNode={(cell) => cell.querySelector("button") ?? undefined}
>
{({ onKeyDownHandler }) => (
<div role="grid" aria-label="Roving grid" onKeyDown={onKeyDownHandler} tabIndex={-1}>
<div role="row">
<div data-grid-cell>
<span>
<GridButton label="A1" />
</span>
</div>
<div data-grid-cell>
<span>
<GridButton label="A2" />
</span>
</div>
</div>
<div role="row">
<div data-grid-cell>
<span>
<GridButton label="B1" />
</span>
</div>
<div data-grid-cell>
<span>
<GridButton label="B2" />
</span>
</div>
</div>
</div>
)}
</RovingGridIndexProvider>,
);
await user.tab();
await user.keyboard("{ArrowDown}");
expect(getButton("B1")).toHaveFocus();
});
});
@@ -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<IAction>): 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<IAction>,
): 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<RovingGridIndexProviderProps> = ({
children,
getAction,
getGridCell = defaultGetGridCell,
getRow,
getRovingNode = defaultGetRovingNode,
handleInputFields,
handleLoop,
moveFocus,
onGridNavigation,
onKeyDown,
scrollIntoView,
...props
}) => {
const resolvedGetRow = useCallback<RovingGridRowResolver>(
(rovingNode) => getRow?.(rovingNode) ?? getGridCell(rovingNode)?.parentElement ?? undefined,
[getGridCell, getRow],
);
const onGridKeyDown = useCallback(
(event: KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): 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 (
<RovingTabIndexProvider
{...props}
getAction={getAction}
handleInputFields={handleInputFields}
handleLoop={handleLoop}
onKeyDown={onGridKeyDown}
scrollIntoView={scrollIntoView}
>
{children}
</RovingTabIndexProvider>
);
};
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;
}
};
@@ -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<typeof RovingTabIndexProvider>,
"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<HTMLButtonElement>();
return (
<button
ref={ref}
style={buttonStyle}
tabIndex={isActive ? 0 : -1}
onFocus={() => {
onFocus();
onActiveLabelChange?.(label);
}}
>
{label}
</button>
);
}
function MultipleToolbarExample({
toolbars,
handleHomeEnd,
handleLeftRight,
handleLoop,
}: Readonly<{
toolbars: Array<{
label: string;
items: string[];
}>;
handleHomeEnd?: boolean;
handleLeftRight?: boolean;
handleLoop?: boolean;
}>): JSX.Element {
return (
<RovingTabIndexProvider handleHomeEnd={handleHomeEnd} handleLeftRight={handleLeftRight} handleLoop={handleLoop}>
{({ onKeyDownHandler }) => (
<div style={containerStyle}>
{toolbars.map((toolbar, index) => (
<section style={containerStyle} key={toolbar.label}>
<span id={`toolbar-label-${index}`} style={toolbarLabelStyle}>
{toolbar.label}
</span>
<div
role="toolbar"
aria-labelledby={`toolbar-label-${index}`}
onKeyDown={onKeyDownHandler}
style={toolbarStyle}
>
{toolbar.items.map((label) => (
<RovingButton key={label} label={label} />
))}
</div>
</section>
))}
</div>
)}
</RovingTabIndexProvider>
);
}
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<IAction>): 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 (
<RovingTabIndexProvider
handleHomeEnd={handleHomeEnd}
handleInputFields={handleInputFields}
handleLeftRight={handleLeftRight}
handleLoop={handleLoop}
handleUpDown={handleUpDown}
onKeyDown={onKeyDown}
>
{({ onKeyDownHandler }) => (
<div style={containerStyle}>
{withInput && (
<input
aria-label="Toolbar input"
onKeyDown={onKeyDownHandler}
placeholder="Focus stays here; use arrow keys"
/>
)}
{withInput && <span style={activeLabelStyle}>Active item: {activeLabel}</span>}
<div
role="toolbar"
aria-label="Example roving toolbar"
aria-orientation={orientation}
onKeyDown={onKeyDownHandler}
style={orientation === "vertical" ? verticalToolbarStyle : toolbarStyle}
>
{labels.map((label) => (
<RovingButton key={label} label={label} onActiveLabelChange={setActiveLabel} />
))}
</div>
</div>
)}
</RovingTabIndexProvider>
);
}
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<typeof RovingTabIndexWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
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<typeof MultipleToolbarExample> = {
render: (args) => <MultipleToolbarExample {...args} />,
args: {
toolbars: [
{
label: "Formatting toolbar",
items: ["Bold", "Italic", "Underline"],
},
{
label: "Insert toolbar",
items: ["Link", "Image", "Table"],
},
],
handleHomeEnd: true,
handleLeftRight: true,
},
};
@@ -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(
<RovingTabIndexProvider handleLeftRight>
{({ onKeyDownHandler }) => (
<>
<div aria-label="Formatting toolbar" onKeyDown={onKeyDownHandler} role="toolbar">
<Button>Bold</Button>
<Button>Italic</Button>
</div>
<div aria-label="Insert toolbar" onKeyDown={onKeyDownHandler} role="toolbar">
<Button>Link</Button>
<Button>Image</Button>
</div>
</>
)}
</RovingTabIndexProvider>,
);
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(
<RovingTabIndexProvider handleHomeEnd handleLeftRight handleLoop>
{({ onKeyDownHandler }) => (
<>
<div aria-label="Formatting toolbar" onKeyDown={onKeyDownHandler} role="toolbar">
<Button>Bold</Button>
<Button>Italic</Button>
</div>
<div aria-label="Insert toolbar" onKeyDown={onKeyDownHandler} role="toolbar">
<Button>Link</Button>
<Button>Image</Button>
</div>
</>
)}
</RovingTabIndexProvider>,
);
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(
<>
@@ -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";