@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.
@@ -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 there are — the span a card claims. */
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: railsTemplate(resolved),
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
  }