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

Configure Feed

Select the types of activity you want to include in your feed.

Reduce dismissable false-positive rate

+84 -53
+4 -16
src/__tests__/useDialogFocus.test.tsx
··· 160 160 cy.get('@input') 161 161 .should('have.focus') 162 162 .should('have.value', 'test'); 163 - 164 - // pressing escape should refocus input 165 - cy.get('li').first().focus(); 166 - cy.realPress('Escape'); 167 - cy.get('@input').should('have.focus'); 168 163 }); 169 164 170 165 it('supports nested dialogs', () => { 171 - const InnerDialog = () => { 166 + const InnerDialog = ({ ownerRef }) => { 172 167 const ref = useRef<HTMLUListElement>(null); 173 - useDialogFocus(ref); 168 + useDialogFocus(ref, { ownerRef }); 174 169 175 170 return ( 176 171 <ul ref={ref} role="dialog"> ··· 195 190 <ul ref={ref} role="dialog"> 196 191 <li tabIndex={0}>Outer #1</li> 197 192 <li tabIndex={0} onFocus={() => setNested(true)}>Outer #2</li> 198 - {nested && <InnerDialog />} 193 + {nested && <InnerDialog ownerRef={ref} />} 199 194 </ul> 200 195 )} 201 196 <button>after</button> ··· 226 221 227 222 // tabs to last dialog 228 223 cy.realPress(['Shift', 'Tab']); 229 - cy.focused().contains('Outer #2'); 230 - 231 - // arrows bring us back to the inner dialog 232 - cy.realPress('ArrowUp'); 233 - cy.focused().contains('Inner #2'); 224 + cy.get('@input').should('have.focus'); 234 225 235 226 // tab out of dialogs 236 227 cy.realPress('Tab'); 237 228 cy.focused().contains('after'); 238 - // we can't reenter the dialogs 239 - cy.realPress(['Shift', 'Tab']); 240 - cy.get('@input').should('have.focus'); 241 229 }); 242 230 243 231 it('allows dialogs in semantic order', () => {
+18 -3
src/__tests__/usePriority.test.tsx
··· 1 - import React, { ReactNode, useState, useLayoutEffect, useRef } from 'react'; 1 + import React, { ReactNode, useState, useReducer, useLayoutEffect, useRef } from 'react'; 2 2 import { mount } from '@cypress/react'; 3 3 4 4 import { makePriorityHook } from '../usePriority'; ··· 9 9 { id, children = null }: 10 10 { id: string, children?: ReactNode } 11 11 ) => { 12 + const forceUpdate = useReducer(() => [], [])[1] 12 13 const ref = useRef<HTMLDivElement>(null); 13 14 const hasPriority = usePriority(ref); 14 15 16 + if (!(hasPriority as any).__marked) { 17 + (hasPriority as any).__marked = true; 18 + let current = hasPriority.current 19 + Object.defineProperty(hasPriority, 'current', { 20 + get() { 21 + return current; 22 + }, 23 + set(value) { 24 + current = value; 25 + forceUpdate(); 26 + }, 27 + }) 28 + } 29 + 15 30 useLayoutEffect(() => { 16 - if (hasPriority && ref.current) { 31 + if (hasPriority.current && ref.current) { 17 32 ref.current!.focus(); 18 33 } 19 - }, [hasPriority, ref]); 34 + }, [hasPriority.current, ref]); 20 35 21 36 return ( 22 37 <div tabIndex={-1} ref={ref} id={id}>
+6 -6
src/useDialogFocus.ts
··· 38 38 if (!element || event.defaultPrevented || willReceiveFocus) return; 39 39 40 40 const target = event.target as HTMLElement | null; 41 - if (target && getFocusTargets(element).indexOf(target) > -1) { 41 + if (target && contains(element, target)) { 42 42 selection = null; 43 43 willReceiveFocus = true; 44 44 } ··· 52 52 53 53 if ( 54 54 willReceiveFocus || 55 - (hasPriority && owner && contains(event.target, owner)) 55 + (hasPriority.current && owner && contains(event.target, owner)) 56 56 ) { 57 57 if (!contains(ref.current, event.relatedTarget)) 58 58 selection = snapshotSelection(owner); ··· 62 62 63 63 // Check whether focus is about to move into the container and prevent it 64 64 if ( 65 - (hasPriority || !contains(ref.current, event.relatedTarget)) && 65 + (hasPriority.current || !contains(ref.current, event.relatedTarget)) && 66 66 contains(ref.current, event.target) 67 67 ) { 68 68 event.preventDefault(); 69 - focusMovesForward = true; 70 69 // Get the next focus target of the container 71 70 focus(getNextFocusTarget(element, !focusMovesForward)); 71 + focusMovesForward = true; 72 72 } 73 73 } 74 74 ··· 78 78 // Mark whether focus is moving forward for the `onFocus` handler 79 79 if (event.code === 'Tab') { 80 80 focusMovesForward = !event.shiftKey; 81 - } else if (!hasPriority) { 81 + } else if (!hasPriority.current) { 82 82 return; 83 83 } 84 84 ··· 183 183 document.body.removeEventListener('focusin', onFocus); 184 184 document.removeEventListener('keydown', onKey); 185 185 }; 186 - }, [ref.current, disabled, hasPriority]); 186 + }, [ref.current, disabled]); 187 187 }
+45 -18
src/useDismissable.ts
··· 29 29 const { current: element } = ref; 30 30 if (!element || disabled) return; 31 31 32 - function onFocusOut(event: FocusEvent) { 33 - if (event.defaultPrevented) return; 32 + let willLoseFocus = false; 34 33 34 + function onFocusOut(event: FocusEvent) { 35 35 const { target, relatedTarget } = event; 36 - if (contains(element, target) && !contains(element, relatedTarget)) { 36 + if ( 37 + !event.defaultPrevented && 38 + (relatedTarget || willLoseFocus) && 39 + contains(element, target) && 40 + !contains(element, relatedTarget) 41 + ) { 42 + willLoseFocus = false; 43 + onDismissRef.current(); 44 + } 45 + } 46 + 47 + function onFocusIn(event: FocusEvent) { 48 + const { target } = event; 49 + if (!event.defaultPrevented && !contains(element, target)) { 37 50 onDismissRef.current(); 38 51 } 39 52 } 40 53 41 54 function onKey(event: KeyboardEvent) { 42 - if (!event.isComposing && event.code === 'Escape') { 55 + if (event.isComposing) { 56 + return; 57 + } 58 + 59 + const active = document.activeElement; 60 + if ( 61 + event.code === 'Escape' && 62 + (hasPriority.current || (active && contains(element, active))) 63 + ) { 43 64 // The current dialog can be dismissed by pressing escape if it either has focus 44 65 // or it has priority 45 - const active = document.activeElement; 46 - if (hasPriority || (active && contains(element, active))) { 47 - event.preventDefault(); 48 - onDismissRef.current(); 49 - } 66 + event.preventDefault(); 67 + onDismissRef.current(); 68 + } else if (event.code === 'Tab') { 69 + willLoseFocus = true; 50 70 } 51 71 } 52 72 53 73 function onClick(event: MouseEvent | TouchEvent) { 54 74 const { target } = event; 55 - if (contains(element, target) || event.defaultPrevented) { 75 + const active = document.activeElement; 76 + if (event.defaultPrevented) { 56 77 return; 57 - } 58 - 59 - // The current dialog can be dismissed by pressing outside of it if it either has 60 - // focus or it has priority 61 - const active = document.activeElement; 62 - if (hasPriority || (active && contains(element, active))) { 78 + } else if (contains(element, target)) { 79 + willLoseFocus = false; 80 + return; 81 + } else if (hasPriority || (active && contains(element, active))) { 82 + // The current dialog can be dismissed by pressing outside of it if it either has 83 + // focus or it has priority 63 84 event.preventDefault(); 64 85 onDismissRef.current(); 65 86 } 66 87 } 67 88 68 - if (focusLoss) document.body.addEventListener('focusout', onFocusOut); 89 + if (focusLoss) { 90 + document.body.addEventListener('focusout', onFocusOut); 91 + document.body.addEventListener('focusin', onFocusIn); 92 + } 69 93 70 94 document.addEventListener('mousedown', onClick); 71 95 document.addEventListener('touchstart', onClick); 72 96 document.addEventListener('keydown', onKey); 73 97 74 98 return () => { 75 - if (focusLoss) document.body.removeEventListener('focusout', onFocusOut); 99 + if (focusLoss) { 100 + document.body.removeEventListener('focusout', onFocusOut); 101 + document.body.removeEventListener('focusin', onFocusIn); 102 + } 76 103 77 104 document.removeEventListener('mousedown', onClick); 78 105 document.removeEventListener('touchstart', onClick);
+3 -3
src/useModalFocus.ts
··· 30 30 31 31 useLayoutEffect(() => { 32 32 const { current: element } = ref; 33 - if (!element || !hasPriority || disabled) return; 33 + if (!element || disabled) return; 34 34 35 35 let selection: RestoreSelection | null = null; 36 36 if (!document.activeElement || !contains(element, document.activeElement)) { ··· 40 40 } 41 41 42 42 function onBlur(event: FocusEvent) { 43 - if (!element || event.defaultPrevented) return; 43 + if (!hasPriority.current || !element || event.defaultPrevented) return; 44 44 45 45 if ( 46 46 contains(element, event.target) && ··· 52 52 } 53 53 54 54 function onKeyDown(event: KeyboardEvent) { 55 - if (!element || event.defaultPrevented) return; 55 + if (!hasPriority.current || !element || event.defaultPrevented) return; 56 56 57 57 if (event.code === 'Tab') { 58 58 const activeElement = document.activeElement as HTMLElement;
+8 -7
src/usePriority.ts
··· 22 22 return function usePriority<T extends HTMLElement>( 23 23 ref: Ref<T>, 24 24 disabled?: boolean 25 - ): boolean { 25 + ): { current: boolean } { 26 26 const isDisabled = !!disabled; 27 - const [hasPriority, setHasPriority] = useState(() => { 28 - if (!ref.current) return false; 29 - const tempStack = priorityStack.concat(ref.current).sort(sortByHierarchy); 30 - return tempStack[0] === ref.current; 31 - }); 27 + const [hasPriority] = useState(() => ({ 28 + current: 29 + !!ref.current && 30 + priorityStack.concat(ref.current).sort(sortByHierarchy)[0] === 31 + ref.current, 32 + })); 32 33 33 34 useLayoutEffect(() => { 34 35 const { current: element } = ref; 35 36 if (!element || isDisabled) return; 36 37 37 38 function onChange() { 38 - setHasPriority(() => priorityStack[0] === ref.current); 39 + hasPriority.current = priorityStack[0] === ref.current; 39 40 } 40 41 41 42 priorityStack.push(element);