@delacour/react-native-ui 0.1.0-alpha.20260927220206 → 0.1.0-alpha.20261004035233

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 (48) hide show
  1. package/README.md +9 -5
  2. package/package.json +14 -3
  3. package/src/components/accordion/AGENTS.md +17 -6
  4. package/src/components/accordion/accordion-content.tsx +7 -4
  5. package/src/components/accordion/accordion-item.tsx +15 -13
  6. package/src/components/accordion/accordion.context.tsx +11 -5
  7. package/src/components/accordion/accordion.variants.test.ts +62 -0
  8. package/src/components/accordion/accordion.variants.ts +30 -6
  9. package/src/components/bottom-sheet/AGENTS.md +186 -231
  10. package/src/components/bottom-sheet/bottom-sheet-background.tsx +31 -22
  11. package/src/components/bottom-sheet/bottom-sheet-close.tsx +23 -30
  12. package/src/components/bottom-sheet/bottom-sheet-container.tsx +54 -208
  13. package/src/components/bottom-sheet/bottom-sheet-content.tsx +25 -48
  14. package/src/components/bottom-sheet/bottom-sheet-description.tsx +8 -2
  15. package/src/components/bottom-sheet/bottom-sheet-flat-list.tsx +65 -0
  16. package/src/components/bottom-sheet/bottom-sheet-footer.tsx +53 -122
  17. package/src/components/bottom-sheet/bottom-sheet-handle.tsx +31 -26
  18. package/src/components/bottom-sheet/bottom-sheet-legend-list.tsx +69 -0
  19. package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +25 -147
  20. package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +38 -62
  21. package/src/components/bottom-sheet/bottom-sheet-section-list.tsx +58 -0
  22. package/src/components/bottom-sheet/bottom-sheet-steps.tsx +88 -0
  23. package/src/components/bottom-sheet/bottom-sheet-text-input.tsx +44 -0
  24. package/src/components/bottom-sheet/bottom-sheet-title.tsx +12 -8
  25. package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +19 -37
  26. package/src/components/bottom-sheet/bottom-sheet.tsx +113 -60
  27. package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +48 -88
  28. package/src/components/bottom-sheet/bottom-sheet.variants.ts +50 -126
  29. package/src/components/bottom-sheet/index.ts +39 -16
  30. package/src/components/bottom-sheet/use-bottom-sheet-input.ts +16 -115
  31. package/src/components/collapsible/AGENTS.md +7 -2
  32. package/src/components/collapsible/collapsible-content.tsx +6 -9
  33. package/src/components/collapsible/collapsible.context.tsx +8 -3
  34. package/src/components/collapsible/collapsible.tsx +11 -9
  35. package/src/components/collapsible/collapsible.variants.test.ts +36 -0
  36. package/src/components/collapsible/collapsible.variants.ts +24 -3
  37. package/src/components/provider/AGENTS.md +38 -25
  38. package/src/components/provider/provider.tsx +38 -11
  39. package/src/components/skeleton/AGENTS.md +6 -3
  40. package/src/components/skeleton/skeleton-group.tsx +3 -3
  41. package/src/components/skeleton/skeleton.tsx +5 -10
  42. package/src/components/skeleton/skeleton.variants.ts +7 -9
  43. package/src/hooks/use-calm-motion.tsx +31 -0
  44. package/src/lib/calm-motion.test.ts +17 -0
  45. package/src/lib/calm-motion.ts +23 -0
  46. package/src/no-gorhom.test.ts +54 -0
  47. package/src/components/bottom-sheet/bottom-sheet-portal.tsx +0 -72
  48. package/src/components/bottom-sheet/bottom-sheet.context.tsx +0 -135
package/README.md CHANGED
@@ -19,14 +19,16 @@ register twice and break at runtime. The complete list — native modules throug
19
19
  `expo install`, the rest through your package manager — is derived from the
20
20
  components' own imports and kept on the
