Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions cypress/component/ModalPreviousFocus.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<React.StrictMode>
<PreviousFocus />
</React.StrictMode>
);
});

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(<DynamicTrigger />));

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(<PreviousFocus autoFocus />));

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');
});
});
33 changes: 33 additions & 0 deletions modules/react/modal/stories/examples/DynamicTrigger.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal model={model}>
{showTrigger && <button onClick={() => model.events.show()}>Open modal</button>}
<button key={targetVersion} ref={model.state.targetRef} onClick={() => model.events.show()}>
Fallback trigger
</button>
<Modal.Overlay>
<Modal.Card>
<Modal.Heading>Dynamic trigger</Modal.Heading>
<button onClick={() => setShowTrigger(false)}>Remove trigger</button>
<button
onClick={() => {
setShowTrigger(false);
setTargetVersion(version => version + 1);
}}
>
Replace trigger
</button>
<Modal.CloseButton>Close</Modal.CloseButton>
</Modal.Card>
</Modal.Overlay>
</Modal>
);
};
21 changes: 21 additions & 0 deletions modules/react/modal/stories/examples/PreviousFocus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {Modal, useModalModel} from '@workday/canvas-kit-react/modal';

export const PreviousFocus = ({autoFocus = false}: {autoFocus?: boolean}) => {
const model = useModalModel();

return (
<Modal model={model}>
<button onClick={() => model.events.show()}>First trigger</button>
<button onClick={() => model.events.show()}>Second trigger</button>
<Modal.Overlay>
<Modal.Card>
<Modal.Heading>Previous focus</Modal.Heading>
<label>
Note <input autoFocus={autoFocus} />
</label>
<Modal.CloseButton>Close</Modal.CloseButton>
</Modal.Card>
</Modal.Overlay>
</Modal>
);
};
5 changes: 5 additions & 0 deletions modules/react/modal/stories/testing.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -250,3 +252,6 @@ export const CustomThemeModal = {
);
},
};

export const PreviousFocus = {render: PreviousFocusExample};
export const DynamicTrigger = {render: DynamicTriggerExample};
4 changes: 2 additions & 2 deletions modules/react/popup/lib/hooks/usePopupModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>,
/**
Expand Down Expand Up @@ -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,
/**
Expand Down
37 changes: 27 additions & 10 deletions modules/react/popup/lib/hooks/useReturnFocus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -58,6 +58,7 @@ function isElementOutOfBounds(
export const useReturnFocus = createElemPropsHook(usePopupModel)(model => {
const visible = model.state.visibility !== 'hidden';
const elementRef = React.useRef<Element | null>(null);
const previousFocusRef = React.useRef<Element | null>(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
Expand Down Expand Up @@ -101,30 +102,44 @@ 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};

// Don't change focus if user focused on a different element like a `input` or the target
// 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;
Expand All @@ -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);
}
});
}

Expand Down
Loading
Loading