@lotics/ui 27.17.0 → 28.0.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/stepper.tsx CHANGED
@@ -16,8 +16,9 @@ import { Text } from "./text";
16
16
  import { PressableHighlight } from "./pressable_highlight";
17
17
  import { AnimationFadeIn } from "./animation_fade_in";
18
18
  import { CheckCircle } from "./check_circle";
19
+ import { CheckboxInput } from "./checkbox_input";
19
20
  import { useLoticsLocale } from "./locale";
20
- import { NODE, STEP_HEAD_TEXT_LINE, STEP_ROW_PAD, markerTopOffset } from "./stepper_layout";
21
+ import { NODE, STEP_HEAD_TEXT_LINE, STEP_MARKER_GAP, STEP_ROW_INSET, STEP_ROW_PAD, STEP_TEXT_COLUMN, markerTopOffset } from "./stepper_layout";
21
22
 
22
23
  // A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
23
24
  // where we are (ring + white centre, pulses when live); `done` = passed (filled);
@@ -28,14 +29,25 @@ export type StepOrientation = "horizontal" | "vertical";
28
29
 
29
30
  const reached = (s: StepStatus) => s !== "upcoming";
30
31
 
32
+ export { STEP_TEXT_COLUMN };
33
+
34
+
31
35
  interface StepperConfig {
32
36
  orientation: StepOrientation;
33
37
  color: string;
38
+ /** What a toggleable marker MEANS — see `StepperProps.mark`. */
39
+ mark: "ring" | "box";
40
+ /** Vertical spacing beneath each step; undefined = the default group rhythm. */
41
+ gap?: number;
42
+ /** Draw the connecting line between markers. Off for a run whose rows are
43
+ * independent, where a line would claim an order the work does not have. */
44
+ connected: boolean;
34
45
  /** Pulse the current node — signals an actively-running sequence (an agent
35
46
  * run streaming its steps). Off for a static wizard/checklist. */
36
47
  live: boolean;
37
48
  }
38
- const StepperContext = createContext<StepperConfig>({ orientation: "horizontal", color: colors.zinc[900], live: false });
49
+ const StepperContext = createContext<StepperConfig>({ orientation: "horizontal", color: colors.zinc[900], live: false, connected: true, mark: "ring" });
50
+
39
51
 
