2020-01-15 05:44:22 +03:00
|
|
|
/*
|
|
|
|
*
|
|
|
|
* Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
|
|
|
*
|
|
|
|
* 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.
|
|
|
|
* /
|
|
|
|
*/
|
|
|
|
|
|
|
|
import React, {
|
|
|
|
createContext,
|
|
|
|
useCallback,
|
|
|
|
useContext,
|
|
|
|
useLayoutEffect,
|
|
|
|
useMemo,
|
|
|
|
useRef,
|
|
|
|
useReducer,
|
|
|
|
} from "react";
|
|
|
|
import {Key} from "../Keyboard";
|
|
|
|
|
|
|
|
const DOCUMENT_POSITION_PRECEDING = 2;
|
|
|
|
|
|
|
|
const RovingTabIndexContext = createContext({
|
|
|
|
state: {
|
|
|
|
activeRef: null,
|
|
|
|
refs: [],
|
|
|
|
},
|
|
|
|
dispatch: () => {},
|
|
|
|
});
|
|
|
|
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
|
|
|
|
|
|
|
// TODO use a TypeScript type here
|
|
|
|
const types = {
|
|
|
|
REGISTER: "REGISTER",
|
|
|
|
UNREGISTER: "UNREGISTER",
|
|
|
|
SET_FOCUS: "SET_FOCUS",
|
|
|
|
};
|
|
|
|
|
|
|
|
const reducer = (state, action) => {
|
|
|
|
switch (action.type) {
|
|
|
|
case types.REGISTER: {
|
|
|
|
if (state.refs.length === 0) {
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
activeRef: action.payload.ref,
|
|
|
|
refs: [action.payload.ref],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (state.refs.includes(action.payload.ref)) {
|
|
|
|
return state; // already in refs, this should not happen
|
|
|
|
}
|
|
|
|
|
|
|
|
let newIndex = state.refs.findIndex(ref => {
|
|
|
|
return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (newIndex < 0) {
|
|
|
|
newIndex = state.refs.length; // append to the end
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
refs: [
|
|
|
|
...state.refs.slice(0, newIndex),
|
|
|
|
action.payload.ref,
|
|
|
|
...state.refs.slice(newIndex),
|
|
|
|
],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
case types.UNREGISTER: {
|
|
|
|
const refs = state.refs.filter(r => r !== action.payload.ref); // keep all other refs
|
|
|
|
|
|
|
|
if (refs.length === state.refs.length) {
|
|
|
|
return state; // already removed, this should not happen
|
|
|
|
}
|
|
|
|
|
|
|
|
if (state.activeRef === action.payload.ref) { // we just removed the active ref, need to replace it
|
|
|
|
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
|
|
|
|
refs,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
refs,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
case types.SET_FOCUS: {
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
activeRef: action.payload.ref,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
return state;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
export const RovingTabIndexContextWrapper = ({children}) => {
|
|
|
|
const [state, dispatch] = useReducer(reducer, {
|
|
|
|
activeRef: null,
|
|
|
|
refs: [],
|
|
|
|
});
|
|
|
|
|
|
|
|
const context = useMemo(() => ({state, dispatch}), [state]);
|
|
|
|
|
2020-01-15 14:37:14 +03:00
|
|
|
const onKeyDown = useCallback((ev) => {
|
|
|
|
if (state.refs.length <= 0) return;
|
|
|
|
|
|
|
|
let handled = true;
|
|
|
|
switch (ev.key) {
|
|
|
|
case Key.HOME:
|
|
|
|
setImmediate(() => state.refs[0].current.focus());
|
|
|
|
break;
|
|
|
|
case Key.END:
|
|
|
|
state.refs[state.refs.length - 1].current.focus();
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
handled = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (handled) {
|
|
|
|
ev.preventDefault();
|
|
|
|
ev.stopPropagation();
|
|
|
|
}
|
|
|
|
}, [state]);
|
|
|
|
|
|
|
|
return <div onKeyDown={onKeyDown}>
|
|
|
|
<RovingTabIndexContext.Provider value={context}>
|
|
|
|
{children}
|
|
|
|
</RovingTabIndexContext.Provider>
|
|
|
|
</div>;
|
2020-01-15 05:44:22 +03:00
|
|
|
};
|
|
|
|
|
2020-01-15 14:37:14 +03:00
|
|
|
export const useRovingTabIndex = (inputRef) => {
|
|
|
|
let ref = useRef(null);
|
2020-01-15 05:44:22 +03:00
|
|
|
const context = useContext(RovingTabIndexContext);
|
|
|
|
|
2020-01-15 14:37:14 +03:00
|
|
|
if (inputRef) {
|
|
|
|
ref = inputRef;
|
|
|
|
}
|
|
|
|
|
2020-01-15 05:44:22 +03:00
|
|
|
// setup/teardown
|
|
|
|
// add ref to the context
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
context.dispatch({
|
|
|
|
type: types.REGISTER,
|
|
|
|
payload: {ref},
|
|
|
|
});
|
|
|
|
return () => {
|
|
|
|
context.dispatch({
|
|
|
|
type: types.UNREGISTER,
|
|
|
|
payload: {ref},
|
|
|
|
});
|
|
|
|
};
|
|
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
const onFocus = useCallback(() => {
|
|
|
|
context.dispatch({
|
|
|
|
type: types.SET_FOCUS,
|
|
|
|
payload: {ref},
|
|
|
|
});
|
|
|
|
}, [ref, context]);
|
|
|
|
|
2020-01-15 14:37:14 +03:00
|
|
|
const isActive = context.state.activeRef === ref;
|
|
|
|
return [onFocus, isActive, ref];
|
2020-01-15 05:44:22 +03:00
|
|
|
};
|
|
|
|
|
2020-01-15 14:37:14 +03:00
|
|
|
export const RovingTabIndexWrapper = ({children, inputRef}) => {
|
|
|
|
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
|
|
|
return children({onFocus, isActive, ref});
|
2020-01-15 05:44:22 +03:00
|
|
|
};
|
|
|
|
|