@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.
@@ -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
+ }