Move RovingTabIndex to shared component and use it in ActionBarView (#33263)
Build / Build on ubuntu-24.04 (push) Failing after 42s
Build and Deploy develop / Build & Deploy develop.element.io (push) Has been skipped
Deploy documentation / GitHub Pages (push) Failing after 35s
Deploy documentation / deploy (push) Has been skipped
Publish shared component storybook / Build storybook (push) Failing after 3m4s
Publish shared component storybook / Publish storybook (push) Has been skipped
Shared Component Visual Tests / Run Visual Tests (push) Failing after 49s
Static Analysis / Docs (push) Failing after 38s
Static Analysis / ESLint (push) Failing after 28s
Static Analysis / Analyse Dead Code (push) Failing after 44s
Static Analysis / Prettier (push) Failing after 36s
Static Analysis / Style Lint (push) Failing after 38s
Static Analysis / Typescript Syntax Check (push) Failing after 38s
Static Analysis / Workflow Lint (push) Failing after 42s
Static Analysis / Rethemendex Check (push) Failing after 41s
Static Analysis / Zizmor Github Actions lint (push) Failing after 37s
Static Analysis / i18n Check (Element Desktop) (push) Failing after 0s
Static Analysis / i18n Check (Shared Components) (push) Failing after 0s
Static Analysis / i18n Check (Element Web) (push) Failing after 0s
Static Analysis / Static Analysis (push) Successful in 1s
Build / Build on macos-14 (push) Has been cancelled
Build / Build on windows-2022 (push) Has been cancelled

* Create a new shared component and a wrapper in app/web

* Move unit tests and add new for better coverage

* Refactor ActionBarView to use the RovingTabIndexProvider

* Clean up the interface and adjust callers

* Added documentation and renamed type for better readabililty

* Reverting the clean up of IContext

* Fix Sonar issues

* More Sonar issus fixed
This commit is contained in:
rbondesson
2026-04-23 11:33:32 +02:00
committed by GitHub
parent 1a6b0e22a1
commit bb4a7e9613
14 changed files with 1067 additions and 602 deletions
@@ -0,0 +1,565 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type HTMLAttributes } from "react";
import userEvent from "@testing-library/user-event";
import { act, fireEvent, render } from "@test-utils";
import { describe, expect, it, vi } from "vitest";
import {
RovingAction,
RovingStateActionType,
RovingTabIndexProvider,
RovingTabIndexWrapper,
useRovingTabIndex,
} from ".";
import type { IState } from ".";
import { reducer } from "./RovingTabIndex";
const Button = (props: HTMLAttributes<HTMLButtonElement>): React.JSX.Element => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>();
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
};
const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[]): void => {
expect([...buttons].map((b) => b.tabIndex)).toStrictEqual(expectations);
};
const createButtonElement = (text: string): HTMLButtonElement => {
const button = document.createElement("button");
button.textContent = text;
return button;
};
const renderToolbar = (
ui: React.ReactNode,
props: Partial<React.ComponentProps<typeof RovingTabIndexProvider>> = {},
): ReturnType<typeof render> => {
return render(
<RovingTabIndexProvider {...props}>
{({ onKeyDownHandler }) => (
<div aria-label="Roving test container" onKeyDown={onKeyDownHandler} role="toolbar">
{ui}
</div>
)}
</RovingTabIndexProvider>,
);
};
const button1 = <Button key={1}>a</Button>;
const button2 = <Button key={2}>b</Button>;
const button3 = <Button key={3}>c</Button>;
const button4 = <Button key={4}>d</Button>;
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
describe("RovingTabIndex", () => {
it("renders children as expected", () => {
const { container } = render(
<RovingTabIndexProvider>
{() => (
<div>
<span>Test</span>
</div>
)}
</RovingTabIndexProvider>,
);
expect(container.textContent).toBe("Test");
expect(container.innerHTML).toBe("<div><span>Test</span></div>");
});
it("works as expected with useRovingTabIndex", () => {
const { container, rerender } = render(
<RovingTabIndexProvider>
{() => (
<>
{button1}
{button2}
{button3}
</>
)}
</RovingTabIndexProvider>,
);
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
act(() => container.querySelectorAll("button")[2].focus());
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
act(() => container.querySelectorAll("button")[1].focus());
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
act(() => container.querySelectorAll("button")[1].blur());
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
rerender(
<RovingTabIndexProvider>
{() => (
<>
{button1}
{button4}
{button2}
{button3}
</>
)}
</RovingTabIndexProvider>,
);
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0, -1]);
rerender(
<RovingTabIndexProvider>
{() => (
<>
{button1}
{button4}
{button3}
</>
)}
</RovingTabIndexProvider>,
);
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
});
it("provides a ref to the dom element", () => {
const nodeRef = React.createRef<HTMLButtonElement>();
const MyButton = (props: HTMLAttributes<HTMLButtonElement>): React.JSX.Element => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>(nodeRef);
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
};
const { container } = render(
<RovingTabIndexProvider>
{() => (
<>
<MyButton />
</>
)}
</RovingTabIndexProvider>,
);
expect(nodeRef.current).toBe(container.querySelector("button"));
});
it("works as expected with RovingTabIndexWrapper", () => {
const { container } = render(
<RovingTabIndexProvider>
{() => (
<>
{button1}
{button2}
<RovingTabIndexWrapper>
{({ onFocus, isActive, ref }) => (
<button onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref}>
.
</button>
)}
</RovingTabIndexWrapper>
</>
)}
</RovingTabIndexProvider>,
);
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
act(() => container.querySelectorAll("button")[2].focus());
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
});
describe("reducer functions as expected", () => {
it("SetFocus works as expected", () => {
const node1 = createButtonElement("Button 1");
const node2 = createButtonElement("Button 2");
expect(
reducer(
{
activeNode: node1,
nodes: [node1, node2],
},
{
type: RovingStateActionType.SetFocus,
payload: {
node: node2,
},
},
),
).toStrictEqual({
activeNode: node2,
nodes: [node1, node2],
});
});
it("Unregister works as expected", () => {
const unregisterButton1 = createButtonElement("Button 1");
const unregisterButton2 = createButtonElement("Button 2");
const unregisterButton3 = createButtonElement("Button 3");
const unregisterButton4 = createButtonElement("Button 4");
let state: IState = {
nodes: [unregisterButton1, unregisterButton2, unregisterButton3, unregisterButton4],
};
state = reducer(state, {
type: RovingStateActionType.Unregister,
payload: {
node: unregisterButton2,
},
});
expect(state).toStrictEqual({
nodes: [unregisterButton1, unregisterButton3, unregisterButton4],
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
payload: {
node: unregisterButton3,
},
});
expect(state).toStrictEqual({
nodes: [unregisterButton1, unregisterButton4],
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
payload: {
node: unregisterButton4,
},
});
expect(state).toStrictEqual({
nodes: [unregisterButton1],
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
payload: {
node: unregisterButton1,
},
});
expect(state).toStrictEqual({
nodes: [],
});
});
it("Register works as expected", () => {
const ref1 = React.createRef<HTMLElement>();
const ref2 = React.createRef<HTMLElement>();
const ref3 = React.createRef<HTMLElement>();
const ref4 = React.createRef<HTMLElement>();
render(
<>
<span ref={ref1} />
<span ref={ref2} />
<span ref={ref3} />
<span ref={ref4} />
</>,
);
let state: IState = {
nodes: [],
};
state = reducer(state, {
type: RovingStateActionType.Register,
payload: {
node: ref1.current!,
},
});
expect(state).toStrictEqual({
activeNode: ref1.current,
nodes: [ref1.current],
});
state = reducer(state, {
type: RovingStateActionType.Register,
payload: {
node: ref2.current!,
},
});
expect(state).toStrictEqual({
activeNode: ref1.current,
nodes: [ref1.current, ref2.current],
});
state = reducer(state, {
type: RovingStateActionType.Register,
payload: {
node: ref3.current!,
},
});
expect(state).toStrictEqual({
activeNode: ref1.current,
nodes: [ref1.current, ref2.current, ref3.current],
});
state = reducer(state, {
type: RovingStateActionType.Register,
payload: {
node: ref4.current!,
},
});
expect(state).toStrictEqual({
activeNode: ref1.current,
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
state = reducer(state, {
type: RovingStateActionType.SetFocus,
payload: {
node: ref2.current!,
},
});
expect(state).toStrictEqual({
activeNode: ref2.current,
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
payload: {
node: ref2.current!,
},
});
expect(state).toStrictEqual({
activeNode: ref3.current,
nodes: [ref1.current, ref3.current, ref4.current],
});
state = reducer(state, {
type: RovingStateActionType.Register,
payload: {
node: ref2.current!,
},
});
expect(state).toStrictEqual({
activeNode: ref3.current,
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
payload: {
node: ref1.current!,
},
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
payload: {
node: ref4.current!,
},
});
expect(state).toStrictEqual({
activeNode: ref3.current,
nodes: [ref2.current, ref3.current],
});
state = reducer(state, {
type: RovingStateActionType.Register,
payload: {
node: ref1.current!,
},
});
state = reducer(state, {
type: RovingStateActionType.Register,
payload: {
node: ref4.current!,
},
});
expect(state).toStrictEqual({
activeNode: ref3.current,
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
});
});
describe("handles keyboard navigation", () => {
it("handles up/down arrow keys when handleUpDown=true", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true },
);
act(() => container.querySelectorAll("button")[0].focus());
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
await userEvent.keyboard("[ArrowDown]");
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
await userEvent.keyboard("[ArrowDown]");
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
});
it("handles left/right arrow keys when handleLeftRight=true", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleLeftRight: true },
);
act(() => container.querySelectorAll("button")[0].focus());
await userEvent.keyboard("[ArrowRight]");
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
await userEvent.keyboard("[ArrowLeft]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
});
it("handles Home and End when handleHomeEnd=true", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleHomeEnd: true },
);
act(() => container.querySelectorAll("button")[1].focus());
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
await userEvent.keyboard("[End]");
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
await userEvent.keyboard("[Home]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
});
it("loops when handleLoop=true", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true, handleLoop: true },
);
act(() => container.querySelectorAll("button")[2].focus());
await userEvent.keyboard("[ArrowDown]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
});
it("uses a custom getAction mapper", async () => {
const getAction = vi.fn((ev: React.KeyboardEvent): RovingAction | undefined => {
if (ev.key === "j") {
return RovingAction.ArrowDown;
}
return undefined;
});
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true, getAction },
);
act(() => container.querySelectorAll("button")[0].focus());
await userEvent.keyboard("j");
expect(getAction).toHaveBeenCalled();
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
});
it("handles input fields when handleInputFields=true", () => {
const { container, getByRole } = renderToolbar(
<>
{button1}
<input aria-label="Search input" />
{button2}
</>,
{ handleUpDown: true, handleInputFields: true },
);
act(() => container.querySelectorAll("button")[0].focus());
const input = getByRole("textbox", { name: "Search input" });
fireEvent.keyDown(input, { key: "ArrowDown" });
checkTabIndexes(container.querySelectorAll("button"), [-1, 0]);
});
it("moves from an input field with Tab when handleInputFields=false", () => {
const { container, getByRole } = renderToolbar(
<>
{button1}
<input aria-label="Search input" />
{button2}
</>,
);
act(() => container.querySelectorAll("button")[0].focus());
const input = getByRole("textbox", { name: "Search input" });
act(() => (input as HTMLElement).focus());
fireEvent.keyDown(input, { key: "Tab" });
checkTabIndexes(container.querySelectorAll("button"), [-1, 0]);
});
it("stops provider processing when onKeyDown prevents default", () => {
const onKeyDown = vi.fn((event: React.KeyboardEvent): void => {
event.preventDefault();
});
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true, onKeyDown },
);
act(() => container.querySelectorAll("button")[0].focus());
fireEvent.keyDown(container.querySelector('[role="toolbar"]')!, { key: "ArrowDown" });
expect(onKeyDown).toHaveBeenCalled();
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
});
it("calls scrollIntoView if specified", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true, scrollIntoView: true },
);
act(() => container.querySelectorAll("button")[0].focus());
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
const button = container.querySelectorAll("button")[1];
const mock = vi.spyOn(button, "scrollIntoView");
await userEvent.keyboard("[ArrowDown]");
expect(mock).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,611 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useReducer,
type Dispatch,
type KeyboardEvent,
type ReactNode,
type Reducer,
type RefCallback,
type RefObject,
} from "react";
/**
* Returns whether an element should keep native arrow-key behaviour instead of
* being intercepted by roving focus navigation.
*
* This excludes radio buttons and checkboxes, which commonly participate in
* directional navigation patterns.
*
* @param el - The element being evaluated for native input behaviour.
* @returns `true` when the element should keep its own arrow-key handling.
*/
export function checkInputableElement(el: HTMLElement): boolean {
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
}
/**
* The current state of a roving tabindex group.
*/
export interface IState {
/**
* The element that currently owns the active tab stop.
*/
activeNode?: HTMLElement;
/**
* Registered elements in DOM order.
*/
nodes: HTMLElement[];
}
/**
* The value exposed by {@link RovingTabIndexContext}.
*/
export interface IContext {
state: IState;
dispatch: Dispatch<IAction>;
}
/**
* React context used by roving tabindex participants to register themselves and
* update the active item.
*/
export const RovingTabIndexContext = createContext<IContext>({
state: {
nodes: [], // list of nodes in DOM order
},
dispatch: () => {},
});
RovingTabIndexContext.displayName = "RovingTabIndexContext";
/**
* Internal reducer action kinds used by the roving tabindex state machine.
*/
export enum RovingStateActionType {
Register = "REGISTER",
Unregister = "UNREGISTER",
SetFocus = "SET_FOCUS",
Update = "UPDATE",
}
/**
* An action dispatched to the roving tabindex reducer for node registration and
* focus updates.
*/
export interface IAction {
/**
* The reducer action kind.
*/
type: Exclude<RovingStateActionType, RovingStateActionType.Update>;
/**
* Action payload carrying the target node.
*/
payload: {
/**
* The DOM node affected by the action.
*/
node: HTMLElement;
};
}
interface UpdateAction {
type: RovingStateActionType.Update;
payload?: never;
}
type Action = IAction | UpdateAction;
/**
* Normalized navigation intents understood by the shared roving provider.
*/
export enum RovingAction {
Home = "HOME",
End = "END",
ArrowLeft = "ARROW_LEFT",
ArrowUp = "ARROW_UP",
ArrowRight = "ARROW_RIGHT",
ArrowDown = "ARROW_DOWN",
Tab = "TAB",
}
/**
* Props for {@link RovingTabIndexProvider}.
*/
export interface RovingTabIndexProviderProps {
/**
* Whether directional navigation should wrap from the last item to the first
* and vice versa.
*/
handleLoop?: boolean;
/**
* Whether `Home` and `End` should move focus to the first and last item.
*/
handleHomeEnd?: boolean;
/**
* Whether vertical arrow keys should move focus within the group.
*/
handleUpDown?: boolean;
/**
* Whether horizontal arrow keys should move focus within the group.
*/
handleLeftRight?: boolean;
/**
* Whether text inputs and similar controls should participate in roving
* keyboard handling instead of keeping their native arrow-key behaviour.
*/
handleInputFields?: boolean;
/**
* Whether newly focused items should be scrolled into view.
*
* Pass `true` to use the browser default, or a scroll options object to
* control alignment and behaviour.
*/
scrollIntoView?: boolean | ScrollIntoViewOptions;
/**
* Render prop receiving keyboard and drag-end handlers for the roving
* container.
*/
children(
this: void,
renderProps: {
/**
* Handles keyboard navigation for the roving container.
*/
onKeyDownHandler(this: void, ev: KeyboardEvent): void;
/**
* Re-sorts registered elements after DOM reordering, such as drag and
* drop.
*/
onDragEndHandler(this: void): void;
},
): ReactNode;
/**
* Optional callback invoked before the provider performs its own keyboard
* handling.
*
* Call `preventDefault()` on the event to suppress the built-in behaviour.
*/
onKeyDown?(this: void, ev: KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
/**
* Optional action resolver used to map keyboard events to
* {@link RovingAction} values.
*
* When omitted, a default mapping based on `KeyboardEvent.key` is used.
*/
getAction?(this: void, ev: KeyboardEvent): RovingAction | undefined;
}
const nodeSorter = (a: HTMLElement, b: HTMLElement): number => {
if (a === b) {
return 0;
}
const position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
return -1;
} else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
return 1;
} else {
return 0;
}
};
const getReplacementActiveNode = (nodes: HTMLElement[], removedIndex: number): HTMLElement | undefined => {
if (removedIndex >= nodes.length) {
return findPreviousSiblingElement(nodes, nodes.length - 1);
}
return findNextSiblingElement(nodes, removedIndex) || findPreviousSiblingElement(nodes, removedIndex);
};
const handleRemovedActiveNode = (state: IState, removedIndex: number): void => {
state.activeNode = getReplacementActiveNode(state.nodes, removedIndex);
if (document.activeElement === document.body) {
// if the focus got reverted to the body then the user was likely focused on the unmounted element
setTimeout(() => state.activeNode?.focus(), 0);
}
};
/**
* Reducer that tracks registered nodes and the currently active roving tab
* stop.
*/
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
switch (action.type) {
case RovingStateActionType.Register: {
// Our list of nodes was empty, set activeNode to this first item
state.activeNode ??= action.payload.node;
if (state.nodes.includes(action.payload.node)) return state;
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
state.nodes.push(action.payload.node);
state.nodes.sort(nodeSorter);
return { ...state };
}
case RovingStateActionType.Unregister: {
const oldIndex = state.nodes.indexOf(action.payload.node);
if (oldIndex === -1) {
return state; // already removed, this should not happen
}
if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
handleRemovedActiveNode(state, oldIndex);
}
return { ...state };
}
case RovingStateActionType.SetFocus: {
if (state.activeNode === action.payload.node) return state;
state.activeNode = action.payload.node;
return { ...state };
}
case RovingStateActionType.Update: {
state.nodes.sort(nodeSorter);
return { ...state };
}
default:
return state;
}
};
const findSiblingElementInRange = (
nodes: HTMLElement[],
startIndex: number,
endIndex: number,
step: 1 | -1,
): HTMLElement | undefined => {
if (step === 1) {
for (let i = startIndex; i < endIndex; i += step) {
if (nodes[i]?.offsetParent !== null) {
return nodes[i];
}
}
} else {
for (let i = startIndex; i > endIndex; i += step) {
if (nodes[i]?.offsetParent !== null) {
return nodes[i];
}
}
}
};
/**
* Finds the next visible sibling element starting from a given index.
*
* @param nodes - Registered roving nodes in DOM order.
* @param startIndex - The index to begin searching from.
* @param loop - Whether to wrap around when no visible sibling is found.
* @returns The next visible sibling element, if one exists.
*/
export const findNextSiblingElement = (
nodes: HTMLElement[],
startIndex: number,
loop = false,
): HTMLElement | undefined => {
const sibling = findSiblingElementInRange(nodes, startIndex, nodes.length, 1);
if (sibling || !loop) {
return sibling;
}
return findSiblingElementInRange(nodes.slice(0, startIndex), 0, startIndex, 1);
};
/**
* Finds the previous visible sibling element starting from a given index.
*
* @param nodes - Registered roving nodes in DOM order.
* @param startIndex - The index to begin searching from.
* @param loop - Whether to wrap around when no visible sibling is found.
* @returns The previous visible sibling element, if one exists.
*/
export const findPreviousSiblingElement = (
nodes: HTMLElement[],
startIndex: number,
loop = false,
): HTMLElement | undefined => {
const sibling = findSiblingElementInRange(nodes, startIndex, -1, -1);
if (sibling || !loop) {
return sibling;
}
const loopNodes = nodes.slice(startIndex + 1);
return findSiblingElementInRange(loopNodes, loopNodes.length - 1, -1, -1);
};
const getDefaultAction = (ev: KeyboardEvent): RovingAction | undefined => {
switch (ev.key) {
case "Home":
return RovingAction.Home;
case "End":
return RovingAction.End;
case "ArrowLeft":
return RovingAction.ArrowLeft;
case "ArrowUp":
return RovingAction.ArrowUp;
case "ArrowRight":
return RovingAction.ArrowRight;
case "ArrowDown":
return RovingAction.ArrowDown;
case "Tab":
return RovingAction.Tab;
default:
return undefined;
}
};
interface NavigationResult {
handled: boolean;
focusNode?: HTMLElement;
}
interface StandardNavigationConfig {
enabled: boolean;
getFocusNode(state: IState): HTMLElement | undefined;
}
const getAdjacentFocusNode = (
nodes: HTMLElement[],
activeNode: HTMLElement | undefined,
backwards: boolean,
loop = false,
): HTMLElement | undefined => {
if (nodes.length === 0 || !activeNode) {
return undefined;
}
const currentIndex = nodes.indexOf(activeNode);
const nextIndex = currentIndex + (backwards ? -1 : 1);
return backwards
? findPreviousSiblingElement(nodes, nextIndex, loop)
: findNextSiblingElement(nodes, nextIndex, loop);
};
const getInputNavigationResult = (
action: RovingAction | undefined,
nodes: HTMLElement[],
activeNode: HTMLElement | undefined,
shiftKey: boolean,
): NavigationResult => {
if (action !== RovingAction.Tab) {
return { handled: false };
}
return {
handled: true,
focusNode: getAdjacentFocusNode(nodes, activeNode, shiftKey),
};
};
const buildStandardNavigationConfig = (
state: IState,
handleHomeEnd: boolean,
handleUpDown: boolean,
handleLeftRight: boolean,
handleLoop: boolean,
): Record<RovingAction, StandardNavigationConfig> => ({
[RovingAction.Home]: {
enabled: handleHomeEnd,
getFocusNode: (currentState) => findNextSiblingElement(currentState.nodes, 0),
},
[RovingAction.End]: {
enabled: handleHomeEnd,
getFocusNode: (currentState) => findPreviousSiblingElement(currentState.nodes, currentState.nodes.length - 1),
},
[RovingAction.ArrowDown]: {
enabled: handleUpDown,
getFocusNode: (currentState) =>
getAdjacentFocusNode(currentState.nodes, currentState.activeNode, false, handleLoop),
},
[RovingAction.ArrowRight]: {
enabled: handleLeftRight,
getFocusNode: (currentState) =>
getAdjacentFocusNode(currentState.nodes, currentState.activeNode, false, handleLoop),
},
[RovingAction.ArrowUp]: {
enabled: handleUpDown,
getFocusNode: (currentState) =>
getAdjacentFocusNode(currentState.nodes, currentState.activeNode, true, handleLoop),
},
[RovingAction.ArrowLeft]: {
enabled: handleLeftRight,
getFocusNode: (currentState) =>
getAdjacentFocusNode(currentState.nodes, currentState.activeNode, true, handleLoop),
},
[RovingAction.Tab]: {
enabled: false,
getFocusNode: () => undefined,
},
});
const getStandardNavigationResult = (
action: RovingAction | undefined,
state: IState,
handleHomeEnd: boolean,
handleUpDown: boolean,
handleLeftRight: boolean,
handleLoop: boolean,
): NavigationResult => {
if (!action) {
return { handled: false };
}
const config = buildStandardNavigationConfig(state, handleHomeEnd, handleUpDown, handleLeftRight, handleLoop)[
action
];
if (!config?.enabled) {
return { handled: false };
}
return {
handled: true,
focusNode: config.getFocusNode(state),
};
};
/**
* Provides shared roving tabindex state and keyboard handling for a group of
* focusable descendants.
*/
export const RovingTabIndexProvider: React.FC<RovingTabIndexProviderProps> = ({
children,
handleHomeEnd,
handleUpDown,
handleLeftRight,
handleLoop,
handleInputFields,
scrollIntoView,
onKeyDown,
getAction = getDefaultAction,
}) => {
const [state, dispatch] = useReducer(reducer, {
nodes: [],
});
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
const onKeyDownHandler = useCallback(
(ev: KeyboardEvent) => {
if (onKeyDown) {
onKeyDown(ev, context.state, context.dispatch);
if (ev.defaultPrevented) {
return;
}
}
const action = getAction(ev);
// Don't interfere with input default keydown behaviour
// but allow people to move focus from it with Tab.
const isInputTarget = !handleInputFields && checkInputableElement(ev.target as HTMLElement);
const { handled, focusNode } = isInputTarget
? getInputNavigationResult(action, context.state.nodes, context.state.activeNode, ev.shiftKey)
: getStandardNavigationResult(
action,
context.state,
handleHomeEnd ?? false,
handleUpDown ?? false,
handleLeftRight ?? false,
handleLoop ?? false,
);
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
if (focusNode) {
focusNode.focus();
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
dispatch({
type: RovingStateActionType.SetFocus,
payload: {
node: focusNode,
},
});
if (scrollIntoView) {
focusNode.scrollIntoView(scrollIntoView);
}
}
},
[
context,
getAction,
onKeyDown,
handleHomeEnd,
handleUpDown,
handleLeftRight,
handleLoop,
handleInputFields,
scrollIntoView,
],
);
const onDragEndHandler = useCallback(() => {
dispatch({
type: RovingStateActionType.Update,
});
}, []);
return (
<RovingTabIndexContext.Provider value={context}>
{children({ onKeyDownHandler, onDragEndHandler })}
</RovingTabIndexContext.Provider>
);
};
/**
* Registers a focusable element with the nearest
* {@link RovingTabIndexContext}.
*
* @param inputRef - Optional ref to reuse for the registered DOM node.
* @returns A tuple containing:
* `onFocus` to mark the item active,
* `isActive` to drive `tabIndex`,
* `ref` to register the DOM node,
* and `nodeRef` pointing at the registered node.
*/
export const useRovingTabIndex = <T extends HTMLElement>(
inputRef?: RefObject<T | null>,
): [() => void, boolean, RefCallback<T>, RefObject<T | null>] => {
const context = useContext(RovingTabIndexContext);
let nodeRef = useRef<T | null>(null);
if (inputRef) {
// if we are given a ref, use it instead of ours
nodeRef = inputRef;
}
const ref = useCallback((node: T | null) => {
if (node) {
nodeRef.current = node;
context.dispatch({
type: RovingStateActionType.Register,
payload: { node },
});
} else {
context.dispatch({
type: RovingStateActionType.Unregister,
payload: { node: nodeRef.current! },
});
nodeRef.current = null;
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onFocus = useCallback(() => {
if (!nodeRef.current) {
console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!");
return;
}
context.dispatch({
type: RovingStateActionType.SetFocus,
payload: { node: nodeRef.current },
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-compiler/react-compiler
const isActive = context.state.activeNode === nodeRef.current;
return [onFocus, isActive, ref, nodeRef];
};
@@ -0,0 +1,32 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type ReactElement, type RefCallback, type RefObject } from "react";
import type React from "react";
import { useRovingTabIndex } from "./RovingTabIndex";
interface IProps {
inputRef?: RefObject<HTMLElement | null>;
children(
this: void,
renderProps: {
onFocus: () => void;
isActive: boolean;
ref: RefCallback<HTMLElement>;
},
): ReactElement;
}
/**
* Render-prop wrapper around {@link useRovingTabIndex} for class components and
* other places where hooks cannot be called directly.
*/
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({ onFocus, isActive, ref });
};
@@ -0,0 +1,18 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export {
checkInputableElement,
findNextSiblingElement,
RovingAction,
RovingStateActionType,
RovingTabIndexContext,
RovingTabIndexProvider,
useRovingTabIndex,
} from "./RovingTabIndex";
export type { IAction, IContext, IState, RovingTabIndexProviderProps } from "./RovingTabIndex";
export { RovingTabIndexWrapper } from "./RovingTabIndexWrapper";
+1
View File
@@ -10,6 +10,7 @@ export * from "./audio/Clock";
export * from "./audio/PlayPauseButton";
export * from "./audio/SeekBar";
export * from "./core/AvatarWithDetails";
export * from "./core/roving";
export * from "./room/composer/Banner";
export * from "./crypto/SasEmoji";
export * from "./room/timeline/ReadMarker";
@@ -5,9 +5,11 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import React, { type JSX, useLayoutEffect, useRef } from "react";
import { useMergeRefs } from "react-merge-refs";
import { Button, Tooltip } from "@vector-im/compound-web";
import { useRovingTabIndex } from "../../../../../core/roving";
import styles from "./ActionBarView.module.css";
interface ActionBarButtonProps {
@@ -36,6 +38,16 @@ export function ActionBarButton({
tooltipCaption,
}: Readonly<ActionBarButtonProps>): JSX.Element {
const iconOnly = presentation === "icon";
const [onFocus, isActive, rovingRef] = useRovingTabIndex<HTMLButtonElement>();
const localRef = useRef<HTMLButtonElement | null>(null);
const ref = useMergeRefs([buttonRef, localRef, disabled ? null : rovingRef]);
const tabIndex = disabled || !isActive ? -1 : 0;
useLayoutEffect(() => {
if (!localRef.current) return;
localRef.current.tabIndex = tabIndex;
}, [tabIndex]);
const handleContextMenu = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
@@ -47,7 +59,7 @@ export function ActionBarButton({
<Tooltip description={tooltipDescription ?? label} caption={tooltipCaption} placement="top">
<Button
data-presentation={presentation}
ref={buttonRef}
ref={ref}
kind="tertiary"
size="sm"
iconOnly={iconOnly}
@@ -57,6 +69,7 @@ export function ActionBarButton({
disabled={disabled}
onClick={(event) => onActivate?.(event.currentTarget)}
onContextMenu={handleContextMenu}
onFocus={disabled ? undefined : onFocus}
className={styles.toolbar_item}
Icon={iconOnly ? icon : undefined}
>
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import React, { type JSX, useCallback, useMemo, useRef } from "react";
import classNames from "classnames";
import {
CollapseIcon,
@@ -27,6 +27,7 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { InlineSpinner } from "@vector-im/compound-web";
import { RovingTabIndexProvider } from "../../../../../core/roving";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import { Flex } from "../../../../../core/utils/Flex";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
@@ -131,7 +132,6 @@ interface ActionBarViewProps {
*/
export function ActionBarView({ vm, className }: Readonly<ActionBarViewProps>): JSX.Element | null {
const { translate: _t } = useI18n();
const [activeIndex, setActiveIndex] = useState(0);
const {
actions,
presentation = "icon",
@@ -364,79 +364,9 @@ export function ActionBarView({ vm, className }: Readonly<ActionBarViewProps>):
disabled: isActionDisabled(action),
}));
}, [actions, isActionDisabled]);
// Handle RovingIndex for toolbar
const enabledIndices = toolbarButtons
.map((item, index) => (item.disabled ? -1 : index))
.filter((index) => index >= 0);
const fallbackIndex = enabledIndices[0] ?? 0;
const currentIndex =
toolbarButtons[activeIndex] && !toolbarButtons[activeIndex].disabled ? activeIndex : fallbackIndex;
useLayoutEffect(() => {
setActiveIndex(currentIndex);
toolbarButtons.forEach(({ action }, index) => {
const button = actionButtonRefs.current[action] ?? null;
if (button) {
button.tabIndex = index === currentIndex ? 0 : -1;
}
});
}, [currentIndex, toolbarButtons]);
const focusButtonAtIndex = (index: number): void => {
const action = toolbarButtons[index]?.action;
const button = action ? (actionButtonRefs.current[action] ?? null) : null;
if (!button) {
return;
}
setActiveIndex(index);
button.focus();
};
const handleToolbarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
if (enabledIndices.length === 0) {
return;
}
const focusedIndex = toolbarButtons.findIndex(
({ action }) => actionButtonRefs.current[action] === document.activeElement,
);
const startIndex = focusedIndex >= 0 ? focusedIndex : currentIndex;
switch (event.key) {
case "ArrowRight": {
event.preventDefault();
const nextIndex = enabledIndices.find((index) => index > startIndex) ?? enabledIndices[0];
focusButtonAtIndex(nextIndex);
break;
}
case "ArrowLeft": {
event.preventDefault();
const previousIndex = [...enabledIndices].reverse().find((index) => index < startIndex);
focusButtonAtIndex(previousIndex ?? enabledIndices[enabledIndices.length - 1]);
break;
}
case "Home":
event.preventDefault();
focusButtonAtIndex(enabledIndices[0]);
break;
case "End":
event.preventDefault();
focusButtonAtIndex(enabledIndices[enabledIndices.length - 1]);
break;
}
};
const handleToolbarFocusCapture = (): void => {
const focusedIndex = toolbarButtons.findIndex(
({ action }) => actionButtonRefs.current[action] === document.activeElement,
);
if (focusedIndex >= 0 && focusedIndex !== activeIndex) {
setActiveIndex(focusedIndex);
}
};
const rovingProviderKey = toolbarButtons
.map(({ action, disabled }) => `${action}:${disabled ? "1" : "0"}`)
.join("|");
if (toolbarButtons.length === 0) {
return null;
@@ -444,17 +374,20 @@ export function ActionBarView({ vm, className }: Readonly<ActionBarViewProps>):
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return (
<Flex
display="inline-flex"
direction="row"
role="toolbar"
aria-label={_t("timeline|mab|label")}
aria-live="off"
onKeyDown={handleToolbarKeyDown}
onFocusCapture={handleToolbarFocusCapture}
className={classNames(className, styles.toolbar)}
>
{toolbarButtons.map((meta) => actionButtons[meta.action])}
</Flex>
<RovingTabIndexProvider key={rovingProviderKey} handleLeftRight handleHomeEnd handleLoop>
{({ onKeyDownHandler }) => (
<Flex
display="inline-flex"
direction="row"
role="toolbar"
aria-label={_t("timeline|mab|label")}
aria-live="off"
onKeyDown={onKeyDownHandler}
className={classNames(className, styles.toolbar)}
>
{toolbarButtons.map((meta) => actionButtons[meta.action])}
</Flex>
)}
</RovingTabIndexProvider>
);
}