@lotics/ui 47.13.0 → 47.14.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,163 @@
1
+ import { useRef, useState, type ReactNode } from "react";
2
+ import { View } from "react-native";
3
+ import { Badge } from "./badge";
4
+ import { Button } from "./button";
5
+ import { colors } from "./colors";
6
+ import { type IconName } from "./icon";
7
+ import { useLoticsLocale } from "./locale";
8
+ import { MenuButton } from "./menu_button";
9
+ import { Popover, PopoverContent } from "./popover";
10
+
11
+ /** One stop on the outline. `issue` is what makes the nav answer "where is the
12
+ * problem" and not merely "where am I" — the section still states the condition
13
+ * itself beside the fields it concerns; this only says where to look. */
14
+ export interface SectionNavItem<K extends string = string> {
15
+ key: K;
16
+ label: string;
17
+ icon: IconName;
18
+ issue?: "warning" | "danger";
19
+ }
20
+
21
+ export interface SectionNavProps<K extends string = string> {
22
+ /** In PAGE ORDER — the same order `useSectionNav` spies on. */
23
+ items: readonly SectionNavItem<K>[];
24
+ /** From `useSectionNav().activeKey`. */
25
+ activeKey: K;
26
+ /** From `useSectionNav().jumpTo`. */
27
+ onJump: (key: K) => void;
28
+ /**
29
+ * Rail when true, pinned bar when false.
30
+ *
31
+ * The CALLER decides, from its own measured container — never from the window.
32
+ * A record surface renders inside a page and inside a drawer, and a window
33
+ * read gives the drawer the page's answer: the rail appears in a column that
34
+ * has no gutter to hold it.
35
+ */
36
+ wide: boolean;
37
+ /**
38
+ * Where this record returns to — a `BackButton`, in both forms.
39
+ *
40
+ * The rail's standard opens with it, visually apart from the outline items; the
41
+ * bar keeps it for the same reason. Omit it where the container already has
42
+ * one (a drawer's own header), because two exits are two exits.
43
+ */
44
+ back?: ReactNode;
45
+ /** Rail width. The gutter it floats in is the caller's arithmetic. */
46
+ railWidth?: number;
47
+ /** The bar's horizontal gutter. A fill-less button shows no box, so its INK is
48
+ * what lines up with the section titles below: pass the container's content
49
+ * padding and this subtracts the button's own. */
50
+ barGutter?: number;
51
+ }
52
+
53
+ /**
54
+ * The record surface's outline, in its two forms.
55
+ *
56
+ * `useSectionNav` is the scroll-spy half — offsets, `activeKey`, `jumpTo`. This
57
+ * is the CHROME half, and the two are used together: the hook decides which
58
+ * section the reader is in, this draws the list that says so and jumps.
59
+ *
60
+ * ## The two forms are one list
61
+ *
62
+ * Where the container affords a gutter, the outline is a RAIL floating in it.
63
+ * Where it does not, the same list collapses to a pinned bar naming the CURRENT
64
+ * section, which opens the list as a picker. Both render the same items with the
65
+ * same `current` item and the same attention dots, because they are one control
66
+ * — a reader who learns the rail has learned the bar.
67
+ *
68
+ * The picker anchors to the bar's own button (`triggerRef`, since the trigger IS
69
+ * a `Button` and a button must not nest in a pressable), so it is scoped to
70
+ * whatever container that bar sits in. A `Modal` escapes the container and takes
71
+ * the window, which inside a drawer covers the register behind it.
72
+ *
73
+ * ```tsx
74
+ * const nav = useSectionNav(["general", "fees"] as const);
75
+ * const [w, setW] = useState<number | null>(null);
76
+ * <View onLayout={(e) => setW(e.nativeEvent.layout.width)}>
77
+ * <SectionNav
78
+ * items={SECTIONS}
79
+ * activeKey={nav.activeKey}
80
+ * onJump={nav.jumpTo}
81
+ * wide={w != null && w >= MY_THRESHOLD}
82
+ * back={<BackButton label="Records" onPress={close} />}
83
+ * />
84
+ * ```
85
+ *
86
+ * What this does NOT own, and must not grow: the gutter the rail floats in, the
87
+ * width threshold, and which sections are in trouble. Those read the caller's
88
+ * layout and the caller's domain — one consumer cannot tell them apart from the
89
+ * contract, and lifting them along is how a component starts describing one
90
+ * screen instead of a shape.
91
+ */
92
+ export function SectionNav<K extends string = string>(props: SectionNavProps<K>) {
93
+ const { items, activeKey, onJump, wide, back, railWidth = 208, barGutter = 28 } = props;
94
+ const [open, setOpen] = useState(false);
95
+ const anchor = useRef<View>(null);
96
+ const words = useLoticsLocale().sectionNav;
97
+
98
+ // A bare dot announces nothing, and here it is the ONLY thing saying a
99
+ // section needs looking at — so it carries the word a sighted reader infers
100
+ // from its colour.
101
+ const dot = (issue: SectionNavItem<K>["issue"]) =>
102
+ issue ? (
103
+ <View accessibilityLabel={issue === "danger" ? words.hasProblem : words.needsAttention}>
104
+ <Badge variant="dot" color={issue === "danger" ? "red" : "amber"} />
105
+ </View>
106
+ ) : undefined;
107
+
108
+ const rows = (onPick: (key: K) => void) => (
109
+ <View style={{ gap: 2 }}>
110
+ {items.map((s) => (
111
+ <MenuButton
112
+ key={s.key}
113
+ icon={s.icon}
114
+ title={s.label}
115
+ current={activeKey === s.key}
116
+ right={dot(s.issue)}
117
+ onPress={() => onPick(s.key)}
118
+ />
119
+ ))}
120
+ </View>
121
+ );
122
+
123
+ if (wide) {
124
+ return (
125
+ <View style={{ width: railWidth, gap: 10 }}>
126
+ {back}
127
+ {rows(onJump)}
128
+ </View>
129
+ );
130
+ }
131
+
132
+ const current = items.find((s) => s.key === activeKey);
133
+
134
+ return (
135
+ <View
136
+ style={{
137
+ borderBottomWidth: 1,
138
+ borderBottomColor: colors.zinc[200],
139
+ paddingHorizontal: Math.max(0, barGutter - 10),
140
+ paddingVertical: 6,
141
+ flexDirection: "row",
142
+ alignItems: "center",
143
+ gap: 8,
144
+ }}
145
+ >
146
+ {back}
147
+ <Popover open={open} onOpenChange={setOpen} triggerRef={anchor} side="bottom" align="start">
148
+ <View ref={anchor}>
149
+ {/* MUTED, not `secondary`: this names WHERE YOU ARE, so it is a
150
+ position readout that happens to be pressable, and a filled chip
151
+ competes with the record's own actions for the eye. */}
152
+ <Button title={current?.label ?? words.sections} color="muted" onPress={() => setOpen(true)} />
153
+ </View>
154
+ <PopoverContent style={{ width: 248 }} disableBodyScroll>
155
+ {rows((key) => {
156
+ setOpen(false);
157
+ onJump(key);
158
+ })}
159
+ </PopoverContent>
160
+ </Popover>
161
+ </View>
162
+ );
163
+ }
@@ -0,0 +1,265 @@
1
+ import { type ReactNode, useState } from "react";
2
+ import { ScrollView, StyleSheet, View } from "react-native";
3
+ import { LEADING_GAP } from "./cell_stack";
4
+ import { colors, solid, type ColorName } from "./colors";
5
+ import { FocusRingPressable } from "./focus_ring_pressable";
6
+ import { useLoticsLocale } from "./locale";
7
+ import { Text } from "./text";
8
+
9
+ /** The glyph's geometry. The legend draws the same one, so a cell and its
10
+ * legend entry can never disagree. */
11
+ export type StateMatrixShape = "filled" | "hollow" | "dot" | "none";
12
+
13
+ export interface StateMatrixState {
14
+ key: string;
15
+ label: string;
16
+ /** Palette family — the glyph and the legend swatch derive from it. */
17
+ color: ColorName;
18
+ /** Default `filled`. `none` keeps the box and draws nothing — "nothing was
19
+ * due here", as distinct from a state worth a glyph. */
20
+ shape?: StateMatrixShape;
21
+ }
22
+
23
+ export interface StateMatrixColumn {
24
+ key: string;
25
+ /** One or two characters — a day number, an hour. Shown ONCE in the header. */
26
+ label: string;
27
+ /** A second header line — the weekday under the day number. */
28
+ caption?: string;
29
+ /** The column the reader is standing in. Tinted down the grid, `aria-current`. */
30
+ current?: boolean;
31
+ }
32
+
33
+ export interface StateMatrixCell {
34
+ state: string;
35
+ /** Replaces the glyph — a count, when one cell holds several. */
36
+ value?: ReactNode;
37
+ /** Only a pressable cell is a `button`; the rest announce as marks. */
38
+ pressable?: boolean;
39
+ /** What a screen reader says — the row, the column and the state, in words. */
40
+ name: string;
41
+ }
42
+
43
+ export interface StateMatrixRow {
44
+ key: string;
45
+ label: ReactNode;
46
+ /** An identity mark before the label. */
47
+ leading?: ReactNode;
48
+ /** A pinned figure beside the label — "9/14". */
49
+ total?: string;
50
+ /** One per column, in column order. */
51
+ cells: StateMatrixCell[];
52
+ }
53
+
54
+ export interface StateMatrixLabels {
55
+ /** Header of the pinned total column. */
56
+ total: string;
57
+ }
58
+
59
+ export interface StateMatrixProps {
60
+ columns: StateMatrixColumn[];
61
+ rows: StateMatrixRow[];
62
+ states: StateMatrixState[];
63
+ onPressCell?: (rowKey: string, colKey: string) => void;
64
+ /** Width of the pinned label block. Default 180. */
65
+ rowLabelWidth?: number;
66
+ /** Cell edge. Default 28 — thirty-one of them fit a 1024px pane beside the labels. */
67
+ cellWidth?: number;
68
+ labels?: Partial<StateMatrixLabels>;
69
+ testID?: string;
70
+ }
71
+
72
+ const ROW_HEIGHT = 36;
73
+ const HEADER_HEIGHT = 40;
74
+ const CELL_GAP = 2;
75
+ const TOTAL_WIDTH = 64;
76
+ const MARK = 12;
77
+
78
+ /**
79
+ * A CATEGORICAL cross-tab: rows of subjects, a fixed axis of positions across
80
+ * the top, and in every cell a STATE drawn as a glyph — an attendance sheet,
81
+ * a fleet's month day by day, a rota. The reader runs an eye down one column
82
+ * to see who was out on the 14th, or along one row to see a subject's month.
83
+ *
84
+ * It is not `Matrix`: nothing here is a number, and a blank cell means
85
+ * "nothing was due", never zero. It is not `StatusGrid`: that is a pile of
86
+ * units with no axes. It is not `Timetable`: a timetable's positions are a
87
+ * pattern with no dates and every mark is its own editor; here the columns are
88
+ * dated and a cell is read, and opened when it has something to say.
89
+ *
90
+ * What the component owns, and why it is not a `View` of `View`s: the label
91
+ * block is PINNED outside the scroller so the row keeps its name however far
92
+ * the axis scrolls; the scroll is the grid's own, never the page's; the legend
93
+ * derives from `states`, so it cannot list a glyph the cells do not draw; the
94
+ * current column carries `aria-current` and a tint down its whole height; and
95
+ * every cell has a NAME, because a glyph announces as nothing.
96
+ */
97
+ export function StateMatrix(props: StateMatrixProps) {
98
+ const { columns, rows, states, onPressCell, rowLabelWidth = 180, cellWidth = 28, labels, testID } = props;
99
+ const l = { ...useLoticsLocale().stateMatrix, ...labels };
100
+ const hasTotal = rows.some((r) => r.total !== undefined);
101
+ const stateOf = (key: string) => states.find((s) => s.key === key);
102
+
103
+ return (
104
+ <View style={styles.root} testID={testID}>
105
+ <View style={styles.body}>
106
+ {/* Pinned: the row's name and its figure stay put while the axis scrolls. */}
107
+ <View style={{ width: rowLabelWidth + (hasTotal ? TOTAL_WIDTH : 0) }}>
108
+ <View style={[styles.headRow, { height: HEADER_HEIGHT }]}>
109
+ <View style={{ flex: 1 }} />
110
+ {hasTotal ? (
111
+ <Text size="sm" weight="medium" color="muted" align="right" numberOfLines={1} style={{ width: TOTAL_WIDTH }}>
112
+ {l.total}
113
+ </Text>
114
+ ) : null}
115
+ </View>
116
+ {rows.map((r) => (
117
+ <View key={r.key} style={[styles.labelRow, { height: ROW_HEIGHT }]}>
118
+ {r.leading}
119
+ <View style={{ flex: 1, minWidth: 0 }}>
120
+ {typeof r.label === "string" ? (
121
+ <Text size="sm" weight="medium" numberOfLines={1}>
122
+ {r.label}
123
+ </Text>
124
+ ) : (
125
+ r.label
126
+ )}
127
+ </View>
128
+ {hasTotal ? (
129
+ <Text size="sm" tabular align="right" numberOfLines={1} style={{ width: TOTAL_WIDTH }}>
130
+ {r.total ?? ""}
131
+ </Text>
132
+ ) : null}
133
+ </View>
134
+ ))}
135
+ </View>
136
+
137
+ <ScrollView horizontal style={{ flex: 1 }} contentContainerStyle={styles.scrollContent}>
138
+ <View>
139
+ <View style={[styles.axis, { height: HEADER_HEIGHT }]}>
140
+ {columns.map((c) => (
141
+ <View
142
+ key={c.key}
143
+ aria-current={c.current ? "date" : undefined}
144
+ style={[styles.axisCell, { width: cellWidth }, c.current ? styles.currentWash : null]}
145
+ >
146
+ <Text size="sm" weight="medium" color={c.current ? "default" : "muted"} tabular numberOfLines={1}>
147
+ {c.label}
148
+ </Text>
149
+ {c.caption !== undefined ? (
150
+ <Text size="xs" color="muted" numberOfLines={1}>
151
+ {c.caption}
152
+ </Text>
153
+ ) : null}
154
+ </View>
155
+ ))}
156
+ </View>
157
+ {rows.map((r) => (
158
+ <View key={r.key} style={[styles.cellRow, { height: ROW_HEIGHT }]}>
159
+ {columns.map((c, i) => {
160
+ const cell = r.cells[i];
161
+ if (cell === undefined) return <View key={c.key} style={{ width: cellWidth }} />;
162
+ const state = stateOf(cell.state);
163
+ return (
164
+ <Cell
165
+ key={c.key}
166
+ cell={cell}
167
+ width={cellWidth}
168
+ current={c.current === true}
169
+ color={state?.color ?? "zinc"}
170
+ shape={state?.shape ?? "filled"}
171
+ onPress={cell.pressable && onPressCell ? () => onPressCell(r.key, c.key) : undefined}
172
+ />
173
+ );
174
+ })}
175
+ </View>
176
+ ))}
177
+ </View>
178
+ </ScrollView>
179
+ </View>
180
+
181
+ <View style={styles.legend}>
182
+ {states.map((s) => {
183
+ const count = rows.reduce((n, r) => n + r.cells.filter((c) => c.state === s.key).length, 0);
184
+ return (
185
+ <View key={s.key} style={styles.legendItem}>
186
+ <Mark color={s.color} shape={s.shape ?? "filled"} />
187
+ <Text size="xs">{s.label}</Text>
188
+ <Text size="xs" color="muted" tabular>
189
+ {String(count)}
190
+ </Text>
191
+ </View>
192
+ );
193
+ })}
194
+ </View>
195
+ </View>
196
+ );
197
+ }
198
+
199
+ interface CellProps {
200
+ cell: StateMatrixCell;
201
+ width: number;
202
+ current: boolean;
203
+ color: ColorName;
204
+ shape: StateMatrixShape;
205
+ onPress?: () => void;
206
+ }
207
+
208
+ function Cell({ cell, width, current, color, shape, onPress }: CellProps) {
209
+ const [hovered, setHovered] = useState(false);
210
+ const inner = cell.value !== undefined ? (
211
+ <Text size="xs" weight="medium" tabular>
212
+ {cell.value}
213
+ </Text>
214
+ ) : (
215
+ <Mark color={color} shape={shape} />
216
+ );
217
+ const wash = current ? styles.currentWash : null;
218
+ if (onPress === undefined) {
219
+ return (
220
+ <View accessibilityRole="image" accessibilityLabel={cell.name} style={[styles.cell, { width }, wash]}>
221
+ {inner}
222
+ </View>
223
+ );
224
+ }
225
+ return (
226
+ <FocusRingPressable
227
+ accessibilityRole="button"
228
+ accessibilityLabel={cell.name}
229
+ onPress={onPress}
230
+ onHoverIn={() => setHovered(true)}
231
+ onHoverOut={() => setHovered(false)}
232
+ style={[styles.cell, { width }, wash, hovered ? styles.cellHover : null]}
233
+ >
234
+ {inner}
235
+ </FocusRingPressable>
236
+ );
237
+ }
238
+
239
+ /** One glyph, drawn the same in a cell and in the legend. */
240
+ function Mark({ color, shape }: { color: ColorName; shape: StateMatrixShape }) {
241
+ if (shape === "none") return <View style={styles.mark} />;
242
+ if (shape === "dot") return <View style={[styles.mark, styles.markDotBox]}><View style={[styles.dot, { backgroundColor: solid(color) }]} /></View>;
243
+ if (shape === "hollow") return <View style={[styles.mark, styles.markHollow, { borderColor: solid(color) }]} />;
244
+ return <View style={[styles.mark, { backgroundColor: solid(color) }]} />;
245
+ }
246
+
247
+ const styles = StyleSheet.create({
248
+ root: { gap: 12 },
249
+ body: { flexDirection: "row" },
250
+ headRow: { flexDirection: "row", alignItems: "flex-end", paddingBottom: 6 },
251
+ labelRow: { flexDirection: "row", alignItems: "center", gap: LEADING_GAP, paddingRight: 8 },
252
+ scrollContent: { flexGrow: 1 },
253
+ axis: { flexDirection: "row", gap: CELL_GAP, alignItems: "flex-end", paddingBottom: 2 },
254
+ axisCell: { alignItems: "center", paddingVertical: 2, borderRadius: 6 },
255
+ cellRow: { flexDirection: "row", gap: CELL_GAP },
256
+ cell: { alignItems: "center", justifyContent: "center", borderRadius: 6 },
257
+ cellHover: { backgroundColor: colors.zinc[200] },
258
+ currentWash: { backgroundColor: colors.zinc[100] },
259
+ mark: { width: MARK, height: MARK, borderRadius: 3 },
260
+ markHollow: { borderWidth: 1.5, backgroundColor: colors.white },
261
+ markDotBox: { alignItems: "center", justifyContent: "center" },
262
+ dot: { width: 6, height: 6, borderRadius: 999 },
263
+ legend: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 14, rowGap: 4 },
264
+ legendItem: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 24 },
265
+ });
package/src/table.tsx CHANGED
@@ -16,9 +16,9 @@ import { colors, solid, type ColorName } from "./colors";
16
16
  import { Icon } from "./icon";
