@lotics/ui 27.17.1 → 28.1.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/AGENTS.md +13 -10
- package/MIGRATION.md +78 -0
- package/docs/catalog.md +103 -141
- package/docs/data_entry.md +57 -87
- package/docs/templates.md +95 -57
- package/examples/tpl_item_list.tsx +77 -36
- package/examples/tpl_record.tsx +372 -460
- package/package.json +3 -3
- package/src/checklist.tsx +353 -0
- package/src/comments_button.tsx +108 -0
- package/src/control_surface.ts +13 -0
- package/src/detail_row.tsx +2 -2
- package/src/field_annotations.tsx +1 -1
- package/src/icon_button.tsx +17 -1
- package/src/locale.tsx +12 -1
- package/src/stepper.tsx +107 -18
- package/src/stepper_layout.ts +21 -1
- package/src/table.tsx +101 -11
- package/src/pipeline.tsx +0 -231
- package/src/task.tsx +0 -518
- package/src/task_metrics.ts +0 -55
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 (`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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" },
|
package/src/stepper_layout.ts
CHANGED
|
@@ -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 `
|
|
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
|
-
|
|
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
|
-
<
|
|
177
|
+
<LeadGutter ordinal={count}>{selectAll}</LeadGutter>
|
|
117
178
|
</View>
|
|
118
179
|
) : null
|
|
119
180
|
) : (
|
|
120
181
|
<View style={styles.headerBand}>
|
|
121
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|