@transferwise/components 46.136.0 → 46.137.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.
Files changed (162) hide show
  1. package/build/common/hooks/useContainerSize.js +30 -0
  2. package/build/common/hooks/useContainerSize.js.map +1 -0
  3. package/build/common/hooks/useContainerSize.mjs +28 -0
  4. package/build/common/hooks/useContainerSize.mjs.map +1 -0
  5. package/build/common/hooks/useResizeObserver.js +3 -3
  6. package/build/common/hooks/useResizeObserver.js.map +1 -1
  7. package/build/common/hooks/useResizeObserver.mjs +3 -3
  8. package/build/common/hooks/useResizeObserver.mjs.map +1 -1
  9. package/build/criticalBanner/CriticalCommsBanner.js +3 -0
  10. package/build/criticalBanner/CriticalCommsBanner.js.map +1 -1
  11. package/build/criticalBanner/CriticalCommsBanner.mjs +3 -0
  12. package/build/criticalBanner/CriticalCommsBanner.mjs.map +1 -1
  13. package/build/field/Field.js +3 -2
  14. package/build/field/Field.js.map +1 -1
  15. package/build/field/Field.mjs +3 -2
  16. package/build/field/Field.mjs.map +1 -1
  17. package/build/i18n/en.json +2 -0
  18. package/build/i18n/en.json.js +2 -0
  19. package/build/i18n/en.json.js.map +1 -1
  20. package/build/i18n/en.json.mjs +2 -0
  21. package/build/i18n/en.json.mjs.map +1 -1
  22. package/build/index.js +2 -0
  23. package/build/index.js.map +1 -1
  24. package/build/index.mjs +1 -0
  25. package/build/index.mjs.map +1 -1
  26. package/build/listItem/Prompt/ListItemPrompt.js +3 -2
  27. package/build/listItem/Prompt/ListItemPrompt.js.map +1 -1
  28. package/build/listItem/Prompt/ListItemPrompt.mjs +3 -2
  29. package/build/listItem/Prompt/ListItemPrompt.mjs.map +1 -1
  30. package/build/logo/Logo.js +77 -25
  31. package/build/logo/Logo.js.map +1 -1
  32. package/build/logo/Logo.mjs +79 -27
  33. package/build/logo/Logo.mjs.map +1 -1
  34. package/build/logo/logo-assets.js +68 -97
  35. package/build/logo/logo-assets.js.map +1 -1
  36. package/build/logo/logo-assets.mjs +62 -90
  37. package/build/logo/logo-assets.mjs.map +1 -1
  38. package/build/main.css +225 -58
  39. package/build/prompt/ActionPrompt/ActionPrompt.js +8 -40
  40. package/build/prompt/ActionPrompt/ActionPrompt.js.map +1 -1
  41. package/build/prompt/ActionPrompt/ActionPrompt.mjs +8 -40
  42. package/build/prompt/ActionPrompt/ActionPrompt.mjs.map +1 -1
  43. package/build/prompt/CriticalBanner/CriticalBanner.js +143 -0
  44. package/build/prompt/CriticalBanner/CriticalBanner.js.map +1 -0
  45. package/build/prompt/CriticalBanner/CriticalBanner.mjs +141 -0
  46. package/build/prompt/CriticalBanner/CriticalBanner.mjs.map +1 -0
  47. package/build/prompt/CriticalBanner/helpers.js +29 -0
  48. package/build/prompt/CriticalBanner/helpers.js.map +1 -0
  49. package/build/prompt/CriticalBanner/helpers.mjs +26 -0
  50. package/build/prompt/CriticalBanner/helpers.mjs.map +1 -0
  51. package/build/prompt/InfoPrompt/InfoPrompt.js +3 -2
  52. package/build/prompt/InfoPrompt/InfoPrompt.js.map +1 -1
  53. package/build/prompt/InfoPrompt/InfoPrompt.mjs +3 -2
  54. package/build/prompt/InfoPrompt/InfoPrompt.mjs.map +1 -1
  55. package/build/prompt/PrimitivePrompt/PrimitivePrompt.js +11 -4
  56. package/build/prompt/PrimitivePrompt/PrimitivePrompt.js.map +1 -1
  57. package/build/prompt/PrimitivePrompt/PrimitivePrompt.mjs +11 -4
  58. package/build/prompt/PrimitivePrompt/PrimitivePrompt.mjs.map +1 -1
  59. package/build/prompt/common/Expander/Expander.js +35 -0
  60. package/build/prompt/common/Expander/Expander.js.map +1 -0
  61. package/build/prompt/common/Expander/Expander.messages.js +17 -0
  62. package/build/prompt/common/Expander/Expander.messages.js.map +1 -0
  63. package/build/prompt/common/Expander/Expander.messages.mjs +13 -0
  64. package/build/prompt/common/Expander/Expander.messages.mjs.map +1 -0
  65. package/build/prompt/common/Expander/Expander.mjs +33 -0
  66. package/build/prompt/common/Expander/Expander.mjs.map +1 -0
  67. package/build/prompt/helpers/promptMedia.js +52 -0
  68. package/build/prompt/helpers/promptMedia.js.map +1 -0
  69. package/build/prompt/helpers/promptMedia.mjs +50 -0
  70. package/build/prompt/helpers/promptMedia.mjs.map +1 -0
  71. package/build/styles/logo/Logo.css +3 -23
  72. package/build/styles/main.css +225 -58
  73. package/build/styles/prompt/CriticalBanner/CriticalBanner.css +134 -0
  74. package/build/styles/prompt/CriticalBanner/CriticalBanner.vars.css +0 -0
  75. package/build/styles/prompt/InfoPrompt/InfoPrompt.css +24 -0
  76. package/build/styles/prompt/common/Expander/Expander.css +8 -0
  77. package/build/table/Table.js +6 -5
  78. package/build/table/Table.js.map +1 -1
  79. package/build/table/Table.mjs +6 -5
  80. package/build/table/Table.mjs.map +1 -1
  81. package/build/typeahead/Typeahead.js +3 -2
  82. package/build/typeahead/Typeahead.js.map +1 -1
  83. package/build/typeahead/Typeahead.mjs +3 -2
  84. package/build/typeahead/Typeahead.mjs.map +1 -1
  85. package/build/types/common/hooks/useContainerSize.d.ts +14 -0
  86. package/build/types/common/hooks/useContainerSize.d.ts.map +1 -0
  87. package/build/types/common/hooks/useResizeObserver.d.ts +1 -1
  88. package/build/types/common/hooks/useResizeObserver.d.ts.map +1 -1
  89. package/build/types/criticalBanner/CriticalCommsBanner.d.ts +3 -0
  90. package/build/types/criticalBanner/CriticalCommsBanner.d.ts.map +1 -1
  91. package/build/types/index.d.ts +2 -2
  92. package/build/types/index.d.ts.map +1 -1
  93. package/build/types/logo/Logo.d.ts +33 -1
  94. package/build/types/logo/Logo.d.ts.map +1 -1
  95. package/build/types/logo/logo-assets.d.ts +33 -9
  96. package/build/types/logo/logo-assets.d.ts.map +1 -1
  97. package/build/types/prompt/ActionPrompt/ActionPrompt.d.ts +2 -11
  98. package/build/types/prompt/ActionPrompt/ActionPrompt.d.ts.map +1 -1
  99. package/build/types/prompt/CriticalBanner/CriticalBanner.d.ts +39 -0
  100. package/build/types/prompt/CriticalBanner/CriticalBanner.d.ts.map +1 -0
  101. package/build/types/prompt/CriticalBanner/helpers.d.ts +18 -0
  102. package/build/types/prompt/CriticalBanner/helpers.d.ts.map +1 -0
  103. package/build/types/prompt/CriticalBanner/index.d.ts +3 -0
  104. package/build/types/prompt/CriticalBanner/index.d.ts.map +1 -0
  105. package/build/types/prompt/InfoPrompt/InfoPrompt.d.ts.map +1 -1
  106. package/build/types/prompt/PrimitivePrompt/PrimitivePrompt.d.ts +35 -3
  107. package/build/types/prompt/PrimitivePrompt/PrimitivePrompt.d.ts.map +1 -1
  108. package/build/types/prompt/common/Expander/Expander.d.ts +20 -0
  109. package/build/types/prompt/common/Expander/Expander.d.ts.map +1 -0
  110. package/build/types/prompt/common/Expander/Expander.messages.d.ts +14 -0
  111. package/build/types/prompt/common/Expander/Expander.messages.d.ts.map +1 -0
  112. package/build/types/prompt/helpers/promptMedia.d.ts +22 -0
  113. package/build/types/prompt/helpers/promptMedia.d.ts.map +1 -0
  114. package/build/types/prompt/index.d.ts +2 -0
  115. package/build/types/prompt/index.d.ts.map +1 -1
  116. package/build/types/table/Table.d.ts.map +1 -1
  117. package/build/types/test-utils/index.d.ts +4 -0
  118. package/build/types/test-utils/index.d.ts.map +1 -1
  119. package/package.json +7 -7
  120. package/src/common/bottomSheet/BottomSheet.test.story.tsx +6 -5
  121. package/src/common/hooks/useContainerSize.test.tsx +125 -0
  122. package/src/common/hooks/useContainerSize.ts +32 -0
  123. package/src/common/hooks/useResizeObserver.ts +3 -2
  124. package/src/criticalBanner/CriticalCommsBanner.story.tsx +4 -0
  125. package/src/criticalBanner/CriticalCommsBanner.test.story.tsx +1 -1
  126. package/src/criticalBanner/CriticalCommsBanner.tsx +3 -0
  127. package/src/i18n/en.json +2 -0
  128. package/src/icons/Icons.story.tsx +43 -35
  129. package/src/index.ts +2 -2
  130. package/src/logo/Logo.css +3 -23
  131. package/src/logo/Logo.less +3 -29
  132. package/src/logo/Logo.story.tsx +117 -89
  133. package/src/logo/Logo.test.story.tsx +15 -24
  134. package/src/logo/Logo.tsx +90 -28
  135. package/src/logo/logo-assets.tsx +36 -92
  136. package/src/main.css +225 -58
  137. package/src/main.less +3 -1
  138. package/src/prompt/ActionPrompt/ActionPrompt.tsx +9 -62
  139. package/src/prompt/CriticalBanner/CriticalBanner.accessibility.docs.mdx +113 -0
  140. package/src/prompt/CriticalBanner/CriticalBanner.css +134 -0
  141. package/src/prompt/CriticalBanner/CriticalBanner.less +155 -0
  142. package/src/prompt/CriticalBanner/CriticalBanner.story.tsx +635 -0
  143. package/src/prompt/CriticalBanner/CriticalBanner.test.story.tsx +422 -0
  144. package/src/prompt/CriticalBanner/CriticalBanner.tsx +179 -0
  145. package/src/prompt/CriticalBanner/CriticalBanner.vars.css +0 -0
  146. package/src/prompt/CriticalBanner/CriticalBanner.vars.less +6 -0
  147. package/src/prompt/CriticalBanner/helpers.ts +39 -0
  148. package/src/prompt/CriticalBanner/index.ts +2 -0
  149. package/src/prompt/InfoPrompt/InfoPrompt.css +24 -0
  150. package/src/prompt/InfoPrompt/InfoPrompt.less +23 -0
  151. package/src/prompt/InfoPrompt/InfoPrompt.tsx +5 -1
  152. package/src/prompt/PrimitivePrompt/PrimitivePrompt.tsx +56 -40
  153. package/src/prompt/common/Expander/Expander.css +8 -0
  154. package/src/prompt/common/Expander/Expander.less +9 -0
  155. package/src/prompt/common/Expander/Expander.messages.ts +14 -0
  156. package/src/prompt/common/Expander/Expander.test.tsx +167 -0
  157. package/src/prompt/common/Expander/Expander.tsx +83 -0
  158. package/src/prompt/helpers/promptMedia.tsx +79 -0
  159. package/src/prompt/index.ts +4 -0
  160. package/src/sentimentSurface/SentimentSurface.story.tsx +43 -17
  161. package/src/table/Table.story.tsx +1 -1
  162. package/src/table/Table.tsx +6 -5