17
17
  import { FocusRingPressable } from "./focus_ring_pressable";
18
18
  import { PressableRow } from "./pressable_row";
19
- import { CONTROL_HEIGHT } from "./control_surface";
19
+ import { CONTROL_HEIGHT, MIN_CONTROL_WIDTH } from "./control_surface";
20
20
  import { PressDoor } from "./press_door";
21
- import { DetailRow } from "./detail_row";
21
+ import { DetailRow, DetailTable } from "./detail_row";
22
22
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
23
23
  import { COLUMN_GAP, ROW_GUTTER, ROW_HEIGHT, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
24
24
 
@@ -40,6 +40,13 @@ export interface TableColumn extends TableFitColumn {
40
40
  /** Header label — sentence case, at the BODY size. Omit for a control column.
41
41
  * In stacked mode it renders as the eyebrow over the cell's value. */
42
42
  label?: string;
43
+ /**
44
+ * A header that is not a WORD — the seven positions a `Timetable` row toggles,
45
+ * a unit ruler. Rendered in the band in place of `label`; `label` stays the
46
+ * column's name for the stacked eyebrow and the sort control. Never a way to
47
+ * restyle a label: a header is chrome, and chrome has one treatment.
48
+ */
49
+ header?: ReactNode;
43
50
  /** Flex grow when no `width` (default 1). */
44
51
  flex?: number;
45
52
  align?: "left" | "right";
@@ -81,6 +88,17 @@ interface TableCtx {
81
88
  }
82
89
  const TableContext = createContext<TableCtx | null>(null);
83
90
 
91
+ /**
92
+ * The label column a STACKED register's field lines take.
93
+ *
94
+ * Narrower than `DETAIL_LABEL_WIDTH` (130) because these labels are COLUMN
95
+ * HEADERS — "Hạn xử lý", "Nhà xe", "Trạng thái" — not field names, and they are
96
+ * competing for a phone's width with the value beside them. It is one number for
97
+ * the surface, which is the whole point: the labels wrap inside it rather than
98
+ * any one of them pushing its own value out of line with the row above.
99
+ */
100
+ const STACKED_LABEL_WIDTH = 104;
101
+
84
102
  /**
85
103
  * The STT gutter — wide enough for four digits at `xs`, which is every register
86
104
  * anyone scrolls. Fixed rather than measured: a width that grew with the number
@@ -288,7 +306,9 @@ export function Table(props: TableProps) {
288
306
  <LeadGutter ordinal={count}>{selectAll}</LeadGutter>
289
307
  {visibleColumns.map((col) => (
290
308
  <View key={col.key} style={colStyle(col)}>
291
- {col.label ? (
309
+ {col.header != null ? (
310
+ col.header
311
+ ) : col.label ? (
292
312
  col.sortable && onSort ? (
293
313
  <SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
294
314
  ) : (
@@ -643,13 +663,37 @@ export function TableRow(props: TableRowProps) {
643
663
  {cells.length > 1 ? (
644
664
  // The field lines sit on the identity column's text edge — indented
645
665
  // past the leading gutter, never wrapping under the checkbox.
666
+ //
667
+ // WRAPPED IN A `DetailTable`, which is what gives them the FORM grammar:
668
+ // one label column, every value on one left edge. Without it each cell's
669
+ // `DetailRow` fell back to SPREAD — the label takes the slack and the
670
+ // value is pushed right — and a spread row is right for ONE detail value
671
+ // and wrong for a register of them. Repeated down twenty-one rows, the
672
+ // same label string measured seven distinct widths and its value started
673
+ // at a different x on every row, so the narrow fork had no value column
674
+ // at all. The `DetailTable` also carries its own floor: below it the rows
675
+ // stack label-over-value, which is still ONE left edge.
646
676
  <View
647
677
  style={[
648
678
  styles.stackedFields,
649
679
  ctx.leading + ctx.ordinal > 0 ? { paddingLeft: ctx.leading + ctx.ordinal + COLUMN_GAP } : null,
650
680
  ]}
651
681
  >
652
- {cells.slice(1)}
682
+ {/* `MIN_CONTROL_WIDTH`, not the record's `MIN_VALUE_WIDTH`. The two
683
+ answer different questions and a register's cells are the other
684
+ case: they hold a badge, a date, a short kind or a control, not the
685
+ company names and addresses a record's value column carries. Left
686
+ at 200 the grid stacked label-OVER-value at 375, turning every row
687
+ into four lines and a twenty-one-row queue into a scroll marathon
688
+ — correct alignment, wrong density. */}
689
+ <DetailTable
690
+ labelWidth={STACKED_LABEL_WIDTH}
691
+ minValueWidth={MIN_CONTROL_WIDTH}
692
+ minHeight={0}
693
+ style={styles.stackedFieldGrid}
694
+ >
695
+ {cells.slice(1)}
696
+ </DetailTable>
653
697
  </View>
654
698
  ) : null}
