@transferwise/components 0.0.0-experimental-09066ec → 0.0.0-experimental-148ffef

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.
Files changed (84) hide show
  1. package/build/common/liveRegion/LiveRegion.js +44 -7
  2. package/build/common/liveRegion/LiveRegion.js.map +1 -1
  3. package/build/common/liveRegion/LiveRegion.mjs +44 -7
  4. package/build/common/liveRegion/LiveRegion.mjs.map +1 -1
  5. package/build/flowNavigation/FlowNavigation.js +1 -0
  6. package/build/flowNavigation/FlowNavigation.js.map +1 -1
  7. package/build/flowNavigation/FlowNavigation.mjs +1 -0
  8. package/build/flowNavigation/FlowNavigation.mjs.map +1 -1
  9. package/build/overlayHeader/OverlayHeader.js +1 -0
  10. package/build/overlayHeader/OverlayHeader.js.map +1 -1
  11. package/build/overlayHeader/OverlayHeader.mjs +1 -0
  12. package/build/overlayHeader/OverlayHeader.mjs.map +1 -1
  13. package/build/prompt/InfoPrompt/InfoPrompt.js +2 -0
  14. package/build/prompt/InfoPrompt/InfoPrompt.js.map +1 -1
  15. package/build/prompt/InfoPrompt/InfoPrompt.mjs +2 -0
  16. package/build/prompt/InfoPrompt/InfoPrompt.mjs.map +1 -1
  17. package/build/types/common/liveRegion/LiveRegion.d.ts +5 -2
  18. package/build/types/common/liveRegion/LiveRegion.d.ts.map +1 -1
  19. package/build/types/prompt/InfoPrompt/InfoPrompt.d.ts.map +1 -1
  20. package/package.json +3 -3
  21. package/src/{DisabledComponents.story.tsx → DisabledComponents.test.story.tsx} +1 -1
  22. package/src/actionButton/ActionButton.story.tsx +11 -7
  23. package/src/actionButton/ActionButton.test.story.tsx +49 -0
  24. package/src/avatarLayout/AvatarLayout.story.tsx +1 -105
  25. package/src/avatarLayout/AvatarLayout.test.story.tsx +117 -0
  26. package/src/avatarView/AvatarView.story.tsx +1 -104
  27. package/src/avatarView/AvatarView.test.story.tsx +114 -0
  28. package/src/button/LegacyButton.story.tsx +1 -26
  29. package/src/button/LegacyButton.test.story.tsx +37 -0
  30. package/src/button/_stories/Button.story.tsx +17 -41
  31. package/src/button/_stories/Button.test.story.tsx +30 -0
  32. package/src/checkbox/Checkbox.story.tsx +1 -6
  33. package/src/checkbox/Checkbox.test.story.tsx +33 -0
  34. package/src/circularButton/CircularButton.story.tsx +0 -6
  35. package/src/circularButton/CircularButton.test.story.tsx +62 -1
  36. package/src/common/bottomSheet/BottomSheet.story.tsx +0 -55
  37. package/src/common/bottomSheet/BottomSheet.test.story.tsx +118 -2
  38. package/src/common/liveRegion/LiveRegion.test.tsx +69 -2
  39. package/src/common/liveRegion/LiveRegion.tsx +75 -8
  40. package/src/criticalBanner/CriticalCommsBanner.test.story.tsx +1 -1
  41. package/src/dateLookup/DateLookup.story.tsx +0 -18
  42. package/src/dateLookup/DateLookup.test.story.tsx +51 -1
  43. package/src/divider/Divider.story.tsx +0 -1
  44. package/src/expressiveMoneyInput/ExpressiveMoneyInput.autofocus.docs.mdx +1 -1
  45. package/src/expressiveMoneyInput/ExpressiveMoneyInput.story.tsx +0 -9
  46. package/src/expressiveMoneyInput/ExpressiveMoneyInput.test.story.tsx +23 -0
  47. package/src/field/Field.story.tsx +1 -34
  48. package/src/field/Field.test.story.tsx +56 -0
  49. package/src/header/Header.test.story.tsx +1 -1
  50. package/src/info/Info.story.tsx +1 -34
  51. package/src/info/Info.test.story.tsx +53 -0
  52. package/src/inputWithDisplayFormat/InputWithDisplayFormat.story.tsx +0 -12
  53. package/src/inputWithDisplayFormat/InputWithDisplayFormat.test.story.tsx +33 -0
  54. package/src/legacylistItem/LegacyListItem.test.story.tsx +1 -1
  55. package/src/listItem/_stories/Breakpoints/ListItem.noMedia.test.story.tsx +1 -1
  56. package/src/listItem/_stories/Breakpoints/ListItem.sideMedia.test.story.tsx +1 -1
  57. package/src/listItem/_stories/Breakpoints/ListItem.stackedMedia.test.story.tsx +1 -1
  58. package/src/listItem/_stories/ListItem.focus.test.story.tsx +1 -1
  59. package/src/listItem/_stories/ListItem.layout.test.story.tsx +1 -1
  60. package/src/listItem/_stories/variants/ListItem.brightGreen.test.story.tsx +1 -1
  61. package/src/listItem/_stories/variants/ListItem.dark.test.story.tsx +1 -1
  62. package/src/listItem/_stories/variants/ListItem.forestGreen.test.story.tsx +1 -1
  63. package/src/listItem/_stories/variants/ListItem.medium.test.story.tsx +1 -1
  64. package/src/listItem/_stories/variants/ListItem.neutral.test.story.tsx +1 -1
  65. package/src/listItem/_stories/variants/ListItem.personal.test.story.tsx +1 -1
  66. package/src/listItem/_stories/variants/ListItem.rtl.test.story.tsx +1 -1
  67. package/src/listItem/_stories/variants/ListItem.small.test.story.tsx +1 -1
  68. package/src/logo/Logo.story.tsx +0 -6
  69. package/src/logo/Logo.test.story.tsx +20 -0
  70. package/src/modal/Modal.story.tsx +0 -26
  71. package/src/modal/Modal.test.story.tsx +125 -0
  72. package/src/moneyInput/MoneyInput.test.story.tsx +1 -1
  73. package/src/prompt/ActionPrompt/ActionPrompt.story.tsx +0 -43
  74. package/src/prompt/ActionPrompt/ActionPrompt.test.story.tsx +45 -1
  75. package/src/prompt/InfoPrompt/InfoPrompt.test.story.tsx +142 -5
  76. package/src/prompt/InfoPrompt/InfoPrompt.test.tsx +11 -6
  77. package/src/prompt/InfoPrompt/InfoPrompt.tsx +2 -1
  78. package/src/radioGroup/RadioGroup.test.story.tsx +1 -1
  79. package/src/stepper/Stepper.test.story.tsx +1 -1
  80. package/src/summary/Summary.test.story.tsx +1 -1
  81. package/src/tooltip/Tooltip.story.tsx +0 -21
  82. package/src/tooltip/Tooltip.test.story.tsx +42 -0
  83. package/src/typeahead/Typeahead.story.tsx +0 -52
  84. package/src/typeahead/Typeahead.test.story.tsx +73 -0
