@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.
Files changed (49) hide show
  1. package/AGENTS.md +38 -1
  2. package/MIGRATION.md +87 -0
  3. package/docs/ai_patterns.md +11 -0
  4. package/docs/catalog.md +217 -21
  5. package/docs/composition.md +74 -6
  6. package/docs/data_entry.md +56 -2
  7. package/docs/reviewing.md +34 -0
  8. package/docs/templates.md +93 -30
  9. package/docs/testing.md +6 -0
  10. package/examples/tpl_board.tsx +257 -0
  11. package/examples/tpl_money.tsx +1027 -0
  12. package/package.json +261 -258
  13. package/src/accordion.tsx +7 -1
  14. package/src/alert.css +0 -1
  15. package/src/alert.tsx +8 -0
  16. package/src/axis_label_indices.ts +84 -0
  17. package/src/bar_chart.tsx +137 -16
  18. package/src/board.tsx +611 -0
  19. package/src/card.tsx +7 -1
  20. package/src/charge_lines.tsx +373 -0
  21. package/src/chip_group.tsx +57 -1
  22. package/src/dialog.tsx +46 -24
  23. package/src/drawer.tsx +21 -2
  24. package/src/file_gallery_modal.tsx +3 -0
  25. package/src/file_row.tsx +98 -5
  26. package/src/icon.tsx +6 -0
  27. package/src/inline_edit.tsx +54 -10
  28. package/src/inline_number_input.tsx +5 -1
  29. package/src/inline_text_input.tsx +1 -1
  30. package/src/line_chart.tsx +2 -2
  31. package/src/locale.tsx +26 -1
  32. package/src/matrix.tsx +23 -8
  33. package/src/modal.tsx +23 -3
  34. package/src/overlay_layer.ts +65 -0
  35. package/src/page_content.tsx +8 -22
  36. package/src/page_header.tsx +60 -11
  37. package/src/popover.tsx +29 -5
  38. package/src/reference_field.tsx +36 -13
  39. package/src/skip_link.tsx +2 -1
  40. package/src/stacked_bar_chart.tsx +31 -1
  41. package/src/table.tsx +6 -1
  42. package/src/tabs.tsx +1 -1
  43. package/src/text.tsx +21 -0
  44. package/src/tooltip.tsx +2 -1
  45. package/src/use_change_set.ts +66 -17
  46. package/src/use_scroll_seam.ts +79 -0
  47. package/examples/tpl_report.tsx +0 -410
  48. package/examples/tpl_statements.tsx +0 -221
  49. 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
+ });
@@ -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/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
- <Modal visible={open} onRequestClose={handleClose} transparent>
195
- <View style={styles.base}>
196
- <View style={styles.background} />
197
- <Animated.View
198
- style={{
199
- top: effectiveOffsetTop,
200
- width: screenSize.small ? "100%" : width,
201
- height: screenSize.small ? "100%" : height,
202
- maxHeight: screenSize.small ? undefined : maxHeight,
203
- maxWidth: screenSize.small ? undefined : maxWidth,
204
- }}
205
- >
206
- <PortalHost>
207
- <View testID={testID} style={[styles.dialogContainer, { borderRadius }]}>
208
- <View style={[styles.closeButtonContainer, { paddingHorizontal: gutter }]}>
209
- <IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={handleClose} />
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
- <SizeBoundary style={styles.container}>{children}</SizeBoundary>
212
- </View>
213
- </PortalHost>
214
- </Animated.View>
215
- </View>
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 contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal: gutter }]}>
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={open} onRequestClose={handleClose} transparent>
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 —