@astryxdesign/core 0.2.0-canary.ea693ac → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +2 -6
- package/dist/Button/Button.d.ts +1 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +2 -4
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +2 -2
- package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
- package/dist/CheckboxList/CheckboxListItem.js +4 -19
- package/dist/CodeBlock/CodeBlock.js +2 -2
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +1 -10
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +11 -8
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +1 -3
- package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioItem.js +1 -1
- package/dist/Field/FieldLabel.d.ts +1 -2
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +3 -7
- package/dist/Item/Item.d.ts +3 -16
- package/dist/Item/Item.d.ts.map +1 -1
- package/dist/Item/Item.js +6 -31
- package/dist/List/ListItem.d.ts +1 -11
- package/dist/List/ListItem.d.ts.map +1 -1
- package/dist/List/ListItem.js +0 -2
- package/dist/MetadataList/MetadataList.d.ts +1 -1
- package/dist/MetadataList/MetadataList.d.ts.map +1 -1
- package/dist/MetadataList/MetadataList.js +2 -4
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +2 -13
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.js +2 -3
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +0 -5
- package/dist/Skeleton/Skeleton.d.ts.map +1 -1
- package/dist/Skeleton/Skeleton.js +2 -2
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +4 -15
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +1 -1
- package/dist/Toast/ToastViewport.d.ts.map +1 -1
- package/dist/Toast/ToastViewport.js +9 -61
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +1 -3
- package/dist/Token/Token.d.ts +1 -4
- package/dist/Token/Token.d.ts.map +1 -1
- package/dist/Token/Token.js +43 -68
- package/dist/Typeahead/BaseTypeahead.d.ts +0 -9
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +0 -2
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +1 -8
- package/dist/astryx.css +3 -16
- package/dist/astryx.umd.js +48 -48
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/useKeyboardHint.d.ts +1 -1
- package/dist/hooks/useKeyboardHint.d.ts.map +1 -1
- package/dist/hooks/useKeyboardHint.js +2 -4
- package/locales/en.json +0 -32
- package/locales/pseudo.json +0 -24
- package/package.json +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +1 -21
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +2 -4
- package/src/Breadcrumbs/Breadcrumbs.test.tsx +1 -1
- package/src/Button/Button.test.tsx +0 -16
- package/src/Button/Button.tsx +2 -4
- package/src/CheckboxInput/CheckboxInput.test.tsx +0 -23
- package/src/CheckboxInput/CheckboxInput.tsx +2 -17
- package/src/CheckboxList/CheckboxList.test.tsx +3 -94
- package/src/CheckboxList/CheckboxListItem.tsx +10 -17
- package/src/CodeBlock/CodeBlock.test.tsx +0 -17
- package/src/CodeBlock/CodeBlock.tsx +2 -2
- package/src/ContextMenu/ContextMenu.test.tsx +2 -17
- package/src/ContextMenu/ContextMenu.tsx +1 -10
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +12 -4
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +10 -9
- package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
- package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +2 -20
- package/src/Field/FieldLabel.test.tsx +0 -25
- package/src/Field/FieldLabel.tsx +2 -11
- package/src/Item/Item.doc.mjs +0 -5
- package/src/Item/Item.test.tsx +0 -88
- package/src/Item/Item.tsx +11 -57
- package/src/List/ListItem.doc.mjs +0 -12
- package/src/List/ListItem.tsx +0 -13
- package/src/MetadataList/MetadataList.test.tsx +0 -24
- package/src/MetadataList/MetadataList.tsx +2 -6
- package/src/MultiSelector/MultiSelector.test.tsx +0 -76
- package/src/MultiSelector/MultiSelector.tsx +2 -22
- package/src/RadioList/RadioList.test.tsx +4 -26
- package/src/RadioList/RadioListItem.tsx +1 -8
- package/src/SegmentedControl/SegmentedControl.test.tsx +0 -28
- package/src/SegmentedControl/SegmentedControlItem.tsx +2 -20
- package/src/Selector/Selector.test.tsx +0 -22
- package/src/Selector/Selector.tsx +1 -8
- package/src/Skeleton/Skeleton.test.tsx +0 -16
- package/src/Skeleton/Skeleton.tsx +1 -13
- package/src/Switch/Switch.test.tsx +0 -37
- package/src/Switch/Switch.tsx +3 -52
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +0 -19
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +1 -3
- package/src/Toast/ToastViewport.test.tsx +8 -218
- package/src/Toast/ToastViewport.tsx +12 -71
- package/src/ToggleButton/ToggleButton.test.tsx +0 -24
- package/src/ToggleButton/ToggleButton.tsx +1 -19
- package/src/Token/Token.test.tsx +0 -145
- package/src/Token/Token.tsx +33 -73
- package/src/Typeahead/BaseTypeahead.tsx +0 -12
- package/src/Typeahead/Typeahead.test.tsx +0 -62
- package/src/Typeahead/Typeahead.tsx +0 -6
- package/src/hooks/useKeyboardHint.test.tsx +0 -22
- package/src/hooks/useKeyboardHint.tsx +2 -6
- package/dist/Token/TokenLink.d.ts +0 -62
- package/dist/Token/TokenLink.d.ts.map +0 -1
- package/dist/Token/TokenLink.js +0 -76
- package/src/Token/TokenLink.tsx +0 -107
- package/src/__tests__/forcedColors.ts +0 -60
|
@@ -3,56 +3,19 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file ToastViewport.test.tsx
|
|
5
5
|
* @input Uses vitest, @testing-library/react, ToastViewport + useToast
|
|
6
|
-
* @output Unit tests for toast keyboard reach
|
|
7
|
-
*
|
|
8
|
-
* @position Testing; validates ToastViewport.tsx + Toast.tsx focus behavior and
|
|
9
|
-
* the announce-at-dispatch flow through the singleton live regions
|
|
6
|
+
* @output Unit tests for toast keyboard reach and focus management
|
|
7
|
+
* @position Testing; validates ToastViewport.tsx + Toast.tsx focus behavior
|
|
10
8
|
*
|
|
11
|
-
* SYNC: When ToastViewport.tsx
|
|
12
|
-
* announcement flow changes, update these tests
|
|
9
|
+
* SYNC: When ToastViewport.tsx or Toast.tsx focus handling changes, update these tests
|
|
13
10
|
*/
|
|
14
11
|
|
|
15
|
-
import {describe, it, expect, vi, beforeAll
|
|
16
|
-
import {
|
|
17
|
-
render,
|
|
18
|
-
screen,
|
|
19
|
-
fireEvent,
|
|
20
|
-
act,
|
|
21
|
-
waitFor,
|
|
22
|
-
within,
|
|
23
|
-
} from '@testing-library/react';
|
|
12
|
+
import {describe, it, expect, vi, beforeAll} from 'vitest';
|
|
13
|
+
import {render, screen, fireEvent, act} from '@testing-library/react';
|
|
24
14
|
import React from 'react';
|
|
25
|
-
import {type AnnounceFn, __resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
26
15
|
import {ToastViewport} from './ToastViewport';
|
|
27
16
|
import {useToast} from './useToast';
|
|
28
17
|
import type {ToastOptions} from './types';
|
|
29
18
|
|
|
30
|
-
// Spy on the announcement sink so tests can prove a toast is announced exactly
|
|
31
|
-
// once. The mock wraps the real useAnnounce, so the singleton live regions are
|
|
32
|
-
// still populated (the region-content assertions below depend on it) while
|
|
33
|
-
// every call is also recorded on announceSpy.
|
|
34
|
-
const {announceSpy} = vi.hoisted(() => ({announceSpy: vi.fn()}));
|
|
35
|
-
vi.mock('../hooks/useAnnounce', async importActual => {
|
|
36
|
-
const actual = await importActual<{
|
|
37
|
-
useAnnounce: () => AnnounceFn;
|
|
38
|
-
__resetLiveRegionsForTest: () => void;
|
|
39
|
-
}>();
|
|
40
|
-
const {useCallback} = await import('react');
|
|
41
|
-
return {
|
|
42
|
-
...actual,
|
|
43
|
-
useAnnounce: (): AnnounceFn => {
|
|
44
|
-
const realAnnounce = actual.useAnnounce();
|
|
45
|
-
return useCallback<AnnounceFn>(
|
|
46
|
-
(message, politeness) => {
|
|
47
|
-
announceSpy(message, politeness);
|
|
48
|
-
realAnnounce(message, politeness);
|
|
49
|
-
},
|
|
50
|
-
[realAnnounce],
|
|
51
|
-
);
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
});
|
|
55
|
-
|
|
56
19
|
// Popover API is not implemented in jsdom.
|
|
57
20
|
beforeAll(() => {
|
|
58
21
|
if (typeof HTMLElement.prototype.showPopover === 'undefined') {
|
|
@@ -61,13 +24,6 @@ beforeAll(() => {
|
|
|
61
24
|
}
|
|
62
25
|
});
|
|
63
26
|
|
|
64
|
-
// Toast text is mirrored into the singleton live regions, which outlive each
|
|
65
|
-
// render — reset them so text from one test never leaks into the next.
|
|
66
|
-
afterEach(() => {
|
|
67
|
-
__resetLiveRegionsForTest();
|
|
68
|
-
announceSpy.mockClear();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
27
|
// Module-level constant default props (avoids unstable-default-props lint).
|
|
72
28
|
const EMPTY_OPTIONS: ToastOptions = {body: 'placeholder'};
|
|
73
29
|
|
|
@@ -213,10 +169,7 @@ describe('Toast blur timer pause', () => {
|
|
|
213
169
|
act(() => {
|
|
214
170
|
fireEvent.click(screen.getByText('Trigger Auto'));
|
|
215
171
|
});
|
|
216
|
-
|
|
217
|
-
// singleton live region, which lives outside the notifications region.
|
|
218
|
-
const viewport = screen.getByRole('region', {name: 'Notifications'});
|
|
219
|
-
expect(within(viewport).getByText('Auto toast')).toBeInTheDocument();
|
|
172
|
+
expect(screen.getByText('Auto toast')).toBeInTheDocument();
|
|
220
173
|
|
|
221
174
|
// Window loses focus — timer should pause.
|
|
222
175
|
act(() => {
|
|
@@ -226,7 +179,7 @@ describe('Toast blur timer pause', () => {
|
|
|
226
179
|
vi.advanceTimersByTime(5000);
|
|
227
180
|
});
|
|
228
181
|
// Still present because the timer was paused while blurred.
|
|
229
|
-
expect(
|
|
182
|
+
expect(screen.getByText('Auto toast')).toBeInTheDocument();
|
|
230
183
|
|
|
231
184
|
// Window regains focus — timer resumes and the toast dismisses.
|
|
232
185
|
act(() => {
|
|
@@ -243,9 +196,7 @@ describe('Toast blur timer pause', () => {
|
|
|
243
196
|
completeExit(toastId);
|
|
244
197
|
});
|
|
245
198
|
}
|
|
246
|
-
expect(
|
|
247
|
-
within(viewport).queryByText('Auto toast'),
|
|
248
|
-
).not.toBeInTheDocument();
|
|
199
|
+
expect(screen.queryByText('Auto toast')).not.toBeInTheDocument();
|
|
249
200
|
} finally {
|
|
250
201
|
vi.useRealTimers();
|
|
251
202
|
}
|
|
@@ -262,167 +213,6 @@ describe('ToastViewport region ARIA', () => {
|
|
|
262
213
|
});
|
|
263
214
|
});
|
|
264
215
|
|
|
265
|
-
describe('toast announcements via singleton live regions', () => {
|
|
266
|
-
function politeRegion(): HTMLElement | null {
|
|
267
|
-
return document.querySelector('[data-astryx-live-region="polite"]');
|
|
268
|
-
}
|
|
269
|
-
function assertiveRegion(): HTMLElement | null {
|
|
270
|
-
return document.querySelector('[data-astryx-live-region="assertive"]');
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const RICH_INFO: ToastOptions = {
|
|
274
|
-
body: (
|
|
275
|
-
<>
|
|
276
|
-
<strong>Update ready</strong>
|
|
277
|
-
<div>Restart to apply</div>
|
|
278
|
-
</>
|
|
279
|
-
),
|
|
280
|
-
};
|
|
281
|
-
const ERROR_TOAST: ToastOptions = {body: 'Upload failed', type: 'error'};
|
|
282
|
-
const SAVING_V1: ToastOptions = {uniqueID: 'save', body: 'Saving changes'};
|
|
283
|
-
const SAVING_V2: ToastOptions = {uniqueID: 'save', body: 'Changes saved'};
|
|
284
|
-
const IGNORE_KEPT: ToastOptions = {
|
|
285
|
-
uniqueID: 'dup',
|
|
286
|
-
collisionBehavior: 'ignore',
|
|
287
|
-
body: 'Kept',
|
|
288
|
-
};
|
|
289
|
-
const IGNORE_DROPPED: ToastOptions = {
|
|
290
|
-
uniqueID: 'dup',
|
|
291
|
-
collisionBehavior: 'ignore',
|
|
292
|
-
body: 'Dropped',
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
it('announces an info toast politely with its flattened text content', async () => {
|
|
296
|
-
renderViewport(<ShowToastButton options={RICH_INFO} triggerLabel="Show" />);
|
|
297
|
-
act(() => {
|
|
298
|
-
fireEvent.click(screen.getByText('Show'));
|
|
299
|
-
});
|
|
300
|
-
// Announced synchronously at dispatch, exactly once.
|
|
301
|
-
expect(announceSpy).toHaveBeenCalledTimes(1);
|
|
302
|
-
expect(announceSpy).toHaveBeenCalledWith(
|
|
303
|
-
'Update ready Restart to apply',
|
|
304
|
-
'polite',
|
|
305
|
-
);
|
|
306
|
-
// The flattened text reaches the polite singleton region (the sink).
|
|
307
|
-
await waitFor(() => {
|
|
308
|
-
expect(politeRegion()).toHaveTextContent('Update ready Restart to apply');
|
|
309
|
-
});
|
|
310
|
-
// Status toasts never touch the assertive region.
|
|
311
|
-
expect(assertiveRegion()).toHaveTextContent('');
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
it('announces an error toast assertively', async () => {
|
|
315
|
-
renderViewport(
|
|
316
|
-
<ShowToastButton options={ERROR_TOAST} triggerLabel="Show" />,
|
|
317
|
-
);
|
|
318
|
-
act(() => {
|
|
319
|
-
fireEvent.click(screen.getByText('Show'));
|
|
320
|
-
});
|
|
321
|
-
expect(announceSpy).toHaveBeenCalledTimes(1);
|
|
322
|
-
expect(announceSpy).toHaveBeenCalledWith('Upload failed', 'assertive');
|
|
323
|
-
await waitFor(() => {
|
|
324
|
-
expect(assertiveRegion()).toHaveTextContent('Upload failed');
|
|
325
|
-
});
|
|
326
|
-
expect(politeRegion()).toHaveTextContent('');
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it('announces exactly once at dispatch, even under StrictMode', () => {
|
|
330
|
-
render(
|
|
331
|
-
<React.StrictMode>
|
|
332
|
-
<ToastViewport isTopLayer={false}>
|
|
333
|
-
<ShowToastButton options={INFO_A} triggerLabel="Show" />
|
|
334
|
-
</ToastViewport>
|
|
335
|
-
</React.StrictMode>,
|
|
336
|
-
);
|
|
337
|
-
act(() => {
|
|
338
|
-
fireEvent.click(screen.getByText('Show'));
|
|
339
|
-
});
|
|
340
|
-
// The announcement lives in the imperative dispatch path (addToast), not a
|
|
341
|
-
// render effect, so StrictMode's double render and double-invoked state
|
|
342
|
-
// updater cannot announce the same toast twice.
|
|
343
|
-
expect(announceSpy).toHaveBeenCalledTimes(1);
|
|
344
|
-
expect(announceSpy).toHaveBeenCalledWith('Toast A', 'polite');
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it('re-announces a uniqueID toast when its content is overwritten', async () => {
|
|
348
|
-
renderViewport(
|
|
349
|
-
<>
|
|
350
|
-
<ShowToastButton options={SAVING_V1} triggerLabel="Show v1" />
|
|
351
|
-
<ShowToastButton options={SAVING_V2} triggerLabel="Show v2" />
|
|
352
|
-
</>,
|
|
353
|
-
);
|
|
354
|
-
act(() => {
|
|
355
|
-
fireEvent.click(screen.getByText('Show v1'));
|
|
356
|
-
});
|
|
357
|
-
expect(announceSpy).toHaveBeenCalledTimes(1);
|
|
358
|
-
expect(announceSpy).toHaveBeenLastCalledWith('Saving changes', 'polite');
|
|
359
|
-
// Overwriting via uniqueID replaces the toast in place — the new content is
|
|
360
|
-
// a fresh dispatch and must be announced again.
|
|
361
|
-
act(() => {
|
|
362
|
-
fireEvent.click(screen.getByText('Show v2'));
|
|
363
|
-
});
|
|
364
|
-
expect(announceSpy).toHaveBeenCalledTimes(2);
|
|
365
|
-
expect(announceSpy).toHaveBeenLastCalledWith('Changes saved', 'polite');
|
|
366
|
-
await waitFor(() => {
|
|
367
|
-
expect(politeRegion()).toHaveTextContent('Changes saved');
|
|
368
|
-
});
|
|
369
|
-
// Still a single toast on screen — overwritten in place, not stacked.
|
|
370
|
-
const viewport = screen.getByRole('region', {name: 'Notifications'});
|
|
371
|
-
expect(
|
|
372
|
-
within(viewport).queryAllByText(/Saving changes|Changes saved/),
|
|
373
|
-
).toHaveLength(1);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
it('does not re-announce an unchanged toast when an unrelated render occurs', () => {
|
|
377
|
-
renderViewport(
|
|
378
|
-
<>
|
|
379
|
-
<ShowToastButton options={INFO_A} triggerLabel="Show A" />
|
|
380
|
-
<ShowToastButton options={ERROR_TOAST} triggerLabel="Show B" />
|
|
381
|
-
</>,
|
|
382
|
-
);
|
|
383
|
-
act(() => {
|
|
384
|
-
fireEvent.click(screen.getByText('Show A'));
|
|
385
|
-
});
|
|
386
|
-
expect(announceSpy).toHaveBeenCalledTimes(1);
|
|
387
|
-
// A second toast arriving re-renders the viewport with a new toast list.
|
|
388
|
-
// Toast A is not re-dispatched, so it must not be announced again — only
|
|
389
|
-
// the newly dispatched toast produces a call.
|
|
390
|
-
act(() => {
|
|
391
|
-
fireEvent.click(screen.getByText('Show B'));
|
|
392
|
-
});
|
|
393
|
-
expect(announceSpy).toHaveBeenCalledTimes(2);
|
|
394
|
-
expect(announceSpy).toHaveBeenNthCalledWith(1, 'Toast A', 'polite');
|
|
395
|
-
expect(announceSpy).toHaveBeenNthCalledWith(
|
|
396
|
-
2,
|
|
397
|
-
'Upload failed',
|
|
398
|
-
'assertive',
|
|
399
|
-
);
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it('does not announce a toast whose uniqueID collision is ignored', () => {
|
|
403
|
-
renderViewport(
|
|
404
|
-
<>
|
|
405
|
-
<ShowToastButton options={IGNORE_KEPT} triggerLabel="Show 1" />
|
|
406
|
-
<ShowToastButton options={IGNORE_DROPPED} triggerLabel="Show 2" />
|
|
407
|
-
</>,
|
|
408
|
-
);
|
|
409
|
-
act(() => {
|
|
410
|
-
fireEvent.click(screen.getByText('Show 1'));
|
|
411
|
-
});
|
|
412
|
-
expect(announceSpy).toHaveBeenCalledTimes(1);
|
|
413
|
-
expect(announceSpy).toHaveBeenLastCalledWith('Kept', 'polite');
|
|
414
|
-
// The colliding toast is suppressed (collisionBehavior: 'ignore'), so it is
|
|
415
|
-
// neither shown nor announced.
|
|
416
|
-
act(() => {
|
|
417
|
-
fireEvent.click(screen.getByText('Show 2'));
|
|
418
|
-
});
|
|
419
|
-
expect(announceSpy).toHaveBeenCalledTimes(1);
|
|
420
|
-
const viewport = screen.getByRole('region', {name: 'Notifications'});
|
|
421
|
-
expect(within(viewport).getByText('Kept')).toBeInTheDocument();
|
|
422
|
-
expect(within(viewport).queryByText('Dropped')).not.toBeInTheDocument();
|
|
423
|
-
});
|
|
424
|
-
});
|
|
425
|
-
|
|
426
216
|
describe('toast timer lifecycle (#3589)', () => {
|
|
427
217
|
it('fires onHide exactly once when dismissed twice during the exit window', () => {
|
|
428
218
|
const onHide = vi.fn();
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
'use client';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
-
isValidElement,
|
|
7
6
|
useCallback,
|
|
8
7
|
useEffect,
|
|
9
8
|
useLayoutEffect,
|
|
@@ -11,12 +10,10 @@ import {
|
|
|
11
10
|
useRef,
|
|
12
11
|
useState,
|
|
13
12
|
} from 'react';
|
|
14
|
-
import type {ReactNode} from 'react';
|
|
15
13
|
import * as stylex from '@stylexjs/stylex';
|
|
16
14
|
import {spacingVars, durationVars, easeVars} from '../theme/tokens.stylex';
|
|
17
15
|
import {mergeProps} from '../utils';
|
|
18
16
|
import {INTERACTIVE_SELECTORS} from '../hooks/useClickableContainer';
|
|
19
|
-
import {useAnnounce} from '../hooks/useAnnounce';
|
|
20
17
|
import {Toast} from './Toast';
|
|
21
18
|
import {ToastContext, type ToastContextValue} from './ToastContext';
|
|
22
19
|
import type {ToastEntry, ToastPosition, ToastDismissReason} from './types';
|
|
@@ -79,29 +76,6 @@ const styles = stylex.create({
|
|
|
79
76
|
},
|
|
80
77
|
});
|
|
81
78
|
|
|
82
|
-
// Flatten a toast's rendered content (title, description, etc.) to the plain
|
|
83
|
-
// text that should be spoken by a screen reader. Only text is announced —
|
|
84
|
-
// interactive endContent is deliberately excluded (it is reachable via F6).
|
|
85
|
-
function getNodeText(node: ReactNode): string {
|
|
86
|
-
if (node == null || typeof node === 'boolean') {
|
|
87
|
-
return '';
|
|
88
|
-
}
|
|
89
|
-
if (typeof node === 'string') {
|
|
90
|
-
return node;
|
|
91
|
-
}
|
|
92
|
-
if (typeof node === 'number') {
|
|
93
|
-
return String(node);
|
|
94
|
-
}
|
|
95
|
-
if (Array.isArray(node)) {
|
|
96
|
-
return node.map(getNodeText).filter(Boolean).join(' ');
|
|
97
|
-
}
|
|
98
|
-
if (isValidElement(node)) {
|
|
99
|
-
const {children} = node.props as {children?: ReactNode};
|
|
100
|
-
return getNodeText(children);
|
|
101
|
-
}
|
|
102
|
-
return '';
|
|
103
|
-
}
|
|
104
|
-
|
|
105
79
|
export interface ToastViewportProps {
|
|
106
80
|
position?: ToastPosition;
|
|
107
81
|
maxVisible?: number;
|
|
@@ -185,54 +159,21 @@ export function ToastViewport({
|
|
|
185
159
|
[],
|
|
186
160
|
);
|
|
187
161
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
// is "born with content" — mounted together with its text — which many
|
|
191
|
-
// screen readers do not announce (see useAnnounce.ts); the singleton regions
|
|
192
|
-
// are mounted empty and only mutated afterwards, so they are what actually
|
|
193
|
-
// guarantees the announcement (the per-toast markup is kept for browse-mode
|
|
194
|
-
// discoverability). The announcement happens in addToast — the imperative
|
|
195
|
-
// dispatch path invoked once per useToast() call from an event handler,
|
|
196
|
-
// never from render — so each toast is announced exactly once by
|
|
197
|
-
// construction, independent of the React render lifecycle (StrictMode
|
|
198
|
-
// double-render/double-effect, viewport remounts, and unrelated list
|
|
199
|
-
// re-renders never re-announce). It is client-only (addToast never runs
|
|
200
|
-
// during SSR), so it is SSR-safe.
|
|
201
|
-
const announce = useAnnounce();
|
|
202
|
-
|
|
203
|
-
const addToast = useCallback(
|
|
204
|
-
(entry: ToastEntry) => {
|
|
162
|
+
const addToast = useCallback((entry: ToastEntry) => {
|
|
163
|
+
setToasts(prev => {
|
|
205
164
|
const {uniqueID, collisionBehavior = 'overwrite'} = entry.options;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
uniqueID &&
|
|
212
|
-
collisionBehavior === 'ignore' &&
|
|
213
|
-
toastsRef.current.some(t => t.options.uniqueID === uniqueID)
|
|
214
|
-
) {
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const text = getNodeText(entry.options.body);
|
|
218
|
-
if (text) {
|
|
219
|
-
// Error toasts map to the assertive region (role="alert"); everything
|
|
220
|
-
// else to the polite region (role="status") — mirrors Toast.tsx.
|
|
221
|
-
announce(text, entry.options.type === 'error' ? 'assertive' : 'polite');
|
|
222
|
-
}
|
|
223
|
-
setToasts(prev => {
|
|
224
|
-
if (uniqueID) {
|
|
225
|
-
const existing = prev.find(t => t.options.uniqueID === uniqueID);
|
|
226
|
-
if (existing) {
|
|
227
|
-
// An ignored collision already returned above; overwrite in place.
|
|
228
|
-
return prev.map(t => (t.options.uniqueID === uniqueID ? entry : t));
|
|
165
|
+
if (uniqueID) {
|
|
166
|
+
const existing = prev.find(t => t.options.uniqueID === uniqueID);
|
|
167
|
+
if (existing) {
|
|
168
|
+
if (collisionBehavior === 'ignore') {
|
|
169
|
+
return prev;
|
|
229
170
|
}
|
|
171
|
+
return prev.map(t => (t.options.uniqueID === uniqueID ? entry : t));
|
|
230
172
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
);
|
|
173
|
+
}
|
|
174
|
+
return [...prev, entry];
|
|
175
|
+
});
|
|
176
|
+
}, []);
|
|
236
177
|
|
|
237
178
|
const removeToast = useCallback((id: string, reason: ToastDismissReason) => {
|
|
238
179
|
// An exiting toast stays in toastsRef until its exit transition ends, so
|
|
@@ -14,10 +14,6 @@ import userEvent from '@testing-library/user-event';
|
|
|
14
14
|
import {useState, type MouseEvent} from 'react';
|
|
15
15
|
import {ToggleButton} from './ToggleButton';
|
|
16
16
|
import {ToggleButtonGroup} from './ToggleButtonGroup';
|
|
17
|
-
import {
|
|
18
|
-
getAllInjectedCss,
|
|
19
|
-
getForcedColorsRules,
|
|
20
|
-
} from '../__tests__/forcedColors';
|
|
21
17
|
|
|
22
18
|
// =============================================================================
|
|
23
19
|
// ToggleButton — Standalone
|
|
@@ -521,23 +517,3 @@ describe('ToggleButtonGroup (multiple)', () => {
|
|
|
521
517
|
);
|
|
522
518
|
});
|
|
523
519
|
});
|
|
524
|
-
|
|
525
|
-
// jsdom cannot emulate forced-colors rendering, so these assert that the
|
|
526
|
-
// compiled output includes the forced-colors rules; visual behavior needs
|
|
527
|
-
// manual verification under Windows High Contrast.
|
|
528
|
-
describe('forced colors (WCAG 1.4.11)', () => {
|
|
529
|
-
it('compiles forced-colors overrides so the pressed state survives Windows High Contrast', () => {
|
|
530
|
-
render(<ToggleButton label="Bold" isPressed onPressedChange={() => {}} />);
|
|
531
|
-
const css = getForcedColorsRules();
|
|
532
|
-
// The painted pressed overlay is stripped; Highlight/HighlightText marks
|
|
533
|
-
// the pressed toggle (critical for icon-only toggles, which otherwise
|
|
534
|
-
// lose all pressed indication).
|
|
535
|
-
expect(css).toContain('background-color: highlight;');
|
|
536
|
-
expect(css).toContain('color: highlighttext;');
|
|
537
|
-
// ToggleButton renders a <button>; without opting out of UA remapping it
|
|
538
|
-
// keeps the native ButtonFace surface and ignores the Highlight fill,
|
|
539
|
-
// leaving HighlightText text on a white surface. forced-color-adjust: none
|
|
540
|
-
// makes both render as authored.
|
|
541
|
-
expect(getAllInjectedCss()).toContain('forced-color-adjust: none;');
|
|
542
|
-
});
|
|
543
|
-
});
|
|
@@ -40,25 +40,7 @@ import {themeProps} from '../utils/themeProps';
|
|
|
40
40
|
*/
|
|
41
41
|
const pressedStyles = stylex.create({
|
|
42
42
|
background: {
|
|
43
|
-
|
|
44
|
-
// and the UA keeps native form-control colors (ButtonFace surface) for it
|
|
45
|
-
// under forced colors, ignoring the authored Highlight fill — the label kept
|
|
46
|
-
// its HighlightText color, giving white text on a white surface. Opting the
|
|
47
|
-
// pressed button out of UA remapping makes both the Highlight surface and
|
|
48
|
-
// the HighlightText label render as authored, restoring figure-ground.
|
|
49
|
-
forcedColorAdjust: 'none',
|
|
50
|
-
backgroundColor: {
|
|
51
|
-
default: colorVars['--color-overlay-pressed'],
|
|
52
|
-
// Forced colors (Windows High Contrast) strips the painted pressed
|
|
53
|
-
// overlay, which would leave icon-only toggles with no pressed
|
|
54
|
-
// indication at all. Highlight/HighlightText is the platform convention
|
|
55
|
-
// for a selected/pressed control (WCAG 1.4.11).
|
|
56
|
-
'@media (forced-colors: active)': 'Highlight',
|
|
57
|
-
},
|
|
58
|
-
color: {
|
|
59
|
-
default: null,
|
|
60
|
-
'@media (forced-colors: active)': 'HighlightText',
|
|
61
|
-
},
|
|
43
|
+
backgroundColor: colorVars['--color-overlay-pressed'],
|
|
62
44
|
},
|
|
63
45
|
});
|
|
64
46
|
|
package/src/Token/Token.test.tsx
CHANGED
|
@@ -330,151 +330,6 @@ describe('Token accessibility', () => {
|
|
|
330
330
|
});
|
|
331
331
|
});
|
|
332
332
|
|
|
333
|
-
describe('Token link with remove button', () => {
|
|
334
|
-
it('does not nest the remove button inside the link', () => {
|
|
335
|
-
render(<Token label="Tag" href="/test" onRemove={() => {}} />);
|
|
336
|
-
const link = screen.getByRole('link', {name: 'Tag'});
|
|
337
|
-
const removeButton = screen.getByRole('button', {name: 'Remove Tag'});
|
|
338
|
-
expect(link.contains(removeButton)).toBe(false);
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it('keeps the link keyboard-focusable and fires onRemove from the sibling button', async () => {
|
|
342
|
-
const user = userEvent.setup();
|
|
343
|
-
const handleRemove = vi.fn();
|
|
344
|
-
render(<Token label="Tag" href="/test" onRemove={handleRemove} />);
|
|
345
|
-
|
|
346
|
-
const link = screen.getByRole('link', {name: 'Tag'});
|
|
347
|
-
expect(link.tagName).toBe('A');
|
|
348
|
-
expect(link).toHaveAttribute('href', '/test');
|
|
349
|
-
|
|
350
|
-
// Tab reaches the link first, then the remove button
|
|
351
|
-
await user.tab();
|
|
352
|
-
expect(link).toHaveFocus();
|
|
353
|
-
await user.tab();
|
|
354
|
-
const removeButton = screen.getByRole('button', {name: 'Remove Tag'});
|
|
355
|
-
expect(removeButton).toHaveFocus();
|
|
356
|
-
|
|
357
|
-
// Activating the remove button fires onRemove without involving the link
|
|
358
|
-
await user.keyboard('{Enter}');
|
|
359
|
-
expect(handleRemove).toHaveBeenCalledTimes(1);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it('wrapper structure matches the onClick branch pattern', () => {
|
|
363
|
-
render(
|
|
364
|
-
<Token
|
|
365
|
-
label="Tag"
|
|
366
|
-
href="/test"
|
|
367
|
-
onRemove={() => {}}
|
|
368
|
-
data-testid="link-token"
|
|
369
|
-
/>,
|
|
370
|
-
);
|
|
371
|
-
render(
|
|
372
|
-
<Token
|
|
373
|
-
label="Tag"
|
|
374
|
-
onClick={() => {}}
|
|
375
|
-
onRemove={() => {}}
|
|
376
|
-
data-testid="click-token"
|
|
377
|
-
/>,
|
|
378
|
-
);
|
|
379
|
-
|
|
380
|
-
const linkRoot = screen.getByTestId('link-token');
|
|
381
|
-
const clickRoot = screen.getByTestId('click-token');
|
|
382
|
-
|
|
383
|
-
// Both render a <span> container with the same visual classes
|
|
384
|
-
expect(linkRoot.tagName).toBe('SPAN');
|
|
385
|
-
expect(linkRoot.className).toBe(clickRoot.className);
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
it('clicking the container activates the link', () => {
|
|
389
|
-
render(
|
|
390
|
-
<Token
|
|
391
|
-
label="Tag"
|
|
392
|
-
href="/test"
|
|
393
|
-
onRemove={() => {}}
|
|
394
|
-
icon={<span data-testid="icon">★</span>}
|
|
395
|
-
data-testid="link-token"
|
|
396
|
-
/>,
|
|
397
|
-
);
|
|
398
|
-
const link = screen.getByRole('link', {name: 'Tag'});
|
|
399
|
-
const handleLinkClick = vi.fn((e: MouseEvent) => e.preventDefault());
|
|
400
|
-
link.addEventListener('click', handleLinkClick);
|
|
401
|
-
|
|
402
|
-
// Clicking outside the anchor (e.g. the icon) still activates the link
|
|
403
|
-
fireEvent.click(screen.getByTestId('icon'));
|
|
404
|
-
expect(handleLinkClick).toHaveBeenCalledTimes(1);
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
it('clicking the remove button does not activate the link', () => {
|
|
408
|
-
const handleRemove = vi.fn();
|
|
409
|
-
render(<Token label="Tag" href="/test" onRemove={handleRemove} />);
|
|
410
|
-
const link = screen.getByRole('link', {name: 'Tag'});
|
|
411
|
-
const handleLinkClick = vi.fn((e: MouseEvent) => e.preventDefault());
|
|
412
|
-
link.addEventListener('click', handleLinkClick);
|
|
413
|
-
|
|
414
|
-
fireEvent.click(screen.getByRole('button', {name: 'Remove Tag'}));
|
|
415
|
-
expect(handleRemove).toHaveBeenCalledTimes(1);
|
|
416
|
-
expect(handleLinkClick).not.toHaveBeenCalled();
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it('cmd/ctrl+click on the container opens the link in a new tab', () => {
|
|
420
|
-
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
421
|
-
render(
|
|
422
|
-
<Token
|
|
423
|
-
label="Tag"
|
|
424
|
-
href="/test"
|
|
425
|
-
onRemove={() => {}}
|
|
426
|
-
icon={<span data-testid="icon">★</span>}
|
|
427
|
-
/>,
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
// Clicking the surface (not the anchor) with a modifier opens a new tab
|
|
431
|
-
// rather than navigating in place — provided by useClickableContainer.
|
|
432
|
-
fireEvent.click(screen.getByTestId('icon'), {metaKey: true});
|
|
433
|
-
expect(openSpy).toHaveBeenCalledWith('/test', '_blank', 'noopener');
|
|
434
|
-
openSpy.mockRestore();
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
it('middle-click on the container opens the link in a new tab', () => {
|
|
438
|
-
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
439
|
-
render(
|
|
440
|
-
<Token
|
|
441
|
-
label="Tag"
|
|
442
|
-
href="/test"
|
|
443
|
-
onRemove={() => {}}
|
|
444
|
-
icon={<span data-testid="icon">★</span>}
|
|
445
|
-
/>,
|
|
446
|
-
);
|
|
447
|
-
|
|
448
|
-
fireEvent.mouseUp(screen.getByTestId('icon'), {button: 1});
|
|
449
|
-
expect(openSpy).toHaveBeenCalledWith('/test', '_blank', 'noopener');
|
|
450
|
-
openSpy.mockRestore();
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
it('a modified click that opens a new tab does not fire onRemove', () => {
|
|
454
|
-
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
455
|
-
const handleRemove = vi.fn();
|
|
456
|
-
render(
|
|
457
|
-
<Token
|
|
458
|
-
label="Tag"
|
|
459
|
-
href="/test"
|
|
460
|
-
onRemove={handleRemove}
|
|
461
|
-
icon={<span data-testid="icon">★</span>}
|
|
462
|
-
/>,
|
|
463
|
-
);
|
|
464
|
-
|
|
465
|
-
fireEvent.click(screen.getByTestId('icon'), {metaKey: true});
|
|
466
|
-
fireEvent.mouseUp(screen.getByTestId('icon'), {button: 1});
|
|
467
|
-
expect(handleRemove).not.toHaveBeenCalled();
|
|
468
|
-
expect(openSpy).toHaveBeenCalledTimes(2);
|
|
469
|
-
openSpy.mockRestore();
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
it('link without onRemove still renders the anchor as the root', () => {
|
|
473
|
-
const {container} = render(<Token label="Link" href="/test" />);
|
|
474
|
-
expect(container.firstElementChild?.tagName).toBe('A');
|
|
475
|
-
});
|
|
476
|
-
});
|
|
477
|
-
|
|
478
333
|
describe('Token text overflow', () => {
|
|
479
334
|
it('label element has overflow hidden and text-overflow ellipsis styles', () => {
|
|
480
335
|
const {container} = render(<Token label="A very long label text" />);
|