@@ -0,0 +1,422 @@
1
+ import { useState } from 'react';
2
+ import { Meta, StoryObj } from '@storybook/react-webpack5';
3
+ import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
4
+ import { Bank } from '@transferwise/icons';
5
+ import Button from '../../button';
6
+ import { withVariantConfig } from '../../../.storybook/helpers';
7
+ import { CriticalBanner } from './CriticalBanner';
8
+
9
+ const meta: Meta<typeof CriticalBanner> = {
10
+ component: CriticalBanner,
11
+ title: 'Prompts/CriticalBanner/Tests',
12
+ tags: ['!autodocs', '!manifest'],
13
+ };
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof CriticalBanner>;
17
+
18
+ const wait = async (ms: number) =>
19
+ new Promise<void>((resolve) => {
20
+ setTimeout(resolve, ms);
21
+ });
22
+
23
+ const ANIMATION_DURATION = 200; // 150ms animation + 50ms buffer
24
+
25
+ const longDescription =
26
+ 'We have detected unusual activity on your account that does not match your typical usage patterns. To protect your funds and personal information, we have temporarily restricted access. Please verify your identity to restore full access to your account.';
27
+
28
+ function AllVariants() {
29
+ const [states, setStates] = useState({
30
+ negativeExpanded: true,
31
+ negativeCollapsed: false,
32
+ warningExpanded: true,
33
+ warningCollapsed: false,
34
+ neutralExpanded: true,
35
+ successExpanded: true,
36
+ noTitle: false,
37
+ withMedia: true,
38
+ withBothActions: true,
39
+ });
40
+
41
+ const toggle = (key: keyof typeof states) =>
42
+ setStates((prev) => ({ ...prev, [key]: !prev[key] }));
43
+
44
+ return (
45
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
46
+ <CriticalBanner
47
+ sentiment="negative"
48
+ title="Negative — expanded"
49
+ description="Your account has been temporarily blocked due to unusual activity."
50
+ action={{ label: 'Verify now', onClick: fn() }}
51
+ actionSecondary={{ label: 'Learn more', onClick: fn() }}
52
+ expanded={states.negativeExpanded}
53
+ onToggle={() => toggle('negativeExpanded')}
54
+ />
55
+ <CriticalBanner
56
+ sentiment="negative"
57
+ title="Negative — collapsed"
58
+ description="Your account has been temporarily blocked."
59
+ action={{ label: 'Verify now', onClick: fn() }}
60
+ expanded={states.negativeCollapsed}
61
+ onToggle={() => toggle('negativeCollapsed')}
62
+ />
63
+ <CriticalBanner
64
+ sentiment="warning"
65
+ title="Warning — expanded"
66
+ description="Your account is at risk of being blocked within 7 days."
67
+ action={{ label: 'Take action', onClick: fn() }}
68
+ expanded={states.warningExpanded}
69
+ onToggle={() => toggle('warningExpanded')}
70
+ />
71
+ <CriticalBanner
72
+ sentiment="warning"
73
+ title="Warning — collapsed"
74
+ description="Your account is at risk."
75
+ action={{ label: 'Take action', onClick: fn() }}
76
+ expanded={states.warningCollapsed}
77
+ onToggle={() => toggle('warningCollapsed')}
78
+ />
79
+ <CriticalBanner
80
+ sentiment="neutral"
81
+ title="Neutral — expanded"
82
+ description="Your account needs attention."
83
+ action={{ label: 'Review', onClick: fn() }}
84
+ expanded={states.neutralExpanded}
85
+ onToggle={() => toggle('neutralExpanded')}
86
+ />
87
+ <CriticalBanner
88
+ sentiment="success"
89
+ title="Success — expanded"
90
+ description="Your account has been restored."
91
+ action={{ label: 'Continue', onClick: fn() }}
92
+ expanded={states.successExpanded}
93
+ onToggle={() => toggle('successExpanded')}
94
+ />
95
+ <CriticalBanner
96
+ sentiment="negative"
97
+ description={longDescription}
98
+ action={{ label: 'Verify now', onClick: fn() }}
99
+ expanded={states.noTitle}
100
+ onToggle={() => toggle('noTitle')}
101
+ />
102
+ <CriticalBanner
103
+ sentiment="warning"
104
+ title="With custom media"
105
+ description="Re-link your bank account to continue receiving transfers."
106
+ media={{ avatar: { asset: <Bank title="Bank account" /> } }}
107
+ action={{ label: 'Connect bank', onClick: fn() }}
108
+ expanded={states.withMedia}
109
+ onToggle={() => toggle('withMedia')}
110
+ />
111
+ <CriticalBanner
112
+ sentiment="negative"
113
+ title="Both actions"
114
+ description="We've blocked your account due to unusual activity."
115
+ action={{ label: 'Verify now', onClick: fn() }}
116
+ actionSecondary={{ label: 'Contact support', onClick: fn() }}
117
+ expanded={states.withBothActions}
118
+ onToggle={() => toggle('withBothActions')}
119
+ />
120
+ </div>
121
+ );
122
+ }
123
+
124
+ /**
125
+ * All four sentiments in both expanded and collapsed states, with and without
126
+ * title, media variants, and action combinations. Used for Chromatic visual
127
+ * regression across default, dark, bright-green, and forest-green themes.
128
+ */
129
+ export const Variants: Story = {
130
+ render: () => <AllVariants />,
131
+ ...withVariantConfig(['default', 'dark', 'bright-green', 'forest-green']),
132
+ };
133
+
134
+ /** Mobile viewport regression test with all variants. */
135
+ export const MobileVariants: Story = {
136
+ render: () => <AllVariants />,
137
+ ...withVariantConfig(['mobile']),
138
+ };
139
+
140
+ /**
141
+ * Tests keyboard navigation and interaction:
142
+ * - Tab focuses the first chevron toggle button
143
+ * - Enter toggles expanded/collapsed state
144
+ * - Tab moves focus to the action button (when expanded)
145
+ * - Tab moves to the next banner's toggle
146
+ */
147
+ export const KeyboardInteraction: Story = {
148
+ render: function Render() {
149
+ const [expanded1, setExpanded1] = useState(true);
150
+ const [expanded2, setExpanded2] = useState(false);
151
+
152
+ return (
153
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
154
+ <CriticalBanner
155
+ sentiment="negative"
156
+ title="First banner"
157
+ description="This banner starts expanded."
158
+ action={{ label: 'Verify now', onClick: fn() }}
159
+ expanded={expanded1}
160
+ onToggle={() => setExpanded1(!expanded1)}
161
+ />
162
+ <CriticalBanner
163
+ sentiment="warning"
164
+ title="Second banner"
165
+ description="This banner starts collapsed."
166
+ action={{ label: 'Take action', onClick: fn() }}
167
+ expanded={expanded2}
168
+ onToggle={() => setExpanded2(!expanded2)}
169
+ />
170
+ </div>
171
+ );
172
+ },
173
+ play: async ({ canvasElement }) => {
174
+ const canvas = within(canvasElement);
175
+
176
+ // Wait for the collapse button to appear (viewport change needs time to apply)
177
+ const collapseBtn = await canvas.findByRole('button', { name: /^collapse$/i });
178
+
179
+ // Tab to first toggle (Collapse button since first banner is expanded)
180
+ await userEvent.tab();
181
+ await expect(collapseBtn).toHaveFocus();
182
+
183
+ // Space collapses the first banner
184
+ await userEvent.keyboard(' ');
185
+ await wait(ANIMATION_DURATION);
186
+
187
+ // After collapsing, the button label changes to "Expand"
188
+ await waitFor(async () => {
189
+ const expandBtns = canvas.getAllByRole('button', { name: /^expand$/i });
190
+ await expect(expandBtns).toHaveLength(2);
191
+ await expect(expandBtns[0]).toHaveFocus();
192
+ });
193
+
194
+ await userEvent.tab();
195
+ await waitFor(async () => {
196
+ const expandBtns = canvas.getAllByRole('button', { name: /^expand$/i });
197
+ await expect(expandBtns[1]).toHaveFocus();
198
+ });
199
+
200
+ // Space expands the second banner
201
+ await userEvent.keyboard(' ');
202
+ await wait(ANIMATION_DURATION);
203
+
204
+ // Now the second banner has a Collapse button and it should have focus
205
+ await waitFor(async () => {
206
+ const secondCollapse = canvas.getByRole('button', { name: /^collapse$/i });
207
+ await expect(secondCollapse).toHaveFocus();
208
+ });
209
+
210
+ // Tab to the second banner's action button (now visible)
211
+ await userEvent.tab();
212
+
213
+ await waitFor(async () => {
214
+ const actionBtn = canvas.getByRole('button', { name: /^take action$/i });
215
+ await expect(actionBtn).toHaveFocus();
216
+ });
217
+ },
218
+ ...withVariantConfig(['mobile']),
219
+ parameters: {
220
+ ...withVariantConfig(['mobile']).parameters,
221
+ viewport: {
222
+ defaultViewport: 'mobile2',
223
+ },
224
+ },
225
+ };
226
+
227
+ /**
228
+ * CriticalBanner uses `aria-live="assertive"` to immediately announce content to screen readers.
229
+ * Toggle the banner on/off and expand/collapse to hear how announcements change.
230
+ */
231
+ export const LiveRegionAnnouncements: Story = {
232
+ render: function Render() {
233
+ const [visible, setVisible] = useState(false);
234
+ const [expanded, setExpanded] = useState(false);
235
+
236
+ return (
237
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
238
+ <div>
239
+ <Button v2 onClick={() => setVisible(!visible)}>
240
+ {visible ? 'Hide banner' : 'Show banner'}
241
+ </Button>
242
+ </div>
243
+ {visible && (
244
+ <CriticalBanner
245
+ sentiment="negative"
246
+ title="Your account requires verification"
247
+ description="Please verify your identity to continue using all features."
248
+ action={{ label: 'Verify now', onClick: fn() }}
249
+ actionSecondary={{ label: 'Learn more', onClick: fn() }}
250
+ expanded={expanded}
251
+ onToggle={() => setExpanded(!expanded)}
252
+ />
253
+ )}
254
+ </div>
255
+ );
256
+ },
257
+ };
258
+
259
+ /** Right-to-left layout regression test. */
260
+ export const RTL: Story = {
261
+ render: function Render() {
262
+ const [expanded, setExpanded] = useState(true);
263
+ const [collapsed, setCollapsed] = useState(false);
264
+
265
+ return (
266
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
267
+ <CriticalBanner
268
+ sentiment="negative"
269
+ title="Your account requires verification"
270
+ description="Please verify your identity to continue using all features."
271
+ action={{ label: 'Verify now', onClick: fn() }}
272
+ actionSecondary={{ label: 'Learn more', onClick: fn() }}
273
+ expanded={expanded}
274
+ onToggle={() => setExpanded(!expanded)}
275
+ />
276
+ <CriticalBanner
277
+ sentiment="warning"
278
+ title="Collapsed state"
279
+ description="This banner is collapsed."
280
+ action={{ label: 'Take action', onClick: fn() }}
281
+ expanded={collapsed}
282
+ onToggle={() => setCollapsed(!collapsed)}
283
+ />
284
+ </div>
285
+ );
286
+ },
287
+ ...withVariantConfig(['rtl']),
288
+ };
289
+
290
+ /** 400% zoom regression test with all variants. */
291
+ export const Zoom400: Story = {
292
+ render: () => <AllVariants />,
293
+ ...withVariantConfig(['400%']),
294
+ };
295
+
296
+ /**
297
+ * Desktop force-expand test: verifies that banner is always expanded
298
+ * when container width >= 768px, regardless of expanded prop value.
299
+ */
300
+ export const DesktopForceExpand: Story = {
301
+ render: function Render() {
302
+ return (
303
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
304
+ <CriticalBanner
305
+ sentiment="negative"
306
+ title="Banner without expanded"
307
+ description="On desktop (width >= 768px), this banner should still be fully expanded even though expanded prop is not set. The toggle button should also be hidden."
308
+ action={{ label: 'Verify now', onClick: fn() }}
309
+ actionSecondary={{ label: 'Learn more', onClick: fn() }}
310
+ expanded={false}
311
+ onToggle={fn()}
312
+ />
313
+ <CriticalBanner
314
+ sentiment="warning"
315
+ title="Banner with expanded=true"
316
+ description="This banner has expanded=true, so it should be expanded on both mobile and desktop."
317
+ action={{ label: 'Take action', onClick: fn() }}
318
+ expanded
319
+ onToggle={fn()}
320
+ />
321
+ </div>
322
+ );
323
+ },
324
+ };
325
+
326
+ /**
327
+ * Multi-line title animation test: verifies that animations work correctly
328
+ * even when the title wraps to multiple lines on mobile.
329
+ */
330
+ export const MultiLineTitleAnimation: Story = {
331
+ render: function Render() {
332
+ const [expanded, setExpanded] = useState(true);
333
+
334
+ return (
335
+ <CriticalBanner
336
+ sentiment="negative"
337
+ title="This is a very long title that will definitely wrap to multiple lines on mobile devices and should not break the animation at all"
338
+ description="The description should animate smoothly even with a multi-line title. This was the main issue with PR #4091 that prevented it from being merged."
339
+ action={{ label: 'Verify now', onClick: fn() }}
340
+ actionSecondary={{ label: 'Learn more', onClick: fn() }}
341
+ expanded={expanded}
342
+ onToggle={() => setExpanded(!expanded)}
343
+ />
344
+ );
345
+ },
346
+ ...withVariantConfig(['mobile']),
347
+ };
348
+
349
+ /**
350
+ * Internal state management test: verifies that the banner can manage its own
351
+ * expanded state when onToggle is not provided (uncontrolled mode).
352
+ */
353
+ export const UncontrolledState: Story = {
354
+ render: function Render() {
355
+ return (
356
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
357
+ <CriticalBanner
358
+ sentiment="negative"
359
+ title="Uncontrolled banner (no onToggle)"
360
+ description="This banner manages its own state internally. Click the toggle to expand/collapse. The state is maintained by the component itself."
361
+ action={{ label: 'Verify now', onClick: fn() }}
362
+ actionSecondary={{ label: 'Learn more', onClick: fn() }}
363
+ />
364
+ <CriticalBanner
365
+ sentiment="warning"
366
+ title="Uncontrolled banner starting collapsed"
367
+ description="This banner also manages its own state, but starts collapsed."
368
+ action={{ label: 'Take action', onClick: fn() }}
369
+ expanded={false}
370
+ />
371
+ </div>
372
+ );
373
+ },
374
+ ...withVariantConfig(['mobile']),
375
+ };
376
+
377
+ /**
378
+ * Tests that clicking the toggle button works correctly in uncontrolled mode.
379
+ * Verifies that the banner can expand/collapse without external state management.
380
+ */
381
+ export const UncontrolledStateInteraction: Story = {
382
+ render: function Render() {
383
+ return (
384
+ <CriticalBanner
385
+ sentiment="negative"
386
+ title="Click the toggle to test"
387
+ description="This banner manages its own expanded state. The toggle button should work without any external state management."
388
+ action={{ label: 'Verify now', onClick: fn() }}
389
+ actionSecondary={{ label: 'Learn more', onClick: fn() }}
390
+ />
391
+ );
392
+ },
393
+ play: async ({ canvasElement }) => {
394
+ const canvas = within(canvasElement);
395
+
396
+ // Wait for the collapse button to appear
397
+ const collapseBtn = await canvas.findByRole('button', { name: /^collapse$/i });
398
+
399
+ // Click to collapse
400
+ await userEvent.click(collapseBtn);
401
+ await wait(ANIMATION_DURATION);
402
+
403
+ // After collapsing, the button label changes to "Expand"
404
+ const expandBtn = canvas.getByRole('button', { name: /^expand$/i });
405
+ await expect(expandBtn).toBeInTheDocument();
406
+
407
+ // Click to expand again
408
+ await userEvent.click(expandBtn);
409
+ await wait(ANIMATION_DURATION);
410
+
411
+ // Should be back to "Collapse"
412
+ const collapseBtnAgain = canvas.getByRole('button', { name: /^collapse$/i });
413
+ await expect(collapseBtnAgain).toBeInTheDocument();
414
+ },
415
+ ...withVariantConfig(['mobile']),
416
+ parameters: {
417
+ ...withVariantConfig(['mobile']).parameters,
418
+ viewport: {
419
+ defaultViewport: 'mobile2',
420
+ },
421
+ },
422
+ };
@@ -0,0 +1,179 @@
1
+ import { ReactNode, useId, useState } from 'react';
2
+ import { clsx } from 'clsx';
3
+
4
+ import Body from '../../body';
5
+ import Button from '../../button';
6
+ import { Breakpoint, LiveRegion, Typography } from '../../common';
7
+ import { ButtonProps } from '../../button/Button.types';
8
+ import { PrimitivePrompt, PrimitivePromptProps } from '../PrimitivePrompt';
9
+
10
+ import { renderPromptMedia, PromptMedia } from '../helpers/promptMedia';
11
+ import { ExpanderToggle } from '../common/Expander/Expander';
12
+ import { buildAnnouncementString } from './helpers';
13
+ import { useContainerSize } from '../../common/hooks/useContainerSize';
14
+
15
+ export type CriticalBannerProps = {
16
+ title?: ReactNode;
17
+ description: ReactNode;
18
+ media?: PromptMedia;
19
+ action?: Pick<ButtonProps, 'onClick' | 'href' | 'target'> & {
20
+ label: ButtonProps['children'];
21
+ };
22
+ actionSecondary?: Pick<ButtonProps, 'onClick' | 'href' | 'target'> & {
23
+ label: ButtonProps['children'];
24
+ };
25
+ sentiment?: Exclude<PrimitivePromptProps['sentiment'], 'proposition'>;
26
+ /**
27
+ * Controls whether the description and actions are visible, as a controlled component.
28
+ * When collapsed with a title, only the title is shown.
29
+ * When collapsed without a title, the description is trimmed to 2 lines.
30
+ *
31
+ * Note: On desktop (container width >= 768px), the banner is always expanded
32
+ * regardless of this prop value.
33
+ */
34
+ expanded?: boolean;
35
+ /**
36
+ * Called when the user clicks the chevron toggle.
37
+ * If not provided, the component will manage expanded state internally.
38
+ */
39
+ onToggle?: () => void;
40
+ } & Pick<PrimitivePromptProps, 'id' | 'className' | 'data-testid'>;
41
+
42
+ /**
43
+ * A full-width, non-dismissible banner for critical messages such as account blocks or
44
+ * time-sensitive actions that require immediate user attention.
45
+
46
+ * @see {@link https://wise.design/components/critical-banner Design Spec}
47
+ */
48
+ export const CriticalBanner = ({
49
+ sentiment = 'negative',
50
+ title,
51
+ description,
52
+ media = {},
53
+ action,
54
+ actionSecondary,
55
+ expanded: expandedProp,
56
+ onToggle,
57
+ id,
58
+ className,
59
+ 'data-testid': testId,
60
+ }: CriticalBannerProps) => {
61
+ const [containerRef, isDesktop] = useContainerSize(Breakpoint.MEDIUM);
62
+ const isControlled = expandedProp !== undefined && onToggle !== undefined;
63
+ const [internalExpanded, setInternalExpanded] = useState(true);
64
+ const resolvedExpanded = isDesktop ? true : isControlled ? expandedProp : internalExpanded;
65
+ const handleToggle = isControlled
66
+ ? onToggle
67
+ : () => setInternalExpanded((previousExpanded) => !previousExpanded);
68
+ const hasActions = action ?? actionSecondary;
69
+ const mediaId = useId();
70
+ const titleId = useId();
71
+ const descId = useId();
72
+
73
+ const ariaLabelledByIds = [
74
+ media['aria-hidden'] ? undefined : mediaId,
75
+ !title ? undefined : titleId,
76
+ ]
77
+ .filter(Boolean)
78
+ .join(' ');
79
+
80
+ return (
81
+ <LiveRegion
82
+ aria-live="assertive"
83
+ announceOnChange={buildAnnouncementString({
84
+ title,
85
+ description,
86
+ expanded: resolvedExpanded,
87
+ actionLabel: action?.label,
88
+ actionSecondaryLabel: actionSecondary?.label,
89
+ })}
90
+ >
91
+ <PrimitivePrompt
92
+ ref={containerRef}
93
+ id={id}
94
+ sentiment={sentiment}
95
+ emphasis={sentiment === 'neutral' ? 'base' : 'elevated'}
96
+ data-testid={testId}
97
+ className={clsx(
98
+ 'wds-critical-banner',
99
+ {
100
+ 'wds-critical-banner--collapsed': !resolvedExpanded,
101
+ 'wds-critical-banner--with-two-actions': !!actionSecondary,
102
+ },
103
+ className,
104
+ )}
105
+ media={renderPromptMedia({
106
+ media,
107
+ sentiment,
108
+ mediaId,
109
+ imgClassName: 'wds-critical-banner--media-image',
110
+ })}
111
+ actions={
112
+ hasActions ? (
113
+ <div aria-hidden={!resolvedExpanded ? true : undefined} style={{ display: 'contents' }}>
114
+ {actionSecondary && (
115
+ // @ts-expect-error onClick type mismatch
116
+ <Button
117
+ v2
118
+ size="md"
119
+ priority="secondary"
120
+ href={actionSecondary.href}
121
+ tabIndex={resolvedExpanded ? undefined : -1}
122
+ onClick={actionSecondary?.onClick}
123
+ >
124
+ {actionSecondary.label}
125
+ </Button>
126
+ )}
127
+ {action && (
128
+ // @ts-expect-error onClick type mismatch
129
+ <Button
130
+ v2
131
+ size="md"
132
+ priority="primary"
133
+ href={action.href}
134
+ tabIndex={resolvedExpanded ? undefined : -1}
135
+ onClick={action.onClick}
136
+ >
137
+ {action.label}
138
+ </Button>
139
+ )}
140
+ </div>
141
+ ) : undefined
142
+ }
143
+ role="region"
144
+ aria-labelledby={ariaLabelledByIds || undefined}
145
+ aria-describedby={description ? descId : undefined}
146
+ >
147
+ <div className="wds-critical-banner__text-wrapper">
148
+ {title && (
149
+ <Body
150
+ id={titleId}
151
+ type={Typography.BODY_LARGE_BOLD}
152
+ className="wds-critical-banner__content wds-critical-banner__title"
153
+ >
154
+ {title}
155
+ </Body>
156
+ )}
157
+ {description && (
158
+ <Body
159
+ id={descId}
160
+ className={clsx('wds-critical-banner__content', 'wds-critical-banner__description', {
161
+ 'wds-critical-banner__description--with-title': !!title,
162
+ })}
163
+ >
164
+ {description}
165
+ </Body>
166
+ )}
167
+ </div>
168
+ <ExpanderToggle
169
+ expanded={resolvedExpanded}
170
+ size={24}
171
+ className="wds-critical-banner__toggle"
172
+ onToggle={handleToggle}
173
+ />
174
+ </PrimitivePrompt>
175
+ </LiveRegion>
176
+ );
177
+ };
178
+
179
+ export default CriticalBanner;
@@ -0,0 +1,6 @@
1
+ // CriticalBanner has 2 layout modes managed through container queries:
2
+ // * buttons are full width below action-wrapper-max token
3
+ // * collapse toggle is visible below collapsible-max token
4
+ // At some point in the not too distant future hopefully these two value will be the same but because of launchpad quirks, for now they'll be different
5
+ @wds-critical-banner-action-wrapper-max: 600px;
6
+ @wds-critical-banner-collapsible-max: 768px;
@@ -0,0 +1,39 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /**
4
+ * Helper to extract string from ReactNode for announcements.
5
+ * Complex ReactNodes (JSX elements) return empty string.
6
+ */
7
+ export const getStringValue = (node: ReactNode): string => {
8
+ if (typeof node === 'string' || typeof node === 'number') {
9
+ return String(node);
10
+ }
11
+ return '';
12
+ };
13
+
14
+ /**
15
+ * Builds the announcement string from visible content only.
16
+ * Content visibility depends on expanded state and presence of title.
17
+ */
18
+ export const buildAnnouncementString = ({
19
+ title,
20
+ description,
21
+ expanded,
22
+ actionLabel,
23
+ actionSecondaryLabel,
24
+ }: {
25
+ title?: ReactNode;
26
+ description?: ReactNode;
27
+ expanded: boolean;
28
+ actionLabel?: ReactNode;
29
+ actionSecondaryLabel?: ReactNode;
30
+ }): string => {
31
+ return [
32
+ title ? getStringValue(title) : undefined,
33
+ description && (expanded || !title) ? getStringValue(description) : undefined,
34
+ expanded && actionLabel ? getStringValue(actionLabel) : undefined,
35
+ expanded && actionSecondaryLabel ? getStringValue(actionSecondaryLabel) : undefined,
36
+ ]
37
+ .filter(Boolean)
38
+ .join('|');
39
+ };
@@ -0,0 +1,2 @@
1
+ export type { CriticalBannerProps } from './CriticalBanner';
2
+ export { CriticalBanner } from './CriticalBanner';
@@ -31,3 +31,27 @@
31
31
  .wds-info-prompt .wds-prompt__media-wrapper {
32
32
  padding: 0;
33
33
  }
34
+ @media (max-width: 320px) {
35
+ .wds-info-prompt .wds-prompt__media-wrapper {
36
+ padding-top: 4px;
37
+ padding-top: var(--size-4);
38
+ }
39
+ }
40
+ @media (max-width: 320px) {
41
+ .wds-info-prompt .wds-prompt__media-wrapper:has(.wds-info-prompt__media > .tw-icon) {
42
+ padding-top: 8px;
43
+ padding-top: var(--size-8);
44
+ }
45
+ }
46
+ @media (max-width: 320px) {
47
+ .wds-info-prompt .wds-prompt__media-wrapper:has(+ .wds-info-prompt__content .wds-info-prompt__title:first-child) {
48
+ padding-top: 8px;
49
+ padding-top: var(--size-8);
50
+ }
51
+ }
52
+ @media (max-width: 320px) {
53
+ .wds-info-prompt .wds-prompt__media-wrapper:has(+ .wds-info-prompt__content .wds-info-prompt__title:first-child):has(.wds-info-prompt__media > .tw-icon) {
54
+ padding-top: 12px;
55
+ padding-top: var(--size-12);
56
+ }
57
+ }