diff --git a/packages/react/src/components/ClickOutsideListener/index.tsx b/packages/react/src/components/ClickOutsideListener/index.tsx index 62577f6e0..8def1a971 100644 --- a/packages/react/src/components/ClickOutsideListener/index.tsx +++ b/packages/react/src/components/ClickOutsideListener/index.tsx @@ -1,6 +1,6 @@ import React, { useRef, useEffect } from 'react'; import setRef from '../../utils/setRef'; -import getElementOrRef from '../../utils/getElementOrRef'; +import resolveElement from '../../utils/resolveElement'; export interface ClickOutsideListenerProps< T extends HTMLElement = HTMLElement @@ -30,7 +30,7 @@ function ClickOutsideListener( } const eventTarget = event.target as HTMLElement; - const elementTarget = getElementOrRef(target); + const elementTarget = resolveElement(target); if (target && !elementTarget?.contains(eventTarget)) { onClickOutside(event); diff --git a/packages/react/src/components/Drawer/index.tsx b/packages/react/src/components/Drawer/index.tsx index 00cdc94bc..355015abc 100644 --- a/packages/react/src/components/Drawer/index.tsx +++ b/packages/react/src/components/Drawer/index.tsx @@ -14,7 +14,7 @@ import ClickOutsideListener from '../ClickOutsideListener'; import useEscapeKey from '../../utils/useEscapeKey'; import useSharedRef from '../../utils/useSharedRef'; import focusableSelector from '../../utils/focusable-selector'; -import getElementOrRef from '../../utils/getElementOrRef'; +import resolveElement from '../../utils/resolveElement'; interface DrawerProps extends React.HTMLAttributes { @@ -49,6 +49,7 @@ const Drawer = forwardRef( ref ) => { const drawerRef = useSharedRef(ref); + const openRef = useRef(!!open); const previousActiveElementRef = useRef( null ) as React.MutableRefObject; @@ -72,15 +73,19 @@ const Drawer = forwardRef( }, [setIsTransitioning]); useEffect(() => { - setIsTransitioning(true); - }, [open]); + if (openRef.current !== open) { + setIsTransitioning(true); + } + + openRef.current = open; + }, [open, setIsTransitioning]); useLayoutEffect(() => { if (open) { previousActiveElementRef.current = document.activeElement as HTMLElement; - const initialFocusElement = getElementOrRef(focusInitial); + const initialFocusElement = resolveElement(focusInitial); if (initialFocusElement) { initialFocusElement.focus(); } else { @@ -95,7 +100,7 @@ const Drawer = forwardRef( } } } else if (previousActiveElementRef.current) { - const returnFocusElement = getElementOrRef(focusReturn); + const returnFocusElement = resolveElement(focusReturn); if (returnFocusElement) { returnFocusElement.focus(); } else { diff --git a/packages/react/src/utils/getElementOrRef.ts b/packages/react/src/utils/resolveElement.ts similarity index 88% rename from packages/react/src/utils/getElementOrRef.ts rename to packages/react/src/utils/resolveElement.ts index fcbd989fe..5adfcdf32 100644 --- a/packages/react/src/utils/getElementOrRef.ts +++ b/packages/react/src/utils/resolveElement.ts @@ -4,7 +4,7 @@ import type { RefObject, MutableRefObject } from 'react'; * When an element can be passed as a value that is either an element or an * elementRef, this will resolve the property down to the resulting element */ -export default function getElementOrRef( +export default function resolveElement( elementOrRef: T | RefObject | MutableRefObject | undefined ): T | null { if (elementOrRef instanceof Element) { diff --git a/packages/react/src/utils/useEscapeKey.test.tsx b/packages/react/src/utils/useEscapeKey.test.tsx new file mode 100644 index 000000000..213ce6f51 --- /dev/null +++ b/packages/react/src/utils/useEscapeKey.test.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { render, fireEvent, createEvent } from '@testing-library/react'; +import useEscapeKey from './useEscapeKey'; + +const renderHook = (hookFn: () => void) => { + const RenderHook = () => { + hookFn(); + return null; + }; + + render(); +}; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('should listen to event in bubble phase', () => { + const callback = jest.fn(); + const addEventListener = jest.spyOn(document.body, 'addEventListener'); + renderHook(() => + useEscapeKey({ + callback, + capture: false + }) + ); + + expect(addEventListener.mock.lastCall?.[2]).toEqual(false); +}); + +it('should call callback with escape', async () => { + const callback = jest.fn(); + renderHook(() => + useEscapeKey({ + callback + }) + ); + + expect(callback).not.toBeCalled(); + await fireEvent.keyUp(document.body, { key: 'Escape' }); + expect(callback).toBeCalled(); +}); + +it('should call callback with esc', async () => { + const callback = jest.fn(); + renderHook(() => + useEscapeKey({ + callback + }) + ); + + expect(callback).not.toBeCalled(); + await fireEvent.keyUp(document.body, { key: 'Esc' }); + expect(callback).toBeCalled(); +}); + +it('should call callback with keyCode', async () => { + const callback = jest.fn(); + renderHook(() => + useEscapeKey({ + callback + }) + ); + + expect(callback).not.toBeCalled(); + await fireEvent.keyUp(document.body, { keyCode: 27 }); + expect(callback).toBeCalled(); +}); + +it('should listen to keydown event', async () => { + const callback = jest.fn(); + renderHook(() => + useEscapeKey({ + callback, + event: 'keydown' + }) + ); + + expect(callback).not.toBeCalled(); + await fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(callback).toBeCalled(); +}); + +it('should listen to keypress event', async () => { + const callback = jest.fn(); + renderHook(() => + useEscapeKey({ + callback, + event: 'keypress' + }) + ); + + expect(callback).not.toBeCalled(); + await fireEvent.keyPress(document.body, { key: 'Escape' }); + expect(callback).toBeCalled(); +}); + +it('should listen to keypress event', async () => { + const callback = jest.fn(); + renderHook(() => + useEscapeKey({ + callback, + event: 'keyup' + }) + ); + + expect(callback).not.toBeCalled(); + await fireEvent.keyUp(document.body, { key: 'Escape' }); + expect(callback).toBeCalled(); +}); + +it('should listen to target', async () => { + const target = document.createElement('div'); + const callback = jest.fn(); + renderHook(() => + useEscapeKey({ + callback, + target + }) + ); + + expect(callback).not.toBeCalled(); + await fireEvent.keyUp(target, { key: 'Escape' }); + expect(callback).toBeCalled(); +}); + +it('should use capture', () => { + const callback = jest.fn(); + const addEventListener = jest.spyOn(document.body, 'addEventListener'); + renderHook(() => + useEscapeKey({ + callback, + capture: true + }) + ); + + expect(addEventListener.mock.lastCall?.[2]).toEqual(true); +}); + +// TODO: fix this test +it.skip('should check for default prevented', async () => { + const callback = jest.fn(); + renderHook(() => + useEscapeKey({ + callback, + defaultPrevented: true + }) + ); + + const fireKeyUpEvent = (preventDefault: boolean) => { + const event = createEvent.keyUp(document.body, { key: 'Escape' }); + // rtl doesn't let us mock preventDefault + // see: https://github.com/testing-library/react-testing-library/issues/572 + // @ts-expect-error not mocking function but + event.preventDefault = preventDefault; + fireEvent(document.body, event); + }; + + await fireEvent.keyUp(document.body, { + key: 'Escape', + defaultPrevented: true + }); + // fireKeyUpEvent(true) + expect(callback).not.toBeCalled(); + // fireKeyUpEvent(false) + await fireEvent.keyUp(document.body, { key: 'Escape' }); + expect(callback).toBeCalled(); +}); diff --git a/packages/react/src/utils/useEscapeKey.ts b/packages/react/src/utils/useEscapeKey.ts index ded77a033..d3c7282da 100644 --- a/packages/react/src/utils/useEscapeKey.ts +++ b/packages/react/src/utils/useEscapeKey.ts @@ -1,4 +1,5 @@ import { useEffect, type DependencyList } from 'react'; +import resolveElement from './resolveElement'; const isEscapeKey = (event: KeyboardEvent) => event.key === 'Escape' || event.key === 'Esc' || event.keyCode === 27; @@ -24,12 +25,7 @@ export default function useEscapeKey( ) { const callback = options.callback; const event = options.event || 'keyup'; - const target = options.target - ? 'current' in options.target - ? options.target.current - : options.target - : document.body; - + const target = resolveElement(options.target) || document.body; const active = typeof options.active === 'boolean' ? options.active : true; useEffect(() => {