2020-01-15 02:44:22 +00:00
|
|
|
/*
|
2020-01-16 01:25:44 +00:00
|
|
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
2020-01-15 02:44:22 +00:00
|
|
|
|
|
|
|
import React, {
|
|
|
|
createContext,
|
|
|
|
useCallback,
|
|
|
|
useContext,
|
|
|
|
useLayoutEffect,
|
|
|
|
useMemo,
|
|
|
|
useRef,
|
|
|
|
useReducer,
|
2020-07-07 16:46:33 +00:00
|
|
|
Reducer,
|
|
|
|
Dispatch,
|
2021-10-26 11:16:50 +00:00
|
|
|
RefObject,
|
2020-01-15 02:44:22 +00:00
|
|
|
} from "react";
|
2020-07-07 16:46:33 +00:00
|
|
|
|
2022-02-28 16:05:52 +00:00
|
|
|
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
|
|
|
import { KeyBindingAction } from "./KeyboardShortcuts";
|
2021-06-29 12:11:58 +00:00
|
|
|
import { FocusHandler, Ref } from "./roving/types";
|
2020-01-15 02:44:22 +00:00
|
|
|
|
2020-01-16 01:25:44 +00:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
|
|
|
|
2021-12-17 17:08:56 +00:00
|
|
|
// 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]');
|
|
|
|
}
|
|
|
|
|
2020-07-15 02:47:35 +00:00
|
|
|
export interface IState {
|
2020-07-07 16:46:33 +00:00
|
|
|
activeRef: Ref;
|
|
|
|
refs: Ref[];
|
|
|
|
}
|
|
|
|
|
|
|
|
interface IContext {
|
|
|
|
state: IState;
|
|
|
|
dispatch: Dispatch<IAction>;
|
|
|
|
}
|
|
|
|
|
2021-12-10 11:50:01 +00:00
|
|
|
export const RovingTabIndexContext = createContext<IContext>({
|
2020-01-15 02:44:22 +00:00
|
|
|
state: {
|
|
|
|
activeRef: null,
|
2020-01-16 01:25:44 +00:00
|
|
|
refs: [], // list of refs in DOM order
|
2020-01-15 02:44:22 +00:00
|
|
|
},
|
|
|
|
dispatch: () => {},
|
|
|
|
});
|
|
|
|
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
|
|
|
|
2021-10-26 11:16:50 +00:00
|
|
|
export enum Type {
|
2020-07-07 16:46:33 +00:00
|
|
|
Register = "REGISTER",
|
|
|
|
Unregister = "UNREGISTER",
|
|
|
|
SetFocus = "SET_FOCUS",
|
|
|
|
}
|
|
|
|
|
|
|
|
interface IAction {
|
|
|
|
type: Type;
|
|
|
|
payload: {
|
|
|
|
ref: Ref;
|
|
|
|
};
|
|
|
|
}
|
2020-01-15 02:44:22 +00:00
|
|
|
|
2021-10-26 11:16:50 +00:00
|
|
|
export const reducer = (state: IState, action: IAction) => {
|
2020-01-15 02:44:22 +00:00
|
|
|
switch (action.type) {
|
2020-07-07 16:46:33 +00:00
|
|
|
case Type.Register: {
|
2021-12-10 11:50:01 +00:00
|
|
|
if (!state.activeRef) {
|
|
|
|
// Our list of refs was empty, set activeRef to this first item
|
|
|
|
state.activeRef = action.payload.ref;
|
|
|
|
}
|
2020-01-15 02:44:22 +00:00
|
|
|
|
2021-12-10 11:50:01 +00:00
|
|
|
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
|
|
|
|
state.refs.push(action.payload.ref);
|
|
|
|
state.refs.sort((a, b) => {
|
|
|
|
if (a === b) {
|
|
|
|
return 0;
|
2021-10-26 11:16:50 +00:00
|
|
|
}
|
2020-01-15 02:44:22 +00:00
|
|
|
|
2021-12-10 11:50:01 +00:00
|
|
|
const position = a.current.compareDocumentPosition(b.current);
|
|
|
|
|
|
|
|
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;
|
2021-10-26 11:16:50 +00:00
|
|
|
} else {
|
2021-12-10 11:50:01 +00:00
|
|
|
return 0;
|
2021-10-26 11:16:50 +00:00
|
|
|
}
|
2021-12-10 11:50:01 +00:00
|
|
|
});
|
2021-10-26 11:16:50 +00:00
|
|
|
|
|
|
|
return { ...state };
|
2020-01-15 02:44:22 +00:00
|
|
|
}
|
2021-10-26 11:16:50 +00:00
|
|
|
|
2020-07-07 16:46:33 +00:00
|
|
|
case Type.Unregister: {
|
2021-10-26 11:16:50 +00:00
|
|
|
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
|
2020-01-15 02:44:22 +00:00
|
|
|
|
2021-10-26 11:16:50 +00:00
|
|
|
if (oldIndex === -1) {
|
2020-01-15 02:44:22 +00:00
|
|
|
return state; // already removed, this should not happen
|
|
|
|
}
|
|
|
|
|
2021-10-26 11:16:50 +00:00
|
|
|
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
|
2020-01-16 01:25:44 +00:00
|
|
|
// we just removed the active ref, need to replace it
|
2021-11-29 17:18:35 +00:00
|
|
|
// pick the ref closest to the index the old ref was in
|
|
|
|
if (oldIndex >= state.refs.length) {
|
|
|
|
state.activeRef = findSiblingElement(state.refs, state.refs.length - 1, true);
|
|
|
|
} else {
|
|
|
|
state.activeRef = findSiblingElement(state.refs, oldIndex)
|
|
|
|
|| findSiblingElement(state.refs, oldIndex, true);
|
|
|
|
}
|
2021-12-02 10:24:55 +00:00
|
|
|
if (document.activeElement === document.body) {
|
|
|
|
// if the focus got reverted to the body then the user was likely focused on the unmounted element
|
|
|
|
state.activeRef?.current?.focus();
|
|
|
|
}
|
2020-01-15 02:44:22 +00:00
|
|
|
}
|
|
|
|
|
2020-01-16 01:25:44 +00:00
|
|
|
// update the refs list
|
2021-10-26 11:16:50 +00:00
|
|
|
return { ...state };
|
2020-01-15 02:44:22 +00:00
|
|
|
}
|
2021-10-26 11:16:50 +00:00
|
|
|
|
2020-07-07 16:46:33 +00:00
|
|
|
case Type.SetFocus: {
|
2021-12-14 14:27:35 +00:00
|
|
|
// if the ref doesn't change just return the same object reference to skip a re-render
|
|
|
|
if (state.activeRef === action.payload.ref) return state;
|
2020-01-16 01:25:44 +00:00
|
|
|
// update active ref
|
2021-10-26 11:16:50 +00:00
|
|
|
state.activeRef = action.payload.ref;
|
|
|
|
return { ...state };
|
2020-01-15 02:44:22 +00:00
|
|
|
}
|
2021-10-26 11:16:50 +00:00
|
|
|
|
2020-01-15 02:44:22 +00:00
|
|
|
default:
|
|
|
|
return state;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-07-07 16:46:33 +00:00
|
|
|
interface IProps {
|
|
|
|
handleHomeEnd?: boolean;
|
2021-08-09 09:29:55 +00:00
|
|
|
handleUpDown?: boolean;
|
2021-10-26 11:16:50 +00:00
|
|
|
handleLeftRight?: boolean;
|
2020-07-07 16:46:33 +00:00
|
|
|
children(renderProps: {
|
|
|
|
onKeyDownHandler(ev: React.KeyboardEvent);
|
|
|
|
});
|
2020-07-15 02:47:35 +00:00
|
|
|
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
2020-07-07 16:46:33 +00:00
|
|
|
}
|
|
|
|
|
2021-10-26 11:16:50 +00:00
|
|
|
export const findSiblingElement = (
|
|
|
|
refs: RefObject<HTMLElement>[],
|
|
|
|
startIndex: number,
|
|
|
|
backwards = false,
|
|
|
|
): RefObject<HTMLElement> => {
|
|
|
|
if (backwards) {
|
|
|
|
for (let i = startIndex; i < refs.length && i >= 0; i--) {
|
2021-11-30 09:26:38 +00:00
|
|
|
if (refs[i].current?.offsetParent !== null) {
|
2021-10-26 11:16:50 +00:00
|
|
|
return refs[i];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
for (let i = startIndex; i < refs.length && i >= 0; i++) {
|
2021-11-30 09:26:38 +00:00
|
|
|
if (refs[i].current?.offsetParent !== null) {
|
2021-10-26 11:16:50 +00:00
|
|
|
return refs[i];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
export const RovingTabIndexProvider: React.FC<IProps> = ({
|
|
|
|
children,
|
|
|
|
handleHomeEnd,
|
|
|
|
handleUpDown,
|
|
|
|
handleLeftRight,
|
|
|
|
onKeyDown,
|
|
|
|
}) => {
|
2020-07-07 16:46:33 +00:00
|
|
|
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
2020-01-15 02:44:22 +00:00
|
|
|
activeRef: null,
|
|
|
|
refs: [],
|
|
|
|
});
|
|
|
|
|
2021-06-29 12:11:58 +00:00
|
|
|
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
2020-01-16 01:35:42 +00:00
|
|
|
|
2021-12-17 17:08:56 +00:00
|
|
|
const onKeyDownHandler = useCallback((ev: React.KeyboardEvent) => {
|
2021-10-26 11:16:50 +00:00
|
|
|
if (onKeyDown) {
|
|
|
|
onKeyDown(ev, context.state);
|
|
|
|
if (ev.defaultPrevented) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-22 10:36:20 +00:00
|
|
|
let handled = false;
|
2022-02-28 16:05:52 +00:00
|
|
|
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
2021-12-14 14:27:35 +00:00
|
|
|
let focusRef: RefObject<HTMLElement>;
|
2020-10-08 09:25:03 +00:00
|
|
|
// Don't interfere with input default keydown behaviour
|
2021-12-17 17:08:56 +00:00
|
|
|
// but allow people to move focus from it with Tab.
|
|
|
|
if (checkInputableElement(ev.target as HTMLElement)) {
|
2022-02-28 16:05:52 +00:00
|
|
|
switch (action) {
|
|
|
|
case KeyBindingAction.Tab:
|
2021-12-17 17:08:56 +00:00
|
|
|
handled = true;
|
|
|
|
if (context.state.refs.length > 0) {
|
|
|
|
const idx = context.state.refs.indexOf(context.state.activeRef);
|
|
|
|
focusRef = findSiblingElement(context.state.refs, idx + (ev.shiftKey ? -1 : 1), ev.shiftKey);
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
} else {
|
2020-01-22 10:36:20 +00:00
|
|
|
// check if we actually have any items
|
2022-02-28 16:05:52 +00:00
|
|
|
switch (action) {
|
|
|
|
case KeyBindingAction.Home:
|
2021-08-09 09:29:55 +00:00
|
|
|
if (handleHomeEnd) {
|
|
|
|
handled = true;
|
2021-10-26 11:16:50 +00:00
|
|
|
// move focus to first (visible) item
|
2021-12-14 14:27:35 +00:00
|
|
|
focusRef = findSiblingElement(context.state.refs, 0);
|
2020-01-22 10:36:20 +00:00
|
|
|
}
|
|
|
|
break;
|
2021-08-09 09:29:55 +00:00
|
|
|
|
2022-02-28 16:05:52 +00:00
|
|
|
case KeyBindingAction.End:
|
2021-08-09 09:29:55 +00:00
|
|
|
if (handleHomeEnd) {
|
|
|
|
handled = true;
|
2021-10-26 11:16:50 +00:00
|
|
|
// move focus to last (visible) item
|
2021-12-14 14:27:35 +00:00
|
|
|
focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true);
|
2021-08-09 09:29:55 +00:00
|
|
|
}
|
|
|
|
break;
|
|
|
|
|
2022-02-28 16:05:52 +00:00
|
|
|
case KeyBindingAction.ArrowDown:
|
|
|
|
case KeyBindingAction.ArrowRight:
|
|
|
|
if ((action === KeyBindingAction.ArrowDown && handleUpDown) ||
|
|
|
|
(action === KeyBindingAction.ArrowRight && handleLeftRight)
|
2021-12-14 14:27:35 +00:00
|
|
|
) {
|
2021-08-09 09:29:55 +00:00
|
|
|
handled = true;
|
|
|
|
if (context.state.refs.length > 0) {
|
|
|
|
const idx = context.state.refs.indexOf(context.state.activeRef);
|
2021-12-14 14:27:35 +00:00
|
|
|
focusRef = findSiblingElement(context.state.refs, idx + 1);
|
2021-08-09 09:29:55 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
|
2022-02-28 16:05:52 +00:00
|
|
|
case KeyBindingAction.ArrowUp:
|
|
|
|
case KeyBindingAction.ArrowLeft:
|
|
|
|
if ((action === KeyBindingAction.ArrowUp && handleUpDown) ||
|
|
|
|
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
|
|
|
|
) {
|
2021-08-09 09:29:55 +00:00
|
|
|
handled = true;
|
|
|
|
if (context.state.refs.length > 0) {
|
|
|
|
const idx = context.state.refs.indexOf(context.state.activeRef);
|
2021-12-14 14:27:35 +00:00
|
|
|
focusRef = findSiblingElement(context.state.refs, idx - 1, true);
|
2021-08-09 09:29:55 +00:00
|
|
|
}
|
2020-01-22 10:36:20 +00:00
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
2020-01-15 11:37:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (handled) {
|
|
|
|
ev.preventDefault();
|
|
|
|
ev.stopPropagation();
|
|
|
|
}
|
2021-12-14 14:27:35 +00:00
|
|
|
|
|
|
|
if (focusRef) {
|
|
|
|
focusRef.current?.focus();
|
|
|
|
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
|
|
|
|
dispatch({
|
|
|
|
type: Type.SetFocus,
|
|
|
|
payload: {
|
|
|
|
ref: focusRef,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}, [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
|
2020-01-16 01:25:44 +00:00
|
|
|
|
2020-01-22 10:36:20 +00:00
|
|
|
return <RovingTabIndexContext.Provider value={context}>
|
2021-06-29 12:11:58 +00:00
|
|
|
{ children({ onKeyDownHandler }) }
|
2020-01-22 10:36:20 +00:00
|
|
|
</RovingTabIndexContext.Provider>;
|
|
|
|
};
|
2020-07-07 16:46:33 +00:00
|
|
|
|
2020-01-16 01:25:44 +00:00
|
|
|
// Hook to register a roving tab index
|
|
|
|
// inputRef parameter specifies the ref to use
|
|
|
|
// 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 should be passed to a DOM node which will be used for DOM compareDocumentPosition
|
2021-12-17 17:08:56 +00:00
|
|
|
export const useRovingTabIndex = <T extends HTMLElement>(
|
|
|
|
inputRef?: RefObject<T>,
|
|
|
|
): [FocusHandler, boolean, RefObject<T>] => {
|
2020-01-15 02:44:22 +00:00
|
|
|
const context = useContext(RovingTabIndexContext);
|
2021-12-17 17:08:56 +00:00
|
|
|
let ref = useRef<T>(null);
|
2020-01-15 02:44:22 +00:00
|
|
|
|
2020-01-15 11:37:14 +00:00
|
|
|
if (inputRef) {
|
2020-01-16 01:25:44 +00:00
|
|
|
// if we are given a ref, use it instead of ours
|
2020-01-15 11:37:14 +00:00
|
|
|
ref = inputRef;
|
|
|
|
}
|
|
|
|
|
2020-01-16 01:25:44 +00:00
|
|
|
// setup (after refs)
|
2020-01-15 02:44:22 +00:00
|
|
|
useLayoutEffect(() => {
|
|
|
|
context.dispatch({
|
2020-07-07 16:46:33 +00:00
|
|
|
type: Type.Register,
|
2021-06-29 12:11:58 +00:00
|
|
|
payload: { ref },
|
2020-01-15 02:44:22 +00:00
|
|
|
});
|
2020-01-16 01:25:44 +00:00
|
|
|
// teardown
|
2020-01-15 02:44:22 +00:00
|
|
|
return () => {
|
|
|
|
context.dispatch({
|
2020-07-07 16:46:33 +00:00
|
|
|
type: Type.Unregister,
|
2021-06-29 12:11:58 +00:00
|
|
|
payload: { ref },
|
2020-01-15 02:44:22 +00:00
|
|
|
});
|
|
|
|
};
|
|
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
const onFocus = useCallback(() => {
|
|
|
|
context.dispatch({
|
2020-07-07 16:46:33 +00:00
|
|
|
type: Type.SetFocus,
|
2021-06-29 12:11:58 +00:00
|
|
|
payload: { ref },
|
2020-01-15 02:44:22 +00:00
|
|
|
});
|
2021-12-14 14:27:35 +00:00
|
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
2020-01-15 02:44:22 +00:00
|
|
|
|
2020-01-15 11:37:14 +00:00
|
|
|
const isActive = context.state.activeRef === ref;
|
|
|
|
return [onFocus, isActive, ref];
|
2020-01-15 02:44:22 +00:00
|
|
|
};
|
|
|
|
|
2020-07-15 03:22:19 +00:00
|
|
|
// re-export the semantic helper components for simplicity
|
2021-06-29 12:11:58 +00:00
|
|
|
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
|
|
|
|
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
|
|
|
|
export { RovingAccessibleTooltipButton } from "./roving/RovingAccessibleTooltipButton";
|