Skip to content

Commit

Permalink
feat: Configurable shortcut to focus most recent toast (#28289)
Browse files Browse the repository at this point in the history
* feat: Implement shortcut to focus toasts

Implements a default shortcut `CTRL+M` that will focus on the most
recent toast.

While focus is inside the toaster, all timeouts of active toasts will be
paused. The toaster also attemps to restore focus on blur.

* publish PR storybook

* add docs

* fix snapshot

* improve focus restore

* fix build

* label

* remove default shortcut

* make shortcuts optional

* add notification centre guidance

* fix example

* add aria-describedby

* update snapshots

* be consistent - use ToastContainer

* change dispatchedAt to order

* update tabster version

* revert

* add ress review issues

* Add tests

* fix version mismatch
  • Loading branch information
ling1726 committed Jun 28, 2023
1 parent cbfa9a3 commit 595d25e
Show file tree
Hide file tree
Showing 24 changed files with 516 additions and 122 deletions.
1 change: 1 addition & 0 deletions packages/react-components/react-toast/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@fluentui/react-jsx-runtime": "9.0.0-alpha.9",
"@fluentui/react-portal": "^9.2.16",
"@fluentui/react-shared-contexts": "^9.5.1",
"@fluentui/react-tabster": "^9.9.1",
"@fluentui/react-theme": "^9.1.9",
"@fluentui/react-utilities": "^9.10.0",
"@griffel/react": "^1.5.7",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,199 @@ describe('Toast', () => {
.eq(3)
.should('have.text', 'unmounted');
});

it('should focus most recent toast with shortcut', () => {
const Example = () => {
const { dispatchToast } = useToastController();
const makeToast = () => {
dispatchToast(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
</Toast>,
{ timeout: 500 },
);
dispatchToast(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
</Toast>,
{ timeout: 500, root: { id: 'most-recent' } },
);
};

return (
<>
<button id="make" onClick={makeToast}>
Make toast
</button>
<Toaster shortcuts={{ focus: e => e.ctrlKey && e.key === 'm' }} />
</>
);
};

mount(<Example />);
cy.get('#make')
.click()
.get('#most-recent')
.should('exist')
.get('body')
.type('{ctrl+m}')
.get('#most-recent')
.should('be.focused');
});

it('should pause all toasts when one is focused', () => {
const Example = () => {
const { dispatchToast } = useToastController();
const makeToast = () => {
dispatchToast(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
</Toast>,
{ timeout: 200 },
);
dispatchToast(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
</Toast>,
{ timeout: 200, root: { id: 'most-recent' } },
);
};

return (
<>
<button id="make" onClick={makeToast}>
Make toast
</button>
<Toaster shortcuts={{ focus: e => e.ctrlKey && e.key === 'm' }} />
</>
);
};

mount(<Example />);
cy.get('#make')
.click()
.get('#most-recent')
.should('exist')
.get('body')
.type('{ctrl+m}')
.get('#most-recent')
.should('be.focused')
.wait(500)
.get(`.${toastClassNames.root}`)
.should('have.length', 2);
});

it('should dismiss toast with escape and revert focus', () => {
const Example = () => {
const { dispatchToast } = useToastController();
const makeToast = () => {
dispatchToast(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
</Toast>,
{ timeout: 200 },
);
};

return (
<>
<button id="make" onClick={makeToast}>
Make toast
</button>
<Toaster shortcuts={{ focus: e => e.ctrlKey && e.key === 'm' }} />
</>
);
};

mount(<Example />);
cy.get('#make')
.click()
.get(`.${toastClassNames.root}`)
.should('exist')
.get('body')
.type('{ctrl+m}')
.focused()
.type('{esc}')
.get(`.${toastClassNames.root}`)
.should('not.exist')
.get('#make')
.should('be.focused');
});

it('should dismiss toast and revert focus with escape', () => {
const Example = () => {
const { dispatchToast } = useToastController();
const makeToast = () => {
dispatchToast(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
</Toast>,
{ timeout: 200 },
);
};

return (
<>
<button id="make" onClick={makeToast}>
Make toast
</button>
<Toaster shortcuts={{ focus: e => e.ctrlKey && e.key === 'm' }} />
</>
);
};

mount(<Example />);
cy.get('#make')
.click()
.get(`.${toastClassNames.root}`)
.should('exist')
.get('body')
.type('{ctrl+m}')
.focused()
.type('{esc}')
.get(`.${toastClassNames.root}`)
.should('not.exist')
.get('#make')
.should('be.focused');
});

it('should revert focus on tab out', () => {
const Example = () => {
const { dispatchToast } = useToastController();
const makeToast = () => {
dispatchToast(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
</Toast>,
{ timeout: 200, root: { id: 'toast' } },
);
};

return (
<>
<button id="make" onClick={makeToast}>
Make toast
</button>
<button>Foo</button>
<button>Foo</button>
<button>Foo</button>
<button>Foo</button>
<Toaster shortcuts={{ focus: e => e.ctrlKey && e.key === 'm' }} />
</>
);
};

mount(<Example />);
cy.get('#make')
.click()
.get('#toast')
.should('exist')
.get('body')
.type('{ctrl+m}')
.get('#toast')
.should('be.focused')
.realPress(['Shift', 'Tab']);

cy.get('#make').should('be.focused');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ exports[`ToastBody renders a default state 1`] = `
<div>
<div
class="fui-ToastBody"
id=""
>
Default ToastBody
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { getNativeElementProps, resolveShorthand } from '@fluentui/react-utilities';
import type { ToastBodyProps, ToastBodyState } from './ToastBody.types';
import { useToastContext } from '../../contexts/toastContext';

/**
* Create the state required to render ToastBody.
Expand All @@ -12,6 +13,7 @@ import type { ToastBodyProps, ToastBodyState } from './ToastBody.types';
* @param ref - reference to root HTMLElement of ToastBody
*/
export const useToastBody_unstable = (props: ToastBodyProps, ref: React.Ref<HTMLElement>): ToastBodyState => {
const { bodyId } = useToastContext();
return {
components: {
root: 'div',
Expand All @@ -20,6 +22,7 @@ export const useToastBody_unstable = (props: ToastBodyProps, ref: React.Ref<HTML
subtitle: resolveShorthand(props.subtitle),
root: getNativeElementProps('div', {
ref,
id: bodyId,
...props,
}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ToastContainer } from './ToastContainer';
import { isConformant } from '../../testing/isConformant';
import { ToastContainerProps } from './ToastContainer.types';
import { toastClassNames } from './useToastContainerStyles.styles';
import { resetIdsForTests } from '@fluentui/react-utilities';

const defaultToastContainerProps: ToastContainerProps = {
announce: () => null,
Expand All @@ -17,7 +18,9 @@ const defaultToastContainerProps: ToastContainerProps = {
intent: undefined,
updateId: 0,
visible: true,
dispatchedAt: 0,
imperativeRef: { current: null },
tryRestoreFocus: () => null,
order: 0,
content: '',
onStatusChange: () => null,
position: 'bottom-end',
Expand All @@ -32,6 +35,7 @@ const pausedTimerSelector = '[data-timer-status="paused"]';
describe('ToastContainer', () => {
beforeEach(() => {
jest.useRealTimers();
resetIdsForTests();
});

isConformant({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ export type ToastContainerProps = ComponentProps<Partial<ToastContainerSlots>> &
visible: boolean;
announce: Announce;
intent: ToastIntent | undefined;
tryRestoreFocus: () => void;
};

/**
* State used in rendering ToastContainer
*/
export type ToastContainerState = ComponentState<ToastContainerSlots> &
Pick<ToastContainerProps, 'remove' | 'close' | 'updateId' | 'visible' | 'intent'> & {
Pick<ToastContainerProps, 'remove' | 'close' | 'updateId' | 'visible' | 'intent'> &
Pick<ToastContextValue, 'titleId' | 'bodyId'> & {
transitionTimeout: number;
timerTimeout: number;
running: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
exports[`ToastContainer renders a default state 1`] = `
<div>
<div
aria-describedby="toast-body2"
aria-labelledby="toast-title1"
class="fui-ToastContainer"
role="group"
style="--fui-toast-height: 0px;"
tabindex="-1"
>
Default ToastContainer
</div>
Expand Down
Loading

0 comments on commit 595d25e

Please sign in to comment.