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

Implement useOptionFocus and virtual click handling

+1
src/index.ts
···
export * from './useModalFocus';
export * from './useDialogFocus';
export * from './useMenuFocus';
+
export * from './useOptionFocus';
export * from './useDismissable';
export * from './useScrollRestoration';
export * from './useStyleTransition';
+18 -7
src/useDialogFocus.ts
···
getFirstFocusTarget,
getFocusTargets,
getNextFocusTarget,
+
focus,
} from './utils/focus';
+
import { click } from './utils/click';
import { useLayoutEffect } from './utils/react';
-
import { contains, focus, isInputElement } from './utils/element';
+
import { contains, isInputElement } from './utils/element';
import { makePriorityHook } from './usePriority';
import { Ref } from './types';
···
let focusMovesForward = true;
function onClick(event: MouseEvent) {
-
if (!element || event.defaultPrevented) return;
+
if (!element || event.defaultPrevented || willReceiveFocus) return;
const target = event.target as HTMLElement | null;
if (target && getFocusTargets(element).indexOf(target) > -1) {
···
const nextIndex =
focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0;
willReceiveFocus = true;
-
focusTargets[nextIndex].focus();
+
focus(focusTargets[nextIndex]);
} else if (
(!isInputElement(active) && event.code === 'ArrowLeft') ||
event.code === 'ArrowUp'
···
const nextIndex =
focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1;
willReceiveFocus = true;
-
focusTargets[nextIndex].focus();
+
focus(focusTargets[nextIndex]);
} else if (event.code === 'Home') {
// Implement Home => first item
event.preventDefault();
willReceiveFocus = true;
-
focusTargets[0].focus();
+
focus(focusTargets[0]);
} else if (event.code === 'End') {
// Implement End => last item
event.preventDefault();
willReceiveFocus = true;
-
focusTargets[focusTargets.length - 1].focus();
+
focus(focusTargets[focusTargets.length - 1]);
} else if (
owner &&
!contains(ref.current, owner) &&
···
const newTarget = getFirstFocusTarget(element);
if (newTarget) {
willReceiveFocus = true;
-
newTarget.focus();
+
focus(newTarget);
}
+
} else if (
+
(event.code === 'Enter' || event.code === 'Space') &&
+
focusTargets.indexOf(active) > -1 &&
+
!isInputElement(active)
+
) {
+
// Implement virtual click / activation for list items
+
event.preventDefault();
+
willReceiveFocus = true;
+
click(active);
} else if (
owner &&
isInputElement(owner) &&
+15 -7
src/useMenuFocus.ts
···
snapshotSelection,
restoreSelection,
} from './utils/selection';
-
import { getFirstFocusTarget, getFocusTargets } from './utils/focus';
+
import { getFirstFocusTarget, getFocusTargets, focus } from './utils/focus';
+
import { click } from './utils/click';
import { useLayoutEffect } from './utils/react';
import { contains, isInputElement } from './utils/element';
import { Ref } from './types';
···
const focusIndex = focusTargets.indexOf(active);
const nextIndex =
focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0;
-
focusTargets[nextIndex].focus();
+
focus(focusTargets[nextIndex]);
} else if (
(!isInputElement(active) && event.code === 'ArrowLeft') ||
event.code === 'ArrowUp'
···
const focusIndex = focusTargets.indexOf(active);
const nextIndex =
focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1;
-
focusTargets[nextIndex].focus();
+
focus(focusTargets[nextIndex]);
} else if (event.code === 'Home') {
// Implement Home => first item
event.preventDefault();
-
focusTargets[0].focus();
+
focus(focusTargets[0]);
} else if (event.code === 'End') {
// Implement End => last item
event.preventDefault();
-
focusTargets[focusTargets.length - 1].focus();
+
focus(focusTargets[focusTargets.length - 1]);
} else if (
owner &&
isInputElement(owner) &&
···
event.code === 'Enter'
) {
// Move focus to first target when enter is pressed
-
const newTarget = getFirstFocusTarget(element);
-
if (newTarget) newTarget.focus();
+
focus(getFirstFocusTarget(element));
} else if (
owner &&
!contains(ref.current, owner) &&
···
// Restore selection if escape is pressed
event.preventDefault();
restoreSelection(selection);
+
} else if (
+
(event.code === 'Enter' || event.code === 'Space') &&
+
focusTargets.indexOf(active) > -1 &&
+
!isInputElement(active)
+
) {
+
// Implement virtual click / activation for list items
+
event.preventDefault();
+
click(active);
} else if (
owner &&
isInputElement(owner) &&
+44
src/useOptionFocus.ts
···
+
import { isFocusTarget } from './utils/focus';
+
import { useLayoutEffect } from './utils/react';
+
import { click } from './utils/click';
+
import { contains, isInputElement } from './utils/element';
+
import { makePriorityHook } from './usePriority';
+
import { Ref } from './types';
+
+
const usePriority = makePriorityHook();
+
+
export interface OptionFocusOptions {
+
disabled?: boolean;
+
}
+
+
export function useOptionFocus<T extends HTMLElement>(
+
ref: Ref<T>,
+
options?: OptionFocusOptions
+
) {
+
const disabled = !!(options && options.disabled);
+
const hasPriority = usePriority(ref, disabled);
+
+
useLayoutEffect(() => {
+
const { current: element } = ref;
+
// NOTE: This behaviour isn't necessary for input elements
+
if (!element || disabled || isInputElement(element)) return;
+
+
function onKey(event: KeyboardEvent) {
+
if (!element || event.defaultPrevented || event.isComposing) return;
+
+
const active = document.activeElement as HTMLElement;
+
if (!isFocusTarget(element) || !contains(active, element)) {
+
// Do nothing if the current item is not a target or not focused
+
return;
+
} else if (event.code === 'Space' || event.code === 'Enter') {
+
event.preventDefault();
+
click(element);
+
}
+
}
+
+
element.addEventListener('keydown', onKey);
+
return () => {
+
element.removeEventListener('keydown', onKey);
+
};
+
}, [ref.current, disabled, hasPriority]);
+
}
+29
src/utils/click.ts
···
+
import { contains } from './element';
+
import { focus } from './focus';
+
+
const clickableSelectors = [
+
'[contenteditable]',
+
'input:not([type="hidden"]):not([disabled])',
+
'button:not([disabled])',
+
'select:not([disabled])',
+
'a[href]',
+
].join(',');
+
+
export const click = (node: Element) => {
+
const activeElement = document.activeElement;
+
if (!activeElement || contains(node, activeElement)) {
+
let target: Element | null = node;
+
+
if (node.tagName === 'LABEL') {
+
const forId = node.getAttribute('for');
+
target = forId ? document.getElementById(forId) : null;
+
}
+
+
if (!target || !node.matches(clickableSelectors)) {
+
target = node.querySelector(clickableSelectors);
+
}
+
+
if (target) (target as HTMLElement).click();
+
focus(activeElement);
+
}
+
};
-8
src/utils/element.ts
···
owner &&
(owner === node || (owner as Element).contains(node as Element))
);
-
-
export const focus = (element: Element | null) => {
-
if (element) {
-
(element as HTMLElement).focus();
-
} else if (document.activeElement) {
-
(document.activeElement as HTMLElement).blur();
-
}
-
};
+13
src/utils/focus.ts
···
return a[1] === a[1] ? a[0] - b[0] : a[1] - a[1];
};
+
/** Returns whether this node is focusable. */
+
export const isFocusTarget = (node: Element): boolean =>
+
!!node.matches(focusableSelectors) && isVisible(node);
+
/** Returns whether this node may contain focusable elements. */
export const hasFocusTargets = (node: Element): boolean =>
!node.matches(excludeSelector) &&
···
return null;
};
+
+
/** Focuses the given node or blurs if null is passed. */
+
export const focus = (node: Element | null) => {
+
if (node) {
+
(node as HTMLElement).focus();
+
} else if (document.activeElement) {
+
(document.activeElement as HTMLElement).blur();
+
}
+
};