@@ -1,5 +1,6 @@
1
1
  import { Meta, StoryObj } from '@storybook/react-webpack5';
2
2
  import { ArrowRight, ChevronRight, Freeze } from '@transferwise/icons';
3
+ import { Flag } from '@wise/art';
3
4
  import { expect, userEvent, within } from 'storybook/test';
4
5
  import Button from '../Button.resolver';
5
6
  import { ButtonPriority } from '../Button.types';
@@ -135,3 +136,32 @@ export const AllVariants: Story = {
135
136
  </div>
136
137
  ),
137
138
  };
139
+
140
+ export const Disabled: Story = {
141
+ args: {
142
+ v2: true,
143
+ disabled: true,
144
+ children: 'Disabled button',
145
+ },
146
+ };
147
+
148
+ export const Loading: Story = {
149
+ args: {
150
+ v2: true,
151
+ loading: true,
152
+ children: 'Loading button',
153
+ },
154
+ };
155
+
156
+ export const AccessibilityAddons: Story = {
157
+ args: {
158
+ v2: true,
159
+ addonStart: {
160
+ type: 'avatar',
161
+ value: [{ asset: <Flag code="br" /> }, { asset: <Flag code="jp" /> }],
162
+ },
163
+ 'aria-label': 'Convert Real to Yen',
164
+ children: 'Convert',
165
+ size: 'md',
166
+ },
167
+ };
@@ -2,10 +2,10 @@ import { Meta, StoryObj } from '@storybook/react-webpack5';
2
2
  import { fn } from 'storybook/test';
3
3
 
4
4
  import { Field } from '../field/Field';
5
- import { lorem10 } from '../test-utils';
6
5
 
