@lotics/ui 11.4.0 → 11.6.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/src/table.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  createContext,
3
3
  useContext,
4
+ useState,
4
5
  Children,
5
6
  cloneElement,
6
7
  isValidElement,
@@ -12,24 +13,26 @@ import { Text } from "./text";
12
13
  import { colors } from "./colors";
13
14
  import { PressableRow } from "./pressable_row";
14
15
  import { Divider } from "./divider";
16
+ import { DetailRow } from "./detail_row";
15
17
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
16
18
  import { FOCUS_RING } from "./control_surface";
17
19
  import { useFocusRing } from "./use_focus_ring";
20
+ import { COLUMN_GAP, ROW_GUTTER, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
18
21
 
19
22
  /**
20
23
  * One column of a register — its width/flex/align/label/sortability defined ONCE,
21
- * here, instead of being re-typed in the header band AND every row.
24
+ * here, instead of being re-typed in the header band AND every row. The fit
25
+ * fields (`key`/`width`/`priority`) live on `TableFitColumn` — the pure fit
26
+ * layer in `table_fit.ts` that decides which columns survive a narrow container.
22
27
  */
23
- export interface TableColumn {
24
- /** Stable id also the `sortKey` when `sortable`. */
25
- key: string;
26
- /** Header label (uppercase eyebrow). Omit for a control column (a trailing ⋯). */
28
+ export interface TableColumn extends TableFitColumn {
29
+ /** Header label (uppercase eyebrow). Omit for a control column (a trailing ⋯).
30
+ * In stacked mode it renders as the eyebrow over the cell's value. */
27
31
  label?: string;
28
- /** Fixed width in px; omit for a flexible column. */
29
- width?: number;
30
32
  /** Flex grow when no `width` (default 1). */
31
33
  flex?: number;
32
34
  align?: "left" | "right";
35
+ /** The `key` doubles as the `sortKey`. */
33
36
  sortable?: boolean;
34
37
  }
35
38
 
@@ -37,6 +40,8 @@ interface TableCtx {
37
40
  columns: TableColumn[];
38
41
  leading: number;
39
42
  trailing: number;
43
+ visibleKeys: ReadonlySet<string>;
44
+ stacked: boolean;
40
45
  }
41
46
  const TableContext = createContext<TableCtx | null>(null);
42
47
 
@@ -73,34 +78,64 @@ export interface TableProps {
73
78
  * sortable column becomes a `SortHeader`) and its `TableRow` children,
74
79
  * `Divider`-separated. Compose `TableRow` / `TableCell` for the body. For a
75
80
  * non-columnar list (entity piles, card stacks) use `PressableRow` directly.
81
+ *
82
+ * The register is container-responsive with no prop: when the measured width
83
+ * can't fit every column it drops droppable columns by `priority`, and below
84
+ * the register floor rows stack (label over value, from the column `label`s) —
85
+ * the header band gives way (sorting rides the columns; a stacked register
86
+ * keeps its current order). See `computeTableFit`.
76
87
  */
77
88
  export function Table(props: TableProps) {
78
89
  const { columns, sort, onSort, sortLabels, selectAll, leading = 0, trailing = 0, children } = props;
79
90
  const rows = Children.toArray(children).filter(isValidElement);
80
91
 
92
+ // Measure-then-REVEAL (the `DetailTable` contract): the unmeasured first
93
+ // frame renders invisible, so the first PAINT is already in the right mode —
94
+ // no register→stacked reshuffle as the screen mounts.
95
+ const [width, setWidth] = useState<number | null>(null);
96
+ const fit: TableFit =
97
+ width == null
98
+ ? { visibleKeys: new Set(columns.map((c) => c.key)), stacked: false }
99
+ : computeTableFit(columns, leading, trailing, width);
100
+ const visibleColumns = columns.filter((c) => fit.visibleKeys.has(c.key));
101
+
81
102
  return (
82
- <TableContext.Provider value={{ columns, leading, trailing }}>
103
+ <TableContext.Provider value={{ columns, leading, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
83
104
  {/* ONE layout node: without this wrapper the header band + body land as two
84
105
  direct flex children of the app's container, and a parent column `gap`
85
106
  (the standard section spacing) opens a hole between the header and rows. */}
86
- <View>
87
- <View style={styles.headerBand}>
88
- {leading > 0 ? <View style={{ width: leading }}>{selectAll}</View> : null}
89
- {columns.map((col) => (
90
- <View key={col.key} style={colStyle(col)}>
91
- {col.label ? (
92
- col.sortable && onSort ? (
93
- <SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
94
- ) : (
95
- <Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
96
- {col.label}
97
- </Text>
98
- )
99
- ) : null}
107
+ <View
108
+ onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
109
+ style={width == null ? styles.unmeasured : null}
110
+ >
111
+ {fit.stacked ? (
112
+ // Stacked mode has no column band — labels moved into the rows. The
113
+ // select-all checkbox keeps its band: it's the bulk-select entry point,
114
+ // aligned over the rows' leading checkboxes.
115
+ selectAll != null && leading > 0 ? (
116
+ <View style={styles.headerBand}>
117
+ <View style={{ width: leading }}>{selectAll}</View>
100
118
  </View>
101
- ))}
102
- {trailing > 0 ? <View style={{ width: trailing }} /> : null}
103
- </View>
119
+ ) : null
120
+ ) : (
121
+ <View style={styles.headerBand}>
122
+ {leading > 0 ? <View style={{ width: leading }}>{selectAll}</View> : null}
123
+ {visibleColumns.map((col) => (
124
+ <View key={col.key} style={colStyle(col)}>
125
+ {col.label ? (
126
+ col.sortable && onSort ? (
127
+ <SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
128
+ ) : (
129
+ <Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
130
+ {col.label}
131
+ </Text>
132
+ )
133
+ ) : null}
134
+ </View>
135
+ ))}
136
+ {trailing > 0 ? <View style={{ width: trailing }} /> : null}
137
+ </View>
138
+ )}
104
139
  <View style={styles.body}>
105
140
  {rows.map((row, i) => (
106
141
  <View key={i}>
@@ -120,9 +155,15 @@ export type TableRowProps = {
120
155
  marked?: boolean;
121
156
  /** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
122
157
  leading?: ReactNode;
123
- /** Outside the door, AFTER the cells (a menu / a button) — width = Table `trailing`. */
158
+ /** Outside the door, AFTER the cells: the row's OVERFLOW chrome (a `ActionMenu`).
159
+ * Register mode renders it in the trailing gutter beside `action`; stacked mode keeps
160
+ * it on the top line. Width = Table `trailing` (shared with `action`). */
124
161
  trailing?: ReactNode;
125
- /** Min row height. Default 52. */
162
+ /** The row's primary action (a CTA `Button`). Register mode: in the trailing gutter,
163
+ * before `trailing`; stacked mode: the content's bottom line, right-aligned — an action
164
+ * reads at content scale, not as row chrome. Reserve Table `trailing` for it. */
165
+ action?: ReactNode;
166
+ /** Min row height (register mode; stacked rows size to their content). Default 52. */
126
167
  minHeight?: number;
127
168
  /** The `TableCell`s, one per column, in column order. */
128
169
  children: ReactNode;
@@ -151,34 +192,68 @@ export type TableRowProps = {
151
192
  * row, rounded to the register wash); mouse presses ride the `PressableRow`
152
193
  * surface, and nested controls claim their own. `leading`/`trailing` slots stay
153
194
  * independently pressable. Cells map to `Table`'s columns by position.
195
+ *
196
+ * In the Table's STACKED mode (container below the register floor) the same
197
+ * row renders as a pile: a top line of [leading] [first cell] [trailing], then
198
+ * the remaining cells as label-over-value blocks — same door, same wash, same
199
+ * slots, different geometry.
154
200
  */
155
201
  export function TableRow(props: TableRowProps) {
156
- const { onPress, selected, marked, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
202
+ const { onPress, selected, marked, accessibilityLabel, leading, trailing, action, minHeight = 52, children } = props;
157
203
  const ctx = useContext(TableContext);
158
204
  if (!ctx) throw new Error("TableRow must be used within a Table");
159
205
 
160
- const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[]).map(
161
- (cell, i) => cloneElement(cell, { _column: ctx.columns[i] }),
162
- );
206
+ const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[])
207
+ .map((cell, i) => ({ cell, column: ctx.columns[i] as TableColumn | undefined }))
208
+ // A dropped column drops its cell (stacked shows everything). Cells beyond
209
+ // the declared columns stay — same tolerance as the width mapping.
210
+ .filter(({ column }) => ctx.stacked || column == null || ctx.visibleKeys.has(column.key))
211
+ .map(({ cell, column }) => cloneElement(cell, { _column: column, _stacked: ctx.stacked }));
163
212
  const { focusVisible, focusProps } = useFocusRing();
164
213
 
214
+ const body = ctx.stacked ? (
215
+ <View style={styles.stackedBody}>
216
+ <View style={styles.stackedTopLine}>
217
+ {ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
218
+ <View style={styles.stackedPrimary}>{cells[0]}</View>
219
+ {trailing != null ? <View style={styles.slot}>{trailing}</View> : null}
220
+ </View>
221
+ {cells.length > 1 ? (
222
+ // The field lines sit on the identity column's text edge — indented
223
+ // past the leading gutter, never wrapping under the checkbox.
224
+ <View style={[styles.stackedFields, ctx.leading > 0 ? { paddingLeft: ctx.leading + COLUMN_GAP } : null]}>
225
+ {cells.slice(1)}
226
+ </View>
227
+ ) : null}
228
+ {action != null ? <View style={styles.stackedActionLine}>{action}</View> : null}
229
+ </View>
230
+ ) : null;
231
+
165
232
  // Read-only row (no `onPress`): a static, non-interactive row — no hover wash,
166
233
  // no pointer cursor, no focusable door — so a `Table` can present read-only
167
234
  // tabular data (a fee breakdown, a spec sheet) without implying the rows open
168
235
  // something. An interactive row (with `onPress`) keeps the full register surface.
169
236
  if (!onPress) {
237
+ if (ctx.stacked) {
238
+ return <View style={styles.staticStackedRow}>{body}</View>;
239
+ }
170
240
  return (
171
241
  <View style={styles.staticRow}>
172
242
  {ctx.leading > 0 ? <View style={{ width: ctx.leading }}>{leading}</View> : null}
173
243
  <View style={[styles.cells, { minHeight }]}>{cells}</View>
174
- {ctx.trailing > 0 ? <View style={{ width: ctx.trailing, alignItems: "flex-end" }}>{trailing}</View> : null}
244
+ {ctx.trailing > 0 ? (
245
+ <View style={[styles.trailingSlot, { width: ctx.trailing }]}>
246
+ {action}
247
+ {trailing}
248
+ </View>
249
+ ) : null}
175
250
  </View>
176
251
  );
177
252
  }
178
253
 
179
254
  return (
180
255
  <PressableRow onPress={onPress} selected={selected} marked={marked} style={styles.row}>
181
- {ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
256
+ {!ctx.stacked && ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
182
257
  {/* An absolutely-positioned sibling paints — and hit-tests — above in-flow
183
258
  content, which would swallow every cell control's press; the slots lift
184
259
  above it via zIndex 1 (z-index applies to flex items), so the door gets
@@ -191,8 +266,19 @@ export function TableRow(props: TableRowProps) {
191
266
  {...focusProps}
192
267
  style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
193
268
  />
194
- <View style={[styles.cells, { minHeight }]}>{cells}</View>
195
- {ctx.trailing > 0 ? <View style={[styles.slot, { width: ctx.trailing, alignItems: "flex-end" }]}>{trailing}</View> : null}
269
+ {ctx.stacked ? (
270
+ body
271
+ ) : (
272
+ <>
273
+ <View style={[styles.cells, { minHeight }]}>{cells}</View>
274
+ {ctx.trailing > 0 ? (
275
+ <View style={[styles.slot, styles.trailingSlot, { width: ctx.trailing }]}>
276
+ {action}
277
+ {trailing}
278
+ </View>
279
+ ) : null}
280
+ </>
281
+ )}
196
282
  </PressableRow>
197
283
  );
198
284
  }
@@ -201,11 +287,26 @@ export interface TableCellProps {
201
287
  children: ReactNode;
202
288
  /** @internal — injected by `TableRow` from the column at this cell's position. */
203
289
  _column?: TableColumn;
290
+ /** @internal — injected by `TableRow`; the Table is in stacked mode. */
291
+ _stacked?: boolean;
204
292
  }
205
293
 
206
- /** One cell — its width/align come from the column `TableRow` injects by position. */
294
+ /** One cell — its width/align come from the column `TableRow` injects by position.
295
+ * In stacked mode it IS a `DetailRow` (spread: muted label left, value at the
296
+ * right edge) — the drawer's detail-row component, not a lookalike, so a
297
+ * stacked register and the record workspace behind its door share one
298
+ * vocabulary by construction. `DetailRow` values are arbitrary nodes (the
299
+ * drawer renders badges, money stacks, editors, popover triggers in them), so
300
+ * register cell content needs no adaptation. A label-less control column has
301
+ * no line to spread — its content pins to the right edge. */
207
302
  export function TableCell(props: TableCellProps) {
208
- const { children, _column } = props;
303
+ const { children, _column, _stacked } = props;
304
+ if (_stacked) {
305
+ if (!_column?.label) {
306
+ return <View style={styles.stackedBareCell}>{children}</View>;
307
+ }
308
+ return <DetailRow label={_column.label}>{children}</DetailRow>;
309
+ }
209
310
  return <View style={_column ? colStyle(_column) : undefined}>{children}</View>;
210
311
  }
211
312
 
@@ -213,11 +314,11 @@ const styles = StyleSheet.create({
213
314
  // A hairline under the column header anchors the columns; the rows below it are
214
315
  // Divider-separated.
215
316
  headerBand: {
216
- paddingHorizontal: 20,
317
+ paddingHorizontal: ROW_GUTTER,
217
318
  paddingVertical: 10,
218
319
  flexDirection: "row",
219
320
  alignItems: "center",
220
- gap: 14,
321
+ gap: COLUMN_GAP,
221
322
  borderBottomWidth: 1,
222
323
  borderColor: colors.border,
223
324
  },
@@ -226,15 +327,22 @@ const styles = StyleSheet.create({
226
327
  gap: 0,
227
328
  },
228
329
  row: {
229
- gap: 14,
330
+ gap: COLUMN_GAP,
230
331
  },
231
332
  // Read-only row (a `TableRow` with no `onPress`): the register row's gutter +
232
333
  // layout so cells still align with the header, minus the hover/press surface.
233
334
  staticRow: {
234
335
  flexDirection: "row",
235
336
  alignItems: "center",
236
- gap: 14,
237
- paddingHorizontal: 20,
337
+ gap: COLUMN_GAP,
338
+ paddingHorizontal: ROW_GUTTER,
339
+ },
340
+ // Row-direction like the pressable path — the body fills the width via
341
+ // `flex: 1` on the horizontal axis and sizes its own height (a `flex: 1`
342
+ // child of an auto-height COLUMN parent risks collapsing to zero).
343
+ staticStackedRow: {
344
+ flexDirection: "row",
345
+ paddingHorizontal: ROW_GUTTER,
238
346
  },
239
347
  // The keyboard door: an empty overlay spanning the row — the tab stop + focus
240
348
  // ring live here, the cells are its SIBLINGS (see TableRow's doc). Radius
@@ -251,7 +359,7 @@ const styles = StyleSheet.create({
251
359
  flex: 1,
252
360
  flexDirection: "row",
253
361
  alignItems: "center",
254
- gap: 14,
362
+ gap: COLUMN_GAP,
255
363
  zIndex: 1,
256
364
  },
257
365
  // Leading/trailing gutters carry their own controls — lifted above the door
@@ -259,4 +367,53 @@ const styles = StyleSheet.create({
259
367
  slot: {
260
368
  zIndex: 1,
261
369
  },
370
+ // Stacked (below the register floor): the row is a pile — top line keeps the
371
+ // leading/trailing slots on the first cell, the rest are label-over-value
372
+ // blocks. Vertical padding replaces the register's minHeight centering.
373
+ stackedBody: {
374
+ flex: 1,
375
+ gap: 10,
376
+ paddingVertical: 12,
377
+ zIndex: 1,
378
+ },
379
+ stackedFields: {
380
+ gap: 8,
381
+ },
382
+ // The register's trailing gutter composes [action, overflow] on one row —
383
+ // the gap matches the composite the templates previously hand-rolled.
384
+ trailingSlot: {
385
+ flexDirection: "row",
386
+ alignItems: "center",
387
+ justifyContent: "flex-end",
388
+ gap: 8,
389
+ },
390
+ // Stacked mode: the CTA closes the content, right-aligned — content scale,
391
+ // not row chrome (the overflow ⋯ stays on the top line).
392
+ stackedActionLine: {
393
+ flexDirection: "row",
394
+ justifyContent: "flex-end",
395
+ },
396
+ stackedTopLine: {
397
+ flexDirection: "row",
398
+ alignItems: "center",
399
+ gap: COLUMN_GAP,
400
+ minHeight: 40,
401
+ },
402
+ stackedPrimary: {
403
+ flex: 1,
404
+ minWidth: 0,
405
+ },
406
+ // A label-less cell in stacked mode: no label to spread against — content
407
+ // pins right, on the `DetailRow` line rhythm.
408
+ stackedBareCell: {
409
+ flexDirection: "row",
410
+ justifyContent: "flex-end",
411
+ alignItems: "center",
412
+ minHeight: 28,
413
+ },
414
+ // Measure-then-reveal: invisible until the container width is known, so the
415
+ // first visible frame is already register OR stacked — never a reshuffle.
416
+ unmeasured: {
417
+ opacity: 0,
418
+ },
262
419
  });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The fit layer for `Table` — the pure decision of which columns fit a measured
3
+ * container width, kept free of any React-Native import so it unit-tests
4
+ * directly (the register in `table.tsx` renders this result; see `matrix_totals`
5
+ * for the same split).
6
+ *
7
+ * The register degrades in two deterministic tiers instead of overflowing.
8
+ * Tier 1: everything fits → the columnar register, unchanged. Tier 2: drop
9
+ * droppable columns by `priority` until the rest fit (the row is a scannable
10
+ * index — the door opens the record where dropped values live). Tier 3: even
11
+ * the minimum column set can't fit (a phone) → every row STACKS, label above
12
+ * value. All of it derives from the container width (onLayout, not the
13
+ * viewport) so a register inside a half-width panel adapts exactly like one on
14
+ * a small screen — the same contract as `DetailTable` / `Breakdown`.
15
+ */
16
+
17
+ /** The column fields the fit decision reads — `TableColumn` extends this. */
18
+ export interface TableFitColumn {
19
+ /** Stable id. */
20
+ key: string;
21
+ /** Fixed width in px; omit for a flexible column. */
22
+ width?: number;
23
+ /** Drop precedence when the container can't fit every column: HIGHER numbers
24
+ * drop first, ties drop right-to-left. Default = the column's index (so an
25
+ * unannotated register sheds from the right). The FIRST column is the row's
26
+ * identity — it never drops. */
27
+ priority?: number;
28
+ }
29
+
30
+ /** Gap between the header band's / a row's children — `table.tsx` styles consume
31
+ * this, so the fit math and the rendered geometry can't drift. */
32
+ export const COLUMN_GAP = 14;
33
+ /** The register's horizontal gutter — mirrors `PressableRow`'s register variant
34
+ * (`paddingHorizontal: 20`), the one geometry this file models but doesn't own.
35
+ * `table.tsx` consumes it for the header band and static rows. */
36
+ export const ROW_GUTTER = 20;
37
+ const ROW_H_PADDING = ROW_GUTTER * 2;
38
+ /** Fit-math width a flexible column needs to stay usable — below this the flex
39
+ * column is crushed to ellipsis soup, so it counts as this wide when deciding
40
+ * what fits. Layout still lets it grow (`flex`) or shrink (`minWidth: 0`). */
41
+ const FLEX_MIN_WIDTH = 120;
42
+ /** Fewer side-by-side columns than this stops being a register — stack instead. */
43
+ const MIN_VISIBLE_COLUMNS = 2;
44
+
45
+ export interface TableFit {
46
+ /** Keys of the columns that render side-by-side. All keys when `stacked`. */
47
+ visibleKeys: ReadonlySet<string>;
48
+ /** The minimum column set can't fit — rows render as label-over-value stacks. */
49
+ stacked: boolean;
50
+ }
51
+
52
+ /**
53
+ * Pure fit decision for a measured container width: which columns stay
54
+ * side-by-side, or whether the register stacks.
55
+ */
56
+ export function computeTableFit(
57
+ columns: TableFitColumn[],
58
+ leading: number,
59
+ trailing: number,
60
+ width: number,
61
+ ): TableFit {
62
+ const requiredWidth = (cols: TableFitColumn[]): number => {
63
+ const slots = cols.length + (leading > 0 ? 1 : 0) + (trailing > 0 ? 1 : 0);
64
+ const gaps = Math.max(0, slots - 1) * COLUMN_GAP;
65
+ const colsWidth = cols.reduce((sum, c) => sum + (c.width ?? FLEX_MIN_WIDTH), 0);
66
+ return ROW_H_PADDING + (leading > 0 ? leading : 0) + (trailing > 0 ? trailing : 0) + colsWidth + gaps;
67
+ };
68
+
69
+ const allKeys = new Set(columns.map((c) => c.key));
70
+ const priorityOf = (c: TableFitColumn) => c.priority ?? columns.indexOf(c);
71
+ // Column 0 never enters the drop order — it's the row's identity.
72
+ const dropOrder = columns
73
+ .slice(1)
74
+ .sort((a, b) => priorityOf(b) - priorityOf(a) || columns.indexOf(b) - columns.indexOf(a));
75
+
76
+ const kept = new Set(allKeys);
77
+ const floor = Math.min(MIN_VISIBLE_COLUMNS, columns.length);
78
+ for (const col of dropOrder) {
79
+ if (kept.size <= floor) break;
80
+ if (requiredWidth(columns.filter((c) => kept.has(c.key))) <= width) break;
81
+ kept.delete(col.key);
82
+ }
83
+ if (requiredWidth(columns.filter((c) => kept.has(c.key))) > width) {
84
+ // Below the register floor: stack every column (vertical space is free).
85
+ return { visibleKeys: allKeys, stacked: true };
86
+ }
87
+ return { visibleKeys: kept, stacked: false };
88
+ }