@12-apps/ui 1.6.3 → 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.
Files changed (39) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +1 -1
  3. package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +183 -0
  4. package/src/components/data-display/DataViews/base-card.tsx +93 -23
  5. package/src/components/data-display/DataViews/base-list-card-slots.tsx +256 -0
  6. package/src/components/data-display/DataViews/base-list-card.tsx +352 -0
  7. package/src/components/data-display/DataViews/card-surface.ts +297 -0
  8. package/src/components/data-display/DataViews/data-views-bar-controls.tsx +119 -0
  9. package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
  10. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
  11. package/src/components/data-display/DataViews/data-views-drag.tsx +135 -0
  12. package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
  13. package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
  14. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
  15. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
  16. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
  17. package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
  18. package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
  19. package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
  20. package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
  21. package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
  22. package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
  23. package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
  24. package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
  25. package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
  26. package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
  27. package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
  28. package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
  29. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
  30. package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
  31. package/src/components/data-display/DataViews/data-views-types.ts +14 -47
  32. package/src/components/data-display/DataViews/index.ts +8 -0
  33. package/src/components/data-display/DataViews/list-card-rails.tsx +141 -0
  34. package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
  35. package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
  36. package/src/components/data-display/DescriptionItem/DescriptionItem.tsx +26 -3
  37. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
  38. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
  39. package/src/components/layout/ContentToolbar/MultiSelectTriggers.tsx +39 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.6.3",
3
+ "version": "1.8.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.5.3",
197
+ "@12-apps/forms-core": "^1.7.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.6.3",
213
- "@12-apps/typescript-config": "^1.6.3",
212
+ "@12-apps/eslint-config": "^1.0.1",
213
+ "@12-apps/typescript-config": "^1.0.2",
214
214
  "@playwright/test": "^1.55.0",
215
215
  "@storybook/addon-docs": "^9.1.10",
216
216
  "@storybook/addon-links": "^9.1.10",
