@lotics/ui 47.5.0 → 47.5.2
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 +2 -2
- package/docs/catalog.md +7 -1
- package/docs/composition.md +42 -0
- package/docs/reviewing.md +28 -0
- package/docs/testing.md +23 -0
- package/package.json +1 -1
- package/src/file_thumbnail.tsx +47 -1
- package/src/image_gallery.tsx +6 -1
- package/src/list.tsx +4 -1
- package/src/popover.tsx +28 -0
- package/src/section_stack.tsx +6 -4
- package/src/sequence.tsx +5 -1
- package/src/stack.tsx +4 -1
- package/src/table.tsx +11 -6
package/AGENTS.md
CHANGED
|
@@ -17,8 +17,8 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — incl. `just_arrived` (`useJustArrived` + `TableRow`'s `justArrived`: the one-shot wash a row wears when it LANDS, so a realtime push is visible rather than one row of two hundred silently changing) — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
18
18
|
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values; findings, provenance, confidence; **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
|
|
20
|
-
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), **a row subject's supporting line takes a REFERENCE and never a sentence** (an explanation truncates to the clause the reader already knew), and where a commit sits. |
|
|
21
|
-
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
|
|
20
|
+
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), **a row subject's supporting line takes a REFERENCE and never a sentence** (an explanation truncates to the clause the reader already knew), **a list stays fast by keeping IDENTITY rather than memoizing** (key by the record not the index; `memo` is the last resort and does nothing against a freshly-built prop), and where a commit sits. |
|
|
21
|
+
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers) and the cross-FRAME check (what the screen destroys when a row arrives — the one defect that exists in no single frame). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
|
|
22
22
|
| [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
|
|
23
23
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
|
|
24
24
|
|
package/docs/catalog.md
CHANGED
|
@@ -2303,7 +2303,13 @@ component rather than showing it at zero.
|
|
|
2303
2303
|
filename; pass `accessibilityLabel` to say what pressing it DOES instead. The per-surface
|
|
2304
2304
|
pieces are exported for a hand-rolled layout: `DocumentBadge` (a bare pressable badge —
|
|
2305
2305
|
its `size` is the square SLOT side, like every other tile here, and the badge is fitted to
|
|
2306
|
-
it), `DocumentCard`, `MediaCard`, `RemoveButton`.
|
|
2306
|
+
it), `DocumentCard`, `MediaCard`, `RemoveButton`. **`toDisplayFile(cell)` is how a file
|
|
2307
|
+
reaches any of them** — it takes a `FileCell` (a record read's `files` entry: `id`,
|
|
2308
|
+
`filename`, `mime_type`, `url`, optional `thumbnail_url`) and returns the `DisplayFile`
|
|
2309
|
+
every component here expects. Reach for it rather than writing the five-line mapping: the
|
|
2310
|
+
hand-written one is where a `mime_type`/`mimeType` slip becomes a tile that silently
|
|
2311
|
+
renders as a generic document. It returns a fresh object per call by design — see
|
|
2312
|
+
composition.md §"A list stays fast" for why an identity cache is the wrong answer.
|
|
2307
2313
|
- **`file_thumbnail_grid`** — **`diffs`** (a `ReadonlyMap<string, DiffKind>`, keyed like
|
|
2308
2314
|
`selectedIds`) marks each tile, so a grid under review is the SAME grid with one more map
|
|
2309
2315
|
passed in rather than a different component the operator has never seen.
|
package/docs/composition.md
CHANGED
|
@@ -136,6 +136,19 @@ the subject into rows.
|
|
|
136
136
|
| Register / work list / calendar | 1040 |
|
|
137
137
|
| Wide wallboard / report | 1100 |
|
|
138
138
|
|
|
139
|
+
**A row that navigates PAINTS.** A link is not a row: it paints nothing, so a row tall enough to
|
|
140
|
+
hold an identity mark becomes a large silent target — the pointer crosses it and the only thing
|
|
141
|
+
saying it is pressable is the cursor. Reach for the row surface (`PressableRow`), never a
|
|
142
|
+
hand-rolled hover on the anchor: the wash has to span the WHOLE row *including its trailing
|
|
143
|
+
controls*, and a parent's Pressability hover is released to the innermost nested pressable, which
|
|
144
|
+
stops the wash short of the `⋯` and leaves a gap exactly where the eye is going. Keep the anchor
|
|
145
|
+
INSIDE as the accessible door — a row of places to go owes middle-click, cmd-click and copy-link,
|
|
146
|
+
which only a real anchor gives.
|
|
147
|
+
|
|
148
|
+
Without that wash the trailing controls are what look wrong: nothing ties a `⋯` to its row, so the
|
|
149
|
+
right edge reads as a floating column competing with the names, and the instinct is to hide the
|
|
150
|
+
control. Paint the row first — the clutter usually WAS the missing wash.
|
|
151
|
+
|
|
139
152
|
**ONE LEFT EDGE — the whole page, and then the whole APP.** Headings, detail labels, prose, a
|
|
140
153
|
table's first column, a footer's totals, a dialog's title. Then check the screen this one OPENS: a
|
|
141
154
|
list and the record it opens are one reading column seen twice, and both must derive from ONE
|
|
@@ -2083,6 +2096,35 @@ most common, because it survives every other check: each piece is defensible alo
|
|
|
2083
2096
|
question "what does this tell me that the screen does not already say" catches decoration wearing
|
|
2084
2097
|
an information costume.
|
|
2085
2098
|
|
|
2099
|
+
## A list stays fast by keeping IDENTITY, not by memoizing
|
|
2100
|
+
|
|
2101
|
+
The expensive thing a screen can do is not re-render — it is REPLACE. Re-running a
|
|
2102
|
+
component produces no DOM work at all when the output matches; React's diff absorbs it. A
|
|
2103
|
+
15-row register with 58 pictures, re-rendered against equal data, writes **nothing**. The
|
|
2104
|
+
same register with one row inserted, when identity is lost, writes **271 mutations and
|
|
2105
|
+
rebuilds 89 nodes** — and every rebuilt `Image` starts empty and paints blank until a
|
|
2106
|
+
frame later, which is the flicker people describe as "the images reload".
|
|
2107
|
+
|
|
2108
|
+
So the rule is about identity:
|
|
2109
|
+
|
|
2110
|
+
- **Key a row by the record, never by its position.** `key={i}` is correct only for a list
|
|
2111
|
+
that cannot reorder or grow. The moment something can be inserted, an index key hands
|
|
2112
|
+
every row a different slot and React rebuilds all of them. This applies to any wrapper
|
|
2113
|
+
you put around a mapped child too — a container keyed by index destroys the identity its
|
|
2114
|
+
child was carrying.
|
|
2115
|
+
- **Don't define a component inside another component's render.** Its type changes every
|
|
2116
|
+
render, so its whole subtree remounts.
|
|
2117
|
+
- **Don't swap a list for a placeholder on refetch.** `loading ? <Skeleton/> : <Rows/>`
|
|
2118
|
+
belongs to the FIRST load only — gate it on having no rows yet, never on a request being
|
|
2119
|
+
in flight, or every background refresh unmounts the content and remounts it.
|
|
2120
|
+
|
|
2121
|
+
**Reach for `memo` last, and only with a measurement.** It prevents a re-render, which is
|
|
2122
|
+
the cheap half, and it silently does nothing when a prop is a fresh object each render —
|
|
2123
|
+
which is the normal case, since rows come from a `useMemo` over query results. Where a list
|
|
2124
|
+
is genuinely large enough to hurt, window the rows; that removes the work rather than
|
|
2125
|
+
comparing it. `toDisplayFile` returns a new object per call for exactly this reason: an
|
|
2126
|
+
identity cache would exist to enable a memo that measurement does not justify.
|
|
2127
|
+
|
|
2086
2128
|
## `accessibilityState` never reaches the DOM — write `aria-*` yourself
|
|
2087
2129
|
|
|
2088
2130
|
React Native Web maps a subset of RN's accessibility props and **silently drops
|
package/docs/reviewing.md
CHANGED
|
@@ -595,6 +595,34 @@ a second copy free to disagree — check it against the FIELD, not against itsel
|
|
|
595
595
|
|
|
596
596
|
→ [data_entry.md](./data_entry.md) §"A picker over a select FIELD keeps the field's colours".
|
|
597
597
|
|
|
598
|
+
### 9c. What the screen does when the data MOVES — the cross-frame check
|
|
599
|
+
Every probe above reads ONE rendered frame. This defect does not exist in a frame: the screen is
|
|
600
|
+
correct before, correct after, and destroys itself in between. A register whose rows are correct
|
|
601
|
+
either side of an insert can still have rebuilt every row to get there, and each rebuilt picture
|
|
602
|
+
paints EMPTY for a frame before it repaints — which is what people report as "the images reload".
|
|
603
|
+
|
|
604
|
+
**Collect:** stamp the nodes, move the data, count the survivors.
|
|
605
|
+
|
|
606
|
+
```js
|
|
607
|
+
document.querySelectorAll("img").forEach((el, i) => { el.__s = i; });
|
|
608
|
+
// …now insert a row / reorder / let a refetch land…
|
|
609
|
+
const imgs = [...document.querySelectorAll("img")];
|
|
610
|
+
({ total: imgs.length, survived: imgs.filter((e) => e.__s !== undefined).length })
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
**The signature** is survivors far below the rows that were already there — a row that only MOVED
|
|
614
|
+
should keep its node. Only genuinely new rows may be fresh. A `MutationObserver` gives the same
|
|
615
|
+
answer as a magnitude: a re-render against equal data should write NOTHING, and one insert should
|
|
616
|
+
write about one row's worth.
|
|
617
|
+
|
|
618
|
+
Two causes, both invisible to every other probe. A container keying its wrapped children by
|
|
619
|
+
POSITION overrides the caller's correct keys, so an insert shifts every child into a different slot
|
|
620
|
+
and React rebuilds all of them — the caller cannot see it or fix it from outside. And a region
|
|
621
|
+
gated on a REQUEST (`loading ? <Spinner/> : <Rows/>`) rather than on having nothing yet unmounts
|
|
622
|
+
its whole subtree on each background refresh.
|
|
623
|
+
|
|
624
|
+
→ [composition.md](./composition.md) §"A list stays fast by keeping IDENTITY, not by memoizing".
|
|
625
|
+
|
|
598
626
|
### 10. Microcopy
|
|
599
627
|
- **No punctuation doing a word's job.** ` · ` is banned outright — it claims a relationship while
|
|
600
628
|
refusing to name it, and it is the loudest templated-metadata tell there is. If a mark cannot be
|
package/docs/testing.md
CHANGED
|
@@ -70,3 +70,26 @@ and the window are the right ones. From the dragged element,
|
|
|
70
70
|
Then check **both** halves: re-snapshot for the optimistic move, and re-read the
|
|
71
71
|
record to confirm the mutation actually persisted. An optimistic move that never
|
|
72
72
|
reached the server looks identical on screen.
|
|
73
|
+
|
|
74
|
+
## Assert what an update KEEPS, not only what it renders
|
|
75
|
+
|
|
76
|
+
A test that reads the output passes either way when a list rebuilds itself: the same rows are on
|
|
77
|
+
screen, so nothing about the text is wrong. What changed is that every node was replaced — and a
|
|
78
|
+
replaced `Image` paints empty before it repaints.
|
|
79
|
+
|
|
80
|
+
Hold the node and compare it after the update:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
const { rerender, getByTestId } = render(board(["a", "b"]));
|
|
84
|
+
const before = getByTestId("b");
|
|
85
|
+
rerender(board(["new", "a", "b"]));
|
|
86
|
+
expect(getByTestId("b")).toBe(before); // MOVED, not rebuilt
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
State the budget as DOM WRITES, never as a render count. Re-running a component is close to free —
|
|
90
|
+
React's diff absorbs it — so counting renders fails a healthy tree and pushes the next author into
|
|
91
|
+
memoising leaves that cost nothing. A `MutationObserver` around a `rerender` with equal data should
|
|
92
|
+
see zero. And assert the opposite too: an insert MUST write, or a green suite only proves the
|
|
93
|
+
observer was blind.
|
|
94
|
+
|
|
95
|
+
→ [composition.md](./composition.md) §"A list stays fast by keeping IDENTITY, not by memoizing".
|
package/package.json
CHANGED
package/src/file_thumbnail.tsx
CHANGED
|
@@ -72,6 +72,47 @@ export interface DisplayFile {
|
|
|
72
72
|
previewUrl?: string;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The wire shape of a file cell — what a record read hands back, before it is
|
|
77
|
+
* anything a component can render.
|
|
78
|
+
*
|
|
79
|
+
* Structural, not imported: the kit stays free of platform packages, and this
|
|
80
|
+
* names only the five fields the conversion reads.
|
|
81
|
+
*/
|
|
82
|
+
export interface FileCell {
|
|
83
|
+
id: string;
|
|
84
|
+
filename: string;
|
|
85
|
+
mime_type: string;
|
|
86
|
+
url: string;
|
|
87
|
+
thumbnail_url?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A file cell → the shape every display component here takes.
|
|
92
|
+
*
|
|
93
|
+
* It lives in the kit because it was living in every app instead: each one
|
|
94
|
+
* re-declared the same five-line mapping, so a field added here reached none of
|
|
95
|
+
* them and a `mime_type`/`mimeType` slip was a per-app bug. One definition, on
|
|
96
|
+
* the side that owns the type.
|
|
97
|
+
*
|
|
98
|
+
* It returns a NEW object per call, deliberately. An identity cache here would
|
|
99
|
+
* let callers memoize on reference, but a re-render that changes nothing
|
|
100
|
+
* already writes nothing to the DOM (`table_render_budget.test.tsx` pins it),
|
|
101
|
+
* so the cache would buy a fraction of a cheap operation and cost a map that
|
|
102
|
+
* grows for the life of the tab — presigned urls rotate, so its keys never stop
|
|
103
|
+
* accumulating. If a list ever outgrows that, the answer is windowing the rows,
|
|
104
|
+
* not remembering the objects.
|
|
105
|
+
*/
|
|
106
|
+
export function toDisplayFile(f: FileCell): DisplayFile {
|
|
107
|
+
return {
|
|
108
|
+
id: f.id,
|
|
109
|
+
filename: f.filename,
|
|
110
|
+
mimeType: f.mime_type,
|
|
111
|
+
url: f.url,
|
|
112
|
+
thumbnailUrl: f.thumbnail_url,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
75
116
|
// =============================================================================
|
|
76
117
|
// FileThumbnail - Main display component
|
|
77
118
|
// =============================================================================
|
|
@@ -485,6 +526,11 @@ function ImageThumbnail(props: ImageThumbnailProps) {
|
|
|
485
526
|
const a11yLabel = props.accessibilityLabel ?? file.filename;
|
|
486
527
|
const [useFallback, setUseFallback] = useState(false);
|
|
487
528
|
const { focusVisible, focusProps } = useFocusRing();
|
|
529
|
+
// STABLE, not an inline arrow. `onError` sits in react-native-web's image
|
|
530
|
+
// load effect's dependency array, so a fresh callback each render tears the
|
|
531
|
+
// pending request down and issues a new `Image()` — re-fetching a picture
|
|
532
|
+
// that never changed, on every render of the row it sits in.
|
|
533
|
+
const handleError = useCallback(() => setUseFallback(true), []);
|
|
488
534
|
|
|
489
535
|
// Use thumbnail if available, fallback to full URL
|
|
490
536
|
const url = !useFallback && file.thumbnailUrl ? file.thumbnailUrl : file.url;
|
|
@@ -502,7 +548,7 @@ function ImageThumbnail(props: ImageThumbnailProps) {
|
|
|
502
548
|
source={{ uri: url }}
|
|
503
549
|
style={styles.image}
|
|
504
550
|
resizeMode="cover"
|
|
505
|
-
onError={
|
|
551
|
+
onError={handleError}
|
|
506
552
|
/>
|
|
507
553
|
<TileScrim hovered={(state as { hovered?: boolean }).hovered} pressed={state.pressed} />
|
|
508
554
|
</>
|
package/src/image_gallery.tsx
CHANGED
|
@@ -68,7 +68,12 @@ export function ImageGallery({
|
|
|
68
68
|
// Per-image rotation, shared with the zoom modal so a turn survives opening it.
|
|
69
69
|
const rotation = useImageRotation();
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
// FIRST load only. Gating the spinner on the request rather than on having
|
|
72
|
+
// nothing to show swaps the whole gallery for it on every revalidation —
|
|
73
|
+
// unmounting each <Image>, which then remounts empty and repaints. A caller
|
|
74
|
+
// passing `loading={isValidating}` is doing the ordinary thing; the component
|
|
75
|
+
// has to survive it.
|
|
76
|
+
if (loading && images.length === 0) return <ActivityIndicator />;
|
|
72
77
|
if (images.length === 0) return <Text size="sm" color="muted">{emptyText ?? locale.imageGallery.empty}</Text>;
|
|
73
78
|
|
|
74
79
|
const idx = Math.min(selected, images.length - 1);
|
package/src/list.tsx
CHANGED
|
@@ -15,7 +15,10 @@ export function List(props: ListProps) {
|
|
|
15
15
|
return (
|
|
16
16
|
<View style={style}>
|
|
17
17
|
{childArray.map((child, index) => (
|
|
18
|
-
|
|
18
|
+
// The child's own key, never its position: a filtered list changes
|
|
19
|
+
// membership on every keystroke, and an index key rebuilds every
|
|
20
|
+
// surviving row (see composition.md §"A list stays fast").
|
|
21
|
+
<React.Fragment key={React.isValidElement(child) ? child.key : index}>
|
|
19
22
|
{child}
|
|
20
23
|
{index < childArray.length - 1 && <Divider />}
|
|
21
24
|
</React.Fragment>
|
package/src/popover.tsx
CHANGED
|
@@ -433,6 +433,34 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
433
433
|
return () => document.removeEventListener("click", onOutsideClick, true);
|
|
434
434
|
}, [open, small, onOpenChange, triggerRef]);
|
|
435
435
|
|
|
436
|
+
// The one press the click listener above CANNOT see: one that lands inside a
|
|
437
|
+
// cross-origin iframe. Those events are dispatched in the frame's own document
|
|
438
|
+
// and never cross into this one, so a popover anchored over an embedded app —
|
|
439
|
+
// the app page's own chrome is exactly this shape — stayed open while the user
|
|
440
|
+
// clicked around underneath it, with no way to dismiss but pressing the trigger
|
|
441
|
+
// again or hitting Escape.
|
|
442
|
+
//
|
|
443
|
+
// Focus is the signal that survives the boundary: clicking into a frame moves
|
|
444
|
+
// this document's `activeElement` to the IFRAME element and blurs the window.
|
|
445
|
+
//
|
|
446
|
+
// Checking `activeElement` is what keeps this from over-firing. A window blur
|
|
447
|
+
// alone also means "switched tab" or "moved to another app", and dismissing
|
|
448
|
+
// then is a menu that quietly disappears while the user was reading something
|
|
449
|
+
// else. Only a blur that HANDED FOCUS TO A FRAME is a press we missed.
|
|
450
|
+
useEffect(() => {
|
|
451
|
+
if (!open || small) return;
|
|
452
|
+
const onWindowBlur = () => {
|
|
453
|
+
// Deferred: at blur time the browser has not always moved `activeElement`
|
|
454
|
+
// to the frame yet, so reading it synchronously can still report the
|
|
455
|
+
// trigger and miss the dismissal entirely.
|
|
456
|
+
setTimeout(() => {
|
|
457
|
+
if (document.activeElement instanceof HTMLIFrameElement) onOpenChange(false);
|
|
458
|
+
}, 0);
|
|
459
|
+
};
|
|
460
|
+
window.addEventListener("blur", onWindowBlur);
|
|
461
|
+
return () => window.removeEventListener("blur", onWindowBlur);
|
|
462
|
+
}, [open, small, onOpenChange]);
|
|
463
|
+
|
|
436
464
|
const calculatePosition = useCallback(() => {
|
|
437
465
|
if (!popoverRef.current) return;
|
|
438
466
|
if (small) return;
|
package/src/section_stack.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Children, Fragment } from "react";
|
|
1
|
+
import { Children, Fragment, isValidElement } from "react";
|
|
2
2
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
3
|
import { Divider } from "./divider";
|
|
4
4
|
|
|
@@ -17,9 +17,11 @@ function DividedStack({ children, divided = true, gap, style }: StackProps & { g
|
|
|
17
17
|
return (
|
|
18
18
|
<View style={[{ gap }, style]}>
|
|
19
19
|
{blocks.map((block, i) => (
|
|
20
|
-
// Children.toArray keys are stable for conditional lists
|
|
21
|
-
//
|
|
22
|
-
|
|
20
|
+
// Children.toArray keys are stable for conditional lists — so USE
|
|
21
|
+
// them. `toArray` drops nulls, so a conditional section appearing
|
|
22
|
+
// shifts every later index and an index key would remount all of
|
|
23
|
+
// them, including any field mid-edit.
|
|
24
|
+
<Fragment key={isValidElement(block) ? block.key : `sec_${i}`}>
|
|
23
25
|
{divided && i > 0 ? <Divider /> : null}
|
|
24
26
|
{block}
|
|
25
27
|
</Fragment>
|
package/src/sequence.tsx
CHANGED
|
@@ -62,7 +62,11 @@ export function Sequence({ children }: SequenceProps) {
|
|
|
62
62
|
// which the rail runs through.
|
|
63
63
|
<View>
|
|
64
64
|
{items.map((child, index) => (
|
|
65
|
-
|
|
65
|
+
// The child's own key, never its position. This list REORDERS by
|
|
66
|
+
// design, and a Provider keyed by index makes "move up" tear both
|
|
67
|
+
// items down — losing the text a person was part-way through typing
|
|
68
|
+
// in them (see composition.md §"A list stays fast").
|
|
69
|
+
<SequenceItemContext.Provider key={child.key ?? index} value={{ index, count: items.length }}>
|
|
66
70
|
{child}
|
|
67
71
|
</SequenceItemContext.Provider>
|
|
68
72
|
))}
|
package/src/stack.tsx
CHANGED
|
@@ -28,7 +28,10 @@ export function Stack(props: StackProps) {
|
|
|
28
28
|
children,
|
|
29
29
|
(child, index) =>
|
|
30
30
|
child && (
|
|
31
|
-
|
|
31
|
+
// NO key: `Children.map` already composes the child's own key
|
|
32
|
+
// onto what it returns. Adding one here overrides that with the
|
|
33
|
+
// position and remounts the list whenever it changes.
|
|
34
|
+
<View>
|
|
32
35
|
{child}
|
|
33
36
|
{useSeparator && index < React.Children.count(children) - 1 && (
|
|
34
37
|
<Separator padding={separatorPadding} />
|
package/src/table.tsx
CHANGED
|
@@ -281,11 +281,15 @@ export function Table(props: TableProps) {
|
|
|
281
281
|
</View>
|
|
282
282
|
)}
|
|
283
283
|
<View style={styles.body}>
|
|
284
|
-
{
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
284
|
+
{/* Keyed by the ROW's own key, never its index. `Children.toArray`
|
|
285
|
+
derives these from the caller's `key`, so they track the record
|
|
286
|
+
rather than the position. Keyed by index, inserting a row at the
|
|
287
|
+
top shifts every row into a different wrapper: React then sees a
|
|
288
|
+
new child key inside each one and remounts the whole list, which
|
|
289
|
+
destroys every `<Image>` and repaints the register blank. */}
|
|
290
|
+
{rows.map((row, i) => (
|
|
291
|
+
<View key={row.key ?? i}>{row}</View>
|
|
292
|
+
))}
|
|
289
293
|
</View>
|
|
290
294
|
</View>
|
|
291
295
|
</TableContext.Provider>
|
|
@@ -356,8 +360,9 @@ export function TableGroup(props: TableGroupProps) {
|
|
|
356
360
|
) : null}
|
|
357
361
|
</View>
|
|
358
362
|
<View style={styles.body}>
|
|
363
|
+
{/* The row's own key, for the reason given in the wide variant above. */}
|
|
359
364
|
{rows.map((row, i) => (
|
|
360
|
-
<View key={i}>{row}</View>
|
|
365
|
+
<View key={row.key ?? i}>{row}</View>
|
|
361
366
|
))}
|
|
362
367
|
</View>
|
|
363
368
|
</View>
|