Move RovingTabIndex to shared component and use it in ActionBarView (#33263)
* 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:
@@ -6,23 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {
|
import React from "react";
|
||||||
createContext,
|
import {
|
||||||
useCallback,
|
RovingAction,
|
||||||
useContext,
|
RovingTabIndexProvider as SharedRovingTabIndexProvider,
|
||||||
useMemo,
|
type RovingTabIndexProviderProps,
|
||||||
useRef,
|
} from "@element-hq/web-shared-components";
|
||||||
useReducer,
|
|
||||||
type Reducer,
|
|
||||||
type Dispatch,
|
|
||||||
type RefObject,
|
|
||||||
type ReactNode,
|
|
||||||
type RefCallback,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
||||||
import { KeyBindingAction } from "./KeyboardShortcuts";
|
import { KeyBindingAction } from "./KeyboardShortcuts";
|
||||||
import { type FocusHandler } from "./roving/types";
|
|
||||||
|
export { findNextSiblingElement, RovingTabIndexContext } from "@element-hq/web-shared-components";
|
||||||
|
export { checkInputableElement } from "@element-hq/web-shared-components";
|
||||||
|
export { RovingStateActionType } from "@element-hq/web-shared-components";
|
||||||
|
export { useRovingTabIndex } from "@element-hq/web-shared-components";
|
||||||
|
export type { IAction, IState } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module to simplify implementing the Roving TabIndex accessibility technique
|
* Module to simplify implementing the Roving TabIndex accessibility technique
|
||||||
@@ -37,370 +35,31 @@ import { type FocusHandler } from "./roving/types";
|
|||||||
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
|
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Check for form elements which utilize the arrow keys for native functions
|
const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => {
|
||||||
// like many of the text input varieties.
|
switch (getKeyBindingsManager().getAccessibilityAction(ev)) {
|
||||||
//
|
case KeyBindingAction.Home:
|
||||||
// i.e. it's ok to press the down arrow on a radio button to move to the next
|
return RovingAction.Home;
|
||||||
// radio. But it's not ok to press the down arrow on a <input type="text"> to
|
case KeyBindingAction.End:
|
||||||
// move away because the down arrow should move the cursor to the end of the
|
return RovingAction.End;
|
||||||
// input.
|
case KeyBindingAction.ArrowLeft:
|
||||||
export function checkInputableElement(el: HTMLElement): boolean {
|
return RovingAction.ArrowLeft;
|
||||||
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
|
case KeyBindingAction.ArrowUp:
|
||||||
}
|
return RovingAction.ArrowUp;
|
||||||
|
case KeyBindingAction.ArrowRight:
|
||||||
export interface IState {
|
return RovingAction.ArrowRight;
|
||||||
activeNode?: HTMLElement;
|
case KeyBindingAction.ArrowDown:
|
||||||
nodes: HTMLElement[];
|
return RovingAction.ArrowDown;
|
||||||
}
|
case KeyBindingAction.Tab:
|
||||||
|
return RovingAction.Tab;
|
||||||
export interface IContext {
|
|
||||||
state: IState;
|
|
||||||
dispatch: Dispatch<IAction>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RovingTabIndexContext = createContext<IContext>({
|
|
||||||
state: {
|
|
||||||
nodes: [], // list of nodes in DOM order
|
|
||||||
},
|
|
||||||
dispatch: () => {},
|
|
||||||
});
|
|
||||||
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
|
||||||
|
|
||||||
export enum Type {
|
|
||||||
Register = "REGISTER",
|
|
||||||
Unregister = "UNREGISTER",
|
|
||||||
SetFocus = "SET_FOCUS",
|
|
||||||
Update = "UPDATE",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IAction {
|
|
||||||
type: Exclude<Type, Type.Update>;
|
|
||||||
payload: {
|
|
||||||
node: HTMLElement;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdateAction {
|
|
||||||
type: Type.Update;
|
|
||||||
payload?: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Action = IAction | UpdateAction;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case Type.Register: {
|
|
||||||
if (!state.activeNode) {
|
|
||||||
// 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 Type.Unregister: {
|
|
||||||
const oldIndex = state.nodes.findIndex((r) => r === action.payload.node);
|
|
||||||
|
|
||||||
if (oldIndex === -1) {
|
|
||||||
return state; // already removed, this should not happen
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
|
|
||||||
// we just removed the active node, need to replace it
|
|
||||||
// pick the node closest to the index the old node was in
|
|
||||||
if (oldIndex >= state.nodes.length) {
|
|
||||||
state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true);
|
|
||||||
} else {
|
|
||||||
state.activeNode =
|
|
||||||
findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update the nodes list
|
|
||||||
return { ...state };
|
|
||||||
}
|
|
||||||
|
|
||||||
case Type.SetFocus: {
|
|
||||||
// if the node doesn't change just return the same object reference to skip a re-render
|
|
||||||
if (state.activeNode === action.payload.node) return state;
|
|
||||||
// update active node
|
|
||||||
state.activeNode = action.payload.node;
|
|
||||||
return { ...state };
|
|
||||||
}
|
|
||||||
|
|
||||||
case Type.Update: {
|
|
||||||
state.nodes.sort(nodeSorter);
|
|
||||||
return { ...state };
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IProps {
|
type IProps = Omit<RovingTabIndexProviderProps, "getAction">;
|
||||||
handleLoop?: boolean;
|
|
||||||
handleHomeEnd?: boolean;
|
|
||||||
handleUpDown?: boolean;
|
|
||||||
handleLeftRight?: boolean;
|
|
||||||
handleInputFields?: boolean;
|
|
||||||
scrollIntoView?: boolean | ScrollIntoViewOptions;
|
|
||||||
children(
|
|
||||||
this: void,
|
|
||||||
renderProps: {
|
|
||||||
onKeyDownHandler(this: void, ev: React.KeyboardEvent): void;
|
|
||||||
onDragEndHandler(this: void): void;
|
|
||||||
},
|
|
||||||
): ReactNode;
|
|
||||||
onKeyDown?(this: void, ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const findSiblingElement = (
|
export const RovingTabIndexProvider: React.FC<IProps> = (props) => {
|
||||||
nodes: HTMLElement[],
|
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
|
||||||
startIndex: number,
|
|
||||||
backwards = false,
|
|
||||||
loop = false,
|
|
||||||
): HTMLElement | undefined => {
|
|
||||||
if (backwards) {
|
|
||||||
for (let i = startIndex; i < nodes.length && i >= 0; i--) {
|
|
||||||
if (nodes[i]?.offsetParent !== null) {
|
|
||||||
return nodes[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (loop) {
|
|
||||||
return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = startIndex; i < nodes.length && i >= 0; i++) {
|
|
||||||
if (nodes[i]?.offsetParent !== null) {
|
|
||||||
return nodes[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (loop) {
|
|
||||||
return findSiblingElement(nodes.slice(0, startIndex), 0, false, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RovingTabIndexProvider: React.FC<IProps> = ({
|
|
||||||
children,
|
|
||||||
handleHomeEnd,
|
|
||||||
handleUpDown,
|
|
||||||
handleLeftRight,
|
|
||||||
handleLoop,
|
|
||||||
handleInputFields,
|
|
||||||
scrollIntoView,
|
|
||||||
onKeyDown,
|
|
||||||
}) => {
|
|
||||||
const [state, dispatch] = useReducer<IState, [Action]>(reducer, {
|
|
||||||
nodes: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
|
||||||
|
|
||||||
const onKeyDownHandler = useCallback(
|
|
||||||
(ev: React.KeyboardEvent) => {
|
|
||||||
if (onKeyDown) {
|
|
||||||
onKeyDown(ev, context.state, context.dispatch);
|
|
||||||
if (ev.defaultPrevented) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let handled = false;
|
|
||||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
|
||||||
let focusNode: HTMLElement | undefined;
|
|
||||||
// Don't interfere with input default keydown behaviour
|
|
||||||
// but allow people to move focus from it with Tab.
|
|
||||||
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
|
|
||||||
switch (action) {
|
|
||||||
case KeyBindingAction.Tab:
|
|
||||||
handled = true;
|
|
||||||
if (context.state.nodes.length > 0) {
|
|
||||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
|
||||||
focusNode = findSiblingElement(
|
|
||||||
context.state.nodes,
|
|
||||||
idx + (ev.shiftKey ? -1 : 1),
|
|
||||||
ev.shiftKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// check if we actually have any items
|
|
||||||
switch (action) {
|
|
||||||
case KeyBindingAction.Home:
|
|
||||||
if (handleHomeEnd) {
|
|
||||||
handled = true;
|
|
||||||
// move focus to first (visible) item
|
|
||||||
focusNode = findSiblingElement(context.state.nodes, 0);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case KeyBindingAction.End:
|
|
||||||
if (handleHomeEnd) {
|
|
||||||
handled = true;
|
|
||||||
// move focus to last (visible) item
|
|
||||||
focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case KeyBindingAction.ArrowDown:
|
|
||||||
case KeyBindingAction.ArrowRight:
|
|
||||||
if (
|
|
||||||
(action === KeyBindingAction.ArrowDown && handleUpDown) ||
|
|
||||||
(action === KeyBindingAction.ArrowRight && handleLeftRight)
|
|
||||||
) {
|
|
||||||
handled = true;
|
|
||||||
if (context.state.nodes.length > 0) {
|
|
||||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
|
||||||
focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case KeyBindingAction.ArrowUp:
|
|
||||||
case KeyBindingAction.ArrowLeft:
|
|
||||||
if (
|
|
||||||
(action === KeyBindingAction.ArrowUp && handleUpDown) ||
|
|
||||||
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
|
|
||||||
) {
|
|
||||||
handled = true;
|
|
||||||
if (context.state.nodes.length > 0) {
|
|
||||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
|
||||||
focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: Type.SetFocus,
|
|
||||||
payload: {
|
|
||||||
node: focusNode,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (scrollIntoView) {
|
|
||||||
focusNode?.scrollIntoView(scrollIntoView);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
context,
|
|
||||||
onKeyDown,
|
|
||||||
handleHomeEnd,
|
|
||||||
handleUpDown,
|
|
||||||
handleLeftRight,
|
|
||||||
handleLoop,
|
|
||||||
handleInputFields,
|
|
||||||
scrollIntoView,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDragEndHandler = useCallback(() => {
|
|
||||||
dispatch({
|
|
||||||
type: Type.Update,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RovingTabIndexContext.Provider value={context}>
|
|
||||||
{children({ onKeyDownHandler, onDragEndHandler })}
|
|
||||||
</RovingTabIndexContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to register a roving tab index.
|
|
||||||
*
|
|
||||||
* inputRef is an optional argument; when passed this ref points to the DOM element
|
|
||||||
* to which the callback ref is attached.
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* onFocus should be called when the index gained focus in any manner.
|
|
||||||
* isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`.
|
|
||||||
* ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition.
|
|
||||||
* nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached.
|
|
||||||
*
|
|
||||||
* nodeRef = inputRef when inputRef argument is provided.
|
|
||||||
*/
|
|
||||||
export const useRovingTabIndex = <T extends HTMLElement>(
|
|
||||||
inputRef?: RefObject<T | null>,
|
|
||||||
): [FocusHandler, 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: Type.Register,
|
|
||||||
payload: { node },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
context.dispatch({
|
|
||||||
type: Type.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: Type.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];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// re-export the semantic helper components for simplicity
|
// re-export the semantic helper components for simplicity
|
||||||
|
|||||||
@@ -6,26 +6,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ReactElement, type RefCallback, type RefObject } from "react";
|
export { RovingTabIndexWrapper } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import type React from "react";
|
|
||||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
|
||||||
import { type FocusHandler } from "./types";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
inputRef?: RefObject<HTMLElement | null>;
|
|
||||||
children(
|
|
||||||
this: void,
|
|
||||||
renderProps: {
|
|
||||||
onFocus: FocusHandler;
|
|
||||||
isActive: boolean;
|
|
||||||
ref: RefCallback<HTMLElement>;
|
|
||||||
},
|
|
||||||
): ReactElement<any, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
|
|
||||||
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
|
|
||||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
|
||||||
return children({ onFocus, isActive, ref });
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
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 type FocusHandler = () => void;
|
|
||||||
@@ -50,9 +50,9 @@ import { RoomContextDetails } from "../rooms/RoomContextDetails";
|
|||||||
import { filterBoolean } from "../../../utils/arrays";
|
import { filterBoolean } from "../../../utils/arrays";
|
||||||
import {
|
import {
|
||||||
type IState,
|
type IState,
|
||||||
|
RovingStateActionType,
|
||||||
RovingTabIndexContext,
|
RovingTabIndexContext,
|
||||||
RovingTabIndexProvider,
|
RovingTabIndexProvider,
|
||||||
Type,
|
|
||||||
useRovingTabIndex,
|
useRovingTabIndex,
|
||||||
} from "../../../accessibility/RovingTabIndex";
|
} from "../../../accessibility/RovingTabIndex";
|
||||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||||
@@ -368,7 +368,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
|||||||
const node = context.state.nodes[0];
|
const node = context.state.nodes[0];
|
||||||
if (node) {
|
if (node) {
|
||||||
context.dispatch({
|
context.dispatch({
|
||||||
type: Type.SetFocus,
|
type: RovingStateActionType.SetFocus,
|
||||||
payload: { node },
|
payload: { node },
|
||||||
});
|
});
|
||||||
node?.scrollIntoView?.({
|
node?.scrollIntoView?.({
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ import {
|
|||||||
|
|
||||||
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
|
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
|
||||||
import {
|
import {
|
||||||
findSiblingElement,
|
findNextSiblingElement,
|
||||||
|
RovingStateActionType,
|
||||||
RovingTabIndexContext,
|
RovingTabIndexContext,
|
||||||
RovingTabIndexProvider,
|
RovingTabIndexProvider,
|
||||||
Type,
|
|
||||||
} from "../../../../accessibility/RovingTabIndex";
|
} from "../../../../accessibility/RovingTabIndex";
|
||||||
import { mediaFromMxc } from "../../../../customisations/Media";
|
import { mediaFromMxc } from "../../../../customisations/Media";
|
||||||
import { Action } from "../../../../dispatcher/actions";
|
import { Action } from "../../../../dispatcher/actions";
|
||||||
@@ -537,7 +537,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
|||||||
const node = rovingContext.state.nodes[0];
|
const node = rovingContext.state.nodes[0];
|
||||||
if (node) {
|
if (node) {
|
||||||
rovingContext.dispatch({
|
rovingContext.dispatch({
|
||||||
type: Type.SetFocus,
|
type: RovingStateActionType.SetFocus,
|
||||||
payload: { node },
|
payload: { node },
|
||||||
});
|
});
|
||||||
node?.scrollIntoView?.({
|
node?.scrollIntoView?.({
|
||||||
@@ -1181,7 +1181,10 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
|||||||
}
|
}
|
||||||
|
|
||||||
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||||
node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
|
node = findNextSiblingElement(
|
||||||
|
nodes,
|
||||||
|
idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -1201,7 +1204,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
|||||||
|
|
||||||
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
|
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
|
||||||
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||||
node = findSiblingElement(
|
node = findNextSiblingElement(
|
||||||
nodes,
|
nodes,
|
||||||
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
|
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
|
||||||
);
|
);
|
||||||
@@ -1211,7 +1214,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
|||||||
|
|
||||||
if (node) {
|
if (node) {
|
||||||
rovingContext.dispatch({
|
rovingContext.dispatch({
|
||||||
type: Type.SetFocus,
|
type: RovingStateActionType.SetFocus,
|
||||||
payload: { node },
|
payload: { node },
|
||||||
});
|
});
|
||||||
node?.scrollIntoView({
|
node?.scrollIntoView({
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
type IAction as RovingAction,
|
type IAction as RovingAction,
|
||||||
type IState as RovingState,
|
type IState as RovingState,
|
||||||
RovingTabIndexProvider,
|
RovingTabIndexProvider,
|
||||||
Type,
|
RovingStateActionType,
|
||||||
} from "../../../accessibility/RovingTabIndex";
|
} from "../../../accessibility/RovingTabIndex";
|
||||||
import { Key } from "../../../Keyboard";
|
import { Key } from "../../../Keyboard";
|
||||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||||
@@ -187,7 +187,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||||||
focusNode?.focus();
|
focusNode?.focus();
|
||||||
}
|
}
|
||||||
dispatch({
|
dispatch({
|
||||||
type: Type.SetFocus,
|
type: RovingStateActionType.SetFocus,
|
||||||
payload: { node: focusNode },
|
payload: { node: focusNode },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
|||||||
// Reset to first emoji when showing highlight for the first time (or after it was hidden)
|
// Reset to first emoji when showing highlight for the first time (or after it was hidden)
|
||||||
if (state.nodes.length > 0) {
|
if (state.nodes.length > 0) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: Type.SetFocus,
|
type: RovingStateActionType.SetFocus,
|
||||||
payload: { node: state.nodes[0] },
|
payload: { node: state.nodes[0] },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
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 from "react";
|
||||||
|
import { render } from "jest-matrix-react";
|
||||||
|
import { RovingAction, type RovingTabIndexProviderProps } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import * as KeyBindingsManagerModule from "../../../src/KeyBindingsManager";
|
||||||
|
import { KeyBindingAction } from "../../../src/accessibility/KeyboardShortcuts";
|
||||||
|
import { RovingTabIndexProvider } from "../../../src/accessibility/RovingTabIndex";
|
||||||
|
|
||||||
|
jest.mock("@element-hq/web-shared-components", () => {
|
||||||
|
const actual = jest.requireActual("@element-hq/web-shared-components");
|
||||||
|
const mockSharedRovingTabIndexProvider = jest.fn(({ children }: RovingTabIndexProviderProps) => {
|
||||||
|
return <>{children({ onDragEndHandler: jest.fn(), onKeyDownHandler: jest.fn() })}</>;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
__mockSharedRovingTabIndexProvider: mockSharedRovingTabIndexProvider,
|
||||||
|
...actual,
|
||||||
|
RovingTabIndexProvider: mockSharedRovingTabIndexProvider,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMockSharedRovingTabIndexProvider = (): jest.Mock => {
|
||||||
|
return jest.requireMock("@element-hq/web-shared-components").__mockSharedRovingTabIndexProvider as jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInjectedGetAction = (): NonNullable<RovingTabIndexProviderProps["getAction"]> => {
|
||||||
|
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
|
||||||
|
expect(mockSharedRovingTabIndexProvider).toHaveBeenCalled();
|
||||||
|
const getAction = (mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps).getAction;
|
||||||
|
expect(getAction).toBeDefined();
|
||||||
|
return getAction!;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("RovingTabIndex adapter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
|
||||||
|
mockSharedRovingTabIndexProvider.mockClear();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[KeyBindingAction.ArrowDown, RovingAction.ArrowDown],
|
||||||
|
[KeyBindingAction.ArrowUp, RovingAction.ArrowUp],
|
||||||
|
[KeyBindingAction.ArrowRight, RovingAction.ArrowRight],
|
||||||
|
[KeyBindingAction.ArrowLeft, RovingAction.ArrowLeft],
|
||||||
|
[KeyBindingAction.Home, RovingAction.Home],
|
||||||
|
[KeyBindingAction.End, RovingAction.End],
|
||||||
|
[KeyBindingAction.Tab, RovingAction.Tab],
|
||||||
|
])("maps %s to %s", (accessibilityAction, expectedRovingAction) => {
|
||||||
|
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
|
||||||
|
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
|
||||||
|
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(accessibilityAction);
|
||||||
|
|
||||||
|
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
|
||||||
|
|
||||||
|
const getAction = getInjectedGetAction();
|
||||||
|
expect(getAction({ key: "irrelevant" } as React.KeyboardEvent)).toBe(expectedRovingAction);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when there is no matching accessibility action", () => {
|
||||||
|
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
|
||||||
|
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
|
||||||
|
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(undefined);
|
||||||
|
|
||||||
|
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
|
||||||
|
|
||||||
|
const getAction = getInjectedGetAction();
|
||||||
|
expect(getAction({ key: "x" } as React.KeyboardEvent)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards provider props to shared-components", () => {
|
||||||
|
const onKeyDown = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RovingTabIndexProvider handleHomeEnd handleLoop handleUpDown onKeyDown={onKeyDown} scrollIntoView>
|
||||||
|
{() => null}
|
||||||
|
</RovingTabIndexProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
|
||||||
|
const props = mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps;
|
||||||
|
expect(props.handleHomeEnd).toBe(true);
|
||||||
|
expect(props.handleLoop).toBe(true);
|
||||||
|
expect(props.handleUpDown).toBe(true);
|
||||||
|
expect(props.onKeyDown).toBe(onKeyDown);
|
||||||
|
expect(props.scrollIntoView).toBe(true);
|
||||||
|
expect(props.getAction).toEqual(expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
+227
-97
@@ -1,30 +1,31 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2024 New Vector Ltd.
|
* Copyright 2026 Element Creations Ltd.
|
||||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
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.
|
||||||
Please see LICENSE files in the repository root for full details.
|
*/
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { type HTMLAttributes } from "react";
|
import React, { type HTMLAttributes } from "react";
|
||||||
import { act, render } from "jest-matrix-react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { act, fireEvent, render } from "@test-utils";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type IState,
|
RovingAction,
|
||||||
reducer,
|
RovingStateActionType,
|
||||||
RovingTabIndexProvider,
|
RovingTabIndexProvider,
|
||||||
RovingTabIndexWrapper,
|
RovingTabIndexWrapper,
|
||||||
Type,
|
|
||||||
useRovingTabIndex,
|
useRovingTabIndex,
|
||||||
} from "../../../src/accessibility/RovingTabIndex";
|
} from ".";
|
||||||
|
import type { IState } from ".";
|
||||||
|
import { reducer } from "./RovingTabIndex";
|
||||||
|
|
||||||
const Button = (props: HTMLAttributes<HTMLButtonElement>) => {
|
const Button = (props: HTMLAttributes<HTMLButtonElement>): React.JSX.Element => {
|
||||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>();
|
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>();
|
||||||
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
|
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[]) => {
|
const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[]): void => {
|
||||||
expect([...buttons].map((b) => b.tabIndex)).toStrictEqual(expectations);
|
expect([...buttons].map((b) => b.tabIndex)).toStrictEqual(expectations);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,13 +35,26 @@ const createButtonElement = (text: string): HTMLButtonElement => {
|
|||||||
return button;
|
return button;
|
||||||
};
|
};
|
||||||
|
|
||||||
// give the buttons keys for the fibre reconciler to not treat them all as the same
|
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 button1 = <Button key={1}>a</Button>;
|
||||||
const button2 = <Button key={2}>b</Button>;
|
const button2 = <Button key={2}>b</Button>;
|
||||||
const button3 = <Button key={3}>c</Button>;
|
const button3 = <Button key={3}>c</Button>;
|
||||||
const button4 = <Button key={4}>d</Button>;
|
const button4 = <Button key={4}>d</Button>;
|
||||||
|
|
||||||
// mock offsetParent
|
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
||||||
get() {
|
get() {
|
||||||
return this.parentNode;
|
return this.parentNode;
|
||||||
@@ -48,7 +62,7 @@ Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("RovingTabIndex", () => {
|
describe("RovingTabIndex", () => {
|
||||||
it("RovingTabIndexProvider renders children as expected", () => {
|
it("renders children as expected", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<RovingTabIndexProvider>
|
<RovingTabIndexProvider>
|
||||||
{() => (
|
{() => (
|
||||||
@@ -62,88 +76,81 @@ describe("RovingTabIndex", () => {
|
|||||||
expect(container.innerHTML).toBe("<div><span>Test</span></div>");
|
expect(container.innerHTML).toBe("<div><span>Test</span></div>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => {
|
it("works as expected with useRovingTabIndex", () => {
|
||||||
const { container, rerender } = render(
|
const { container, rerender } = render(
|
||||||
<RovingTabIndexProvider>
|
<RovingTabIndexProvider>
|
||||||
{() => (
|
{() => (
|
||||||
<React.Fragment>
|
<>
|
||||||
{button1}
|
{button1}
|
||||||
{button2}
|
{button2}
|
||||||
{button3}
|
{button3}
|
||||||
</React.Fragment>
|
</>
|
||||||
)}
|
)}
|
||||||
</RovingTabIndexProvider>,
|
</RovingTabIndexProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// should begin with 0th being active
|
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||||
|
|
||||||
// focus on 2nd button and test it is the only active one
|
|
||||||
act(() => container.querySelectorAll("button")[2].focus());
|
act(() => container.querySelectorAll("button")[2].focus());
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
||||||
|
|
||||||
// focus on 1st button and test it is the only active one
|
|
||||||
act(() => container.querySelectorAll("button")[1].focus());
|
act(() => container.querySelectorAll("button")[1].focus());
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
|
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
|
||||||
|
|
||||||
// check that the active button does not change even on an explicit blur event
|
|
||||||
act(() => container.querySelectorAll("button")[1].blur());
|
act(() => container.querySelectorAll("button")[1].blur());
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
|
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
|
||||||
|
|
||||||
// update the children, it should remain on the same button
|
|
||||||
rerender(
|
rerender(
|
||||||
<RovingTabIndexProvider>
|
<RovingTabIndexProvider>
|
||||||
{() => (
|
{() => (
|
||||||
<React.Fragment>
|
<>
|
||||||
{button1}
|
{button1}
|
||||||
{button4}
|
{button4}
|
||||||
{button2}
|
{button2}
|
||||||
{button3}
|
{button3}
|
||||||
</React.Fragment>
|
</>
|
||||||
)}
|
)}
|
||||||
</RovingTabIndexProvider>,
|
</RovingTabIndexProvider>,
|
||||||
);
|
);
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0, -1]);
|
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0, -1]);
|
||||||
|
|
||||||
// update the children, remove the active button, it should move to the next one
|
|
||||||
rerender(
|
rerender(
|
||||||
<RovingTabIndexProvider>
|
<RovingTabIndexProvider>
|
||||||
{() => (
|
{() => (
|
||||||
<React.Fragment>
|
<>
|
||||||
{button1}
|
{button1}
|
||||||
{button4}
|
{button4}
|
||||||
{button3}
|
{button3}
|
||||||
</React.Fragment>
|
</>
|
||||||
)}
|
)}
|
||||||
</RovingTabIndexProvider>,
|
</RovingTabIndexProvider>,
|
||||||
);
|
);
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("RovingTabIndexProvider provides a ref to the dom element", () => {
|
it("provides a ref to the dom element", () => {
|
||||||
const nodeRef = React.createRef<HTMLButtonElement>();
|
const nodeRef = React.createRef<HTMLButtonElement>();
|
||||||
const MyButton = (props: HTMLAttributes<HTMLButtonElement>) => {
|
const MyButton = (props: HTMLAttributes<HTMLButtonElement>): React.JSX.Element => {
|
||||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>(nodeRef);
|
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>(nodeRef);
|
||||||
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
|
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
|
||||||
};
|
};
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<RovingTabIndexProvider>
|
<RovingTabIndexProvider>
|
||||||
{() => (
|
{() => (
|
||||||
<React.Fragment>
|
<>
|
||||||
<MyButton />
|
<MyButton />
|
||||||
</React.Fragment>
|
</>
|
||||||
)}
|
)}
|
||||||
</RovingTabIndexProvider>,
|
</RovingTabIndexProvider>,
|
||||||
);
|
);
|
||||||
// nodeRef should point to button
|
|
||||||
expect(nodeRef.current).toBe(container.querySelector("button"));
|
expect(nodeRef.current).toBe(container.querySelector("button"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => {
|
it("works as expected with RovingTabIndexWrapper", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<RovingTabIndexProvider>
|
<RovingTabIndexProvider>
|
||||||
{() => (
|
{() => (
|
||||||
<React.Fragment>
|
<>
|
||||||
{button1}
|
{button1}
|
||||||
{button2}
|
{button2}
|
||||||
<RovingTabIndexWrapper>
|
<RovingTabIndexWrapper>
|
||||||
@@ -153,15 +160,13 @@ describe("RovingTabIndex", () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</RovingTabIndexWrapper>
|
</RovingTabIndexWrapper>
|
||||||
</React.Fragment>
|
</>
|
||||||
)}
|
)}
|
||||||
</RovingTabIndexProvider>,
|
</RovingTabIndexProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// should begin with 0th being active
|
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||||
|
|
||||||
// focus on 2nd button and test it is the only active one
|
|
||||||
act(() => container.querySelectorAll("button")[2].focus());
|
act(() => container.querySelectorAll("button")[2].focus());
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
||||||
});
|
});
|
||||||
@@ -177,7 +182,7 @@ describe("RovingTabIndex", () => {
|
|||||||
nodes: [node1, node2],
|
nodes: [node1, node2],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: Type.SetFocus,
|
type: RovingStateActionType.SetFocus,
|
||||||
payload: {
|
payload: {
|
||||||
node: node2,
|
node: node2,
|
||||||
},
|
},
|
||||||
@@ -190,49 +195,49 @@ describe("RovingTabIndex", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Unregister works as expected", () => {
|
it("Unregister works as expected", () => {
|
||||||
const button1 = createButtonElement("Button 1");
|
const unregisterButton1 = createButtonElement("Button 1");
|
||||||
const button2 = createButtonElement("Button 2");
|
const unregisterButton2 = createButtonElement("Button 2");
|
||||||
const button3 = createButtonElement("Button 3");
|
const unregisterButton3 = createButtonElement("Button 3");
|
||||||
const button4 = createButtonElement("Button 4");
|
const unregisterButton4 = createButtonElement("Button 4");
|
||||||
|
|
||||||
let state: IState = {
|
let state: IState = {
|
||||||
nodes: [button1, button2, button3, button4],
|
nodes: [unregisterButton1, unregisterButton2, unregisterButton3, unregisterButton4],
|
||||||
};
|
};
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: RovingStateActionType.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
node: button2,
|
node: unregisterButton2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
nodes: [button1, button3, button4],
|
nodes: [unregisterButton1, unregisterButton3, unregisterButton4],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: RovingStateActionType.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
node: button3,
|
node: unregisterButton3,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
nodes: [button1, button4],
|
nodes: [unregisterButton1, unregisterButton4],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: RovingStateActionType.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
node: button4,
|
node: unregisterButton4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
nodes: [button1],
|
nodes: [unregisterButton1],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: RovingStateActionType.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
node: button1,
|
node: unregisterButton1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
@@ -247,12 +252,12 @@ describe("RovingTabIndex", () => {
|
|||||||
const ref4 = React.createRef<HTMLElement>();
|
const ref4 = React.createRef<HTMLElement>();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<React.Fragment>
|
<>
|
||||||
<span ref={ref1} />
|
<span ref={ref1} />
|
||||||
<span ref={ref2} />
|
<span ref={ref2} />
|
||||||
<span ref={ref3} />
|
<span ref={ref3} />
|
||||||
<span ref={ref4} />
|
<span ref={ref4} />
|
||||||
</React.Fragment>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
let state: IState = {
|
let state: IState = {
|
||||||
@@ -260,7 +265,7 @@ describe("RovingTabIndex", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: RovingStateActionType.Register,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref1.current!,
|
node: ref1.current!,
|
||||||
},
|
},
|
||||||
@@ -271,7 +276,7 @@ describe("RovingTabIndex", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: RovingStateActionType.Register,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref2.current!,
|
node: ref2.current!,
|
||||||
},
|
},
|
||||||
@@ -282,7 +287,7 @@ describe("RovingTabIndex", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: RovingStateActionType.Register,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref3.current!,
|
node: ref3.current!,
|
||||||
},
|
},
|
||||||
@@ -293,7 +298,7 @@ describe("RovingTabIndex", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: RovingStateActionType.Register,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref4.current!,
|
node: ref4.current!,
|
||||||
},
|
},
|
||||||
@@ -303,9 +308,8 @@ describe("RovingTabIndex", () => {
|
|||||||
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
// test that the automatic focus switch works for unmounting
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.SetFocus,
|
type: RovingStateActionType.SetFocus,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref2.current!,
|
node: ref2.current!,
|
||||||
},
|
},
|
||||||
@@ -316,7 +320,7 @@ describe("RovingTabIndex", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: RovingStateActionType.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref2.current!,
|
node: ref2.current!,
|
||||||
},
|
},
|
||||||
@@ -326,9 +330,8 @@ describe("RovingTabIndex", () => {
|
|||||||
nodes: [ref1.current, ref3.current, ref4.current],
|
nodes: [ref1.current, ref3.current, ref4.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
// test that the insert into the middle works as expected
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: RovingStateActionType.Register,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref2.current!,
|
node: ref2.current!,
|
||||||
},
|
},
|
||||||
@@ -338,15 +341,14 @@ describe("RovingTabIndex", () => {
|
|||||||
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
// test that insertion at the edges works
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: RovingStateActionType.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref1.current!,
|
node: ref1.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: RovingStateActionType.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref4.current!,
|
node: ref4.current!,
|
||||||
},
|
},
|
||||||
@@ -357,14 +359,14 @@ describe("RovingTabIndex", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: RovingStateActionType.Register,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref1.current!,
|
node: ref1.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: RovingStateActionType.Register,
|
||||||
payload: {
|
payload: {
|
||||||
node: ref4.current!,
|
node: ref4.current!,
|
||||||
},
|
},
|
||||||
@@ -376,18 +378,15 @@ describe("RovingTabIndex", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handles arrow keys", () => {
|
describe("handles keyboard navigation", () => {
|
||||||
it("should handle up/down arrow keys work when handleUpDown=true", async () => {
|
it("handles up/down arrow keys when handleUpDown=true", async () => {
|
||||||
const { container } = render(
|
const { container } = renderToolbar(
|
||||||
<RovingTabIndexProvider handleUpDown>
|
<>
|
||||||
{({ onKeyDownHandler }) => (
|
{button1}
|
||||||
<div onKeyDown={onKeyDownHandler}>
|
{button2}
|
||||||
{button1}
|
{button3}
|
||||||
{button2}
|
</>,
|
||||||
{button3}
|
{ handleUpDown: true },
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</RovingTabIndexProvider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => container.querySelectorAll("button")[0].focus());
|
act(() => container.querySelectorAll("button")[0].focus());
|
||||||
@@ -405,29 +404,160 @@ describe("RovingTabIndex", () => {
|
|||||||
await userEvent.keyboard("[ArrowUp]");
|
await userEvent.keyboard("[ArrowUp]");
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||||
|
|
||||||
// Does not loop without
|
|
||||||
await userEvent.keyboard("[ArrowUp]");
|
await userEvent.keyboard("[ArrowUp]");
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call scrollIntoView if specified", async () => {
|
it("handles left/right arrow keys when handleLeftRight=true", async () => {
|
||||||
const { container } = render(
|
const { container } = renderToolbar(
|
||||||
<RovingTabIndexProvider handleUpDown scrollIntoView>
|
<>
|
||||||
{({ onKeyDownHandler }) => (
|
{button1}
|
||||||
<div onKeyDown={onKeyDownHandler}>
|
{button2}
|
||||||
{button1}
|
{button3}
|
||||||
{button2}
|
</>,
|
||||||
{button3}
|
{ handleLeftRight: true },
|
||||||
</div>
|
);
|
||||||
)}
|
|
||||||
</RovingTabIndexProvider>,
|
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());
|
act(() => container.querySelectorAll("button")[0].focus());
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||||
|
|
||||||
const button = container.querySelectorAll("button")[1];
|
const button = container.querySelectorAll("button")[1];
|
||||||
const mock = jest.spyOn(button, "scrollIntoView");
|
const mock = vi.spyOn(button, "scrollIntoView");
|
||||||
await userEvent.keyboard("[ArrowDown]");
|
await userEvent.keyboard("[ArrowDown]");
|
||||||
expect(mock).toHaveBeenCalled();
|
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";
|
||||||
@@ -10,6 +10,7 @@ export * from "./audio/Clock";
|
|||||||
export * from "./audio/PlayPauseButton";
|
export * from "./audio/PlayPauseButton";
|
||||||
export * from "./audio/SeekBar";
|
export * from "./audio/SeekBar";
|
||||||
export * from "./core/AvatarWithDetails";
|
export * from "./core/AvatarWithDetails";
|
||||||
|
export * from "./core/roving";
|
||||||
export * from "./room/composer/Banner";
|
export * from "./room/composer/Banner";
|
||||||
export * from "./crypto/SasEmoji";
|
export * from "./crypto/SasEmoji";
|
||||||
export * from "./room/timeline/ReadMarker";
|
export * from "./room/timeline/ReadMarker";
|
||||||
|
|||||||
+15
-2
@@ -5,9 +5,11 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 { Button, Tooltip } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import { useRovingTabIndex } from "../../../../../core/roving";
|
||||||
import styles from "./ActionBarView.module.css";
|
import styles from "./ActionBarView.module.css";
|
||||||
|
|
||||||
interface ActionBarButtonProps {
|
interface ActionBarButtonProps {
|
||||||
@@ -36,6 +38,16 @@ export function ActionBarButton({
|
|||||||
tooltipCaption,
|
tooltipCaption,
|
||||||
}: Readonly<ActionBarButtonProps>): JSX.Element {
|
}: Readonly<ActionBarButtonProps>): JSX.Element {
|
||||||
const iconOnly = presentation === "icon";
|
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 => {
|
const handleContextMenu = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -47,7 +59,7 @@ export function ActionBarButton({
|
|||||||
<Tooltip description={tooltipDescription ?? label} caption={tooltipCaption} placement="top">
|
<Tooltip description={tooltipDescription ?? label} caption={tooltipCaption} placement="top">
|
||||||
<Button
|
<Button
|
||||||
data-presentation={presentation}
|
data-presentation={presentation}
|
||||||
ref={buttonRef}
|
ref={ref}
|
||||||
kind="tertiary"
|
kind="tertiary"
|
||||||
size="sm"
|
size="sm"
|
||||||
iconOnly={iconOnly}
|
iconOnly={iconOnly}
|
||||||
@@ -57,6 +69,7 @@ export function ActionBarButton({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={(event) => onActivate?.(event.currentTarget)}
|
onClick={(event) => onActivate?.(event.currentTarget)}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
onFocus={disabled ? undefined : onFocus}
|
||||||
className={styles.toolbar_item}
|
className={styles.toolbar_item}
|
||||||
Icon={iconOnly ? icon : undefined}
|
Icon={iconOnly ? icon : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
+20
-87
@@ -5,7 +5,7 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 classNames from "classnames";
|
||||||
import {
|
import {
|
||||||
CollapseIcon,
|
CollapseIcon,
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { InlineSpinner } from "@vector-im/compound-web";
|
import { InlineSpinner } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import { RovingTabIndexProvider } from "../../../../../core/roving";
|
||||||
import { useI18n } from "../../../../../core/i18n/i18nContext";
|
import { useI18n } from "../../../../../core/i18n/i18nContext";
|
||||||
import { Flex } from "../../../../../core/utils/Flex";
|
import { Flex } from "../../../../../core/utils/Flex";
|
||||||
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
||||||
@@ -131,7 +132,6 @@ interface ActionBarViewProps {
|
|||||||
*/
|
*/
|
||||||
export function ActionBarView({ vm, className }: Readonly<ActionBarViewProps>): JSX.Element | null {
|
export function ActionBarView({ vm, className }: Readonly<ActionBarViewProps>): JSX.Element | null {
|
||||||
const { translate: _t } = useI18n();
|
const { translate: _t } = useI18n();
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const {
|
const {
|
||||||
actions,
|
actions,
|
||||||
presentation = "icon",
|
presentation = "icon",
|
||||||
@@ -364,79 +364,9 @@ export function ActionBarView({ vm, className }: Readonly<ActionBarViewProps>):
|
|||||||
disabled: isActionDisabled(action),
|
disabled: isActionDisabled(action),
|
||||||
}));
|
}));
|
||||||
}, [actions, isActionDisabled]);
|
}, [actions, isActionDisabled]);
|
||||||
|
const rovingProviderKey = toolbarButtons
|
||||||
// Handle RovingIndex for toolbar
|
.map(({ action, disabled }) => `${action}:${disabled ? "1" : "0"}`)
|
||||||
const enabledIndices = toolbarButtons
|
.join("|");
|
||||||
.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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (toolbarButtons.length === 0) {
|
if (toolbarButtons.length === 0) {
|
||||||
return null;
|
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.
|
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
|
||||||
return (
|
return (
|
||||||
<Flex
|
<RovingTabIndexProvider key={rovingProviderKey} handleLeftRight handleHomeEnd handleLoop>
|
||||||
display="inline-flex"
|
{({ onKeyDownHandler }) => (
|
||||||
direction="row"
|
<Flex
|
||||||
role="toolbar"
|
display="inline-flex"
|
||||||
aria-label={_t("timeline|mab|label")}
|
direction="row"
|
||||||
aria-live="off"
|
role="toolbar"
|
||||||
onKeyDown={handleToolbarKeyDown}
|
aria-label={_t("timeline|mab|label")}
|
||||||
onFocusCapture={handleToolbarFocusCapture}
|
aria-live="off"
|
||||||
className={classNames(className, styles.toolbar)}
|
onKeyDown={onKeyDownHandler}
|
||||||
>
|
className={classNames(className, styles.toolbar)}
|
||||||
{toolbarButtons.map((meta) => actionButtons[meta.action])}
|
>
|
||||||
</Flex>
|
{toolbarButtons.map((meta) => actionButtons[meta.action])}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</RovingTabIndexProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user