@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.
- package/build/common/hooks/useContainerSize.js +30 -0
- package/build/common/hooks/useContainerSize.js.map +1 -0
- package/build/common/hooks/useContainerSize.mjs +28 -0
- package/build/common/hooks/useContainerSize.mjs.map +1 -0
- package/build/common/hooks/useResizeObserver.js +3 -3
- package/build/common/hooks/useResizeObserver.js.map +1 -1
- package/build/common/hooks/useResizeObserver.mjs +3 -3
- package/build/common/hooks/useResizeObserver.mjs.map +1 -1
- package/build/criticalBanner/CriticalCommsBanner.js +3 -0
- package/build/criticalBanner/CriticalCommsBanner.js.map +1 -1
- package/build/criticalBanner/CriticalCommsBanner.mjs +3 -0
- package/build/criticalBanner/CriticalCommsBanner.mjs.map +1 -1
- package/build/field/Field.js +3 -2
- package/build/field/Field.js.map +1 -1
- package/build/field/Field.mjs +3 -2
- package/build/field/Field.mjs.map +1 -1
- package/build/i18n/en.json +2 -0
- package/build/i18n/en.json.js +2 -0
- package/build/i18n/en.json.js.map +1 -1
- package/build/i18n/en.json.mjs +2 -0
- package/build/i18n/en.json.mjs.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/index.mjs +1 -0
- package/build/index.mjs.map +1 -1
- package/build/listItem/Prompt/ListItemPrompt.js +3 -2
- package/build/listItem/Prompt/ListItemPrompt.js.map +1 -1
- package/build/listItem/Prompt/ListItemPrompt.mjs +3 -2
- package/build/listItem/Prompt/ListItemPrompt.mjs.map +1 -1
- package/build/logo/Logo.js +77 -25
- package/build/logo/Logo.js.map +1 -1
- package/build/logo/Logo.mjs +79 -27
- package/build/logo/Logo.mjs.map +1 -1
- package/build/logo/logo-assets.js +68 -97
- package/build/logo/logo-assets.js.map +1 -1
- package/build/logo/logo-assets.mjs +62 -90
- package/build/logo/logo-assets.mjs.map +1 -1
- package/build/main.css +225 -58
- package/build/prompt/ActionPrompt/ActionPrompt.js +8 -40
- package/build/prompt/ActionPrompt/ActionPrompt.js.map +1 -1
- package/build/prompt/ActionPrompt/ActionPrompt.mjs +8 -40
- package/build/prompt/ActionPrompt/ActionPrompt.mjs.map +1 -1
- package/build/prompt/CriticalBanner/CriticalBanner.js +143 -0
- package/build/prompt/CriticalBanner/CriticalBanner.js.map +1 -0
- package/build/prompt/CriticalBanner/CriticalBanner.mjs +141 -0
- package/build/prompt/CriticalBanner/CriticalBanner.mjs.map +1 -0
- package/build/prompt/CriticalBanner/helpers.js +29 -0
- package/build/prompt/CriticalBanner/helpers.js.map +1 -0
- package/build/prompt/CriticalBanner/helpers.mjs +26 -0
- package/build/prompt/CriticalBanner/helpers.mjs.map +1 -0
- package/build/prompt/InfoPrompt/InfoPrompt.js +3 -2
- package/build/prompt/InfoPrompt/InfoPrompt.js.map +1 -1
- package/build/prompt/InfoPrompt/InfoPrompt.mjs +3 -2
- package/build/prompt/InfoPrompt/InfoPrompt.mjs.map +1 -1
- package/build/prompt/PrimitivePrompt/PrimitivePrompt.js +11 -4
- package/build/prompt/PrimitivePrompt/PrimitivePrompt.js.map +1 -1
- package/build/prompt/PrimitivePrompt/PrimitivePrompt.mjs +11 -4
- package/build/prompt/PrimitivePrompt/PrimitivePrompt.mjs.map +1 -1
- package/build/prompt/common/Expander/Expander.js +35 -0
- package/build/prompt/common/Expander/Expander.js.map +1 -0
- package/build/prompt/common/Expander/Expander.messages.js +17 -0
- package/build/prompt/common/Expander/Expander.messages.js.map +1 -0
- package/build/prompt/common/Expander/Expander.messages.mjs +13 -0
- package/build/prompt/common/Expander/Expander.messages.mjs.map +1 -0
- package/build/prompt/common/Expander/Expander.mjs +33 -0
- package/build/prompt/common/Expander/Expander.mjs.map +1 -0
- package/build/prompt/helpers/promptMedia.js +52 -0
- package/build/prompt/helpers/promptMedia.js.map +1 -0
- package/build/prompt/helpers/promptMedia.mjs +50 -0
- package/build/prompt/helpers/promptMedia.mjs.map +1 -0
- package/build/styles/logo/Logo.css +3 -23
- package/build/styles/main.css +225 -58
- package/build/styles/prompt/CriticalBanner/CriticalBanner.css +134 -0
- package/build/styles/prompt/CriticalBanner/CriticalBanner.vars.css +0 -0
- package/build/styles/prompt/InfoPrompt/InfoPrompt.css +24 -0
- package/build/styles/prompt/common/Expander/Expander.css +8 -0
- package/build/table/Table.js +6 -5
- package/build/table/Table.js.map +1 -1
- package/build/table/Table.mjs +6 -5
- package/build/table/Table.mjs.map +1 -1
- package/build/typeahead/Typeahead.js +3 -2
- package/build/typeahead/Typeahead.js.map +1 -1
- package/build/typeahead/Typeahead.mjs +3 -2
- package/build/typeahead/Typeahead.mjs.map +1 -1
- package/build/types/common/hooks/useContainerSize.d.ts +14 -0
- package/build/types/common/hooks/useContainerSize.d.ts.map +1 -0
- package/build/types/common/hooks/useResizeObserver.d.ts +1 -1
- package/build/types/common/hooks/useResizeObserver.d.ts.map +1 -1
- package/build/types/criticalBanner/CriticalCommsBanner.d.ts +3 -0
- package/build/types/criticalBanner/CriticalCommsBanner.d.ts.map +1 -1
- package/build/types/index.d.ts +2 -2
- package/build/types/index.d.ts.map +1 -1
- package/build/types/logo/Logo.d.ts +33 -1
- package/build/types/logo/Logo.d.ts.map +1 -1
- package/build/types/logo/logo-assets.d.ts +33 -9
- package/build/types/logo/logo-assets.d.ts.map +1 -1
- package/build/types/prompt/ActionPrompt/ActionPrompt.d.ts +2 -11
- package/build/types/prompt/ActionPrompt/ActionPrompt.d.ts.map +1 -1
- package/build/types/prompt/CriticalBanner/CriticalBanner.d.ts +39 -0
- package/build/types/prompt/CriticalBanner/CriticalBanner.d.ts.map +1 -0
- package/build/types/prompt/CriticalBanner/helpers.d.ts +18 -0
- package/build/types/prompt/CriticalBanner/helpers.d.ts.map +1 -0
- package/build/types/prompt/CriticalBanner/index.d.ts +3 -0
- package/build/types/prompt/CriticalBanner/index.d.ts.map +1 -0
- package/build/types/prompt/InfoPrompt/InfoPrompt.d.ts.map +1 -1
- package/build/types/prompt/PrimitivePrompt/PrimitivePrompt.d.ts +35 -3
- package/build/types/prompt/PrimitivePrompt/PrimitivePrompt.d.ts.map +1 -1
- package/build/types/prompt/common/Expander/Expander.d.ts +20 -0
- package/build/types/prompt/common/Expander/Expander.d.ts.map +1 -0
- package/build/types/prompt/common/Expander/Expander.messages.d.ts +14 -0
- package/build/types/prompt/common/Expander/Expander.messages.d.ts.map +1 -0
- package/build/types/prompt/helpers/promptMedia.d.ts +22 -0
- package/build/types/prompt/helpers/promptMedia.d.ts.map +1 -0
- package/build/types/prompt/index.d.ts +2 -0
- package/build/types/prompt/index.d.ts.map +1 -1
- package/build/types/table/Table.d.ts.map +1 -1
- package/build/types/test-utils/index.d.ts +4 -0
- package/build/types/test-utils/index.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/common/bottomSheet/BottomSheet.test.story.tsx +6 -5
- package/src/common/hooks/useContainerSize.test.tsx +125 -0
- package/src/common/hooks/useContainerSize.ts +32 -0
- package/src/common/hooks/useResizeObserver.ts +3 -2
- package/src/criticalBanner/CriticalCommsBanner.story.tsx +4 -0
- package/src/criticalBanner/CriticalCommsBanner.test.story.tsx +1 -1
- package/src/criticalBanner/CriticalCommsBanner.tsx +3 -0
- package/src/i18n/en.json +2 -0
- package/src/icons/Icons.story.tsx +43 -35
- package/src/index.ts +2 -2
- package/src/logo/Logo.css +3 -23
- package/src/logo/Logo.less +3 -29
- package/src/logo/Logo.story.tsx +117 -89
- package/src/logo/Logo.test.story.tsx +15 -24
- package/src/logo/Logo.tsx +90 -28
- package/src/logo/logo-assets.tsx +36 -92
- package/src/main.css +225 -58
- package/src/main.less +3 -1
- package/src/prompt/ActionPrompt/ActionPrompt.tsx +9 -62
- package/src/prompt/CriticalBanner/CriticalBanner.accessibility.docs.mdx +113 -0
- package/src/prompt/CriticalBanner/CriticalBanner.css +134 -0
- package/src/prompt/CriticalBanner/CriticalBanner.less +155 -0
- package/src/prompt/CriticalBanner/CriticalBanner.story.tsx +635 -0
- package/src/prompt/CriticalBanner/CriticalBanner.test.story.tsx +422 -0
- package/src/prompt/CriticalBanner/CriticalBanner.tsx +179 -0
- package/src/prompt/CriticalBanner/CriticalBanner.vars.css +0 -0
- package/src/prompt/CriticalBanner/CriticalBanner.vars.less +6 -0
- package/src/prompt/CriticalBanner/helpers.ts +39 -0
- package/src/prompt/CriticalBanner/index.ts +2 -0
- package/src/prompt/InfoPrompt/InfoPrompt.css +24 -0
- package/src/prompt/InfoPrompt/InfoPrompt.less +23 -0
- package/src/prompt/InfoPrompt/InfoPrompt.tsx +5 -1
- package/src/prompt/PrimitivePrompt/PrimitivePrompt.tsx +56 -40
- package/src/prompt/common/Expander/Expander.css +8 -0
- package/src/prompt/common/Expander/Expander.less +9 -0
- package/src/prompt/common/Expander/Expander.messages.ts +14 -0
- package/src/prompt/common/Expander/Expander.test.tsx +167 -0
- package/src/prompt/common/Expander/Expander.tsx +83 -0
- package/src/prompt/helpers/promptMedia.tsx +79 -0
- package/src/prompt/index.ts +4 -0
- package/src/sentimentSurface/SentimentSurface.story.tsx +43 -17
- package/src/table/Table.story.tsx +1 -1
- 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;
|
|
File without changes
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
}
|