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();
+ });
+});