21
21
  [Installation page](https://ui.delacour.co.nz/docs/native/getting-started/installation).
22
- Four peers are optional and resolve only if you import the subpath that needs them:
22
+ Six peers are optional and resolve only if you import the subpath that needs them:
23
23
 
24
24
  | Peer | Needed for |
25
25
  | --- | --- |
26
26
  | `expo-router` | `@delacour/react-native-ui/expo/navigation-theme` |
27
- | `@legendapp/list` | `Screen.LegendList` and `Screen.ChatList` |
27
+ | `@legendapp/list` | `Screen.LegendList`, `Screen.ChatList` and `BottomSheet.LegendList` |
28
28
  | `react-native-screens` | native stack navigation |
29
29
  | `@delacour/react-native-charts` | `Chart` |
30
+ | `@delacour/react-native-bottom-sheet` | `BottomSheet` — the engine it skins |
31
+ | `react-native-teleport` | `BottomSheet` — the engine's portal; a native module, so rebuild the dev client after installing |
30
32
 
31
33
  Granular exports are what make that safe: an app that never imports a subpath
32
34
  never makes Metro resolve its peers.
@@ -53,8 +55,10 @@ import { DelacourProvider } from "@delacour/react-native-ui/provider";
53
55
  ```
54
56
 
55
57
  That is the gesture root every `Pressable` needs above it, the safe-area provider
56
- and keyboard provider `Screen` reads, the bottom-sheet modal provider, and the
57
- keyboard state sync that keeps them honest.
58
+ and keyboard provider `Screen` reads, and the keyboard state sync that keeps them
59
+ honest. An app with a `BottomSheet` mounts `BottomSheetProvider` inside it, from
60
+ `@delacour/react-native-ui/bottom-sheet` — the sheet's engine is an optional peer,
61
+ so the provider cannot.
58
62
 
59
63
  ## Usage
60
64
 
@@ -78,7 +82,7 @@ import { IconArrowRight } from "@delacour/react-native-ui/icons/central";
78
82
  | --- | --- | --- |
79
83
  | Accordion | `@delacour/react-native-ui/accordion` | Selection modes, measured panels, indicators |
80
84
  | Badge | `@delacour/react-native-ui/badge` | Variants, colours, sizes, dismiss |
81
- | BottomSheet | `@delacour/react-native-ui/bottom-sheet` | Overlay, snap points, sticky footer, keyboard |
85
+ | BottomSheet | `@delacour/react-native-ui/bottom-sheet` | Detents, keyboard, sticky footer, scrollables, steps, detached, teleported portal |
82
86
  | Button | `@delacour/react-native-ui/button` | Variants, sizes, icons, loading |
83
87
  | Checkbox | `@delacour/react-native-ui/checkbox` | Colours, sizes, indeterminate, groups |
84
88
  | Field | `@delacour/react-native-ui/field` | Form layout, grouping, state cascade |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20260927220206",
3
+ "version": "0.1.0-alpha.20261004035233",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,10 +71,12 @@
71
71
  "./textarea": "./src/components/textarea/index.ts",
72
72
  "./toggle-button": "./src/components/toggle-button/index.ts",
73
73
  "./expo/navigation-theme": "./src/expo/navigation-theme.tsx",
74
+ "./hooks/use-calm-motion": "./src/hooks/use-calm-motion.tsx",
74
75
  "./hooks/use-controllable-state": "./src/hooks/use-controllable-state.ts",
75
76
  "./hooks/use-keyboard-state-sync": "./src/hooks/use-keyboard-state-sync.tsx",
76
77
  "./hooks/use-navigation-theme": "./src/hooks/use-navigation-theme.ts",
77
78
  "./hooks/use-theme-color": "./src/hooks/use-theme-color.ts",
79
+ "./lib/calm-motion": "./src/lib/calm-motion.ts",
78
80
  "./lib/cn": "./src/lib/cn.ts",
79
81
  "./lib/color": "./src/lib/color.ts",
80
82
  "./lib/compose-refs": "./src/lib/compose-refs.ts",
@@ -98,8 +100,8 @@
98
100
  },
99
101
  "peerDependencies": {
100
102
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
101
- "@delacour/react-native-charts": "0.1.0-alpha.20260927220206",
102
- "@gorhom/bottom-sheet": "^5.2.8",
103
+ "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261004035233",
104
+ "@delacour/react-native-charts": "0.1.0-alpha.20261004035233",
103
105
  "@legendapp/list": ">=3.3",
104
106
  "expo-linear-gradient": ">=15",
105
107
  "expo-router": ">=57",
@@ -112,11 +114,15 @@
112
114
  "react-native-safe-area-context": ">=5",
113
115
  "react-native-screens": ">=4.16",
114
116
  "react-native-svg": ">=15",
117
+ "react-native-teleport": ">=1.2",
115
118
  "react-native-worklets": ">=0.5",
116
119
  "tailwindcss": "^4",
117
120
  "uniwind": "^1.11.0"
118
121
  },
119
122
  "peerDependenciesMeta": {
123
+ "@delacour/react-native-bottom-sheet": {
124
+ "optional": true
125
+ },
120
126
  "@delacour/react-native-charts": {
121
127
  "optional": true
122
128
  },
@@ -128,15 +134,20 @@
128
134
  },
129
135
  "react-native-screens": {
130
136
  "optional": true
137
+ },
138
+ "react-native-teleport": {
139
+ "optional": true
131
140
  }
132
141
  },
133
142
  "devDependencies": {
134
143
  "@delacour/biome-config": "workspace:*",
144
+ "@delacour/react-native-bottom-sheet": "workspace:*",
135
145
  "@delacour/react-native-charts": "workspace:*",
136
146
  "@delacour/tsconfig": "workspace:*",
137
147
  "@types/bun": "^1.3.8",
138
148
  "@types/react": "catalog:",
139
149
  "expo-router": "~57.0.15",
150
+ "react-native-teleport": "^1.2.2",
140
151
  "typescript": "^5.9.3"
141
152
  }
142
153
  }
@@ -24,7 +24,7 @@ a list of rows.
24
24
  | `accordion.context.tsx` | `AccordionContext` and `AccordionItemContext`, with their hooks |
25
25
  | `accordion.types.ts` | Prop types shared by two or more parts |
26
26
  | `accordion.variants.ts` | Pure `tv()` slots + the selection maths, no RN imports |
27
- | `accordion.variants.test.ts` | |
27
+ | `accordion.variants.test.ts` | `accordionTravelTarget`, and the sweep for a JS-thread `contentHeight.value` read (exports `jsThreadSource` and `CONTENT_HEIGHT_READ` for `Collapsible`'s) |
28
28
 
29
29
  ## Design
30
30
 
@@ -90,12 +90,23 @@ a list of rows.
90
90
  effect.** A panel that has never mounted has no height to travel against, so
91
91
  springing on the state change would run the whole animation at zero and jump
92
92
  the moment a measurement arrived. The item's effect skips exactly that one
93
- case and `accordion-content.tsx`'s `onLayout` starts it instead. Every later
94
- toggle, in either direction, is the item's.
93
+ case and `accordion-content.tsx`'s `onLayout` reports the measurement, which
94
+ flips the item's `isMeasured` state and re-runs the effect. Every later toggle,
95
+ in either direction, is the item's. The decision is `accordionTravelTarget`,
96
+ pure and tested.
97
+ - **Nothing on the JS thread reads `contentHeight.value`.** "Has this panel
98
+ measured?" is React state (`isMeasured`, on both the item and the panel), never
99
+ a read of the shared value. A JS-thread write to a shared value is *queued* onto
100
+ the UI runtime, while a JS-thread read runs there synchronously under its lock
101
+ without draining that queue — so the item's effect, running straight after the
102
+ `onLayout` that wrote the height, can read `ACCORDION_UNMEASURED`. A Release
103
+ build does, deterministically: the effect bailed, nothing re-ran it, and the
104
+ panel reported `expanded` while never opening (a Debug client is slow enough
105
+ for the write to land first, which is why it hid). `accordion.variants.test.ts`
106
+ sweeps both files for such a read outside `useAnimatedStyle`.
95
107
  - **`ACCORDION_UNMEASURED` is negative, and that is load-bearing.** A panel that
96
- measured `0` is a real answer — content that rendered nothing — and an item
97
- treating it as "still waiting" would never start its spring, leaving the
98
- indicator pointing the wrong way. Only a value no layout can produce can mean
108
+ measured `0` is a real answer — content that rendered nothing — and must still
109
+ open, indicator and all. Only a value no layout can produce can mean
99
110
  *unmeasured*, so the height style floors it.
100
111
 
101
112
  ## Selection
@@ -2,7 +2,7 @@ import { type ReactElement, type ReactNode, useCallback, useState } from "react"
2
2
  import { type LayoutChangeEvent, StyleSheet, View, type ViewProps } from "react-native";
3
3
  import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
4
4
  import { useAccordionItemPart, useAccordionPart } from "./accordion.context";
5
- import { ACCORDION_CONTENT_FADE, ACCORDION_UNMEASURED, accordionVariants } from "./accordion.variants";
5
+ import { ACCORDION_CONTENT_FADE, accordionVariants } from "./accordion.variants";
6
6
 
7
7
  export type AccordionContentProps = Omit<ViewProps, "style"> & {
8
8
  className?: string;
@@ -55,7 +55,6 @@ export function AccordionContent({ className, children, ...props }: AccordionCon
55
55
 
56
56
  const handleLayout = useCallback(
57
57
  (event: LayoutChangeEvent) => {
58
- const wasMeasured = contentHeight.value > ACCORDION_UNMEASURED;
59
58
  contentHeight.value = event.nativeEvent.layout.height;
60
59
 
61
60
  // The panel reports its first measurement and stops there — it must not
@@ -64,11 +63,15 @@ export function AccordionContent({ className, children, ...props }: AccordionCon
64
63
  // is sometimes cancelled a moment later by the cleanup of the item's
65
64
  // effect it raced, leaving the panel shut and the indicator pointing the
66
65
  // wrong way. Telling the item instead leaves one owner of the spring.
67
- if (wasMeasured) return;
66
+ //
67
+ // "First" is this component's own state, never `contentHeight.value`: the
68
+ // write above is queued onto the UI runtime, and a JS-thread read straight
69
+ // after it can still see the old value.
70
+ if (isMeasured) return;
68
71
  setMeasured(true);
69
72
  onMeasured();
70
73
  },
71
- [contentHeight, onMeasured]
74
+ [contentHeight, isMeasured, onMeasured]
72
75
  );
73
76
 
74
77
  // Height and opacity off the one `progress`, never off a timing of their own —
@@ -5,6 +5,7 @@ import { type AccordionItemContextValue, AccordionItemProvider, useAccordionPart
5
5
  import {
6
6
  ACCORDION_SPRING,
7
7
  ACCORDION_UNMEASURED,
8
+ accordionTravelTarget,
8
9
  accordionVariants,
9
10
  isItemExpanded,
10
11
  resolveAccordionItemAxes,
@@ -42,25 +43,26 @@ export function AccordionItem({ value, isDisabled, className, children, ...props
42
43
  const progress = useSharedValue(isExpanded ? 1 : 0);
43
44
  const contentHeight = useSharedValue(ACCORDION_UNMEASURED);
44
45
 
45
- // Bumped the first time this item's panel reports a height, purely to make the
46
- // effect below run again. See `onMeasured` on the item context for why the
47
- // panel must not start the spring itself.
48
- const [measurements, setMeasurements] = useState(0);
49
- const onMeasured = useCallback(() => setMeasurements((count) => count + 1), []);
46
+ // Whether this item's panel has ever reported a height. React state, not a read
47
+ // of `contentHeight` on the JS thread: that write is queued onto the UI runtime
48
+ // and a read straight after it can still see `ACCORDION_UNMEASURED` — which a
49
+ // Release build does, every time. See `accordionTravelTarget`.
50
+ const [isMeasured, setMeasured] = useState(false);
51
+ const onMeasured = useCallback(() => setMeasured(true), []);
50
52
 
51
- // `measurements` is not read in the body — being unread is the whole point of
52
- // it, the way `settledDrags` is for a `Switch`. The first expand of a panel
53
- // that has never been mounted has no height to travel against, so this run
54
- // bails; the measurement that follows is what brings it back.
55
- // biome-ignore lint/correctness/useExhaustiveDependencies: the extra dependency is the re-run trigger, see above
53
+ // The first expand of a panel that has never been mounted has no height to
54
+ // travel against, so this run waits; the measurement that follows flips
55
+ // `isMeasured` and brings it back. See `onMeasured` on the item context for why
56
+ // the panel must not start the spring itself.
56
57
  useEffect(() => {
57
- if (isExpanded && contentHeight.value === ACCORDION_UNMEASURED) return;
58
+ const target = accordionTravelTarget({ isExpanded, isMeasured });
59
+ if (target === null) return;
58
60
 
59
- progress.value = withSpring(isExpanded ? 1 : 0, ACCORDION_SPRING);
61
+ progress.value = withSpring(target, ACCORDION_SPRING);
60
62
 
61
63
  // Without this an item unmounted mid-travel leaves its spring running.
62
64
  return () => cancelAnimation(progress);
63
- }, [contentHeight, isExpanded, measurements, progress]);
65
+ }, [isExpanded, isMeasured, progress]);
64
66
 
65
67
  const context = useMemo<AccordionItemContextValue>(
66
68
  () => ({ contentHeight, isDisabled: axes.isDisabled, isExpanded, onMeasured, progress, value }),
@@ -35,8 +35,9 @@ export type AccordionItemContextValue = {
35
35
  * The panel's natural height in points, from its own `onLayout`.
36
36
  *
37
37
  * Holds {@link ACCORDION_UNMEASURED} until a panel has reported, which is not
38
- * the same as a panel that measured zero: the item's spring waits on the first
39
- * measurement rather than travelling against a height that is not there yet.
38
+ * the same as a panel that measured zero. Read on the UI runtime only — by the
39
+ * height style — and written from the panel's `onLayout`; nothing on the JS
40
+ * thread reads it, because a JS read can lag a JS write. See {@link onMeasured}.
40
41
  */
41
42
  contentHeight: SharedValue<number>;
42
43
  /**
@@ -46,9 +47,14 @@ export type AccordionItemContextValue = {
46
47
  * spring. The panel cannot start the travel itself: `onLayout` is dispatched
47
48
  * from the native side and can land either side of React's effects, so a panel
48
49
  * that started its own spring would sometimes have it cancelled a moment later
49
- * by the cleanup of the effect it raced. This hands the item a reason to re-run
50
- * instead — `Slider`'s and `Switch`'s `settledDrags`, for the same reason: a
51
- * counter whose only job is to give an effect something to fire on.
50
+ * by the cleanup of the effect it raced. This flips the item's own
51
+ * `isMeasured` state instead, which is what re-runs the item's effect.
52
+ *
53
+ * That state, not a JS-thread read of {@link contentHeight}, is how the item
54
+ * knows a panel has measured. The panel's write is queued onto the UI runtime
55
+ * and a read straight after it can still see `ACCORDION_UNMEASURED` — a Release
56
+ * build does, every time, and the panel stayed shut. See
57
+ * `accordionTravelTarget`.
52
58
  */
53
59
  onMeasured: () => void;
54
60
  };
@@ -16,6 +16,7 @@ import {
16
16
  ACCORDION_SPRING,
17
17
  ACCORDION_UNMEASURED,
18
18
  ACCORDION_VARIANTS,
19
+ accordionTravelTarget,
19
20
  accordionVariants,
20
21
  isItemExpanded,
21
22
  resolveAccordionItemAxes,
@@ -432,3 +433,64 @@ describe("accordion animation", () => {
432
433
  expect(end).toBeLessThan(1);
433
434
  });
434
435
  });
436
+
437
+ describe("accordionTravelTarget", () => {
438
+ test("an expand waits for the panel's first measurement", () => {
439
+ expect(accordionTravelTarget({ isExpanded: true, isMeasured: false })).toBeNull();
440
+ });
441
+
442
+ test("an expand travels to 1 once the panel has measured", () => {
443
+ expect(accordionTravelTarget({ isExpanded: true, isMeasured: true })).toBe(1);
444
+ });
445
+
446
+ test("a collapse never waits, measured or not", () => {
447
+ expect(accordionTravelTarget({ isExpanded: false, isMeasured: false })).toBe(0);
448
+ expect(accordionTravelTarget({ isExpanded: false, isMeasured: true })).toBe(0);
449
+ });
450
+ });
451
+
452
+ /**
453
+ * A file's source with comments and every `useAnimatedStyle(...)` call stripped — the one
454
+ * place a read of a shared value runs on the UI runtime — leaving only code that runs on the
455
+ * JS thread.
456
+ */
457
+ export function jsThreadSource(path: string): string {
458
+ const source = readFileSync(path, "utf8")
459
+ .replace(/\/\*[\s\S]*?\*\//g, "")
460
+ .replace(/\/\/.*$/gm, "");
461
+ let out = "";
462
+ let index = 0;
463
+ for (;;) {
464
+ const start = source.indexOf("useAnimatedStyle(", index);
465
+ if (start === -1) return out + source.slice(index);
466
+ out += source.slice(index, start);
467
+ let depth = 0;
468
+ let cursor = start + "useAnimatedStyle".length;
469
+ do {
470
+ if (source[cursor] === "(") depth++;
471
+ if (source[cursor] === ")") depth--;
472
+ cursor++;
473
+ } while (depth > 0 && cursor < source.length);
474
+ index = cursor;
475
+ }
476
+ }
477
+
478
+ /** `contentHeight.value` as a read: anything but the left side of a plain `=`. */
479
+ export const CONTENT_HEIGHT_READ = /\bcontentHeight\.value\b(?!\s*=(?!=))/;
480
+
481
+ describe("the measured height is never read on the JS thread", () => {
482
+ // A JS-thread write to a shared value is queued onto the UI runtime, while a JS-thread read
483
+ // runs there synchronously under its lock without draining that queue. A read straight after
484
+ // the write — the item's effect after the panel's `onLayout` — can see the old value. In a
485
+ // Release build it does, every time: the item read `ACCORDION_UNMEASURED`, bailed, and nothing
486
+ // re-ran it, so the panel said `expanded` and stayed shut.
487
+ test.each(["accordion-item.tsx", "accordion-content.tsx"])("%s", (file) => {
488
+ expect(jsThreadSource(join(import.meta.dirname, file))).not.toMatch(CONTENT_HEIGHT_READ);
489
+ });
490
+
491
+ test("the guard sees a read and ignores a write", () => {
492
+ expect("if (contentHeight.value === -1) return;").toMatch(CONTENT_HEIGHT_READ);
493
+ expect("const was = contentHeight.value > -1;").toMatch(CONTENT_HEIGHT_READ);
494
+ expect("contentHeight.value = event.nativeEvent.layout.height;").not.toMatch(CONTENT_HEIGHT_READ);
495
+ });
496
+ });
@@ -76,17 +76,41 @@ export const ACCORDION_INDICATOR_ROTATION = { collapsed: 0, expanded: 180 } as c
76
76
  * What {@link AccordionItemContextValue.contentHeight} holds before a panel has
77
77
  * ever reported its own layout.
78
78
  *
79
- * Negative rather than zero, because the two mean different things and the
80
- * difference is load-bearing. A panel that measured `0` is a real answer — a
81
- * panel whose content rendered nothing — and an item that treated it as "still
82
- * waiting" would never start its spring, leaving the indicator stuck pointing the
83
- * wrong way for an empty panel. Only a value no layout can produce can mean
84
- * *unmeasured*.
79
+ * Negative rather than zero, because the two mean different things. A panel that
80
+ * measured `0` is a real answer — a panel whose content rendered nothing — and
81
+ * still opens, indicator and all. Only a value no layout can produce can mean
82
+ * *unmeasured*. Whether a panel *has* measured is never decided by comparing
83
+ * against this on the JS thread — see {@link accordionTravelTarget}.
85
84
  *
86
85
  * The height style therefore floors it: `progress * max(contentHeight, 0)`.
87
86
  */
88
87
  export const ACCORDION_UNMEASURED = -1;
89
88
 
89
+ /**
90
+ * Where an item's `progress` should spring to, or `null` to wait.
91
+ *
92
+ * Only an expand waits, and only for a panel that has never measured: it has no
93
+ * height to travel against yet, and the measurement is what starts it. A collapse
94
+ * never waits.
95
+ *
96
+ * `isMeasured` is **React state**, never a read of `contentHeight` on the JS
97
+ * thread. A JS-thread write to a shared value is queued onto the UI runtime, while
98
+ * a JS-thread read runs there synchronously without draining that queue, so a read
99
+ * straight after the panel's `onLayout` wrote the height can still see
100
+ * {@link ACCORDION_UNMEASURED}. A Release build does, every time — the item bailed
101
+ * and nothing re-ran it, so the panel said `expanded` and stayed shut.
102
+ */
103
+ export function accordionTravelTarget({
104
+ isExpanded,
105
+ isMeasured,
106
+ }: {
107
+ isExpanded: boolean;
108
+ isMeasured: boolean;
109
+ }): 0 | 1 | null {
110
+ if (!isExpanded) return 0;
111
+ return isMeasured ? 1 : null;
112
+ }
113
+
90
114
  /**
91
115
  * The window of the travel the panel's opacity ramps across.
92
116
  *