@delacour/react-native-ui 0.1.0-alpha.20260928015421 → 0.1.0-alpha.20261004043720
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/README.md +1 -1
- package/package.json +5 -3
- package/src/components/accordion/AGENTS.md +17 -6
- package/src/components/accordion/accordion-content.tsx +7 -4
- package/src/components/accordion/accordion-item.tsx +15 -13
- package/src/components/accordion/accordion.context.tsx +11 -5
- package/src/components/accordion/accordion.variants.test.ts +62 -0
- package/src/components/accordion/accordion.variants.ts +30 -6
- package/src/components/bottom-sheet/AGENTS.md +6 -6
- package/src/components/bottom-sheet/bottom-sheet-flat-list.tsx +1 -1
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +1 -1
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +1 -1
- package/src/components/bottom-sheet/bottom-sheet-legend-list.tsx +1 -1
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +2 -2
- package/src/components/bottom-sheet/bottom-sheet.tsx +6 -6
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +2 -2
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +3 -3
- package/src/components/collapsible/AGENTS.md +7 -2
- package/src/components/collapsible/collapsible-content.tsx +6 -9
- package/src/components/collapsible/collapsible.context.tsx +8 -3
- package/src/components/collapsible/collapsible.tsx +11 -9
- package/src/components/collapsible/collapsible.variants.test.ts +36 -0
- package/src/components/collapsible/collapsible.variants.ts +24 -3
- package/src/components/provider/AGENTS.md +13 -4
- package/src/components/provider/provider.tsx +22 -2
- package/src/components/skeleton/AGENTS.md +6 -3
- package/src/components/skeleton/skeleton-group.tsx +3 -3
- package/src/components/skeleton/skeleton.tsx +5 -10
- package/src/components/skeleton/skeleton.variants.ts +7 -9
- package/src/hooks/use-calm-motion.tsx +31 -0
- package/src/lib/calm-motion.test.ts +17 -0
- package/src/lib/calm-motion.ts +23 -0
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ import { IconArrowRight } from "@delacour/react-native-ui/icons/central";
|
|
|
82
82
|
| --- | --- | --- |
|
|
83
83
|
| Accordion | `@delacour/react-native-ui/accordion` | Selection modes, measured panels, indicators |
|
|
84
84
|
| Badge | `@delacour/react-native-ui/badge` | Variants, colours, sizes, dismiss |
|
|
85
|
-
| BottomSheet | `@delacour/react-native-ui/bottom-sheet` |
|
|
85
|
+
| BottomSheet | `@delacour/react-native-ui/bottom-sheet` | Snap points, keyboard, sticky footer, scrollables, steps, detached, teleported portal |
|
|
86
86
|
| Button | `@delacour/react-native-ui/button` | Variants, sizes, icons, loading |
|
|
87
87
|
| Checkbox | `@delacour/react-native-ui/checkbox` | Colours, sizes, indeterminate, groups |
|
|
88
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.
|
|
3
|
+
"version": "0.1.0-alpha.20261004043720",
|
|
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-bottom-sheet": "0.1.0-alpha.
|
|
102
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
103
|
+
"@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261004043720",
|
|
104
|
+
"@delacour/react-native-charts": "0.1.0-alpha.20261004043720",
|
|
103
105
|
"@legendapp/list": ">=3.3",
|
|
104
106
|
"expo-linear-gradient": ">=15",
|
|
105
107
|
"expo-router": ">=57",
|
|
@@ -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`
|
|
94
|
-
|
|
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
|
|
97
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
const
|
|
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
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
-
|
|
58
|
+
const target = accordionTravelTarget({ isExpanded, isMeasured });
|
|
59
|
+
if (target === null) return;
|
|
58
60
|
|
|
59
|
-
progress.value = withSpring(
|
|
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
|
-
}, [
|
|
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
|
|
39
|
-
*
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
*
|
|
@@ -71,7 +71,7 @@ engine's names win everywhere; this skin aliases nothing.
|
|
|
71
71
|
## What the engine owns
|
|
72
72
|
|
|
73
73
|
Everything that is not a class. The open state and the intent queue; the
|
|
74
|
-
height-space geometry and the
|
|
74
|
+
height-space geometry and the snap points; the handle and content pans and how a
|
|
75
75
|
list shares a finger with them; the keyboard — who owns it, `interactive` /
|
|
76
76
|
`extend` / `fillParent` / `none`, the lift and the restore; the sticky footer's
|
|
77
77
|
line and the safe-area band under it; the teleport portal, the hosts, the
|
|
@@ -92,7 +92,7 @@ read those before touching behaviour, because behaviour is not here.
|
|
|
92
92
|
components applies: the engine is another package.
|
|
93
93
|
- **Where the classes land.** `Content`, `Footer` and `Steps` hand `style` to
|
|
94
94
|
the engine's *measured* inner view, so a class there is height the dynamic
|
|
95
|
-
|
|
95
|
+
snap point counts. `ScrollView` puts its content classes on an inner `View`
|
|
96
96
|
rather than the content container, so the one style this file writes there —
|
|
97
97
|
the gap above a pinned footer — has a single writer. The three virtualised
|
|
98
98
|
lists have no inner box, so theirs go on `contentContainerClassName`; the
|
|
@@ -115,16 +115,16 @@ read those before touching behaviour, because behaviour is not here.
|
|
|
115
115
|
`fade` and an 8pt slop, for `Badge.CloseButton`'s and `Checkbox`'s reasons.
|
|
116
116
|
- **The root fills in three defaults.** `bottomInset` is `useSafeAreaInsets().bottom`,
|
|
117
117
|
so the engine reserves the home-indicator band and nobody pads for it by
|
|
118
|
-
hand. `
|
|
118
|
+
hand. `onSnapPointHaptic` is `Presets.System.selection` and `onCloseHaptic` is
|
|
119
119
|
`Presets.System.impactLight` — pulsar's presets are worklets, so they are
|
|
120
120
|
passed as they are; a JS function there is `undefined is not a function` on
|
|
121
121
|
the UI thread. And `useKeyboardAnimationGuard()` runs on mount, the same
|
|
122
122
|
stale-keyboard repair `Screen.Footer` runs. `topInset` stays at the engine's
|
|
123
|
-
zero, so a `%`
|
|
123
|
+
zero, so a `%` snap point is the fraction of the window it always was.
|
|
124
124
|
- **`Footer.sticky` defaults to `false`**, matching `Screen.Footer`, and this is
|
|
125
125
|
the one engine default the skin overrides. A pinned footer takes
|
|
126
126
|
`padding={BOTTOM_SHEET_FOOTER_PADDING}` through the engine's prop rather
|
|
127
|
-
than a class, so the padding is inside the box the
|
|
127
|
+
than a class, so the padding is inside the box the snap point measures; the
|
|
128
128
|
`stickyFooter` slot carries the gutter, the surface and the hairline, and
|
|
129
129
|
the test asserts it carries no vertical padding.
|
|
130
130
|
- **The overlay's opacity is 1**, because `--overlay` carries its own alpha and
|
|
@@ -178,7 +178,7 @@ problem it has:
|
|
|
178
178
|
- nesting a sheet in a sheet — the registry stacks them; `stackBehavior` and
|
|
179
179
|
`dismissAll` are for exactly that
|
|
180
180
|
- `ScrollView` needing `dynamicSizing={false}` and `snapPoints` — a list's
|
|
181
|
-
content size is the dynamic
|
|
181
|
+
content size is the dynamic snap point
|
|
182
182
|
- lifting `Overlay` and a sticky `Footer` out of the tree as render props —
|
|
183
183
|
every part renders where it is written
|
|
184
184
|
- a `Content` inside a `Container` paying the safe-area band by hand — the
|
|
@@ -37,7 +37,7 @@ export type BottomSheetFlatListProps<ItemT> = HeadlessProps<ItemT> & {
|
|
|
37
37
|
* A virtualised body.
|
|
38
38
|
*
|
|
39
39
|
* The engine's `FlatList` — the scroll lock, the drag budget and content size
|
|
40
|
-
* as the dynamic
|
|
40
|
+
* as the dynamic snap point are all its — with the library's gutter on the content
|
|
41
41
|
* container. A virtualised list has no inner box to put the classes on, so
|
|
42
42
|
* here they go on `contentContainerClassName`; the engine flattens the
|
|
43
43
|
* resulting style to one object before the list measures it.
|
|
@@ -36,7 +36,7 @@ export type BottomSheetFooterProps = HeadlessProps & {
|
|
|
36
36
|
* geometry's footer line, which the core proves holds still through a keyboard
|
|
37
37
|
* animation, so its buttons land on the keyboard's top edge without moving;
|
|
38
38
|
* the safe-area band under it is a spacer the geometry owns; and its measured
|
|
39
|
-
* height is what the body reserves and the dynamic
|
|
39
|
+
* height is what the body reserves and the dynamic snap point counts. The
|
|
40
40
|
* `padding` goes to the engine rather than a class for that reason — it has to
|
|
41
41
|
* be inside the measured box.
|
|
42
42
|
*
|
|
@@ -25,7 +25,7 @@ export type BottomSheetHandleProps = HeadlessProps & {
|
|
|
25
25
|
*
|
|
26
26
|
* The engine's handle draws nothing of its own: it owns the pan, measures
|
|
27
27
|
* itself into the sheet's height, and is the adjustable element a screen
|
|
28
|
-
* reader steps through the
|
|
28
|
+
* reader steps through the snap points with. This puts the pill inside it, and
|
|
29
29
|
* classes on both.
|
|
30
30
|
*
|
|
31
31
|
* Pass children to replace the pill; the row, the pan and the accessibility
|
|
@@ -44,7 +44,7 @@ export type BottomSheetLegendListProps<ItemT> = Omit<AnimatedLegendListProps<Ite
|
|
|
44
44
|
* Built here rather than in the engine because `@legendapp/list` is this
|
|
45
45
|
* library's optional peer, not the engine's: the engine exports the factory,
|
|
46
46
|
* and this is what the factory is for. The same scroll lock, drag budget and
|
|
47
|
-
* content-size
|
|
47
|
+
* content-size snap point as the other three bodies.
|
|
48
48
|
*
|
|
49
49
|
* @example
|
|
50
50
|
* <BottomSheet.LegendList data={rows} keyExtractor={(row) => row.id} renderItem={renderRow} />
|
|
@@ -36,13 +36,13 @@ export type BottomSheetScrollViewProps = Omit<HeadlessProps, "children"> & {
|
|
|
36
36
|
* A scrolling body.
|
|
37
37
|
*
|
|
38
38
|
* Use this rather than a plain `ScrollView`: the engine's scrollable and the
|
|
39
|
-
* sheet's pan share a finger — below the highest
|
|
39
|
+
* sheet's pan share a finger — below the highest snap point the list is held and
|
|
40
40
|
* the drag moves the sheet, at the highest the list scrolls, and a drag down
|
|
41
41
|
* from the top hands back to the sheet. A React Native `ScrollView` in here has
|
|
42
42
|
* no such arrangement.
|
|
43
43
|
*
|
|
44
44
|
* **It needs no `snapPoints` and no `dynamicSizing={false}`.** The list
|
|
45
|
-
* reports its content size, and that is the dynamic
|
|
45
|
+
* reports its content size, and that is the dynamic snap point: six rows make a
|
|
46
46
|
* short sheet, forty make one capped at `maxDynamicContentSize` that scrolls
|
|
47
47
|
* inside the cap. Explicit `snapPoints` on the root still work, for a height
|
|
48
48
|
* that is a decision rather than a measurement.
|
|
@@ -38,11 +38,11 @@ export type BottomSheetProps = HeadlessBottomSheetProps;
|
|
|
38
38
|
* Both run on the UI thread from inside the engine's pan, so they are
|
|
39
39
|
* `Presets.System.*` — themselves worklets — and nothing else: a JS function
|
|
40
40
|
* here would be `undefined is not a function` at the moment a finger crosses a
|
|
41
|
-
*
|
|
41
|
+
* snap point. `selection` on a snap point because that is what a picker's tick feels
|
|
42
42
|
* like, and `impactLight` on a close because letting go of a sheet is a
|
|
43
43
|
* heavier event than passing a stop, but not by much.
|
|
44
44
|
*/
|
|
45
|
-
const
|
|
45
|
+
const snapPointHaptic = Presets.System.selection;
|
|
46
46
|
const closeHaptic = Presets.System.impactLight;
|
|
47
47
|
|
|
48
48
|
/**
|
|
@@ -55,19 +55,19 @@ const closeHaptic = Presets.System.impactLight;
|
|
|
55
55
|
* `useSafeAreaInsets()`. The engine reserves that band under the body or the
|
|
56
56
|
* sticky footer and gives it back to the keyboard as it arrives; a caller
|
|
57
57
|
* never pads for the home indicator by hand.
|
|
58
|
-
* - **The haptics are on by default.** Pass `
|
|
58
|
+
* - **The haptics are on by default.** Pass `onSnapPointHaptic={undefined}` to
|
|
59
59
|
* turn one off, or a worklet of your own to change it.
|
|
60
60
|
* - **The stale-keyboard guard runs on mount.** A sheet mounted while
|
|
61
61
|
* `KeyboardProvider`'s shared values are pinned open by a keyboard that
|
|
62
62
|
* vanished without a `will` event would lift for a keyboard that is not
|
|
63
63
|
* there; `Screen.Footer` runs the same repair for the same reason.
|
|
64
64
|
*
|
|
65
|
-
* `topInset` is left at the engine's zero, so a `%`
|
|
65
|
+
* `topInset` is left at the engine's zero, so a `%` snap point is a fraction of the
|
|
66
66
|
* whole window — the same fraction it was before this rewrite.
|
|
67
67
|
*/
|
|
68
68
|
function BottomSheetRoot({
|
|
69
69
|
bottomInset,
|
|
70
|
-
|
|
70
|
+
onSnapPointHaptic = snapPointHaptic,
|
|
71
71
|
onCloseHaptic = closeHaptic,
|
|
72
72
|
...props
|
|
73
73
|
}: BottomSheetProps): ReactElement {
|
|
@@ -78,7 +78,7 @@ function BottomSheetRoot({
|
|
|
78
78
|
<Headless
|
|
79
79
|
bottomInset={bottomInset ?? insets.bottom}
|
|
80
80
|
onCloseHaptic={onCloseHaptic}
|
|
81
|
-
|
|
81
|
+
onSnapPointHaptic={onSnapPointHaptic}
|
|
82
82
|
{...props}
|
|
83
83
|
/>
|
|
84
84
|
);
|
|
@@ -112,7 +112,7 @@ describe("every token the slots name", () => {
|
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
describe("the backdrop indices", () => {
|
|
115
|
-
test("show the scrim from the first
|
|
115
|
+
test("show the scrim from the first snap point and hide it only when closed", () => {
|
|
116
116
|
// A modal sheet has no resting state: presented or gone.
|
|
117
117
|
expect(BOTTOM_SHEET_BACKDROP_INDICES.appearsOnIndex).toBe(0);
|
|
118
118
|
expect(BOTTOM_SHEET_BACKDROP_INDICES.disappearsOnIndex).toBe(-1);
|
|
@@ -198,7 +198,7 @@ describe("bottomSheetVariants slots", () => {
|
|
|
198
198
|
test("a pinned footer writes its vertical padding as the engine's prop, never a class", () => {
|
|
199
199
|
// The engine measures the footer's inner box into the sheet's height, and
|
|
200
200
|
// padding handed to its `padding` prop lands on that box. A class on the
|
|
201
|
-
// outer view would be height the
|
|
201
|
+
// outer view would be height the snap point never counts.
|
|
202
202
|
expect(SLOTS.stickyFooter()).not.toMatch(/\bp[tby]?-[\d.]+\b/);
|
|
203
203
|
expect(BOTTOM_SHEET_FOOTER_PADDING).toBeGreaterThan(0);
|
|
204
204
|
});
|
|
@@ -21,10 +21,10 @@ export const BOTTOM_SHEET_OVERLAY_TOKEN = "overlay";
|
|
|
21
21
|
export const BOTTOM_SHEET_OVERLAY_OPACITY = 1;
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* The
|
|
24
|
+
* The snap point indices the scrim appears and disappears on.
|
|
25
25
|
*
|
|
26
26
|
* A modal sheet has no resting state: it is either presented or gone, so the
|
|
27
|
-
* scrim belongs from the FIRST
|
|
27
|
+
* scrim belongs from the FIRST snap point (`0`) and is only absent when the sheet
|
|
28
28
|
* is closed (`-1`). These are the engine's own defaults too; they are named
|
|
29
29
|
* here so the test that pins them has something to read.
|
|
30
30
|
*/
|
|
@@ -43,7 +43,7 @@ export const BOTTOM_SHEET_CLOSE_HIT_SLOP = 8;
|
|
|
43
43
|
* The padding a pinned footer's measured box carries.
|
|
44
44
|
*
|
|
45
45
|
* A number rather than a `p-4`, because it is handed to the engine's `padding`
|
|
46
|
-
* prop: the engine measures the footer's inner box into the dynamic
|
|
46
|
+
* prop: the engine measures the footer's inner box into the dynamic snap point,
|
|
47
47
|
* and padding written there is counted where a class on the outer view would
|
|
48
48
|
* not be. The horizontal gutter still comes from the `stickyFooter` slot, which
|
|
49
49
|
* is merged after it.
|
|
@@ -23,7 +23,7 @@ section first. What follows is only where a standalone disclosure differs.
|
|
|
23
23
|
| `collapsible.context.tsx` | `CollapsibleContext`, `useCollapsible` and the internal part hook |
|
|
24
24
|
| `collapsible.types.ts` | `CollapsibleTextProps`, shared by the title, description and trigger |
|
|
25
25
|
| `collapsible.variants.ts` | Pure `tv()` slots, the constants, the toggle and the accessibility resolver — no RN imports |
|
|
26
|
-
| `collapsible.variants.test.ts` | |
|
|
26
|
+
| `collapsible.variants.test.ts` | `collapsibleTravelTarget`, pinned equal to the accordion's, and the JS-thread `contentHeight.value` sweep |
|
|
27
27
|
|
|
28
28
|
## Design
|
|
29
29
|
|
|
@@ -41,9 +41,14 @@ section first. What follows is only where a standalone disclosure differs.
|
|
|
41
41
|
is the only thing that imports both, and tests are not shipped.
|
|
42
42
|
- **The root owns the state and the travel.** With no item layer, what
|
|
43
43
|
`Accordion.Item` owns — `progress`, `contentHeight`, the spring, the
|
|
44
|
-
`onMeasured`
|
|
44
|
+
`isMeasured` state `onMeasured` flips — lives on the root. The race it closes
|
|
45
45
|
(`onLayout` landing either side of React's effects) is the same one, so the
|
|
46
46
|
panel reports its first measurement and never starts the spring itself.
|
|
47
|
+
- **Nothing on the JS thread reads `contentHeight.value`** — the accordion's
|
|
48
|
+
Release-build bug, in the same shape: a JS read can lag the panel's queued
|
|
49
|
+
write, see `COLLAPSIBLE_UNMEASURED`, and leave the panel shut. "Measured" is
|
|
50
|
+
React state and the decision is `collapsibleTravelTarget`; the test sweeps both
|
|
51
|
+
files. See the accordion's AGENTS.md.
|
|
47
52
|
- **The disabled fade lands on the root.** Never on the trigger, which is a
|
|
48
53
|
`Pressable` whose `Animated.View` writes `opacity` every frame; the accordion
|
|
49
54
|
fades its item for the same reason, and a collapsible's root is its item.
|
|
@@ -2,12 +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 { useCollapsiblePart } from "./collapsible.context";
|
|
5
|
-
import {
|
|
6
|
-
COLLAPSIBLE_CONTENT_FADE,
|
|
7
|
-
COLLAPSIBLE_UNMEASURED,
|
|
8
|
-
collapsibleVariants,
|
|
9
|
-
resolveCollapsibleAccessibility,
|
|
10
|
-
} from "./collapsible.variants";
|
|
5
|
+
import { COLLAPSIBLE_CONTENT_FADE, collapsibleVariants, resolveCollapsibleAccessibility } from "./collapsible.variants";
|
|
11
6
|
|
|
12
7
|
export type CollapsibleContentProps = Omit<ViewProps, "style"> & {
|
|
13
8
|
className?: string;
|
|
@@ -47,13 +42,15 @@ export function CollapsibleContent({ className, children, ...props }: Collapsibl
|
|
|
47
42
|
|
|
48
43
|
const handleLayout = useCallback(
|
|
49
44
|
(event: LayoutChangeEvent) => {
|
|
50
|
-
const wasMeasured = contentHeight.value > COLLAPSIBLE_UNMEASURED;
|
|
51
45
|
contentHeight.value = event.nativeEvent.layout.height;
|
|
52
|
-
|
|
46
|
+
// "First" is this component's own state, never `contentHeight.value`: the
|
|
47
|
+
// write above is queued onto the UI runtime, and a read straight after it
|
|
48
|
+
// can still see the old value.
|
|
49
|
+
if (isMeasured) return;
|
|
53
50
|
setMeasured(true);
|
|
54
51
|
onMeasured();
|
|
55
52
|
},
|
|
56
|
-
[contentHeight, onMeasured]
|
|
53
|
+
[contentHeight, isMeasured, onMeasured]
|
|
57
54
|
);
|
|
58
55
|
|
|
59
56
|
const clipStyle = useAnimatedStyle(() => ({
|
|
@@ -18,13 +18,18 @@ export type CollapsibleContextValue = {
|
|
|
18
18
|
* and the indicator's rotation — so they cannot drift out of step by a frame.
|
|
19
19
|
*/
|
|
20
20
|
progress: SharedValue<number>;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The panel's natural height in points, or {@link COLLAPSIBLE_UNMEASURED} until it
|
|
23
|
+
* has reported. Read on the UI runtime only, by the height style — a JS-thread read
|
|
24
|
+
* can lag the panel's write. See {@link onMeasured}.
|
|
25
|
+
*/
|
|
22
26
|
contentHeight: SharedValue<number>;
|
|
23
27
|
/**
|
|
24
28
|
* Told by the panel that it has measured itself for the first time.
|
|
25
29
|
*
|
|
26
|
-
* Internal machinery: the root is the only owner of the spring, and this
|
|
27
|
-
* its
|
|
30
|
+
* Internal machinery: the root is the only owner of the spring, and this flips
|
|
31
|
+
* its `isMeasured` state — the reason its effect re-runs once there is a height
|
|
32
|
+
* to travel against, and how it knows, never by reading `contentHeight`. The
|
|
28
33
|
* panel cannot start the spring itself — `onLayout` lands either side of
|
|
29
34
|
* React's effects, and a spring started there is sometimes cancelled by the
|
|
30
35
|
* cleanup of the effect it raced. `Accordion.Item`'s `onMeasured`, verbatim.
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
COLLAPSIBLE_UNMEASURED,
|
|
11
11
|
type CollapsibleSize,
|
|
12
12
|
type CollapsibleVariant,
|
|
13
|
+
collapsibleTravelTarget,
|
|
13
14
|
collapsibleVariants,
|
|
14
15
|
toggleCollapsibleOpen,
|
|
15
16
|
} from "./collapsible.variants";
|
|
@@ -79,21 +80,22 @@ function CollapsibleRoot({
|
|
|
79
80
|
const progress = useSharedValue(isOpen ? 1 : 0);
|
|
80
81
|
const contentHeight = useSharedValue(COLLAPSIBLE_UNMEASURED);
|
|
81
82
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
const
|
|
83
|
+
// Whether the panel has ever reported a height — React state, never a JS-thread
|
|
84
|
+
// read of `contentHeight`, which can lag the write. See `collapsibleTravelTarget`
|
|
85
|
+
// and `onMeasured` on the context.
|
|
86
|
+
const [isMeasured, setMeasured] = useState(false);
|
|
87
|
+
const onMeasured = useCallback(() => setMeasured(true), []);
|
|
86
88
|
|
|
87
89
|
// The first open of a panel that has never mounted has no height to travel
|
|
88
|
-
// against, so that run
|
|
89
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: `measurements` is the re-run trigger, see above
|
|
90
|
+
// against, so that run waits and the measurement that follows brings it back.
|
|
90
91
|
useEffect(() => {
|
|
91
|
-
|
|
92
|
+
const target = collapsibleTravelTarget({ isMeasured, isOpen });
|
|
93
|
+
if (target === null) return;
|
|
92
94
|
|
|
93
|
-
progress.value = withSpring(
|
|
95
|
+
progress.value = withSpring(target, COLLAPSIBLE_SPRING);
|
|
94
96
|
|
|
95
97
|
return () => cancelAnimation(progress);
|
|
96
|
-
}, [
|
|
98
|
+
}, [isMeasured, isOpen, progress]);
|
|
97
99
|
|
|
98
100
|
const context = useMemo<CollapsibleContextValue>(
|
|
99
101
|
() => ({ contentHeight, isDisabled, isOpen, onMeasured, progress, size, toggle, variant }),
|
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
ACCORDION_SIZES,
|
|
10
10
|
ACCORDION_SPRING,
|
|
11
11
|
ACCORDION_VARIANTS,
|
|
12
|
+
accordionTravelTarget,
|
|
12
13
|
accordionVariants,
|
|
13
14
|
} from "../accordion/accordion.variants";
|
|
15
|
+
import { CONTENT_HEIGHT_READ, jsThreadSource } from "../accordion/accordion.variants.test";
|
|
14
16
|
import { ICON_SIZES } from "../icon/icon.variants";
|
|
15
17
|
import {
|
|
16
18
|
COLLAPSIBLE_CONTENT_FADE,
|
|
@@ -24,6 +26,7 @@ import {
|
|
|
24
26
|
COLLAPSIBLE_SPRING,
|
|
25
27
|
COLLAPSIBLE_UNMEASURED,
|
|
26
28
|
COLLAPSIBLE_VARIANTS,
|
|
29
|
+
collapsibleTravelTarget,
|
|
27
30
|
collapsibleVariants,
|
|
28
31
|
resolveCollapsibleAccessibility,
|
|
29
32
|
toggleCollapsibleOpen,
|
|
@@ -300,3 +303,36 @@ describe("collapsible and accordion move alike", () => {
|
|
|
300
303
|
}
|
|
301
304
|
});
|
|
302
305
|
});
|
|
306
|
+
|
|
307
|
+
describe("collapsibleTravelTarget", () => {
|
|
308
|
+
test("an open waits for the panel's first measurement", () => {
|
|
309
|
+
expect(collapsibleTravelTarget({ isOpen: true, isMeasured: false })).toBeNull();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("an open travels to 1 once the panel has measured", () => {
|
|
313
|
+
expect(collapsibleTravelTarget({ isOpen: true, isMeasured: true })).toBe(1);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("a close never waits, measured or not", () => {
|
|
317
|
+
expect(collapsibleTravelTarget({ isOpen: false, isMeasured: false })).toBe(0);
|
|
318
|
+
expect(collapsibleTravelTarget({ isOpen: false, isMeasured: true })).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("agrees with the accordion's rule on every input", () => {
|
|
322
|
+
for (const isOpen of [false, true]) {
|
|
323
|
+
for (const isMeasured of [false, true]) {
|
|
324
|
+
expect(collapsibleTravelTarget({ isOpen, isMeasured })).toBe(
|
|
325
|
+
accordionTravelTarget({ isExpanded: isOpen, isMeasured })
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("the measured height is never read on the JS thread", () => {
|
|
333
|
+
// `Accordion`'s Release-build bug, in the same two files: a JS-thread read straight after the
|
|
334
|
+
// panel's `onLayout` wrote the height can still see `COLLAPSIBLE_UNMEASURED`.
|
|
335
|
+
test.each(["collapsible.tsx", "collapsible-content.tsx"])("%s", (file) => {
|
|
336
|
+
expect(jsThreadSource(join(import.meta.dirname, file))).not.toMatch(CONTENT_HEIGHT_READ);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -55,12 +55,33 @@ export const COLLAPSIBLE_INDICATOR_ROTATION = { collapsed: 0, expanded: 180 } as
|
|
|
55
55
|
/**
|
|
56
56
|
* What the measured height holds before the panel has ever reported its layout.
|
|
57
57
|
*
|
|
58
|
-
* Negative rather than zero: a panel that measured `0` is a real answer
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* Negative rather than zero: a panel that measured `0` is a real answer and still
|
|
59
|
+
* opens. Only a value no layout can produce can mean *unmeasured*, so the height
|
|
60
|
+
* style floors it. Whether the panel *has* measured is never decided by comparing
|
|
61
|
+
* against this on the JS thread — see {@link collapsibleTravelTarget}.
|
|
61
62
|
*/
|
|
62
63
|
export const COLLAPSIBLE_UNMEASURED = -1;
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Where the root's `progress` should spring to, or `null` to wait — only an open,
|
|
67
|
+
* and only before the panel's first measurement.
|
|
68
|
+
*
|
|
69
|
+
* `isMeasured` is React state, never a JS-thread read of the measured height: the
|
|
70
|
+
* panel's write is queued onto the UI runtime, and a read straight after it can
|
|
71
|
+
* still see {@link COLLAPSIBLE_UNMEASURED} — a Release build does, every time, and
|
|
72
|
+
* the panel stays shut. `accordionTravelTarget`, restated.
|
|
73
|
+
*/
|
|
74
|
+
export function collapsibleTravelTarget({
|
|
75
|
+
isOpen,
|
|
76
|
+
isMeasured,
|
|
77
|
+
}: {
|
|
78
|
+
isOpen: boolean;
|
|
79
|
+
isMeasured: boolean;
|
|
80
|
+
}): 0 | 1 | null {
|
|
81
|
+
if (!isOpen) return 0;
|
|
82
|
+
return isMeasured ? 1 : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
64
85
|
/**
|
|
65
86
|
* The window of the travel the panel's opacity ramps across — ahead of the
|
|
66
87
|
* height, so the content is legible for most of an expand rather than half
|
|
@@ -14,9 +14,9 @@ everything — a root layout, an `App.tsx`.
|
|
|
14
14
|
|
|
15
15
|
## Design
|
|
16
16
|
|
|
17
|
-
- **
|
|
18
|
-
`SafeAreaProvider` → `KeyboardProvider` → `<KeyboardStateSync />` beside
|
|
19
|
-
children. The order is not stylistic. The gesture root has to be an ancestor
|
|
17
|
+
- **Five layers, outermost first**: `GestureHandlerRootView` →
|
|
18
|
+
`SafeAreaProvider` → `KeyboardProvider` → `<KeyboardStateSync />` beside
|
|
19
|
+
`CalmMotionProvider`, which wraps the children. The order is not stylistic. The gesture root has to be an ancestor
|
|
20
20
|
native view of every handler a `Pressable` creates, and its absence is
|
|
21
21
|
*silent* — no error, no warning, presses simply stop landing.
|
|
22
22
|
`KeyboardStateSync` has to be a CHILD of `KeyboardProvider`, because it calls
|
|
@@ -67,6 +67,15 @@ everything — a root layout, an `App.tsx`.
|
|
|
67
67
|
`BottomSheet.Portal` still renders, where it is written, as an inline sheet.
|
|
68
68
|
The previous sheet library was a required peer while this component mounted
|
|
69
69
|
its modal provider; nothing imports it any more.
|
|
70
|
+
- **`isMotionCalm` is a behaviour prop, not a layer-named one.** It feeds
|
|
71
|
+
`CalmMotionProvider` (`hooks/use-calm-motion.tsx`), which `useCalmMotion()`
|
|
72
|
+
reads beside the OS reduce-motion setting. It exists for E2E builds: a runner
|
|
73
|
+
that waits for the screen to settle before each gesture (Argent waits up to
|
|
74
|
+
3 s) pays the whole wait on a screen holding a never-ending loop. Only
|
|
75
|
+
decorative loops ask — `Skeleton` today; `Spinner` and an indeterminate
|
|
76
|
+
`Progress` are the behaviour and keep moving. The context defaults to `false`,
|
|
77
|
+
so a component used without this provider (a `delacour add` copy in an app
|
|
78
|
+
with its own stack) still honours reduce motion.
|
|
70
79
|
- **Deliberately not idempotent.** It does not detect an enclosing copy of
|
|
71
80
|
itself. Nesting `GestureHandlerRootView` costs a `View`; nesting
|
|
72
81
|
`SafeAreaProvider` seeds from the parent's insets and costs a native view;
|
|
@@ -87,7 +96,7 @@ everything — a root layout, an `App.tsx`.
|
|
|
87
96
|
per-subpath and survives intact: `/button` still pulls nothing
|
|
88
97
|
keyboard-related.
|
|
89
98
|
- **Nothing here for `bun test`, and no `provider.variants.ts` to give it
|
|
90
|
-
some.** The component is
|
|
99
|
+
some.** The component is five nested elements and one default parameter;
|
|
91
100
|
extracting a `resolveInitialMetrics()` would be a unit test of `??`. The rule
|
|
92
101
|
that pure decisions live in a `*.variants.ts` has no decision here to
|
|
93
102
|
relocate.
|
|
@@ -3,6 +3,7 @@ import type { StyleProp, ViewStyle } from "react-native";
|
|
|
3
3
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
4
4
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
|
5
5
|
import { initialWindowMetrics, type Metrics, SafeAreaProvider } from "react-native-safe-area-context";
|
|
6
|
+
import { CalmMotionProvider } from "../../hooks/use-calm-motion";
|
|
6
7
|
import { KeyboardStateSync } from "../../hooks/use-keyboard-state-sync";
|
|
7
8
|
|
|
8
9
|
export type DelacourProviderProps = {
|
|
@@ -20,6 +21,16 @@ export type DelacourProviderProps = {
|
|
|
20
21
|
* `null` is a value here rather than an absence.
|
|
21
22
|
*/
|
|
22
23
|
initialMetrics?: Metrics | null;
|
|
24
|
+
/**
|
|
25
|
+
* Hold every decorative loop still — a skeleton's shimmer or pulse — whatever
|
|
26
|
+
* the OS reduce-motion setting says. Defaults to `false`.
|
|
27
|
+
*
|
|
28
|
+
* For an E2E build. A test runner that waits for the screen to stop moving
|
|
29
|
+
* before each gesture (Argent, Detox, Maestro) pays its whole timeout on any
|
|
30
|
+
* screen holding a loop that never ends. Motion that *is* the behaviour — a
|
|
31
|
+
* `Spinner`, an indeterminate `Progress` — keeps moving either way.
|
|
32
|
+
*/
|
|
33
|
+
isMotionCalm?: boolean;
|
|
23
34
|
/**
|
|
24
35
|
* Style for the outermost `GestureHandlerRootView`.
|
|
25
36
|
*
|
|
@@ -37,7 +48,7 @@ export type DelacourProviderProps = {
|
|
|
37
48
|
* Mount it ONCE, around everything — a root layout, an `App.tsx`. It is not
|
|
38
49
|
* idempotent and does not detect an enclosing copy of itself; see AGENTS.md.
|
|
39
50
|
*
|
|
40
|
-
*
|
|
51
|
+
* Five layers, outermost first, and the order is not stylistic:
|
|
41
52
|
*
|
|
42
53
|
* 1. `GestureHandlerRootView` — an ancestor native view every gesture handler
|
|
43
54
|
* `Pressable` creates has to attach to. Its absence is silent: no error, no
|
|
@@ -51,6 +62,9 @@ export type DelacourProviderProps = {
|
|
|
51
62
|
* `will` events, so a keyboard that vanishes without one — an interactive
|
|
52
63
|
* dismiss interrupted by navigation, a stack pop, an app suspend — leaves
|
|
53
64
|
* every screen in the app believing it is still open.
|
|
65
|
+
* 5. `CalmMotionProvider` — the app's `isMotionCalm`, for `useCalmMotion()`.
|
|
66
|
+
* Pure context, no native view, so it wraps `{children}` alone — innermost,
|
|
67
|
+
* where a new layer goes.
|
|
54
68
|
*
|
|
55
69
|
* **`BottomSheetProvider` is not here, and the app mounts it.** The sheet's
|
|
56
70
|
* engine, `@delacour/react-native-bottom-sheet`, is an optional peer of this
|
|
@@ -92,10 +106,16 @@ export type DelacourProviderProps = {
|
|
|
92
106
|
* // app that launches into a rotated or split-screen window and cannot
|
|
93
107
|
* // tolerate one stale frame.
|
|
94
108
|
* <DelacourProvider initialMetrics={null}>{children}</DelacourProvider>
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* // An E2E build: decorative loops hold still so the test runner never waits
|
|
112
|
+
* // out a shimmer. `IS_E2E` is the app's own build flag.
|
|
113
|
+
* <DelacourProvider isMotionCalm={IS_E2E}>{children}</DelacourProvider>
|
|
95
114
|
*/
|
|
96
115
|
export function DelacourProvider({
|
|
97
116
|
children,
|
|
98
117
|
initialMetrics = initialWindowMetrics,
|
|
118
|
+
isMotionCalm = false,
|
|
99
119
|
style,
|
|
100
120
|
}: DelacourProviderProps): ReactElement {
|
|
101
121
|
return (
|
|
@@ -103,7 +123,7 @@ export function DelacourProvider({
|
|
|
103
123
|
<SafeAreaProvider initialMetrics={initialMetrics}>
|
|
104
124
|
<KeyboardProvider>
|
|
105
125
|
<KeyboardStateSync />
|
|
106
|
-
{children}
|
|
126
|
+
<CalmMotionProvider isMotionCalm={isMotionCalm}>{children}</CalmMotionProvider>
|
|
107
127
|
</KeyboardProvider>
|
|
108
128
|
</SafeAreaProvider>
|
|
109
129
|
</GestureHandlerRootView>
|
|
@@ -78,9 +78,12 @@ with a glint sweeping across it, or a pulse, on the UI thread. Compound root plu
|
|
|
78
78
|
line into a full one, that line's glint sat off-centre and swept visibly out
|
|
79
79
|
of step with the rest. `StyleSheet.absoluteFill` plus
|
|
80
80
|
`preserveAspectRatio="none"` makes the native view follow the band.
|
|
81
|
-
- **
|
|
82
|
-
`resolveSkeletonAnimation` returns `none` while
|
|
83
|
-
|
|
81
|
+
- **Calm motion stills the skeleton, it does not hide it.**
|
|
82
|
+
`resolveSkeletonAnimation` returns `none` while `useCalmMotion()` is true — the
|
|
83
|
+
OS reduce-motion setting, or `DelacourProvider`'s `isMotionCalm` — and the
|
|
84
|
+
clock is never started. The second half is for E2E builds: a test runner that
|
|
85
|
+
waits for the screen to settle before each gesture pays its whole timeout on a
|
|
86
|
+
screen holding a shimmer that never ends. The shape is the message: a placeholder that stops
|
|
84
87
|
moving still reads as content on its way. The clock's own timing sets
|
|
85
88
|
`ReduceMotion.Never` because the decision has already been made one level up —
|
|
86
89
|
under the default `System` policy `withTiming` completes instantly and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactElement, type ReactNode, useMemo } from "react";
|
|
2
2
|
import { View, type ViewProps } from "react-native";
|
|
3
|
-
import {
|
|
3
|
+
import { useCalmMotion } from "../../hooks/use-calm-motion";
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
5
|
import { type SkeletonGroupContextValue, SkeletonGroupProvider } from "./skeleton.context";
|
|
6
6
|
import { resolveSkeletonAccessibility, resolveSkeletonAnimation, type SkeletonAnimation } from "./skeleton.variants";
|
|
@@ -38,8 +38,8 @@ export function SkeletonGroup({
|
|
|
38
38
|
children,
|
|
39
39
|
...props
|
|
40
40
|
}: SkeletonGroupProps): ReactElement {
|
|
41
|
-
const
|
|
42
|
-
const resolvedAnimation = resolveSkeletonAnimation(animation,
|
|
41
|
+
const isCalm = useCalmMotion();
|
|
42
|
+
const resolvedAnimation = resolveSkeletonAnimation(animation, isCalm);
|
|
43
43
|
const isRunning = isLoading && resolvedAnimation !== "none";
|
|
44
44
|
const progress = useSkeletonClock(isRunning);
|
|
45
45
|
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { type ReactElement, type ReactNode, useEffect } from "react";
|
|
2
2
|
import { View, type ViewProps } from "react-native";
|
|
3
|
-
import Animated, {
|
|
4
|
-
|
|
5
|
-
useAnimatedStyle,
|
|
6
|
-
useReducedMotion,
|
|
7
|
-
useSharedValue,
|
|
8
|
-
withTiming,
|
|
9
|
-
} from "react-native-reanimated";
|
|
3
|
+
import Animated, { useAnimatedRef, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
|
|
4
|
+
import { useCalmMotion } from "../../hooks/use-calm-motion";
|
|
10
5
|
import { cn } from "../../lib/cn";
|
|
11
6
|
import { useSkeletonGroup } from "./skeleton.context";
|
|
12
7
|
import {
|
|
@@ -29,7 +24,7 @@ export type SkeletonProps = Omit<ViewProps, "children"> & {
|
|
|
29
24
|
/**
|
|
30
25
|
* `shimmer` sweeps a glint across the placeholder, `pulse` breathes its
|
|
31
26
|
* opacity, `none` holds it still. Inherited from an enclosing `Skeleton.Group`,
|
|
32
|
-
* then `shimmer`. Every animation stills under the OS reduce-motion setting
|
|
27
|
+
* then `shimmer`. Every animation stills under the OS reduce-motion setting, and under `DelacourProvider`'s `isMotionCalm`.
|
|
33
28
|
*/
|
|
34
29
|
animation?: SkeletonAnimation;
|
|
35
30
|
/**
|
|
@@ -63,10 +58,10 @@ function SkeletonRoot({
|
|
|
63
58
|
...props
|
|
64
59
|
}: SkeletonProps): ReactElement {
|
|
65
60
|
const group = useSkeletonGroup();
|
|
66
|
-
const
|
|
61
|
+
const isCalm = useCalmMotion();
|
|
67
62
|
|
|
68
63
|
const loading = isLoading ?? group?.isLoading ?? true;
|
|
69
|
-
const resolvedAnimation = resolveSkeletonAnimation(animation ?? group?.animation,
|
|
64
|
+
const resolvedAnimation = resolveSkeletonAnimation(animation ?? group?.animation, isCalm);
|
|
70
65
|
const isAnimating = loading && resolvedAnimation !== "none";
|
|
71
66
|
|
|
72
67
|
// One clock drives either animation, so a group's clock serves every
|
|
@@ -61,16 +61,14 @@ export const SKELETON_SHIMMER_STOPS: readonly SkeletonShimmerStop[] = [
|
|
|
61
61
|
/**
|
|
62
62
|
* The animation a skeleton actually runs.
|
|
63
63
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* set `ReduceMotion.Never` —
|
|
64
|
+
* Calm motion — the OS reduce-motion setting, or an app's `isMotionCalm` — stills
|
|
65
|
+
* every animation rather than hiding the placeholder: the shape is the message,
|
|
66
|
+
* and a shape that stops moving still reads as content on its way. The clock then
|
|
67
|
+
* never starts, which is also why the timing itself can set `ReduceMotion.Never` —
|
|
68
|
+
* see AGENTS.md.
|
|
68
69
|
*/
|
|
69
|
-
export function resolveSkeletonAnimation(
|
|
70
|
-
|
|
71
|
-
isReduceMotion: boolean
|
|
72
|
-
): SkeletonAnimation {
|
|
73
|
-
if (isReduceMotion) return "none";
|
|
70
|
+
export function resolveSkeletonAnimation(animation: SkeletonAnimation | undefined, isCalm: boolean): SkeletonAnimation {
|
|
71
|
+
if (isCalm) return "none";
|
|
74
72
|
return animation ?? SKELETON_FALLBACK_ANIMATION;
|
|
75
73
|
}
|
|
76
74
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, useContext } from "react";
|
|
2
|
+
import { useReducedMotion } from "react-native-reanimated";
|
|
3
|
+
import { calmMotion } from "../lib/calm-motion";
|
|
4
|
+
|
|
5
|
+
const CalmMotionContext = createContext(false);
|
|
6
|
+
|
|
7
|
+
export type CalmMotionProviderProps = {
|
|
8
|
+
/** Hold every decorative loop beneath this still, whatever the OS setting. */
|
|
9
|
+
isMotionCalm: boolean;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Asks every decorative loop beneath it to hold still — for an E2E build, whose test runner waits
|
|
15
|
+
* for the screen to stop moving before each gesture. `DelacourProvider` mounts it from its own
|
|
16
|
+
* `isMotionCalm`; mount it by hand only in an app that composes its providers itself.
|
|
17
|
+
*
|
|
18
|
+
* Optional: without one, {@link useCalmMotion} follows the OS reduce-motion setting alone.
|
|
19
|
+
*/
|
|
20
|
+
export function CalmMotionProvider({ isMotionCalm, children }: CalmMotionProviderProps): ReactElement {
|
|
21
|
+
return <CalmMotionContext.Provider value={isMotionCalm}>{children}</CalmMotionContext.Provider>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether decorative motion should hold still, for this device and this app: the one place a
|
|
26
|
+
* component asks whether to loop. See `calmMotion` for what counts as decorative.
|
|
27
|
+
*/
|
|
28
|
+
export function useCalmMotion(): boolean {
|
|
29
|
+
return calmMotion({ isMotionCalm: useContext(CalmMotionContext), isReduceMotion: useReducedMotion() });
|
|
30
|
+
}
|
|
31
|
+
CalmMotionProvider.displayName = "DelacourUI.CalmMotionProvider";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { calmMotion } from "./calm-motion";
|
|
3
|
+
|
|
4
|
+
describe("calmMotion", () => {
|
|
5
|
+
test("moves when neither the OS nor the app asks for stillness", () => {
|
|
6
|
+
expect(calmMotion({ isReduceMotion: false, isMotionCalm: false })).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("holds still under the OS reduce-motion setting", () => {
|
|
10
|
+
expect(calmMotion({ isReduceMotion: true, isMotionCalm: false })).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("holds still when the app asks, whatever the OS says", () => {
|
|
14
|
+
expect(calmMotion({ isReduceMotion: false, isMotionCalm: true })).toBe(true);
|
|
15
|
+
expect(calmMotion({ isReduceMotion: true, isMotionCalm: true })).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether decorative motion holds still: under the OS reduce-motion setting, or whenever the app
|
|
3
|
+
* asks — `DelacourProvider`'s `isMotionCalm`.
|
|
4
|
+
*
|
|
5
|
+
* The app's half is for automation, not accessibility. A UI test runner that waits for the screen
|
|
6
|
+
* to stop moving before every gesture (Argent waits up to 3 s, Detox and Maestro idle-wait the
|
|
7
|
+
* same way) pays the whole wait on any screen holding a loop that never ends — a shimmering
|
|
8
|
+
* skeleton turns every tap there into a timeout. An E2E build passes `isMotionCalm` and the loops
|
|
9
|
+
* hold still.
|
|
10
|
+
*
|
|
11
|
+
* Only motion that is decoration asks. Motion that *is* the behaviour — a spinner saying work is
|
|
12
|
+
* in flight, a progress bar a reader reads — keeps moving: stilling it would change what the
|
|
13
|
+
* screen says.
|
|
14
|
+
*/
|
|
15
|
+
export function calmMotion({
|
|
16
|
+
isReduceMotion,
|
|
17
|
+
isMotionCalm,
|
|
18
|
+
}: {
|
|
19
|
+
isReduceMotion: boolean;
|
|
20
|
+
isMotionCalm: boolean;
|
|
21
|
+
}): boolean {
|
|
22
|
+
return isReduceMotion || isMotionCalm;
|
|
23
|
+
}
|