@lotics/ui 46.3.0 → 46.8.1
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 +14 -1
- package/docs/catalog.md +121 -6
- package/docs/composition.md +26 -0
- package/docs/data_entry.md +56 -2
- package/docs/reviewing.md +34 -0
- package/docs/templates.md +90 -29
- package/examples/tpl_board.tsx +257 -0
- package/examples/tpl_money.tsx +1048 -0
- package/package.json +261 -259
- package/src/accordion.tsx +7 -1
- package/src/board.tsx +611 -0
- package/src/card.tsx +7 -1
- package/src/charge_lines.tsx +376 -0
- package/src/chip_group.tsx +57 -1
- package/src/file_row.tsx +98 -5
- package/src/icon.tsx +6 -0
- package/src/inline_edit.tsx +54 -10
- package/src/inline_number_input.tsx +5 -1
- package/src/inline_text_input.tsx +1 -1
- package/src/locale.tsx +26 -1
- package/src/matrix.tsx +23 -8
- package/src/reference_field.tsx +36 -13
- package/src/table.tsx +6 -1
- package/src/tabs.tsx +1 -1
- package/examples/tpl_report.tsx +0 -410
- package/examples/tpl_statements.tsx +0 -221
|
@@ -0,0 +1,376 @@
|
|
|
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 verb is drawn — not the row's
|
|
107
|
+
* action and not either money verb. A verb on a figure nobody can change is a
|
|
108
|
+
* control with nothing behind it, and a disabled one that merely repeats the
|
|
109
|
+
* value beside it prints the same number twice. */
|
|
110
|
+
locked?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ChargeLinesProps {
|
|
114
|
+
/** The lines. Absent is a real state — a record that has not been priced yet
|
|
115
|
+
* — so this is optional and `empty` speaks for it. */
|
|
116
|
+
children?: ReactNode;
|
|
117
|
+
/** Label for the closing row. */
|
|
118
|
+
totalLabel: string;
|
|
119
|
+
/** The band's total. Pass the sum of the lines on screen — a total that is
|
|
120
|
+
* not the sum of something visible is the one figure a reader cannot check. */
|
|
121
|
+
total: number;
|
|
122
|
+
formatMoney: (n: number) => string;
|
|
123
|
+
/** Sits in the closing row beside the total — the issue/collect action. */
|
|
124
|
+
action?: ReactNode;
|
|
125
|
+
/** Shown in place of the lines when there are none. */
|
|
126
|
+
empty?: ReactNode;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const QTY_W = 76;
|
|
130
|
+
const OP_W = 14;
|
|
131
|
+
const PRICE_W = 132;
|
|
132
|
+
const QTY_W_NARROW = 48;
|
|
133
|
+
const PRICE_W_NARROW = 92;
|
|
134
|
+
const AMOUNT_W = 108;
|
|
135
|
+
const ACTION_W = 32;
|
|
136
|
+
/** Below this the row forks to two lines. Measured on the CONTAINER, never the
|
|
137
|
+
* window: this band lives in drawers as often as on pages. */
|
|
138
|
+
const FORK_AT = 520;
|
|
139
|
+
|
|
140
|
+
export function ChargeLines(props: ChargeLinesProps) {
|
|
141
|
+
const { children, totalLabel, total, formatMoney, action, empty } = props;
|
|
142
|
+
const [width, setWidth] = useState(0);
|
|
143
|
+
const narrow = width > 0 && width < FORK_AT;
|
|
144
|
+
const onLayout = useCallback((e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width), []);
|
|
145
|
+
const rows = Array.isArray(children) ? children.filter(Boolean) : children;
|
|
146
|
+
const isEmpty = Array.isArray(rows) ? rows.length === 0 : !rows;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<ChargeLinesContext.Provider value={{ narrow, formatMoney }}>
|
|
150
|
+
<View onLayout={onLayout} style={styles.band}>
|
|
151
|
+
{isEmpty ? (
|
|
152
|
+
<View style={styles.empty}>{empty}</View>
|
|
153
|
+
) : (
|
|
154
|
+
rows
|
|
155
|
+
)}
|
|
156
|
+
{/* The closing row mirrors a LINE's trailing geometry exactly — amount
|
|
157
|
+
slot, then the same verb gutter — or the total misses the column it
|
|
158
|
+
is closing: without the gutter it sits a verb's width too far right,
|
|
159
|
+
and with the commit beside it the button shoves it left. Neither is
|
|
160
|
+
a nudge to tune; the row has to be built from the line's parts. */}
|
|
161
|
+
<View style={styles.total}>
|
|
162
|
+
<Text size="sm" weight="medium" style={styles.grow}>
|
|
163
|
+
{totalLabel}
|
|
164
|
+
</Text>
|
|
165
|
+
<View style={styles.maths}>
|
|
166
|
+
<View style={styles.amountSlot}>
|
|
167
|
+
<Text size="sm" weight="semibold" tabular style={[styles.right, styles.inset]}>
|
|
168
|
+
{formatMoney(total)}
|
|
169
|
+
</Text>
|
|
170
|
+
</View>
|
|
171
|
+
<View style={styles.actionSlot} />
|
|
172
|
+
</View>
|
|
173
|
+
</View>
|
|
174
|
+
{/* The commit takes its OWN row at the band's end and sits on the left,
|
|
175
|
+
because a right-floated act aligns to nothing — and here it would be
|
|
176
|
+
aligning to the one column that must stay the reader's. */}
|
|
177
|
+
{action ? <View style={styles.commit}>{action}</View> : null}
|
|
178
|
+
</View>
|
|
179
|
+
</ChargeLinesContext.Provider>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function ChargeLine(props: ChargeLineProps) {
|
|
184
|
+
const {
|
|
185
|
+
label, meta, quantity, unitPrice, onQuantityChange, onUnitPriceChange,
|
|
186
|
+
unitPriceActions, amountActions, action, locked,
|
|
187
|
+
} = props;
|
|
188
|
+
const band = useChargeLines();
|
|
189
|
+
const words = useLoticsLocale().chargeLines;
|
|
190
|
+
const money = props.formatMoney ?? band.formatMoney;
|
|
191
|
+
const priced = quantity !== undefined || unitPrice !== undefined;
|
|
192
|
+
const amount = priced ? (quantity ?? 0) * (unitPrice ?? 0) : (props.amount ?? 0);
|
|
193
|
+
/** The row's money verb — the unit price's on a priced line, the amount's on a
|
|
194
|
+
* flat one. A locked band has nothing to act on, so it draws none. */
|
|
195
|
+
const warning = props.warning ? (
|
|
196
|
+
<Text size="xs" color="warning" style={styles.warn}>
|
|
197
|
+
{props.warning}
|
|
198
|
+
</Text>
|
|
199
|
+
) : null;
|
|
200
|
+
|
|
201
|
+
const maths = (
|
|
202
|
+
/* RIGHT-ALIGNED, always. Wide, the row's growing label already pushes this
|
|
203
|
+
block to the band's edge; narrow it is a block in a COLUMN, so it stretches
|
|
204
|
+
and would lay its slots out from the LEFT — landing the amounts wherever
|
|
205
|
+
the fixed widths happen to end, which coincides with the closing row's
|
|
206
|
+
amount column only when the container is exactly as wide as this row. It
|
|
207
|
+
was 50px adrift in a 448 container and 33px in a 360 one. Justifying to the
|
|
208
|
+
end makes the amount slot the band's own trailing geometry, which is what
|
|
209
|
+
the total is built from. */
|
|
210
|
+
<View style={[styles.maths, band.narrow ? styles.mathsNarrow : null]}>
|
|
211
|
+
{!priced ? null : (
|
|
212
|
+
<View style={{ width: band.narrow ? QTY_W_NARROW : QTY_W }}>
|
|
213
|
+
{locked || !onQuantityChange ? (
|
|
214
|
+
<Text size="sm" tabular style={styles.inset}>
|
|
215
|
+
{quantity ?? "—"}
|
|
216
|
+
</Text>
|
|
217
|
+
) : (
|
|
218
|
+
<InlineNumberInput
|
|
219
|
+
value={quantity ?? null}
|
|
220
|
+
onSave={onQuantityChange}
|
|
221
|
+
min={0}
|
|
222
|
+
placeholder="1"
|
|
223
|
+
align="right"
|
|
224
|
+
accessibilityLabel={words.quantity(label)}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
</View>
|
|
228
|
+
)}
|
|
229
|
+
{!priced ? null : (
|
|
230
|
+
<Text size="sm" color="muted" style={styles.op}>
|
|
231
|
+
×
|
|
232
|
+
</Text>
|
|
233
|
+
)}
|
|
234
|
+
{!priced ? null : (
|
|
235
|
+
<View style={{ width: band.narrow ? PRICE_W_NARROW : PRICE_W }}>
|
|
236
|
+
{locked || !onUnitPriceChange ? (
|
|
237
|
+
<Text size="sm" tabular style={[styles.inset, styles.right]}>
|
|
238
|
+
{unitPrice == null ? "—" : money(unitPrice)}
|
|
239
|
+
</Text>
|
|
240
|
+
) : (
|
|
241
|
+
<InlineNumberInput
|
|
242
|
+
value={unitPrice ?? null}
|
|
243
|
+
onSave={onUnitPriceChange}
|
|
244
|
+
min={0}
|
|
245
|
+
format={(v) => (v == null ? "" : money(v))}
|
|
246
|
+
placeholder="—"
|
|
247
|
+
align="right"
|
|
248
|
+
accessibilityLabel={words.unitPrice(label)}
|
|
249
|
+
/>
|
|
250
|
+
)}
|
|
251
|
+
</View>
|
|
252
|
+
)}
|
|
253
|
+
{/* BESIDE the price field, never inside it: a verb inside eats the field's
|
|
254
|
+
width, so the figure slides left and a row carrying a one-tap rate stops
|
|
255
|
+
sharing this column with a row that has none. Width is the verb's own —
|
|
256
|
+
the contract is that a caller passes it on every row and DISABLES it
|
|
257
|
+
where it has nothing to do, which is what keeps the rows equal. */}
|
|
258
|
+
{!priced || locked ? null : unitPriceActions}
|
|
259
|
+
{/* The amount ENDS the expression it derives from. Put it on a second
|
|
260
|
+
line and the reader parses left to right, then jumps back to find the
|
|
261
|
+
answer — and it lands in no column, so the total below closes nothing. */}
|
|
262
|
+
<View style={styles.amountSlot}>
|
|
263
|
+
{priced || locked || !props.onAmountChange ? (
|
|
264
|
+
<Text size="sm" tabular style={[styles.right, styles.inset]}>
|
|
265
|
+
{money(amount)}
|
|
266
|
+
</Text>
|
|
267
|
+
) : (
|
|
268
|
+
<InlineNumberInput
|
|
269
|
+
value={props.amount ?? null}
|
|
270
|
+
onSave={props.onAmountChange}
|
|
271
|
+
min={0}
|
|
272
|
+
format={(v) => (v == null ? "" : money(v))}
|
|
273
|
+
placeholder="—"
|
|
274
|
+
align="right"
|
|
275
|
+
accessibilityLabel={words.amount(label)}
|
|
276
|
+
/>
|
|
277
|
+
)}
|
|
278
|
+
</View>
|
|
279
|
+
{/* The flat line's twin of `unitPriceActions`, on the figure a flat line
|
|
280
|
+
actually edits — its amount. Same contract: pass it on every row and
|
|
281
|
+
disable it where it has nothing to do. */}
|
|
282
|
+
{priced || locked ? null : amountActions}
|
|
283
|
+
{/* Held open whether or not this row has a verb, so the money column does
|
|
284
|
+
not shift between a row that can be removed and one that cannot. */}
|
|
285
|
+
<View style={styles.actionSlot}>{locked ? null : action}</View>
|
|
286
|
+
</View>
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
if (band.narrow) {
|
|
290
|
+
return (
|
|
291
|
+
<View style={styles.rowNarrow}>
|
|
292
|
+
{/* The NAME line carries the row's verb here, because the room it needs
|
|
293
|
+
is spare on this line and scarce on the next one. The arithmetic row
|
|
294
|
+
below then holds the same slots on every line whether or not a rate
|
|
295
|
+
is one tap away, which is the property the reserved slot bought in
|
|
296
|
+
the wide row — bought here without spending width the fork does not
|
|
297
|
+
have. */}
|
|
298
|
+
<View style={styles.narrowHead}>
|
|
299
|
+
<View style={styles.grow}>
|
|
300
|
+
<Text size="sm">{label}</Text>
|
|
301
|
+
{props.extra ? <View style={styles.extra}>{props.extra}</View> : null}
|
|
302
|
+
{meta ? (
|
|
303
|
+
<Text size="xs" color="muted">
|
|
304
|
+
{meta}
|
|
305
|
+
</Text>
|
|
306
|
+
) : null}
|
|
307
|
+
</View>
|
|
308
|
+
</View>
|
|
309
|
+
{maths}
|
|
310
|
+
{/* The problem follows the row through the fork. It was rendered only in
|
|
311
|
+
the wide branch, so a line's warning vanished at exactly the width
|
|
312
|
+
where the reader has least room to work out what is wrong. */}
|
|
313
|
+
{warning}
|
|
314
|
+
</View>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<View>
|
|
320
|
+
<View style={styles.row}>
|
|
321
|
+
<View style={styles.grow}>
|
|
322
|
+
<Text size="sm" numberOfLines={1}>
|
|
323
|
+
{label}
|
|
324
|
+
</Text>
|
|
325
|
+
{props.extra ? <View style={styles.extra}>{props.extra}</View> : null}
|
|
326
|
+
{meta ? (
|
|
327
|
+
<Text size="xs" color="muted" numberOfLines={1}>
|
|
328
|
+
{meta}
|
|
329
|
+
</Text>
|
|
330
|
+
) : null}
|
|
331
|
+
</View>
|
|
332
|
+
{maths}
|
|
333
|
+
</View>
|
|
334
|
+
{warning}
|
|
335
|
+
</View>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const ChargeLinesContext = createContext<{ narrow: boolean; formatMoney: (n: number) => string }>({
|
|
340
|
+
narrow: false,
|
|
341
|
+
formatMoney: (n) => String(n),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
function useChargeLines() {
|
|
345
|
+
return useContext(ChargeLinesContext);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const styles = StyleSheet.create({
|
|
349
|
+
band: { gap: SPACE.sm },
|
|
350
|
+
row: { flexDirection: "row", alignItems: "center", gap: SPACE.md },
|
|
351
|
+
rowNarrow: { gap: SPACE.xs },
|
|
352
|
+
grow: { flex: 1, minWidth: 0 },
|
|
353
|
+
maths: { flexDirection: "row", alignItems: "center", gap: SPACE.xs },
|
|
354
|
+
mathsNarrow: { justifyContent: "flex-end" },
|
|
355
|
+
narrowHead: { flexDirection: "row", alignItems: "flex-start", gap: SPACE.sm },
|
|
356
|
+
op: { width: OP_W, textAlign: "center" },
|
|
357
|
+
amountSlot: { width: AMOUNT_W },
|
|
358
|
+
|
|
359
|
+
extra: { maxWidth: 200, paddingTop: 4 },
|
|
360
|
+
actionSlot: { width: ACTION_W, alignItems: "flex-end" },
|
|
361
|
+
commit: { alignSelf: "flex-start", paddingTop: SPACE.xs },
|
|
362
|
+
right: { textAlign: "right" },
|
|
363
|
+
warn: { paddingTop: 2 },
|
|
364
|
+
/** A value standing where an editor stands lines up with it, rather than
|
|
365
|
+
* sitting where the editor's frame would have been. */
|
|
366
|
+
inset: { paddingHorizontal: CONTROL_TEXT_INSET },
|
|
367
|
+
total: {
|
|
368
|
+
flexDirection: "row",
|
|
369
|
+
alignItems: "center",
|
|
370
|
+
gap: SPACE.md,
|
|
371
|
+
paddingTop: SPACE.sm,
|
|
372
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
373
|
+
borderTopColor: colors.zinc[200],
|
|
374
|
+
},
|
|
375
|
+
empty: { paddingVertical: SPACE.sm },
|
|
376
|
+
});
|
package/src/chip_group.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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,
|