Skip to content

Commit b17415f

Browse files
author
Kubit
committed
Improve Accessibility: Focus Management on Snackbar Close
This commit enhances the accessibility of the snackbar component by implementing advanced focus management when the snackbar is manually closed. Upon closure, the component now attempts to return focus to the last element that was focused before the snackbar opened. If this element no longer exists or is not focusable, the focus is automatically shifted to the first focusable element on the page. This improvement ensures a more seamless and accessible user experience by maintaining logical focus flow, thereby aiding users who rely on keyboard navigation and screen readers.
1 parent bb3e098 commit b17415f

File tree

2 files changed

+95
-8
lines changed

2 files changed

+95
-8
lines changed

src/components/snackbar/__tests__/snackbar.test.tsx

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, screen } from '@testing-library/react';
1+
import { act, fireEvent, screen } from '@testing-library/react';
22
import * as React from 'react';
33

44
import { axe } from 'jest-axe';
@@ -11,6 +11,7 @@ import { Snackbar, SnackbarMessageType } from '../index';
1111

1212
const MOCK = {
1313
variant: 'DEFAULT',
14+
ref: jest.fn(),
1415
onSecondaryActionClick: jest.fn(),
1516
open: true,
1617
closeIcon: {
@@ -82,6 +83,75 @@ describe('Snackbar component', () => {
8283
expect(screen.queryAllByText('button')).toHaveLength(0);
8384
});
8485

86+
const MockSnackbarFocusLastElement = () => {
87+
const [open, setOpen] = React.useState(false);
88+
return (
89+
<div>
90+
<button data-testid="firstPageElement">First Page Element</button>
91+
<button data-testid="openSnackbar" onClick={() => setOpen(true)}>
92+
Open Snackbar
93+
</button>
94+
<Snackbar {...MOCK} closeIcon={{ icon: 'UNICORN' }} open={open} />
95+
</div>
96+
);
97+
};
98+
it('after closing manually, if the element foucsed before the snackbar is open exist, this element will be focused', () => {
99+
renderProvider(<MockSnackbarFocusLastElement />);
100+
101+
act(() => {
102+
// Focus the button
103+
screen.getByTestId('openSnackbar').focus();
104+
});
105+
106+
act(() => {
107+
// Open snackbar bar
108+
fireEvent.click(screen.getByTestId('openSnackbar'));
109+
});
110+
111+
act(() => {
112+
// Close snackbar bar
113+
fireEvent.click(screen.getByTestId(`${MOCK.dataTestId}Icon`));
114+
});
115+
// open snackbar should have the focus back
116+
expect(screen.getByTestId('openSnackbar')).toHaveFocus();
117+
});
118+
119+
const MockSnackbarCloseManuallyFocusFirstDescentand = () => {
120+
const [open, setOpen] = React.useState(false);
121+
return (
122+
<div>
123+
<button data-testid="firstPageElement">First Page Element</button>
124+
{!open && (
125+
<button data-testid="openSnackbar" onClick={() => setOpen(true)}>
126+
Open Snackbar
127+
</button>
128+
)}
129+
<Snackbar {...MOCK} closeIcon={{ icon: 'UNICORN' }} open={open} />
130+
</div>
131+
);
132+
};
133+
it('after closing manually, if the element that opens the snackbar does not exist, first element of the page should have the focus', () => {
134+
renderProvider(<MockSnackbarCloseManuallyFocusFirstDescentand />);
135+
136+
act(() => {
137+
// Focus the button
138+
screen.getByTestId('openSnackbar').focus();
139+
});
140+
141+
act(() => {
142+
// Open snackbar bar
143+
fireEvent.click(screen.getByTestId('openSnackbar'));
144+
});
145+
146+
act(() => {
147+
// Close snackbar bar
148+
fireEvent.click(screen.getByTestId(`${MOCK.dataTestId}Icon`));
149+
});
150+
151+
// first element of the page should have the focus
152+
expect(screen.getByTestId('firstPageElement')).toHaveFocus();
153+
});
154+
85155
it('Should render icon, link, button and description when passed props', async () => {
86156
const { container } = renderProvider(
87157
<Snackbar {...mockWithDescription}>Snackbar Content</Snackbar>
@@ -109,7 +179,7 @@ describe('Snackbar component', () => {
109179
expect(results).toHaveNoViolations();
110180
});
111181

112-
test('Snackbar call the onOpenClose function, when the functions is provider and the snackbar closes automatically due to the closeTimeOut if neither focus or hovering', () => {
182+
it('Snackbar call the onOpenClose function, when the functions is provider and the snackbar closes automatically due to the closeTimeOut if neither focus or hovering', () => {
113183
jest.useFakeTimers();
114184
const mockOpenClose = jest.fn();
115185
renderProvider(<Snackbar {...MOCK} closeTimeout={3000} onOpenClose={mockOpenClose} />);
@@ -122,7 +192,7 @@ describe('Snackbar component', () => {
122192
expect(mockOpenClose).toHaveBeenCalled();
123193
});
124194

125-
test('Snackbar will not call the onOpenClose function if the closeTimeOut set, but the user is hovering the snackbar', () => {
195+
it('Snackbar will not call the onOpenClose function if the closeTimeOut set, but the user is hovering the snackbar', () => {
126196
jest.useFakeTimers();
127197
const mockOpenClose = jest.fn();
128198
renderProvider(<Snackbar {...MOCK} closeTimeout={3000} onOpenClose={mockOpenClose} />);
@@ -136,7 +206,7 @@ describe('Snackbar component', () => {
136206
expect(mockOpenClose).toHaveBeenCalled();
137207
});
138208

139-
test('Snackbar will not call the onOpenClose function if the closeTimeOut set, but the user is focusing the snackbar', () => {
209+
it('Snackbar will not call the onOpenClose function if the closeTimeOut set, but the user is focusing the snackbar', () => {
140210
jest.useFakeTimers();
141211
const mockOpenClose = jest.fn();
142212
renderProvider(<Snackbar {...MOCK} closeTimeout={3000} onOpenClose={mockOpenClose} />);

src/components/snackbar/snackbarUnControlled.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22

3-
import { focusFirstDescendant } from '@/utils';
3+
import { focusFirstDescendantV2 } from '@/utils/focusHandlers/focusHandlers';
44

55
import { SnackbarControlled } from './snackbarControlled';
66
import { ISnackbarUnControlled } from './types';
@@ -21,6 +21,10 @@ const SnackbarUnControlledComponent = <V extends string | unknown>(
2121
const closeSnackbarTimeOut = React.useRef<ReturnType<typeof setTimeout> | null>(null);
2222
const hover = React.useRef(false);
2323
const focus = React.useRef(false);
24+
const innerRef = React.useRef<HTMLDivElement>(null);
25+
const lastFocusedElement = React.useRef<Element | null>(null);
26+
27+
React.useImperativeHandle(ref, () => innerRef?.current as HTMLDivElement, []);
2428

2529
/**
2630
* Only start timeout if closeTimeOut is defined, and if the snackbar is not hovered or focused
@@ -43,6 +47,7 @@ const SnackbarUnControlledComponent = <V extends string | unknown>(
4347

4448
React.useEffect(() => {
4549
if (open) {
50+
lastFocusedElement.current = document.activeElement;
4651
startCloseSnackbarTimeout();
4752
} else {
4853
clearCloseSnackbarTimeout();
@@ -55,8 +60,20 @@ const SnackbarUnControlledComponent = <V extends string | unknown>(
5560
const handleCloseButton: (_open: boolean) => React.MouseEventHandler<HTMLButtonElement> =
5661
_open => event => {
5762
props.onOpenClose?.(_open, event);
58-
// Only after the user closes the snackbar manually, the focus will be set to the first focusable element of the page
59-
focusFirstDescendant(document.body);
63+
// After manually closing the popover, focus the last focused element
64+
// If the last focused element is not available, focus the first focusable element
65+
if (
66+
lastFocusedElement.current instanceof HTMLElement &&
67+
document.contains(lastFocusedElement.current) &&
68+
document.body !== lastFocusedElement.current
69+
) {
70+
lastFocusedElement.current.focus();
71+
return;
72+
}
73+
focusFirstDescendantV2({
74+
element: document.body,
75+
elementsToOmit: innerRef.current ? [innerRef.current] : [],
76+
});
6077
};
6178

6279
const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -95,7 +112,7 @@ const SnackbarUnControlledComponent = <V extends string | unknown>(
95112
return (
96113
<SnackbarControlled
97114
{...props}
98-
ref={ref}
115+
ref={innerRef}
99116
open={open}
100117
variant={variant}
101118
onBlur={handleBlur}

0 commit comments

Comments
 (0)