diff --git a/cypress/component/ModalPreviousFocus.spec.tsx b/cypress/component/ModalPreviousFocus.spec.tsx new file mode 100644 index 0000000000..419106ca0d --- /dev/null +++ b/cypress/component/ModalPreviousFocus.spec.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import {DynamicTrigger} from '../../modules/react/modal/stories/examples/DynamicTrigger'; +import {PreviousFocus} from '../../modules/react/modal/stories/examples/PreviousFocus'; + +describe('Modal previous focus', () => { + context('given native triggers without a target ref', () => { + beforeEach(() => { + cy.mount( + + + + ); + }); + + it('should not have any axe errors', () => { + cy.findByRole('button', {name: 'First trigger'}).realClick(); + cy.findByRole('dialog', {name: 'Previous focus'}).should('be.visible'); + cy.checkA11y(); + }); + + it('should return focus to each trigger after closing', () => { + for (const name of ['First trigger', 'Second trigger']) { + cy.findByRole('button', {name}).realClick(); + cy.findByRole('heading', {name: 'Previous focus'}).should('have.focus'); + cy.findByRole('button', {name: 'Close'}).realClick(); + cy.findByRole('dialog').should('not.exist'); + cy.findByRole('button', {name}).should('have.focus'); + } + }); + + it('should return focus after keyboard opening and Escape', () => { + cy.findByRole('button', {name: 'Second trigger'}).focus().realPress('Enter'); + cy.findByRole('heading', {name: 'Previous focus'}).should('have.focus'); + cy.realPress('Escape'); + cy.findByRole('dialog').should('not.exist'); + cy.findByRole('button', {name: 'Second trigger'}).should('have.focus'); + }); + }); +}); + +context('given a trigger that changes while the modal is open', () => { + beforeEach(() => cy.mount()); + + it('should not have any axe errors', () => { + cy.findByRole('button', {name: 'Open modal'}).realClick(); + cy.findByRole('dialog', {name: 'Dynamic trigger'}).should('be.visible'); + cy.checkA11y(); + }); + + ['Remove trigger', 'Replace trigger'].forEach(action => { + it(`should return focus to the current target after ${action.toLowerCase()}`, () => { + cy.findByRole('button', {name: 'Open modal'}).realClick(); + cy.findByRole('button', {name: action}).realClick(); + cy.findByRole('button', {name: 'Close'}).realClick(); + cy.findByRole('dialog').should('not.exist'); + cy.findByRole('button', {name: 'Fallback trigger'}).should('have.focus'); + }); + }); +}); + +context('given a modal with a native autofocus input', () => { + beforeEach(() => cy.mount()); + + it('should not have any axe errors', () => { + cy.findByRole('button', {name: 'First trigger'}).realClick(); + cy.checkA11y(); + }); + + it('should return focus to the trigger after closing', () => { + cy.findByRole('button', {name: 'First trigger'}).realClick(); + cy.findByRole('dialog', {name: 'Previous focus'}).should('be.visible'); + cy.realPress('Escape'); + cy.findByRole('dialog').should('not.exist'); + cy.findByRole('button', {name: 'First trigger'}).should('have.focus'); + }); +}); diff --git a/modules/react/modal/stories/examples/DynamicTrigger.tsx b/modules/react/modal/stories/examples/DynamicTrigger.tsx new file mode 100644 index 0000000000..8cd10a1992 --- /dev/null +++ b/modules/react/modal/stories/examples/DynamicTrigger.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import {Modal, useModalModel} from '@workday/canvas-kit-react/modal'; + +export const DynamicTrigger = () => { + const model = useModalModel(); + const [showTrigger, setShowTrigger] = React.useState(true); + const [targetVersion, setTargetVersion] = React.useState(0); + + return ( + + {showTrigger && } + + + + Dynamic trigger + + + Close + + + + ); +}; diff --git a/modules/react/modal/stories/examples/PreviousFocus.tsx b/modules/react/modal/stories/examples/PreviousFocus.tsx new file mode 100644 index 0000000000..a9b54c4413 --- /dev/null +++ b/modules/react/modal/stories/examples/PreviousFocus.tsx @@ -0,0 +1,21 @@ +import {Modal, useModalModel} from '@workday/canvas-kit-react/modal'; + +export const PreviousFocus = ({autoFocus = false}: {autoFocus?: boolean}) => { + const model = useModalModel(); + + return ( + + + + + + Previous focus + + Close + + + + ); +}; diff --git a/modules/react/modal/stories/testing.stories.tsx b/modules/react/modal/stories/testing.stories.tsx index 1268e12392..b9328c66f6 100644 --- a/modules/react/modal/stories/testing.stories.tsx +++ b/modules/react/modal/stories/testing.stories.tsx @@ -7,10 +7,12 @@ import {Modal, useModalModel} from '@workday/canvas-kit-react/modal'; import {createStyles, px2rem} from '@workday/canvas-kit-styling'; import {brand, system} from '@workday/canvas-tokens-web'; +import {DynamicTrigger as DynamicTriggerExample} from './examples/DynamicTrigger'; import {IframeTest as IframeTestExample} from './examples/IframeTest'; import {ModalWithPopup as ModalWithPopupExample} from './examples/ModalWithPopup'; import {ModalWithPopupRTL as ModalWithPopupRTLExample} from './examples/ModalWithPopupRTL'; import {NoTargetRTL as NoTargetRTLExample} from './examples/NoTargetRTL'; +import {PreviousFocus as PreviousFocusExample} from './examples/PreviousFocus'; import {StackedModals as StackedModalsExample} from './examples/StackedModals'; import {WithRadioButtons as WithRadioButtonsExample} from './examples/WithRadioButtons'; import {WithTooltips as WithTooltipsExample} from './examples/WithTooltips'; @@ -250,3 +252,6 @@ export const CustomThemeModal = { ); }, }; + +export const PreviousFocus = {render: PreviousFocusExample}; +export const DynamicTrigger = {render: DynamicTriggerExample}; diff --git a/modules/react/popup/lib/hooks/usePopupModel.ts b/modules/react/popup/lib/hooks/usePopupModel.ts index d7b60b4180..6d5bb46c67 100644 --- a/modules/react/popup/lib/hooks/usePopupModel.ts +++ b/modules/react/popup/lib/hooks/usePopupModel.ts @@ -15,7 +15,7 @@ export const usePopupModel = createModelHook({ ...useDisclosureModel.defaultConfig, /** * Optional reference to an element that should receive focus when a popup is hidden. If left - * blank, focus will return to the `targetRef` + * blank, focus will return to the previously focused element, falling back to `targetRef` */ returnFocusRef: undefined as undefined | React.RefObject, /** @@ -50,7 +50,7 @@ export const usePopupModel = createModelHook({ initialFocusRef: config.initialFocusRef, /** * Optional reference to an element that should receive focus when a popup is hidden. If left - * blank, focus will return to the `targetRef` + * blank, focus will return to the previously focused element, falling back to `targetRef` */ returnFocusRef: config.returnFocusRef, /** diff --git a/modules/react/popup/lib/hooks/useReturnFocus.tsx b/modules/react/popup/lib/hooks/useReturnFocus.tsx index 4491387d5f..d73ec008ed 100644 --- a/modules/react/popup/lib/hooks/useReturnFocus.tsx +++ b/modules/react/popup/lib/hooks/useReturnFocus.tsx @@ -49,7 +49,7 @@ function isElementOutOfBounds( } /** - * Returns focus to the target element when the popup is hidden. This works well with + * Returns focus to the previously focused element when the popup is hidden. This works well with * {@link useInitialFocus}. This should be used with {@link useFocusRedirect} or * {@link useFocusTrap} for a complete focus management solution. * @@ -58,6 +58,7 @@ function isElementOutOfBounds( export const useReturnFocus = createElemPropsHook(usePopupModel)(model => { const visible = model.state.visibility !== 'hidden'; const elementRef = React.useRef(null); + const previousFocusRef = React.useRef(null); // This boolean tracks keyboard-driven focus changes. This is required for `useFocusRedirect` as // focus redirection needs synchronous focus management and everything else needs asynchronous @@ -101,21 +102,35 @@ export const useReturnFocus = createElemPropsHook(usePopupModel)(model => { // we'll use `mousedown` and `mouseup` to determine if we're closing because of a mouse click. React.useLayoutEffect(() => { if (!visible) { - return; + // Capture before children's layout effects can apply native autofocus while opening. + return () => { + previousFocusRef.current = document.activeElement; + }; } // capture the element here. The refs will be null by the time the cleanup function is called - const element = (model.state.returnFocusRef || model.state.targetRef) - .current as HTMLElement | null; + const activeElement = previousFocusRef.current || document.activeElement; + const target = model.state.targetRef.current; + const previousFocus = + activeElement instanceof HTMLElement && activeElement !== document.body + ? activeElement + : target; + const element = model.state.returnFocusRef + ? (model.state.returnFocusRef.current as HTMLElement | null) + : previousFocus; document.addEventListener('mousedown', onMouseDown, true); document.addEventListener('keydown', onKeyDown, true); return () => { document.removeEventListener('mousedown', onMouseDown, true); document.removeEventListener('keydown', onKeyDown, true); - if (!element) { + const focusElement = + !model.state.returnFocusRef && !element?.isConnected + ? model.state.targetRef.current || target + : element; + if (!focusElement?.isConnected) { return; } - const scrollParent = getScrollParent(element); + const scrollParent = getScrollParent(focusElement); const scrollParentRect = scrollParent.getBoundingClientRect(); const viewportRect = {left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight}; @@ -123,8 +138,8 @@ export const useReturnFocus = createElemPropsHook(usePopupModel)(model => { // element isn't on at least halfway rendered on the screen. if ( (elementRef.current && getFocusableElement(elementRef.current)) || // did the user click on a focusable element? - isElementOutOfBounds(element, scrollParentRect) || // Is the element not visible in its scroll parent? - isElementOutOfBounds(element, viewportRect) // Is the element not visible in the viewport? + isElementOutOfBounds(focusElement, scrollParentRect) || // Is the element not visible in its scroll parent? + isElementOutOfBounds(focusElement, viewportRect) // Is the element not visible in the viewport? ) { // reset the focus element and bail early elementRef.current = null; @@ -141,12 +156,14 @@ export const useReturnFocus = createElemPropsHook(usePopupModel)(model => { // We need to change focus _before_ the browser process the default action of picking a new // focus target. Doing this immediately prevents the `focus` event from firing on `element`, // but that's okay because the browser will change focus anyways. - changeFocus(element); + changeFocus(focusElement); } else { // We wait a frame for the current event to process, allowing the browser to fire default // actions. This delay allows the focus change to trigger a `focus` event on `element`. requestAnimationFrame(() => { - changeFocus(element); + if (focusElement.isConnected) { + changeFocus(focusElement); + } }); } diff --git a/modules/react/popup/spec/useReturnFocus.spec.tsx b/modules/react/popup/spec/useReturnFocus.spec.tsx new file mode 100644 index 0000000000..8baf5b379a --- /dev/null +++ b/modules/react/popup/spec/useReturnFocus.spec.tsx @@ -0,0 +1,173 @@ +import {act, renderHook} from '@testing-library/react-hooks'; +import React from 'react'; + +import {usePopupModel, useReturnFocus} from '@workday/canvas-kit-react/popup'; + +describe('useReturnFocus', () => { + let trigger: HTMLButtonElement; + let popup: HTMLButtonElement; + let target: HTMLButtonElement; + + beforeEach(() => { + vi.useFakeTimers(); + trigger = document.createElement('button'); + popup = document.createElement('button'); + target = document.createElement('button'); + document.body.append(trigger, popup, target); + trigger.focus(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + trigger.remove(); + popup.remove(); + target.remove(); + }); + + function setup(returnFocusRef?: React.RefObject) { + return renderHook(() => { + const model = usePopupModel({returnFocusRef}); + useReturnFocus(model); + return model; + }); + } + + it('returns focus to a custom trigger without a target ref', () => { + const {result} = setup(); + act(() => result.current.events.show()); + popup.focus(); + act(() => result.current.events.hide()); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(trigger).toHaveFocus(); + }); + + it('captures the focused element again on each opening', () => { + const {result} = setup(); + act(() => result.current.events.show()); + popup.focus(); + act(() => result.current.events.hide()); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(trigger).toHaveFocus(); + + popup.focus(); + act(() => result.current.events.show()); + trigger.focus(); + act(() => result.current.events.hide()); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(popup).toHaveFocus(); + }); + + it('prioritizes an explicit returnFocusRef', () => { + const {result} = setup({current: popup}); + act(() => result.current.events.show()); + act(() => result.current.events.hide()); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(popup).toHaveFocus(); + }); + + it('preserves an explicit empty returnFocusRef', () => { + const {result} = setup({current: null}); + (result.current.state.targetRef as React.MutableRefObject).current = target; + act(() => result.current.events.show()); + popup.focus(); + act(() => result.current.events.hide()); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(popup).toHaveFocus(); + }); + + it('falls back to the registered target when the previous focus is removed', () => { + const {result} = setup(); + (result.current.state.targetRef as React.MutableRefObject).current = target; + act(() => result.current.events.show()); + popup.focus(); + trigger.remove(); + act(() => result.current.events.hide()); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(target).toHaveFocus(); + }); + + it('uses the current target when the target was replaced while open', () => { + const {result} = setup(); + const targetRef = result.current.state.targetRef as React.MutableRefObject; + targetRef.current = trigger; + act(() => result.current.events.show()); + popup.focus(); + trigger.remove(); + targetRef.current = target; + act(() => result.current.events.hide()); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(target).toHaveFocus(); + }); + + it('retains the connected target when its ref is cleared before cleanup', () => { + const {result, unmount} = setup(); + const targetRef = result.current.state + .targetRef as React.MutableRefObject; + targetRef.current = target; + act(() => result.current.events.show()); + popup.focus(); + trigger.remove(); + targetRef.current = null; + unmount(); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(target).toHaveFocus(); + }); + + it('does not replace an explicit returnFocusRef with the target', () => { + const {result} = setup({current: trigger}); + (result.current.state.targetRef as React.MutableRefObject).current = target; + act(() => result.current.events.show()); + popup.focus(); + trigger.remove(); + act(() => result.current.events.hide()); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(popup).toHaveFocus(); + }); + + it('does not focus a target removed before the next frame', () => { + const {result} = setup(); + act(() => result.current.events.show()); + popup.focus(); + const focus = vi.spyOn(trigger, 'focus'); + act(() => result.current.events.hide()); + trigger.remove(); + target.focus(); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(focus).not.toHaveBeenCalled(); + expect(target).toHaveFocus(); + }); + + it('uses the registered target when the document body had focus', () => { + trigger.blur(); + const {result} = setup(); + (result.current.state.targetRef as React.MutableRefObject).current = trigger; + act(() => result.current.events.show()); + popup.focus(); + act(() => result.current.events.hide()); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(trigger).toHaveFocus(); + }); +});