7
6
  import Checkbox from './Checkbox';
8
7
  import { withVariantConfig } from '../../.storybook/helpers';
8
+ import { lorem10 } from '../test-utils';
9
9
 
10
10
  const meta: Meta<typeof Checkbox> = {
11
11
  component: Checkbox,
@@ -36,11 +36,6 @@ export const Multiple: Story = {
36
36
  ...withVariantConfig(['default', 'dark', 'rtl']),
37
37
  };
38
38
 
39
- export const MultipleMobile: Story = {
40
- ...Multiple,
41
- ...withVariantConfig(['default', 'dark', 'rtl', 'mobile'], Multiple),
42
- };
43
-
44
39
  export const WithinField = {
45
40
  decorators: [
46
41
  (Story) => (
@@ -0,0 +1,33 @@
1
+ import { Meta, StoryObj } from '@storybook/react-webpack5';
2
+ import { fn } from 'storybook/test';
3
+ import { lorem10 } from '../test-utils';
4
+ import Checkbox from './Checkbox';
5
+ import { withVariantConfig } from '../../.storybook/helpers';
6
+
7
+ const meta: Meta<typeof Checkbox> = {
8
+ component: Checkbox,
9
+ tags: ['!autodocs', '!manifest'],
10
+ title: 'Forms/Checkbox/Tests',
11
+ args: {
12
+ label: 'This is the label',
13
+ onChange: fn(),
14
+ },
15
+ };
16
+ export default meta;
17
+
18
+ type Story = StoryObj<typeof Checkbox>;
19
+
20
+ export const MultipleMobile: Story = {
21
+ render: (args) => {
22
+ return (
23
+ <>
24
+ <Checkbox {...args} />
25
+ <Checkbox {...args} checked />
26
+ <Checkbox {...args} label={lorem10} />
27
+ <Checkbox {...args} label={lorem10} secondary={lorem10} />
28
+ <Checkbox {...args} disabled />
29
+ </>
30
+ );
31
+ },
32
+ ...withVariantConfig(['default', 'dark', 'rtl', 'mobile']),
33
+ };
@@ -6,7 +6,6 @@ import { Meta, StoryObj } from '@storybook/react-webpack5';
6
6
  import CircularButton from './CircularButton';
7
7
  import Title from '../title';
8
8
  import Body from '../body';
9
- import { withVariantConfig } from '../../.storybook/helpers';
10
9
 
11
10
  export default {
12
11
  component: CircularButton,
@@ -86,8 +85,3 @@ export const All: Story = {
86
85
  );
87
86
  },
88
87
  };
89
-
90
- export const All400Zoom: Story = {
91
- ...All,
92
- ...withVariantConfig(['400%'], All),
93
- };
@@ -2,11 +2,14 @@ import { Freeze } from '@transferwise/icons';
2
2
 
3
3
  import { Meta, StoryObj } from '@storybook/react-webpack5';
4
4
  import CircularButton from './CircularButton';
5
+ import Title from '../title';
6
+ import Body from '../body';
7
+ import { withVariantConfig } from '../../.storybook/helpers';
5
8
 
6
9
  export default {
7
10
  component: CircularButton,
8
11
  title: 'Actions/CircularButton/Tests',
9
- tags: ['!manifest'],
12
+ tags: ['!autodocs', '!manifest'],
10
13
  } satisfies Meta<typeof CircularButton>;
11
14
 
12
15
  type Story = StoryObj<typeof CircularButton>;
@@ -22,3 +25,61 @@ export const CenteredText: Story = {
22
25
  );
23
26
  },
24
27
  };
28
+
29
+ const All: Story = {
30
+ args: {
31
+ children: 'Button text',
32
+ icon: <Freeze />,
33
+ className: 'm-r-2',
34
+ },
35
+ render: (props) => {
36
+ return (
37
+ <>
38
+ <Title type="title-subsection" className="m-y-2">
39
+ Default (Primary and secondary)
40
+ </Title>
41
+ <Body>
42
+ <CircularButton {...props} priority="primary" type="default" />
43
+ <CircularButton {...props} priority="secondary" type="default" />
44
+ <CircularButton {...props} priority="primary" type="default" disabled />
45
+ <CircularButton {...props} priority="secondary" type="default" disabled />
46
+ </Body>
47
+
48
+ <Title type="title-subsection" className="m-y-2">
49
+ Negative (Primary and secondary)
50
+ </Title>
51
+ <Body>
52
+ <CircularButton {...props} priority="primary" type="negative" />
53
+ <CircularButton {...props} priority="secondary" type="negative" />
54
+ <CircularButton {...props} priority="primary" type="negative" disabled />
55
+ <CircularButton {...props} priority="secondary" type="negative" disabled />
56
+ </Body>
57
+
58
+ <Title type="title-body" className="m-y-2">
59
+ Accent (Deprecated)
60
+ </Title>
61
+ <Body>
62
+ <CircularButton {...props} priority="primary" type="accent" />
63
+ <CircularButton {...props} priority="secondary" type="accent" />
64
+ <CircularButton {...props} priority="primary" type="accent" disabled />
65
+ <CircularButton {...props} priority="secondary" type="accent" disabled />
66
+ </Body>
67
+
68
+ <Title type="title-body" className="m-y-2">
69
+ Positive (Deprecated)
70
+ </Title>
71
+ <Body>
72
+ <CircularButton {...props} priority="primary" type="positive" />
73
+ <CircularButton {...props} priority="secondary" type="positive" />
74
+ <CircularButton {...props} priority="primary" type="positive" disabled />
75
+ <CircularButton {...props} priority="secondary" type="positive" disabled />
76
+ </Body>
77
+ </>
78
+ );
79
+ },
80
+ };
81
+
82
+ export const All400Zoom: Story = {
83
+ ...All,
84
+ ...withVariantConfig(['400%'], All),
85
+ };
@@ -11,7 +11,6 @@ import Title from '../../title/Title';
11
11
  import { Typography } from '../propsValues/typography';
12
12
 
13
13
  import BottomSheet from './BottomSheet';
14
- import { withVariantConfig } from '../../../.storybook/helpers';
15
14
 
16
15
  export default {
17
16
  component: BottomSheet,
@@ -64,57 +63,3 @@ export const Basic: Story = {
64
63
  },
65
64
  },
66
65
  };
