@12-apps/ui 1.18.0 → 1.20.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-list-card-disclosure.ts +40 -0
- package/src/components/data-display/DataViews/base-list-card-geometry.tsx +254 -0
- package/src/components/data-display/DataViews/base-list-card-gutters.tsx +83 -4
- package/src/components/data-display/DataViews/base-list-card-slots.tsx +2 -11
- package/src/components/data-display/DataViews/base-list-card.tsx +202 -251
- package/src/components/data-display/DataViews/data-views-drag.tsx +64 -1
- package/src/components/data-display/DataViews/data-views-types.ts +10 -1
- package/src/components/data-display/DataViews/list-card-cells.tsx +160 -0
- package/src/components/data-display/DataViews/list-card-rails.tsx +73 -9
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { Box } from "../../../mui/Box";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* THE CELL CONFIG — the list declares its columns once, every row obeys them.
|
|
9
|
+
*
|
|
10
|
+
* The row used to name its middle slots: a title, a subtitle beneath it, then
|
|
11
|
+
* labelled `meta` pairs, then a value, then a status. That vocabulary was the
|
|
12
|
+
* mistake twice over. It reads as a description of every list, and no list is
|
|
13
|
+
* quite that shape — so a consumer wanting a date over a time, or an order
|
|
14
|
+
* number over the client who placed it, had to pick whichever named slot was
|
|
15
|
+
* least wrong and take its styling with it. And because each row decided its own
|
|
16
|
+
* content, alignment across a list was a convention the rows could break.
|
|
17
|
+
*
|
|
18
|
+
* So cells are CONFIGURED, the way a data grid's columns are: the list declares
|
|
19
|
+
* them once, and every card renders the same columns in the same tracks from its
|
|
20
|
+
* own row. Two cards cannot disagree about the shape of the list, because
|
|
21
|
+
* neither of them is deciding it.
|
|
22
|
+
*
|
|
23
|
+
* THE SECOND LINE IS AN OPTION, NOT A SUBTITLE. `secondary` is not "the small
|
|
24
|
+
* grey text under the title" — it is the cell's second line, for whatever pairs
|
|
25
|
+
* naturally in this list: date over time, name over e-mail, pedido over cliente.
|
|
26
|
+
* It carries no meaning the config did not put there, and a cell that wants one
|
|
27
|
+
* line simply omits it.
|
|
28
|
+
*/
|
|
29
|
+
export interface ListCardCellConfig<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
30
|
+
/** Stable key. Also the cell's `data-slot`, so a test can find it by name. */
|
|
31
|
+
id: string;
|
|
32
|
+
/** The cell's first line, from the row. */
|
|
33
|
+
primary: (row: T) => ReactNode;
|
|
34
|
+
/** An optional second line beneath it. Anything that pairs with the first. */
|
|
35
|
+
secondary?: (row: T) => ReactNode;
|
|
36
|
+
/**
|
|
37
|
+
* Which edge the lines sit on. `end` is what a column of numbers wants, so its
|
|
38
|
+
* digits line up with the values above and below it — and it switches the cell
|
|
39
|
+
* to tabular figures, since nothing else ever asks for that alignment.
|
|
40
|
+
*/
|
|
41
|
+
align?: "start" | "center" | "end";
|
|
42
|
+
/**
|
|
43
|
+
* This cell's track, as a `grid-template-columns` entry. Defaults to an even
|
|
44
|
+
* share of the spare width.
|
|
45
|
+
*
|
|
46
|
+
* A cell holding a number usually wants `max-content`: the rail is then as
|
|
47
|
+
* wide as the widest amount in the list and no wider, and its right edge does
|
|
48
|
+
* not move when a longer row arrives.
|
|
49
|
+
*/
|
|
50
|
+
width?: string;
|
|
51
|
+
/** Emphasise the first line — for the one figure the row is really about. */
|
|
52
|
+
strong?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ALIGN_ITEMS = { start: "flex-start", center: "center", end: "flex-end" } as const;
|
|
56
|
+
const TEXT_ALIGN = { start: "left", center: "center", end: "right" } as const;
|
|
57
|
+
|
|
58
|
+
/** The default track: an even share of whatever width the row has spare. */
|
|
59
|
+
export const DEFAULT_CELL_WIDTH = "minmax(0, 1fr)";
|
|
60
|
+
|
|
61
|
+
/** The tracks a cell config asks for, in order. */
|
|
62
|
+
export function cellTracks(cells: readonly ListCardCellConfig<never>[] | undefined): string[] {
|
|
63
|
+
return (cells ?? []).map((cell) => cell.width ?? DEFAULT_CELL_WIDTH);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The config handed down by the list.
|
|
68
|
+
*
|
|
69
|
+
* A card inside a group takes its columns from here rather than from props, so
|
|
70
|
+
* there is exactly one declaration of the list's shape and no way for a row to
|
|
71
|
+
* hold a different one.
|
|
72
|
+
*/
|
|
73
|
+
const CellConfigContext = createContext<readonly ListCardCellConfig<never>[] | null>(null);
|
|
74
|
+
|
|
75
|
+
export function CellConfigProvider({
|
|
76
|
+
cells,
|
|
77
|
+
children,
|
|
78
|
+
}: {
|
|
79
|
+
cells: readonly ListCardCellConfig<never>[] | undefined;
|
|
80
|
+
children: ReactNode;
|
|
81
|
+
}): React.JSX.Element {
|
|
82
|
+
return <CellConfigContext.Provider value={cells ?? null}>{children}</CellConfigContext.Provider>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The list's cell config, if it declared one. */
|
|
86
|
+
export function useCellConfig(): readonly ListCardCellConfig<never>[] | null {
|
|
87
|
+
return useContext(CellConfigContext);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One cell rendered from its config.
|
|
92
|
+
*
|
|
93
|
+
* Both lines truncate rather than wrap: a row is a fixed-height scanning unit,
|
|
94
|
+
* and a cell that grows to two lines because one name was long drags every rail
|
|
95
|
+
* on that row down with it. Whatever does not fit belongs in the expandable
|
|
96
|
+
* body, which is the entire reason the body exists.
|
|
97
|
+
*/
|
|
98
|
+
function Cell<T extends Record<string, unknown>>({
|
|
99
|
+
config,
|
|
100
|
+
row,
|
|
101
|
+
}: {
|
|
102
|
+
config: ListCardCellConfig<T>;
|
|
103
|
+
row: T;
|
|
104
|
+
}): React.JSX.Element {
|
|
105
|
+
const align = config.align ?? "start";
|
|
106
|
+
const numeric = align === "end" ? { fontVariantNumeric: "tabular-nums" } : {};
|
|
107
|
+
const clamp = {
|
|
108
|
+
minWidth: 0,
|
|
109
|
+
maxWidth: "100%",
|
|
110
|
+
overflow: "hidden",
|
|
111
|
+
textOverflow: "ellipsis",
|
|
112
|
+
whiteSpace: "nowrap",
|
|
113
|
+
} as const;
|
|
114
|
+
const secondary = config.secondary?.(row);
|
|
115
|
+
return (
|
|
116
|
+
<Box
|
|
117
|
+
data-slot={`cell-${config.id}`}
|
|
118
|
+
sx={{
|
|
119
|
+
minWidth: 0,
|
|
120
|
+
display: "flex",
|
|
121
|
+
flexDirection: "column",
|
|
122
|
+
alignItems: ALIGN_ITEMS[align],
|
|
123
|
+
justifyContent: "center",
|
|
124
|
+
textAlign: TEXT_ALIGN[align],
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<Box
|
|
128
|
+
component="span"
|
|
129
|
+
sx={{ ...clamp, fontSize: 14, fontWeight: config.strong ? 700 : 600, lineHeight: 1.25, ...numeric }}
|
|
130
|
+
>
|
|
131
|
+
{config.primary(row)}
|
|
132
|
+
</Box>
|
|
133
|
+
{secondary != null && (
|
|
134
|
+
<Box
|
|
135
|
+
component="span"
|
|
136
|
+
sx={{ ...clamp, fontSize: 12.5, lineHeight: 1.3, color: "text.secondary", ...numeric }}
|
|
137
|
+
>
|
|
138
|
+
{secondary}
|
|
139
|
+
</Box>
|
|
140
|
+
)}
|
|
141
|
+
</Box>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every configured cell of a row, in the list's declared order. */
|
|
146
|
+
export function ListCardCells<T extends Record<string, unknown>>({
|
|
147
|
+
cells,
|
|
148
|
+
row,
|
|
149
|
+
}: {
|
|
150
|
+
cells: readonly ListCardCellConfig<T>[];
|
|
151
|
+
row: T;
|
|
152
|
+
}): React.JSX.Element {
|
|
153
|
+
return (
|
|
154
|
+
<>
|
|
155
|
+
{cells.map((config) => (
|
|
156
|
+
<Cell key={config.id} config={config} row={row} />
|
|
157
|
+
))}
|
|
158
|
+
</>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -4,6 +4,7 @@ import { createContext, useContext, type ReactNode } from "react";
|
|
|
4
4
|
|
|
5
5
|
import { Box } from "../../../mui/Box";
|
|
6
6
|
import { DENSITY_ROW_PADDING, type DataViewsDensity } from "./data-views-layout-context";
|
|
7
|
+
import { CellConfigProvider, cellTracks, type ListCardCellConfig } from "./list-card-cells";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* THE LIST OWNS THE COLUMNS, NOT THE ROW.
|
|
@@ -27,6 +28,15 @@ import { DENSITY_ROW_PADDING, type DataViewsDensity } from "./data-views-layout-
|
|
|
27
28
|
|
|
28
29
|
/** One rail. Fixed where alignment matters, flexible where it does not. */
|
|
29
30
|
export interface ListRails {
|
|
31
|
+
/**
|
|
32
|
+
* The disclosure chevron, ahead of everything.
|
|
33
|
+
*
|
|
34
|
+
* Reserved on the same terms as the gutters behind it: a list where only SOME
|
|
35
|
+
* rows carry an expandable body would otherwise indent those rows and no
|
|
36
|
+
* others, and a column of captions that starts at two different offsets reads
|
|
37
|
+
* as a rendering fault rather than as a row you can open.
|
|
38
|
+
*/
|
|
39
|
+
disclose: string;
|
|
30
40
|
/** Reserved even when nothing drags: toggling drag mode must not shift the list. */
|
|
31
41
|
drag: string;
|
|
32
42
|
/** Reserved even when nothing is selectable, for the same reason. */
|
|
@@ -38,8 +48,6 @@ export interface ListRails {
|
|
|
38
48
|
meta: string;
|
|
39
49
|
/** The value's own rail, with a fixed right edge. */
|
|
40
50
|
value: string;
|
|
41
|
-
/** Wide enough for the longest status this list holds. */
|
|
42
|
-
status: string;
|
|
43
51
|
actions: string;
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -83,6 +91,7 @@ export function contentRails(metaColumns: number): Pick<ListRails, "caption" | "
|
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
export const DEFAULT_RAILS: ListRails = {
|
|
94
|
+
disclose: "auto",
|
|
86
95
|
drag: "auto",
|
|
87
96
|
select: "auto",
|
|
88
97
|
leading: "auto",
|
|
@@ -94,14 +103,21 @@ export const DEFAULT_RAILS: ListRails = {
|
|
|
94
103
|
// NOT an `fr`: the value's whole promise is a fixed right edge, and a rail
|
|
95
104
|
// that grows with the spare width moves that edge on every list.
|
|
96
105
|
value: "max-content",
|
|
97
|
-
status: "max-content",
|
|
98
106
|
actions: "auto",
|
|
99
107
|
};
|
|
100
108
|
|
|
101
|
-
interface ListRailsValue {
|
|
109
|
+
export interface ListRailsValue {
|
|
102
110
|
density: DataViewsDensity;
|
|
103
111
|
/** Whether the gutters are held open even when empty. */
|
|
104
112
|
reserveGutters: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* How many tracks a row spans.
|
|
115
|
+
*
|
|
116
|
+
* Fixed at {@link RAIL_COUNT} for the named-slot layout, but a cell-configured
|
|
117
|
+
* list has as many tracks as the config declares, and every row must span the
|
|
118
|
+
* same number or subgrid tears.
|
|
119
|
+
*/
|
|
120
|
+
railCount: number;
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
const ListRailsContext = createContext<ListRailsValue | null>(null);
|
|
@@ -122,13 +138,13 @@ export function useListRails(): ListRailsValue | null {
|
|
|
122
138
|
*/
|
|
123
139
|
export function railsTemplate(rails: RailOverrides): string {
|
|
124
140
|
return [
|
|
141
|
+
rails.disclose,
|
|
125
142
|
rails.drag,
|
|
126
143
|
rails.select,
|
|
127
144
|
rails.leading,
|
|
128
145
|
rails.caption,
|
|
129
146
|
rails.meta,
|
|
130
147
|
rails.value,
|
|
131
|
-
rails.status,
|
|
132
148
|
rails.actions,
|
|
133
149
|
]
|
|
134
150
|
.filter((rail): rail is string => rail != null)
|
|
@@ -163,7 +179,7 @@ export const RAIL_GAP_PX = RAIL_GAP * 8;
|
|
|
163
179
|
* mode on does not shift every row sideways.
|
|
164
180
|
*/
|
|
165
181
|
export function railsTemplateFor(
|
|
166
|
-
gutters: { drag: boolean; select: boolean },
|
|
182
|
+
gutters: { disclose: boolean; drag: boolean; select: boolean },
|
|
167
183
|
metaColumns: number,
|
|
168
184
|
): string {
|
|
169
185
|
return railsTemplate({
|
|
@@ -171,14 +187,42 @@ export function railsTemplateFor(
|
|
|
171
187
|
// Standalone, the card can simply COUNT its pairs — no `metaColumns` to be
|
|
172
188
|
// told, and no guess.
|
|
173
189
|
...contentRails(metaColumns),
|
|
190
|
+
...(gutters.disclose ? {} : { disclose: null }),
|
|
174
191
|
...(gutters.drag ? {} : { drag: null }),
|
|
175
192
|
...(gutters.select ? {} : { select: null }),
|
|
176
193
|
});
|
|
177
194
|
}
|
|
178
195
|
|
|
179
|
-
/** How many rails
|
|
196
|
+
/** How many rails the named-slot layout has — the span such a card claims. */
|
|
180
197
|
export const RAIL_COUNT = 8;
|
|
181
198
|
|
|
199
|
+
/**
|
|
200
|
+
* The gutters and the trailing menu that bracket a CONFIGURED row.
|
|
201
|
+
*
|
|
202
|
+
* A cell-configured list still has a head and a tail the config does not
|
|
203
|
+
* describe: the disclosure chevron, the drag grip and the checkbox in front, and
|
|
204
|
+
* the overflow menu behind. The menu is not optional — it is where a row's
|
|
205
|
+
* actions live, and a list that hid it would have nowhere to put them.
|
|
206
|
+
*/
|
|
207
|
+
const CELL_FIXED_RAILS = 4;
|
|
208
|
+
|
|
209
|
+
/** Tracks for a cell-configured row: gutters, leading, the cells, then the menu. */
|
|
210
|
+
export function cellRailsTemplate(cells: readonly string[]): string {
|
|
211
|
+
return [
|
|
212
|
+
DEFAULT_RAILS.disclose,
|
|
213
|
+
DEFAULT_RAILS.drag,
|
|
214
|
+
DEFAULT_RAILS.select,
|
|
215
|
+
DEFAULT_RAILS.leading,
|
|
216
|
+
...cells,
|
|
217
|
+
DEFAULT_RAILS.actions,
|
|
218
|
+
].join(" ");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** How many tracks such a row spans — the fixed head/tail plus one per cell. */
|
|
222
|
+
export function cellRailCount(cells: number): number {
|
|
223
|
+
return CELL_FIXED_RAILS + cells + 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
182
226
|
/**
|
|
183
227
|
* A list of {@link BaseListCard}s that share one set of columns.
|
|
184
228
|
*
|
|
@@ -191,6 +235,7 @@ export const RAIL_COUNT = 8;
|
|
|
191
235
|
export function ListCardGroup({
|
|
192
236
|
children,
|
|
193
237
|
density = "cozy",
|
|
238
|
+
cells,
|
|
194
239
|
metaColumns,
|
|
195
240
|
rails,
|
|
196
241
|
reserveGutters = true,
|
|
@@ -199,6 +244,20 @@ export function ListCardGroup({
|
|
|
199
244
|
}: {
|
|
200
245
|
children: ReactNode;
|
|
201
246
|
density?: DataViewsDensity;
|
|
247
|
+
/**
|
|
248
|
+
* The list's CELL CONFIG — declared once here, obeyed by every row.
|
|
249
|
+
*
|
|
250
|
+
* This is what makes the columns line up by construction rather than by
|
|
251
|
+
* convention: the group resolves the tracks and hands the same config to every
|
|
252
|
+
* card, so two rows cannot disagree about the shape of the list because
|
|
253
|
+
* neither of them decides it.
|
|
254
|
+
*
|
|
255
|
+
* Supplying this replaces the named-slot layout (title/subtitle/meta/value/
|
|
256
|
+
* status) for the rows inside — they render `row` through these cells instead.
|
|
257
|
+
* The gutters and the overflow menu are unaffected; they bracket the cells and
|
|
258
|
+
* are not the config's to describe.
|
|
259
|
+
*/
|
|
260
|
+
cells?: readonly ListCardCellConfig<never>[];
|
|
202
261
|
/**
|
|
203
262
|
* How many labelled pairs the rows in this list carry.
|
|
204
263
|
*
|
|
@@ -226,8 +285,12 @@ export function ListCardGroup({
|
|
|
226
285
|
...(metaColumns == null ? {} : contentRails(metaColumns)),
|
|
227
286
|
...rails,
|
|
228
287
|
};
|
|
288
|
+
const configured = cells != null && cells.length > 0;
|
|
289
|
+
const template = configured ? cellRailsTemplate(cellTracks(cells)) : railsTemplate(resolved);
|
|
290
|
+
const railCount = configured ? cellRailCount(cells.length) : RAIL_COUNT;
|
|
229
291
|
return (
|
|
230
|
-
<ListRailsContext.Provider value={{ density, reserveGutters }}>
|
|
292
|
+
<ListRailsContext.Provider value={{ density, reserveGutters, railCount }}>
|
|
293
|
+
<CellConfigProvider cells={cells}>
|
|
231
294
|
<Box
|
|
232
295
|
data-testid={dataTestId}
|
|
233
296
|
sx={{
|
|
@@ -237,13 +300,14 @@ export function ListCardGroup({
|
|
|
237
300
|
// honest place to ask the question.
|
|
238
301
|
containerType: "inline-size",
|
|
239
302
|
display: "grid",
|
|
240
|
-
gridTemplateColumns:
|
|
303
|
+
gridTemplateColumns: template,
|
|
241
304
|
alignItems: "center",
|
|
242
305
|
rowGap: gap ?? DENSITY_ROW_PADDING[density],
|
|
243
306
|
}}
|
|
244
307
|
>
|
|
245
308
|
{children}
|
|
246
309
|
</Box>
|
|
310
|
+
</CellConfigProvider>
|
|
247
311
|
</ListRailsContext.Provider>
|
|
248
312
|
);
|
|
249
313
|
}
|