@lotics/ui 46.3.0 → 46.8.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,373 @@
1
+ import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
2
+ import { StyleSheet, View, type LayoutChangeEvent } from "react-native";
3
+ import { colors } from "./colors";
4
+ import { CONTROL_TEXT_INSET } from "./control_surface";
5
+ import { InlineNumberInput } from "./inline_number_input";
6
+ import { useLoticsLocale } from "./locale";
7
+ import { SPACE } from "./spacing";
8
+ import { Text } from "./text";
9
+
10
+
11
+ /**
12
+ * The editable money band — what a record charges, priced line by line, closed
13
+ * by its own total.
14
+ *
15
+ * `Ledger` is the READ side of the same subject: three sides and a statement,
16
+ * for a reader who is checking arithmetic somebody else did. This is the WRITE
17
+ * side, for the person doing the pricing, and the two are not interchangeable —
18
+ * a ledger has nothing to type into and this has no notion of adjustments.
19
+ *
20
+ * ## Why this exists as a component
21
+ *
22
+ * `data_entry.md` §Billing has always prescribed the shape in prose — "a FLAT
23
+ * hairline-set band that holds its own editable charge lines, its live total,
24
+ * and its issue action in its closing row" — with nothing to build it from, so
25
+ * every app hand-rolled it. Hand-rolled versions converge on the same defects,
26
+ * and they are the reason this file exists:
27
+ *
28
+ * - **The derived amount ends up on its own line.** Building the row as
29
+ * `[qty] × [price]` and hanging `= amount` underneath doubles the height of
30
+ * every charge and leaves the answer floating in the middle of the band,
31
+ * aligned to nothing. A charge is ONE line: the arithmetic reads across it and
32
+ * ends at the amount.
33
+ * - **Three money edges instead of two.** A typed price left-aligns inside its
34
+ * editor while the derived amount right-aligns, so the two columns drift and
35
+ * the total closes a column that only half the figures are in. Every money
36
+ * column here is right-aligned and the total lands on the amount column, so
37
+ * the band can be added up by eye.
38
+ * - **The total drifts from the lines.** Computed by the caller from a
39
+ * different array, or rounded differently, it stops being the sum of what is
40
+ * on screen. Here the total is derived from the lines themselves.
41
+ *
42
+ * ## Anatomy
43
+ *
44
+ * ```
45
+ * Gate movements 45 × 4.000 ₫ 180.000 ₫ ×
46
+ * Terminal cleaning 1 × 30.000 ₫ 30.000 ₫ ×
47
+ * ──────────────────────────────────────────────────────────
48
+ * Total 210.000 ₫
49
+ * ```
50
+ *
51
+ * Narrow containers fork deliberately: the name takes its own line and the
52
+ * arithmetic sits under it, RIGHT-ALIGNED to the band so it still ends on the
53
+ * amount column. That is a two-line row on purpose, which is a different thing
54
+ * from a derived value that fell off the end of a one-line row — and the tell is
55
+ * exactly that the amounts still share an edge with the total. The row's money
56
+ * verb moves up to the name line there, because the arithmetic line has no width
57
+ * to reserve for it and the name line has room to spare.
58
+ *
59
+ * ```
60
+ * Gate movements 4.200 ₫
61
+ * 45 × 4.000 ₫ 180.000 ₫ ×
62
+ * ```
63
+ */
64
+ export interface ChargeLineProps {
65
+ /** What is being charged. */
66
+ label: string;
67
+ /** A neutral qualifier under the label — a basis, a period. Never a problem:
68
+ * a problem belongs on the control that can fix it. */
69
+ meta?: string;
70
+ /** Priced line: `quantity × unitPrice` derives the amount, and the amount is
71
+ * never typed. Leave BOTH out for a flat line whose amount IS the figure
72
+ * someone states — a fee agreed as one number rather than computed. */
73
+ quantity?: number | null;
74
+ unitPrice?: number | null;
75
+ /** Flat line: the amount itself. Ignored when `quantity`/`unitPrice` are set,
76
+ * because a derived figure and a typed one cannot both be the truth. */
77
+ amount?: number | null;
78
+ onAmountChange?: (next: number | null) => void | Promise<void>;
79
+ /** The same, for a FLAT line's amount. Unconditional and `disabled` when idle. */
80
+ amountActions?: ReactNode;
81
+ /** One more control on this line — how it was paid, which party owes it.
82
+ * It belongs to the charge, so it rides the charge's own row. */
83
+ extra?: ReactNode;
84
+ /** A problem with THIS line, beside the control that fixes it. */
85
+ warning?: string;
86
+ /** Format a money figure. Comes from the band; a line never formats its own,
87
+ * or two lines in one band disagree about a currency. */
88
+ formatMoney?: (n: number) => string;
89
+ onQuantityChange?: (next: number | null) => void | Promise<void>;
90
+ onUnitPriceChange?: (next: number | null) => void | Promise<void>;
91
+ /**
92
+ * A verb about the unit price — "apply the standard rate" — as an
93
+ * `InlineButton`, which rides the field it acts on rather than standing on
94
+ * its own beside it.
95
+ *
96
+ * **Pass it UNCONDITIONALLY and `disabled` it when there is nothing to act
97
+ * on.** Passing it only when it has work makes the field narrower on the rows
98
+ * that carry it, so a row with a one-tap rate and a row without stop sharing
99
+ * a price column — and a verb that appears and disappears as a value changes
100
+ * is furniture the reader cannot learn.
101
+ */
102
+ unitPriceActions?: ReactNode;
103
+ /** The row's own verb, usually removal. Rendered in a fixed slot so the money
104
+ * column does not move between a row that has one and a row that does not. */
105
+ action?: ReactNode;
106
+ /** Read-only band: editors become values, and no action slot is drawn. */
107
+ locked?: boolean;
108
+ }
109
+
110
+ export interface ChargeLinesProps {
111
+ /** The lines. Absent is a real state — a record that has not been priced yet
112
+ * — so this is optional and `empty` speaks for it. */
113
+ children?: ReactNode;
114
+ /** Label for the closing row. */
115
+ totalLabel: string;
116
+ /** The band's total. Pass the sum of the lines on screen — a total that is
117
+ * not the sum of something visible is the one figure a reader cannot check. */
118
+ total: number;
119
+ formatMoney: (n: number) => string;
120
+ /** Sits in the closing row beside the total — the issue/collect action. */
121
+ action?: ReactNode;
122
+ /** Shown in place of the lines when there are none. */
123
+ empty?: ReactNode;
124
+ }
125
+
126
+ const QTY_W = 76;
127
+ const OP_W = 14;
128
+ const PRICE_W = 132;
129
+ const QTY_W_NARROW = 48;
130
+ const PRICE_W_NARROW = 92;
131
+ const AMOUNT_W = 108;
132
+ const ACTION_W = 32;
133
+ /** Below this the row forks to two lines. Measured on the CONTAINER, never the
134
+ * window: this band lives in drawers as often as on pages. */
135
+ const FORK_AT = 520;
136
+
137
+ export function ChargeLines(props: ChargeLinesProps) {
138
+ const { children, totalLabel, total, formatMoney, action, empty } = props;
139
+ const [width, setWidth] = useState(0);
140
+ const narrow = width > 0 && width < FORK_AT;
141
+ const onLayout = useCallback((e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width), []);
142
+ const rows = Array.isArray(children) ? children.filter(Boolean) : children;
143
+ const isEmpty = Array.isArray(rows) ? rows.length === 0 : !rows;
144
+
145
+ return (
146
+ <ChargeLinesContext.Provider value={{ narrow, formatMoney }}>
147
+ <View onLayout={onLayout} style={styles.band}>
148
+ {isEmpty ? (
149
+ <View style={styles.empty}>{empty}</View>
150
+ ) : (
151
+ rows
152
+ )}
153
+ {/* The closing row mirrors a LINE's trailing geometry exactly — amount
154
+ slot, then the same verb gutter — or the total misses the column it
155
+ is closing: without the gutter it sits a verb's width too far right,
156
+ and with the commit beside it the button shoves it left. Neither is
157
+ a nudge to tune; the row has to be built from the line's parts. */}
158
+ <View style={styles.total}>
159
+ <Text size="sm" weight="medium" style={styles.grow}>
160
+ {totalLabel}
161
+ </Text>
162
+ <View style={styles.maths}>
163
+ <View style={styles.amountSlot}>
164
+ <Text size="sm" weight="semibold" tabular style={[styles.right, styles.inset]}>
165
+ {formatMoney(total)}
166
+ </Text>
167
+ </View>
168
+ <View style={styles.actionSlot} />
169
+ </View>
170
+ </View>
171
+ {/* The commit takes its OWN row at the band's end and sits on the left,
172
+ because a right-floated act aligns to nothing — and here it would be
173
+ aligning to the one column that must stay the reader's. */}
174
+ {action ? <View style={styles.commit}>{action}</View> : null}
175
+ </View>
176
+ </ChargeLinesContext.Provider>
177
+ );
178
+ }
179
+
180
+ export function ChargeLine(props: ChargeLineProps) {
181
+ const {
182
+ label, meta, quantity, unitPrice, onQuantityChange, onUnitPriceChange,
183
+ unitPriceActions, amountActions, action, locked,
184
+ } = props;
185
+ const band = useChargeLines();
186
+ const words = useLoticsLocale().chargeLines;
187
+ const money = props.formatMoney ?? band.formatMoney;
188
+ const priced = quantity !== undefined || unitPrice !== undefined;
189
+ const amount = priced ? (quantity ?? 0) * (unitPrice ?? 0) : (props.amount ?? 0);
190
+ /** The row's money verb — the unit price's on a priced line, the amount's on a
191
+ * flat one. A locked band has nothing to act on, so it draws none. */
192
+ const warning = props.warning ? (
193
+ <Text size="xs" color="warning" style={styles.warn}>
194
+ {props.warning}
195
+ </Text>
196
+ ) : null;
197
+
198
+ const maths = (
199
+ /* RIGHT-ALIGNED, always. Wide, the row's growing label already pushes this
200
+ block to the band's edge; narrow it is a block in a COLUMN, so it stretches
201
+ and would lay its slots out from the LEFT — landing the amounts wherever
202
+ the fixed widths happen to end, which coincides with the closing row's
203
+ amount column only when the container is exactly as wide as this row. It
204
+ was 50px adrift in a 448 container and 33px in a 360 one. Justifying to the
205
+ end makes the amount slot the band's own trailing geometry, which is what
206
+ the total is built from. */
207
+ <View style={[styles.maths, band.narrow ? styles.mathsNarrow : null]}>
208
+ {!priced ? null : (
209
+ <View style={{ width: band.narrow ? QTY_W_NARROW : QTY_W }}>
210
+ {locked || !onQuantityChange ? (
211
+ <Text size="sm" tabular style={styles.inset}>
212
+ {quantity ?? "—"}
213
+ </Text>
214
+ ) : (
215
+ <InlineNumberInput
216
+ value={quantity ?? null}
217
+ onSave={onQuantityChange}
218
+ min={0}
219
+ placeholder="1"
220
+ align="right"
221
+ accessibilityLabel={words.quantity(label)}
222
+ />
223
+ )}
224
+ </View>
225
+ )}
226
+ {!priced ? null : (
227
+ <Text size="sm" color="muted" style={styles.op}>
228
+ ×
229
+ </Text>
230
+ )}
231
+ {!priced ? null : (
232
+ <View style={{ width: band.narrow ? PRICE_W_NARROW : PRICE_W }}>
233
+ {locked || !onUnitPriceChange ? (
234
+ <Text size="sm" tabular style={[styles.inset, styles.right]}>
235
+ {unitPrice == null ? "—" : money(unitPrice)}
236
+ </Text>
237
+ ) : (
238
+ <InlineNumberInput
239
+ value={unitPrice ?? null}
240
+ onSave={onUnitPriceChange}
241
+ min={0}
242
+ format={(v) => (v == null ? "" : money(v))}
243
+ placeholder="—"
244
+ align="right"
245
+ accessibilityLabel={words.unitPrice(label)}
246
+ />
247
+ )}
248
+ </View>
249
+ )}
250
+ {/* BESIDE the price field, never inside it: a verb inside eats the field's
251
+ width, so the figure slides left and a row carrying a one-tap rate stops
252
+ sharing this column with a row that has none. Width is the verb's own —
253
+ the contract is that a caller passes it on every row and DISABLES it
254
+ where it has nothing to do, which is what keeps the rows equal. */}
255
+ {!priced ? null : unitPriceActions}
256
+ {/* The amount ENDS the expression it derives from. Put it on a second
257
+ line and the reader parses left to right, then jumps back to find the
258
+ answer — and it lands in no column, so the total below closes nothing. */}
259
+ <View style={styles.amountSlot}>
260
+ {priced || locked || !props.onAmountChange ? (
261
+ <Text size="sm" tabular style={[styles.right, styles.inset]}>
262
+ {money(amount)}
263
+ </Text>
264
+ ) : (
265
+ <InlineNumberInput
266
+ value={props.amount ?? null}
267
+ onSave={props.onAmountChange}
268
+ min={0}
269
+ format={(v) => (v == null ? "" : money(v))}
270
+ placeholder="—"
271
+ align="right"
272
+ accessibilityLabel={words.amount(label)}
273
+ />
274
+ )}
275
+ </View>
276
+ {/* The flat line's twin of `unitPriceActions`, on the figure a flat line
277
+ actually edits — its amount. Same contract: pass it on every row and
278
+ disable it where it has nothing to do. */}
279
+ {priced ? null : amountActions}
280
+ {/* Held open whether or not this row has a verb, so the money column does
281
+ not shift between a row that can be removed and one that cannot. */}
282
+ <View style={styles.actionSlot}>{locked ? null : action}</View>
283
+ </View>
284
+ );
285
+
286
+ if (band.narrow) {
287
+ return (
288
+ <View style={styles.rowNarrow}>
289
+ {/* The NAME line carries the row's verb here, because the room it needs
290
+ is spare on this line and scarce on the next one. The arithmetic row
291
+ below then holds the same slots on every line whether or not a rate
292
+ is one tap away, which is the property the reserved slot bought in
293
+ the wide row — bought here without spending width the fork does not
294
+ have. */}
295
+ <View style={styles.narrowHead}>
296
+ <View style={styles.grow}>
297
+ <Text size="sm">{label}</Text>
298
+ {props.extra ? <View style={styles.extra}>{props.extra}</View> : null}
299
+ {meta ? (
300
+ <Text size="xs" color="muted">
301
+ {meta}
302
+ </Text>
303
+ ) : null}
304
+ </View>
305
+ </View>
306
+ {maths}
307
+ {/* The problem follows the row through the fork. It was rendered only in
308
+ the wide branch, so a line's warning vanished at exactly the width
309
+ where the reader has least room to work out what is wrong. */}
310
+ {warning}
311
+ </View>
312
+ );
313
+ }
314
+
315
+ return (
316
+ <View>
317
+ <View style={styles.row}>
318
+ <View style={styles.grow}>
319
+ <Text size="sm" numberOfLines={1}>
320
+ {label}
321
+ </Text>
322
+ {props.extra ? <View style={styles.extra}>{props.extra}</View> : null}
323
+ {meta ? (
324
+ <Text size="xs" color="muted" numberOfLines={1}>
325
+ {meta}
326
+ </Text>
327
+ ) : null}
328
+ </View>
329
+ {maths}
330
+ </View>
331
+ {warning}
332
+ </View>
333
+ );
334
+ }
335
+
336
+ const ChargeLinesContext = createContext<{ narrow: boolean; formatMoney: (n: number) => string }>({
337
+ narrow: false,
338
+ formatMoney: (n) => String(n),
339
+ });
340
+
341
+ function useChargeLines() {
342
+ return useContext(ChargeLinesContext);
343
+ }
344
+
345
+ const styles = StyleSheet.create({
346
+ band: { gap: SPACE.sm },
347
+ row: { flexDirection: "row", alignItems: "center", gap: SPACE.md },
348
+ rowNarrow: { gap: SPACE.xs },
349
+ grow: { flex: 1, minWidth: 0 },
350
+ maths: { flexDirection: "row", alignItems: "center", gap: SPACE.xs },
351
+ mathsNarrow: { justifyContent: "flex-end" },
352
+ narrowHead: { flexDirection: "row", alignItems: "flex-start", gap: SPACE.sm },
353
+ op: { width: OP_W, textAlign: "center" },
354
+ amountSlot: { width: AMOUNT_W },
355
+
356
+ extra: { maxWidth: 200, paddingTop: 4 },
357
+ actionSlot: { width: ACTION_W, alignItems: "flex-end" },
358
+ commit: { alignSelf: "flex-start", paddingTop: SPACE.xs },
359
+ right: { textAlign: "right" },
360
+ warn: { paddingTop: 2 },
361
+ /** A value standing where an editor stands lines up with it, rather than
362
+ * sitting where the editor's frame would have been. */
363
+ inset: { paddingHorizontal: CONTROL_TEXT_INSET },
364
+ total: {
365
+ flexDirection: "row",
366
+ alignItems: "center",
367
+ gap: SPACE.md,
368
+ paddingTop: SPACE.sm,
369
+ borderTopWidth: StyleSheet.hairlineWidth,
370
+ borderTopColor: colors.zinc[200],
371
+ },
372
+ empty: { paddingVertical: SPACE.sm },
373
+ });
@@ -1,4 +1,7 @@
1
1
  import { StyleSheet, View, type ViewStyle } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { Icon, type IconName } from "./icon";
