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

Respect shadow DOM root in root node / activeElement

+17 -11
src/useDialogFocus.ts
···
import { snapshotSelection, restoreSelection } from './utils/selection';
-
import { getFocusTargets, getNextFocusTarget, focus } from './utils/focus';
+
import {
+
getFocusTargets,
+
getNextFocusTarget,
+
getActive,
+
focus,
+
} from './utils/focus';
import { click } from './utils/click';
import { useLayoutEffect } from './utils/react';
-
import { contains, isInputElement } from './utils/element';
+
import { contains, getRoot, isInputElement } from './utils/element';
import { makePriorityHook } from './usePriority';
import { Ref } from './types';
···
const { current: element } = ref;
if (!element || disabled) return;
+
const root = getRoot(element);
let selection = snapshotSelection(ownerRef && ownerRef.current);
let willReceiveFocus = false;
let focusMovesForward = true;
···
return;
}
-
const active = document.activeElement as HTMLElement;
+
const active = getActive();
const owner =
(ownerRef && ownerRef.current) || (selection && selection.element);
const focusTargets = getFocusTargets(element);
···
) {
// Implement forward movement in focus targets
event.preventDefault();
-
const focusIndex = focusTargets.indexOf(active);
+
const focusIndex = focusTargets.indexOf(active!);
const nextIndex =
focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0;
willReceiveFocus = true;
···
) {
// Implement backward movement in focus targets
event.preventDefault();
-
const focusIndex = focusTargets.indexOf(active);
+
const focusIndex = focusTargets.indexOf(active!);
const nextIndex =
focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1;
willReceiveFocus = true;
···
}
} else if (
(event.code === 'Enter' || event.code === 'Space') &&
-
focusTargets.indexOf(active) > -1 &&
+
focusTargets.indexOf(active!) > -1 &&
!isInputElement(active)
) {
// Implement virtual click / activation for list items
···
}
element.addEventListener('mousedown', onClick, true);
-
document.body.addEventListener('focusin', onFocus);
-
document.addEventListener('keydown', onKey);
+
root.addEventListener('focusin', onFocus);
+
root.addEventListener('keydown', onKey);
return () => {
element.removeEventListener('mousedown', onClick);
-
document.body.removeEventListener('focusin', onFocus);
-
document.removeEventListener('keydown', onKey);
+
root.removeEventListener('focusin', onFocus);
+
root.removeEventListener('keydown', onKey);
-
const active = document.activeElement as HTMLElement;
+
const active = getActive();
if (!active || contains(element, active)) {
restoreSelection(selection);
}
+12 -11
src/useDismissable.ts
···
import { useRef } from 'react';
import { useLayoutEffect } from './utils/react';
-
import { contains } from './utils/element';
+
import { contains, getRoot } from './utils/element';
import { makePriorityHook } from './usePriority';
import { Ref } from './types';
···
const { current: element } = ref;
if (!element || disabled) return;
+
const root = getRoot(element);
let willLoseFocus = false;
function onFocusOut(event: FocusEvent) {
···
}
if (focusLoss) {
-
document.body.addEventListener('focusout', onFocusOut, true);
-
document.body.addEventListener('focusin', onFocusIn, true);
+
root.addEventListener('focusout', onFocusOut, true);
+
root.addEventListener('focusin', onFocusIn, true);
}
-
document.addEventListener('click', onClick, true);
-
document.addEventListener('touchstart', onClick, true);
-
document.addEventListener('keydown', onKey, true);
+
root.addEventListener('click', onClick, true);
+
root.addEventListener('touchstart', onClick, true);
+
root.addEventListener('keydown', onKey, true);
return () => {
if (focusLoss) {
-
document.body.removeEventListener('focusout', onFocusOut, true);
-
document.body.removeEventListener('focusin', onFocusIn, true);
+
root.removeEventListener('focusout', onFocusOut, true);
+
root.removeEventListener('focusin', onFocusIn, true);
}
-
document.removeEventListener('click', onClick, true);
-
document.removeEventListener('touchstart', onClick, true);
-
document.removeEventListener('keydown', onKey, true);
+
root.removeEventListener('click', onClick, true);
+
root.removeEventListener('touchstart', onClick, true);
+
root.removeEventListener('keydown', onKey, true);
};
}, [ref.current, hasPriority, disabled, focusLoss]);
}
+13 -11
src/useMenuFocus.ts
···
snapshotSelection,
restoreSelection,
} from './utils/selection';
-
import { getFocusTargets, focus } from './utils/focus';
+
+
import { getActive, getFocusTargets, focus } from './utils/focus';
import { click } from './utils/click';
import { useLayoutEffect } from './utils/react';
-
import { contains, isInputElement } from './utils/element';
+
import { contains, getRoot, isInputElement } from './utils/element';
import { Ref } from './types';
export interface MenuFocusOptions {
···
const { current: element } = ref;
if (!element || disabled) return;
+
const root = getRoot(element);
let selection: RestoreSelection | null = null;
function onFocus(event: FocusEvent) {
···
const owner =
(ownerRef && ownerRef.current) || (selection && selection.element);
-
const active = document.activeElement as HTMLElement;
+
const active = getActive();
const focusTargets = getFocusTargets(element);
if (
!focusTargets.length ||
···
) {
// Implement forward movement in focus targets
event.preventDefault();
-
const focusIndex = focusTargets.indexOf(active);
+
const focusIndex = focusTargets.indexOf(active!);
const nextIndex =
focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0;
focus(focusTargets[nextIndex]);
···
) {
// Implement backward movement in focus targets
event.preventDefault();
-
const focusIndex = focusTargets.indexOf(active);
+
const focusIndex = focusTargets.indexOf(active!);
const nextIndex =
focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1;
focus(focusTargets[nextIndex]);
···
restoreSelection(selection);
} else if (
(event.code === 'Enter' || event.code === 'Space') &&
-
focusTargets.indexOf(active) > -1 &&
+
focusTargets.indexOf(active!) > -1 &&
!isInputElement(active)
) {
// Implement virtual click / activation for list items
···
}
}
-
document.body.addEventListener('focusin', onFocus);
-
document.addEventListener('keydown', onKey);
+
root.addEventListener('focusin', onFocus);
+
root.addEventListener('keydown', onKey);
return () => {
-
document.body.removeEventListener('focusin', onFocus);
-
document.removeEventListener('keydown', onKey);
+
root.removeEventListener('focusin', onFocus);
+
root.removeEventListener('keydown', onKey);
-
const active = document.activeElement as HTMLElement;
+
const active = getActive();
if (!active || contains(element, active)) {
restoreSelection(selection);
}
+13 -14
src/useModalFocus.ts
···
restoreSelection,
} from './utils/selection';
-
import { getAutofocusTarget, getFocusTargets } from './utils/focus';
-
+
import { getActive, getAutofocusTarget, getFocusTargets } from './utils/focus';
import { useLayoutEffect } from './utils/react';
-
import { contains } from './utils/element';
+
import { contains, getRoot } from './utils/element';
import { makePriorityHook } from './usePriority';
import { Ref } from './types';
···
const hasPriority = usePriority(ref, disabled);
useLayoutEffect(() => {
-
if (disabled) return;
+
const { current: element } = ref;
+
if (!element || disabled) return;
+
const root = getRoot(element);
+
const active = getActive();
let selection: RestoreSelection | null = null;
-
if (
-
!document.activeElement ||
-
!contains(ref.current, document.activeElement)
-
) {
+
if (!active || !contains(element, active)) {
const newTarget = ref.current ? getAutofocusTarget(ref.current) : null;
selection = snapshotSelection();
if (newTarget) newTarget.focus();
···
if (!hasPriority.current || !element || event.defaultPrevented) return;
if (event.code === 'Tab') {
-
const activeElement = document.activeElement as HTMLElement;
+
const activeElement = getActive()!;
const targets = getFocusTargets(element);
const index = targets.indexOf(activeElement);
if (event.shiftKey && index === 0) {
···
}
}
-
document.body.addEventListener('focusout', onBlur);
-
document.addEventListener('keydown', onKeyDown);
+
root.addEventListener('focusout', onBlur);
+
root.addEventListener('keydown', onKeyDown);
return () => {
-
document.body.removeEventListener('focusout', onBlur);
-
document.removeEventListener('keydown', onKeyDown);
+
root.removeEventListener('focusout', onBlur);
+
root.removeEventListener('keydown', onKeyDown);
restoreSelection(selection);
};
-
}, [ref, hasPriority, disabled]);
+
}, [ref.current, hasPriority, disabled]);
}
+2 -2
src/useOptionFocus.ts
···
-
import { isFocusTarget } from './utils/focus';
+
import { isFocusTarget, getActive } from './utils/focus';
import { useLayoutEffect } from './utils/react';
import { click } from './utils/click';
import { contains, isInputElement } from './utils/element';
···
function onKey(event: KeyboardEvent) {
if (!element || event.defaultPrevented || event.isComposing) return;
-
const active = document.activeElement as HTMLElement;
+
const active = getActive();
if (!isFocusTarget(element) || !contains(active, element)) {
// Do nothing if the current item is not a target or not focused
return;
+5 -3
src/utils/click.ts
···
-
import { clickableSelectors, focus } from './focus';
+
import { clickableSelectors, focus, getActive } from './focus';
import { contains } from './element';
-
export const click = (node: Element) => {
-
const activeElement = document.activeElement;
+
export const click = (node: Element | null) => {
+
if (!node) return;
+
+
const activeElement = getActive();
if (!activeElement || contains(node, activeElement)) {
let target: Element | null = node;
if (node.tagName === 'LABEL') {
+6 -2
src/utils/element.ts
···
node.matches(excludeSelector) && node.getClientRects().length > 0;
/** Returns whether an element accepts text input. */
-
export const isInputElement = (node: Element): boolean =>
-
node.matches(inputSelectors);
+
export const isInputElement = (node: Element | null): boolean =>
+
!!node && node.matches(inputSelectors);
export const contains = (
owner: Element | EventTarget | null,
···
owner &&
(owner === node || (owner as Element).contains(node as Element))
);
+
+
/** Returns the root element of the input element */
+
export const getRoot = (node: Element): HTMLElement =>
+
(node.getRootNode() || document.body) as HTMLElement;
+11 -2
src/utils/focus.ts
···
export const focus = (node: Element | null) => {
if (node) {
(node as HTMLElement).focus();
-
} else if (document.activeElement) {
-
(document.activeElement as HTMLElement).blur();
+
} else {
+
const active = getActive();
+
if (active) active.blur();
}
};
+
+
/** Returns the currently active element, even if it’s contained in a shadow root. */
+
export const getActive = (): HTMLElement | null => {
+
let element = document.activeElement;
+
while (element && element.shadowRoot)
+
element = element.shadowRoot.activeElement;
+
return element as HTMLElement | null;
+
};
+2 -1
src/utils/selection.ts
···
import { contains } from './element';
+
import { getActive } from './focus';
export interface RestoreSelection {
element: HTMLElement;
···
export const snapshotSelection = (
node?: HTMLElement | null
): RestoreSelection | null => {
-
const target = document.activeElement as HTMLElement | null;
+
const target = getActive();
const element = node && target && node !== target ? node : target;
if (!element || !target) {
return null;