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 React from "react";
|
||||||
import {
|
import {
|
||||||
RovingAction,
|
RovingAction,
|
||||||
|
RovingGridIndexProvider as SharedRovingGridIndexProvider,
|
||||||
|
type RovingGridIndexProviderProps,
|
||||||
RovingTabIndexProvider as SharedRovingTabIndexProvider,
|
RovingTabIndexProvider as SharedRovingTabIndexProvider,
|
||||||
type RovingTabIndexProviderProps,
|
type RovingTabIndexProviderProps,
|
||||||
} from "@element-hq/web-shared-components";
|
} 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} />;
|
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
|
// re-export the semantic helper components for simplicity
|
||||||
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
|
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
|
||||||
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
|
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 React, { type Dispatch } from "react";
|
||||||
import { DATA_BY_CATEGORY, getEmojiFromUnicode, type Emoji as IEmoji } from "@matrix-org/emojibase-bindings";
|
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 classNames from "classnames";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
@@ -24,7 +23,7 @@ import { filterBoolean } from "../../../utils/arrays";
|
|||||||
import {
|
import {
|
||||||
type IAction as RovingAction,
|
type IAction as RovingAction,
|
||||||
type IState as RovingState,
|
type IState as RovingState,
|
||||||
RovingTabIndexProvider,
|
RovingGridIndexProvider,
|
||||||
RovingStateActionType,
|
RovingStateActionType,
|
||||||
} from "../../../accessibility/RovingTabIndex";
|
} from "../../../accessibility/RovingTabIndex";
|
||||||
import { Key } from "../../../Keyboard";
|
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
|
// 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;
|
return this.getGridcell(rovingNode)?.parentElement ?? undefined;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Given a roving emoji button returns the role=gridcell element containing it
|
// 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;
|
return rovingNode?.parentElement ?? undefined;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Given a role=gridcell node returns the roving emoji button contained within
|
// Given a role=gridcell node returns the roving emoji button contained within
|
||||||
private getRovingNode(gridcellNode?: Element): Element | undefined {
|
private readonly getRovingNode = (gridcellNode: Element): HTMLElement | undefined => {
|
||||||
return gridcellNode?.children[0];
|
const node = gridcellNode.children[0];
|
||||||
}
|
return node instanceof HTMLElement ? node : undefined;
|
||||||
|
};
|
||||||
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 onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => {
|
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)) {
|
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();
|
ev.stopPropagation();
|
||||||
return;
|
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 {
|
public render(): React.ReactNode {
|
||||||
return (
|
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 }) => {
|
{({ onKeyDownHandler }) => {
|
||||||
let heightBefore = 0;
|
let heightBefore = 0;
|
||||||
return (
|
return (
|
||||||
@@ -440,7 +397,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||||||
</section>
|
</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]);
|
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 () => {
|
it("handles Home and End when handleHomeEnd=true", async () => {
|
||||||
const { container } = renderToolbar(
|
const { container } = renderToolbar(
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
export {
|
export {
|
||||||
checkInputableElement,
|
checkInputableElement,
|
||||||
findNextSiblingElement,
|
findNextSiblingElement,
|
||||||
|
findPreviousSiblingElement,
|
||||||
RovingAction,
|
RovingAction,
|
||||||
RovingStateActionType,
|
RovingStateActionType,
|
||||||
RovingTabIndexContext,
|
RovingTabIndexContext,
|
||||||
@@ -15,4 +16,12 @@ export {
|
|||||||
useRovingTabIndex,
|
useRovingTabIndex,
|
||||||
} from "./RovingTabIndex";
|
} from "./RovingTabIndex";
|
||||||
export type { IAction, IContext, IState, RovingTabIndexProviderProps } 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";
|
export { RovingTabIndexWrapper } from "./RovingTabIndexWrapper";
|
||||||
|
|||||||
Reference in New Issue
Block a user