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

Compare changes

Choose any two refs to compare.

+26
.github/workflows/mirror.yml
···
+
# Mirrors to https://tangled.sh/@kitten.sh (knot.kitten.sh)
+
name: Mirror (Git Backup)
+
on:
+
push:
+
branches:
+
- main
+
jobs:
+
mirror:
+
runs-on: ubuntu-latest
+
steps:
+
- name: Checkout repository
+
uses: actions/checkout@v4
+
with:
+
fetch-depth: 0
+
fetch-tags: true
+
- name: Mirror
+
env:
+
MIRROR_SSH_KEY: ${{ secrets.MIRROR_SSH_KEY }}
+
GIT_SSH_COMMAND: 'ssh -o StrictHostKeyChecking=yes'
+
run: |
+
mkdir -p ~/.ssh
+
echo "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
+
chmod 600 ~/.ssh/id_rsa
+
ssh-keyscan -H knot.kitten.sh >> ~/.ssh/known_hosts
+
git remote add mirror "git@knot.kitten.sh:kitten.sh/${GITHUB_REPOSITORY#*/}"
+
git push --mirror mirror
+1 -1
package.json
···
{
"name": "use-interactions",
"description": "Reusable and common web interactions with WCAG accessibility criteria for React",
-
"version": "0.1.0",
+
"version": "0.1.5",
"main": "dist/use-interactions.js",
"module": "dist/use-interactions.es.js",
"types": "dist/use-interactions.d.ts",
+1 -1
src/types.ts
···
import type { CSSProperties } from 'react';
-
export interface Ref<T extends HTMLElement> {
+
export interface Ref<T extends HTMLElement | SVGElement> {
readonly current: T | null;
}
+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);
}
+22 -13
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) {
const { target, relatedTarget } = event;
if (
!event.defaultPrevented &&
-
(relatedTarget || willLoseFocus) &&
+
willLoseFocus &&
contains(element, target) &&
!contains(element, relatedTarget)
) {
···
function onFocusIn(event: FocusEvent) {
const { target } = event;
-
if (!event.defaultPrevented && !contains(element, target)) {
+
if (
+
!event.defaultPrevented &&
+
willLoseFocus &&
+
!contains(element, target)
+
) {
+
willLoseFocus = false;
onDismissRef.current(event);
}
}
···
}
}
+
const opts = { capture: true } as any;
+
const touchOpts = { capture: true, passive: false } as any;
+
if (focusLoss) {
-
document.body.addEventListener('focusout', onFocusOut, true);
-
document.body.addEventListener('focusin', onFocusIn, true);
+
root.addEventListener('focusout', onFocusOut, opts);
+
root.addEventListener('focusin', onFocusIn, opts);
}
-
document.addEventListener('click', onClick, true);
-
document.addEventListener('touchstart', onClick, true);
-
document.addEventListener('keydown', onKey, true);
+
root.addEventListener('click', onClick, opts);
+
root.addEventListener('touchstart', onClick, touchOpts);
+
root.addEventListener('keydown', onKey, opts);
return () => {
if (focusLoss) {
-
document.body.removeEventListener('focusout', onFocusOut, true);
-
document.body.removeEventListener('focusin', onFocusIn, true);
+
root.removeEventListener('focusout', onFocusOut, opts);
+
root.removeEventListener('focusin', onFocusIn, opts);
}
-
document.removeEventListener('click', onClick, true);
-
document.removeEventListener('touchstart', onClick, true);
-
document.removeEventListener('keydown', onKey, true);
+
root.removeEventListener('click', onClick, opts);
+
root.removeEventListener('touchstart', onClick, touchOpts);
+
root.removeEventListener('keydown', onKey, opts);
};
}, [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;