67
-
68
- export const BasicMobile: Story = {
69
- ...Basic,
70
- ...withVariantConfig(['mobile'], Basic),
71
- };
72
-
73
- export const WithOverflowContent: Story = {
74
- args: {
75
- children: (
76
- <>
77
- <Title type={Typography.TITLE_SECTION}>Money without borders</Title>
78
- {lorem10}
79
- <NavigationOption
80
- title="Navigation option"
81
- content="Button and icon are vertically centered."
82
- media={<FastFlag size={24} />}
83
- />
84
- <NavigationOption
85
- title="Navigation option"
86
- content="Button and icon are vertically centered."
87
- media={<FastFlag size={24} />}
88
- isContainerAligned
89
- />
90
- {lorem500}
91
- <Body type={Typography.BODY_DEFAULT_BOLD}>{lorem10}</Body>
92
- </>
93
- ),
94
- },
95
- parameters: {
96
- chromatic: {
97
- delay: 1000,
98
- },
99
- },
100
- };
101
-
102
- export const WithOverflowContentMobile: Story = {
103
- ...WithOverflowContent,
104
- ...withVariantConfig(['mobile'], WithOverflowContent),
105
- };
106
-
107
- export const WithOverflowContentDark: Story = {
108
- ...WithOverflowContent,
109
- ...withVariantConfig(['dark'], WithOverflowContent),
110
- };
111
-
112
- export const WithOverflowContentDarkMobile: Story = {
113
- ...WithOverflowContent,
114
- ...withVariantConfig(['dark', 'mobile'], WithOverflowContent),
115
- };
116
-
117
- export const WithOverflowContentZoom400: Story = {
118
- ...WithOverflowContent,
119
- ...withVariantConfig(['400%'], WithOverflowContent),
120
- };
@@ -1,13 +1,17 @@
1
+ import { action } from 'storybook/actions';
1
2
  import { useState } from 'react';
2
3
  import { Meta, StoryObj } from '@storybook/react-webpack5';
3
4
  import { fireEvent, within } from 'storybook/test';
4
- import { lorem100, lorem500 } from '../../test-utils';
5
+ import { FastFlag } from '@transferwise/icons';
6
+ import { lorem10, lorem100, lorem500 } from '../../test-utils';
5
7
  import { Typography } from '../propsValues/typography';
6
8
  import Alert from '../../alert';
7
9
  import Body from '../../body';
8
10
  import Button from '../../button';