40
52
  export interface StepperProps {
41
53
  /** Compound form: `<Stepper><Step status>…</Step></Stepper>` — swappable content. */
@@ -47,11 +59,39 @@ export interface StepperProps {
47
59
  live?: boolean;
48
60
  /** Accent ink for reached nodes + the connecting track. Defaults to neutral ink. */
49
61
  color?: string;
62
+ /**
63
+ * VERTICAL only. Space beneath each step. The default separates GROUPS — stages
64
+ * that each carry a body — and is too much for a run of bare rows: the spine
65
+ * between two one-line steps stretches until it reads as a dash rather than a
66
+ * line. A run of plain rows passes a tighter number.
67
+ */
68
+ gap?: number;
69
+ /**
70
+ * What a toggleable marker MEANS, and therefore its SHAPE.
71
+ *
72
+ * `ring` (default) is COMPLETION — the row is a thing someone finishes.
73
+ * `box` is SELECTION — the row is a thing someone PICKS, and finishing never
74
+ * enters into it (which forms to produce, which records to export).
75
+ *
76
+ * The two are 24px either way and share one gutter, so a surface swaps between
77
+ * them without moving anything. Spending the ring on selection is the failure
78
+ * this exists to prevent: a reader who has learned that a filled ring means
79
+ * "done" reads a ticked pick-list as work already carried out.
80
+ */
81
+ mark?: "ring" | "box";
82
+ /**
83
+ * VERTICAL only. Draw the connecting line between markers (default on).
84
+ *
85
+ * The line asserts SEQUENCE. On a set of independent items it claims an order
86
+ * the work does not have, and the reader infers a first and a next that are not
87
+ * there.
88
+ */
89
+ connected?: boolean;
50
90
  accessibilityLabel?: string;
51
91
  }
52
92
 
53
93
  /** The props `Stepper` CLONES onto its direct children. Exported so a component
54
- * that stands where a `Step` would (`PipelineStage`) can accept and forward the
94
+ * that stands where a `Step` would (a `ChecklistItem`) can accept and forward the
55
95
  * whole set — extend this and spread it through, never re-list the keys, or a
56
96
  * new positional prop is dropped silently by everything but `Step`. */
57
97
  export interface StepPositional {
@@ -93,6 +133,18 @@ export interface StepProps extends StepPositional {
93
133
  * difference. Ignored horizontally, where the marker sits above the label.
94
134
  */
95
135
  headHeight?: number;
136
+ /**
137
+ * VERTICAL only. Drop the marker COLUMN — no node, no spine, content flush to
138
+ * the run's left edge.
139
+ *
140
+ * For a step that GROUPS others rather than being one. A grouping row has no
141
+ * completion of its own, so any node it wore would be a control that reports
142
+ * without responding — and a ring identical to the pressable ones below it
143
+ * reads as pressable, which is a worse failure than showing nothing: the reader
144
+ * clicks and the product says nothing back. Its children carry the markers, and
145
+ * the group is a heading over them.
146
+ */
147
+ marker?: boolean;
96
148
  accessibilityLabel?: string;
97
149
  }
98
150
 
@@ -107,7 +159,7 @@ export interface StepProps extends StepPositional {
107
159
  * for a compact stage bar use `StepProgress`.
108
160
  */
109
161
  export function Stepper(props: StepperProps) {
110
- const { children, steps, current, orientation = "horizontal", live = false, color = colors.zinc[700], accessibilityLabel } = props;
162
+ const { children, steps, current, orientation = "horizontal", live = false, color = colors.zinc[700], gap, connected = true, mark = "ring", accessibilityLabel } = props;
111
163
  const locale = useLoticsLocale();
112
164
 
113
165
  const content: ReactNode =
@@ -135,17 +187,24 @@ export function Stepper(props: StepperProps) {
135
187
  // accessibility tree. A discrete named sequence is a list whose live entry
136
188
  // carries `aria-current="step"`. The proportional bar is `StepProgress`.
137
189
 
190
+ // A row is visually LAST when nothing below it carries a marker to connect to —
191
+ // the end of the run, or the row before a GROUP heading. Without this the final
192
+ // row of every phase drew a spine down into the heading's empty column and the
193
+ // line dangled into blank space, which reads as a rendering fault.
194
+ const breaksAfter = (i: number) =>
195
+ i === last || (items[i + 1]?.type as { markerless?: boolean } | undefined)?.markerless === true;
196
+
138
197
  const positioned = items.map((child, i) =>
139
198
  cloneElement(child, {
140
199
  _first: i === 0,
141
- _last: i === last,
200
+ _last: breaksAfter(i),
142
201
  _leftFilled: i > 0 && reached(statuses[i - 1]),
143
202
  _rightFilled: i < last && reached(statuses[i]),
144
203
  }),
145
204
  );
146
205
 
147
206
  return (
148
- <StepperContext.Provider value={{ orientation, color, live }}>
207
+ <StepperContext.Provider value={{ orientation, color, live, gap, connected, mark }}>
149
208
  <View role="list" accessibilityLabel={a11y} style={orientation === "horizontal" ? styles.hRow : undefined}>
150
209
  {positioned}
151
210
  </View>
@@ -154,8 +213,8 @@ export function Stepper(props: StepperProps) {
154
213
  }
155
214
 
156
215
  export function Step(props: StepProps) {
157
- const { status, children, onPress, active, onToggle, headHeight = STEP_HEAD_TEXT_LINE, accessibilityLabel, _first, _last, _leftFilled, _rightFilled } = props;
158
- const { orientation, color, live } = useContext(StepperContext);
216
+ const { status, children, onPress, active, onToggle, headHeight = STEP_HEAD_TEXT_LINE, marker = true, accessibilityLabel, _first, _last, _leftFilled, _rightFilled } = props;
217
+ const { orientation, color, live, gap, connected, mark } = useContext(StepperContext);
159
218
  const locale = useLoticsLocale();
160
219
  // Each step is one `listitem`, and the live one says so with `aria-current`.
161
220
  // It goes on the ITEM, not the label: "current" is a fact about this position
@@ -194,7 +253,14 @@ export function Step(props: StepProps) {
194
253
  return <View {...item} style={styles.hStep}>{inner}</View>;
195
254
  }
196
255
 
197
- const body = <View style={[styles.vRowBox, active ? styles.vActive : null]}>{children}</View>;
256
+ // The row box's horizontal inset exists for the hover/active WASH, so a
257
+ // markerless row — a group heading, which shows no wash — pays nothing for it.
258
+ // Left in, it pushed the heading AND the run nested under it 10px off the
259
+ // section's own left edge: near enough to read as a mistake, and the kind that
260
+ // is invisible until something above it is aligned properly.
261
+ const body = (
262
+ <View style={[styles.vRowBox, !marker ? styles.vRowFlush : null, active ? styles.vActive : null]}>{children}</View>
263
+ );
198
264
  // Each step rises + fades into place on mount — so a streamed feed reads as
199
265
  // steps APPEARING, not popping in. Animates once (on mount); a status change
200
266
  // (current → done) re-renders without re-animating.
@@ -205,6 +271,12 @@ export function Step(props: StepProps) {
205
271
  <View {...item}>
206
272
  <AnimationFadeIn translateY={6}>
207
273
  <View style={styles.vItem}>
274
+ {/* MARKERLESS: no column at all. A group heading owns no completion, so
275
+ it wears no node — and no line either: a spine drawn THROUGH the row
276
+ runs straight into the ring below it and reads as a rendering fault
277
+ rather than a connection. The heading sits flush on the run's left
278
+ edge, where its steps' rings are, so the column still reads as one. */}
279
+ {marker ? (
208
280
  <View style={styles.vSpineCol}>
209
281
  {/* The spine is TWO segments per step, above the marker and below it,
210
282
  because the marker does not sit at the top of its row: it centres
@@ -222,11 +294,22 @@ export function Step(props: StepProps) {
222
294
  styles.vSpineUp,
223
295
  { height: markerTopOffset(headHeight) },
224
296
  // The first step has nothing above it to connect to, so its band
225
- // is spacing, not line.
226
- _first ? null : { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] },
297
+ // is spacing, not line — and neither does any step when the run is
298
+ // unconnected, where a stub above each ring is the line's leftovers.
299
+ _first || !connected ? null : { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] },
227
300
  ]}
228
301
  />
229
- {onToggle ? (
302
+ {onToggle && mark === "box" ? (
303
+ // SELECTION: the square, in the ring's own box so the column and the
304
+ // spine keep their rhythm whichever mark a surface uses.
305
+ <View style={{ width: NODE, height: NODE, alignItems: "center", justifyContent: "center" }}>
306
+ <CheckboxInput
307
+ checked={status === "done" || status === "complete"}
308
+ onChange={onToggle}
309
+ accessibilityLabel={accessibilityLabel ?? locale.stepper.complete}
310
+ />
311
+ </View>
312
+ ) : onToggle ? (
230
313
  // Sized to NODE so a toggleable step keeps the spine's rhythm — a
231
314
  // ring a couple of pixels wider would bow the column at that row.
232
315
  <CheckCircle
@@ -247,9 +330,10 @@ export function Step(props: StepProps) {
247
330
  ) : (
248
331
  <Marker status={status} color={color} live={live} />
249
332
  )}
250
- {!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
333
+ {!_last && connected ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
251
334
  </View>
252
- <View style={[styles.vContent, !_last ? styles.vGap : null]}>
335
+ ) : null}
336
+ <View style={[styles.vContent, !_last ? (gap != null ? { paddingBottom: gap } : styles.vGap) : null]}>
253
337
  {onPress ? (
254
338
  <PressableHighlight focusRing onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
255
339
  {body}
@@ -326,7 +410,7 @@ const styles = StyleSheet.create({
326
410
  hTrack: { flex: 1, height: 2 },
327
411
  hLabel: { alignItems: "center" },
328
412
 
329
- vItem: { flexDirection: "row", gap: 12 },
413
+ vItem: { flexDirection: "row", gap: STEP_MARKER_GAP },
330
414
  vSpineCol: { width: NODE, alignItems: "center" },
331
415
  // No top margin: the segment starts AT the marker, or the line detaches from
332
416
  // the node it is supposed to leave.
@@ -335,9 +419,14 @@ const styles = StyleSheet.create({
335
419
  // read as one line through the node.
336
420
  vSpineUp: { width: 1.5 },
337
421
  vContent: { flex: 1 },
338
- vGap: { paddingBottom: 12 },
339
- vPress: { borderRadius: 8, marginHorizontal: -10 },
340
- vRowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: STEP_ROW_PAD },
422
+ // The gap BETWEEN steps of a vertical run. It has to beat the spacing INSIDE a
423
+ // step, or a stage's own children spread wider than the distance to the next
424
+ // stage and the groups stop reading as groups — a three-step phase spending 4px
425
+ // per child needs more than 12 beneath it to look like one thing.
426
+ vGap: { paddingBottom: 18 },
427
+ vPress: { borderRadius: 8, marginHorizontal: -STEP_ROW_INSET },
428
+ vRowBox: { borderRadius: 8, paddingHorizontal: STEP_ROW_INSET, paddingVertical: STEP_ROW_PAD },
429
+ vRowFlush: { paddingHorizontal: 0 },
341
430
  vActive: { backgroundColor: colors.zinc[100] },
342
431
 
343
432
  discWrap: { width: NODE, height: NODE, alignItems: "center", justifyContent: "center" },
@@ -22,7 +22,7 @@
22
22
  * size makes the geometry follow the control instead of the other way round.
23
23
  *
24
24
  * 24 is that size, and it is `Checkbox`'s box as well: a ring and a box are two
25
- * forms of one act, they share the leading gutter on `Table` and `TaskList`, and
25
+ * forms of one act, they share the leading gutter on `Table` and `Checklist`, and
26
26
  * a gutter whose width depended on which one a surface hosted is a difference no
27
27
  * reader could name either.
28
28
  *
@@ -34,6 +34,26 @@ export const NODE = 24;
34
34
  /** `vRowBox`'s vertical padding — the gap above the content's first row. */
35
35
  export const STEP_ROW_PAD = 2;
36
36
 
37
+ /**
38
+ * The gap between a vertical step's marker column and its content.
39
+ *
40
+ * Read it with `STEP_ROW_INSET`, which follows it: the two together are what a
41
+ * reader sees between a ring and the words it belongs to. At 12 + 10 that came to
42
+ * 22px, which on a checklist row reads as a ring floating away from its label
43
+ * rather than marking it.
44
+ */
45
+ export const STEP_MARKER_GAP = 6;
46
+ /** The horizontal inset inside a step's row box (its hover/active wash). */
47
+ export const STEP_ROW_INSET = 10;
48
+ /**
49
+ * Where a vertical step's TEXT actually begins, from the run's left edge.
50
+ *
51
+ * The marker, the gap after it, AND the row box's own inset — all three, because
52
+ * leaving the last one out puts a heading meant to share that edge 10px off it,
53
+ * which is precisely near enough to look like a mistake rather than a margin.
54
+ */
55
+ export const STEP_TEXT_COLUMN = NODE + STEP_MARKER_GAP + STEP_ROW_INSET;
56
+
37
57
  /**
38
58
  * A single line of `Text size="sm"` on web (`text.css`), which is what a step's
39
59
  * first row is unless it carries a control. NOT the native StyleSheet's 24: the
package/src/table.tsx CHANGED
@@ -38,12 +38,55 @@ export interface TableColumn extends TableFitColumn {
38
38
  interface TableCtx {
39
39
  columns: TableColumn[];
40
40
  leading: number;
41
+ /** Width of the ordinal gutter, 0 when the register is not counted. */
42
+ ordinal: number;
41
43
  trailing: number;
42
44
  visibleKeys: ReadonlySet<string>;
43
45
  stacked: boolean;
44
46
  }
45
47
  const TableContext = createContext<TableCtx | null>(null);
46
48
 
49
+ /**
50
+ * The STT gutter — wide enough for four digits at `xs`, which is every register
51
+ * anyone scrolls. Fixed rather than measured: a width that grew with the number
52
+ * would step the whole column band left as the reader paged from 99 to 100.
53
+ */
54
+ const ORDINAL_W = 28;
55
+
56
+ /**
57
+ * The row's left chrome: its number, then whatever the caller put in `leading`.
58
+ *
59
+ * ONE component for what was the same two lines repeated at five render sites
60
+ * (two header modes, the static row, the pressable row, the stacked top line).
61
+ * The ordinal had to reach all five, and five copies is five chances for one of
62
+ * them to keep the old width and put the column band 28px out of true on exactly
63
+ * one surface.
64
+ */
65
+ function LeadGutter({ ordinal, children }: { ordinal?: number; children?: ReactNode }) {
66
+ const ctx = useContext(TableContext);
67
+ // THROWS, like `TableRow` does. Returning null here would take the row's
68
+ // selection checkbox and its number with it and say nothing — the gutter would
69
+ // simply not be there, which is the failure mode a reader cannot report.
70
+ if (!ctx) throw new Error("LeadGutter must be used within a Table");
71
+ return (
72
+ <>
73
+ {ctx.ordinal > 0 ? (
74
+ <View style={[styles.ordinal, { width: ctx.ordinal }]}>
75
+ {ordinal != null ? (
76
+ // Muted and small: it orients, it is not data. A row that has no
77
+ // number (a group band) leaves the box empty rather than collapsing
78
+ // it, or its cells would sit 28px left of every other row's.
79
+ <Text size="xs" color="muted" numberOfLines={1}>
80
+ {ordinal}
81
+ </Text>
82
+ ) : null}
83
+ </View>
84
+ ) : null}
85
+ {ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{children}</View> : null}
86
+ </>
87
+ );
88
+ }
89
+
47
90
  function colStyle(col: TableColumn): ViewStyle {
48
91
  const base: ViewStyle =
49
92
  col.width != null ? { width: col.width } : { flex: col.flex ?? 1, minWidth: 0 };
@@ -64,6 +107,20 @@ export interface TableProps {
64
107
  selectAll?: ReactNode;
65
108
  /** Reserve a leading gutter (px) for rows that render a `leading` slot (a checkbox). */
66
109
  leading?: number;
110
+ /**
111
+ * Turn the register into a COUNTED one: every row shows its position in a
112
+ * narrow gutter left of `leading`, and this number — the total — heads it.
113
+ *
114
+ * The total is yours to pass because it is rarely the child count: a paged
115
+ * register renders fifty of it, a filtered one counts what matched, and a
116
+ * grouped one counts across bands the reader can collapse. The kit would have
117
+ * to guess which, and would guess wrong on every register that paginates.
118
+ *
119
+ * Positions are yours for the same reason — see `TableRowProps.ordinal`. This
120
+ * prop only says "this register is counted", which is what reserves the gutter
121
+ * on the header and on every row at once, so they cannot disagree.
122
+ */
123
+ count?: number;
67
124
  /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
68
125
  trailing?: number;
69
126
  /** The `TableRow`s. */
@@ -85,7 +142,8 @@ export interface TableProps {
85
142
  * keeps its current order). See `computeTableFit`.
86
143
  */
87
144
  export function Table(props: TableProps) {
88
- const { columns, sort, onSort, sortLabels, selectAll, leading = 0, trailing = 0, children } = props;
145
+ const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, children } = props;
146
+ const ordinal = count != null ? ORDINAL_W : 0;
89
147
  const rows = Children.toArray(children).filter(isValidElement);
90
148
 
91
149
  // Measure-then-REVEAL (the `DetailTable` contract): the unmeasured first
@@ -95,11 +153,14 @@ export function Table(props: TableProps) {
95
153
  const fit: TableFit =
96
154
  width == null
97
155
  ? { visibleKeys: new Set(columns.map((c) => c.key)), stacked: false }
98
- : computeTableFit(columns, leading, trailing, width);
156
+ // The ordinal gutter is reserved space like any other chrome, so the fit
157
+ // sees it: a counted register that ignored it would keep a column it can
158
+ // no longer fit and push the last one off the edge.
159
+ : computeTableFit(columns, leading + ordinal, trailing, width);
99
160
  const visibleColumns = columns.filter((c) => fit.visibleKeys.has(c.key));
100
161
 
101
162
  return (
102
- <TableContext.Provider value={{ columns, leading, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
163
+ <TableContext.Provider value={{ columns, leading, ordinal, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
103
164
  {/* ONE layout node: without this wrapper the header band + body land as two
104
165
  direct flex children of the app's container, and a parent column `gap`
105
166
  (the standard section spacing) opens a hole between the header and rows. */}
@@ -111,14 +172,16 @@ export function Table(props: TableProps) {
111
172
  // Stacked mode has no column band — labels moved into the rows. The
112
173
  // select-all checkbox keeps its band: it's the bulk-select entry point,
113
174
  // aligned over the rows' leading checkboxes.
114
- selectAll != null && leading > 0 ? (
175
+ (selectAll != null && leading > 0) || ordinal > 0 ? (
115
176
  <View style={styles.headerBand}>
116
- <View style={{ width: leading }}>{selectAll}</View>
177
+ <LeadGutter ordinal={count}>{selectAll}</LeadGutter>
117
178
  </View>
118
179
  ) : null
119
180
  ) : (
120
181
  <View style={styles.headerBand}>
121
- {leading > 0 ? <View style={{ width: leading }}>{selectAll}</View> : null}
182
+ {/* The TOTAL heads the column of positions the one number that
183
+ answers "how many" without the reader scrolling to the last row. */}
184
+ <LeadGutter ordinal={count}>{selectAll}</LeadGutter>
122
185
  {visibleColumns.map((col) => (
123
186
  <View key={col.key} style={colStyle(col)}>
124
187
  {col.label ? (
@@ -154,6 +217,21 @@ export type TableRowProps = {
154
217
  marked?: boolean;
155
218
  /** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
156
219
  leading?: ReactNode;
220
+ /**
221
+ * This row's POSITION, shown left of `leading` when the Table carries `count`.
222
+ *
223
+ * Yours to compute, because only you know what the number means here: a paged
224
+ * register continues (`offset + i + 1`) rather than restarting at 1, and a
225
+ * grouped one restarts inside each band, where the reader's question is "which
226
+ * of these" rather than "which of all". Omit it on a row that has no position —
227
+ * a group band, a total line — and the gutter stays blank so the cells beside
228
+ * it stay in line.
229
+ *
230
+ * It is a POSITION, never an identity: sort or filter the register and row 12
231
+ * is a different record. Where a stable handle is wanted, that is a column
232
+ * (the record's key), and it leads the cells.
233
+ */
234
+ ordinal?: number;
157
235
  /** Outside the door, AFTER the cells: the row's OVERFLOW chrome (a ⋯ `ActionMenu`).
158
236
  * Register mode renders it in the trailing gutter beside `action`; stacked mode keeps
159
237
  * it on the top line. Width = Table `trailing` (shared with `action`). */
@@ -219,7 +297,7 @@ export type TableRowProps = {
219
297
  * slots, different geometry.
220
298
  */
221
299
  export function TableRow(props: TableRowProps) {
222
- const { onPress, selected, marked, accessibilityLabel, leading, trailing, action, minHeight = 52, detail, expanded, children } = props;
300
+ const { onPress, selected, marked, accessibilityLabel, leading, ordinal, trailing, action, minHeight = 52, detail, expanded, children } = props;
223
301
  const ctx = useContext(TableContext);
224
302
  if (!ctx) throw new Error("TableRow must be used within a Table");
225
303
  const showDetail = detail != null && expanded === true;
@@ -237,14 +315,19 @@ export function TableRow(props: TableRowProps) {
237
315
  const body = ctx.stacked ? (
238
316
  <View style={styles.stackedBody}>
239
317
  <View style={styles.stackedTopLine}>
240
- {ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
318
+ <LeadGutter ordinal={ordinal}>{leading}</LeadGutter>
241
319
  <View style={styles.stackedPrimary}>{cells[0]}</View>
242
320
  {trailing != null ? <View style={styles.slot}>{trailing}</View> : null}
243
321
  </View>
244
322
  {cells.length > 1 ? (
245
323
  // The field lines sit on the identity column's text edge — indented
246
324
  // past the leading gutter, never wrapping under the checkbox.
247
- <View style={[styles.stackedFields, ctx.leading > 0 ? { paddingLeft: ctx.leading + COLUMN_GAP } : null]}>
325
+ <View
326
+ style={[
327
+ styles.stackedFields,
328
+ ctx.leading + ctx.ordinal > 0 ? { paddingLeft: ctx.leading + ctx.ordinal + COLUMN_GAP } : null,
329
+ ]}
330
+ >
248
331
  {cells.slice(1)}
249
332
  </View>
250
333
  ) : null}
@@ -262,7 +345,7 @@ export function TableRow(props: TableRowProps) {
262
345
  }
263
346
  return (
264
347
  <View style={styles.staticRow}>
265
- {ctx.leading > 0 ? <View style={{ width: ctx.leading }}>{leading}</View> : null}
348
+ <LeadGutter ordinal={ordinal}>{leading}</LeadGutter>
266
349
  <View style={[styles.cells, { minHeight }]}>{cells}</View>
267
350
  {ctx.trailing > 0 ? (
268
351
  <View style={[styles.trailingSlot, { width: ctx.trailing }]}>
@@ -276,7 +359,7 @@ export function TableRow(props: TableRowProps) {
276
359
 
277
360
  const rowSurface = (
278
361
  <PressableRow onPress={onPress} selected={selected || showDetail} marked={marked} style={styles.row}>
279
- {!ctx.stacked && ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
362
+ {!ctx.stacked ? <LeadGutter ordinal={ordinal}>{leading}</LeadGutter> : null}
280
363
  {/* The door hit-tests above in-flow content; the cells/slots lift back above it
281
364
  via zIndex 1 (see `PressDoor`), so the door gets only the keyboard. Radius
282
365
  matches the register wash. */}
@@ -417,6 +500,13 @@ const styles = StyleSheet.create({
417
500
  slot: {
418
501
  zIndex: 1,
419
502
  },
503
+ // RIGHT-aligned so the digits line up against the row's content whatever their
504
+ // length — 9 and 100 share an edge, which is what makes the column scannable.
505
+ // No `zIndex`, unlike `slot`: the number is text, not a control, so it belongs
506
+ // UNDER the press door and the whole row stays one click target.
507
+ ordinal: {
508
+ alignItems: "flex-end",
509
+ },
420
510
  // Stacked (below the register floor): the row is a pile — top line keeps the
421
511
  // leading/trailing slots on the first cell, the rest are label-over-value
422
512
  // blocks. Vertical padding replaces the register's minHeight centering.