@lotics/ui 46.2.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.
- package/AGENTS.md +38 -1
- package/MIGRATION.md +87 -0
- package/docs/ai_patterns.md +11 -0
- package/docs/catalog.md +217 -21
- package/docs/composition.md +74 -6
- package/docs/data_entry.md +56 -2
- package/docs/reviewing.md +34 -0
- package/docs/templates.md +93 -30
- package/docs/testing.md +6 -0
- package/examples/tpl_board.tsx +257 -0
- package/examples/tpl_money.tsx +1027 -0
- package/package.json +261 -258
- package/src/accordion.tsx +7 -1
- package/src/alert.css +0 -1
- package/src/alert.tsx +8 -0
- package/src/axis_label_indices.ts +84 -0
- package/src/bar_chart.tsx +137 -16
- package/src/board.tsx +611 -0
- package/src/card.tsx +7 -1
- package/src/charge_lines.tsx +373 -0
- package/src/chip_group.tsx +57 -1
- package/src/dialog.tsx +46 -24
- package/src/drawer.tsx +21 -2
- package/src/file_gallery_modal.tsx +3 -0
- 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/line_chart.tsx +2 -2
- package/src/locale.tsx +26 -1
- package/src/matrix.tsx +23 -8
- package/src/modal.tsx +23 -3
- package/src/overlay_layer.ts +65 -0
- package/src/page_content.tsx +8 -22
- package/src/page_header.tsx +60 -11
- package/src/popover.tsx +29 -5
- package/src/reference_field.tsx +36 -13
- package/src/skip_link.tsx +2 -1
- package/src/stacked_bar_chart.tsx +31 -1
- package/src/table.tsx +6 -1
- package/src/tabs.tsx +1 -1
- package/src/text.tsx +21 -0
- package/src/tooltip.tsx +2 -1
- package/src/use_change_set.ts +66 -17
- package/src/use_scroll_seam.ts +79 -0
- package/examples/tpl_report.tsx +0 -410
- package/examples/tpl_statements.tsx +0 -221
- package/src/line_chart_labels.ts +0 -32
|
@@ -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
|
+
});
|
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/dialog.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
useNavigationStack,
|
|
18
18
|
} from "./screen_router";
|
|
19
19
|
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
20
|
+
import { useScrollSeam } from "./use_scroll_seam";
|
|
20
21
|
|
|
21
22
|
// ============================================================================
|
|
22
23
|
// Shared Navigation Context (used by both Dialog and MasterDetailDialog)
|
|
@@ -191,29 +192,39 @@ export function Dialog(props: DialogProps) {
|
|
|
191
192
|
<ScreenRouterInternalContext.Provider value={internalValue}>
|
|
192
193
|
<DialogContext.Provider value={dialogValue}>
|
|
193
194
|
<DialogNavigationProvider value={navigationContextValue}>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
195
|
+
{/* Mounted only while OPEN. react-native-web appends a `Modal`'s
|
|
196
|
+
body-level div on first render and never re-orders it, so an
|
|
197
|
+
always-mounted dialog claims its DOM slot before a drawer that
|
|
198
|
+
opens later and is then covered by it — readable, announced, and
|
|
199
|
+
dead to every press. Mounting on open makes DOM order open order.
|
|
200
|
+
Nothing is lost: a closed `Modal` renders its children as `null`
|
|
201
|
+
already, and everything that must survive a close (the router,
|
|
202
|
+
the contexts) lives outside this element. */}
|
|
203
|
+
{open && (
|
|
204
|
+
<Modal visible onRequestClose={handleClose} transparent>
|
|
205
|
+
<View style={styles.base}>
|
|
206
|
+
<View style={styles.background} />
|
|
207
|
+
<Animated.View
|
|
208
|
+
style={{
|
|
209
|
+
top: effectiveOffsetTop,
|
|
210
|
+
width: screenSize.small ? "100%" : width,
|
|
211
|
+
height: screenSize.small ? "100%" : height,
|
|
212
|
+
maxHeight: screenSize.small ? undefined : maxHeight,
|
|
213
|
+
maxWidth: screenSize.small ? undefined : maxWidth,
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
<PortalHost>
|
|
217
|
+
<View testID={testID} style={[styles.dialogContainer, { borderRadius }]}>
|
|
218
|
+
<View style={[styles.closeButtonContainer, { paddingHorizontal: gutter }]}>
|
|
219
|
+
<IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={handleClose} />
|
|
220
|
+
</View>
|
|
221
|
+
<SizeBoundary style={styles.container}>{children}</SizeBoundary>
|
|
210
222
|
</View>
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
</Modal>
|
|
223
|
+
</PortalHost>
|
|
224
|
+
</Animated.View>
|
|
225
|
+
</View>
|
|
226
|
+
</Modal>
|
|
227
|
+
)}
|
|
217
228
|
</DialogNavigationProvider>
|
|
218
229
|
</DialogContext.Provider>
|
|
219
230
|
</ScreenRouterInternalContext.Provider>
|
|
@@ -280,11 +291,19 @@ export function DialogHeaderActions(props: DialogHeaderActionsProps) {
|
|
|
280
291
|
|
|
281
292
|
export interface DialogScrollAreaProps {
|
|
282
293
|
children: React.ReactNode;
|
|
294
|
+
/**
|
|
295
|
+
* The IDENTITY of the content in the scroller, for a pane that SWAPS its body
|
|
296
|
+
* in place — see `DrawerScrollArea`. A dialog that navigates between `Screen`s
|
|
297
|
+
* does not need it: a stacked screen stays mounted, so each screen keeps its
|
|
298
|
+
* own scroll area and its own offset already.
|
|
299
|
+
*/
|
|
300
|
+
scrollKey?: string;
|
|
283
301
|
}
|
|
284
302
|
|
|
285
303
|
export function DialogScrollArea(props: DialogScrollAreaProps) {
|
|
286
|
-
const { children } = props;
|
|
304
|
+
const { children, scrollKey } = props;
|
|
287
305
|
const gutter = useDialogGutter();
|
|
306
|
+
const seam = useScrollSeam(scrollKey);
|
|
288
307
|
|
|
289
308
|
// The gutter and the heading altitude are the same kind of fact — both belong
|
|
290
309
|
// to the panel, and both were being answered by callers who could only guess.
|
|
@@ -292,7 +311,10 @@ export function DialogScrollArea(props: DialogScrollAreaProps) {
|
|
|
292
311
|
// `lg` `DialogHeaderTitle` above it. See `heading_altitude.ts`.
|
|
293
312
|
return (
|
|
294
313
|
<HeadingAltitudeContext.Provider value="panel">
|
|
295
|
-
<ScrollView
|
|
314
|
+
<ScrollView
|
|
315
|
+
{...seam}
|
|
316
|
+
contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal: gutter }]}
|
|
317
|
+
>
|
|
296
318
|
{children}
|
|
297
319
|
</ScrollView>
|
|
298
320
|
</HeadingAltitudeContext.Provider>
|
package/src/drawer.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { Text } from "@lotics/ui/text";
|
|
|
9
9
|
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
10
10
|
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
11
11
|
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
12
|
+
import { useScrollSeam } from "./use_scroll_seam";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The panel's inset — the ONE left edge the header, `DrawerScrollArea` and the footer
|
|
@@ -103,8 +104,14 @@ export function Drawer(props: DrawerProps) {
|
|
|
103
104
|
return () => document.removeEventListener("keydown", handler);
|
|
104
105
|
}, [open, onPrev, onNext]);
|
|
105
106
|
|
|
107
|
+
// Mounted only while OPEN — see `overlay_layer.ts`. react-native-web appends a
|
|
108
|
+
// `Modal`'s body-level div on first render and never re-orders it, so an
|
|
109
|
+
// always-mounted overlay claims its slot ahead of one opened later and covers
|
|
110
|
+
// it. Mounting on open makes DOM order open order.
|
|
111
|
+
if (!open) return null;
|
|
112
|
+
|
|
106
113
|
return (
|
|
107
|
-
<Modal visible
|
|
114
|
+
<Modal visible onRequestClose={handleClose} transparent>
|
|
108
115
|
<View style={styles.base}>
|
|
109
116
|
{/* Scrim is a sibling of the panel, so tapping the panel never closes. */}
|
|
110
117
|
<Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel={loc.close} tabIndex={-1} />
|
|
@@ -153,6 +160,17 @@ export function Drawer(props: DrawerProps) {
|
|
|
153
160
|
|
|
154
161
|
export interface DrawerScrollAreaProps {
|
|
155
162
|
children: ReactNode;
|
|
163
|
+
/**
|
|
164
|
+
* The IDENTITY of the content in the scroller — a record id, a step name.
|
|
165
|
+
*
|
|
166
|
+
* Set it on a drawer that SWAPS its body in place (the master-detail shape: a
|
|
167
|
+
* child row replaces the record rather than stacking a second drawer).
|
|
168
|
+
* Changing it opens the new content at the top and restores the previous
|
|
169
|
+
* content's offset when the key comes back, so the way forward starts where a
|
|
170
|
+
* reader expects and the way back keeps their place in the list they came
|
|
171
|
+
* from. Omit it on a drawer whose body is one thing.
|
|
172
|
+
*/
|
|
173
|
+
scrollKey?: string;
|
|
156
174
|
}
|
|
157
175
|
|
|
158
176
|
/**
|
|
@@ -192,9 +210,10 @@ export function DrawerScrollArea(props: DrawerScrollAreaProps) {
|
|
|
192
210
|
// Not on `Drawer` itself: the drawer's BARE slot is where a whole record
|
|
193
211
|
// screen goes, and that surface brings its own `#` identity band, so its
|
|
194
212
|
// sections are page sections and must stay `##`. See `heading_altitude.ts`.
|
|
213
|
+
const seam = useScrollSeam(props.scrollKey);
|
|
195
214
|
return (
|
|
196
215
|
<HeadingAltitudeContext.Provider value="panel">
|
|
197
|
-
<ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>{props.children}</ScrollView>
|
|
216
|
+
<ScrollView {...seam} style={styles.body} contentContainerStyle={styles.bodyContent}>{props.children}</ScrollView>
|
|
198
217
|
</HeadingAltitudeContext.Provider>
|
|
199
218
|
);
|
|
200
219
|
}
|
|
@@ -211,6 +211,9 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
211
211
|
];
|
|
212
212
|
|
|
213
213
|
return (
|
|
214
|
+
// Rendered only while a file is open (`activeIndex !== null` returns null
|
|
215
|
+
// above), which is what puts this overlay's body-level div in open order —
|
|
216
|
+
// see `overlay_layer.ts`.
|
|
214
217
|
<Modal visible transparent onRequestClose={close} animationType="fade">
|
|
215
218
|
{/* PortalHost so the ⋯ menu's popover portals INSIDE the modal's stacking
|
|
216
219
|
context (on top) instead of to the app-root host behind the overlay —
|