655
699
  {action != null ? <View style={styles.stackedActionLine}>{action}</View> : null}
@@ -665,7 +709,7 @@ export function TableRow(props: TableRowProps) {
665
709
  return <View style={styles.staticStackedRow}>{body}</View>;
666
710
  }
667
711
  return (
668
- <View style={[styles.staticRow, vAlign]}>
712
+ <View style={topAligned ? [styles.staticRow, { alignItems: "flex-start" as const }] : styles.staticRow}>
669
713
  <LeadGutter ordinal={ordinal}>{leading}</LeadGutter>
670
714
  <View style={cellsStyle}>{cells}</View>
671
715
  {ctx.trailing > 0 ? (
@@ -752,6 +796,18 @@ export function TableCell(props: TableCellProps) {
752
796
  if (_primary) {
753
797
  return <>{children}</>;
754
798
  }
799
+ // A column whose header is a set of POSITIONS keeps them over its value
800
+ // when the register stacks: the marks under it are unreadable otherwise.
801
+ if (_column?.header != null) {
802
+ return (
803
+ <View style={styles.stackedBareCell}>
804
+ <View style={{ gap: 4 }}>
805
+ {_column.header}
806
+ {children}
807
+ </View>
808
+ </View>
809
+ );
810
+ }
755
811
  if (!_column?.label) {
756
812
  return <View style={styles.stackedBareCell}>{children}</View>;
757
813
  }
@@ -903,6 +959,12 @@ const styles = StyleSheet.create({
903
959
  stackedFields: {
904
960
  gap: 8,
905
961
  },
962
+ // The field grid inside a stacked row. `DetailTable` owns the label column and
963
+ // the row gap; this only removes the gap it would add on top of
964
+ // `stackedFields`' own, so the two are not paid twice.
965
+ stackedFieldGrid: {
966
+ gap: 8,
967
+ },
906
968
  // The register's trailing gutter composes [action, overflow] on one row —
907
969
  // the gap matches the composite the templates previously hand-rolled.
908
970
  trailingSlot: {