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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user