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

Reduce dismissable false-positive rate

+4 -16
src/__tests__/useDialogFocus.test.tsx
···
cy.get('@input')
.should('have.focus')
.should('have.value', 'test');
-
-
// pressing escape should refocus input
-
cy.get('li').first().focus();
-
cy.realPress('Escape');
-
cy.get('@input').should('have.focus');
});
it('supports nested dialogs', () => {
-
const InnerDialog = () => {
const ref = useRef<HTMLUListElement>(null);
-
useDialogFocus(ref);
return (
<ul ref={ref} role="dialog">
···
<ul ref={ref} role="dialog">
<li tabIndex={0}>Outer #1</li>
<li tabIndex={0} onFocus={() => setNested(true)}>Outer #2</li>
-
{nested && <InnerDialog />}
</ul>
)}
<button>after</button>
···
// tabs to last dialog
cy.realPress(['Shift', 'Tab']);
-
cy.focused().contains('Outer #2');
-
-
// arrows bring us back to the inner dialog
-
cy.realPress('ArrowUp');
-
cy.focused().contains('Inner #2');
// tab out of dialogs
cy.realPress('Tab');
cy.focused().contains('after');
-
// we can't reenter the dialogs
-
cy.realPress(['Shift', 'Tab']);
-
cy.get('@input').should('have.focus');
});
it('allows dialogs in semantic order', () => {
···
cy.get('@input')
.should('have.focus')
.should('have.value', 'test');
});
it('supports nested dialogs', () => {
+
const InnerDialog = ({ ownerRef }) => {
const ref = useRef<HTMLUListElement>(null);
+
useDialogFocus(ref, { ownerRef });
return (
<ul ref={ref} role="dialog">
···
<ul ref={ref} role="dialog">
<li tabIndex={0}>Outer #1</li>
<li tabIndex={0} onFocus={() => setNested(true)}>Outer #2</li>
+
{nested && <InnerDialog ownerRef={ref} />}
</ul>
)}
<button>after</button>
···
// tabs to last dialog
cy.realPress(['Shift', 'Tab']);
+
cy.get('@input').should('have.focus');
// tab out of dialogs
cy.realPress('Tab');
cy.focused().contains('after');
});
it('allows dialogs in semantic order', () => {
+18 -3
src/__tests__/usePriority.test.tsx
···
-
import React, { ReactNode, useState, useLayoutEffect, useRef } from 'react';
import { mount } from '@cypress/react';
import { makePriorityHook } from '../usePriority';
···
{ id, children = null }:
{ id: string, children?: ReactNode }
) => {
const ref = useRef<HTMLDivElement>(null);
const hasPriority = usePriority(ref);
useLayoutEffect(() => {
-
if (hasPriority && ref.current) {
ref.current!.focus();
}
-
}, [hasPriority, ref]);
return (
<div tabIndex={-1} ref={ref} id={id}>
···
+
import React, { ReactNode, useState, useReducer, useLayoutEffect, useRef } from 'react';
import { mount } from '@cypress/react';
import { makePriorityHook } from '../usePriority';
···
{ id, children = null }:
{ id: string, children?: ReactNode }
) => {
+
const forceUpdate = useReducer(() => [], [])[1]
const ref = useRef<HTMLDivElement>(null);
const hasPriority = usePriority(ref);
+
if (!(hasPriority as any).__marked) {
+
(hasPriority as any).__marked = true;
+
let current = hasPriority.current
+
Object.defineProperty(hasPriority, 'current', {
+
get() {
+
return current;
+
},
+
set(value) {
+
current = value;
+
forceUpdate();
+
},
+
})
+
}
+
useLayoutEffect(() => {
+
if (hasPriority.current && ref.current) {
ref.current!.focus();
}
+
}, [hasPriority.current, ref]);
return (
<div tabIndex={-1} ref={ref} id={id}>
+6 -6
src/useDialogFocus.ts
···
if (!element || event.defaultPrevented || willReceiveFocus) return;
const target = event.target as HTMLElement | null;
-
if (target && getFocusTargets(element).indexOf(target) > -1) {
selection = null;
willReceiveFocus = true;
}
···
if (
willReceiveFocus ||
-
(hasPriority && owner && contains(event.target, owner))
) {
if (!contains(ref.current, event.relatedTarget))
selection = snapshotSelection(owner);
···
// Check whether focus is about to move into the container and prevent it
if (
-
(hasPriority || !contains(ref.current, event.relatedTarget)) &&
contains(ref.current, event.target)
) {
event.preventDefault();
-
focusMovesForward = true;
// Get the next focus target of the container
focus(getNextFocusTarget(element, !focusMovesForward));
}
}
···
// Mark whether focus is moving forward for the `onFocus` handler
if (event.code === 'Tab') {
focusMovesForward = !event.shiftKey;
-
} else if (!hasPriority) {
return;
}
···
document.body.removeEventListener('focusin', onFocus);
document.removeEventListener('keydown', onKey);
};
-
}, [ref.current, disabled, hasPriority]);
}
···
if (!element || event.defaultPrevented || willReceiveFocus) return;
const target = event.target as HTMLElement | null;
+
if (target && contains(element, target)) {
selection = null;
willReceiveFocus = true;
}
···
if (
willReceiveFocus ||
+
(hasPriority.current && owner && contains(event.target, owner))
) {
if (!contains(ref.current, event.relatedTarget))
selection = snapshotSelection(owner);
···
// Check whether focus is about to move into the container and prevent it
if (
+
(hasPriority.current || !contains(ref.current, event.relatedTarget)) &&
contains(ref.current, event.target)
) {
event.preventDefault();
// Get the next focus target of the container
focus(getNextFocusTarget(element, !focusMovesForward));
+
focusMovesForward = true;
}
}
···
// Mark whether focus is moving forward for the `onFocus` handler
if (event.code === 'Tab') {
focusMovesForward = !event.shiftKey;
+
} else if (!hasPriority.current) {
return;
}
···
document.body.removeEventListener('focusin', onFocus);
document.removeEventListener('keydown', onKey);
};
+
}, [ref.current, disabled]);
}
+45 -18
src/useDismissable.ts
···
const { current: element } = ref;
if (!element || disabled) return;
-
function onFocusOut(event: FocusEvent) {
-
if (event.defaultPrevented) return;
const { target, relatedTarget } = event;
-
if (contains(element, target) && !contains(element, relatedTarget)) {
onDismissRef.current();
}
}
function onKey(event: KeyboardEvent) {
-
if (!event.isComposing && event.code === 'Escape') {
// The current dialog can be dismissed by pressing escape if it either has focus
// or it has priority
-
const active = document.activeElement;
-
if (hasPriority || (active && contains(element, active))) {
-
event.preventDefault();
-
onDismissRef.current();
-
}
}
}
function onClick(event: MouseEvent | TouchEvent) {
const { target } = event;
-
if (contains(element, target) || event.defaultPrevented) {
return;
-
}
-
-
// The current dialog can be dismissed by pressing outside of it if it either has
-
// focus or it has priority
-
const active = document.activeElement;
-
if (hasPriority || (active && contains(element, active))) {
event.preventDefault();
onDismissRef.current();
}
}
-
if (focusLoss) document.body.addEventListener('focusout', onFocusOut);
document.addEventListener('mousedown', onClick);
document.addEventListener('touchstart', onClick);
document.addEventListener('keydown', onKey);
return () => {
-
if (focusLoss) document.body.removeEventListener('focusout', onFocusOut);
document.removeEventListener('mousedown', onClick);
document.removeEventListener('touchstart', onClick);
···
const { current: element } = ref;
if (!element || disabled) return;
+
let willLoseFocus = false;
+
function onFocusOut(event: FocusEvent) {
const { target, relatedTarget } = event;
+
if (
+
!event.defaultPrevented &&
+
(relatedTarget || willLoseFocus) &&
+
contains(element, target) &&
+
!contains(element, relatedTarget)
+
) {
+
willLoseFocus = false;
+
onDismissRef.current();
+
}
+
}
+
+
function onFocusIn(event: FocusEvent) {
+
const { target } = event;
+
if (!event.defaultPrevented && !contains(element, target)) {
onDismissRef.current();
}
}
function onKey(event: KeyboardEvent) {
+
if (event.isComposing) {
+
return;
+
}
+
+
const active = document.activeElement;
+
if (
+
event.code === 'Escape' &&
+
(hasPriority.current || (active && contains(element, active)))
+
) {
// The current dialog can be dismissed by pressing escape if it either has focus
// or it has priority
+
event.preventDefault();
+
onDismissRef.current();
+
} else if (event.code === 'Tab') {
+
willLoseFocus = true;
}
}
function onClick(event: MouseEvent | TouchEvent) {
const { target } = event;
+
const active = document.activeElement;
+
if (event.defaultPrevented) {
return;
+
} else if (contains(element, target)) {
+
willLoseFocus = false;
+
return;
+
} else if (hasPriority || (active && contains(element, active))) {
+
// The current dialog can be dismissed by pressing outside of it if it either has
+
// focus or it has priority
event.preventDefault();
onDismissRef.current();
}
}
+
if (focusLoss) {
+
document.body.addEventListener('focusout', onFocusOut);
+
document.body.addEventListener('focusin', onFocusIn);
+
}
document.addEventListener('mousedown', onClick);
document.addEventListener('touchstart', onClick);
document.addEventListener('keydown', onKey);
return () => {
+
if (focusLoss) {
+
document.body.removeEventListener('focusout', onFocusOut);
+
document.body.removeEventListener('focusin', onFocusIn);
+
}
document.removeEventListener('mousedown', onClick);
document.removeEventListener('touchstart', onClick);
+3 -3
src/useModalFocus.ts
···
useLayoutEffect(() => {
const { current: element } = ref;
-
if (!element || !hasPriority || disabled) return;
let selection: RestoreSelection | null = null;
if (!document.activeElement || !contains(element, document.activeElement)) {
···
}
function onBlur(event: FocusEvent) {
-
if (!element || event.defaultPrevented) return;
if (
contains(element, event.target) &&
···
}
function onKeyDown(event: KeyboardEvent) {
-
if (!element || event.defaultPrevented) return;
if (event.code === 'Tab') {
const activeElement = document.activeElement as HTMLElement;
···
useLayoutEffect(() => {
const { current: element } = ref;
+
if (!element || disabled) return;
let selection: RestoreSelection | null = null;
if (!document.activeElement || !contains(element, document.activeElement)) {
···
}
function onBlur(event: FocusEvent) {
+
if (!hasPriority.current || !element || event.defaultPrevented) return;
if (
contains(element, event.target) &&
···
}
function onKeyDown(event: KeyboardEvent) {
+
if (!hasPriority.current || !element || event.defaultPrevented) return;
if (event.code === 'Tab') {
const activeElement = document.activeElement as HTMLElement;
+8 -7
src/usePriority.ts
···
return function usePriority<T extends HTMLElement>(
ref: Ref<T>,
disabled?: boolean
-
): boolean {
const isDisabled = !!disabled;
-
const [hasPriority, setHasPriority] = useState(() => {
-
if (!ref.current) return false;
-
const tempStack = priorityStack.concat(ref.current).sort(sortByHierarchy);
-
return tempStack[0] === ref.current;
-
});
useLayoutEffect(() => {
const { current: element } = ref;
if (!element || isDisabled) return;
function onChange() {
-
setHasPriority(() => priorityStack[0] === ref.current);
}
priorityStack.push(element);
···
return function usePriority<T extends HTMLElement>(
ref: Ref<T>,
disabled?: boolean
+
): { current: boolean } {
const isDisabled = !!disabled;
+
const [hasPriority] = useState(() => ({
+
current:
+
!!ref.current &&
+
priorityStack.concat(ref.current).sort(sortByHierarchy)[0] ===
+
ref.current,
+
}));
useLayoutEffect(() => {
const { current: element } = ref;
if (!element || isDisabled) return;
function onChange() {
+
hasPriority.current = priorityStack[0] === ref.current;
}
priorityStack.push(element);