4
+ import { solid, type ColorName } from "./colors";
2
5
  import { Text } from "./text";
3
6
  import { PressableHighlight } from "./pressable_highlight";
4
7
  import { chipSurfaceStyle } from "./control_surface";
@@ -22,6 +25,41 @@ import { chipSurfaceStyle } from "./control_surface";
22
25
  export interface ChipOption<T extends string = string> {
23
26
  label: string;
24
27
  value: T;
28
+ /**
29
+ * A mark BEFORE the label, for a set whose members differ in KIND rather than
30
+ * in degree — payment methods, channels, document types.
31
+ *
32
+ * It earns its place when the distinction it draws is one the reader acts on
33
+ * faster than they read: a glyph plus `iconColor` separates "the money is
34
+ * here" from "it is not" before the words are parsed. It is decoration on a
35
+ * set whose labels already differ plainly (Low / Medium / High), and there it
36
+ * costs width every chip pays for nothing.
37
+ *
38
+ * Never icon-ONLY: the label stays, because a chip that is a bare glyph is a
39
+ * control whose value cannot be read aloud or guessed.
40
+ */
41
+ icon?: IconName;
42
+ /** Ink for `icon`. Defaults to the label's own colour, which is what a mark
43
+ * that only says "which one" should take; give it a colour when the icon
44
+ * carries a MEANING the label does not (settled vs pending, ok vs blocked). */
45
+ iconColor?: string;
46
+ /**
47
+ * How many rows this chip leads to, as a PROP — never formatted into `label`.
48
+ * A number inside the label is a second copy that goes stale the moment the
49
+ * set behind it changes, and it cannot be styled apart from the word it
50
+ * follows. A count is also a reason to PRESS: a chip that states how many
51
+ * rows sit behind it and does not filter to them states a fact it refuses to
52
+ * act on.
53
+ */
54
+ count?: number;
55
+ /**
56
+ * A status dot BEFORE the label, in the same vocabulary as `Badge
57
+ * variant="dot"` — so a chip and the rows it filters to cannot disagree about
58
+ * what colour a state is. STATUS only: a category, a type or a place is not a
59
+ * status and takes no dot. Mutually exclusive with `icon`, which marks a
60
+ * difference in KIND rather than in state.
61
+ */
62
+ status?: ColorName;
25
63
  testID?: string;
26
64
  }
