Skip to content

Commit 2212c21

Browse files
authored
fix: Fix focus management for flashbar when motion is enabled (#3793)
1 parent 63bb54e commit 2212c21

File tree

8 files changed

+129
-68
lines changed

8 files changed

+129
-68
lines changed

src/flashbar/__integ__/collapsible.test.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
44

5-
import { FOCUS_THROTTLE_DELAY } from '../utils';
5+
import { FOCUS_DEBOUNCE_DELAY } from '../utils';
66
import { FlashbarBasePage } from './pages/base';
77
import { setupTest } from './pages/interactive-page';
88
import { setupTest as setupStickyFlashbarTest } from './pages/sticky-page';
@@ -21,6 +21,7 @@ describe('Collapsible Flashbar', () => {
2121
await expect(page.countFlashes()).resolves.toBe(1);
2222
await page.keys('Space');
2323

24+
await page.pause(FOCUS_DEBOUNCE_DELAY);
2425
await expect(page.countFlashes()).resolves.toBe(5);
2526
await expect(page.isFlashFocused(1)).resolves.toBe(true);
2627

@@ -40,7 +41,6 @@ describe('Collapsible Flashbar', () => {
4041
setupTest(async page => {
4142
await page.toggleStackingFeature();
4243
await page.addInfoFlash();
43-
await page.pause(FOCUS_THROTTLE_DELAY);
4444
return expect(page.isFlashFocused(1)).resolves.toBe(false);
4545
})
4646
);
@@ -50,7 +50,6 @@ describe('Collapsible Flashbar', () => {
5050
setupTest(async page => {
5151
await page.toggleStackingFeature();
5252
await page.addErrorFlash();
53-
await page.pause(FOCUS_THROTTLE_DELAY);
5453
return expect(page.isFlashFocused(1)).resolves.toBe(true);
5554
})
5655
);
@@ -60,7 +59,6 @@ describe('Collapsible Flashbar', () => {
6059
setupTest(async page => {
6160
await page.toggleStackingFeature();
6261
await page.addErrorFlash();
63-
await page.pause(FOCUS_THROTTLE_DELAY);
6462
await expect(page.isFlashFocused(1)).resolves.toBe(true);
6563
await page.addInfoFlash();
6664
await expect(page.isFlashFocused(1)).resolves.toBe(false);
@@ -74,7 +72,6 @@ describe('Collapsible Flashbar', () => {
7472
setupTest(async page => {
7573
await page.toggleStackingFeature();
7674
await page.toggleCollapsedState();
77-
await page.pause(FOCUS_THROTTLE_DELAY);
7875
await page.addInfoFlash();
7976
return expect(page.isFlashFocused(1)).resolves.toBe(false);
8077
})
@@ -85,7 +82,6 @@ describe('Collapsible Flashbar', () => {
8582
setupTest(async page => {
8683
await page.toggleStackingFeature();
8784
await page.toggleCollapsedState();
88-
await page.pause(FOCUS_THROTTLE_DELAY);
8985
await page.addErrorFlash();
9086
return expect(page.isFlashFocused(1)).resolves.toBe(true);
9187
})
@@ -96,11 +92,9 @@ describe('Collapsible Flashbar', () => {
9692
setupTest(async page => {
9793
await page.toggleStackingFeature();
9894
await page.toggleCollapsedState();
99-
await page.pause(FOCUS_THROTTLE_DELAY);
10095
await page.addErrorFlash();
10196
await expect(page.isFlashFocused(1)).resolves.toBe(true);
10297
await page.addInfoFlash();
103-
await page.pause(FOCUS_THROTTLE_DELAY);
10498
await expect(page.isFlashFocused(1)).resolves.toBe(false);
10599
})
106100
);
@@ -110,10 +104,8 @@ describe('Collapsible Flashbar', () => {
110104
setupTest(async page => {
111105
await page.toggleStackingFeature();
112106
await page.toggleCollapsedState();
113-
await page.pause(FOCUS_THROTTLE_DELAY);
114107
await page.addErrorFlash();
115108
await expect(page.isFlashFocused(1)).resolves.toBe(true);
116-
await page.pause(FOCUS_THROTTLE_DELAY);
117109
await page.addInfoFlash();
118110
await expect(page.isFlashFocused(2)).resolves.toBe(false);
119111
})
@@ -126,9 +118,8 @@ describe('Collapsible Flashbar', () => {
126118
setupTest(async page => {
127119
await page.toggleStackingFeature();
128120
await page.addErrorFlash();
129-
await page.pause(FOCUS_THROTTLE_DELAY);
130121
await page.toggleCollapsedState();
131-
await page.pause(FOCUS_THROTTLE_DELAY);
122+
await page.pause(FOCUS_DEBOUNCE_DELAY);
132123
await expect(page.isFlashFocused(1)).resolves.toBe(true);
133124
})
134125
);

src/flashbar/__integ__/focus-interactions.test.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import { FOCUS_THROTTLE_DELAY } from '../utils';
43
import { setupTest } from './pages/interactive-page';
54

65
test(
@@ -40,20 +39,16 @@ test(
4039
await page.addErrorFlash();
4140
await expect(page.isFlashFocused(1)).resolves.toBe(true);
4241
await page.addInfoFlash();
43-
await page.pause(FOCUS_THROTTLE_DELAY);
4442
await expect(page.isFlashFocused(1)).resolves.toBe(false);
4543
})
4644
);
4745

4846
test(
49-
'adding multiple flashes with ariaRole="alert" throttles focus moves',
47+
'adding multiple flashes with ariaRole="alert" debounces focus moves',
5048
setupTest(async page => {
51-
const initialCount = await page.countFlashes();
5249
await page.addSequentialErrorFlashes();
53-
await page.pause(300);
54-
const currentCount = await page.countFlashes();
55-
const firstAddedItemIndex = currentCount - initialCount;
56-
return expect(page.isFlashFocused(firstAddedItemIndex)).resolves.toBe(true);
50+
// Flash items are added from bottom to top, so the last one added is the first one in the DOM.
51+
return expect(page.isFlashFocused(1)).resolves.toBe(true);
5752
})
5853
);
5954

@@ -78,10 +73,8 @@ test(
7873
'dismissing flash item moves focus to next item',
7974
setupTest(async page => {
8075
await page.addSequentialErrorFlashes();
81-
await page.pause(FOCUS_THROTTLE_DELAY);
8276

8377
await page.dismissFirstItem();
84-
await page.pause(FOCUS_THROTTLE_DELAY);
8578

8679
return expect(await page.isFlashFocused(1)).toBe(true);
8780
})
@@ -94,10 +87,8 @@ test(
9487
await page.toggleStackingFeature();
9588
await page.addSequentialErrorFlashes();
9689
await page.toggleCollapsedState();
97-
await page.pause(FOCUS_THROTTLE_DELAY);
9890

9991
await page.dismissFirstItem();
100-
await page.pause(FOCUS_THROTTLE_DELAY);
10192

10293
return expect(await page.isFlashFocused(1)).toBe(true);
10394
})
@@ -109,10 +100,8 @@ test(
109100
await page.removeAll();
110101
await page.toggleStackingFeature();
111102
await page.addSequentialErrorFlashes();
112-
await page.pause(FOCUS_THROTTLE_DELAY);
113103

114104
await page.dismissFirstItem();
115-
await page.pause(FOCUS_THROTTLE_DELAY);
116105

117106
const isDismissButtonFocused = await page.isDismissButtonFocused();
118107

src/flashbar/__integ__/pages/interactive-page.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,39 @@
33
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
44

55
import createWrapper from '../../../../lib/components/test-utils/selectors';
6+
import { FOCUS_DEBOUNCE_DELAY } from '../../utils';
67
import { flashbar, FlashbarBasePage } from './base';
78

89
export class FlashbarInteractivePage extends FlashbarBasePage {
910
async addInfoFlash() {
1011
await this.click('[data-id="add-info"]');
12+
await this.pause(FOCUS_DEBOUNCE_DELAY);
1113
}
1214

1315
async addErrorFlash() {
1416
await this.click('[data-id="add-error"]');
17+
await this.pause(FOCUS_DEBOUNCE_DELAY);
1518
}
1619

1720
async addErrorFlashToBottom() {
1821
await this.click('[data-id="add-error-to-bottom"]');
22+
await this.pause(FOCUS_DEBOUNCE_DELAY);
1923
}
2024

2125
async addSequentialErrorFlashes() {
2226
await this.click('[data-id="add-multiple"]');
27+
// Extra time for the items to be added on top of the debounce starting from the latest one.
28+
await this.pause(FOCUS_DEBOUNCE_DELAY * 2);
2329
}
2430

2531
async toggleStackingFeature() {
2632
await this.click('[data-id="stack-items"]');
33+
await this.pause(FOCUS_DEBOUNCE_DELAY);
2734
}
2835

2936
async dismissFirstItem() {
3037
await this.click(createWrapper().findFlashbar().findItems().get(1).findDismissButton().toSelector());
38+
await this.pause(FOCUS_DEBOUNCE_DELAY);
3139
}
3240

3341
getItem(index: number) {
@@ -36,6 +44,7 @@ export class FlashbarInteractivePage extends FlashbarBasePage {
3644

3745
async removeAll() {
3846
await this.click('[data-id="remove-all"]');
47+
await this.pause(FOCUS_DEBOUNCE_DELAY);
3948
}
4049
}
4150

src/flashbar/__tests__/focus-handling.test.tsx

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,94 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import React from 'react';
3+
import React, { useEffect } from 'react';
44
import { render, waitFor } from '@testing-library/react';
55

66
import Flashbar, { FlashbarProps } from '../../../lib/components/flashbar';
77
import createWrapper from '../../../lib/components/test-utils/dom';
88

9+
const createDismissibleFlashbar = (initialItems: FlashbarProps.MessageDefinition[]) => {
10+
const TestComponent = ({ items = initialItems }: { items?: FlashbarProps.MessageDefinition[] }) => {
11+
const [flashItems, setFlashItems] = React.useState(items);
12+
useEffect(() => {
13+
setFlashItems(items);
14+
}, [items]);
15+
16+
const itemsWithHandlers = flashItems.map(item => ({
17+
...item,
18+
onDismiss: () => {
19+
setFlashItems(prev => prev.filter(prevItem => prevItem.id !== item.id));
20+
},
21+
}));
22+
23+
return <Flashbar items={itemsWithHandlers} />;
24+
};
25+
26+
return TestComponent;
27+
};
28+
29+
describe('Flashbar focus handling on add', () => {
30+
test("doesn't affect focus when a new non-alert item is added", () => {
31+
createDismissibleFlashbar([
32+
{ id: 'a', content: 'Item 1', type: 'info', dismissible: true },
33+
{ id: 'b', content: 'Item 2', type: 'info', ariaRole: 'status', dismissible: true },
34+
]);
35+
expect(document.body).toHaveFocus();
36+
});
37+
38+
test("doesn't move focus to alert item when included in first render", () => {
39+
const TestComponent = createDismissibleFlashbar([
40+
{ id: 'a', content: 'Item 1', type: 'info', dismissible: true },
41+
{ id: 'b', content: 'Item 2', type: 'info', ariaRole: 'alert', dismissible: true },
42+
]);
43+
44+
jest.useFakeTimers();
45+
render(<TestComponent />);
46+
jest.runAllTimers();
47+
48+
expect(document.body).toHaveFocus();
49+
});
50+
51+
test('moves focus to the new item when a new alert item is added', () => {
52+
const TestComponent = createDismissibleFlashbar([{ id: 'a', content: 'Item 1', type: 'info', dismissible: true }]);
53+
const { container, rerender } = render(<TestComponent />);
54+
55+
jest.useFakeTimers();
56+
rerender(
57+
<TestComponent
58+
items={[
59+
{ id: 'a', content: 'Item 1', type: 'info', dismissible: true },
60+
{ id: 'b', content: 'Item 2', type: 'info', ariaRole: 'alert', dismissible: true },
61+
]}
62+
/>
63+
);
64+
jest.runAllTimers();
65+
66+
const wrapper = createWrapper(container).findFlashbar()!;
67+
expect(wrapper.findItems()[1]!.find('[role=group]')!.getElement()).toHaveFocus();
68+
});
69+
70+
test('moves focus to the first item added when multiple alert items are added at once', () => {
71+
const TestComponent = createDismissibleFlashbar([{ id: 'a', content: 'Item 1', type: 'info', dismissible: true }]);
72+
const { container, rerender } = render(<TestComponent />);
73+
74+
jest.useFakeTimers();
75+
rerender(
76+
<TestComponent
77+
items={[
78+
{ id: 'a', content: 'Item 1', type: 'info', dismissible: true },
79+
{ id: 'b', content: 'Item 2', type: 'info', ariaRole: 'alert', dismissible: true },
80+
{ id: 'c', content: 'Item 3', type: 'info', ariaRole: 'alert', dismissible: true },
81+
{ id: 'd', content: 'Item 4', type: 'info', ariaRole: 'alert', dismissible: true },
82+
]}
83+
/>
84+
);
85+
jest.runAllTimers();
86+
87+
const wrapper = createWrapper(container).findFlashbar()!;
88+
expect(wrapper.findItems()[1]!.find('[role=group]')!.getElement()).toHaveFocus();
89+
});
90+
});
91+
992
describe('Flashbar focus handling on dismiss', () => {
1093
let mockMainElement: HTMLElement;
1194
let originalQuerySelector: typeof document.querySelector;
@@ -43,23 +126,6 @@ describe('Flashbar focus handling on dismiss', () => {
43126
}));
44127
};
45128

46-
const createDismissibleFlashbar = (items: FlashbarProps.MessageDefinition[]) => {
47-
const TestComponent = () => {
48-
const [flashItems, setFlashItems] = React.useState(items);
49-
50-
const itemsWithHandlers = flashItems.map(item => ({
51-
...item,
52-
onDismiss: () => {
53-
setFlashItems(prev => prev.filter(prevItem => prevItem.id !== item.id));
54-
},
55-
}));
56-
57-
return <Flashbar items={itemsWithHandlers} />;
58-
};
59-
60-
return TestComponent;
61-
};
62-
63129
test('dismiss functionality works correctly', () => {
64130
const items = createTestItems(3);
65131
const TestComponent = createDismissibleFlashbar(items);

src/flashbar/collapsible-flashbar.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { animate, getDOMRects } from '../internal/animate';
1515
import { Transition } from '../internal/components/transition';
1616
import { getVisualContextClassname } from '../internal/components/visual-context';
1717
import customCssProps from '../internal/generated/custom-css-properties';
18+
import { useDebounceCallback } from '../internal/hooks/use-debounce-callback';
1819
import { useEffectOnUpdate } from '../internal/hooks/use-effect-on-update';
1920
import { scrollElementIntoView } from '../internal/utils/scrollable-containers';
2021
import { throttle } from '../internal/utils/throttle';
@@ -27,7 +28,14 @@ import { useFlashbar } from './common';
2728
import { Flash, focusFlashById } from './flash';
2829
import { FlashbarProps } from './interfaces';
2930
import { getCollapsibleFlashStyles, getNotificationBarStyles } from './style';
30-
import { counterTypes, getFlashTypeCount, getItemColor, getVisibleCollapsedItems, StackableItem } from './utils';
31+
import {
32+
counterTypes,
33+
FOCUS_DEBOUNCE_DELAY,
34+
getFlashTypeCount,
35+
getItemColor,
36+
getVisibleCollapsedItems,
37+
StackableItem,
38+
} from './utils';
3139

3240
import styles from './styles.css.js';
3341

@@ -93,16 +101,17 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Flas
93101
setIsFlashbarStackExpanded(prev => !prev);
94102
}
95103

104+
const debouncedFocus = useDebounceCallback(focusFlashById, FOCUS_DEBOUNCE_DELAY);
96105
useLayoutEffect(() => {
97106
if (isFlashbarStackExpanded && items?.length) {
98107
const mostRecentItem = items[0];
99108
if (mostRecentItem.id !== undefined) {
100-
focusFlashById(ref.current, mostRecentItem.id);
109+
debouncedFocus(ref.current, mostRecentItem.id);
101110
}
102111
}
103112
// Run this after expanding, but not every time the items change.
104113
// eslint-disable-next-line react-hooks/exhaustive-deps
105-
}, [isFlashbarStackExpanded]);
114+
}, [debouncedFocus, isFlashbarStackExpanded]);
106115

107116
// When collapsing, scroll up if necessary to avoid losing track of the focused button
108117
useEffectOnUpdate(() => {

src/flashbar/common.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { useMergeRefs, useReducedMotion, warnOnce } from '@cloudscape-design/com
66

77
import { getBaseProps } from '../internal/base-component';
88
import useBaseComponent from '../internal/hooks/use-base-component';
9+
import { useDebounceCallback } from '../internal/hooks/use-debounce-callback';
910
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
1011
import { isDevelopment } from '../internal/is-development';
1112
import { focusFlashById, focusFlashFocusableArea } from './flash';
1213
import { FlashbarProps } from './interfaces';
14+
import { FOCUS_DEBOUNCE_DELAY } from './utils';
1315

1416
import styles from './styles.css.js';
1517

@@ -118,11 +120,13 @@ export function useFlashbar({
118120
}
119121
}
120122

123+
const debouncedFocus = useDebounceCallback(focusFlashById, FOCUS_DEBOUNCE_DELAY);
124+
121125
useEffect(() => {
122126
if (nextFocusId) {
123-
focusFlashById(ref.current, nextFocusId);
127+
debouncedFocus(ref.current, nextFocusId);
124128
}
125-
}, [nextFocusId, ref]);
129+
}, [debouncedFocus, nextFocusId, ref]);
126130

127131
const handleFlashDismissed = (dismissedId?: string) => {
128132
handleFlashDismissedInternal(dismissedId, items, ref.current, flashRefs.current);

0 commit comments

Comments
 (0)