@transferwise/components 0.0.0-experimental-14ff413 → 0.0.0-experimental-8bafa84
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/field/Field.js +4 -4
- package/build/field/Field.mjs +4 -4
- 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 +4 -4
- package/build/listItem/Prompt/ListItemPrompt.mjs +4 -4
- package/build/main.css +85 -29
- 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 +145 -0
- package/build/prompt/CriticalBanner/CriticalBanner.js.map +1 -0
- package/build/prompt/CriticalBanner/CriticalBanner.mjs +143 -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/PrimitivePrompt/PrimitivePrompt.js +2 -0
- package/build/prompt/PrimitivePrompt/PrimitivePrompt.js.map +1 -1
- package/build/prompt/PrimitivePrompt/PrimitivePrompt.mjs +2 -0
- package/build/prompt/PrimitivePrompt/PrimitivePrompt.mjs.map +1 -1
- package/build/prompt/common/Expander/Expander.js +46 -0
- package/build/prompt/common/Expander/Expander.js.map +1 -0
- package/build/prompt/common/Expander/Expander.mjs +43 -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/main.css +85 -29
- package/build/styles/prompt/CriticalBanner/CriticalBanner.css +39 -0
- package/build/styles/prompt/common/Expander/Expander.css +8 -0
- package/build/typeahead/Typeahead.js +4 -4
- package/build/typeahead/Typeahead.mjs +4 -4
- package/build/types/index.d.ts +2 -2
- package/build/types/index.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 +43 -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/PrimitivePrompt/PrimitivePrompt.d.ts +8 -3
- package/build/types/prompt/PrimitivePrompt/PrimitivePrompt.d.ts.map +1 -1
- package/build/types/prompt/common/Expander/Expander.d.ts +22 -0
- package/build/types/prompt/common/Expander/Expander.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/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/main.css +85 -29
- package/src/main.less +3 -1
- package/src/prompt/ActionPrompt/ActionPrompt.tsx +9 -62
- package/src/prompt/CriticalBanner/CriticalBanner.accessibility.docs.mdx +131 -0
- package/src/prompt/CriticalBanner/CriticalBanner.css +39 -0
- package/src/prompt/CriticalBanner/CriticalBanner.less +45 -0
- package/src/prompt/CriticalBanner/CriticalBanner.story.tsx +476 -0
- package/src/prompt/CriticalBanner/CriticalBanner.test.story.tsx +67 -0
- package/src/prompt/CriticalBanner/CriticalBanner.tsx +189 -0
- package/src/prompt/CriticalBanner/helpers.ts +39 -0
- package/src/prompt/CriticalBanner/index.ts +2 -0
- package/src/prompt/PrimitivePrompt/PrimitivePrompt.tsx +9 -2
- package/src/prompt/common/Expander/Expander.css +8 -0
- package/src/prompt/common/Expander/Expander.less +9 -0
- package/src/prompt/common/Expander/Expander.test.tsx +176 -0
- package/src/prompt/common/Expander/Expander.tsx +81 -0
- package/src/prompt/helpers/promptMedia.tsx +79 -0
- package/src/prompt/index.ts +4 -0
- package/src/sentimentSurface/SentimentSurface.story.tsx +43 -17
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { AriaAttributes, ReactNode, useId } 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
|
+
import { useScreenSize } from '../../common/hooks/useScreenSize';
|
|
10
|
+
|
|
11
|
+
import IconButton from '../../iconButton';
|
|
12
|
+
import { renderPromptMedia, PromptMedia } from '../helpers/promptMedia';
|
|
13
|
+
import { shouldShowWhenExpanded, ExpanderToggle } from '../common/Expander/Expander';
|
|
14
|
+
import { buildAnnouncementString } from './helpers';
|
|
15
|
+
|
|
16
|
+
export type CriticalBannerProps = {
|
|
17
|
+
title?: ReactNode;
|
|
18
|
+
description?: ReactNode;
|
|
19
|
+
/** @default {} */
|
|
20
|
+
media?: PromptMedia;
|
|
21
|
+
action?: Pick<ButtonProps, 'onClick' | 'href' | 'target'> & {
|
|
22
|
+
label: ButtonProps['children'];
|
|
23
|
+
};
|
|
24
|
+
actionSecondary?: Pick<ButtonProps, 'onClick' | 'href' | 'target'> & {
|
|
25
|
+
label: ButtonProps['children'];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The sentiment determines the colour scheme
|
|
29
|
+
* @default 'negative'
|
|
30
|
+
*/
|
|
31
|
+
sentiment?: Exclude<PrimitivePromptProps['sentiment'], 'proposition'>;
|
|
32
|
+
/**
|
|
33
|
+
* Whether the banner is expanded (showing description and actions)
|
|
34
|
+
*/
|
|
35
|
+
expanded: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Callback when the chevron toggle is clicked
|
|
38
|
+
*/
|
|
39
|
+
onToggle: () => void;
|
|
40
|
+
'aria-label'?: AriaAttributes['aria-label'];
|
|
41
|
+
} & Pick<PrimitivePromptProps, 'id' | 'className' | 'data-testid'>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* CriticalBanner is a full-width banner component for critical messages that require attention.
|
|
45
|
+
* It features a collapsible design with a chevron toggle to show/hide description and action buttons.
|
|
46
|
+
* Unlike ActionPrompt, it has no dismiss button and no border radius.
|
|
47
|
+
*
|
|
48
|
+
* The component is fully controlled - parent owns the `expanded` state and provides `onToggle` callback.
|
|
49
|
+
*
|
|
50
|
+
* **Accessibility:** Uses `aria-live="assertive"` to immediately announce critical messages to screen readers.
|
|
51
|
+
* See the Accessibility documentation for detailed information on announcement behavior and testing.
|
|
52
|
+
*/
|
|
53
|
+
export const CriticalBanner = ({
|
|
54
|
+
sentiment = 'negative',
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
media = {},
|
|
58
|
+
action,
|
|
59
|
+
actionSecondary,
|
|
60
|
+
expanded,
|
|
61
|
+
onToggle,
|
|
62
|
+
id,
|
|
63
|
+
className,
|
|
64
|
+
'data-testid': testId,
|
|
65
|
+
'aria-label': ariaLabel,
|
|
66
|
+
}: CriticalBannerProps) => {
|
|
67
|
+
const isMobile = !useScreenSize(Breakpoint.MEDIUM);
|
|
68
|
+
const mediaId = useId();
|
|
69
|
+
const titleId = useId();
|
|
70
|
+
const descId = useId();
|
|
71
|
+
|
|
72
|
+
const ariaLabelledByIds = [
|
|
73
|
+
media['aria-hidden'] ? undefined : mediaId,
|
|
74
|
+
ariaLabel || !title ? undefined : titleId,
|
|
75
|
+
]
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.join(' ');
|
|
78
|
+
const hasActions = action || actionSecondary;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<LiveRegion
|
|
82
|
+
aria-live="assertive"
|
|
83
|
+
announceOnChange={buildAnnouncementString({
|
|
84
|
+
title,
|
|
85
|
+
description,
|
|
86
|
+
expanded,
|
|
87
|
+
actionLabel: action?.label,
|
|
88
|
+
actionSecondaryLabel: actionSecondary?.label,
|
|
89
|
+
})}
|
|
90
|
+
>
|
|
91
|
+
<PrimitivePrompt
|
|
92
|
+
id={id}
|
|
93
|
+
sentiment={sentiment}
|
|
94
|
+
emphasis={sentiment === 'neutral' ? 'base' : 'elevated'}
|
|
95
|
+
data-testid={testId}
|
|
96
|
+
className={clsx(
|
|
97
|
+
'wds-critical-banner',
|
|
98
|
+
{
|
|
99
|
+
'wds-critical-banner--collapsed': !expanded,
|
|
100
|
+
'wds-critical-banner--with-two-actions': !!actionSecondary,
|
|
101
|
+
},
|
|
102
|
+
className,
|
|
103
|
+
)}
|
|
104
|
+
media={renderPromptMedia({
|
|
105
|
+
media,
|
|
106
|
+
sentiment,
|
|
107
|
+
mediaId,
|
|
108
|
+
imgClassName: 'wds-critical-banner--media-image',
|
|
109
|
+
})}
|
|
110
|
+
actions={
|
|
111
|
+
shouldShowWhenExpanded({ expanded, hasContent: !!hasActions }) ? (
|
|
112
|
+
<>
|
|
113
|
+
{actionSecondary && (
|
|
114
|
+
// @ts-expect-error onClick type mismatch
|
|
115
|
+
<Button
|
|
116
|
+
v2
|
|
117
|
+
size="md"
|
|
118
|
+
priority="secondary"
|
|
119
|
+
href={actionSecondary.href}
|
|
120
|
+
block={isMobile}
|
|
121
|
+
onClick={actionSecondary?.onClick}
|
|
122
|
+
>
|
|
123
|
+
{actionSecondary.label}
|
|
124
|
+
</Button>
|
|
125
|
+
)}
|
|
126
|
+
{action && (
|
|
127
|
+
// @ts-expect-error onClick type mismatch
|
|
128
|
+
<Button
|
|
129
|
+
v2
|
|
130
|
+
size="md"
|
|
131
|
+
priority="primary"
|
|
132
|
+
href={action.href}
|
|
133
|
+
block={isMobile}
|
|
134
|
+
onClick={action.onClick}
|
|
135
|
+
>
|
|
136
|
+
{action.label}
|
|
137
|
+
</Button>
|
|
138
|
+
)}
|
|
139
|
+
</>
|
|
140
|
+
) : undefined
|
|
141
|
+
}
|
|
142
|
+
role="region"
|
|
143
|
+
{...(ariaLabel
|
|
144
|
+
? { 'aria-label': ariaLabel }
|
|
145
|
+
: {
|
|
146
|
+
'aria-labelledby': ariaLabelledByIds,
|
|
147
|
+
'aria-describedby': descId,
|
|
148
|
+
})}
|
|
149
|
+
>
|
|
150
|
+
<div
|
|
151
|
+
className={clsx(
|
|
152
|
+
'd-flex',
|
|
153
|
+
'flex-column',
|
|
154
|
+
'justify-content-center',
|
|
155
|
+
'wds-critical-banner__text-wrapper',
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
{title && (
|
|
159
|
+
<Body
|
|
160
|
+
id={titleId}
|
|
161
|
+
type={Typography.BODY_LARGE_BOLD}
|
|
162
|
+
className="wds-critical-banner__content"
|
|
163
|
+
>
|
|
164
|
+
{title}
|
|
165
|
+
</Body>
|
|
166
|
+
)}
|
|
167
|
+
{shouldShowWhenExpanded({ expanded, hasContent: !!description, alwaysShow: !title }) && (
|
|
168
|
+
<Body
|
|
169
|
+
id={descId}
|
|
170
|
+
className={clsx('wds-critical-banner__content', {
|
|
171
|
+
'wds-critical-banner__description--collapsed': !expanded && !title,
|
|
172
|
+
})}
|
|
173
|
+
>
|
|
174
|
+
{description}
|
|
175
|
+
</Body>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
<ExpanderToggle
|
|
179
|
+
expanded={expanded}
|
|
180
|
+
size={24}
|
|
181
|
+
className="wds-critical-banner__toggle"
|
|
182
|
+
onToggle={onToggle}
|
|
183
|
+
/>
|
|
184
|
+
</PrimitivePrompt>
|
|
185
|
+
</LiveRegion>
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export default CriticalBanner;
|
|
@@ -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
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Cross } from '@transferwise/icons';
|
|
2
2
|
import { clsx } from 'clsx';
|
|
3
|
-
import SentimentSurface, { Sentiment } from '../../sentimentSurface';
|
|
3
|
+
import SentimentSurface, { Emphasis, Sentiment } from '../../sentimentSurface';
|
|
4
4
|
import IconButton from '../../iconButton';
|
|
5
5
|
import { useIntl } from 'react-intl';
|
|
6
6
|
import closeBtnMessages from '../../common/closeButton/CloseButton.messages';
|
|
@@ -8,10 +8,15 @@ import { HTMLAttributes, ReactNode } from 'react';
|
|
|
8
8
|
|
|
9
9
|
export type PrimitivePromptProps = HTMLAttributes<HTMLDivElement> & {
|
|
10
10
|
/**
|
|
11
|
-
* The sentiment determines the colour scheme
|
|
11
|
+
* The sentiment determines the colour scheme.
|
|
12
12
|
* @default success
|
|
13
13
|
*/
|
|
14
14
|
sentiment?: Sentiment;
|
|
15
|
+
/**
|
|
16
|
+
* The emphasis level affecting background and text contrast.
|
|
17
|
+
* @default 'base
|
|
18
|
+
*/
|
|
19
|
+
emphasis?: Emphasis;
|
|
15
20
|
/**
|
|
16
21
|
* Media to be displayed on the prompt (icon/image/etc).
|
|
17
22
|
*/
|
|
@@ -35,6 +40,7 @@ export type PrimitivePromptProps = HTMLAttributes<HTMLDivElement> & {
|
|
|
35
40
|
* Uses several css variables to handle styling from within the consuming component, e.g. --Prompt-padding. */
|
|
36
41
|
export const PrimitivePrompt = ({
|
|
37
42
|
sentiment = 'success',
|
|
43
|
+
emphasis = 'base',
|
|
38
44
|
media,
|
|
39
45
|
actions,
|
|
40
46
|
onDismiss,
|
|
@@ -47,6 +53,7 @@ export const PrimitivePrompt = ({
|
|
|
47
53
|
return (
|
|
48
54
|
<SentimentSurface
|
|
49
55
|
sentiment={sentiment}
|
|
56
|
+
emphasis={emphasis}
|
|
50
57
|
className={clsx('wds-prompt', `wds-prompt--${sentiment}`, className)}
|
|
51
58
|
{...restProps}
|
|
52
59
|
>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { renderHook, act, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
|
|
4
|
+
import { useExpanderState, shouldShowWhenExpanded, ExpanderToggle } from './Expander';
|
|
5
|
+
import { mockMatchMedia, render } from '../../../test-utils';
|
|
6
|
+
|
|
7
|
+
mockMatchMedia();
|
|
8
|
+
|
|
9
|
+
describe('useExpanderState', () => {
|
|
10
|
+
it('initializes with default expanded state (true)', () => {
|
|
11
|
+
const { result } = renderHook(() => useExpanderState());
|
|
12
|
+
|
|
13
|
+
expect(result.current.expanded).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('initializes with custom expanded state', () => {
|
|
17
|
+
const { result } = renderHook(() => useExpanderState(false));
|
|
18
|
+
|
|
19
|
+
expect(result.current.expanded).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('toggles expanded state', () => {
|
|
23
|
+
const { result } = renderHook(() => useExpanderState(true));
|
|
24
|
+
|
|
25
|
+
expect(result.current.expanded).toBe(true);
|
|
26
|
+
|
|
27
|
+
act(() => {
|
|
28
|
+
result.current.toggle();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.current.expanded).toBe(false);
|
|
32
|
+
|
|
33
|
+
act(() => {
|
|
34
|
+
result.current.toggle();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(result.current.expanded).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('sets expanded state programmatically', () => {
|
|
41
|
+
const { result } = renderHook(() => useExpanderState(true));
|
|
42
|
+
|
|
43
|
+
act(() => {
|
|
44
|
+
result.current.setExpanded(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result.current.expanded).toBe(false);
|
|
48
|
+
|
|
49
|
+
act(() => {
|
|
50
|
+
result.current.setExpanded(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result.current.expanded).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('memoizes toggle callback', () => {
|
|
57
|
+
const { result, rerender } = renderHook(() => useExpanderState(true));
|
|
58
|
+
|
|
59
|
+
const firstToggle = result.current.toggle;
|
|
60
|
+
|
|
61
|
+
rerender();
|
|
62
|
+
|
|
63
|
+
expect(result.current.toggle).toBe(firstToggle);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('shouldShowWhenExpanded', () => {
|
|
68
|
+
it('returns false when hasContent is false', () => {
|
|
69
|
+
expect(shouldShowWhenExpanded({ expanded: true, hasContent: false })).toBe(false);
|
|
70
|
+
expect(shouldShowWhenExpanded({ expanded: false, hasContent: false })).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns true when expanded and hasContent are true', () => {
|
|
74
|
+
expect(shouldShowWhenExpanded({ expanded: true, hasContent: true })).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns false when collapsed but hasContent is true', () => {
|
|
78
|
+
expect(shouldShowWhenExpanded({ expanded: false, hasContent: true })).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns true when alwaysShow is true and hasContent is true, regardless of expanded', () => {
|
|
82
|
+
expect(shouldShowWhenExpanded({ expanded: true, hasContent: true, alwaysShow: true })).toBe(
|
|
83
|
+
true,
|
|
84
|
+
);
|
|
85
|
+
expect(shouldShowWhenExpanded({ expanded: false, hasContent: true, alwaysShow: true })).toBe(
|
|
86
|
+
true,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns false when alwaysShow is true but hasContent is false', () => {
|
|
91
|
+
expect(shouldShowWhenExpanded({ expanded: true, hasContent: false, alwaysShow: true })).toBe(
|
|
92
|
+
false,
|
|
93
|
+
);
|
|
94
|
+
expect(shouldShowWhenExpanded({ expanded: false, hasContent: false, alwaysShow: true })).toBe(
|
|
95
|
+
false,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('ExpanderToggle', () => {
|
|
101
|
+
it('renders with correct aria-label when expanded', () => {
|
|
102
|
+
render(<ExpanderToggle expanded onToggle={jest.fn()} />);
|
|
103
|
+
|
|
104
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Collapse');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('renders with correct aria-label when collapsed', () => {
|
|
108
|
+
render(<ExpanderToggle expanded={false} onToggle={jest.fn()} />);
|
|
109
|
+
|
|
110
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Expand');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('renders with custom labels', () => {
|
|
114
|
+
const { rerender } = render(
|
|
115
|
+
<ExpanderToggle expanded collapseLabel="Close" expandLabel="Open" onToggle={jest.fn()} />,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Close');
|
|
119
|
+
|
|
120
|
+
rerender(
|
|
121
|
+
<ExpanderToggle
|
|
122
|
+
expanded={false}
|
|
123
|
+
collapseLabel="Close"
|
|
124
|
+
expandLabel="Open"
|
|
125
|
+
onToggle={jest.fn()}
|
|
126
|
+
/>,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Open');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('sets aria-expanded attribute correctly', () => {
|
|
133
|
+
const { rerender } = render(<ExpanderToggle expanded onToggle={jest.fn()} />);
|
|
134
|
+
|
|
135
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
|
|
136
|
+
|
|
137
|
+
rerender(<ExpanderToggle expanded={false} onToggle={jest.fn()} />);
|
|
138
|
+
|
|
139
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('calls onToggle when clicked', async () => {
|
|
143
|
+
const user = userEvent.setup();
|
|
144
|
+
const onToggle = jest.fn();
|
|
145
|
+
|
|
146
|
+
render(<ExpanderToggle expanded onToggle={onToggle} />);
|
|
147
|
+
|
|
148
|
+
await user.click(screen.getByRole('button'));
|
|
149
|
+
|
|
150
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('applies custom className', () => {
|
|
154
|
+
render(<ExpanderToggle expanded className="custom-class" onToggle={jest.fn()} />);
|
|
155
|
+
|
|
156
|
+
expect(screen.getByRole('button')).toHaveClass('custom-class');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('applies base class and collapsed class correctly', () => {
|
|
160
|
+
const { rerender } = render(<ExpanderToggle expanded onToggle={jest.fn()} />);
|
|
161
|
+
|
|
162
|
+
expect(screen.getByRole('button')).toHaveClass('wds-expander-toggle');
|
|
163
|
+
expect(screen.getByRole('button')).not.toHaveClass('wds-expander-toggle--collapsed');
|
|
164
|
+
|
|
165
|
+
rerender(<ExpanderToggle expanded={false} onToggle={jest.fn()} />);
|
|
166
|
+
|
|
167
|
+
expect(screen.getByRole('button')).toHaveClass('wds-expander-toggle');
|
|
168
|
+
expect(screen.getByRole('button')).toHaveClass('wds-expander-toggle--collapsed');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('applies data-testid attribute', () => {
|
|
172
|
+
render(<ExpanderToggle expanded data-testid="expander-toggle" onToggle={jest.fn()} />);
|
|
173
|
+
|
|
174
|
+
expect(screen.getByTestId('expander-toggle')).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { clsx } from 'clsx';
|
|
3
|
+
import { ChevronUp } from '@transferwise/icons';
|
|
4
|
+
import IconButton from '../../../iconButton';
|
|
5
|
+
|
|
6
|
+
export type ExpanderState = {
|
|
7
|
+
expanded: boolean;
|
|
8
|
+
toggle: () => void;
|
|
9
|
+
setExpanded: (expanded: boolean) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ExpanderToggleProps = {
|
|
13
|
+
expanded: boolean;
|
|
14
|
+
onToggle: () => void;
|
|
15
|
+
size?: 16 | 24 | 32 | 40 | 48 | 56 | 72;
|
|
16
|
+
collapseLabel?: string;
|
|
17
|
+
expandLabel?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
'data-testid'?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Hook for managing expander state
|
|
23
|
+
export function useExpanderState(initialExpanded = true): ExpanderState {
|
|
24
|
+
const [expanded, setExpanded] = useState(initialExpanded);
|
|
25
|
+
|
|
26
|
+
const toggle = useCallback(() => {
|
|
27
|
+
setExpanded((prev) => !prev);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
expanded,
|
|
32
|
+
toggle,
|
|
33
|
+
setExpanded,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Helper for conditional rendering based on expanded state
|
|
38
|
+
export function shouldShowWhenExpanded({
|
|
39
|
+
expanded,
|
|
40
|
+
hasContent,
|
|
41
|
+
alwaysShow = false,
|
|
42
|
+
}: {
|
|
43
|
+
expanded: boolean;
|
|
44
|
+
hasContent: boolean;
|
|
45
|
+
alwaysShow?: boolean;
|
|
46
|
+
}): boolean {
|
|
47
|
+
if (alwaysShow) {
|
|
48
|
+
return hasContent;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return expanded && hasContent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Reusable toggle button component
|
|
55
|
+
export const ExpanderToggle = ({
|
|
56
|
+
expanded,
|
|
57
|
+
onToggle,
|
|
58
|
+
size = 24,
|
|
59
|
+
collapseLabel = 'Collapse',
|
|
60
|
+
expandLabel = 'Expand',
|
|
61
|
+
className,
|
|
62
|
+
'data-testid': testId,
|
|
63
|
+
}: ExpanderToggleProps) => {
|
|
64
|
+
return (
|
|
65
|
+
<IconButton
|
|
66
|
+
size={size}
|
|
67
|
+
priority="secondary"
|
|
68
|
+
aria-label={expanded ? collapseLabel : expandLabel}
|
|
69
|
+
aria-expanded={expanded}
|
|
70
|
+
className={clsx(
|
|
71
|
+
'wds-expander-toggle',
|
|
72
|
+
{ 'wds-expander-toggle--collapsed': !expanded },
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
75
|
+
data-testid={testId}
|
|
76
|
+
onClick={onToggle}
|
|
77
|
+
>
|
|
78
|
+
<ChevronUp />
|
|
79
|
+
</IconButton>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { GiftBox } from '@transferwise/icons';
|
|
3
|
+
|
|
4
|
+
import AvatarView, { AvatarViewProps } from '../../avatarView';
|
|
5
|
+
import Image from '../../image';
|
|
6
|
+
import StatusIcon from '../../statusIcon';
|
|
7
|
+
import { BadgeAssetsProps } from '../../badge';
|
|
8
|
+
import { PrimitivePromptProps } from '../PrimitivePrompt';
|
|
9
|
+
|
|
10
|
+
export type PromptMedia = {
|
|
11
|
+
imgSrc?: string;
|
|
12
|
+
avatar?: Pick<AvatarViewProps, 'imgSrc' | 'profileName' | 'profileType'> & {
|
|
13
|
+
asset?: AvatarViewProps['children'];
|
|
14
|
+
badge?: Pick<BadgeAssetsProps, 'flagCode'>;
|
|
15
|
+
};
|
|
16
|
+
'aria-label'?: string;
|
|
17
|
+
'aria-hidden'?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type RenderPromptMediaOptions = {
|
|
21
|
+
media: PromptMedia;
|
|
22
|
+
sentiment: PrimitivePromptProps['sentiment'];
|
|
23
|
+
mediaId: string;
|
|
24
|
+
imgClassName: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function renderPromptMedia({
|
|
28
|
+
media,
|
|
29
|
+
sentiment,
|
|
30
|
+
mediaId,
|
|
31
|
+
imgClassName,
|
|
32
|
+
}: RenderPromptMediaOptions): ReactNode {
|
|
33
|
+
if (media?.imgSrc) {
|
|
34
|
+
return (
|
|
35
|
+
<Image
|
|
36
|
+
id={mediaId}
|
|
37
|
+
src={media.imgSrc}
|
|
38
|
+
className={imgClassName}
|
|
39
|
+
alt={media['aria-label'] ?? ''}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (media?.avatar) {
|
|
44
|
+
const badge = media.avatar.badge
|
|
45
|
+
? media.avatar.badge
|
|
46
|
+
: sentiment === 'proposition'
|
|
47
|
+
? {}
|
|
48
|
+
: { status: sentiment };
|
|
49
|
+
return (
|
|
50
|
+
<AvatarView
|
|
51
|
+
{...media.avatar}
|
|
52
|
+
badge={badge}
|
|
53
|
+
aria-label={media['aria-label']}
|
|
54
|
+
aria-hidden={media['aria-hidden']}
|
|
55
|
+
id={mediaId}
|
|
56
|
+
size={48}
|
|
57
|
+
>
|
|
58
|
+
{media.avatar.asset}
|
|
59
|
+
</AvatarView>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return sentiment === 'proposition' ? (
|
|
63
|
+
<AvatarView
|
|
64
|
+
id={mediaId}
|
|
65
|
+
size={48}
|
|
66
|
+
aria-label={media['aria-label']}
|
|
67
|
+
aria-hidden={media['aria-hidden']}
|
|
68
|
+
>
|
|
69
|
+
<GiftBox />
|
|
70
|
+
</AvatarView>
|
|
71
|
+
) : (
|
|
72
|
+
<StatusIcon
|
|
73
|
+
id={mediaId}
|
|
74
|
+
size={48}
|
|
75
|
+
sentiment={sentiment}
|
|
76
|
+
iconLabel={media['aria-hidden'] ? null : media['aria-label']}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
}
|
package/src/prompt/index.ts
CHANGED
|
@@ -12,3 +12,7 @@ export { ActionPrompt } from './ActionPrompt';
|
|
|
12
12
|
// InfoPrompt
|
|
13
13
|
export type { InfoPromptProps, InfoPromptAction, InfoPromptMedia } from './InfoPrompt';
|
|
14
14
|
export { InfoPrompt } from './InfoPrompt';
|
|
15
|
+
|
|
16
|
+
// CriticalBanner
|
|
17
|
+
export type { CriticalBannerProps } from './CriticalBanner';
|
|
18
|
+
export { CriticalBanner } from './CriticalBanner';
|