@12-apps/ui 1.7.0 → 1.8.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 +93 -23
- package/src/components/data-display/DataViews/base-list-card-slots.tsx +256 -0
- package/src/components/data-display/DataViews/base-list-card.tsx +352 -0
- package/src/components/data-display/DataViews/card-surface.ts +297 -0
- package/src/components/data-display/DataViews/data-views-drag.tsx +135 -0
- package/src/components/data-display/DataViews/index.ts +7 -0
- package/src/components/data-display/DataViews/list-card-rails.tsx +141 -0
- package/src/components/data-display/DescriptionItem/DescriptionItem.tsx +26 -3
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTheme } from "@mui/material/styles";
|
|
4
|
+
import { type ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
import { Checkbox } from "../../form/Checkbox";
|
|
7
|
+
import { Card } from "../../layout/Card";
|
|
8
|
+
import type { DescriptionItemProps } from "../DescriptionItem";
|
|
9
|
+
import { Box } from "../../../mui/Box";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
cardSurfaceStyles,
|
|
13
|
+
isActionable,
|
|
14
|
+
isSelectable,
|
|
15
|
+
slotTestIds,
|
|
16
|
+
type CardSurfaceProps,
|
|
17
|
+
} from "./card-surface";
|
|
18
|
+
import { DragHandle, useDragItem } from "./data-views-drag";
|
|
19
|
+
import { DENSITY_ROW_PADDING, type DataViewsDensity } from "./data-views-layout-context";
|
|
20
|
+
import { RAIL_COUNT, useListRails } from "./list-card-rails";
|
|
21
|
+
import {
|
|
22
|
+
ListCardCaption,
|
|
23
|
+
ListCardMeta,
|
|
24
|
+
ListCardTail,
|
|
25
|
+
META_BREAK,
|
|
26
|
+
STACK_BREAK,
|
|
27
|
+
} from "./base-list-card-slots";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* THE "LISTA" LAYOUT'S CARD — {@link BaseCard}'s horizontal sibling.
|
|
31
|
+
*
|
|
32
|
+
* `ListBody` always described the shape it wanted ("a marker, a title, a
|
|
33
|
+
* subtitle and a value on the right") and never provided it, so every host
|
|
34
|
+
* hand-rolled a `<Box>` flex row: a tint for selection with no checkbox, fixed
|
|
35
|
+
* `minWidth` columns that ignored density and never collapsed, no hover, no
|
|
36
|
+
* focus ring, and a `borderBottom` fighting the gap between rows.
|
|
37
|
+
*
|
|
38
|
+
* THE LIST OWNS THE COLUMNS. Inside a {@link ListCardGroup} this card is
|
|
39
|
+
* `grid-template-columns: subgrid` across the group's rails, so the value sits
|
|
40
|
+
* on its own edge and cannot be shunted about by the width of the status chip
|
|
41
|
+
* beside it. Standalone it falls back to the same template on its own. See
|
|
42
|
+
* `list-card-rails`.
|
|
43
|
+
*
|
|
44
|
+
* IT ANSWERS ITS OWN WIDTH, not the viewport — a container query, because the
|
|
45
|
+
* same row renders full-bleed on a phone, in a 300px board column, and beside a
|
|
46
|
+
* filter panel that opens and closes. Below `STACK_BREAK` it leaves the shared
|
|
47
|
+
* rails entirely and goes two-line, which is the standard mobile transaction
|
|
48
|
+
* row and beats truncating everything: title + value, then subtitle + status.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export interface BaseListCardProps extends CardSurfaceProps {
|
|
52
|
+
/** A marker: avatar, thumbnail or status glyph. Earn the pixels — a generic
|
|
53
|
+
* icon repeated down fifty rows is noise. */
|
|
54
|
+
leading?: ReactNode;
|
|
55
|
+
/** First-class title. Rendered as the link when `href` is set. */
|
|
56
|
+
title?: ReactNode;
|
|
57
|
+
/** Secondary line under the title. */
|
|
58
|
+
subtitle?: ReactNode;
|
|
59
|
+
/**
|
|
60
|
+
* Middle columns as labelled pairs — {@link DescriptionItem}s, not free-form
|
|
61
|
+
* nodes, so a screen reader reads "Data, 05/08/2026" rather than four
|
|
62
|
+
* unrelated strings. Stacked (label above value) by default.
|
|
63
|
+
*
|
|
64
|
+
* The FIRST rung of the collapse ladder: the only slot whose absence does not
|
|
65
|
+
* stop the row being identified or acted on.
|
|
66
|
+
*/
|
|
67
|
+
meta?: DescriptionItemProps[];
|
|
68
|
+
/** Escape hatch for a middle section these pairs cannot express. */
|
|
69
|
+
metaSlot?: ReactNode;
|
|
70
|
+
/** The number this row is about. Its own rail, tabular figures, never truncated. */
|
|
71
|
+
value?: ReactNode;
|
|
72
|
+
/** A status chip or badge. Its own fixed slot, so it cannot move the value. */
|
|
73
|
+
status?: ReactNode;
|
|
74
|
+
/**
|
|
75
|
+
* Inline actions — the one or two things done often enough to deserve a
|
|
76
|
+
* button. Revealed on hover and `:focus-within` by default, so fifty rows of
|
|
77
|
+
* buttons are not the resting state of the page.
|
|
78
|
+
*/
|
|
79
|
+
actions?: ReactNode;
|
|
80
|
+
/** Always show the inline actions, rather than on hover/focus. */
|
|
81
|
+
actionsAlwaysVisible?: boolean;
|
|
82
|
+
/** The 3-dots overflow menu, pinned to the end. Also opens on right-click. */
|
|
83
|
+
menu?: ReactNode;
|
|
84
|
+
/** What opens the overflow menu on right-click / long-press. */
|
|
85
|
+
onContextMenu?: (event: React.MouseEvent) => void;
|
|
86
|
+
/** Extra content below the main row; spans every rail. */
|
|
87
|
+
children?: ReactNode;
|
|
88
|
+
/** Toggle this row's selection. Receives the raw event so a list can range-select. */
|
|
89
|
+
onToggleSelect?: (event?: React.MouseEvent) => void;
|
|
90
|
+
/** How much air the row gets. Vertical only — a full-width row cannot narrow. */
|
|
91
|
+
density?: DataViewsDensity;
|
|
92
|
+
/** Fine-grained size multiplier on top of `density`. */
|
|
93
|
+
scale?: number;
|
|
94
|
+
/** Flush style: a bottom rule instead of an outline. Row-only. */
|
|
95
|
+
divider?: boolean;
|
|
96
|
+
/** This row's id for drag purposes. Inert outside a `DragContainerProvider`. */
|
|
97
|
+
dragId?: string | number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The rails, for a card with no group above it. */
|
|
101
|
+
const STANDALONE_RAILS =
|
|
102
|
+
"auto auto auto minmax(0, 1fr) auto max-content max-content auto";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether a click was really a click, or the tail of a text selection.
|
|
106
|
+
*
|
|
107
|
+
* Without this, selecting an order id to copy it navigates away instead — the
|
|
108
|
+
* single most annoying thing a clickable row does.
|
|
109
|
+
*/
|
|
110
|
+
function isTextSelection(): boolean {
|
|
111
|
+
return (globalThis.getSelection?.()?.toString().length ?? 0) > 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Keyboard equivalence for a card that acts but does not navigate. */
|
|
115
|
+
function clickKeys(onClick: () => void) {
|
|
116
|
+
return (event: React.KeyboardEvent): void => {
|
|
117
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
onClick();
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The grip. Its gutter is reserved whether or not anything drags. */
|
|
124
|
+
function DragSlot({
|
|
125
|
+
drag,
|
|
126
|
+
testId,
|
|
127
|
+
}: {
|
|
128
|
+
drag: ReturnType<typeof useDragItem>;
|
|
129
|
+
testId?: string;
|
|
130
|
+
}): React.JSX.Element {
|
|
131
|
+
if (!drag.draggable) return <Box data-slot="drag" />;
|
|
132
|
+
return (
|
|
133
|
+
<Box data-slot="drag" sx={{ position: "relative", zIndex: 1 }}>
|
|
134
|
+
<DragHandle
|
|
135
|
+
handleProps={drag.handleProps}
|
|
136
|
+
gated={drag.handleProps !== undefined}
|
|
137
|
+
testId={testId}
|
|
138
|
+
/>
|
|
139
|
+
</Box>
|
|
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. */
|
|
175
|
+
function rowSx(opts: {
|
|
176
|
+
inGroup: boolean;
|
|
177
|
+
pad: number;
|
|
178
|
+
padY: number;
|
|
179
|
+
divider: boolean;
|
|
180
|
+
interactive: boolean;
|
|
181
|
+
draggable: boolean;
|
|
182
|
+
}): Record<string, unknown> {
|
|
183
|
+
const { inGroup, pad, padY, divider, interactive, draggable } = opts;
|
|
184
|
+
return {
|
|
185
|
+
position: "relative",
|
|
186
|
+
display: "grid",
|
|
187
|
+
// SUBGRID AND `container-type` CANNOT COEXIST on one element: containment
|
|
188
|
+
// makes the box size independently of its parent, so the browser drops the
|
|
189
|
+
// subgrid and every slot stacks into one column. (Observed exactly that —
|
|
190
|
+
// computed `grid-template-columns: 1222px` on a card asking for subgrid.)
|
|
191
|
+
//
|
|
192
|
+
// So inside a group the GROUP is the query container and the row is the
|
|
193
|
+
// subgrid; standalone, the row is both the container and its own grid.
|
|
194
|
+
...(inGroup
|
|
195
|
+
? { gridColumn: `span ${RAIL_COUNT}`, gridTemplateColumns: "subgrid" }
|
|
196
|
+
: { containerType: "inline-size", gridTemplateColumns: STANDALONE_RAILS }),
|
|
197
|
+
alignItems: "center",
|
|
198
|
+
// Wide enough that the meta cluster, the value and the status read as three
|
|
199
|
+
// columns rather than one run of text — the complaint that started all this
|
|
200
|
+
// was `MÉTODO PIX R$ 13,90` scanning as a single phrase.
|
|
201
|
+
columnGap: 3,
|
|
202
|
+
width: "100%",
|
|
203
|
+
px: pad,
|
|
204
|
+
py: padY,
|
|
205
|
+
cursor: draggable ? "grab" : interactive ? "pointer" : undefined,
|
|
206
|
+
...(draggable ? { touchAction: "none", "&:active": { cursor: "grabbing" } } : {}),
|
|
207
|
+
...(interactive ? { "&:hover": { backgroundColor: "action.hover" } } : {}),
|
|
208
|
+
// A real focus ring, which a clickable <div> never had.
|
|
209
|
+
"&:focus-visible": { outline: 2, outlineStyle: "solid", outlineColor: "primary.main", outlineOffset: 2 },
|
|
210
|
+
// TWO-LINE, below the point where the shared rails stop helping.
|
|
211
|
+
//
|
|
212
|
+
// The standard mobile transaction row: what the record IS and what it COST
|
|
213
|
+
// on the first line, the supporting detail on the second. Better than
|
|
214
|
+
// truncating four columns into ellipses, and the reason each slot carries a
|
|
215
|
+
// `data-slot` — the placement is explicit rather than whatever order the
|
|
216
|
+
// children happen to be in.
|
|
217
|
+
//
|
|
218
|
+
// [ select ][ leading ][ title …………… ][ menu ]
|
|
219
|
+
// [ status …………… ][ value ]
|
|
220
|
+
//
|
|
221
|
+
// The MENU stays in the top-right corner at every width. It is the row's
|
|
222
|
+
// one fixed landmark — an overflow that moves to the second line on a phone
|
|
223
|
+
// is an overflow nobody finds twice.
|
|
224
|
+
[`@container (max-width: ${STACK_BREAK}px)`]: {
|
|
225
|
+
gridTemplateColumns: "auto auto minmax(0, 1fr) max-content",
|
|
226
|
+
...(inGroup ? { gridColumn: `span ${RAIL_COUNT}` } : {}),
|
|
227
|
+
rowGap: 0.75,
|
|
228
|
+
columnGap: 1,
|
|
229
|
+
'& > [data-slot="drag"]': { display: "none" },
|
|
230
|
+
'& > [data-slot="select"]': { gridArea: "1 / 1" },
|
|
231
|
+
'& > [data-slot="leading"]': { gridArea: "1 / 2" },
|
|
232
|
+
'& > [data-slot="caption"]': { gridArea: "1 / 3" },
|
|
233
|
+
'& > [data-slot="actions"]': { gridArea: "1 / 4", justifyContent: "flex-end" },
|
|
234
|
+
'& > [data-slot="meta"]': { display: "none" },
|
|
235
|
+
// Second line, starting under the title rather than under the checkbox.
|
|
236
|
+
'& > [data-slot="status"]': { gridArea: "2 / 3", justifyContent: "flex-start" },
|
|
237
|
+
'& > [data-slot="value"]': { gridArea: "2 / 4", textAlign: "right" },
|
|
238
|
+
},
|
|
239
|
+
...(divider
|
|
240
|
+
? { border: 0, borderRadius: 0, borderBottom: 1, borderBottomStyle: "solid", borderBottomColor: "divider" }
|
|
241
|
+
: {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A selectable full-width row: a marker, a title over a subtitle, labelled
|
|
247
|
+
* middle columns, a value, a status, actions and a menu.
|
|
248
|
+
*
|
|
249
|
+
* A SHELL, like {@link BaseCard}. Domain rows live in the app and compose it.
|
|
250
|
+
*/
|
|
251
|
+
/**
|
|
252
|
+
* Everything the row derives before it can render — extracted so the component
|
|
253
|
+
* stays inside the size budget.
|
|
254
|
+
*/
|
|
255
|
+
function useRowShell(props: BaseListCardProps) {
|
|
256
|
+
const actionable = isActionable(props.state);
|
|
257
|
+
const group = useListRails();
|
|
258
|
+
const scale = props.scale ?? 1;
|
|
259
|
+
return {
|
|
260
|
+
group,
|
|
261
|
+
actionable,
|
|
262
|
+
scale,
|
|
263
|
+
theme: useTheme(),
|
|
264
|
+
selectable: isSelectable(props),
|
|
265
|
+
slot: slotTestIds(props.testId),
|
|
266
|
+
drag: useDragItem(props.draggable === false || !actionable ? undefined : props.dragId),
|
|
267
|
+
pad: 1.5 * scale,
|
|
268
|
+
padY: DENSITY_ROW_PADDING[group?.density ?? props.density ?? "cozy"] * scale,
|
|
269
|
+
// A card that navigates does so with a real anchor, so it takes no click
|
|
270
|
+
// handler of its own — the stretched link IS the target.
|
|
271
|
+
acts: props.onClick != null && props.href == null && actionable,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The click/keyboard props a row that acts (but does not navigate) needs. */
|
|
276
|
+
function actionProps(onClick: (() => void) | undefined) {
|
|
277
|
+
return {
|
|
278
|
+
role: "button",
|
|
279
|
+
tabIndex: 0,
|
|
280
|
+
onClick: () => {
|
|
281
|
+
if (isTextSelection()) return;
|
|
282
|
+
onClick?.();
|
|
283
|
+
},
|
|
284
|
+
onKeyDown: clickKeys(() => onClick?.()),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
|
|
289
|
+
const { selected = false, divider = false, href, onClick } = props;
|
|
290
|
+
const { group, actionable, scale, theme, selectable, slot, drag, pad, padY, acts } =
|
|
291
|
+
useRowShell(props);
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<Card
|
|
295
|
+
variant="outlined"
|
|
296
|
+
borderRadius="lg"
|
|
297
|
+
className={props.className}
|
|
298
|
+
dataTestId={props.testId}
|
|
299
|
+
aria-label={props["aria-label"]}
|
|
300
|
+
aria-disabled={actionable ? undefined : true}
|
|
301
|
+
onContextMenu={props.onContextMenu}
|
|
302
|
+
{...(acts ? actionProps(onClick) : {})}
|
|
303
|
+
{...drag.itemProps}
|
|
304
|
+
// 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
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
<DragSlot drag={drag} testId={slot("drag")} />
|
|
318
|
+
<SelectSlot
|
|
319
|
+
selectable={selectable}
|
|
320
|
+
selected={selected}
|
|
321
|
+
onToggleSelect={props.onToggleSelect}
|
|
322
|
+
testId={slot("checkbox")}
|
|
323
|
+
/>
|
|
324
|
+
<Box data-slot="leading" sx={{ display: "flex", alignItems: "center" }}>
|
|
325
|
+
{props.leading}
|
|
326
|
+
</Box>
|
|
327
|
+
<ListCardCaption
|
|
328
|
+
title={props.title}
|
|
329
|
+
subtitle={props.subtitle}
|
|
330
|
+
href={href}
|
|
331
|
+
target={props.target}
|
|
332
|
+
scale={scale}
|
|
333
|
+
testId={slot("title")}
|
|
334
|
+
/>
|
|
335
|
+
<ListCardMeta meta={props.meta} metaSlot={props.metaSlot} />
|
|
336
|
+
<ListCardTail
|
|
337
|
+
value={props.value}
|
|
338
|
+
status={props.status}
|
|
339
|
+
actions={actionable ? props.actions : undefined}
|
|
340
|
+
actionsAlwaysVisible={props.actionsAlwaysVisible}
|
|
341
|
+
menu={props.menu}
|
|
342
|
+
scale={scale}
|
|
343
|
+
testId={slot}
|
|
344
|
+
/>
|
|
345
|
+
{props.children != null && (
|
|
346
|
+
<Box sx={{ gridColumn: "1 / -1", mt: pad * 0.5, minWidth: 0 }}>{props.children}</Box>
|
|
347
|
+
)}
|
|
348
|
+
</Card>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export { META_BREAK, STACK_BREAK };
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { alpha, keyframes, type CSSObject, type Theme } from "@mui/material";
|
|
2
|
+
|
|
3
|
+
import { fadeInScaleAnimation } from "../Badge/Badge.animations";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A plain style object, not MUI's `SxProps`.
|
|
7
|
+
*
|
|
8
|
+
* `SxProps` is a union including arrays and functions, so a value typed as one
|
|
9
|
+
* cannot be spread — and these fragments exist precisely to be merged. Note also
|
|
10
|
+
* that `Card` merges by SPREADING (`sx={{ ...defaults, ...sx }}`), so handing it
|
|
11
|
+
* a function yields nothing at all: resolve the theme with `useTheme` first.
|
|
12
|
+
*/
|
|
13
|
+
type CardSx = CSSObject | Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
/** The ring, as scale + opacity so it never leaves the compositor. */
|
|
16
|
+
const pulseRing = keyframes`
|
|
17
|
+
0% { transform: scale(1); opacity: 0.9; }
|
|
18
|
+
70% { transform: scale(1.04); opacity: 0; }
|
|
19
|
+
100% { transform: scale(1.04); opacity: 0; }
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* THE CONTRACT BOTH CARDS IMPLEMENT.
|
|
24
|
+
*
|
|
25
|
+
* `BaseCard` (a tile) and `BaseListCard` (a row) are one object seen from two
|
|
26
|
+
* angles, so the surface — variant, colour, selectability, draggability,
|
|
27
|
+
* emphasis and state — is declared once and spread into both.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How much chrome the surface claims. Button's vocabulary, spelled Button's
|
|
32
|
+
* way: a card and a button are both surfaces the operator presses, and having
|
|
33
|
+
* one say `outline` while the other says `outlined` taxes everyone who writes
|
|
34
|
+
* both. Chip's `filled | outlined` was the wrong model — a chip is a token in a
|
|
35
|
+
* sentence and has only those two things to say.
|
|
36
|
+
*/
|
|
37
|
+
export type CardSurfaceVariant = "outline" | "ghost" | "text" | "glass";
|
|
38
|
+
|
|
39
|
+
/** The theme colour a card's selection and emphasis are drawn in. */
|
|
40
|
+
export type CardSurfaceColor =
|
|
41
|
+
| "primary"
|
|
42
|
+
| "secondary"
|
|
43
|
+
| "success"
|
|
44
|
+
| "warning"
|
|
45
|
+
| "error"
|
|
46
|
+
| "info";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Why this card is asking to be noticed.
|
|
50
|
+
*
|
|
51
|
+
* ONE union, not six booleans. `glow`/`pulse`/`animate`/`bounce`/`shimmer`
|
|
52
|
+
* combined into 32 states of which about three meant anything —
|
|
53
|
+
* `pulse + shimmer + bounce` is not a thing anyone wants, and every one of
|
|
54
|
+
* those combinations was a state somebody could reach by accident.
|
|
55
|
+
*/
|
|
56
|
+
export type CardEmphasis =
|
|
57
|
+
/** Nothing. The overwhelming default. */
|
|
58
|
+
| "none"
|
|
59
|
+
/** Look here now: an accent bar, and a ring that beats six times and settles. */
|
|
60
|
+
| "attention"
|
|
61
|
+
/** This just arrived: an accent bar and one fade-in. */
|
|
62
|
+
| "new";
|
|
63
|
+
|
|
64
|
+
/** A row and a tile carry emphasis differently — see `emphasisSx`. */
|
|
65
|
+
type CardShape = "row" | "tile";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What this record IS, as opposed to how it looks.
|
|
69
|
+
*
|
|
70
|
+
* `dimmed` only greyed pixels, which left a cancelled row's buttons live and
|
|
71
|
+
* clickable. This carries the meaning, so the card can also stop offering
|
|
72
|
+
* actions on something that cannot be acted on.
|
|
73
|
+
*/
|
|
74
|
+
export type CardState = "default" | "cancelled" | "disabled";
|
|
75
|
+
|
|
76
|
+
export interface CardSurfaceProps {
|
|
77
|
+
/** How much chrome the surface claims. Defaults to `outline`. */
|
|
78
|
+
variant?: CardSurfaceVariant;
|
|
79
|
+
/** Theme colour for selection and emphasis. Defaults to `primary`. */
|
|
80
|
+
color?: CardSurfaceColor;
|
|
81
|
+
/**
|
|
82
|
+
* Can this card be selected — and does it show it?
|
|
83
|
+
*
|
|
84
|
+
* The capability, separate from the handler. `selectable` gets the checkbox
|
|
85
|
+
* AND the selected treatment; `selectable={false}` has neither, however
|
|
86
|
+
* `selected` is set, so a stale flag arriving with the data cannot make a
|
|
87
|
+
* read-only row render as picked. Defaults to whether `onToggleSelect` exists.
|
|
88
|
+
*/
|
|
89
|
+
selectable?: boolean;
|
|
90
|
+
/** Whether this card is currently in the selection. */
|
|
91
|
+
selected?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Opt OUT of dragging even inside a drag container.
|
|
94
|
+
*
|
|
95
|
+
* Draggability is the container's decision (see `data-views-drag`); this is
|
|
96
|
+
* the card's veto — a pinned row, a summary line, a record that may not move.
|
|
97
|
+
*/
|
|
98
|
+
draggable?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Where this card leads.
|
|
101
|
+
*
|
|
102
|
+
* PREFER THIS TO `onClick`. A clickable `<div>` cannot be cmd-clicked,
|
|
103
|
+
* middle-clicked or "copy link address"'d, and wrapping interactive children
|
|
104
|
+
* in a click handler is the a11y violation it looks like. With `href` the
|
|
105
|
+
* title is a real `<a>` stretched over the card by a pseudo-element: one
|
|
106
|
+
* whole-row target, correct tab order, every browser affordance intact, and
|
|
107
|
+
* the buttons inside stay ordinary buttons.
|
|
108
|
+
*/
|
|
109
|
+
href?: string;
|
|
110
|
+
/** Anchor target, when `href` is set. */
|
|
111
|
+
target?: string;
|
|
112
|
+
/** Card click, for a card that does not navigate. See `href` first. */
|
|
113
|
+
onClick?: () => void;
|
|
114
|
+
/** What this record is. `cancelled`/`disabled` also suppress the actions. */
|
|
115
|
+
state?: CardState;
|
|
116
|
+
/** Why this card is asking to be noticed. Defaults to `none`. */
|
|
117
|
+
emphasis?: CardEmphasis;
|
|
118
|
+
className?: string;
|
|
119
|
+
/**
|
|
120
|
+
* One id; the parts derive from it (`${testId}-checkbox`, `-drag`, `-menu`,
|
|
121
|
+
* `-value`). A prop per slot would have been `checkboxTestId`,
|
|
122
|
+
* `menuTestId`, `valueTestId`… one for every part anybody ever tested.
|
|
123
|
+
*/
|
|
124
|
+
testId?: string;
|
|
125
|
+
"aria-label"?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every slot's test id, derived from the card's one. */
|
|
129
|
+
export function slotTestIds(testId: string | undefined): (slot: string) => string | undefined {
|
|
130
|
+
return (slot) => (testId ? `${testId}-${slot}` : undefined);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Is this card selectable? Explicit wins; otherwise a handler implies it. */
|
|
134
|
+
export function isSelectable(
|
|
135
|
+
props: Pick<CardSurfaceProps, "selectable"> & { onToggleSelect?: () => void },
|
|
136
|
+
): boolean {
|
|
137
|
+
return props.selectable ?? props.onToggleSelect != null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Can this record be acted on at all? */
|
|
141
|
+
export function isActionable(state: CardState | undefined): boolean {
|
|
142
|
+
return state !== "disabled" && state !== "cancelled";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Variant styling. Everything but `outline` drops the border.
|
|
147
|
+
*
|
|
148
|
+
* `ghost` keeps a faint SURFACE where `text` has none — without that the two
|
|
149
|
+
* were pixel-identical at rest and the union carried a distinction it did not
|
|
150
|
+
* draw. Ghost is "a surface, quietly"; text is "no surface at all", which is
|
|
151
|
+
* what a host wants when it is drawing the container itself.
|
|
152
|
+
*/
|
|
153
|
+
function variantSx(variant: CardSurfaceVariant): CardSx {
|
|
154
|
+
if (variant === "ghost") {
|
|
155
|
+
return {
|
|
156
|
+
border: 0,
|
|
157
|
+
backgroundColor: "action.hover",
|
|
158
|
+
"&:hover": { backgroundColor: "action.selected" },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (variant === "text") {
|
|
162
|
+
return { border: 0, backgroundColor: "transparent", boxShadow: "none" };
|
|
163
|
+
}
|
|
164
|
+
if (variant === "glass") {
|
|
165
|
+
return {
|
|
166
|
+
backgroundColor: "rgba(255,255,255,0.55)",
|
|
167
|
+
backdropFilter: "blur(8px)",
|
|
168
|
+
borderColor: "rgba(255,255,255,0.35)",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* MOTION IS OPT-IN, ALWAYS.
|
|
176
|
+
*
|
|
177
|
+
* Everything animated here is wrapped in this. Fifty rows breathing in unison
|
|
178
|
+
* is a vestibular trigger, and honouring the OS setting is not a polish item —
|
|
179
|
+
* it is the difference between a list somebody can look at and one they cannot.
|
|
180
|
+
*/
|
|
181
|
+
const MOTION_OK = "@media (prefers-reduced-motion: no-preference)";
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* THE ROW'S ATTENTION SIGNAL IS A BAR, NOT A HALO.
|
|
185
|
+
*
|
|
186
|
+
* A glow is a weak signal in a list: full-width rows stacked tight means the
|
|
187
|
+
* halo bleeds 15px into the neighbours above and below and reads as a rendering
|
|
188
|
+
* artifact, and stacked with a `selected` ring and the browser's focus ring it
|
|
189
|
+
* becomes three concentric halos on one row. Five rows needing attention at
|
|
190
|
+
* once turns the page into a smear.
|
|
191
|
+
*
|
|
192
|
+
* A 3px accent on the leading edge is stronger, costs no motion at all, works
|
|
193
|
+
* under reduced-motion, cannot bleed into a neighbour, and stacks cleanly
|
|
194
|
+
* however many rows raise their hand. It is where operational tables land.
|
|
195
|
+
*/
|
|
196
|
+
function accentBar(main: string): CardSx {
|
|
197
|
+
return {
|
|
198
|
+
"&::before": {
|
|
199
|
+
content: '""',
|
|
200
|
+
position: "absolute",
|
|
201
|
+
insetInlineStart: 0,
|
|
202
|
+
insetBlock: 0,
|
|
203
|
+
width: 3,
|
|
204
|
+
borderStartStartRadius: "inherit",
|
|
205
|
+
borderEndStartRadius: "inherit",
|
|
206
|
+
backgroundColor: main,
|
|
207
|
+
pointerEvents: "none",
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The tile's halo. Button's glow, kept for `BaseCard` — a grid of tiles has gaps
|
|
214
|
+
* around each card, so the spread has somewhere to go that a row does not.
|
|
215
|
+
*/
|
|
216
|
+
function glowSx(main: string): CardSx {
|
|
217
|
+
return {
|
|
218
|
+
boxShadow: `0 0 20px 5px ${alpha(main, 0.6)}, 0 0 40px 10px ${alpha(main, 0.3)}`,
|
|
219
|
+
filter: "brightness(1.05)",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A breath that STOPS.
|
|
225
|
+
*
|
|
226
|
+
* Six iterations, not `infinite`. A restaurant admin has this open for a whole
|
|
227
|
+
* shift, and something pulsing in the periphery forever is fatigue rather than
|
|
228
|
+
* urgency — worse, once two or three rows are doing it the signal means
|
|
229
|
+
* nothing. It settles, and the accent bar carries the state from then on.
|
|
230
|
+
*
|
|
231
|
+
* Animating `transform`/`opacity` on a pseudo-element, never `box-shadow`: a
|
|
232
|
+
* box-shadow keyframe repaints every frame, and fifty of them drop frames on
|
|
233
|
+
* exactly the tablet a restaurant floor uses. This one is compositor-only.
|
|
234
|
+
*/
|
|
235
|
+
function pulseSx(main: string): CardSx {
|
|
236
|
+
return {
|
|
237
|
+
[MOTION_OK]: {
|
|
238
|
+
"&::after": {
|
|
239
|
+
content: '""',
|
|
240
|
+
position: "absolute",
|
|
241
|
+
inset: 0,
|
|
242
|
+
borderRadius: "inherit",
|
|
243
|
+
border: `2px solid ${main}`,
|
|
244
|
+
animation: `${pulseRing} 1.6s ease-out 6`,
|
|
245
|
+
pointerEvents: "none",
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The emphasis treatments, in the terms the shape can actually carry. */
|
|
252
|
+
function emphasisSx(emphasis: CardEmphasis, main: string, shape: CardShape): CardSx {
|
|
253
|
+
if (emphasis === "attention") {
|
|
254
|
+
// The bar on both; the halo only where there is room for it to spread.
|
|
255
|
+
return {
|
|
256
|
+
...accentBar(main),
|
|
257
|
+
...(shape === "tile" ? glowSx(main) : {}),
|
|
258
|
+
...pulseSx(main),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (emphasis === "new") {
|
|
262
|
+
return {
|
|
263
|
+
...accentBar(main),
|
|
264
|
+
[MOTION_OK]: { animation: `${fadeInScaleAnimation} 240ms ease-out` },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The shared half of a card's styling. Each component adds its own geometry —
|
|
272
|
+
* a tile's aspect ratio, a row's rails.
|
|
273
|
+
*/
|
|
274
|
+
export function cardSurfaceStyles(
|
|
275
|
+
props: CardSurfaceProps & { selectable: boolean; shape: CardShape },
|
|
276
|
+
theme: Theme,
|
|
277
|
+
): CardSx {
|
|
278
|
+
const { variant = "outline", color = "primary", selected = false, selectable } = props;
|
|
279
|
+
const { state = "default", emphasis = "none" } = props;
|
|
280
|
+
const main = theme.palette[color].main;
|
|
281
|
+
// Selection styling is gated on the CAPABILITY: a card that cannot be
|
|
282
|
+
// selected must not render as selected because a stale flag came with its data.
|
|
283
|
+
const showSelected = selectable && selected;
|
|
284
|
+
return {
|
|
285
|
+
transition: "border-color 120ms, box-shadow 120ms, background-color 120ms",
|
|
286
|
+
opacity: state === "default" ? 1 : 0.6,
|
|
287
|
+
...variantSx(variant),
|
|
288
|
+
...(showSelected
|
|
289
|
+
? {
|
|
290
|
+
borderColor: `${color}.main`,
|
|
291
|
+
boxShadow: `inset 0 0 0 1px ${main}`,
|
|
292
|
+
backgroundColor: "action.selected",
|
|
293
|
+
}
|
|
294
|
+
: {}),
|
|
295
|
+
...emphasisSx(emphasis, main, props.shape),
|
|
296
|
+
};
|
|
297
|
+
}
|