@12-apps/ui 1.12.0 → 1.13.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/package.json +4 -4
- package/src/components/data-display/DataViews/base-card.tsx +61 -28
- package/src/components/data-display/DataViews/base-list-card-gutters.tsx +90 -0
- package/src/components/data-display/DataViews/base-list-card-slots.tsx +98 -22
- package/src/components/data-display/DataViews/base-list-card.tsx +121 -84
- package/src/components/data-display/DataViews/card-surface.ts +93 -12
- package/src/components/data-display/DataViews/list-card-rails.tsx +116 -8
- package/src/components/form/Button/Button.styles.ts +37 -2
- package/src/components/form/Button/Button.tsx +21 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"packageManager": "pnpm@9.0.0",
|
|
6
6
|
"type": "module",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
195
195
|
},
|
|
196
196
|
"dependencies": {
|
|
197
|
-
"@12-apps/forms-core": "^1.
|
|
197
|
+
"@12-apps/forms-core": "^1.12.0",
|
|
198
198
|
"@emotion/react": "^11.14.0",
|
|
199
199
|
"@emotion/styled": "^11.14.0",
|
|
200
200
|
"@monaco-editor/react": "^4.7.0",
|
|
@@ -209,8 +209,8 @@
|
|
|
209
209
|
"recharts": "^3.5.1"
|
|
210
210
|
},
|
|
211
211
|
"devDependencies": {
|
|
212
|
-
"@12-apps/eslint-config": "^1.
|
|
213
|
-
"@12-apps/typescript-config": "^1.
|
|
212
|
+
"@12-apps/eslint-config": "^1.13.0",
|
|
213
|
+
"@12-apps/typescript-config": "^1.13.0",
|
|
214
214
|
"@playwright/test": "^1.55.0",
|
|
215
215
|
"@storybook/addon-docs": "^9.1.10",
|
|
216
216
|
"@storybook/addon-links": "^9.1.10",
|
|
@@ -14,6 +14,7 @@ import { Text } from "../../typography/Text";
|
|
|
14
14
|
|
|
15
15
|
import { DragHandle, useDragItem } from "./data-views-drag";
|
|
16
16
|
import {
|
|
17
|
+
CARD_RADIUS,
|
|
17
18
|
cardSurfaceStyles,
|
|
18
19
|
isActionable,
|
|
19
20
|
isSelectable,
|
|
@@ -28,9 +29,11 @@ export interface BaseCardProps extends CardSurfaceProps {
|
|
|
28
29
|
/** Tile proportion. Defaults to "4:3". */
|
|
29
30
|
aspectRatio?: CardAspectRatio;
|
|
30
31
|
/**
|
|
31
|
-
* Size multiplier from the zoom slider (1 = base).
|
|
32
|
-
*
|
|
33
|
-
* so the whole card grows together —
|
|
32
|
+
* Size multiplier from the zoom slider (1 = base).
|
|
33
|
+
*
|
|
34
|
+
* Applied as `zoom`, so the whole card grows together — padding, typography,
|
|
35
|
+
* the checkbox, the menu and the media alike — in step with its width, which
|
|
36
|
+
* the grid sizes to base × scale. A card at scale 2 renders at ~twice the
|
|
34
37
|
* size, its proportion preserved by `aspectRatio`. Default 1.
|
|
35
38
|
*/
|
|
36
39
|
scale?: number;
|
|
@@ -58,16 +61,26 @@ export interface BaseCardProps extends CardSurfaceProps {
|
|
|
58
61
|
checkboxTestId?: string;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The outlined-tile style: fixed ratio, scaled padding, selected/dimmed states.
|
|
66
|
+
*
|
|
67
|
+
* See the note on the row: `zoom` scales EVERY length in the subtree, including
|
|
68
|
+
* the MUI internals (checkbox, chip, icon button) that no prop of ours reaches.
|
|
69
|
+
* The grid still sizes the track to BASE_CARD_WIDTH x scale, so the tile fills
|
|
70
|
+
* its track physically and lays its contents out in the unscaled box behind it
|
|
71
|
+
* — which is what "the whole card grows together" was always meant to mean.
|
|
72
|
+
*/
|
|
62
73
|
function cardSx(opts: {
|
|
63
74
|
aspectRatio: CardAspectRatio;
|
|
64
75
|
pad: number;
|
|
76
|
+
scale: number;
|
|
65
77
|
interactive: boolean;
|
|
66
78
|
draggable: boolean;
|
|
67
79
|
}): CardSx {
|
|
68
|
-
const { aspectRatio, pad, interactive, draggable } = opts;
|
|
80
|
+
const { aspectRatio, pad, scale, interactive, draggable } = opts;
|
|
69
81
|
return {
|
|
70
82
|
position: "relative",
|
|
83
|
+
zoom: scale,
|
|
71
84
|
aspectRatio: String(CARD_ASPECT_RATIOS[aspectRatio]),
|
|
72
85
|
display: "flex",
|
|
73
86
|
flexDirection: "column",
|
|
@@ -78,6 +91,35 @@ function cardSx(opts: {
|
|
|
78
91
|
};
|
|
79
92
|
}
|
|
80
93
|
|
|
94
|
+
/**
|
|
95
|
+
* How far the first block in flow must start below the tile's top edge to clear
|
|
96
|
+
* the absolute overlays: their 4px inset plus the checkbox's 38px hit area (20px
|
|
97
|
+
* glyph in MUI's 9px padding), which is the taller of the two corners.
|
|
98
|
+
*/
|
|
99
|
+
const OVERLAY_BAND_PX = 42;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The top margin each stacked slot needs, in MUI spacing units.
|
|
103
|
+
*
|
|
104
|
+
* The overlays are absolute, so they cost the flow nothing — over MEDIA that is
|
|
105
|
+
* the whole point (a checkbox on a thumbnail reads as a checkbox on a
|
|
106
|
+
* thumbnail). Over TEXT it is a collision: a title-only tile started its first
|
|
107
|
+
* line at the padding edge, directly under the checkbox, and rendered
|
|
108
|
+
* `Selecionada` with the box sitting on the `Se`. So when nothing pictorial is
|
|
109
|
+
* there to absorb them, whichever block comes first clears the band instead.
|
|
110
|
+
*/
|
|
111
|
+
function contentGaps(opts: {
|
|
112
|
+
hasMedia: boolean;
|
|
113
|
+
hasBody: boolean;
|
|
114
|
+
overlays: boolean;
|
|
115
|
+
pad: number;
|
|
116
|
+
}): { body: number; caption: number } {
|
|
117
|
+
const { hasMedia, hasBody, overlays, pad } = opts;
|
|
118
|
+
const gap = pad * 0.5;
|
|
119
|
+
const clear = !hasMedia && overlays ? Math.max(0, OVERLAY_BAND_PX / 8 - pad) : 0;
|
|
120
|
+
return { body: hasMedia ? gap : clear, caption: hasMedia || hasBody ? gap : clear };
|
|
121
|
+
}
|
|
122
|
+
|
|
81
123
|
/** The top-corner overlays: the select checkbox (left) and the menu slot (right). */
|
|
82
124
|
function CardOverlays({
|
|
83
125
|
selected,
|
|
@@ -141,12 +183,10 @@ function CardMedia({ image, imageFallback }: Pick<BaseCardProps, "image" | "imag
|
|
|
141
183
|
function CardBodyContent({
|
|
142
184
|
children,
|
|
143
185
|
fill,
|
|
144
|
-
scale,
|
|
145
186
|
topGap,
|
|
146
187
|
}: {
|
|
147
188
|
children: ReactNode;
|
|
148
189
|
fill: boolean;
|
|
149
|
-
scale: number;
|
|
150
190
|
topGap: number;
|
|
151
191
|
}): React.JSX.Element {
|
|
152
192
|
return (
|
|
@@ -158,7 +198,7 @@ function CardBodyContent({
|
|
|
158
198
|
flexDirection: "column",
|
|
159
199
|
alignItems: "center",
|
|
160
200
|
justifyContent: "center",
|
|
161
|
-
gap: 0.75
|
|
201
|
+
gap: 0.75,
|
|
162
202
|
mt: topGap,
|
|
163
203
|
overflow: "hidden",
|
|
164
204
|
}}
|
|
@@ -172,12 +212,10 @@ function CardBodyContent({
|
|
|
172
212
|
function CardCaption({
|
|
173
213
|
title,
|
|
174
214
|
subtitle,
|
|
175
|
-
scale,
|
|
176
215
|
topGap,
|
|
177
216
|
}: {
|
|
178
217
|
title?: ReactNode;
|
|
179
218
|
subtitle?: ReactNode;
|
|
180
|
-
scale: number;
|
|
181
219
|
topGap: number;
|
|
182
220
|
}): React.JSX.Element {
|
|
183
221
|
return (
|
|
@@ -190,7 +228,7 @@ function CardCaption({
|
|
|
190
228
|
as="p"
|
|
191
229
|
style={{
|
|
192
230
|
lineHeight: 1.15,
|
|
193
|
-
fontSize:
|
|
231
|
+
fontSize: "0.95rem",
|
|
194
232
|
overflow: "hidden",
|
|
195
233
|
textOverflow: "ellipsis",
|
|
196
234
|
whiteSpace: "nowrap",
|
|
@@ -215,32 +253,25 @@ function CardContents({
|
|
|
215
253
|
imageFallback,
|
|
216
254
|
title,
|
|
217
255
|
subtitle,
|
|
218
|
-
|
|
256
|
+
overlays,
|
|
219
257
|
pad,
|
|
220
258
|
}: Pick<BaseCardProps, "children" | "image" | "imageFallback" | "title" | "subtitle"> & {
|
|
221
|
-
|
|
259
|
+
overlays: boolean;
|
|
222
260
|
pad: number;
|
|
223
261
|
}): React.JSX.Element {
|
|
224
262
|
const hasMedia = image != null || imageFallback != null;
|
|
225
263
|
const hasBody = children != null;
|
|
226
264
|
const hasCaption = title != null || subtitle != null;
|
|
227
|
-
const
|
|
265
|
+
const top = contentGaps({ hasMedia, hasBody, overlays, pad });
|
|
228
266
|
return (
|
|
229
267
|
<>
|
|
230
268
|
{hasMedia && <CardMedia image={image} imageFallback={imageFallback} />}
|
|
231
269
|
{hasBody && (
|
|
232
|
-
<CardBodyContent fill={!hasMedia}
|
|
270
|
+
<CardBodyContent fill={!hasMedia} topGap={top.body}>
|
|
233
271
|
{children}
|
|
234
272
|
</CardBodyContent>
|
|
235
273
|
)}
|
|
236
|
-
{hasCaption &&
|
|
237
|
-
<CardCaption
|
|
238
|
-
title={title}
|
|
239
|
-
subtitle={subtitle}
|
|
240
|
-
scale={scale}
|
|
241
|
-
topGap={hasMedia || hasBody ? gap : 0}
|
|
242
|
-
/>
|
|
243
|
-
)}
|
|
274
|
+
{hasCaption && <CardCaption title={title} subtitle={subtitle} topGap={top.caption} />}
|
|
244
275
|
</>
|
|
245
276
|
);
|
|
246
277
|
}
|
|
@@ -275,12 +306,11 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
|
|
|
275
306
|
const { selectable, actionable, theme, slot, scale, drag } = useCardShell(props);
|
|
276
307
|
const dataTestId = props.testId;
|
|
277
308
|
const checkboxTestId = slot("checkbox");
|
|
278
|
-
const pad = 1.5
|
|
309
|
+
const pad = 1.5;
|
|
279
310
|
|
|
280
311
|
return (
|
|
281
312
|
<Card
|
|
282
313
|
variant="outlined"
|
|
283
|
-
borderRadius="lg"
|
|
284
314
|
onClick={onClick}
|
|
285
315
|
dataTestId={dataTestId}
|
|
286
316
|
className={props.className}
|
|
@@ -292,6 +322,10 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
|
|
|
292
322
|
// nothing at all — the styles vanish silently, which is exactly how the
|
|
293
323
|
// first version of this shipped with no variants and no effects.
|
|
294
324
|
sx={{
|
|
325
|
+
// The same corner the row uses: a Grade and a Lista of the same records
|
|
326
|
+
// sit beside each other, and a tile that rounds harder than the row
|
|
327
|
+
// reads as a different component.
|
|
328
|
+
borderRadius: CARD_RADIUS,
|
|
295
329
|
// The shared half — variant, colour, selection, effects — identical to
|
|
296
330
|
// the one BaseListCard uses, so a Grade and a Lista of the same records
|
|
297
331
|
// are dressed by one set of rules.
|
|
@@ -308,8 +342,7 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
|
|
|
308
342
|
),
|
|
309
343
|
// …and the tile's own geometry on top.
|
|
310
344
|
...cardSx({
|
|
311
|
-
aspectRatio,
|
|
312
|
-
pad,
|
|
345
|
+
aspectRatio, pad, scale,
|
|
313
346
|
interactive: onClick != null,
|
|
314
347
|
draggable: drag.draggable && drag.handleProps === undefined,
|
|
315
348
|
}),
|
|
@@ -338,7 +371,7 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
|
|
|
338
371
|
imageFallback={props.imageFallback}
|
|
339
372
|
title={props.title}
|
|
340
373
|
subtitle={props.subtitle}
|
|
341
|
-
|
|
374
|
+
overlays={selectable || menu != null}
|
|
342
375
|
pad={pad}
|
|
343
376
|
>
|
|
344
377
|
{props.children}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Checkbox } from "../../form/Checkbox";
|
|
4
|
+
import { Box } from "../../../mui/Box";
|
|
5
|
+
|
|
6
|
+
import { DragHandle, useDragItem } from "./data-views-drag";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* THE TWO GUTTERS AT THE HEAD OF A ROW — the drag grip and the select checkbox.
|
|
10
|
+
*
|
|
11
|
+
* Split from `base-list-card` at the size gate. Both answer the same question in
|
|
12
|
+
* the same way: whether to occupy a rail, hold one open, or leave the template
|
|
13
|
+
* altogether. See `railsTemplateFor` for why the third option has to exist.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* MUI's Checkbox carries 9px of hit-area padding on every side.
|
|
18
|
+
*
|
|
19
|
+
* Left uncancelled, a row asking for 8px of padding puts the checkbox SQUARE 17px
|
|
20
|
+
* in — and with the empty drag gutter's gap ahead of it, 46px in, which is what
|
|
21
|
+
* made the left edge look like a mistake. The hit area is worth keeping; the
|
|
22
|
+
* optical inset is not, so the padding is cancelled with a negative margin and
|
|
23
|
+
* the glyph lands where the row's own padding says it should.
|
|
24
|
+
*/
|
|
25
|
+
const CHECKBOX_PAD = "-9px";
|
|
26
|
+
|
|
27
|
+
/** The grip. Its gutter is reserved only when the list says to reserve it. */
|
|
28
|
+
export function DragSlot({
|
|
29
|
+
drag,
|
|
30
|
+
reserve,
|
|
31
|
+
testId,
|
|
32
|
+
}: {
|
|
33
|
+
drag: ReturnType<typeof useDragItem>;
|
|
34
|
+
reserve: boolean;
|
|
35
|
+
testId?: string;
|
|
36
|
+
}): React.JSX.Element | null {
|
|
37
|
+
if (!drag.draggable) return reserve ? <Box data-slot="drag" /> : null;
|
|
38
|
+
return (
|
|
39
|
+
// display:flex, as on the checkbox below: the grip is an inline-grid, and in
|
|
40
|
+
// a block parent an inline box sits on the text baseline — the line box adds
|
|
41
|
+
// descender leading under it (21px glyph in a 31px slot) and drops the grip
|
|
42
|
+
// 5px below the row's centre. A flex parent has no baseline to sit on.
|
|
43
|
+
<Box
|
|
44
|
+
data-slot="drag"
|
|
45
|
+
sx={{ position: "relative", zIndex: 1, display: "flex", alignItems: "center" }}
|
|
46
|
+
>
|
|
47
|
+
<DragHandle
|
|
48
|
+
handleProps={drag.handleProps}
|
|
49
|
+
gated={drag.handleProps !== undefined}
|
|
50
|
+
testId={testId}
|
|
51
|
+
/>
|
|
52
|
+
</Box>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The select checkbox. Its gutter is reserved only when the list says to. */
|
|
57
|
+
export function SelectSlot({
|
|
58
|
+
selectable,
|
|
59
|
+
selected,
|
|
60
|
+
reserve,
|
|
61
|
+
onToggleSelect,
|
|
62
|
+
testId,
|
|
63
|
+
}: {
|
|
64
|
+
selectable: boolean;
|
|
65
|
+
selected: boolean;
|
|
66
|
+
reserve: boolean;
|
|
67
|
+
onToggleSelect?: (event?: React.MouseEvent) => void;
|
|
68
|
+
testId?: string;
|
|
69
|
+
}): React.JSX.Element | null {
|
|
70
|
+
if (!selectable) return reserve ? <Box data-slot="select" /> : null;
|
|
71
|
+
return (
|
|
72
|
+
// z-index 1: above the stretched link, or the anchor swallows the click.
|
|
73
|
+
<Box
|
|
74
|
+
data-slot="select"
|
|
75
|
+
sx={{ position: "relative", zIndex: 1, mx: CHECKBOX_PAD, display: "flex" }}
|
|
76
|
+
>
|
|
77
|
+
<Checkbox
|
|
78
|
+
checked={selected}
|
|
79
|
+
onChange={() => onToggleSelect?.()}
|
|
80
|
+
onClick={(event) => {
|
|
81
|
+
event.stopPropagation();
|
|
82
|
+
onToggleSelect?.(event);
|
|
83
|
+
}}
|
|
84
|
+
size="small"
|
|
85
|
+
data-testid={testId}
|
|
86
|
+
aria-label="Selecionar"
|
|
87
|
+
/>
|
|
88
|
+
</Box>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { type ReactNode } from "react";
|
|
3
|
+
import { Fragment, type ReactNode } from "react";
|
|
4
4
|
|
|
5
5
|
import { DescriptionItem, type DescriptionItemProps } from "../DescriptionItem";
|
|
6
|
+
import { RAIL_GAP_PX } from "./list-card-rails";
|
|
6
7
|
import { Box } from "../../../mui/Box";
|
|
7
8
|
import { Text } from "../../typography/Text";
|
|
8
9
|
|
|
@@ -41,14 +42,12 @@ export function ListCardCaption({
|
|
|
41
42
|
subtitle,
|
|
42
43
|
href,
|
|
43
44
|
target,
|
|
44
|
-
scale,
|
|
45
45
|
testId,
|
|
46
46
|
}: {
|
|
47
47
|
title?: ReactNode;
|
|
48
48
|
subtitle?: ReactNode;
|
|
49
49
|
href?: string;
|
|
50
50
|
target?: string;
|
|
51
|
-
scale: number;
|
|
52
51
|
testId?: string;
|
|
53
52
|
}): React.JSX.Element {
|
|
54
53
|
return (
|
|
@@ -61,7 +60,7 @@ export function ListCardCaption({
|
|
|
61
60
|
sx={{
|
|
62
61
|
display: "block",
|
|
63
62
|
lineHeight: 1.2,
|
|
64
|
-
fontSize:
|
|
63
|
+
fontSize: "0.9rem",
|
|
65
64
|
color: "inherit",
|
|
66
65
|
textDecoration: "none",
|
|
67
66
|
...CLAMP,
|
|
@@ -95,19 +94,56 @@ export function ListCardCaption({
|
|
|
95
94
|
);
|
|
96
95
|
}
|
|
97
96
|
|
|
97
|
+
/** One rule between two columns. Shared, so every gap gets the same mark. */
|
|
98
|
+
function MetaRule(): React.JSX.Element {
|
|
99
|
+
return (
|
|
100
|
+
<Box
|
|
101
|
+
aria-hidden
|
|
102
|
+
sx={{
|
|
103
|
+
flex: "0 0 auto",
|
|
104
|
+
color: "text.disabled",
|
|
105
|
+
opacity: 0.5,
|
|
106
|
+
userSelect: "none",
|
|
107
|
+
fontSize: "0.875rem",
|
|
108
|
+
// A cancelled row strikes the whole meta cluster, and a struck `|` is a
|
|
109
|
+
// dagger. The rule is punctuation, not data — nothing to void.
|
|
110
|
+
textDecoration: "none",
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
|
|
|
114
|
+
</Box>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
/**
|
|
99
119
|
* The middle columns: stacked label-over-value pairs, separated by a rule.
|
|
100
120
|
*
|
|
101
121
|
* Stacked rather than side by side because `DATA 05/08/2026` inline spends the
|
|
102
122
|
* horizontal room the value rail needs, and the separator is what stops two
|
|
103
123
|
* adjacent pairs reading as one four-word phrase.
|
|
124
|
+
*
|
|
125
|
+
* EVEN GAPS, NOT EVEN BOXES. The rail takes a share of the row's spare width
|
|
126
|
+
* per pair (see `metaRail`), and the obvious way to spend it — give every pair
|
|
127
|
+
* an equal-width box and centre its content — puts the separator on the BOX
|
|
128
|
+
* boundary, which is only the visual midpoint when both pairs are the same
|
|
129
|
+
* width. They never are: `05/08/2026, 13:45` reaches almost to the edge of its
|
|
130
|
+
* box while `PIX` stops well short of its own, so the rule ends up crowded
|
|
131
|
+
* against the date and adrift from the method.
|
|
132
|
+
*
|
|
133
|
+
* So the pairs are sized to their content and the SPARE space is what gets
|
|
134
|
+
* divided evenly. `space-evenly` gives every gap — including the two either
|
|
135
|
+
* side of each separator — the same width, which puts each rule exactly halfway
|
|
136
|
+
* between the two labels it separates, whatever they happen to say.
|
|
104
137
|
*/
|
|
105
138
|
export function ListCardMeta({
|
|
106
139
|
meta,
|
|
107
140
|
metaSlot,
|
|
141
|
+
trailingRule,
|
|
108
142
|
}: {
|
|
109
143
|
meta?: DescriptionItemProps[];
|
|
110
144
|
metaSlot?: ReactNode;
|
|
145
|
+
/** Close the run with a rule, dividing the last pair from the value. */
|
|
146
|
+
trailingRule?: boolean;
|
|
111
147
|
}): React.JSX.Element {
|
|
112
148
|
const items = meta ?? [];
|
|
113
149
|
return (
|
|
@@ -116,7 +152,11 @@ export function ListCardMeta({
|
|
|
116
152
|
sx={{
|
|
117
153
|
display: "flex",
|
|
118
154
|
alignItems: "center",
|
|
119
|
-
|
|
155
|
+
justifyContent: "space-evenly",
|
|
156
|
+
// A FLOOR, not the spacing itself: `space-evenly` supplies the rest, and
|
|
157
|
+
// this is only what keeps a rail with no room to spare from running the
|
|
158
|
+
// date straight into the rule.
|
|
159
|
+
gap: 1,
|
|
120
160
|
whiteSpace: "nowrap",
|
|
121
161
|
...TABULAR,
|
|
122
162
|
// Rung 1 of the ladder.
|
|
@@ -124,34 +164,62 @@ export function ListCardMeta({
|
|
|
124
164
|
}}
|
|
125
165
|
>
|
|
126
166
|
{items.map((item, index) => (
|
|
127
|
-
<
|
|
128
|
-
{
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
|
134
|
-
</Box>
|
|
135
|
-
)}
|
|
167
|
+
<Fragment key={`${item.label}-${index}`}>
|
|
168
|
+
{/* ITS OWN ITEM, not the first child of the pair that follows it.
|
|
169
|
+
Nested inside, the rule travelled with that pair's centred content
|
|
170
|
+
and came to rest nearer one label than the other — a separator
|
|
171
|
+
visibly closer to MÉTODO than to DATA reads as belonging to it. */}
|
|
172
|
+
{index > 0 && <MetaRule />}
|
|
136
173
|
{/* Centred: a stacked pair sitting in its own column reads as one
|
|
137
174
|
unit only when the label is over the value rather than flush left
|
|
138
175
|
of a value shorter than it — `MÉTODO` above `PIX` was visibly
|
|
139
176
|
unhinged from it. Overridable per item. */}
|
|
140
|
-
<
|
|
141
|
-
|
|
177
|
+
<Box
|
|
178
|
+
sx={{
|
|
179
|
+
display: "flex",
|
|
180
|
+
alignItems: "center",
|
|
181
|
+
justifyContent: "center",
|
|
182
|
+
minWidth: 0,
|
|
183
|
+
// Sized to the pair, so the gaps either side of it are free space
|
|
184
|
+
// that `space-evenly` can equalise.
|
|
185
|
+
flex: "0 0 auto",
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<DescriptionItem orientation="vertical" align="center" {...item} />
|
|
189
|
+
</Box>
|
|
190
|
+
</Fragment>
|
|
142
191
|
))}
|
|
143
192
|
{metaSlot}
|
|
193
|
+
{trailingRule && items.length > 0 && <MetaRule />}
|
|
144
194
|
</Box>
|
|
145
195
|
);
|
|
146
196
|
}
|
|
147
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The value sits flush against the meta rail, with no gap of its own.
|
|
200
|
+
*
|
|
201
|
+
* Its divider is now the LAST ITEM OF THE META CLUSTER, placed by the same
|
|
202
|
+
* `space-evenly` that places the rules between the pairs — so it is equidistant
|
|
203
|
+
* from `PIX` and from `R$ 13,90` for free, which a rule centred in the rail gap
|
|
204
|
+
* could never be: the cluster's content stops well short of the rail's right
|
|
205
|
+
* edge, leaving that rule 150px from the method and 12px from the money.
|
|
206
|
+
*
|
|
207
|
+
* Cancelling this one gap is what makes the two distances equal. Below
|
|
208
|
+
* `META_BREAK` the cluster (and its rule) are gone, so the gap comes back —
|
|
209
|
+
* otherwise the value would sit straight against the title.
|
|
210
|
+
*/
|
|
211
|
+
const VALUE_FLUSH = {
|
|
212
|
+
marginLeft: `-${RAIL_GAP_PX}px`,
|
|
213
|
+
[`@container (max-width: ${META_BREAK}px)`]: { marginLeft: 0 },
|
|
214
|
+
} as const;
|
|
215
|
+
|
|
148
216
|
/** The value, on its own rail, never truncated and never shunted by the chip. */
|
|
149
|
-
function ListCardValue({ value
|
|
217
|
+
function ListCardValue({ value }: { value: ReactNode }): React.JSX.Element {
|
|
150
218
|
return (
|
|
151
219
|
<Text variant="body" size="sm" weight="medium" as="span">
|
|
152
220
|
<Box
|
|
153
221
|
component="span"
|
|
154
|
-
sx={{ fontSize:
|
|
222
|
+
sx={{ fontSize: "0.875rem", whiteSpace: "nowrap", ...TABULAR }}
|
|
155
223
|
>
|
|
156
224
|
{value}
|
|
157
225
|
</Box>
|
|
@@ -218,25 +286,33 @@ function ListCardActions({
|
|
|
218
286
|
/** Value, status and actions — three rails, not one flex run. */
|
|
219
287
|
export function ListCardTail({
|
|
220
288
|
value,
|
|
289
|
+
separated,
|
|
221
290
|
status,
|
|
222
291
|
actions,
|
|
223
292
|
actionsAlwaysVisible,
|
|
224
293
|
menu,
|
|
225
|
-
scale,
|
|
226
294
|
testId,
|
|
227
295
|
}: {
|
|
228
296
|
value?: ReactNode;
|
|
297
|
+
/** Whether a meta cluster precedes the value and wants dividing from it. */
|
|
298
|
+
separated?: boolean;
|
|
229
299
|
status?: ReactNode;
|
|
230
300
|
actions?: ReactNode;
|
|
231
301
|
actionsAlwaysVisible?: boolean;
|
|
232
302
|
menu?: ReactNode;
|
|
233
|
-
scale: number;
|
|
234
303
|
testId: (slot: string) => string | undefined;
|
|
235
304
|
}): React.JSX.Element {
|
|
236
305
|
return (
|
|
237
306
|
<>
|
|
238
|
-
<Box
|
|
239
|
-
|
|
307
|
+
<Box
|
|
308
|
+
data-slot="value"
|
|
309
|
+
sx={{
|
|
310
|
+
textAlign: "right",
|
|
311
|
+
...(separated && value != null ? VALUE_FLUSH : {}),
|
|
312
|
+
}}
|
|
313
|
+
data-testid={testId("value")}
|
|
314
|
+
>
|
|
315
|
+
{value != null && <ListCardValue value={value} />}
|
|
240
316
|
</Box>
|
|
241
317
|
<Box
|
|
242
318
|
data-slot="status"
|
|
@@ -3,21 +3,22 @@
|
|
|
3
3
|
import { useTheme } from "@mui/material/styles";
|
|
4
4
|
import { type ReactNode } from "react";
|
|
5
5
|
|
|
6
|
-
import { Checkbox } from "../../form/Checkbox";
|
|
7
6
|
import { Card } from "../../layout/Card";
|
|
8
7
|
import type { DescriptionItemProps } from "../DescriptionItem";
|
|
9
8
|
import { Box } from "../../../mui/Box";
|
|
10
9
|
|
|
11
10
|
import {
|
|
11
|
+
CARD_RADIUS,
|
|
12
12
|
cardSurfaceStyles,
|
|
13
13
|
isActionable,
|
|
14
14
|
isSelectable,
|
|
15
15
|
slotTestIds,
|
|
16
16
|
type CardSurfaceProps,
|
|
17
17
|
} from "./card-surface";
|
|
18
|
-
import {
|
|
18
|
+
import { useDragItem } from "./data-views-drag";
|
|
19
|
+
import { DragSlot, SelectSlot } from "./base-list-card-gutters";
|
|
19
20
|
import { DENSITY_ROW_PADDING, type DataViewsDensity } from "./data-views-layout-context";
|
|
20
|
-
import { RAIL_COUNT, useListRails } from "./list-card-rails";
|
|
21
|
+
import { RAIL_COUNT, RAIL_GAP, railsTemplateFor, useListRails } from "./list-card-rails";
|
|
21
22
|
import {
|
|
22
23
|
ListCardCaption,
|
|
23
24
|
ListCardMeta,
|
|
@@ -89,7 +90,13 @@ export interface BaseListCardProps extends CardSurfaceProps {
|
|
|
89
90
|
onToggleSelect?: (event?: React.MouseEvent) => void;
|
|
90
91
|
/** How much air the row gets. Vertical only — a full-width row cannot narrow. */
|
|
91
92
|
density?: DataViewsDensity;
|
|
92
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Fine-grained size multiplier on top of `density` (1 = base).
|
|
95
|
+
*
|
|
96
|
+
* Applied as `zoom`, so it scales EVERYTHING the row draws — type, gutters,
|
|
97
|
+
* gaps, padding, the checkbox, the grip, the chip and the menu — not just the
|
|
98
|
+
* handful of font sizes it once multiplied.
|
|
99
|
+
*/
|
|
93
100
|
scale?: number;
|
|
94
101
|
/** Flush style: a bottom rule instead of an outline. Row-only. */
|
|
95
102
|
divider?: boolean;
|
|
@@ -97,9 +104,7 @@ export interface BaseListCardProps extends CardSurfaceProps {
|
|
|
97
104
|
dragId?: string | number;
|
|
98
105
|
}
|
|
99
106
|
|
|
100
|
-
|
|
101
|
-
const STANDALONE_RAILS =
|
|
102
|
-
"auto auto auto minmax(0, 1fr) auto max-content max-content auto";
|
|
107
|
+
|
|
103
108
|
|
|
104
109
|
/**
|
|
105
110
|
* Whether a click was really a click, or the tail of a text selection.
|
|
@@ -120,70 +125,41 @@ function clickKeys(onClick: () => void) {
|
|
|
120
125
|
};
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** The select checkbox. Its gutter is reserved whether or not it is here. */
|
|
144
|
-
function SelectSlot({
|
|
145
|
-
selectable,
|
|
146
|
-
selected,
|
|
147
|
-
onToggleSelect,
|
|
148
|
-
testId,
|
|
149
|
-
}: {
|
|
150
|
-
selectable: boolean;
|
|
151
|
-
selected: boolean;
|
|
152
|
-
onToggleSelect?: (event?: React.MouseEvent) => void;
|
|
153
|
-
testId?: string;
|
|
154
|
-
}): React.JSX.Element {
|
|
155
|
-
if (!selectable) return <Box data-slot="select" />;
|
|
156
|
-
return (
|
|
157
|
-
// z-index 1: above the stretched link, or the anchor swallows the click.
|
|
158
|
-
<Box data-slot="select" sx={{ position: "relative", zIndex: 1 }}>
|
|
159
|
-
<Checkbox
|
|
160
|
-
checked={selected}
|
|
161
|
-
onChange={() => onToggleSelect?.()}
|
|
162
|
-
onClick={(event) => {
|
|
163
|
-
event.stopPropagation();
|
|
164
|
-
onToggleSelect?.(event);
|
|
165
|
-
}}
|
|
166
|
-
size="small"
|
|
167
|
-
data-testid={testId}
|
|
168
|
-
aria-label="Selecionar"
|
|
169
|
-
/>
|
|
170
|
-
</Box>
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Geometry: the rails, the density padding, the two-line collapse. */
|
|
128
|
+
/**
|
|
129
|
+
* Geometry: the rails, the density padding, the two-line collapse.
|
|
130
|
+
*
|
|
131
|
+
* SCALE IS `zoom`, NOT A SET OF MULTIPLIED FONT SIZES.
|
|
132
|
+
*
|
|
133
|
+
* It used to be the latter, on exactly three values — title, value, row padding
|
|
134
|
+
* — so the slider left the subtitle, the meta labels, the rules, the checkbox,
|
|
135
|
+
* the grip, the chip and the menu at their fixed sizes, and the row came apart
|
|
136
|
+
* as you dragged it. Multiplying the rest is not a fix: the checkbox and the
|
|
137
|
+
* chip are MUI internals sized in their own px, and no prop we pass reaches
|
|
138
|
+
* them.
|
|
139
|
+
*
|
|
140
|
+
* `zoom` scales the used value of every length in the subtree — ours and theirs
|
|
141
|
+
* — and unlike `transform: scale` it REFLOWS, so the row still occupies the
|
|
142
|
+
* space it draws and the rails still resolve. Verified against the group: at
|
|
143
|
+
* 1.4 all four rows keep identical rail edges, because subgrid tracks and the
|
|
144
|
+
* zoom that reads them are the same for every row.
|
|
145
|
+
*/
|
|
175
146
|
function rowSx(opts: {
|
|
176
147
|
inGroup: boolean;
|
|
148
|
+
gutters: { drag: boolean; select: boolean };
|
|
149
|
+
metaColumns: number;
|
|
177
150
|
pad: number;
|
|
178
151
|
padY: number;
|
|
152
|
+
scale: number;
|
|
179
153
|
divider: boolean;
|
|
180
154
|
interactive: boolean;
|
|
181
155
|
draggable: boolean;
|
|
182
156
|
}): Record<string, unknown> {
|
|
183
|
-
const { inGroup, pad, padY, divider, interactive, draggable } = opts;
|
|
157
|
+
const { inGroup, gutters, metaColumns, pad, padY, scale, divider, interactive, draggable } = opts;
|
|
184
158
|
return {
|
|
185
159
|
position: "relative",
|
|
160
|
+
borderRadius: CARD_RADIUS,
|
|
186
161
|
display: "grid",
|
|
162
|
+
zoom: scale,
|
|
187
163
|
// SUBGRID AND `container-type` CANNOT COEXIST on one element: containment
|
|
188
164
|
// makes the box size independently of its parent, so the browser drops the
|
|
189
165
|
// subgrid and every slot stacks into one column. (Observed exactly that —
|
|
@@ -193,12 +169,15 @@ function rowSx(opts: {
|
|
|
193
169
|
// subgrid; standalone, the row is both the container and its own grid.
|
|
194
170
|
...(inGroup
|
|
195
171
|
? { gridColumn: `span ${RAIL_COUNT}`, gridTemplateColumns: "subgrid" }
|
|
196
|
-
: {
|
|
172
|
+
: {
|
|
173
|
+
containerType: "inline-size",
|
|
174
|
+
gridTemplateColumns: railsTemplateFor(gutters, metaColumns),
|
|
175
|
+
}),
|
|
197
176
|
alignItems: "center",
|
|
198
177
|
// Wide enough that the meta cluster, the value and the status read as three
|
|
199
178
|
// columns rather than one run of text — the complaint that started all this
|
|
200
179
|
// was `MÉTODO PIX R$ 13,90` scanning as a single phrase.
|
|
201
|
-
columnGap:
|
|
180
|
+
columnGap: RAIL_GAP,
|
|
202
181
|
width: "100%",
|
|
203
182
|
px: pad,
|
|
204
183
|
py: padY,
|
|
@@ -248,6 +227,31 @@ function rowSx(opts: {
|
|
|
248
227
|
*
|
|
249
228
|
* A SHELL, like {@link BaseCard}. Domain rows live in the app and compose it.
|
|
250
229
|
*/
|
|
230
|
+
/**
|
|
231
|
+
* The id this row drags under, or nothing.
|
|
232
|
+
*
|
|
233
|
+
* `undefined` is the inert answer: `useDragItem` treats a missing id as "no
|
|
234
|
+
* drag", which is how the card's own veto (`draggable={false}`) and an
|
|
235
|
+
* unactionable record both switch dragging off without the card knowing
|
|
236
|
+
* anything about the container above it.
|
|
237
|
+
*/
|
|
238
|
+
function dragIdFor(
|
|
239
|
+
props: BaseListCardProps,
|
|
240
|
+
actionable: boolean,
|
|
241
|
+
): string | number | undefined {
|
|
242
|
+
return props.draggable === false || !actionable ? undefined : props.dragId;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* What the meta cluster amounts to — asked once, used three times: to size the
|
|
247
|
+
* rail, to close the cluster with a rule, and to decide whether the value has
|
|
248
|
+
* anything to be divided FROM.
|
|
249
|
+
*/
|
|
250
|
+
function metaShape(props: BaseListCardProps): { columns: number; present: boolean } {
|
|
251
|
+
const columns = props.meta?.length ?? 0;
|
|
252
|
+
return { columns, present: columns > 0 || props.metaSlot != null };
|
|
253
|
+
}
|
|
254
|
+
|
|
251
255
|
/**
|
|
252
256
|
* Everything the row derives before it can render — extracted so the component
|
|
253
257
|
* stays inside the size budget.
|
|
@@ -263,9 +267,18 @@ function useRowShell(props: BaseListCardProps) {
|
|
|
263
267
|
theme: useTheme(),
|
|
264
268
|
selectable: isSelectable(props),
|
|
265
269
|
slot: slotTestIds(props.testId),
|
|
266
|
-
drag: useDragItem(props
|
|
267
|
-
|
|
268
|
-
|
|
270
|
+
drag: useDragItem(dragIdFor(props, actionable)),
|
|
271
|
+
// Standalone holds no gutter open: there is no list beside it to line up
|
|
272
|
+
// with, so a reserved-but-empty rail is pure inset.
|
|
273
|
+
reserve: group?.reserveGutters ?? false,
|
|
274
|
+
// 1, not 1.5. The row's contents still have to line up with the toolbar
|
|
275
|
+
// above them, but 12px of card padding stacked on the checkbox's own 9px
|
|
276
|
+
// and an empty drag gutter's 24px read as a row indented for no reason.
|
|
277
|
+
//
|
|
278
|
+
// Not multiplied by `scale` — the row's `zoom` already scales it, and doing
|
|
279
|
+
// both would square the factor on padding alone.
|
|
280
|
+
pad: 1,
|
|
281
|
+
padY: DENSITY_ROW_PADDING[group?.density ?? props.density ?? "cozy"],
|
|
269
282
|
// A card that navigates does so with a real anchor, so it takes no click
|
|
270
283
|
// handler of its own — the stretched link IS the target.
|
|
271
284
|
acts: props.onClick != null && props.href == null && actionable,
|
|
@@ -285,15 +298,45 @@ function actionProps(onClick: (() => void) | undefined) {
|
|
|
285
298
|
};
|
|
286
299
|
}
|
|
287
300
|
|
|
301
|
+
/** The surface and the geometry, merged — the row's whole `sx` in one place. */
|
|
302
|
+
function rowStyles(
|
|
303
|
+
props: BaseListCardProps,
|
|
304
|
+
shell: ReturnType<typeof useRowShell>,
|
|
305
|
+
): Record<string, unknown> {
|
|
306
|
+
const { group, theme, selectable, drag, reserve, pad, padY, scale, acts } = shell;
|
|
307
|
+
return {
|
|
308
|
+
...cardSurfaceStyles(
|
|
309
|
+
{
|
|
310
|
+
...props,
|
|
311
|
+
selectable,
|
|
312
|
+
shape: "row",
|
|
313
|
+
state: drag.dragging ? "disabled" : props.state,
|
|
314
|
+
},
|
|
315
|
+
theme,
|
|
316
|
+
),
|
|
317
|
+
...rowSx({
|
|
318
|
+
inGroup: group !== null,
|
|
319
|
+
gutters: { drag: reserve || drag.draggable, select: reserve || selectable },
|
|
320
|
+
metaColumns: metaShape(props).columns,
|
|
321
|
+
pad,
|
|
322
|
+
padY,
|
|
323
|
+
scale,
|
|
324
|
+
divider: props.divider ?? false,
|
|
325
|
+
interactive: acts || props.href != null,
|
|
326
|
+
draggable: drag.draggable && drag.handleProps === undefined,
|
|
327
|
+
}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
288
331
|
export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
|
|
289
|
-
const { selected = false,
|
|
290
|
-
const
|
|
291
|
-
|
|
332
|
+
const { selected = false, href, onClick } = props;
|
|
333
|
+
const shell = useRowShell(props);
|
|
334
|
+
const { actionable, selectable, slot, drag, reserve, pad, acts } = shell;
|
|
335
|
+
const meta = metaShape(props);
|
|
292
336
|
|
|
293
337
|
return (
|
|
294
338
|
<Card
|
|
295
339
|
variant="outlined"
|
|
296
|
-
borderRadius="lg"
|
|
297
340
|
className={props.className}
|
|
298
341
|
dataTestId={props.testId}
|
|
299
342
|
aria-label={props["aria-label"]}
|
|
@@ -302,22 +345,13 @@ export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
|
|
|
302
345
|
{...(acts ? actionProps(onClick) : {})}
|
|
303
346
|
{...drag.itemProps}
|
|
304
347
|
// A PLAIN OBJECT: `Card` merges by spreading, so a function sx vanishes.
|
|
305
|
-
sx={
|
|
306
|
-
...cardSurfaceStyles({ ...props, selectable, shape: "row", state: drag.dragging ? "disabled" : props.state }, theme),
|
|
307
|
-
...rowSx({
|
|
308
|
-
inGroup: group !== null,
|
|
309
|
-
pad,
|
|
310
|
-
padY,
|
|
311
|
-
divider,
|
|
312
|
-
interactive: acts || href != null,
|
|
313
|
-
draggable: drag.draggable && drag.handleProps === undefined,
|
|
314
|
-
}),
|
|
315
|
-
}}
|
|
348
|
+
sx={rowStyles(props, shell)}
|
|
316
349
|
>
|
|
317
|
-
<DragSlot drag={drag} testId={slot("drag")} />
|
|
350
|
+
<DragSlot drag={drag} reserve={reserve} testId={slot("drag")} />
|
|
318
351
|
<SelectSlot
|
|
319
352
|
selectable={selectable}
|
|
320
353
|
selected={selected}
|
|
354
|
+
reserve={reserve}
|
|
321
355
|
onToggleSelect={props.onToggleSelect}
|
|
322
356
|
testId={slot("checkbox")}
|
|
323
357
|
/>
|
|
@@ -329,17 +363,20 @@ export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
|
|
|
329
363
|
subtitle={props.subtitle}
|
|
330
364
|
href={href}
|
|
331
365
|
target={props.target}
|
|
332
|
-
scale={scale}
|
|
333
366
|
testId={slot("title")}
|
|
334
367
|
/>
|
|
335
|
-
<ListCardMeta
|
|
368
|
+
<ListCardMeta
|
|
369
|
+
meta={props.meta}
|
|
370
|
+
metaSlot={props.metaSlot}
|
|
371
|
+
trailingRule={props.value != null}
|
|
372
|
+
/>
|
|
336
373
|
<ListCardTail
|
|
337
374
|
value={props.value}
|
|
375
|
+
separated={meta.present}
|
|
338
376
|
status={props.status}
|
|
339
377
|
actions={actionable ? props.actions : undefined}
|
|
340
378
|
actionsAlwaysVisible={props.actionsAlwaysVisible}
|
|
341
379
|
menu={props.menu}
|
|
342
|
-
scale={scale}
|
|
343
380
|
testId={slot}
|
|
344
381
|
/>
|
|
345
382
|
{props.children != null && (
|
|
@@ -125,6 +125,20 @@ export interface CardSurfaceProps {
|
|
|
125
125
|
"aria-label"?: string;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* ONE RADIUS FOR BOTH SHAPES, off the token scale on purpose.
|
|
130
|
+
*
|
|
131
|
+
* `Card`'s own steps are 4px and 8px and its `lg` is 16px, all chosen for a
|
|
132
|
+
* surface nearly as tall as it is wide. A 56px row is not that shape — the
|
|
133
|
+
* corner runs most of the way down both ends and curves away from the square
|
|
134
|
+
* checkbox and square thumbnail inside it — and a tile that rounds harder than
|
|
135
|
+
* the row it sits beside in the same view reads as a different component.
|
|
136
|
+
*
|
|
137
|
+
* 3px takes the hard point off the corner without being seen as a curve.
|
|
138
|
+
* `divider` rows override it back to 0; a bottom rule has no corners to round.
|
|
139
|
+
*/
|
|
140
|
+
export const CARD_RADIUS = "3px";
|
|
141
|
+
|
|
128
142
|
/** Every slot's test id, derived from the card's one. */
|
|
129
143
|
export function slotTestIds(testId: string | undefined): (slot: string) => string | undefined {
|
|
130
144
|
return (slot) => (testId ? `${testId}-${slot}` : undefined);
|
|
@@ -150,7 +164,7 @@ export function isActionable(state: CardState | undefined): boolean {
|
|
|
150
164
|
* draw. Ghost is "a surface, quietly"; text is "no surface at all", which is
|
|
151
165
|
* what a host wants when it is drawing the container itself.
|
|
152
166
|
*/
|
|
153
|
-
function variantSx(variant: CardSurfaceVariant): CardSx {
|
|
167
|
+
function variantSx(variant: CardSurfaceVariant, theme: Theme): CardSx {
|
|
154
168
|
if (variant === "ghost") {
|
|
155
169
|
return {
|
|
156
170
|
border: 0,
|
|
@@ -162,10 +176,26 @@ function variantSx(variant: CardSurfaceVariant): CardSx {
|
|
|
162
176
|
return { border: 0, backgroundColor: "transparent", boxShadow: "none" };
|
|
163
177
|
}
|
|
164
178
|
if (variant === "glass") {
|
|
179
|
+
// THEME-AWARE, and it keeps its edges.
|
|
180
|
+
//
|
|
181
|
+
// This was a hardcoded `rgba(255,255,255,·)` fill with a near-white border,
|
|
182
|
+
// which had two problems. On a dark palette a 55%-white pane is not frosted
|
|
183
|
+
// glass, it is a pale rectangle. And on a light one BOTH the fill and the
|
|
184
|
+
// border land on white and vanish, so `glass` rendered as `text` — the same
|
|
185
|
+
// nothing, reached by a different name.
|
|
186
|
+
//
|
|
187
|
+
// The blur is what makes it glass, and a blur is only visible over
|
|
188
|
+
// something. So the pane keeps a real hairline and a shallow shadow: over a
|
|
189
|
+
// plain surface it still reads as a raised pane rather than as absence, and
|
|
190
|
+
// over an image or a gradient the blur does the rest.
|
|
165
191
|
return {
|
|
166
|
-
backgroundColor:
|
|
192
|
+
backgroundColor: alpha(theme.palette.background.paper, 0.55),
|
|
167
193
|
backdropFilter: "blur(8px)",
|
|
168
|
-
|
|
194
|
+
// `divider` as-is. NOT `alpha(divider, …)`: MUI's `alpha` REPLACES the
|
|
195
|
+
// channel rather than scaling it, and `divider` is already a 12% black —
|
|
196
|
+
// so asking for 80% of it produced an 80% black slab of a border.
|
|
197
|
+
borderColor: theme.palette.divider,
|
|
198
|
+
boxShadow: `0 1px 3px ${alpha(theme.palette.common.black, 0.1)}`,
|
|
169
199
|
};
|
|
170
200
|
}
|
|
171
201
|
return {};
|
|
@@ -210,13 +240,28 @@ function accentBar(main: string): CardSx {
|
|
|
210
240
|
}
|
|
211
241
|
|
|
212
242
|
/**
|
|
213
|
-
* The tile's
|
|
214
|
-
*
|
|
243
|
+
* The tile's lift — a card that has been RAISED, not one that is lit from within.
|
|
244
|
+
*
|
|
245
|
+
* This was Button's glow: a 20px bloom at 0.6 over a 40px bloom at 0.3, plus
|
|
246
|
+
* `brightness(1.05)`. On a button, which is small and pressed and gone, that
|
|
247
|
+
* reads as energy. On a tile it read as a neon sign — 40px of saturated colour
|
|
248
|
+
* bleeding into the gaps of a grid whose whole job is to let the eye compare
|
|
249
|
+
* cards, and the brightness filter washing the image and the caption with it.
|
|
250
|
+
*
|
|
251
|
+
* Three shadows doing three jobs instead: a hairline ring that tints the card's
|
|
252
|
+
* own edge in the accent colour, a tight contact shadow, and one soft shadow
|
|
253
|
+
* offset DOWNWARD. Offset is what separates elevation from emission — a glow is
|
|
254
|
+
* symmetrical because its source is the object; a shadow falls because the
|
|
255
|
+
* light is somewhere else. It stays legible on a dark canvas, where the old
|
|
256
|
+
* bloom was the brightest thing on the screen.
|
|
215
257
|
*/
|
|
216
|
-
function
|
|
258
|
+
function liftSx(main: string): CardSx {
|
|
217
259
|
return {
|
|
218
|
-
boxShadow:
|
|
219
|
-
|
|
260
|
+
boxShadow: [
|
|
261
|
+
`0 0 0 1px ${alpha(main, 0.45)}`,
|
|
262
|
+
`0 1px 2px ${alpha(main, 0.12)}`,
|
|
263
|
+
`0 8px 20px -6px ${alpha(main, 0.3)}`,
|
|
264
|
+
].join(", "),
|
|
220
265
|
};
|
|
221
266
|
}
|
|
222
267
|
|
|
@@ -251,10 +296,10 @@ function pulseSx(main: string): CardSx {
|
|
|
251
296
|
/** The emphasis treatments, in the terms the shape can actually carry. */
|
|
252
297
|
function emphasisSx(emphasis: CardEmphasis, main: string, shape: CardShape): CardSx {
|
|
253
298
|
if (emphasis === "attention") {
|
|
254
|
-
// The bar on both; the
|
|
299
|
+
// The bar on both; the lift only where there is room for it to spread.
|
|
255
300
|
return {
|
|
256
301
|
...accentBar(main),
|
|
257
|
-
...(shape === "tile" ?
|
|
302
|
+
...(shape === "tile" ? liftSx(main) : {}),
|
|
258
303
|
...pulseSx(main),
|
|
259
304
|
};
|
|
260
305
|
}
|
|
@@ -271,6 +316,42 @@ function emphasisSx(emphasis: CardEmphasis, main: string, shape: CardShape): Car
|
|
|
271
316
|
* The shared half of a card's styling. Each component adds its own geometry —
|
|
272
317
|
* a tile's aspect ratio, a row's rails.
|
|
273
318
|
*/
|
|
319
|
+
/**
|
|
320
|
+
* WHAT THE TWO NON-DEFAULT STATES LOOK LIKE — and why they cannot look alike.
|
|
321
|
+
*
|
|
322
|
+
* Both were `opacity: 0.6` and nothing else, so a record the business CANCELLED
|
|
323
|
+
* and a row this user merely cannot touch were pixel-identical. The type has
|
|
324
|
+
* always drawn the distinction (`CardState`); only the styling had not, which
|
|
325
|
+
* made `cancelled` a comment rather than a state.
|
|
326
|
+
*
|
|
327
|
+
* `cancelled` is a VOIDED record: still there, still worth reading, no longer
|
|
328
|
+
* counting. The figures get struck through, because a dimmed `R$ 1.250,00`
|
|
329
|
+
* still reads as money owed and a struck one cannot.
|
|
330
|
+
*
|
|
331
|
+
* `disabled` is INERT: nothing to read into, nothing to do with. It goes
|
|
332
|
+
* greyscale — which also drains the status chip's colour, the one thing on the
|
|
333
|
+
* row still shouting for attention — and says so on hover.
|
|
334
|
+
*/
|
|
335
|
+
function stateSx(state: CardState): CardSx {
|
|
336
|
+
if (state === "cancelled") {
|
|
337
|
+
return {
|
|
338
|
+
opacity: 0.65,
|
|
339
|
+
// The META CLUSTER is struck too. It was left out, so a voided row kept a
|
|
340
|
+
// crisp `05/08/2026, 13:45` and `PIX` between a struck title and a struck
|
|
341
|
+
// total — the middle of the row reading as live data on a record the two
|
|
342
|
+
// ends call void. Both lines of each pair go, as both lines of the caption
|
|
343
|
+
// already do: on a cancelled record the label is as void as the value.
|
|
344
|
+
'& [data-slot="value"], & [data-slot="caption"], & [data-slot="meta"]': {
|
|
345
|
+
textDecoration: "line-through",
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (state === "disabled") {
|
|
350
|
+
return { opacity: 0.45, filter: "grayscale(1)", cursor: "not-allowed" };
|
|
351
|
+
}
|
|
352
|
+
return { opacity: 1 };
|
|
353
|
+
}
|
|
354
|
+
|
|
274
355
|
export function cardSurfaceStyles(
|
|
275
356
|
props: CardSurfaceProps & { selectable: boolean; shape: CardShape },
|
|
276
357
|
theme: Theme,
|
|
@@ -283,8 +364,8 @@ export function cardSurfaceStyles(
|
|
|
283
364
|
const showSelected = selectable && selected;
|
|
284
365
|
return {
|
|
285
366
|
transition: "border-color 120ms, box-shadow 120ms, background-color 120ms",
|
|
286
|
-
|
|
287
|
-
...variantSx(variant),
|
|
367
|
+
...stateSx(state),
|
|
368
|
+
...variantSx(variant, theme),
|
|
288
369
|
...(showSelected
|
|
289
370
|
? {
|
|
290
371
|
borderColor: `${color}.main`,
|
|
@@ -32,8 +32,9 @@ export interface ListRails {
|
|
|
32
32
|
/** Reserved even when nothing is selectable, for the same reason. */
|
|
33
33
|
select: string;
|
|
34
34
|
leading: string;
|
|
35
|
-
/**
|
|
35
|
+
/** Absorbs spare width, alongside `meta`. */
|
|
36
36
|
caption: string;
|
|
37
|
+
/** Also absorbs spare width, so the middle columns spread rather than bunch. */
|
|
37
38
|
meta: string;
|
|
38
39
|
/** The value's own rail, with a fixed right edge. */
|
|
39
40
|
value: string;
|
|
@@ -42,14 +43,56 @@ export interface ListRails {
|
|
|
42
43
|
actions: string;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The meta rail, sized to sit its pairs on even intervals WITH the caption.
|
|
48
|
+
*
|
|
49
|
+
* ONE SHARE OF THE SPARE WIDTH PER PAIR, not one for the cluster. A single `1fr`
|
|
50
|
+
* handed the whole cluster as much room as the title and then split it between
|
|
51
|
+
* the pairs, so the title sat on a full share while `DATA` and `MÉTODO` had half
|
|
52
|
+
* a share each — which is why the gap after the title came out twice the gap
|
|
53
|
+
* between the pairs. Even columns need even shares, so the rail grows with the
|
|
54
|
+
* number of pairs it has to hold.
|
|
55
|
+
*
|
|
56
|
+
* `max-content` still floors it: the evenness is never paid for with a clipped
|
|
57
|
+
* label on a row too narrow to afford it.
|
|
58
|
+
*/
|
|
59
|
+
export function metaRail(columns: number): string {
|
|
60
|
+
return `minmax(max-content, ${Math.max(columns, 1)}fr)`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The caption and meta rails, resolved TOGETHER — because exactly one of them
|
|
65
|
+
* has to be the flexible track, and which one depends on whether the row has any
|
|
66
|
+
* middle columns at all.
|
|
67
|
+
*
|
|
68
|
+
* A grid distributes its free space to `fr` tracks; with no `fr` present it
|
|
69
|
+
* spreads it across every `auto` track instead — including the empty gutters,
|
|
70
|
+
* the empty meta rail and the empty status rail. So a row with a title and a
|
|
71
|
+
* value and nothing between them drifted its title into the middle of the row,
|
|
72
|
+
* each empty rail having claimed an equal share of the slack.
|
|
73
|
+
*
|
|
74
|
+
* With pairs to place, `meta` is the flexible one and the caption sizes to its
|
|
75
|
+
* title (the spare width belongs to the only rail with several things to space
|
|
76
|
+
* out). With no pairs, there is nothing to distribute and the caption takes the
|
|
77
|
+
* slack back, which is what it always did.
|
|
78
|
+
*/
|
|
79
|
+
export function contentRails(metaColumns: number): Pick<ListRails, "caption" | "meta"> {
|
|
80
|
+
return metaColumns > 0
|
|
81
|
+
? { caption: "minmax(0, auto)", meta: metaRail(metaColumns) }
|
|
82
|
+
: { caption: "minmax(0, 1fr)", meta: "auto" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const DEFAULT_RAILS: ListRails = {
|
|
46
86
|
drag: "auto",
|
|
47
87
|
select: "auto",
|
|
48
88
|
leading: "auto",
|
|
49
|
-
|
|
50
|
-
|
|
89
|
+
// Two is the canonical row (a date and a method); a list carrying a different
|
|
90
|
+
// number says so with `metaColumns`, and a standalone card counts its own.
|
|
91
|
+
...contentRails(2),
|
|
51
92
|
// `max-content` on the value, so the rail is as wide as the widest amount in
|
|
52
93
|
// the list and no wider — a fixed px guess is either clipping or dead space.
|
|
94
|
+
// NOT an `fr`: the value's whole promise is a fixed right edge, and a rail
|
|
95
|
+
// that grows with the spare width moves that edge on every list.
|
|
53
96
|
value: "max-content",
|
|
54
97
|
status: "max-content",
|
|
55
98
|
actions: "auto",
|
|
@@ -68,8 +111,16 @@ export function useListRails(): ListRailsValue | null {
|
|
|
68
111
|
return useContext(ListRailsContext);
|
|
69
112
|
}
|
|
70
113
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
114
|
+
/**
|
|
115
|
+
* The column template, as one `grid-template-columns` value.
|
|
116
|
+
*
|
|
117
|
+
* A rail set to `null` is DROPPED rather than sized to zero. The two are not the
|
|
118
|
+
* same: a zero-width track still takes a `column-gap` beside it, so a gutter
|
|
119
|
+
* nobody is using still pushes the row's contents inward. Only a standalone card
|
|
120
|
+
* may drop one — inside a group the rails are subgrid over a shared template and
|
|
121
|
+
* every row must span the same count.
|
|
122
|
+
*/
|
|
123
|
+
export function railsTemplate(rails: RailOverrides): string {
|
|
73
124
|
return [
|
|
74
125
|
rails.drag,
|
|
75
126
|
rails.select,
|
|
@@ -79,7 +130,50 @@ export function railsTemplate(rails: ListRails): string {
|
|
|
79
130
|
rails.value,
|
|
80
131
|
rails.status,
|
|
81
132
|
rails.actions,
|
|
82
|
-
]
|
|
133
|
+
]
|
|
134
|
+
.filter((rail): rail is string => rail != null)
|
|
135
|
+
.join(" ");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A rail set, where `null` means "this rail is not in the template at all". */
|
|
139
|
+
type RailOverrides = { [K in keyof ListRails]: ListRails[K] | null };
|
|
140
|
+
|
|
141
|
+
/** The gap between rails, in theme spacing units. */
|
|
142
|
+
export const RAIL_GAP = 3;
|
|
143
|
+
|
|
144
|
+
/** …the same gap in pixels, for anything drawing INTO it. */
|
|
145
|
+
export const RAIL_GAP_PX = RAIL_GAP * 8;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* AN EMPTY GUTTER COSTS ITS OWN WIDTH *AND* THE RAIL GAP BESIDE IT.
|
|
149
|
+
*
|
|
150
|
+
* A zero-width grid track still gets a `column-gap`, and no margin on the item
|
|
151
|
+
* inside it can win that back — gaps sit between TRACKS, and a track's position
|
|
152
|
+
* is settled before any item's margin is applied. (Tried it; the box simply
|
|
153
|
+
* overhangs its own track and everything after it stays put.) So a row with
|
|
154
|
+
* nothing to drag was paying 24px for a rail holding nothing, which was the
|
|
155
|
+
* single biggest contributor to the left inset the checkbox appeared to have.
|
|
156
|
+
*
|
|
157
|
+
* The only way out is for the track NOT TO EXIST: the slot renders nothing and
|
|
158
|
+
* the rail leaves the template, which is safe because grid auto-placement fills
|
|
159
|
+
* the remaining tracks in DOM order. Standalone only — inside a
|
|
160
|
+
* {@link ListCardGroup} the rails are subgrid across a shared template and the
|
|
161
|
+
* count cannot vary per row, which is also where `reserveGutters` earns its
|
|
162
|
+
* keep: a selectable list and a read-only one still line up, and turning drag
|
|
163
|
+
* mode on does not shift every row sideways.
|
|
164
|
+
*/
|
|
165
|
+
export function railsTemplateFor(
|
|
166
|
+
gutters: { drag: boolean; select: boolean },
|
|
167
|
+
metaColumns: number,
|
|
168
|
+
): string {
|
|
169
|
+
return railsTemplate({
|
|
170
|
+
...DEFAULT_RAILS,
|
|
171
|
+
// Standalone, the card can simply COUNT its pairs — no `metaColumns` to be
|
|
172
|
+
// told, and no guess.
|
|
173
|
+
...contentRails(metaColumns),
|
|
174
|
+
...(gutters.drag ? {} : { drag: null }),
|
|
175
|
+
...(gutters.select ? {} : { select: null }),
|
|
176
|
+
});
|
|
83
177
|
}
|
|
84
178
|
|
|
85
179
|
/** How many rails there are — the span a card claims. */
|
|
@@ -97,6 +191,7 @@ export const RAIL_COUNT = 8;
|
|
|
97
191
|
export function ListCardGroup({
|
|
98
192
|
children,
|
|
99
193
|
density = "cozy",
|
|
194
|
+
metaColumns,
|
|
100
195
|
rails,
|
|
101
196
|
reserveGutters = true,
|
|
102
197
|
gap,
|
|
@@ -104,6 +199,15 @@ export function ListCardGroup({
|
|
|
104
199
|
}: {
|
|
105
200
|
children: ReactNode;
|
|
106
201
|
density?: DataViewsDensity;
|
|
202
|
+
/**
|
|
203
|
+
* How many labelled pairs the rows in this list carry.
|
|
204
|
+
*
|
|
205
|
+
* The rail claims one share of the spare width per pair, so the title and
|
|
206
|
+
* every pair land on even intervals. A list is homogeneous — its rows show
|
|
207
|
+
* the same fields — but the group renders its children opaquely and cannot
|
|
208
|
+
* count them, so this is the one thing it has to be told.
|
|
209
|
+
*/
|
|
210
|
+
metaColumns?: number;
|
|
107
211
|
/** Override individual rails; anything omitted keeps its default. */
|
|
108
212
|
rails?: Partial<ListRails>;
|
|
109
213
|
/**
|
|
@@ -117,7 +221,11 @@ export function ListCardGroup({
|
|
|
117
221
|
gap?: number;
|
|
118
222
|
dataTestId?: string;
|
|
119
223
|
}): React.JSX.Element {
|
|
120
|
-
const resolved = {
|
|
224
|
+
const resolved = {
|
|
225
|
+
...DEFAULT_RAILS,
|
|
226
|
+
...(metaColumns == null ? {} : contentRails(metaColumns)),
|
|
227
|
+
...rails,
|
|
228
|
+
};
|
|
121
229
|
return (
|
|
122
230
|
<ListRailsContext.Provider value={{ density, reserveGutters }}>
|
|
123
231
|
<Box
|
|
@@ -61,14 +61,49 @@ export const getColorFromTheme = (theme: Theme, color: string): ColorPalette =>
|
|
|
61
61
|
};
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/** What an unrecognised size falls back to, named so indexing can never be undefined. */
|
|
65
|
+
const DEFAULT_SIZE: CSSObject = { padding: '8px 16px', fontSize: '1rem' };
|
|
66
|
+
|
|
67
|
+
const SIZE_MAP: Record<string, CSSObject> = {
|
|
65
68
|
xs: { padding: '2px 8px', fontSize: '0.75rem' },
|
|
66
69
|
sm: { padding: '6px 12px', fontSize: '0.875rem' },
|
|
67
|
-
md:
|
|
70
|
+
md: DEFAULT_SIZE,
|
|
68
71
|
lg: { padding: '10px 20px', fontSize: '1.125rem' },
|
|
69
72
|
xl: { padding: '12px 24px', fontSize: '1.25rem' },
|
|
70
73
|
};
|
|
71
74
|
|
|
75
|
+
/**
|
|
76
|
+
* A BUTTON THAT IS ONLY AN ICON IS SQUARE.
|
|
77
|
+
*
|
|
78
|
+
* MUI puts a 64px `min-width` on every button, which is right for a label and
|
|
79
|
+
* wrong for a glyph: three dots in a 64px slab reads as a button that failed to
|
|
80
|
+
* load its text, and at the end of a dense row it claims space the row needs.
|
|
81
|
+
* The horizontal padding goes too — a square of padding round a square glyph —
|
|
82
|
+
* and so does the gap MUI reserves beside a start/end icon, since there is
|
|
83
|
+
* nothing on the other side of it.
|
|
84
|
+
*
|
|
85
|
+
* Derived, not declared: a button with an `icon` and no children can only be an
|
|
86
|
+
* icon button, so no consumer has to opt in and none can forget to.
|
|
87
|
+
*/
|
|
88
|
+
const ICON_ONLY_PADDING: Record<string, string> = {
|
|
89
|
+
xs: '2px',
|
|
90
|
+
sm: '5px',
|
|
91
|
+
md: '7px',
|
|
92
|
+
lg: '9px',
|
|
93
|
+
xl: '11px',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const iconOnlySize = (size: string): CSSObject => ({
|
|
97
|
+
minWidth: 0,
|
|
98
|
+
padding: ICON_ONLY_PADDING[size] ?? ICON_ONLY_PADDING.md,
|
|
99
|
+
fontSize: SIZE_MAP[size]?.fontSize ?? DEFAULT_SIZE.fontSize,
|
|
100
|
+
'& .MuiButton-startIcon, & .MuiButton-endIcon': { margin: 0 },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** The size styles for a button, square when it carries nothing but an icon. */
|
|
104
|
+
export const buttonSize = (size: string, iconOnly: boolean): CSSObject =>
|
|
105
|
+
iconOnly ? iconOnlySize(size) : (SIZE_MAP[size] ?? DEFAULT_SIZE);
|
|
106
|
+
|
|
72
107
|
/**
|
|
73
108
|
* MUI centres icons with a negative margin that fights our own padding, so the
|
|
74
109
|
* flex alignment is restated here rather than fought with `!important`.
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
buttonVariantStyles,
|
|
9
9
|
getColorFromTheme,
|
|
10
10
|
iconAlignmentStyles,
|
|
11
|
-
|
|
11
|
+
buttonSize,
|
|
12
12
|
} from './Button.styles';
|
|
13
13
|
import type { ButtonProps } from './Button.types';
|
|
14
14
|
|
|
@@ -53,6 +53,22 @@ const muiColorFor = (color: NonNullable<ButtonProps['color']>): MuiButtonColor =
|
|
|
53
53
|
return color === 'neutral' ? 'inherit' : color;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* An icon and nothing else — the shape that should render square.
|
|
58
|
+
*
|
|
59
|
+
* A separate predicate rather than three clauses inline: the component function
|
|
60
|
+
* is already at the complexity ceiling, and "what counts as an icon button" is
|
|
61
|
+
* a rule worth naming anyway. `loading` disqualifies, because a spinner
|
|
62
|
+
* replaces the children and would otherwise make every loading button square.
|
|
63
|
+
*/
|
|
64
|
+
function isIconOnly({
|
|
65
|
+
loading,
|
|
66
|
+
icon,
|
|
67
|
+
children,
|
|
68
|
+
}: Pick<ButtonProps, 'loading' | 'icon' | 'children'>): boolean {
|
|
69
|
+
return !loading && icon != null && children == null;
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
57
73
|
(rawProps, ref) => {
|
|
58
74
|
const {
|
|
@@ -81,6 +97,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
81
97
|
const iconWithTestId =
|
|
82
98
|
!loading && icon ? <span data-testid={testId('icon')}>{icon}</span> : undefined;
|
|
83
99
|
|
|
100
|
+
// Only an icon, so the button is a square rather than a 64px slab.
|
|
101
|
+
const iconOnly = isIconOnly({ loading, icon, children });
|
|
102
|
+
|
|
84
103
|
const mergedClassName =
|
|
85
104
|
[active ? 'active' : '', props.className].filter(Boolean).join(' ') || undefined;
|
|
86
105
|
|
|
@@ -101,7 +120,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
101
120
|
onClick={onClick}
|
|
102
121
|
onFocus={onFocus}
|
|
103
122
|
onBlur={onBlur}
|
|
104
|
-
sx={
|
|
123
|
+
sx={buttonSize(size, iconOnly)}
|
|
105
124
|
data-testid={dataTestId || 'button'}
|
|
106
125
|
{...props}
|
|
107
126
|
className={mergedClassName}
|