@transferwise/components 46.166.0 → 46.167.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/CardContainer/CardContainer.js +34 -0
- package/build/CardContainer/CardContainer.js.map +1 -0
- package/build/CardContainer/CardContainer.mjs +30 -0
- package/build/CardContainer/CardContainer.mjs.map +1 -0
- package/build/CardWidget/CardWidget.js +98 -0
- package/build/CardWidget/CardWidget.js.map +1 -0
- package/build/CardWidget/CardWidget.mjs +94 -0
- package/build/CardWidget/CardWidget.mjs.map +1 -0
- package/build/CardWidget/CardWidgetAction.js +73 -0
- package/build/CardWidget/CardWidgetAction.js.map +1 -0
- package/build/CardWidget/CardWidgetAction.mjs +68 -0
- package/build/CardWidget/CardWidgetAction.mjs.map +1 -0
- package/build/ListItem/ListItem.js +2 -0
- package/build/ListItem/ListItem.js.map +1 -1
- package/build/ListItem/ListItem.mjs +2 -0
- package/build/ListItem/ListItem.mjs.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/index.mjs +2 -0
- package/build/index.mjs.map +1 -1
- package/build/main.css +194 -0
- package/build/styles/CardContainer/CardContainer.css +108 -0
- package/build/styles/CardWidget/CardWidget.css +78 -0
- package/build/styles/ListItem/ListItem.css +8 -0
- package/build/styles/main.css +194 -0
- package/build/types/Body/Body.d.ts +2 -2
- package/build/types/CardContainer/CardContainer.d.ts +8 -0
- package/build/types/CardContainer/CardContainer.d.ts.map +1 -0
- package/build/types/CardContainer/CardContainer.types.d.ts +55 -0
- package/build/types/CardContainer/CardContainer.types.d.ts.map +1 -0
- package/build/types/CardContainer/_stories/helpers.d.ts +3 -0
- package/build/types/CardContainer/_stories/helpers.d.ts.map +1 -0
- package/build/types/CardContainer/index.d.ts +3 -0
- package/build/types/CardContainer/index.d.ts.map +1 -0
- package/build/types/CardWidget/CardWidget.d.ts +10 -0
- package/build/types/CardWidget/CardWidget.d.ts.map +1 -0
- package/build/types/CardWidget/CardWidget.types.d.ts +55 -0
- package/build/types/CardWidget/CardWidget.types.d.ts.map +1 -0
- package/build/types/CardWidget/CardWidgetAction.d.ts +54 -0
- package/build/types/CardWidget/CardWidgetAction.d.ts.map +1 -0
- package/build/types/CardWidget/index.d.ts +4 -0
- package/build/types/CardWidget/index.d.ts.map +1 -0
- package/build/types/IconButton/IconButton.d.ts +1 -1
- package/build/types/ListItem/ListItem.d.ts +1 -1
- package/build/types/ListItem/ListItem.d.ts.map +1 -1
- package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
- package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
- package/build/types/Title/Title.d.ts +2 -2
- package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
- package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
- package/build/types/index.d.ts +4 -0
- package/build/types/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/CardContainer/CardContainer.css +108 -0
- package/src/CardContainer/CardContainer.less +171 -0
- package/src/CardContainer/CardContainer.test.tsx +150 -0
- package/src/CardContainer/CardContainer.tsx +51 -0
- package/src/CardContainer/CardContainer.types.ts +62 -0
- package/src/CardContainer/_stories/CardContainer.story.tsx +167 -0
- package/src/CardContainer/_stories/CardContainer.test.story.tsx +103 -0
- package/src/CardContainer/_stories/helpers.tsx +50 -0
- package/src/CardContainer/index.ts +6 -0
- package/src/CardWidget/CardWidget.css +78 -0
- package/src/CardWidget/CardWidget.less +107 -0
- package/src/CardWidget/CardWidget.test.tsx +221 -0
- package/src/CardWidget/CardWidget.tsx +78 -0
- package/src/CardWidget/CardWidget.types.ts +63 -0
- package/src/CardWidget/CardWidgetAction.tsx +128 -0
- package/src/CardWidget/_stories/CardWidget.story.tsx +238 -0
- package/src/CardWidget/_stories/CardWidget.test.story.tsx +350 -0
- package/src/CardWidget/index.ts +3 -0
- package/src/ListItem/ListItem.css +8 -0
- package/src/ListItem/ListItem.less +8 -0
- package/src/ListItem/ListItem.test.tsx +20 -0
- package/src/ListItem/ListItem.tsx +3 -1
- package/src/ListItem/_stories/ListItem.layout.test.story.tsx +1 -1
- package/src/ListItem/_stories/ListItem.story.tsx +1 -1
- package/src/Prompt/CriticalBanner/_stories/CriticalBanner.docs.mdx +122 -0
- package/src/Prompt/CriticalBanner/_stories/CriticalBanner.story.tsx +0 -2
- package/src/index.ts +13 -0
- package/src/main.css +194 -0
- package/src/main.less +2 -0
- package/src/FlowNavigation/_stories/FlowNavigation.CriticalBanner.docs.mdx +0 -66
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { Plus, Settings } from '@transferwise/icons';
|
|
2
|
+
|
|
3
|
+
import { mockMatchMedia, render, screen, userEvent } from '../test-utils';
|
|
4
|
+
import CardWidget from './CardWidget';
|
|
5
|
+
import type { CardWidgetHeader } from './CardWidget.types';
|
|
6
|
+
|
|
7
|
+
mockMatchMedia();
|
|
8
|
+
|
|
9
|
+
describe('CardWidget', () => {
|
|
10
|
+
const content = 'Widget content';
|
|
11
|
+
|
|
12
|
+
const renderWidget = (header: Partial<CardWidgetHeader> = {}) =>
|
|
13
|
+
render(
|
|
14
|
+
<CardWidget data-testid="widget" header={{ title: 'Cards', ...header }}>
|
|
15
|
+
{content}
|
|
16
|
+
</CardWidget>,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe('rendering', () => {
|
|
20
|
+
it('renders children without a header', () => {
|
|
21
|
+
render(<CardWidget data-testid="widget">{content}</CardWidget>);
|
|
22
|
+
expect(screen.getByText(content)).toBeInTheDocument();
|
|
23
|
+
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
|
24
|
+
expect(screen.getByTestId('widget')).not.toHaveClass('wds-card-widget--with-header');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders on a CardContainer', () => {
|
|
28
|
+
render(
|
|
29
|
+
<CardWidget data-testid="widget" spotlight="active" as="article" className="custom">
|
|
30
|
+
{content}
|
|
31
|
+
</CardWidget>,
|
|
32
|
+
);
|
|
33
|
+
const root = screen.getByTestId('widget');
|
|
34
|
+
expect(root.tagName).toBe('ARTICLE');
|
|
35
|
+
expect(root).toHaveClass(
|
|
36
|
+
'wds-card-container',
|
|
37
|
+
'wds-card-container--active',
|
|
38
|
+
'wds-card-widget',
|
|
39
|
+
'custom',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('always keeps the default padding', () => {
|
|
44
|
+
// `padding` is not a CardWidget prop, but untyped consumers can still pass it.
|
|
45
|
+
const untypedProps = { padding: 'none' } as object;
|
|
46
|
+
render(
|
|
47
|
+
<CardWidget data-testid="widget" {...untypedProps}>
|
|
48
|
+
{content}
|
|
49
|
+
</CardWidget>,
|
|
50
|
+
);
|
|
51
|
+
expect(screen.getByTestId('widget')).not.toHaveClass('wds-card-container--padding-none');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('renders the title as an h3 by default', () => {
|
|
55
|
+
renderWidget();
|
|
56
|
+
expect(screen.getByRole('heading', { level: 3, name: 'Cards' })).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByTestId('widget')).toHaveClass('wds-card-widget--with-header');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('renders the title as the given heading level', () => {
|
|
61
|
+
renderWidget({ as: 'h2' });
|
|
62
|
+
expect(screen.getByRole('heading', { level: 2, name: 'Cards' })).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders the header without content', () => {
|
|
66
|
+
render(<CardWidget header={{ title: 'Cards' }} />);
|
|
67
|
+
expect(screen.getByRole('heading', { name: 'Cards' })).toBeInTheDocument();
|
|
68
|
+
expect(document.querySelector('.wds-card-widget__content')).not.toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('titleAction', () => {
|
|
73
|
+
it('renders the title as a link inside the heading', () => {
|
|
74
|
+
renderWidget({ titleAction: { href: '/cards' } });
|
|
75
|
+
const link = screen.getByRole('link', { name: 'Cards' });
|
|
76
|
+
expect(link).toHaveAttribute('href', '/cards');
|
|
77
|
+
expect(screen.getByRole('heading', { level: 3 })).toContainElement(link);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('calls the titleAction onClick handler', async () => {
|
|
81
|
+
const onClick = jest.fn();
|
|
82
|
+
renderWidget({ titleAction: { href: '#cards', onClick } });
|
|
83
|
+
await userEvent.click(screen.getByRole('link', { name: 'Cards' }));
|
|
84
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('renders the title as a button when titleAction has only onClick', async () => {
|
|
88
|
+
const onClick = jest.fn();
|
|
89
|
+
renderWidget({ titleAction: { onClick } });
|
|
90
|
+
await userEvent.click(screen.getByRole('button', { name: 'Cards' }));
|
|
91
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('marks a title link that opens in a new tab', () => {
|
|
95
|
+
renderWidget({ titleAction: { href: '/cards', target: '_blank' } });
|
|
96
|
+
expect(screen.getByRole('link', { name: /Cards/u })).toHaveAttribute('rel', 'noreferrer');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('keeps the title link alongside actions', () => {
|
|
100
|
+
renderWidget({ titleAction: { href: '/cards' }, action: { label: 'Order card' } });
|
|
101
|
+
expect(screen.getByRole('link', { name: 'Cards' })).toBeInTheDocument();
|
|
102
|
+
expect(screen.getByRole('button', { name: 'Order card' })).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('states', () => {
|
|
107
|
+
it("always follows the title link, instead of the CardContainer's own states", () => {
|
|
108
|
+
// `interactive` is not a CardWidget prop, but untyped consumers can still pass it.
|
|
109
|
+
const untypedProps = { interactive: true } as object;
|
|
110
|
+
render(
|
|
111
|
+
<CardWidget
|
|
112
|
+
data-testid="widget"
|
|
113
|
+
header={{ title: 'Cards', titleAction: { href: '/cards' } }}
|
|
114
|
+
{...untypedProps}
|
|
115
|
+
>
|
|
116
|
+
{content}
|
|
117
|
+
</CardWidget>,
|
|
118
|
+
);
|
|
119
|
+
const root = screen.getByTestId('widget');
|
|
120
|
+
expect(root).not.toHaveClass('wds-card-container--interactive');
|
|
121
|
+
expect(root).not.toHaveAttribute('interactive');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('actions', () => {
|
|
126
|
+
it('renders a labelled action as a button', () => {
|
|
127
|
+
renderWidget({ action: { label: 'Order card' } });
|
|
128
|
+
expect(screen.getByRole('button', { name: 'Order card' })).toBeInTheDocument();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('renders a labelled action with an href as a link', () => {
|
|
132
|
+
renderWidget({ action: { label: 'Order card', href: '/cards/order' } });
|
|
133
|
+
expect(screen.getByRole('link', { name: 'Order card' })).toHaveAttribute(
|
|
134
|
+
'href',
|
|
135
|
+
'/cards/order',
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('names an unlabelled action from its aria-label', () => {
|
|
140
|
+
renderWidget({ action: { 'aria-label': 'Card settings', icon: <Settings size={16} /> } });
|
|
141
|
+
expect(screen.getByRole('button', { name: 'Card settings' })).toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('calls the action onClick handler', async () => {
|
|
145
|
+
const onClick = jest.fn();
|
|
146
|
+
renderWidget({ action: { label: 'Order card', onClick } });
|
|
147
|
+
await userEvent.click(screen.getByRole('button', { name: 'Order card' }));
|
|
148
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does not submit a surrounding form', async () => {
|
|
152
|
+
const onSubmit = jest.fn((event: React.FormEvent) => event.preventDefault());
|
|
153
|
+
render(
|
|
154
|
+
<form method="post" onSubmit={onSubmit}>
|
|
155
|
+
<CardWidget header={{ title: 'Cards', action: { label: 'Order card' } }} />
|
|
156
|
+
</form>,
|
|
157
|
+
);
|
|
158
|
+
const button = screen.getByRole('button', { name: 'Order card' });
|
|
159
|
+
expect(button).toHaveAttribute('type', 'button');
|
|
160
|
+
await userEvent.click(button);
|
|
161
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('does not call onClick when disabled', async () => {
|
|
165
|
+
const onClick = jest.fn();
|
|
166
|
+
renderWidget({ action: { label: 'Order card', disabled: true, onClick } });
|
|
167
|
+
await userEvent.click(screen.getByRole('button', { name: 'Order card' }));
|
|
168
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('renders actionSecondary before action in the DOM', () => {
|
|
172
|
+
renderWidget({ action: { label: 'Order card' }, actionSecondary: { label: 'Manage' } });
|
|
173
|
+
const [first, second] = screen.getAllByRole('button');
|
|
174
|
+
expect(first).toHaveAccessibleName('Manage');
|
|
175
|
+
expect(second).toHaveAccessibleName('Order card');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('takes button priority from the slot the action is passed to', () => {
|
|
179
|
+
renderWidget({ action: { label: 'Order card' }, actionSecondary: { label: 'Manage' } });
|
|
180
|
+
expect(screen.getByRole('button', { name: 'Order card' })).toHaveClass(
|
|
181
|
+
'wds-Button--secondary',
|
|
182
|
+
);
|
|
183
|
+
expect(screen.getByRole('button', { name: 'Manage' })).toHaveClass(
|
|
184
|
+
'wds-Button--secondary-neutral',
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('takes icon button priority from the slot the action is passed to', () => {
|
|
189
|
+
renderWidget({
|
|
190
|
+
action: { 'aria-label': 'Add', icon: <Plus size={16} /> },
|
|
191
|
+
actionSecondary: { 'aria-label': 'Settings', icon: <Settings size={16} /> },
|
|
192
|
+
});
|
|
193
|
+
expect(screen.getByRole('button', { name: 'Add' })).toHaveClass(
|
|
194
|
+
'np-icon-button-secondary-default',
|
|
195
|
+
);
|
|
196
|
+
expect(screen.getByRole('button', { name: 'Settings' })).toHaveClass(
|
|
197
|
+
'np-icon-button-tertiary-default',
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('keeps the lower emphasis when actionSecondary is used on its own', () => {
|
|
202
|
+
renderWidget({ actionSecondary: { label: 'Manage' } });
|
|
203
|
+
expect(screen.getByRole('button', { name: 'Manage' })).toHaveClass(
|
|
204
|
+
'wds-Button--secondary-neutral',
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('renders an arbitrary node in place of the action buttons', () => {
|
|
209
|
+
renderWidget({ action: <span>Custom node</span> });
|
|
210
|
+
expect(screen.getByText('Custom node')).toBeInTheDocument();
|
|
211
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('renders an element as given, even when it has a label prop', () => {
|
|
215
|
+
const CustomAction = ({ label }: { label: string }) => <span>{label}</span>;
|
|
216
|
+
renderWidget({ action: <CustomAction label="Custom action" /> });
|
|
217
|
+
expect(screen.getByText('Custom action')).toBeInTheDocument();
|
|
218
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ChevronEnd } from '@transferwise/icons';
|
|
2
|
+
import { clsx } from 'clsx';
|
|
3
|
+
import { forwardRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import CardContainer from '../CardContainer';
|
|
6
|
+
import { Typography } from '../common';
|
|
7
|
+
import Link from '../Link';
|
|
8
|
+
import Title from '../Title';
|
|
9
|
+
import type { CardWidgetHeader, CardWidgetProps } from './CardWidget.types';
|
|
10
|
+
import CardWidgetAction, {
|
|
11
|
+
isCardWidgetActionButton,
|
|
12
|
+
type CardWidgetActionSlot,
|
|
13
|
+
} from './CardWidgetAction';
|
|
14
|
+
|
|
15
|
+
const renderAction = (action: CardWidgetHeader['action'], slot: CardWidgetActionSlot) =>
|
|
16
|
+
isCardWidgetActionButton(action) ? <CardWidgetAction action={action} slot={slot} /> : action;
|
|
17
|
+
|
|
18
|
+
const CardWidgetHeaderComponent = ({
|
|
19
|
+
title,
|
|
20
|
+
as = 'h3',
|
|
21
|
+
titleAction,
|
|
22
|
+
action,
|
|
23
|
+
actionSecondary,
|
|
24
|
+
}: CardWidgetHeader) => (
|
|
25
|
+
<div className="wds-card-widget__header">
|
|
26
|
+
<Title as={as} type={Typography.TITLE_GROUP} className="wds-card-widget__title">
|
|
27
|
+
{titleAction ? (
|
|
28
|
+
<Link
|
|
29
|
+
href={titleAction.href}
|
|
30
|
+
target={titleAction.target}
|
|
31
|
+
className="wds-card-widget__title-link"
|
|
32
|
+
onClick={titleAction.onClick}
|
|
33
|
+
>
|
|
34
|
+
{title}
|
|
35
|
+
{/* The word joiner keeps the chevron on the title's last line instead of wrapping alone. */}
|
|
36
|
+
<span className="wds-card-widget__title-chevron" aria-hidden>
|
|
37
|
+
{'\u2060'}
|
|
38
|
+
<ChevronEnd size={16} />
|
|
39
|
+
</span>
|
|
40
|
+
</Link>
|
|
41
|
+
) : (
|
|
42
|
+
title
|
|
43
|
+
)}
|
|
44
|
+
</Title>
|
|
45
|
+
{(action || actionSecondary) && (
|
|
46
|
+
<div className="wds-card-widget__actions">
|
|
47
|
+
{actionSecondary && renderAction(actionSecondary, 'actionSecondary')}
|
|
48
|
+
{action && renderAction(action, 'action')}
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A `CardContainer` with an optional header — a title, a title link and up to two actions — above
|
|
56
|
+
* its content. The card shows as hovered or pressed while the title link is, and as hovered while
|
|
57
|
+
* the link has keyboard focus. Hovering or pressing the rest of the card, or its actions, leaves it
|
|
58
|
+
* at rest.
|
|
59
|
+
*/
|
|
60
|
+
const CardWidget = forwardRef<HTMLElement, CardWidgetProps>(
|
|
61
|
+
({ header, children, className, ...props }, ref) => (
|
|
62
|
+
<CardContainer
|
|
63
|
+
ref={ref}
|
|
64
|
+
className={clsx('wds-card-widget', header && 'wds-card-widget--with-header', className)}
|
|
65
|
+
{...props}
|
|
66
|
+
// The widget's states follow its title link alone, so the card's own are left off.
|
|
67
|
+
interactive={false}
|
|
68
|
+
padding="md"
|
|
69
|
+
>
|
|
70
|
+
{header && <CardWidgetHeaderComponent {...header} />}
|
|
71
|
+
{children != null && <div className="wds-card-widget__content">{children}</div>}
|
|
72
|
+
</CardContainer>
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
CardWidget.displayName = 'CardWidget';
|
|
77
|
+
|
|
78
|
+
export default CardWidget;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { Heading } from '../common';
|
|
4
|
+
import type { CardContainerProps } from '../CardContainer';
|
|
5
|
+
import type { LinkProps } from '../Link';
|
|
6
|
+
import type { CardWidgetActionButton } from './CardWidgetAction';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Props passed to the [Link](https://storybook.wise.design/?path=/docs/typography-link--docs)
|
|
10
|
+
* that the title is rendered as. A trailing chevron is rendered automatically, so do not put one
|
|
11
|
+
* in `title`.
|
|
12
|
+
*/
|
|
13
|
+
export type CardWidgetTitleAction = Pick<LinkProps, 'href' | 'target' | 'onClick'>;
|
|
14
|
+
|
|
15
|
+
export interface CardWidgetHeader {
|
|
16
|
+
/** The widget's title. */
|
|
17
|
+
title: ReactNode;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Heading element rendered for the title.
|
|
21
|
+
* @default 'h3'
|
|
22
|
+
*/
|
|
23
|
+
as?: Heading;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Makes the title a link with a trailing chevron.
|
|
27
|
+
* @default undefined
|
|
28
|
+
*/
|
|
29
|
+
titleAction?: CardWidgetTitleAction;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A `CardWidgetActionButton` object (with a `label` or an `icon`) renders a small button,
|
|
33
|
+
* anchored to the inline end. Any other node is rendered as given.
|
|
34
|
+
* @default undefined
|
|
35
|
+
*/
|
|
36
|
+
action?: CardWidgetActionButton | ReactNode;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Second action, at a lower emphasis, rendered before `action`. Two is the maximum, to keep
|
|
40
|
+
* the title legible. Takes the same values as `action`, and keeps its lower emphasis when used
|
|
41
|
+
* on its own.
|
|
42
|
+
* @default undefined
|
|
43
|
+
*/
|
|
44
|
+
actionSecondary?: CardWidgetActionButton | ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The widget always has the card's default padding, so its header and content line up, and its
|
|
49
|
+
* states always follow its title link.
|
|
50
|
+
*/
|
|
51
|
+
export interface CardWidgetProps extends Omit<CardContainerProps, 'padding' | 'interactive'> {
|
|
52
|
+
/**
|
|
53
|
+
* The widget's header: a title, optionally a link, and up to two actions.
|
|
54
|
+
* @default undefined
|
|
55
|
+
*/
|
|
56
|
+
header?: CardWidgetHeader;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The widget's content, rendered under the header.
|
|
60
|
+
* @default undefined
|
|
61
|
+
*/
|
|
62
|
+
children?: ReactNode;
|
|
63
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { isValidElement, type MouseEventHandler, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import Button from '../Button';
|
|
4
|
+
import type { ButtonPriority, ButtonProps } from '../Button/Button.types';
|
|
5
|
+
import IconButton, { type IconButtonProps } from '../IconButton';
|
|
6
|
+
|
|
7
|
+
/** The action slot a `CardWidgetActionButton` was passed to, which is what determines its emphasis. */
|
|
8
|
+
export type CardWidgetActionSlot = 'action' | 'actionSecondary';
|
|
9
|
+
|
|
10
|
+
type CardWidgetActionButtonBase = Pick<ButtonProps, 'href' | 'target' | 'disabled'> & {
|
|
11
|
+
/**
|
|
12
|
+
* `HTMLElement` rather than a narrower target, because an action is a `button` or an `a`
|
|
13
|
+
* depending on whether `href` is set.
|
|
14
|
+
* @default undefined
|
|
15
|
+
*/
|
|
16
|
+
onClick?: MouseEventHandler<HTMLElement>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Rendered after `label`, or as the only content of an unlabelled action.
|
|
20
|
+
* Size it yourself — 16px is expected in both cases.
|
|
21
|
+
* @default undefined
|
|
22
|
+
*/
|
|
23
|
+
icon?: ReactNode;
|
|
24
|
+
'data-testid'?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** An action with a visible label, rendered as a small button. */
|
|
28
|
+
type CardWidgetLabelledActionButton = CardWidgetActionButtonBase & {
|
|
29
|
+
/** Non-rendering values are excluded, so an unlabelled action has to meet the icon-only rules. */
|
|
30
|
+
label: Exclude<ButtonProps['children'], null | undefined | boolean>;
|
|
31
|
+
/** @default undefined */
|
|
32
|
+
'aria-label'?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An action with no visible label, rendered as a 32px icon button. The icon is
|
|
37
|
+
* the only thing on screen and `aria-label` is the only accessible name, so
|
|
38
|
+
* both are required.
|
|
39
|
+
*/
|
|
40
|
+
type CardWidgetIconActionButton = CardWidgetActionButtonBase & {
|
|
41
|
+
label?: never;
|
|
42
|
+
icon: ReactNode;
|
|
43
|
+
'aria-label': string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* An action the `CardWidget` header renders as a button. With a `label` it is a small `Button`; without
|
|
48
|
+
* one, a 32px `IconButton`, which requires both an `icon` and an `aria-label`. Emphasis is not
|
|
49
|
+
* part of the object — it comes from the slot the action is passed to.
|
|
50
|
+
*/
|
|
51
|
+
export type CardWidgetActionButton = CardWidgetLabelledActionButton | CardWidgetIconActionButton;
|
|
52
|
+
|
|
53
|
+
const BUTTON_PRIORITY = {
|
|
54
|
+
action: 'secondary',
|
|
55
|
+
actionSecondary: 'secondary-neutral',
|
|
56
|
+
} as const satisfies Record<CardWidgetActionSlot, ButtonPriority>;
|
|
57
|
+
|
|
58
|
+
const ICON_BUTTON_PRIORITY = {
|
|
59
|
+
action: 'secondary',
|
|
60
|
+
actionSecondary: 'tertiary',
|
|
61
|
+
} as const satisfies Record<CardWidgetActionSlot, IconButtonProps['priority']>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tells a `CardWidgetActionButton` object apart from any other node passed to an action slot.
|
|
65
|
+
* Every button action has a `label` or an `icon`, which no React element, portal or array does.
|
|
66
|
+
*/
|
|
67
|
+
export const isCardWidgetActionButton = (value: unknown): value is CardWidgetActionButton =>
|
|
68
|
+
typeof value === 'object' &&
|
|
69
|
+
value !== null &&
|
|
70
|
+
!isValidElement(value) &&
|
|
71
|
+
!Array.isArray(value) &&
|
|
72
|
+
('label' in value || 'icon' in value);
|
|
73
|
+
|
|
74
|
+
/** Renders a `CardWidgetActionButton` at the emphasis of the slot it was passed to. */
|
|
75
|
+
const CardWidgetAction = function CardWidgetAction({
|
|
76
|
+
action: {
|
|
77
|
+
label,
|
|
78
|
+
icon,
|
|
79
|
+
'aria-label': ariaLabel,
|
|
80
|
+
'data-testid': dataTestId,
|
|
81
|
+
href,
|
|
82
|
+
target,
|
|
83
|
+
onClick,
|
|
84
|
+
disabled,
|
|
85
|
+
},
|
|
86
|
+
slot,
|
|
87
|
+
}: {
|
|
88
|
+
action: CardWidgetActionButton;
|
|
89
|
+
slot: CardWidgetActionSlot;
|
|
90
|
+
}) {
|
|
91
|
+
if (label === undefined) {
|
|
92
|
+
return (
|
|
93
|
+
<IconButton
|
|
94
|
+
size={32}
|
|
95
|
+
priority={ICON_BUTTON_PRIORITY[slot]}
|
|
96
|
+
aria-label={ariaLabel}
|
|
97
|
+
href={href}
|
|
98
|
+
target={target}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
data-testid={dataTestId}
|
|
101
|
+
onClick={onClick}
|
|
102
|
+
>
|
|
103
|
+
{icon}
|
|
104
|
+
</IconButton>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Button
|
|
110
|
+
v2
|
|
111
|
+
size="sm"
|
|
112
|
+
priority={BUTTON_PRIORITY[slot]}
|
|
113
|
+
aria-label={ariaLabel}
|
|
114
|
+
href={href}
|
|
115
|
+
target={target}
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
addonEnd={icon ? { type: 'icon', value: icon } : undefined}
|
|
118
|
+
// The v2 resolver drops `Button`'s own `button` default, so an action would submit a form.
|
|
119
|
+
type="button"
|
|
120
|
+
data-testid={dataTestId}
|
|
121
|
+
onClick={onClick}
|
|
122
|
+
>
|
|
123
|
+
{label}
|
|
124
|
+
</Button>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export default CardWidgetAction;
|