@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.
- package/package.json +4 -4
- package/src/components/data-display/DataViews/DataViewsTableBase.tsx +1 -1
- package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +183 -0
- 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-bar-controls.tsx +119 -0
- package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
- package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
- package/src/components/data-display/DataViews/data-views-drag.tsx +135 -0
- package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
- package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
- package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
- package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
- package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
- package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
- package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
- package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
- package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
- package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
- package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
- package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
- package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
- package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
- package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
- package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
- package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
- package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
- package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
- package/src/components/data-display/DataViews/data-views-types.ts +14 -47
- package/src/components/data-display/DataViews/index.ts +8 -0
- package/src/components/data-display/DataViews/list-card-rails.tsx +141 -0
- package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
- package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
- package/src/components/data-display/DescriptionItem/DescriptionItem.tsx +26 -3
- package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
- package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
- 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.
|
|
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.
|
|
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.
|
|
213
|
-
"@12-apps/typescript-config": "^1.
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
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
|
-
|
|
65
|
-
|
|
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",
|
|
249
|
-
const { menu, onToggleSelect, onClick
|
|
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
|
-
|
|
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
|
+
}
|