11
+ import NavigationOption from '../../navigationOption';
9
12
  import Title from '../../title';
10
13
  import BottomSheet from './BottomSheet';
14
+ import { withVariantConfig } from '../../../.storybook/helpers';
11
15
 
12
16
  const wait = async (duration = 500) =>
13
17
  new Promise<void>((resolve) => {
@@ -16,7 +20,7 @@ const wait = async (duration = 500) =>
16
20
 
17
21
  export default {
18
22
  component: BottomSheet,
19
- title: 'Dialogs/BottomSheet/tests',
23
+ title: 'Dialogs/BottomSheet/Tests',
20
24
  tags: ['!autodocs'],
21
25
  args: {
22
26
  open: false,
@@ -96,3 +100,115 @@ export const SmoothScrollReset: Story = {
96
100
  );
97
101
  },
98
102
  };
103
+
104
+ const Basic: Story = {
105
+ args: {
106
+ children: (
107
+ <>
108
+ <Title type={Typography.TITLE_SECTION}>Money without borders</Title>
109
+ {lorem10}
110
+ <br />
111
+ <br />
112
+ <NavigationOption
113
+ title="Navigation option"
114
+ content="Button and icon are vertically centered."
115
+ media={<FastFlag size={24} />}
116
+ isContainerAligned
117
+ />
118
+ </>
119
+ ),
120
+ },
121
+ parameters: {
122
+ chromatic: {
123
+ delay: 1000,
124
+ },
125
+ },
126
+ render: (args, { viewMode }) => {
127
+ const [open, setOpen] = useState(viewMode !== 'docs');
128
+
129
+ return (
130
+ <div>
131
+ <Button onClick={() => setOpen(true)}>Open BottomSheet</Button>
132
+ <BottomSheet
133
+ {...args}
134
+ open={open}
135
+ onClose={() => {
136
+ setOpen(false);
137
+ action('BottomSheet closed')();
138
+ }}
139
+ />
140
+ </div>
141
+ );
142
+ },
143
+ };
144
+
145
+ export const BasicMobile: Story = {
146
+ ...Basic,
147
+ ...withVariantConfig(['mobile'], Basic),
148
+ };
149
+
150
+ const WithOverflowContent: Story = {
151
+ args: {
152
+ children: (
153
+ <>
154
+ <Title type={Typography.TITLE_SECTION}>Money without borders</Title>
155
+ {lorem10}
156
+ <NavigationOption
157
+ title="Navigation option"
158
+ content="Button and icon are vertically centered."
159
+ media={<FastFlag size={24} />}
160
+ />
161
+ <NavigationOption
162
+ title="Navigation option"
163
+ content="Button and icon are vertically centered."
164
+ media={<FastFlag size={24} />}
165
+ isContainerAligned
166
+ />
167
+ {lorem500}
168
+ <Body type={Typography.BODY_DEFAULT_BOLD}>{lorem10}</Body>
169
+ </>
170
+ ),
171
+ },
172
+ parameters: {
173
+ chromatic: {
174
+ delay: 1000,
175
+ },
176
+ },
177
+ render: (args, { viewMode }) => {
178
+ const [open, setOpen] = useState(viewMode !== 'docs');
179
+
180
+ return (
181
+ <div>
182
+ <Button onClick={() => setOpen(true)}>Open BottomSheet</Button>
183
+ <BottomSheet
184
+ {...args}
185
+ open={open}
186
+ onClose={() => {
187
+ setOpen(false);
188
+ action('BottomSheet closed')();
189
+ }}
190
+ />
191
+ </div>
192
+ );
193
+ },
194
+ };
195
+
196
+ export const WithOverflowContentMobile: Story = {
197
+ ...WithOverflowContent,
198
+ ...withVariantConfig(['mobile'], WithOverflowContent),
199
+ };
200
+
201
+ export const WithOverflowContentDark: Story = {
202
+ ...WithOverflowContent,
203
+ ...withVariantConfig(['dark'], WithOverflowContent),
204
+ };
205
+
206
+ export const WithOverflowContentDarkMobile: Story = {
207
+ ...WithOverflowContent,
208
+ ...withVariantConfig(['dark', 'mobile'], WithOverflowContent),
209
+ };
210
+
211
+ export const WithOverflowContentZoom400: Story = {
212
+ ...WithOverflowContent,
213
+ ...withVariantConfig(['400%'], WithOverflowContent),
214
+ };
@@ -1,23 +1,43 @@
1
- import { render, screen } from '@testing-library/react';
2
- import { LiveRegion, LiveRegionProps } from './LiveRegion';
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import { WDS_LIVE_REGION_DELAY_MS } from '../constants';
3
+ import { LiveRegion, LiveRegionProps, resetLiveRegionAnnouncementQueue } from './LiveRegion';
3
4
 
4
5
  describe('LiveRegion', () => {
6
+ beforeEach(() => {
7
+ jest.useFakeTimers();
8
+ resetLiveRegionAnnouncementQueue();
9
+ });
10
+
11
+ afterEach(() => {
12
+ jest.clearAllTimers();
13
+ jest.useRealTimers();
14
+ });
15
+
5
16
  const renderLiveRegion = (props: Partial<LiveRegionProps> & Pick<LiveRegionProps, 'aria-live'>) =>
6
17
  render(<LiveRegion {...props}>{props.children ?? 'Live content'}</LiveRegion>);
7
18
 
19
+ const enableLiveRegion = (delay = WDS_LIVE_REGION_DELAY_MS) => {
20
+ act(() => {
21
+ jest.advanceTimersByTime(delay);
22
+ });
23
+ };
24
+
8
25
  describe('when aria-live is "polite"', () => {
9
26
  it('renders with role="status"', () => {
10
27
  renderLiveRegion({ 'aria-live': 'polite' });
28
+ enableLiveRegion();
11
29
  expect(screen.getByRole('status')).toBeInTheDocument();
12
30
  });
13
31
 
14
32
  it('sets aria-live="polite"', () => {
15
33
  renderLiveRegion({ 'aria-live': 'polite' });
34
+ enableLiveRegion();
16
35
  expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite');
17
36
  });
18
37
 
19
38
  it('sets aria-atomic="true"', () => {
20
39
  renderLiveRegion({ 'aria-live': 'polite' });
40
+ enableLiveRegion();
21
41
  expect(screen.getByRole('status')).toHaveAttribute('aria-atomic', 'true');
22
42
  });
23
43
  });
@@ -25,32 +45,79 @@ describe('LiveRegion', () => {
25
45
  describe('when aria-live is "assertive"', () => {
26
46
  it('renders with role="alert"', () => {
27
47
  renderLiveRegion({ 'aria-live': 'assertive' });
48
+ enableLiveRegion();
28
49
  expect(screen.getByRole('alert')).toBeInTheDocument();
29
50
  });
30
51
 
31
52
  it('sets aria-live="assertive"', () => {
32
53
  renderLiveRegion({ 'aria-live': 'assertive' });
54
+ enableLiveRegion();
33
55
  expect(screen.getByRole('alert')).toHaveAttribute('aria-live', 'assertive');
34
56
  });
35
57
 
36
58
  it('sets aria-atomic="true"', () => {
37
59
  renderLiveRegion({ 'aria-live': 'assertive' });
60
+ enableLiveRegion();
38
61
  expect(screen.getByRole('alert')).toHaveAttribute('aria-atomic', 'true');
39
62
  });
40
63
  });
41
64
 
65
+ it('delays live-region activation before the configured timeout', () => {
66
+ renderLiveRegion({ 'aria-live': 'polite', children: 'Delayed content' });
67
+
68
+ const liveRegion = screen.getByRole('status');
69
+ expect(liveRegion).toBeInTheDocument();
70
+ expect(liveRegion.firstElementChild).toHaveAttribute('aria-hidden', 'true');
71
+
72
+ enableLiveRegion(WDS_LIVE_REGION_DELAY_MS - 1);
73
+ expect(liveRegion.firstElementChild).toHaveAttribute('aria-hidden', 'true');
74
+
75
+ enableLiveRegion(1);
76
+ expect(liveRegion.firstElementChild).not.toHaveAttribute('aria-hidden');
77
+ expect(liveRegion).toHaveTextContent('Delayed content');
78
+ });
79
+
80
+ it('queues multiple assertive regions so each one gets announced', () => {
81
+ render(
82
+ <>
83
+ <LiveRegion aria-live="assertive">First prompt</LiveRegion>
84
+ <LiveRegion aria-live="assertive">Second prompt</LiveRegion>
85
+ </>,
86
+ );
87
+
88
+ const liveRegions = screen.getAllByRole('alert');
89
+ expect(liveRegions).toHaveLength(2);
90
+ expect(liveRegions[0].firstElementChild).toHaveAttribute('aria-hidden', 'true');
91
+ expect(liveRegions[1].firstElementChild).toHaveAttribute('aria-hidden', 'true');
92
+
93
+ enableLiveRegion();
94
+ const firstEnabledLiveRegions = screen.getAllByRole('alert');
95
+ expect(firstEnabledLiveRegions[0].firstElementChild).not.toHaveAttribute('aria-hidden');
96
+ expect(firstEnabledLiveRegions[1].firstElementChild).toHaveAttribute('aria-hidden', 'true');
97
+ expect(firstEnabledLiveRegions[0]).toHaveTextContent('First prompt');
98
+
99
+ enableLiveRegion();
100
+ const secondEnabledLiveRegions = screen.getAllByRole('alert');
101
+ expect(secondEnabledLiveRegions[1].firstElementChild).not.toHaveAttribute('aria-hidden');
102
+ expect(secondEnabledLiveRegions[0]).toHaveTextContent('First prompt');
103
+ expect(secondEnabledLiveRegions[1]).toHaveTextContent('Second prompt');
104
+ });
105
+
42
106
  it('renders children', () => {
43
107
  renderLiveRegion({ 'aria-live': 'polite', children: 'Transfer sent' });
108
+ enableLiveRegion();
44
109
  expect(screen.getByText('Transfer sent')).toBeInTheDocument();
45
110
  });
46
111
 
47
112
  it('passes additional HTML attributes to the wrapper div', () => {
48
113
  renderLiveRegion({ 'aria-live': 'polite', className: 'custom' });
114
+ enableLiveRegion();
49
115
  expect(screen.getByRole('status')).toHaveClass('custom');
50
116
  });
51
117
 
52
118
  it('supports data-testid prop', () => {
53
119
  renderLiveRegion({ 'aria-live': 'polite', 'data-testid': 'live-region' });
120
+ enableLiveRegion();
54
121
  expect(screen.getByTestId('live-region')).toBeInTheDocument();
55
122
  });
56
123
  });
@@ -1,11 +1,52 @@
1
+ import { useEffect, useState } from 'react';
1
2
  import type { HTMLAttributes, ReactNode } from 'react';
2
3
 
3
- const ARIA_LIVE_ROLE_MAP = {
4
+ import { WDS_LIVE_REGION_DELAY_MS } from '../constants';
5
+
6
+ export type AriaLive = 'off' | 'polite' | 'assertive';
7
+
8
+ type LivePoliteness = Exclude<AriaLive, 'off'>;
9
+
10
+ const LIVE_REGION_ROLE_BY_POLITENESS: Record<LivePoliteness, 'status' | 'alert'> = {
4
11
  assertive: 'alert',
5
12
  polite: 'status',
6
- } as const;
13
+ };
7
14
 
8
- export type AriaLive = 'off' | 'polite' | 'assertive';
15
+ let nextPoliteAnnouncementAt = 0;
16
+ let nextAssertiveAnnouncementAt = 0;
17
+
18
+ const getNextAnnouncementAt = (politeness: LivePoliteness): number => {
19
+ if (politeness === 'polite') {
20
+ return nextPoliteAnnouncementAt;
21
+ }
22
+
23
+ return nextAssertiveAnnouncementAt;
24
+ };
25
+
26
+ const setNextAnnouncementAt = (politeness: LivePoliteness, value: number): void => {
27
+ if (politeness === 'polite') {
28
+ nextPoliteAnnouncementAt = value;
29
+ return;
30
+ }
31
+
32
+ nextAssertiveAnnouncementAt = value;
33
+ };
34
+
35
+ export const resetLiveRegionAnnouncementQueue = (): void => {
36
+ nextPoliteAnnouncementAt = 0;
37
+ nextAssertiveAnnouncementAt = 0;
38
+ };
39
+
40
+ const calcAnnouncementDelayMs = (politeness: LivePoliteness, now: number): number => {
41
+ return Math.max(now + WDS_LIVE_REGION_DELAY_MS, getNextAnnouncementAt(politeness)) - now;
42
+ };
43
+
44
+ const scheduleAnnouncement = (politeness: LivePoliteness): number => {
45
+ const now = Date.now();
46
+ const delayMs = calcAnnouncementDelayMs(politeness, now);
47
+ setNextAnnouncementAt(politeness, now + delayMs + WDS_LIVE_REGION_DELAY_MS);
48
+ return delayMs;
49
+ };
9
50
 
10
51
  export interface LiveRegionProps extends Omit<
11
52
  HTMLAttributes<HTMLDivElement>,
@@ -15,6 +56,8 @@ export interface LiveRegionProps extends Omit<
15
56
  * Determines urgency: 'assertive' interrupts, 'polite' waits for idle, 'off' disables live region.
16
57
  */
17
58
  'aria-live': AriaLive;
59
+ /** Optional stable key that triggers a new announcement when it changes. */
60
+ announceOnChange?: string | number;
18
61
  /** Test ID for testing tools */
19
62
  'data-testid'?: string;
20
63
  children?: ReactNode;
@@ -25,25 +68,49 @@ export interface LiveRegionProps extends Omit<
25
68
  *
26
69
  * - `aria-live="polite"` → `role="status"`
27
70
  * - `aria-live="assertive"` → `role="alert"`
28
- * - `aria-live="off"` → no live region
71
+ * - `aria-live="off"` → no live region (renders children unwrapped)
29
72
  *
30
73
  * The `role` prop is intentionally excluded from the public API
31
74
  * to prevent mismatches between `aria-live` and `role`.
32
75
  */
33
- export const LiveRegion = ({ 'aria-live': ariaLive, children, ...props }: LiveRegionProps) => {
76
+ export const LiveRegion = ({
77
+ 'aria-live': ariaLive,
78
+ announceOnChange,
79
+ children,
80
+ ...props
81
+ }: LiveRegionProps) => {
82
+ const [shouldAnnounce, setShouldAnnounce] = useState(false);
83
+ const announcementTrigger =
84
+ announceOnChange ??
85
+ (typeof children === 'string' || typeof children === 'number' ? children : undefined);
86
+
87
+ useEffect(() => {
88
+ setShouldAnnounce(false);
89
+
90
+ if (ariaLive === 'off') {
91
+ return;
92
+ }
93
+
94
+ const timeoutId = window.setTimeout(
95
+ () => setShouldAnnounce(true),
96
+ scheduleAnnouncement(ariaLive),
97
+ );
98
+
99
+ return () => window.clearTimeout(timeoutId);
100
+ }, [ariaLive, announcementTrigger]);
101
+
34
102
  if (ariaLive === 'off') {
35
103
  return <>{children}</>;
36
104
  }
37
105
 
38
106
  return (
39
107
  <div
40
- role={ARIA_LIVE_ROLE_MAP[ariaLive]}
108
+ role={LIVE_REGION_ROLE_BY_POLITENESS[ariaLive]}
41
109
  aria-live={ariaLive}
42
110
  aria-atomic="true"
43
- style={{ display: 'contents' }}
44
111
  {...props}
45
112
  >
46
- {children}
113
+ <div aria-hidden={shouldAnnounce ? undefined : 'true'}>{children}</div>
47
114
  </div>
48
115
  );
49
116
  };
@@ -5,7 +5,7 @@ import { withVariantConfig } from '../../.storybook/helpers';
5
5
 
6
6
  export default {
7
7
  component: CriticalCommsBanner,
8
- title: 'Prompts/CriticalCommsBanner/tests',
8
+ title: 'Prompts/CriticalCommsBanner/Tests',
9
9
  tags: ['!autodocs'],
10
10
  } satisfies Meta<typeof CriticalCommsBanner>;
11
11
 
@@ -1,10 +1,8 @@
1
1
  import { useState } from 'react';
2
- import { userEvent } from 'storybook/test';
3
2
  import { StoryObj } from '@storybook/react-webpack5';
4
3
  import { Field } from '..';
5
4
  import { Size } from '../common';
6
5
  import DateLookup, { type DateLookupProps } from './DateLookup';
7
- import { withVariantConfig } from '../../.storybook/helpers';
8
6
 
9
7
  export default {
10
8
  component: DateLookup,
@@ -54,22 +52,6 @@ export const Basic: Story = {
54
52
  min: thePast,
55
53
  max: theFuture,
56
54
  },
57
- play: async () => {
58
- await userEvent.tab();
59
- await userEvent.keyboard(' ');
60
- },
61
- };
62
- export const Basic400Zoom: Story = {
63
- ...Basic,
64
-
65
- ...withVariantConfig(['400%'], {
66
- ...Basic,
67
- parameters: {
68
- chromatic: {
69
- delay: 2000,
70
- },
71
- },
72
- }),
73
55
  };
74
56
 
75
57
  export const WithField: Story = {