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

Fix first focus targets for modals/dialogs

In modals and dialogs we'll generally only move focus to
the first target, if said target is marked with `[autofocus]`
otherwise we'll focus the dialog/modal element itself and
let the regular tab order kick in.

See: https://github.com/KittyGiraudel/a11y-dialog/blob/b7da40433682ee01798aa601c41c3ae8869e6719/a11y-dialog.js#L310-L314

Changed files
+27 -11
src
+4 -2
src/__tests__/useModalFocus.test.tsx
··· 25 25 </main> 26 26 ); 27 27 28 - // starts out with first element available 29 - cy.focused().contains('Focus 1'); 28 + // starts out with first modal element available 29 + cy.focused().should('have.attr', 'aria-modal', 'true') 30 30 31 31 // cycles through the modal's focusable targets only 32 + cy.realPress('Tab'); 33 + cy.focused().contains('Focus 1'); 32 34 cy.realPress('Tab'); 33 35 cy.focused().contains('Focus 2'); 34 36 cy.realPress('Tab');
+10 -6
src/useModalFocus.ts
··· 3 3 snapshotSelection, 4 4 restoreSelection, 5 5 } from './utils/selection'; 6 - import { getFirstFocusTarget, getFocusTargets } from './utils/focus'; 6 + 7 + import { 8 + getAutofocusTarget, 9 + getFirstFocusTarget, 10 + getFocusTargets, 11 + } from './utils/focus'; 12 + 7 13 import { useLayoutEffect } from './utils/react'; 8 14 import { contains } from './utils/element'; 9 15 import { makePriorityHook } from './usePriority'; ··· 30 36 !document.activeElement || 31 37 !ref.current.contains(document.activeElement) 32 38 ) { 33 - const newTarget = getFirstFocusTarget(ref.current); 34 - if (newTarget) { 35 - selection = snapshotSelection(ref.current); 36 - newTarget.focus(); 37 - } 39 + const newTarget = getAutofocusTarget(ref.current); 40 + selection = snapshotSelection(ref.current); 41 + newTarget.focus(); 38 42 } 39 43 40 44 function onBlur(event: FocusEvent) {
+13 -3
src/utils/focus.ts
··· 60 60 }; 61 61 62 62 /** Returns the first focus target that should be focused automatically. */ 63 - export const getFirstFocusTarget = (node: HTMLElement): HTMLElement | null => { 64 - const targets = getFocusTargets(node); 65 - return targets.find(x => x.matches('[autofocus]')) || targets[0] || null; 63 + export const getFirstFocusTarget = (node: HTMLElement): HTMLElement | null => 64 + getFocusTargets(node)[0] || null; 65 + 66 + /** Returns the first focus target that should be focused automatically in a modal/dialog. */ 67 + export const getAutofocusTarget = (node: HTMLElement): HTMLElement => { 68 + const elements = node.querySelectorAll(focusableSelectors); 69 + for (let i = 0, l = elements.length; i < l; i++) { 70 + const element = elements[i] as HTMLElement; 71 + if (isVisible(element) && element.matches('[autofocus]')) return element; 72 + } 73 + 74 + node.setAttribute('tabindex', '-1'); 75 + return node; 66 76 }; 67 77 68 78 /** Returns the next (optionally in reverse) focus target given a target node. */