27
65
 
@@ -77,6 +115,15 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
77
115
  filter row selects the chip text instead of reading as a control —
78
116
  `SegmentedControl` and `Button` already suppress it, and the chips
79
117
  were the gap. */}
118
+ {option.status ? (
119
+ <View style={[styles.dot, { backgroundColor: solid(option.status) }]} />
120
+ ) : option.icon ? (
121
+ <Icon
122
+ name={option.icon}
123
+ size={14}
124
+ color={option.iconColor ?? (active ? colors.zinc[900] : colors.zinc[500])}
125
+ />
126
+ ) : null}
80
127
  <Text
81
128
  userSelect="none"
82
129
  size="sm"
@@ -85,6 +132,11 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
85
132
  >
86
133
  {option.label}
87
134
  </Text>
135
+ {option.count != null ? (
136
+ <Text userSelect="none" size="sm" color="muted" tabular>
137
+ {option.count}
138
+ </Text>
139
+ ) : null}
88
140
  </PressableHighlight>
89
141
  );
90
142
  })}
@@ -93,5 +145,9 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
93
145
  }
94
146
 
95
147
  const styles = StyleSheet.create({
96
- chip: { paddingHorizontal: 14, ...({ cursor: "auto" } as ViewStyle) },
148
+ dot: { width: 6, height: 6, borderRadius: 3 },
149
+ chip: {
150
+ flexDirection: "row",
151
+ alignItems: "center",
152
+ gap: 6, paddingHorizontal: 14, ...({ cursor: "auto" } as ViewStyle) },
97
153
  });
