Skip to content

Commit 0960991

Browse files
authored
chore: Introduce focus behavior when flash items gets dismissed (#3646)
1 parent 9e83d9a commit 0960991

File tree

10 files changed

+840
-38
lines changed

10 files changed

+840
-38
lines changed

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,49 @@ test(
7373
return expect(page.isFlashFocused(initialCount + 1)).resolves.toBe(true);
7474
})
7575
);
76+
77+
test(
78+
'dismissing flash item moves focus to next item',
79+
setupTest(async page => {
80+
await page.addSequentialErrorFlashes();
81+
await page.pause(FOCUS_THROTTLE_DELAY);
82+
83+
await page.dismissFirstItem();
84+
await page.pause(FOCUS_THROTTLE_DELAY);
85+
86+
return expect(await page.isFlashFocused(1)).toBe(true);
87+
})
88+
);
89+
90+
test(
91+
'dismissing flash in expanded collapsible state moves focus to next item',
92+
setupTest(async page => {
93+
await page.removeAll();
94+
await page.toggleStackingFeature();
95+
await page.addSequentialErrorFlashes();
96+
await page.toggleCollapsedState();
97+
await page.pause(FOCUS_THROTTLE_DELAY);
98+
99+
await page.dismissFirstItem();
100+
await page.pause(FOCUS_THROTTLE_DELAY);
101+
102+
return expect(await page.isFlashFocused(1)).toBe(true);
103+
})
104+
);
105+
106+
test(
107+
'dismissing flash in collapsed state moves focus to notification bar',
108+
setupTest(async page => {
109+
await page.removeAll();
110+
await page.toggleStackingFeature();
111+
await page.addSequentialErrorFlashes();
112+
await page.pause(FOCUS_THROTTLE_DELAY);
113+
114+
await page.dismissFirstItem();
115+
await page.pause(FOCUS_THROTTLE_DELAY);
116+
117+
const isDismissButtonFocused = await page.isDismissButtonFocused();
118+
119+
return expect(isDismissButtonFocused).toBe(true);
120+
})
121+
);

src/flashbar/__integ__/pages/base.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,21 @@ export class FlashbarBasePage extends BasePageObject {
3030
return createWrapper().findFlashbar().findByClassName(selectors['dismiss-button']).toSelector();
3131
}
3232

