Files
element-web/src/accessibility/RovingTabIndex.tsx
renovate[bot] e1b2e3a101 Update react monorepo to v19 (major) (#28914)
* Update react monorepo to v19

* Import JSX explicitly for React 19 compatibility

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update usages of refs for React 19 compatibility

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update react imports

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Avoid legacy contexts as much as possible

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Avoid deprecated React symbols

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Stash

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update usages of refs for React 19 compatibility

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Switch pillify to use a html-react-parser approach rather than DOM muddling

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate react html parsing

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate react html parsing

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate html parsing

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Memoize the EventContentBody component

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate html parsing

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Simplify

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Discard changes to src/Linkify.tsx

* Discard changes to src/components/views/messages/TextualBody.tsx

* Discard changes to src/settings/handlers/AbstractLocalStorageSettingsHandler.ts

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Prepare for React 19 upgrade

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove stale comment

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-09 19:03:09 +00:00

403 lines
14 KiB
TypeScript

/*
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.
*/
import React, {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useReducer,
type Reducer,
type Dispatch,
type RefObject,
type ReactNode,
type RefCallback,
} from "react";
import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts";
import { type FocusHandler } from "./roving/types";
/**
* Module to simplify implementing the Roving TabIndex accessibility technique
*
* Wrap the Widget in an RovingTabIndexContextProvider
* and then for all buttons make use of useRovingTabIndex or RovingTabIndexWrapper.
* The code will keep track of which tabIndex was most recently focused and expose that information as `isActive` which
* can then be used to only set the tabIndex to 0 as expected by the roving tabindex technique.
* When the active button gets unmounted the closest button will be chosen as expected.
* Initially the first button to mount will be given active state.
*
* 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
// like many of the text input varieties.
//
// i.e. it's ok to press the down arrow on a radio button to move to the next
// radio. But it's not ok to press the down arrow on a <input type="text"> to
// move away because the down arrow should move the cursor to the end of the
// input.
export function checkInputableElement(el: HTMLElement): boolean {
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
}
export interface IState {
activeNode?: HTMLElement;
nodes: HTMLElement[];
}
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:
return state;
}
};
interface IProps {
handleLoop?: boolean;
handleHomeEnd?: boolean;
handleUpDown?: boolean;
handleLeftRight?: boolean;
handleInputFields?: boolean;
scrollIntoView?: boolean | ScrollIntoViewOptions;
children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void; onDragEndHandler(): void }): ReactNode;
onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
}
export const findSiblingElement = (
nodes: HTMLElement[],
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
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";