@@ -83,7 +83,7 @@ export interface DataViewsTableBaseProps<T extends Record<string, unknown>> {
83
83
  board?: BoardConfig<T>;
84
84
  /** Opt-in "Lista" layout — one full-width, entity-rendered row per record (FUT-733). */
85
85
  renderListRow?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
86
- /** The page-level partition rendered as tabs above the toolbar (server mode only). */
86
+ /** The page-level partition rendered as tabs under the toolbar (server mode only). */
87
87
  scopes?: ScopeConfig[];
88
88
  /** The row field the scopes partition by — used only to reject a pill over the same field. */
89
89
  scopeFieldId?: string;
@@ -0,0 +1,183 @@
1
+ # The DataViews toolbar, at every width
2
+
3
+ The toolbar degrades through a **measured ladder**, not breakpoints. Nothing in
4
+ `data-views-overflow.ts` reads a media query: a `ResizeObserver` reports the
5
+ toolbar row's width, every control is priced, and the ladder sheds one rung at a
6
+ time until the row fits.
7
+
8
+ That is deliberate and worth restating, because it is what makes this document
9
+ approximate. Pages declare different numbers of filters with different label
10
+ lengths, and the same page collapses at a different width in another language. A
11
+ shared breakpoint is wrong for at least one table by construction.
12
+
13
+ **So the widths below are the ones the tests pin, not thresholds in the code.**
14
+ They are named for the device class they stand in for, and the behaviour column
15
+ is what the six-control Pedidos table does there. A two-filter table degrades
16
+ later; an eight-filter one degrades sooner. Both are correct.
17
+
18
+ | class | width | stands for |
19
+ | --- | --- | --- |
20
+ | small mobile | 320 | iPhone SE, the floor we support |
21
+ | large mobile | 430 | iPhone Pro Max |
22
+ | tablet | 768 | iPad portrait |
23
+ | small desktop | 1280 | laptop |
24
+ | large desktop | 1600 | external monitor |
25
+
26
+ **These are toolbar ROW widths, not viewport widths.** The `ResizeObserver`
27
+ watches the row, and the row is narrower than the window by whatever the page
28
+ puts around it — around 48px in the Pedidos shell. So a 430px row is roughly a
29
+ 478px phone, and the same page at a 430px *viewport* sits one rung further down
30
+ than this table shows. The tests drive the row directly, which is the only
31
+ number the ladder ever sees.
32
+
33
+ ---
34
+
35
+ ## The one rule that outranks the rest
36
+
37
+ **The toolbar is one line and never scrolls sideways.** Not the row, not the
38
+ filter cluster, not the "Mais" panel, not the document. At any width, with any
39
+ combination of filters applied, with the search open or closed.
40
+
41
+ Everything below exists to keep that true. When a change breaks it, the fix is
42
+ another rung on the ladder — never a scrollbar, and never letting controls paint
43
+ outside the toolbar.
44
+
45
+ ---
46
+
47
+ ## The ladder
48
+
49
+ Cheapest loss first. Each rung is taken only if the one before it did not free
50
+ enough room.
51
+
52
+ | # | rung | flag | what goes |
53
+ | --- | --- | --- | --- |
54
+ | 1 | filter controls move into "Mais" | `inline` / `overflow` | one control at a time, idle ones first |
55
+ | 2 | Exibir / Exportar drop their labels | `compactControls` | the text, and the dropdown chevron with it |
56
+ | 3 | the search box shrinks | — | none; it is `flex: 1` and CSS does it |
57
+ | 4 | the search collapses to a magnifier | `searchCollapsed` | the box |
58
+ | 5 | the "N de N" counter is dropped | `counterHidden` | the count |
59
+ | 6 | "Limpar" leaves the bar | `clearAllHidden` | the button — it survives in the panel footer |
60
+
61
+ Rung order is not arbitrary. **A control leaves the bar early if it has somewhere
62
+ else to be, and late if it does not.** "Limpar" goes before the magnifier because
63
+ the "Mais" panel footer still carries "Limpar todos os filtros"; the magnifier
64
+ and "Mais" go last because nothing else on screen can stand in for them.
65
+
66
+ The counter goes before the search because it is the only thing left that
67
+ *reports* rather than *does* — an operator can act without knowing the total,
68
+ but not without a way to search.
69
+
70
+ ---
71
+
72
+ ## Behaviour by class
73
+
74
+ Read this as "what the Pedidos table does", not "what the code hard-codes".
75
+
76
+ ### Filters
77
+
78
+ | | small mobile | large mobile | tablet | small desktop | large desktop |
79
+ | --- | --- | --- | --- | --- | --- |
80
+ | controls on the bar | 0 | 0 | 0 | 1–2 | 3+ |
81
+ | "Mais" present | yes | yes | yes | yes | only if something overflowed |
82
+
83
+ **An applied filter is ranked first, not exempt.** Applied controls take the
84
+ visible slots ahead of idle ones and go into "Mais" like anything else when even
85
+ those run out. The earlier rule exempted them outright, and on a phone four
86
+ applied pills then claimed more width than the row had: the bar painted past its
87
+ own edge and the pills were reachable only by scrolling it sideways.
88
+
89
+ Hiding one is safe *because the badge says so* — see below. A control scrolled
90
+ off-screen carries no signal at all, which is the failure the exemption was
91
+ written to prevent and did not.
92
+
93
+ ### The "Mais" badge
94
+
95
+ | state | badge shows | tone |
96
+ | --- | --- | --- |
97
+ | overflowed fields, none applied | count of hidden **fields** | neutral |
98
+ | any overflowed field applied | count of **applied** ones | filled, primary |
99
+
100
+ A neutral "3" would make *three filters you have not used* and *three filters
101
+ narrowing this list* look identical. On a phone, where every filter is in there,
102
+ that badge is the only thing on screen saying the list is filtered at all.
103
+
104
+ ### Search
105
+
106
+ | | small mobile | large mobile | tablet | small desktop | large desktop |
107
+ | --- | --- | --- | --- | --- | --- |
108
+ | resting state | magnifier | magnifier | box | box | box |
109
+ | on expanding the magnifier | **takes over** the cluster | shares the row | — | — | — |
110
+
111
+ The share/takeover boundary sits inside the "large mobile" band and moves with
112
+ the table: it is a comparison against what the box would actually be left with,
113
+ so a page with four short filter labels shares where Pedidos, with six longer
114
+ ones, takes over. Measured on the real Pedidos screen, takeover begins at a
115
+ 500px viewport and sharing resumes at 600px.
116
+
117
+ Two distinct behaviours, and conflating them was a bug:
118
+
119
+ - **Shrink** (`fill`) — an expanded box drops its usual 200px floor and takes
120
+ whatever the cluster has. That floor is right for a box that lives on the row
121
+ permanently; it is wrong for one expanded into a cluster the ladder sized for
122
+ an icon, where insisting on it made the row scroll (154px of overhang at
123
+ 320px, 84px at 390px, 42px at 500px).
124
+ - **Takeover** (`searchTakeover`) — the filters stand down and the box owns the
125
+ cluster, with a ✕ to leave. Only where shrinking has run out of road and what
126
+ is left would be too narrow to read back. On a large phone there is room to
127
+ share, and evicting the filters there cost the operator their filters for
128
+ nothing.
129
+
130
+ ### The right-hand controls
131
+
132
+ | | small mobile | large mobile | tablet | small desktop | large desktop |
133
+ | --- | --- | --- | --- | --- | --- |
134
+ | Exibir / Exportar | icon | icon | label | label | label |
135
+ | dropdown chevron | no | no | yes | yes | yes |
136
+ | "N de N" counter | hidden | hidden | shown | shown | shown |
137
+
138
+ A tablet keeps the labels, which is worth stating because it is easy to assume
139
+ otherwise. With every filter already behind "Mais" the row has room to spare
140
+ there, and rung 2 is taken only when the search would otherwise fall below its
141
+ floor. The ladder spends the width it has rather than degrading on a schedule —
142
+ that is the whole point of measuring.
143
+
144
+ The chevron goes with the label. A bare icon already reads as a button, and the
145
+ ~24px each buys is the difference between a row that fits and one that scrolls.
146
+ It is dropped from Exibir, Exportar **and** "Mais" together — one chevron left
147
+ among three identical buttons reads as a defect.
148
+
149
+ ### Clear all
150
+
151
+ | | small mobile | large mobile | tablet | small desktop | large desktop |
152
+ | --- | --- | --- | --- | --- | --- |
153
+ | on the bar | no | no | icon | icon | icon + "Limpar" |
154
+ | in the panel footer | yes | yes | yes | yes | yes |
155
+
156
+ Present only while something is applied — a dead button is worse than none — and
157
+ last in the cluster, because it is destructive and must never appear where a
158
+ filter control was a moment ago.
159
+
160
+ It is styled flat: no border, no fill, muted until hovered. Every other control
161
+ on the row is an outlined pill because it *opens* something; this one is an
162
+ escape hatch, and giving it the same weight made it read as a sixth filter.
163
+
164
+ ### Scope tabs
165
+
166
+ Below the toolbar, above the rows they narrow. Hidden entirely when the board
167
+ layout groups by the same field the scopes partition by — a column per situação
168
+ and a tab per situação are the same partition offered twice, and picking one tab
169
+ leaves the board with a single populated column, which reads as data loss.
170
+
171
+ ---
172
+
173
+ ## Where this is tested
174
+
175
+ `__tests__/data-views-responsive.test.tsx` drives a fake `ResizeObserver` at each
176
+ of the five widths and asserts the tables above. It is the executable copy of
177
+ this document; if the two disagree, the test is right and this needs updating.
178
+
179
+ Two things it cannot check, because jsdom has no layout engine: actual pixel
180
+ overflow, and how anything looks. Those are verified in a real browser against
181
+ the running Storybook, at 1600 / 1280 / 1024 / 900 / 768 / 600 / 500 / 430 / 390
182
+ / 360 / 320, measured on the toolbar row, the filter cluster's own scroll box,
183
+ and the document.
@@ -2,16 +2,27 @@
2
2
 
3
3
  import { type ReactNode } from "react";
4
4
 
5
- import type { SxProps, Theme } from "@mui/material/styles";
5
+ import { useTheme, type CSSObject } from "@mui/material/styles";
6
+
7
+ /** See the note on `CardSx` in `card-surface` — these fragments get merged. */
8
+ type CardSx = CSSObject | Record<string, unknown>;
6
9
 
7
10
  import { Checkbox } from "../../form/Checkbox";
8
11
  import { Card } from "../../layout/Card";
9
12
  import { Box } from "../../../mui/Box";
10
13
  import { Text } from "../../typography/Text";
11
14
 
15
+ import { DragHandle, useDragItem } from "./data-views-drag";
16
+ import {
17
+ cardSurfaceStyles,
18
+ isActionable,
19
+ isSelectable,
20
+ slotTestIds,
21
+ type CardSurfaceProps,
22
+ } from "./card-surface";
12
23
  import { CARD_ASPECT_RATIOS, type CardAspectRatio } from "./data-views-types";
13
24
 
14
- interface BaseCardProps {
25
+ export interface BaseCardProps extends CardSurfaceProps {
15
26
  /** Extra body content below the caption (chips, meta). Optional. */
16
27
  children?: ReactNode;
17
28
  /** Tile proportion. Defaults to "4:3". */
@@ -33,15 +44,17 @@ interface BaseCardProps {
33
44
  subtitle?: ReactNode;
34
45
  /** The 3-dots menu (or any action node) pinned to the top-right corner. */
35
46
  menu?: ReactNode;
36
- /** Whether this card is in the grid's selection. */
37
- selected?: boolean;
38
47
  /** Toggle this card's selection. Omit to hide the checkbox (non-selectable). */
39
48
  onToggleSelect?: () => void;
40
- /** Card click (e.g. open/edit). The checkbox/menu stop propagation. */
41
- onClick?: () => void;
42
- /** Visually de-emphasise (e.g. a disabled/hidden entity). */
43
- dimmed?: boolean;
44
- dataTestId?: string;
49
+ /**
50
+ * This card's id for drag purposes.
51
+ *
52
+ * Draggable ONLY inside a {@link DragContainerProvider} — see
53
+ * `data-views-drag`. On the board that is how a card moves between columns;
54
+ * in a grade it is how the operator reorders. Outside a container this is
55
+ * inert and no grip appears.
56
+ */
57
+ dragId?: string | number;
45
58
  checkboxTestId?: string;
46
59
  }
47
60
 
@@ -50,10 +63,9 @@ function cardSx(opts: {
50
63
  aspectRatio: CardAspectRatio;
51
64
  pad: number;
52
65
  interactive: boolean;
53
- dimmed: boolean;
54
- selected: boolean;
55
- }): SxProps<Theme> {
56
- const { aspectRatio, pad, interactive, dimmed, selected } = opts;
66
+ draggable: boolean;
67
+ }): CardSx {
68
+ const { aspectRatio, pad, interactive, draggable } = opts;
57
69
  return {
58
70
  position: "relative",
59
71
  aspectRatio: String(CARD_ASPECT_RATIOS[aspectRatio]),
@@ -61,12 +73,8 @@ function cardSx(opts: {
61
73
  flexDirection: "column",
62
74
  overflow: "hidden",
63
75
  p: pad,
64
- transition: "border-color 120ms, box-shadow 120ms, background-color 120ms",
65
- cursor: interactive ? "pointer" : undefined,
66
- opacity: dimmed ? 0.6 : 1,
67
- borderColor: selected ? "primary.main" : undefined,
68
- boxShadow: selected ? (theme) => `inset 0 0 0 1px ${theme.palette.primary.main}` : undefined,
69
- backgroundColor: selected ? "action.selected" : undefined,
76
+ cursor: draggable ? "grab" : interactive ? "pointer" : undefined,
77
+ ...(draggable ? { touchAction: "none", "&:active": { cursor: "grabbing" } } : {}),
70
78
  };
71
79
  }
72
80
 
@@ -244,9 +252,29 @@ function CardContents({
244
252
  * per-slot helpers above; this shell just assembles them within a fixed-ratio
245
253
  * tile. Reads no context, so it renders standalone as well as inside the grid.
246
254
  */
255
+ /**
256
+ * Everything the tile derives from its props before it can render — pulled out
257
+ * so `BaseCard` itself stays inside the complexity budget.
258
+ */
259
+ function useCardShell(props: BaseCardProps) {
260
+ const actionable = isActionable(props.state);
261
+ return {
262
+ selectable: isSelectable(props),
263
+ actionable,
264
+ theme: useTheme(),
265
+ slot: slotTestIds(props.testId),
266
+ scale: props.scale ?? 1,
267
+ // The card's own veto over the container's decision — see `CardSurfaceProps`.
268
+ drag: useDragItem(props.draggable === false || !actionable ? undefined : props.dragId),
269
+ };
270
+ }
271
+
247
272
  export function BaseCard(props: BaseCardProps): React.JSX.Element {
248
- const { aspectRatio = "4:3", scale = 1, selected = false, dimmed = false } = props;
249
- const { menu, onToggleSelect, onClick, dataTestId, checkboxTestId } = props;
273
+ const { aspectRatio = "4:3", selected = false } = props;
274
+ const { menu, onToggleSelect, onClick } = props;
275
+ const { selectable, actionable, theme, slot, scale, drag } = useCardShell(props);
276
+ const dataTestId = props.testId;
277
+ const checkboxTestId = slot("checkbox");
250
278
  const pad = 1.5 * scale;
251
279
 
252
280
  return (
@@ -255,11 +283,53 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
255
283
  borderRadius="lg"
256
284
  onClick={onClick}
257
285
  dataTestId={dataTestId}
258
- sx={cardSx({ aspectRatio, pad, interactive: onClick != null, dimmed, selected })}
286
+ className={props.className}
287
+ aria-label={props["aria-label"]}
288
+ aria-disabled={actionable ? undefined : true}
289
+ {...drag.itemProps}
290
+ // A PLAIN OBJECT, not a function. `Card` merges by spreading
291
+ // (`sx={{ ...defaults, ...sx }}`), and spreading a function yields
292
+ // nothing at all — the styles vanish silently, which is exactly how the
293
+ // first version of this shipped with no variants and no effects.
294
+ sx={{
295
+ // The shared half — variant, colour, selection, effects — identical to
296
+ // the one BaseListCard uses, so a Grade and a Lista of the same records
297
+ // are dressed by one set of rules.
298
+ ...cardSurfaceStyles(
299
+ {
300
+ ...props,
301
+ selectable,
302
+ shape: "tile",
303
+ // A card mid-drag is a ghost of itself; the drop target is what the
304
+ // eye should be on.
305
+ state: drag.dragging ? "disabled" : props.state,
306
+ },
307
+ theme,
308
+ ),
309
+ // …and the tile's own geometry on top.
310
+ ...cardSx({
311
+ aspectRatio,
312
+ pad,
313
+ interactive: onClick != null,
314
+ draggable: drag.draggable && drag.handleProps === undefined,
315
+ }),
316
+ }}
259
317
  >
318
+ {/* Bottom-left, opposite the menu and clear of the checkbox — a grip in
319
+ the same corner as a control that toggles selection means every drag
320
+ starts with a near-miss on it. */}
321
+ {drag.draggable && (
322
+ <Box sx={{ position: "absolute", bottom: 4, left: 4, zIndex: 2 }}>
323
+ <DragHandle
324
+ handleProps={drag.handleProps}
325
+ gated={drag.handleProps !== undefined}
326
+ testId={slot("drag")}
327
+ />
328
+ </Box>
329
+ )}
260
330
  <CardOverlays
261
331
  selected={selected}
262
- onToggleSelect={onToggleSelect}
332
+ onToggleSelect={selectable ? onToggleSelect ?? (() => {}) : undefined}
263
333
  checkboxTestId={checkboxTestId}
264
334
  menu={menu}
265
335
  />
@@ -0,0 +1,256 @@
1
+ "use client";
2
+
3
+ import { type ReactNode } from "react";
4
+
5
+ import { DescriptionItem, type DescriptionItemProps } from "../DescriptionItem";
6
+ import { Box } from "../../../mui/Box";
7
+ import { Text } from "../../typography/Text";
8
+
9
+ /**
10
+ * WHAT SITS IN EACH RAIL.
11
+ *
12
+ * Split from `base-list-card` at the size gate: that module is the shell, the
13
+ * rails and the states; this is the content of each column.
14
+ */
15
+
16
+ /** Below this the row drops its middle columns. */
17
+ export const META_BREAK = 520;
18
+ /** …and below this it leaves the shared rails and goes two-line. */
19
+ export const STACK_BREAK = 360;
20
+
21
+ /** Money and dates line up only if their digits are the same width. */
22
+ const TABULAR = { fontVariantNumeric: "tabular-nums" } as const;
23
+
24
+ const CLAMP = {
25
+ overflow: "hidden",
26
+ textOverflow: "ellipsis",
27
+ whiteSpace: "nowrap",
28
+ } as const;
29
+
30
+ /**
31
+ * Title over subtitle — and, when the row navigates, the anchor that covers it.
32
+ *
33
+ * The link is a real `<a>` stretched by `::after` over the whole card rather
34
+ * than a click handler on a `<div>`. That buys cmd-click, middle-click, "copy
35
+ * link address", the status bar preview and correct tab order, and it stops the
36
+ * buttons elsewhere in the row from being interactive elements nested inside a
37
+ * clickable one.
38
+ */
39
+ export function ListCardCaption({
40
+ title,
41
+ subtitle,
42
+ href,
43
+ target,
44
+ scale,
45
+ testId,
46
+ }: {
47
+ title?: ReactNode;
48
+ subtitle?: ReactNode;
49
+ href?: string;
50
+ target?: string;
51
+ scale: number;
52
+ testId?: string;
53
+ }): React.JSX.Element {
54
+ return (
55
+ <Box data-slot="caption" sx={{ minWidth: 0 }}>
56
+ {title != null && (
57
+ <Text variant="heading" size="sm" weight="bold" as="p">
58
+ <Box
59
+ {...(href ? { component: "a", href, target } : { component: "span" })}
60
+ data-testid={testId}
61
+ sx={{
62
+ display: "block",
63
+ lineHeight: 1.2,
64
+ fontSize: `${0.9 * scale}rem`,
65
+ color: "inherit",
66
+ textDecoration: "none",
67
+ ...CLAMP,
68
+ ...(href
69
+ ? {
70
+ "&::after": {
71
+ content: '""',
72
+ position: "absolute",
73
+ inset: 0,
74
+ // Under the checkbox, drag grip, actions and menu, which
75
+ // all raise themselves to 1.
76
+ zIndex: 0,
77
+ },
78
+ "&:hover": { textDecoration: "underline" },
79
+ }
80
+ : {}),
81
+ }}
82
+ >
83
+ {title}
84
+ </Box>
85
+ </Text>
86
+ )}
87
+ {subtitle != null && (
88
+ <Text variant="caption" size="xs" color="secondary" as="p">
89
+ <Box component="span" sx={{ display: "block", ...CLAMP }}>
90
+ {subtitle}
91
+ </Box>
92
+ </Text>
93
+ )}
94
+ </Box>
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The middle columns: stacked label-over-value pairs, separated by a rule.
100
+ *
101
+ * Stacked rather than side by side because `DATA 05/08/2026` inline spends the
102
+ * horizontal room the value rail needs, and the separator is what stops two
103
+ * adjacent pairs reading as one four-word phrase.
104
+ */
105
+ export function ListCardMeta({
106
+ meta,
107
+ metaSlot,
108
+ }: {
109
+ meta?: DescriptionItemProps[];
110
+ metaSlot?: ReactNode;
111
+ }): React.JSX.Element {
112
+ const items = meta ?? [];
113
+ return (
114
+ <Box
115
+ data-slot="meta"
116
+ sx={{
117
+ display: "flex",
118
+ alignItems: "center",
119
+ gap: 2,
120
+ whiteSpace: "nowrap",
121
+ ...TABULAR,
122
+ // Rung 1 of the ladder.
123
+ [`@container (max-width: ${META_BREAK}px)`]: { display: "none" },
124
+ }}
125
+ >
126
+ {items.map((item, index) => (
127
+ <Box key={`${item.label}-${index}`} sx={{ display: "flex", alignItems: "center", gap: 2 }}>
128
+ {index > 0 && (
129
+ <Box
130
+ aria-hidden
131
+ sx={{ color: "text.disabled", opacity: 0.5, userSelect: "none", fontSize: "0.875rem" }}
132
+ >
133
+ |
134
+ </Box>
135
+ )}
136
+ {/* Centred: a stacked pair sitting in its own column reads as one
137
+ unit only when the label is over the value rather than flush left
138
+ of a value shorter than it — `MÉTODO` above `PIX` was visibly
139
+ unhinged from it. Overridable per item. */}
140
+ <DescriptionItem orientation="vertical" align="center" {...item} />
141
+ </Box>
142
+ ))}
143
+ {metaSlot}
144
+ </Box>
145
+ );
146
+ }
147
+
148
+ /** The value, on its own rail, never truncated and never shunted by the chip. */
149
+ function ListCardValue({ value, scale }: { value: ReactNode; scale: number }): React.JSX.Element {
150
+ return (
151
+ <Text variant="body" size="sm" weight="medium" as="span">
152
+ <Box
153
+ component="span"
154
+ sx={{ fontSize: `${0.875 * scale}rem`, whiteSpace: "nowrap", ...TABULAR }}
155
+ >
156
+ {value}
157
+ </Box>
158
+ </Text>
159
+ );
160
+ }
161
+
162
+ /**
163
+ * The actions and the menu, at the end of the row.
164
+ *
165
+ * Actions are revealed on hover and `:focus-within` — always-visible buttons on
166
+ * every row of fifty is a lot of chrome for something used on one of them.
167
+ * `:focus-within` is what keeps them reachable by keyboard, and `visibility`
168
+ * rather than `display` keeps their space reserved so nothing shifts.
169
+ */
170
+ function ListCardActions({
171
+ actions,
172
+ alwaysVisible,
173
+ menu,
174
+ testId,
175
+ }: {
176
+ actions?: ReactNode;
177
+ alwaysVisible?: boolean;
178
+ menu?: ReactNode;
179
+ testId: (slot: string) => string | undefined;
180
+ }): React.JSX.Element | null {
181
+ if (actions == null && menu == null) return null;
182
+ return (
183
+ // z-index 1: above the stretched link, so these stay clickable.
184
+ <Box
185
+ data-slot="actions"
186
+ sx={{ display: "flex", alignItems: "center", gap: 0.5, position: "relative", zIndex: 1 }}
187
+ >
188
+ {actions && (
189
+ <Box
190
+ data-testid={testId("actions")}
191
+ onClick={(event) => event.stopPropagation()}
192
+ sx={{
193
+ display: "flex",
194
+ alignItems: "center",
195
+ gap: 0.5,
196
+ ...(alwaysVisible
197
+ ? {}
198
+ : {
199
+ visibility: "hidden",
200
+ ".MuiPaper-root:hover &, .MuiPaper-root:focus-within &": {
201
+ visibility: "visible",
202
+ },
203
+ }),
204
+ }}
205
+ >
206
+ {actions}
207
+ </Box>
208
+ )}
209
+ {menu && (
210
+ <Box data-testid={testId("menu")} onClick={(event) => event.stopPropagation()}>
211
+ {menu}
212
+ </Box>
213
+ )}
214
+ </Box>
215
+ );
216
+ }
217
+
218
+ /** Value, status and actions — three rails, not one flex run. */
219
+ export function ListCardTail({
220
+ value,
221
+ status,
222
+ actions,
223
+ actionsAlwaysVisible,
224
+ menu,
225
+ scale,
226
+ testId,
227
+ }: {
228
+ value?: ReactNode;
229
+ status?: ReactNode;
230
+ actions?: ReactNode;
231
+ actionsAlwaysVisible?: boolean;
232
+ menu?: ReactNode;
233
+ scale: number;
234
+ testId: (slot: string) => string | undefined;
235
+ }): React.JSX.Element {
236
+ return (
237
+ <>
238
+ <Box data-slot="value" sx={{ textAlign: "right" }} data-testid={testId("value")}>
239
+ {value != null && <ListCardValue value={value} scale={scale} />}
240
+ </Box>
241
+ <Box
242
+ data-slot="status"
243
+ sx={{ display: "flex", justifyContent: "flex-start" }}
244
+ data-testid={testId("status")}
245
+ >
246
+ {status}
247
+ </Box>
248
+ <ListCardActions
249
+ actions={actions}
250
+ alwaysVisible={actionsAlwaysVisible}
251
+ menu={menu}
252
+ testId={testId}
253
+ />
254
+ </>
255
+ );
256
+ }