33-
isFlashFocused(index: number) {
34-
return this.isFocused(
35-
flashbar.findItems().get(index).findByClassName(selectors['flash-focus-container']).toSelector()
36-
);
33+
isDismissButtonFocused() {
34+
const dismissButton = this.getDismissButton();
35+
return this.isFocused(dismissButton);
36+
}
37+
38+
isFlashDismissButtonFocused(index: number) {
39+
const dismissButton = flashbar.findItems().get(index).findDismissButton().toSelector();
40+
return this.isFocused(dismissButton);
41+
}
42+
43+
async isFlashFocused(index: number) {
44+
const flash = flashbar.findItems().get(index);
45+
const container = flash.findByClassName(selectors['flash-focus-container']).toSelector();
46+
const containerFocused = await this.isFocused(container);
47+
const dismissButtonFocused = await this.isFlashDismissButtonFocused(index);
48+
return containerFocused || dismissButtonFocused;
3749
}
3850
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { handleFlashDismissedInternal } from '../../../lib/components/flashbar/common';
4+
import { FlashbarProps } from '../../../lib/components/flashbar/interfaces';
5+
6+
// Mock the focus utility function
7+
jest.mock('../../../lib/components/flashbar/flash', () => ({
8+
...jest.requireActual('../../../lib/components/flashbar/flash'),
9+
focusFlashFocusableArea: jest.fn(),
10+
}));
11+
12+
import { focusFlashFocusableArea } from '../../../lib/components/flashbar/flash';
13+
const mockFocusFlashFocusableArea = focusFlashFocusableArea as jest.MockedFunction<typeof focusFlashFocusableArea>;
14+
15+
import styles from '../../../lib/components/flashbar/styles.css.js';
16+
17+
describe('handleFlashDismissedInternal', () => {
18+
let mockMainElement: HTMLElement;
19+
let originalQuerySelector: typeof document.querySelector;
20+
let originalSetTimeout: typeof setTimeout;
21+
22+
beforeEach(() => {
23+
jest.clearAllMocks();
24+
25+
mockMainElement = document.createElement('main');
26+
mockMainElement.focus = jest.fn();
27+
28+
originalQuerySelector = document.querySelector;
29+
document.querySelector = jest.fn(selector => {
30+
if (selector === 'main' || selector === '[role="main"]') {
31+
return mockMainElement;
32+
}
33+
return null;
34+
});
35+
36+
originalSetTimeout = global.setTimeout;
37+
global.setTimeout = jest.fn((callback: any) => {
38+
callback();
39+
return 0 as any;
40+
}) as any;
41+
});
42+
43+
afterEach(() => {
44+
document.querySelector = originalQuerySelector;
45+
global.setTimeout = originalSetTimeout;
46+
});
47+
48+
const createTestItems = (count: number): FlashbarProps.MessageDefinition[] => {
49+
return Array.from({ length: count }, (_, i) => ({
50+
id: `item-${i}`,
51+
type: 'info' as const,
52+
header: `Item ${i}`,
53+
content: `Content ${i}`,
54+
dismissible: true,
55+
}));
56+
};
57+
58+
test('does nothing when items is undefined', () => {
59+
const mockRef = document.createElement('div');
60+
const flashRefs = {};
61+
62+
handleFlashDismissedInternal('item-1', undefined, mockRef, flashRefs);
63+
64+
expect(mockFocusFlashFocusableArea).not.toHaveBeenCalled();
65+
expect(mockMainElement.focus).not.toHaveBeenCalled();
66+
});
67+
68+
test('does nothing when dismissedId is undefined', () => {
69+
const items = createTestItems(2);
70+
const mockRef = document.createElement('div');
71+
const flashRefs = {};
72+
73+
handleFlashDismissedInternal(undefined, items, mockRef, flashRefs);
74+
75+
expect(mockFocusFlashFocusableArea).not.toHaveBeenCalled();
76+
expect(mockMainElement.focus).not.toHaveBeenCalled();
77+
});
78+
79+
test('does nothing when refCurrent is null', () => {
80+
const items = createTestItems(2);
81+
const flashRefs = {};
82+
83+
handleFlashDismissedInternal('item-1', items, null, flashRefs);
84+
85+
expect(mockFocusFlashFocusableArea).not.toHaveBeenCalled();
86+
expect(mockMainElement.focus).not.toHaveBeenCalled();
87+
});
88+
89+
test('does nothing when dismissed item is not found', () => {
90+
const items = createTestItems(2);
91+
const mockRef = document.createElement('div');
92+
const flashRefs = {};
93+
94+
handleFlashDismissedInternal('non-existent-item', items, mockRef, flashRefs);
95+
96+
expect(mockFocusFlashFocusableArea).not.toHaveBeenCalled();
97+
expect(mockMainElement.focus).not.toHaveBeenCalled();
98+
});
99+
100+
test('focuses on next item when dismissing first item', () => {
101+
const items = createTestItems(3);
102+
const mockRef = document.createElement('div');
103+
const mockNextElement = document.createElement('div');
104+
const flashRefs = {
105+
'item-1': mockNextElement,
106+
};
107+
108+
handleFlashDismissedInternal('item-0', items, mockRef, flashRefs);
109+
110+
expect(mockFocusFlashFocusableArea).toHaveBeenCalledWith(mockNextElement);
111+
expect(mockMainElement.focus).not.toHaveBeenCalled();
112+
});
113+
114+
test('focuses on previous item when dismissing last item', () => {
115+
const items = createTestItems(3);
116+
const mockRef = document.createElement('div');
117+
const mockPrevElement = document.createElement('div');
118+
const flashRefs = {
119+
'item-1': mockPrevElement,
120+
};
121+
122+
handleFlashDismissedInternal('item-2', items, mockRef, flashRefs);
123+
124+
expect(mockFocusFlashFocusableArea).toHaveBeenCalledWith(mockPrevElement);
125+
expect(mockMainElement.focus).not.toHaveBeenCalled();
126+
});
127+
128+
test('focuses on main element when dismissing only item', () => {
129+
const items = createTestItems(1);
130+
const mockRef = document.createElement('div');
131+
const flashRefs = {};
132+
133+
handleFlashDismissedInternal('item-0', items, mockRef, flashRefs);
134+
135+
expect(mockFocusFlashFocusableArea).not.toHaveBeenCalled();
136+
expect(mockMainElement.focus).toHaveBeenCalled();
137+
});
138+
139+
test('focuses on notification bar button when next flash element is not available', () => {
140+
const items = createTestItems(2);
141+
const mockRef = document.createElement('div');
142+
const mockNotificationButton = document.createElement('button');
143+
mockNotificationButton.focus = jest.fn();
144+
mockNotificationButton.className = styles.button;
145+
146+
mockRef.querySelector = jest.fn(selector => {
147+
if (selector === `.${styles.button}`) {
148+
return mockNotificationButton;
149+
}
150+
return null;
151+
});
152+
153+
const flashRefs = {};
154+
155+
handleFlashDismissedInternal('item-0', items, mockRef, flashRefs);
156+
157+
expect(mockFocusFlashFocusableArea).not.toHaveBeenCalled();
158+
expect(mockNotificationButton.focus).toHaveBeenCalled();
159+
expect(mockMainElement.focus).not.toHaveBeenCalled();
160+
});
161+
162+
test('falls back to main element when neither next flash element nor notification button is available', () => {
163+
const items = createTestItems(2);
164+
const mockRef = document.createElement('div');
165+
166+
mockRef.querySelector = jest.fn(() => null);
167+
168+
const flashRefs = {};
169+
170+
handleFlashDismissedInternal('item-0', items, mockRef, flashRefs);
171+
172+
expect(mockFocusFlashFocusableArea).not.toHaveBeenCalled();
173+
expect(mockMainElement.focus).toHaveBeenCalled();
174+
});
175+
176+
test('focuses on middle item correctly', () => {
177+
const items = createTestItems(5);
178+
const mockRef = document.createElement('div');
179+
const mockNextElement = document.createElement('div');
180+
const flashRefs = {
181+
'item-3': mockNextElement,
182+
};
183+
184+
handleFlashDismissedInternal('item-2', items, mockRef, flashRefs);
185+
186+
expect(mockFocusFlashFocusableArea).toHaveBeenCalledWith(mockNextElement);
187+
});
188+
189+
test('setTimeout is called to defer focus operation', () => {
190+
const items = createTestItems(2);
191+
const mockRef = document.createElement('div');
192+
const mockNextElement = document.createElement('div');
193+
const flashRefs = {
194+
'item-1': mockNextElement,
195+
};
196+
197+
handleFlashDismissedInternal('item-0', items, mockRef, flashRefs);
198+
199+
expect(global.setTimeout).toHaveBeenCalledTimes(1);
200+
expect(global.setTimeout).toHaveBeenCalledWith(expect.any(Function), 0);
201+
});
202+
203+
test('handles empty items array', () => {
204+
const items: FlashbarProps.MessageDefinition[] = [];
205+
const mockRef = document.createElement('div');
206+
const flashRefs = {};
207+
208+
handleFlashDismissedInternal('item-0', items, mockRef, flashRefs);
209+
210+
expect(mockFocusFlashFocusableArea).not.toHaveBeenCalled();
211+
expect(mockMainElement.focus).not.toHaveBeenCalled();
212+
});
213+
214+
test('handles dismissing item at various positions', () => {
215+
const items = createTestItems(4);
216+
const mockRef = document.createElement('div');
217+
218+
const mockNextElement = document.createElement('div');
219+
const flashRefs = { 'item-2': mockNextElement };
220+
221+
handleFlashDismissedInternal('item-1', items, mockRef, flashRefs);
222+
223+
expect(mockFocusFlashFocusableArea).toHaveBeenCalledWith(mockNextElement);
224+
});
225+
226+
test('handles notification button fallback with role="main"', () => {
227+
const items = createTestItems(1);
228+
const mockRef = document.createElement('div');
229+
const flashRefs = {};
230+
231+
const mockRoleMainElement = document.createElement('div');
232+
mockRoleMainElement.setAttribute('role', 'main');
233+
mockRoleMainElement.focus = jest.fn();
234+
235+
document.querySelector = jest.fn(selector => {
236+
if (selector === 'main') {
237+
return null;
238+
}
239+
if (selector === '[role="main"]') {
240+
return mockRoleMainElement;
241+
}
242+
return null;
243+
});
244+
245+
handleFlashDismissedInternal('item-0', items, mockRef, flashRefs);
246+
247+
expect(mockRoleMainElement.focus).toHaveBeenCalled();
248+
});
249+
});

0 commit comments

Comments
 (0)