package/src/file_row.tsx CHANGED
@@ -3,11 +3,25 @@ import { Pressable, StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
5
  import { FileBadge } from "./file_badge";
6
+ import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
6
7
  import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
7
8
  import { useFocusRing } from "./use_focus_ring";
9
+ import { isImageMimeType } from "./mime";
8
10
  import { useLoticsLocale } from "./locale";
11
+ import { FieldAnnotations, type FieldAnnotationProps } from "./field_annotations";
9
12
 
10
- export interface FileRowProps {
13
+ /**
14
+ * A document-checklist row is a FIELD SURFACE — an expected document is a value
15
+ * the reader supplies — so it carries the kit's one annotation anatomy
16
+ * (`description` / `warning` / `error`), the same block `DetailRow` renders.
17
+ *
18
+ * Without it a row whose document FAILS could only say so in `meta`, which is
19
+ * muted single-line prose, or by demoting the fault to a callout about the whole
20
+ * SECTION — which says something different: the callout summarises, the field
21
+ * pinpoints. It is `flat`: the value above is the file's own name, not a painted
22
+ * control, so the annotation sits on that name's left edge with no inset.
23
+ */
24
+ export interface FileRowProps extends FieldAnnotationProps {
11
25
  /** The file / document name — the primary line. */
12
26
  name: string;
13
27
  /**
@@ -25,6 +39,32 @@ export interface FileRowProps {
25
39
  /** File type → the FileBadge glyph + color. Omit (with `placeholder`) for an
26
40
  * expected-but-not-yet-provided document. */
27
41
  mimeType?: string;
42
+ /**
43
+ * The stored file, so an IMAGE shows the scan itself where the glyph would be.
44
+ *
45
+ * A checklist of photographed paperwork — a permit, a certificate, an ID — is
46
+ * a list of documents that all carry the SAME badge, so the badge stops
47
+ * telling one row from another and only says "this is a picture". The picture
48
+ * says which picture. A non-image (a PDF) falls back to the badge, so a mixed
49
+ * list keeps one footprint down the column.
50
+ *
51
+ * It replaces the badge rather than sitting beside it: two marks on one row is
52
+ * the same fact twice, and `leading` is reserved for a mark ABOUT the row (a
53
+ * `DiffMark`, a checkbox) which must line up outside the file's own identity.
54
+ */
55
+ file?: DisplayFile;
56
+ /**
57
+ * Makes the THUMBNAIL its own press target, separate from the row's.
58
+ *
59
+ * A document row often has two different destinations: the row opens what the
60
+ * row is ABOUT (expand its fields, drill to the record), while the picture
61
+ * opens the picture. Without this the row's single door swallows both, so the
62
+ * only way to see the scan is whatever the row happens to do.
63
+ *
64
+ * It is a sibling press, not a nested one — the thumbnail sits outside the
65
+ * row's own `Pressable`, so this never puts a button inside a button.
66
+ */
67
+ onOpenFile?: () => void;
28
68
  /** Ghost badge — an expected document that hasn't been provided yet. */
29
69
  placeholder?: boolean;
30
70
  /** Marks the badge as a template (TMPL). */
@@ -36,6 +76,12 @@ export interface FileRowProps {
36
76
  * control. An independently-interactive SIBLING: never swallowed by the row
37
77
  * press (a button never nests in the door button). */
38
78
  trailing?: React.ReactNode;
79
+ /**
80
+ * What the row IS — a status `Badge`, a verdict. Rides INSIDE the press door,
81
+ * because a status is part of what the row says and pressing it should open
82
+ * the row. `trailing` is for what you DO to the row and stays outside.
83
+ */
84
+ status?: React.ReactNode;
39
85
  /**
40
86
  * Leading slot, BEFORE the badge — a `DiffMark` saying what happened to this
41
87
  * document, a selection checkbox, a status dot.
@@ -79,11 +125,17 @@ export function FileRow({
79
125
  meta,
80
126
  mimeType,
81
127
  placeholder,
128
+ file,
129
+ onOpenFile,
82
130
  isTemplate,
83
131
  onPress,
84
132
  trailing,
85
133
  leading,
86
134
  size = "sm",
135
+ description,
136
+ warning,
137
+ error,
138
+ status,
87
139
  }: FileRowProps) {
88
140
  const locale = useLoticsLocale();
89
141
  const md = size === "md";
@@ -97,9 +149,28 @@ export function FileRow({
97
149
  onMouseLeave: () => setHovered(false),
98
150
  } as object;
99
151
 
100
- const content = (
152
+ const mark = (
153
+ <>
154
+ {file && isImageMimeType(file.mimeType) ? (
155
+ /* A PHOTO is worth looking at, so it gets more room than a glyph would
156
+ earn — but only on `md`, and only for an image: a non-image above
157
+ `COMPACT_THUMBNAIL_SIZE` turns into a CARD that prints the filename,
158
+ which the row already says. */
159
+ <FileThumbnail
160
+ file={file}
161
+ size={md ? IMAGE_THUMB_MD : 30}
162
+ disablePress={!onOpenFile}
163
+ onPress={onOpenFile}
164
+ accessibilityLabel={name}
165
+ />
166
+ ) : (
167
+ <FileBadge size={md ? 38 : 30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
168
+ )}
169
+ </>
170
+ );
171
+
172
+ const body = (
101
173
  <>
102
- <FileBadge size={md ? 38 : 30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
103
174
  <View style={styles.text}>
104
175
  <Text size="sm" weight="medium" numberOfLines={1}>
105
176
  {name}
@@ -114,7 +185,15 @@ export function FileRow({
114
185
  ) : (
115
186
  meta
116
187
  )}
188
+ <FieldAnnotations description={description} warning={warning} error={error} flat />
117
189
  </View>
190
+ {/* STATUS rides INSIDE the door, unlike `trailing`. What a row IS belongs
191
+ to the row, so pressing its status opens the row like pressing its
192
+ name does; what you DO to a row is a control and stays a sibling.
193
+ Put a status Badge in `trailing` instead and it becomes a dead patch
194
+ in the middle of a pressable row — the reader presses the most
195
+ eye-catching thing on the line and nothing happens. */}
196
+ {status == null ? null : <View style={styles.status}>{status}</View>}
118
197
  </>
119
198
  );
120
199
 
@@ -122,7 +201,8 @@ export function FileRow({
122
201
  return (
123
202
  <View style={[styles.row, md && styles.rowMd]}>
124
203
  {leading}
125
- {content}
204
+ {mark}
205
+ {body}
126
206
  {trailing}
127
207
  </View>
128
208
  );
@@ -139,6 +219,11 @@ export function FileRow({
139
219
  {...mouseProps}
140
220
  >
141
221
  {leading}
222
+ {/* With its own press, the mark is a SIBLING of the door — nesting it
223
+ would put a button inside a button, and the browser would rather
224
+ reparent the DOM than render it. Without one it rides inside, where
225
+ pressing the file's face opens the row like the rest of it. */}
226
+ {onOpenFile ? mark : null}
142
227
  <Pressable
143
228
  onPress={onPress}
144
229
  {...focusProps}
@@ -148,7 +233,8 @@ export function FileRow({
148
233
  accessibilityLabel={locale.fileRow.open(name)}
149
234
  style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
150
235
  >
151
- {content}
236
+ {onOpenFile ? null : mark}
237
+ {body}
152
238
  </Pressable>
153
239
  {trailing}
154
240
  </View>
@@ -168,6 +254,12 @@ export function FileRow({
168
254
  * `TableRow` 52, this component's own `md` 56 — which is what "squeezed"
169
255
  * literally was: a file line packed tighter than any list it sits beside.
170
256
  */
257
+ /** An image thumbnail on the DOCUMENT-DESK row. Bigger than the 38 a badge
258
+ * takes, because a scan is read rather than recognised — at badge size a
259
+ * photographed certificate is an indistinct grey rectangle, and the whole
260
+ * reason to show the picture is to tell one document from another. */
261
+ export const IMAGE_THUMB_MD = 56;
262
+
171
263
  const ROW_MIN_HEIGHT = 48;
172
264
  const ROW_PAD_Y = 6;
173
265
 
@@ -202,5 +294,6 @@ const styles = StyleSheet.create({
202
294
  rowPressed: { backgroundColor: colors.zinc["100"] },
203
295
  // The accessible "Open" door — fills the row left of the trailing sibling.
204
296
  door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12, cursor: CURSOR_DEFAULT },
297
+ status: { marginLeft: "auto", paddingLeft: 12 },
205
298
  text: { flex: 1, gap: 1 },
206
299
  });
package/src/icon.tsx CHANGED
@@ -71,6 +71,9 @@ import Columns3Cog from "lucide-react-native/dist/esm/icons/columns-3-cog";
71
71
  import Construction from "lucide-react-native/dist/esm/icons/construction";
72
72
  import Copy from "lucide-react-native/dist/esm/icons/copy";
73
73
  import CreditCard from "lucide-react-native/dist/esm/icons/credit-card";
74
+ import Banknote from "lucide-react-native/dist/esm/icons/banknote";
75
+ import ArrowRightLeft from "lucide-react-native/dist/esm/icons/arrow-right-left";
76
+ import HandCoins from "lucide-react-native/dist/esm/icons/hand-coins";
74
77
  import Database from "lucide-react-native/dist/esm/icons/database";
75
78
  import Download from "lucide-react-native/dist/esm/icons/download";
76
79
  import Ellipsis from "lucide-react-native/dist/esm/icons/ellipsis";
@@ -271,6 +274,9 @@ const iconComponents = {
271
274
  construction: Construction,
272
275
  copy: Copy,
273
276
  "credit-card": CreditCard,
277
+ banknote: Banknote,
278
+ "arrow-right-left": ArrowRightLeft,
279
+ "hand-coins": HandCoins,
274
280
  database: Database,
275
281
  download: Download,
276
282
  ellipsis: Ellipsis,