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

Add tests for useDismissable

Changed files
+137 -6
src
+123
src/__tests__/useDismissable.test.tsx
··· 1 + import React, { useState, useRef } from 'react'; 2 + import { mount } from '@cypress/react'; 3 + 4 + import { useDismissable } from '../useDismissable'; 5 + 6 + const Dialog = ({ focusLoss }: { focusLoss?: boolean }) => { 7 + const [visible, setVisible] = useState(true); 8 + const ref = useRef<HTMLDivElement>(null); 9 + 10 + const onDismiss = () => setVisible(false); 11 + useDismissable(ref, onDismiss, { focusLoss, disabled: !visible }); 12 + 13 + return ( 14 + <div ref={ref} role="dialog" style={{ display: visible ? 'block' : 'none' }}> 15 + <button className="inside">focusable</button> 16 + </div> 17 + ); 18 + }; 19 + 20 + it('is dismissed by clicking outside', () => { 21 + mount( 22 + <main> 23 + <button className="outside">outside</button> 24 + <Dialog /> 25 + </main> 26 + ); 27 + 28 + cy.get('.inside').as('inside').click(); 29 + cy.get('@inside').should('be.visible'); 30 + cy.get('.outside').first().click(); 31 + cy.get('@inside').should('not.be.visible'); 32 + }); 33 + 34 + it('is not dismissed by clicking outside when it does not have priority', () => { 35 + mount( 36 + <main> 37 + <button className="outside">outside</button> 38 + <Dialog /> 39 + <Dialog /> 40 + </main> 41 + ); 42 + 43 + cy.get('.inside').as('inside').should('be.visible'); 44 + // at first not dismissed 45 + cy.get('.outside').first().click(); 46 + cy.get('@inside').should('be.visible'); 47 + // dismissed when the second Dialog loses focus 48 + cy.get('.outside').first().click(); 49 + cy.get('@inside').should('not.be.visible'); 50 + }); 51 + 52 + it('is dismissed by pressing Escape', () => { 53 + mount( 54 + <main> 55 + <button className="outside">outside</button> 56 + <Dialog /> 57 + </main> 58 + ); 59 + 60 + cy.get('.inside').as('inside').should('be.visible'); 61 + cy.realPress('Escape'); 62 + cy.get('@inside').should('not.be.visible'); 63 + }); 64 + 65 + it('is not dismissed by pressing Escape when it does not have priority', () => { 66 + mount( 67 + <main> 68 + <button className="outside">outside</button> 69 + <Dialog /> 70 + <Dialog /> 71 + </main> 72 + ); 73 + 74 + cy.get('.inside').as('inside').should('be.visible'); 75 + // at first not dismissed 76 + cy.realPress('Escape'); 77 + cy.get('@inside').should('be.visible'); 78 + // dismissed when the second Dialog loses focus 79 + cy.realPress('Escape'); 80 + cy.get('@inside').should('not.be.visible'); 81 + }); 82 + 83 + it('is dismissed without priority when it has focus', () => { 84 + const Second = () => { 85 + const ref = useRef<HTMLDivElement>(null); 86 + useDismissable(ref, () => {}); 87 + return <div ref={ref} />; 88 + }; 89 + 90 + mount( 91 + <main> 92 + <button className="outside">outside</button> 93 + <Dialog /> 94 + <Second /> 95 + </main> 96 + ); 97 + 98 + cy.get('.inside').as('inside').should('be.visible'); 99 + // not dismissed with escape press 100 + cy.realPress('Escape'); 101 + cy.get('@inside').should('be.visible'); 102 + // is dismissed when it has focus 103 + cy.get('@inside').focus(); 104 + cy.realPress('Escape'); 105 + cy.get('@inside').should('not.be.visible'); 106 + }); 107 + 108 + it('is dismissed when focus moves out of it, with focus loss active', () => { 109 + mount( 110 + <main> 111 + <button className="outside">outside</button> 112 + <Dialog focusLoss /> 113 + </main> 114 + ); 115 + 116 + cy.get('.inside').as('inside').should('be.visible'); 117 + cy.get('@inside').focus(); 118 + cy.get('@inside').should('be.visible'); 119 + // is dismissed when it loses focus 120 + cy.realPress(['Shift', 'Tab']); 121 + cy.focused().contains('outside'); 122 + cy.get('@inside').should('not.be.visible'); 123 + });
+14 -6
src/useDismissable.ts
··· 1 + import { useRef } from 'react'; 1 2 import { useLayoutEffect } from './utils/react'; 2 3 import { contains } from './utils/element'; 3 4 import { makePriorityHook } from './usePriority'; ··· 7 8 8 9 export interface DismissableOptions { 9 10 focusLoss?: boolean; 11 + disabled?: boolean; 10 12 } 11 13 12 14 export function useDismissable<T extends HTMLElement>( ··· 15 17 options?: DismissableOptions 16 18 ) { 17 19 const focusLoss = !!(options && options.focusLoss); 18 - const hasPriority = usePriority(ref); 20 + const disabled = !!(options && options.disabled); 21 + const hasPriority = usePriority(ref, disabled); 22 + const onDismissRef = useRef(onDismiss); 19 23 20 24 useLayoutEffect(() => { 21 - if (!ref.current || !hasPriority) return; 25 + onDismissRef.current = onDismiss; 26 + }, [onDismiss]); 27 + 28 + useLayoutEffect(() => { 29 + if (!ref.current || disabled) return; 22 30 23 31 function onFocusOut(event: FocusEvent) { 24 32 if (event.defaultPrevented) return; ··· 28 36 contains(ref.current, target) && 29 37 !contains(ref.current, relatedTarget) 30 38 ) { 31 - onDismiss(); 39 + onDismissRef.current(); 32 40 } 33 41 } 34 42 ··· 39 47 const active = document.activeElement; 40 48 if (hasPriority || (active && contains(ref.current, active))) { 41 49 event.preventDefault(); 42 - onDismiss(); 50 + onDismissRef.current(); 43 51 } 44 52 } 45 53 } ··· 55 63 const active = document.activeElement; 56 64 if (hasPriority || (active && contains(ref.current, active))) { 57 65 event.preventDefault(); 58 - onDismiss(); 66 + onDismissRef.current(); 59 67 } 60 68 } 61 69 ··· 72 80 document.removeEventListener('touchstart', onClick); 73 81 document.removeEventListener('keydown', onKey); 74 82 }; 75 - }, [ref, hasPriority, focusLoss, onDismiss]); 83 + }, [ref, hasPriority, disabled, focusLoss]); 76 84 }