Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
pull/21833/head
Michael Telatynski 2020-01-16 01:25:44 +00:00
parent 2b37fe7624
commit 8c1fdf4cab
1 changed files with 54 additions and 24 deletions

View File

@ -1,20 +1,18 @@
/* /*
* Copyright 2020 The Matrix.org Foundation C.I.C.
* Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
* Licensed under the Apache License, Version 2.0 (the "License");
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* you may not use this file except in compliance with the License. You may obtain a copy of the License at
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and
* See the License for the specific language governing permissions and limitations under the License.
* limitations under the License. */
* /
*/
import React, { import React, {
createContext, createContext,
@ -27,12 +25,25 @@ import React, {
} from "react"; } from "react";
import {Key} from "../Keyboard"; import {Key} from "../Keyboard";
/**
* 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
*/
const DOCUMENT_POSITION_PRECEDING = 2; const DOCUMENT_POSITION_PRECEDING = 2;
const RovingTabIndexContext = createContext({ const RovingTabIndexContext = createContext({
state: { state: {
activeRef: null, activeRef: null,
refs: [], refs: [], // list of refs in DOM order
}, },
dispatch: () => {}, dispatch: () => {},
}); });
@ -49,6 +60,7 @@ const reducer = (state, action) => {
switch (action.type) { switch (action.type) {
case types.REGISTER: { case types.REGISTER: {
if (state.refs.length === 0) { if (state.refs.length === 0) {
// Our list of refs was empty, set activeRef to this first item
return { return {
...state, ...state,
activeRef: action.payload.ref, activeRef: action.payload.ref,
@ -60,6 +72,7 @@ const reducer = (state, action) => {
return state; // already in refs, this should not happen return state; // already in refs, this should not happen
} }
// find the index of the first ref which is not preceding this one in DOM order
let newIndex = state.refs.findIndex(ref => { let newIndex = state.refs.findIndex(ref => {
return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING; return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING;
}); });
@ -68,6 +81,7 @@ const reducer = (state, action) => {
newIndex = state.refs.length; // append to the end newIndex = state.refs.length; // append to the end
} }
// update the refs list
return { return {
...state, ...state,
refs: [ refs: [
@ -78,13 +92,16 @@ const reducer = (state, action) => {
}; };
} }
case types.UNREGISTER: { case types.UNREGISTER: {
const refs = state.refs.filter(r => r !== action.payload.ref); // keep all other refs // filter out the ref which we are removing
const refs = state.refs.filter(r => r !== action.payload.ref);
if (refs.length === state.refs.length) { if (refs.length === state.refs.length) {
return state; // already removed, this should not happen return state; // already removed, this should not happen
} }
if (state.activeRef === action.payload.ref) { // we just removed the active ref, need to replace it if (state.activeRef === action.payload.ref) {
// we just removed the active ref, need to replace it
// pick the ref which is now in the index the old ref was in
const oldIndex = state.refs.findIndex(r => r === action.payload.ref); const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
return { return {
...state, ...state,
@ -93,12 +110,14 @@ const reducer = (state, action) => {
}; };
} }
// update the refs list
return { return {
...state, ...state,
refs, refs,
}; };
} }
case types.SET_FOCUS: { case types.SET_FOCUS: {
// update active ref
return { return {
...state, ...state,
activeRef: action.payload.ref, activeRef: action.payload.ref,
@ -115,17 +134,18 @@ export const RovingTabIndexContextProvider = ({children}) => {
refs: [], refs: [],
}); });
const context = useMemo(() => ({state, dispatch}), [state]);
const onKeyDown = useCallback((ev) => { const onKeyDown = useCallback((ev) => {
// check if we actually have any items
if (state.refs.length <= 0) return; if (state.refs.length <= 0) return;
let handled = true; let handled = true;
switch (ev.key) { switch (ev.key) {
case Key.HOME: case Key.HOME:
// move focus to first item
setImmediate(() => state.refs[0].current.focus()); setImmediate(() => state.refs[0].current.focus());
break; break;
case Key.END: case Key.END:
// move focus to last item
state.refs[state.refs.length - 1].current.focus(); state.refs[state.refs.length - 1].current.focus();
break; break;
default: default:
@ -138,6 +158,9 @@ export const RovingTabIndexContextProvider = ({children}) => {
} }
}, [state]); }, [state]);
const context = useMemo(() => ({state, dispatch}), [state]);
// wrap in a div with key-down handling for HOME/END keys
return <div onKeyDown={onKeyDown}> return <div onKeyDown={onKeyDown}>
<RovingTabIndexContext.Provider value={context}> <RovingTabIndexContext.Provider value={context}>
{children} {children}
@ -145,21 +168,27 @@ export const RovingTabIndexContextProvider = ({children}) => {
</div>; </div>;
}; };
// 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
export const useRovingTabIndex = (inputRef) => { export const useRovingTabIndex = (inputRef) => {
let ref = useRef(null);
const context = useContext(RovingTabIndexContext); const context = useContext(RovingTabIndexContext);
let ref = useRef(null);
if (inputRef) { if (inputRef) {
// if we are given a ref, use it instead of ours
ref = inputRef; ref = inputRef;
} }
// setup/teardown // setup (after refs)
// add ref to the context
useLayoutEffect(() => { useLayoutEffect(() => {
context.dispatch({ context.dispatch({
type: types.REGISTER, type: types.REGISTER,
payload: {ref}, payload: {ref},
}); });
// teardown
return () => { return () => {
context.dispatch({ context.dispatch({
type: types.UNREGISTER, type: types.UNREGISTER,
@ -179,6 +208,7 @@ export const useRovingTabIndex = (inputRef) => {
return [onFocus, isActive, ref]; return [onFocus, isActive, ref];
}; };
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper = ({children, inputRef}) => { export const RovingTabIndexWrapper = ({children, inputRef}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({onFocus, isActive, ref}); return children({onFocus, isActive, ref});