Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.

Add more focus helpers

+3 -1
src/index.ts
···
-
export { useFocusLoop } from './useFocusLoop';
···
+
export * from './useModalFocus';
+
export * from './useDialogFocus';
+
export * from './useMenuFocus';
+139
src/useDialogFocus.ts
···
···
+
import { snapshotSelection, restoreSelection } from './utils/selection';
+
import { getFocusTargets, getNextFocusTarget } from './utils/focus';
+
import { useLayoutEffect } from './utils/react';
+
import { contains, isInputElement } from './utils/element';
+
import { makePriorityHook } from './usePriority';
+
import { Ref } from './types';
+
+
const usePriority = makePriorityHook();
+
+
export interface DialogFocusOptions {
+
disabled?: boolean;
+
ownerRef?: Ref<HTMLElement>;
+
}
+
+
export function useDialogFocus<T extends HTMLElement>(
+
ref: Ref<T>,
+
options?: DialogFocusOptions
+
) {
+
const ownerRef = options && options.ownerRef;
+
const disabled = !!(options && options.disabled);
+
const hasPriority = usePriority(ref, disabled);
+
+
useLayoutEffect(() => {
+
if (!ref.current || disabled || !hasPriority) return;
+
+
let selection = snapshotSelection(ownerRef && ownerRef.current);
+
let willReceiveFocus = false;
+
let focusMovesForward = true;
+
+
function onClick(event: MouseEvent) {
+
if (!ref.current || event.defaultPrevented) return;
+
+
const target = event.target as HTMLElement | null;
+
if (target && getFocusTargets(ref.current).indexOf(target) > -1) {
+
selection = null;
+
willReceiveFocus = true;
+
}
+
}
+
+
function onFocus(event: FocusEvent) {
+
if (!ref.current || event.defaultPrevented) return;
+
+
const active = document.activeElement as HTMLElement;
+
const owner = (ownerRef && ownerRef.current) || selection && selection.element;
+
+
if (willReceiveFocus || (owner && event.target === owner)) {
+
if (!contains(ref.current, active)) selection = snapshotSelection(owner);
+
willReceiveFocus = false;
+
return;
+
}
+
+
const { relatedTarget, target } = event;
+
// Check whether focus is about to move into the container and prevent it
+
if (contains(ref.current, target) && !contains(ref.current, relatedTarget)) {
+
// Get the next focus target of the container
+
const focusTarget = getNextFocusTarget(ref.current, !focusMovesForward);
+
if (focusTarget) {
+
focusMovesForward = true;
+
event.preventDefault();
+
focusTarget.focus();
+
}
+
}
+
}
+
+
function onKey(event: KeyboardEvent) {
+
if (!ref.current || event.defaultPrevented) return;
+
+
// Mark whether focus is moving forward for the `onFocus` handler
+
if (event.code === 'Tab') {
+
focusMovesForward = !event.shiftKey;
+
}
+
+
const active = document.activeElement as HTMLElement;
+
const owner = (ownerRef && ownerRef.current) || selection && selection.element;
+
const focusTargets = getFocusTargets(ref.current);
+
+
if (
+
!focusTargets.length ||
+
(!contains(owner, active) && !contains(ref.current, active))
+
) {
+
// Do nothing if no targets are available or the listbox or owner don't have focus
+
return;
+
} else if (event.code === 'Tab') {
+
// Skip over the listbox via the parent if we press tab
+
const currentTarget = contains(owner, active) ? owner! : ref.current;
+
const focusTarget = getNextFocusTarget(currentTarget, event.shiftKey);
+
if (focusTarget) {
+
event.preventDefault();
+
focusTarget.focus();
+
}
+
} else if (
+
(!isInputElement(active) && event.code === 'ArrowRight') ||
+
event.code === 'ArrowDown'
+
) {
+
// Implement forward movement in focus targets
+
event.preventDefault();
+
const focusIndex = focusTargets.indexOf(active);
+
const nextIndex = focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0;
+
willReceiveFocus = true;
+
focusTargets[nextIndex].focus();
+
} else if (
+
(!isInputElement(active) && event.code === 'ArrowLeft') ||
+
event.code === 'ArrowUp'
+
) {
+
// Implement backward movement in focus targets
+
event.preventDefault();
+
const focusIndex = focusTargets.indexOf(active);
+
const nextIndex = focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1;
+
willReceiveFocus = true;
+
focusTargets[nextIndex].focus();
+
} else if (selection && event.code === 'Escape') {
+
// Restore selection if escape is pressed
+
event.preventDefault();
+
willReceiveFocus = false;
+
restoreSelection(selection);
+
} else if (
+
owner &&
+
active !== owner &&
+
isInputElement(owner) &&
+
/^(?:Key|Digit)/.test(event.code)
+
) {
+
// Restore selection if a key is pressed on input
+
event.preventDefault();
+
willReceiveFocus = false;
+
restoreSelection(selection);
+
}
+
}
+
+
ref.current.addEventListener('mousedown', onClick, true);
+
document.body.addEventListener('focusin', onFocus);
+
document.addEventListener('keydown', onKey);
+
+
return () => {
+
ref.current!.removeEventListener('mousedown', onClick);
+
document.body.removeEventListener('focusin', onFocus);
+
document.removeEventListener('keydown', onKey);
+
};
+
}, [ref, hasPriority, disabled]);
+
}
-53
src/useFocusLoop.ts
···
-
import { useLayoutEffect } from 'react';
-
import { getFirstFocusTarget, getFocusTargets } from './utils/focus';
-
import { contains } from './utils/element';
-
import { Ref } from './types';
-
-
export function useFocusLoop<T extends HTMLElement>(ref: Ref<T>) {
-
useLayoutEffect(() => {
-
if (!ref.current) return;
-
-
let active = document.activeElement as HTMLElement | null;
-
if (!active || !ref.current.contains(active)) {
-
active = getFirstFocusTarget(ref.current);
-
if (active) active.focus();
-
}
-
-
function onBlur(event: FocusEvent) {
-
const parent = ref.current;
-
if (!parent || event.defaultPrevented) return;
-
-
if (contains(parent, event.target) && !contains(parent, event.relatedTarget)) {
-
const target = getFirstFocusTarget(parent);
-
if (target) target.focus();
-
}
-
}
-
-
function onKeyDown(event: KeyboardEvent) {
-
const parent = ref.current;
-
if (!parent || event.defaultPrevented) return;
-
-
if (event.code === 'Tab') {
-
const activeElement = document.activeElement as HTMLElement;
-
const targets = getFocusTargets(parent);
-
const index = targets.indexOf(activeElement);
-
if (event.shiftKey && index === 0) {
-
event.preventDefault();
-
targets[targets.length - 1].focus();
-
} else if (!event.shiftKey && index === targets.length - 1) {
-
event.preventDefault();
-
targets[0].focus();
-
}
-
-
}
-
}
-
-
document.body.addEventListener('focusout', onBlur);
-
document.addEventListener('keydown', onKeyDown);
-
-
return () => {
-
document.body.removeEventListener('focusout', onBlur);
-
document.removeEventListener('keydown', onKeyDown);
-
};
-
}, [ref]);
-
}
···
+99
src/useMenuFocus.ts
···
···
+
import { RestoreSelection, snapshotSelection, restoreSelection } from './utils/selection';
+
import { getFocusTargets } from './utils/focus';
+
import { useLayoutEffect } from './utils/react';
+
import { contains, isInputElement } from './utils/element';
+
import { Ref } from './types';
+
+
export interface MenuFocusOptions {
+
disabled?: boolean;
+
ownerRef?: Ref<HTMLElement>;
+
}
+
+
export function useMenuFocus<T extends HTMLElement>(ref: Ref<T>, options?: MenuFocusOptions) {
+
const ownerRef = options && options.ownerRef;
+
const disabled = !!(options && options.disabled);
+
+
useLayoutEffect(() => {
+
if (!ref.current || disabled) return;
+
+
let selection: RestoreSelection | null = null;
+
+
function onFocus(event: FocusEvent) {
+
if (!ref.current || event.defaultPrevented) return;
+
+
const owner = (ownerRef && ownerRef.current) || selection && selection.element;
+
const { relatedTarget, target } = event;
+
if (relatedTarget === owner) {
+
// When owner is explicitly passed we can make a snapshot early
+
selection = snapshotSelection(owner);
+
} else if (contains(ref.current, target) && !contains(ref.current, relatedTarget)) {
+
// Check whether focus is about to move into the container and snapshot last focus
+
selection = snapshotSelection(owner);
+
} else if (contains(ref.current, relatedTarget) && !contains(ref.current, target)) {
+
// Reset focus if it's lost and has left the menu
+
selection = null;
+
}
+
}
+
+
function onKey(event: KeyboardEvent) {
+
if (!ref.current || event.defaultPrevented) return;
+
+
const owner = (ownerRef && ownerRef.current) || selection && selection.element;
+
const active = document.activeElement as HTMLElement;
+
const focusTargets = getFocusTargets(ref.current);
+
if (!focusTargets.length || !contains(ref.current, active) || !contains(owner, active)) {
+
// Do nothing if container doesn't contain focus or not targets are available
+
return;
+
}
+
+
if (
+
(!isInputElement(active) && event.code === 'ArrowRight') ||
+
event.code === 'ArrowDown'
+
) {
+
// Implement forward movement in focus targets
+
event.preventDefault();
+
const focusIndex = focusTargets.indexOf(active);
+
const nextIndex = focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0;
+
focusTargets[nextIndex].focus();
+
} else if (
+
(!isInputElement(active) && event.code === 'ArrowLeft') ||
+
event.code === 'ArrowUp'
+
) {
+
// Implement backward movement in focus targets
+
event.preventDefault();
+
const focusIndex = focusTargets.indexOf(active);
+
const nextIndex = focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1;
+
focusTargets[nextIndex].focus();
+
} else if (event.code === 'Home') {
+
// Implement Home => first item
+
event.preventDefault();
+
focusTargets[0].focus();
+
} else if (event.code === 'End') {
+
// Implement End => last item
+
event.preventDefault();
+
focusTargets[focusTargets.length - 1].focus();
+
} else if (owner && active !== owner && event.code === 'Escape') {
+
// Restore selection if escape is pressed
+
event.preventDefault();
+
restoreSelection(selection);
+
} else if (
+
owner &&
+
active !== owner &&
+
isInputElement(owner) &&
+
/^(?:Key|Digit)/.test(event.code)
+
) {
+
// Restore selection if a key is pressed on input
+
event.preventDefault();
+
restoreSelection(selection);
+
}
+
}
+
+
document.body.addEventListener('focusin', onFocus);
+
document.addEventListener('keydown', onKey);
+
+
return () => {
+
document.body.removeEventListener('focusin', onFocus);
+
document.removeEventListener('keydown', onKey);
+
};
+
}, [ref, disabled]);
+
}
+68
src/useModalFocus.ts
···
···
+
import { RestoreSelection, snapshotSelection, restoreSelection } from './utils/selection';
+
import { getFirstFocusTarget, getFocusTargets } from './utils/focus';
+
import { useLayoutEffect } from './utils/react';
+
import { contains } from './utils/element';
+
import { makePriorityHook } from './usePriority';
+
import { Ref } from './types';
+
+
const usePriority = makePriorityHook();
+
+
export interface ModalFocusOptions {
+
disabled?: boolean;
+
}
+
+
export function useModalFocus<T extends HTMLElement>(ref: Ref<T>, options?: ModalFocusOptions) {
+
const disabled = !!(options && options.disabled);
+
const hasPriority = usePriority(ref, disabled);
+
+
useLayoutEffect(() => {
+
if (!ref.current || !hasPriority || disabled) return;
+
+
let selection: RestoreSelection | null = null;
+
if (!document.activeElement || !ref.current.contains(document.activeElement)) {
+
const newTarget = getFirstFocusTarget(ref.current);
+
if (newTarget) {
+
selection = snapshotSelection(ref.current);
+
newTarget.focus();
+
}
+
}
+
+
function onBlur(event: FocusEvent) {
+
const parent = ref.current;
+
if (!parent || event.defaultPrevented) return;
+
+
if (contains(parent, event.target) && !contains(parent, event.relatedTarget)) {
+
const target = getFirstFocusTarget(parent);
+
if (target) target.focus();
+
}
+
}
+
+
function onKeyDown(event: KeyboardEvent) {
+
const parent = ref.current;
+
if (!parent || event.defaultPrevented) return;
+
+
if (event.code === 'Tab') {
+
const activeElement = document.activeElement as HTMLElement;
+
const targets = getFocusTargets(parent);
+
const index = targets.indexOf(activeElement);
+
if (event.shiftKey && index === 0) {
+
event.preventDefault();
+
targets[targets.length - 1].focus();
+
} else if (!event.shiftKey && index === targets.length - 1) {
+
event.preventDefault();
+
targets[0].focus();
+
}
+
+
}
+
}
+
+
document.body.addEventListener('focusout', onBlur);
+
document.addEventListener('keydown', onKeyDown);
+
+
return () => {
+
restoreSelection(selection);
+
document.body.removeEventListener('focusout', onBlur);
+
document.removeEventListener('keydown', onKeyDown);
+
};
+
}, [ref, hasPriority, disabled]);
+
}
+55
src/usePriority.ts
···
···
+
import { useState } from 'react';
+
import { useLayoutEffect } from './utils/react';
+
import { Ref } from './types';
+
+
/** Creates a priority stack of elements so that we can determine the "deepest" one to be the active hook */
+
export const makePriorityHook = () => {
+
const listeners: Set<Function> = new Set();
+
const priorityStack: HTMLElement[] = [];
+
+
const sortByHierarchy = (a: HTMLElement, b: HTMLElement) => {
+
const x = a.compareDocumentPosition(b);
+
return (
+
(x & 16 /* a contains b */ && -1) ||
+
(x & 8 /* b contains a */ && 1) ||
+
(x & 2 /* b follows a */ && -1) ||
+
(x & 4 /* a follows b */ && 1)
+
) || 0;
+
};
+
+
/** Indicates whether a given element on a stack of active priority hooks is the deepest element. */
+
return function usePriority<T extends HTMLElement>(ref: Ref<T>, disabled?: boolean): boolean {
+
function computeHasPriority(): boolean {
+
if (!ref.current) return false;
+
const tempStack = priorityStack.concat(ref.current).sort(sortByHierarchy);
+
return tempStack[tempStack.length - 1] === ref.current;
+
}
+
+
const isDisabled = !!disabled;
+
const [hasPriority, setHasPriority] = useState(computeHasPriority);
+
+
useLayoutEffect(() => {
+
if (!ref.current || isDisabled) return;
+
+
const { current } = ref;
+
+
function onChange() {
+
setHasPriority(computeHasPriority);
+
}
+
+
priorityStack.push(current);
+
priorityStack.sort(sortByHierarchy);
+
listeners.forEach(fn => fn());
+
listeners.add(onChange);
+
+
return () => {
+
const index = priorityStack.indexOf(current);
+
priorityStack.splice(index, 1);
+
listeners.delete(onChange);
+
listeners.forEach(fn => fn());
+
};
+
}, [ref, isDisabled]);
+
+
return hasPriority;
+
}
+
};
+8 -1
src/utils/element.ts
···
const index = parseInt(node.getAttribute('tabindex')!, 10);
return (
index === index &&
-
(node as HTMLElement).contentEditable !== 'true' &&
index
) || 0;
};
···
(node as HTMLElement).offsetHeight &&
node.getClientRects().length &&
getComputedStyle(node).visibility !== 'hidden'
);
export const contains = (owner: Element | null, node: Element | EventTarget | null) =>
···
const index = parseInt(node.getAttribute('tabindex')!, 10);
return (
index === index &&
+
!(node as HTMLElement).isContentEditable &&
index
) || 0;
};
···
(node as HTMLElement).offsetHeight &&
node.getClientRects().length &&
getComputedStyle(node).visibility !== 'hidden'
+
);
+
+
/** Returns whether an element accepts text input. */
+
export const isInputElement = (node: Element): boolean => !!(
+
node.tagName === 'INPUT'
+
|| node.tagName === 'TEXTAREA'
+
|| (node as HTMLElement).isContentEditable
);
export const contains = (owner: Element | null, node: Element | EventTarget | null) =>
+4 -2
src/utils/focus.ts
···
};
/** Returns the next (optionally in reverse) focus target given a target node. */
-
export const getNextFocusTarget = (node: HTMLElement, reverse?: boolean) => {
let current: Element | null = node;
while (current) {
let next: Element | null = current;
while (next = reverse ? next.previousElementSibling : next.nextElementSibling) {
-
if (hasFocusTargets(next)) {
const targets = getFocusTargets(next);
if (targets.length)
return targets[reverse ? targets.length - 1 : 0];
···
};
/** Returns the next (optionally in reverse) focus target given a target node. */
+
export const getNextFocusTarget = (node: HTMLElement, reverse?: boolean): HTMLElement | null => {
let current: Element | null = node;
while (current) {
let next: Element | null = current;
while (next = reverse ? next.previousElementSibling : next.nextElementSibling) {
+
if (isVisible(next) && !!node.matches(focusableSelectors)) {
+
return next as HTMLElement;
+
} else if (hasFocusTargets(next)) {
const targets = getFocusTargets(next);
if (targets.length)
return targets[reverse ? targets.length - 1 : 0];
+7
src/utils/react.ts
···
···
+
import { useEffect, useLayoutEffect } from 'react';
+
+
const useIsomorphicEffect = typeof window !== 'undefined'
+
? useLayoutEffect
+
: useEffect;
+
+
export { useIsomorphicEffect as useLayoutEffect };
+6 -8
src/utils/selection.ts
···
-
type NodeRef = { current?: HTMLElement | null | void } & HTMLElement;
-
interface RestoreInputSelection {
-
element: NodeRef,
method: 'setSelectionRange',
arguments: [number, number, 'forward' | 'backward' | 'none' | undefined],
}
interface RestoreActiveNode {
-
element: NodeRef,
method: 'focus',
}
interface RestoreSelectionRange {
-
element: NodeRef,
method: 'range',
range: Range
}
···
);
/** Snapshots the current focus or selection target, optinally using a ref if it's passed. */
-
export const snapshotSelection = (node?: NodeRef): RestoreSelection | null => {
const target = document.activeElement as HTMLElement | null;
-
const element: NodeRef | null = node && target && (node !== target || (node as any).current !== target) ? node : target;
if (!element || !target) {
return null;
} else if (isInputElement(target)) {
···
/** Restores a given snapshot of a selection, falling back to a simple focus. */
export const restoreSelection = (restore: RestoreSelection | null) => {
-
const target = restore && restore.element && (restore.element.current || restore.element);
if (!restore || !target || !target.parentNode) {
return;
} else if (restore.method === 'setSelectionRange' && isInputElement(target)) {
···
interface RestoreInputSelection {
+
element: HTMLElement,
method: 'setSelectionRange',
arguments: [number, number, 'forward' | 'backward' | 'none' | undefined],
}
interface RestoreActiveNode {
+
element: HTMLElement,
method: 'focus',
}
interface RestoreSelectionRange {
+
element: HTMLElement,
method: 'range',
range: Range
}
···
);
/** Snapshots the current focus or selection target, optinally using a ref if it's passed. */
+
export const snapshotSelection = (node?: HTMLElement | null): RestoreSelection | null => {
const target = document.activeElement as HTMLElement | null;
+
const element = node && target && node !== target ? node : target;
if (!element || !target) {
return null;
} else if (isInputElement(target)) {
···
/** Restores a given snapshot of a selection, falling back to a simple focus. */
export const restoreSelection = (restore: RestoreSelection | null) => {
+
const target = restore && restore.element;
if (!restore || !target || !target.parentNode) {
return;
} else if (restore.method === 'setSelectionRange' && isInputElement(target)) {