@delacour/react-native-ui 0.1.0-alpha.3
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 +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +192 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +435 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +232 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +100 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +33 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +247 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +581 -0
- package/src/components/radio/radio.variants.ts +272 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +48 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +161 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +804 -0
- package/src/components/slider/slider.variants.ts +607 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +286 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +672 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
type ComponentRef,
|
|
4
|
+
type ComponentType,
|
|
5
|
+
createElement,
|
|
6
|
+
isValidElement,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type Ref,
|
|
10
|
+
useMemo,
|
|
11
|
+
} from "react";
|
|
12
|
+
import type { AccessibilityState, ViewProps } from "react-native";
|
|
13
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
14
|
+
import { Presets } from "react-native-pulsar";
|
|
15
|
+
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";
|
|
16
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
17
|
+
import { composeRefs } from "../../lib/compose-refs";
|
|
18
|
+
import { mergeProps } from "../../lib/merge-props";
|
|
19
|
+
import { type PressableFeedback, resolvePressedState } from "./pressable.variants";
|
|
20
|
+
|
|
21
|
+
const PRESS_SPRING = { damping: 18, mass: 0.4, stiffness: 320 } as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Animated counterpart of each component type seen by `asChild`.
|
|
25
|
+
*
|
|
26
|
+
* `Animated.createAnimatedComponent` must be called once per type, never per
|
|
27
|
+
* render — a fresh wrapper each render remounts the subtree.
|
|
28
|
+
*/
|
|
29
|
+
const animatedTypeCache = new WeakMap<object, ComponentType<Record<string, unknown>>>();
|
|
30
|
+
|
|
31
|
+
function resolveAnimatedType(type: unknown): ComponentType<Record<string, unknown>> {
|
|
32
|
+
if (typeof type !== "function" && typeof type !== "object") {
|
|
33
|
+
throw new Error("Pressable asChild expects a component element, not an intrinsic or text node.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const key = type as object;
|
|
37
|
+
const cached = animatedTypeCache.get(key);
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
|
|
40
|
+
const created = Animated.createAnimatedComponent(type as ComponentType<Record<string, unknown>>) as ComponentType<
|
|
41
|
+
Record<string, unknown>
|
|
42
|
+
>;
|
|
43
|
+
animatedTypeCache.set(key, created);
|
|
44
|
+
return created;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type HapticFeedback = "selection" | "light" | "medium" | "heavy" | "success" | "warning" | "error";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fires a haptic preset from the UI thread.
|
|
51
|
+
*
|
|
52
|
+
* Every branch is a worklet, so this runs inside the gesture callback itself —
|
|
53
|
+
* the tap and the haptic land in the same frame rather than waiting on a round
|
|
54
|
+
* trip to the JS thread.
|
|
55
|
+
*
|
|
56
|
+
* Exported because it is the library's *only* haptic vocabulary, and a second
|
|
57
|
+
* component with a gesture of its own — `Slider`, whose pan ticks as the value
|
|
58
|
+
* crosses a step — must reach this switch rather than fork one. It is safe to
|
|
59
|
+
* import across a component folder: nothing in `pressable/` imports a component
|
|
60
|
+
* that could import it back, so there is no cycle for Metro to serve half of.
|
|
61
|
+
*/
|
|
62
|
+
export function playHaptic(feedback: HapticFeedback): void {
|
|
63
|
+
"worklet";
|
|
64
|
+
switch (feedback) {
|
|
65
|
+
case "selection":
|
|
66
|
+
Presets.System.selection();
|
|
67
|
+
return;
|
|
68
|
+
case "light":
|
|
69
|
+
Presets.System.impactLight();
|
|
70
|
+
return;
|
|
71
|
+
case "medium":
|
|
72
|
+
Presets.System.impactMedium();
|
|
73
|
+
return;
|
|
74
|
+
case "heavy":
|
|
75
|
+
Presets.System.impactHeavy();
|
|
76
|
+
return;
|
|
77
|
+
case "success":
|
|
78
|
+
Presets.System.notificationSuccess();
|
|
79
|
+
return;
|
|
80
|
+
case "warning":
|
|
81
|
+
Presets.System.notificationWarning();
|
|
82
|
+
return;
|
|
83
|
+
case "error":
|
|
84
|
+
Presets.System.notificationError();
|
|
85
|
+
return;
|
|
86
|
+
default:
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type PressableProps = Omit<ViewProps, "style"> & {
|
|
92
|
+
children?: ReactNode;
|
|
93
|
+
className?: string;
|
|
94
|
+
disabled?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Work is in flight: presses are blocked and a screen reader announces the
|
|
97
|
+
* element as busy, but not as disabled. Use this over `disabled` for a
|
|
98
|
+
* temporary state the component clears itself, so assistive tech reports a
|
|
99
|
+
* control that is momentarily unavailable rather than one that is inert.
|
|
100
|
+
*/
|
|
101
|
+
busy?: boolean;
|
|
102
|
+
onPress?: () => void;
|
|
103
|
+
onLongPress?: () => void;
|
|
104
|
+
/** Haptic played on press-in. Off by default. */
|
|
105
|
+
haptic?: false | HapticFeedback;
|
|
106
|
+
/**
|
|
107
|
+
* How the press moves the element: `scale`, `fade`, `scale-fade` or `none`.
|
|
108
|
+
* `pressedScale` / `pressedOpacity` still win on the axis they name.
|
|
109
|
+
*/
|
|
110
|
+
feedback?: PressableFeedback;
|
|
111
|
+
/** Scale at full press. 1 disables the scale. Overrides `feedback`. */
|
|
112
|
+
pressedScale?: number;
|
|
113
|
+
/** Opacity at full press. 1 disables the fade. Overrides `feedback`. */
|
|
114
|
+
pressedOpacity?: number;
|
|
115
|
+
/** Render into the single child element instead of emitting a View. */
|
|
116
|
+
asChild?: boolean;
|
|
117
|
+
ref?: Ref<ComponentRef<typeof Animated.View>>;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The interaction primitive every pressable component in this library builds on.
|
|
122
|
+
*
|
|
123
|
+
* Press feedback runs entirely on the UI thread: a Gesture Handler tap drives a
|
|
124
|
+
* shared value, a spring maps it to scale and opacity, and the haptic fires in
|
|
125
|
+
* the same worklet. Only `onPress` and `onLongPress` cross back to JS.
|
|
126
|
+
*
|
|
127
|
+
* How far each axis travels comes from `feedback`, the named vocabulary every
|
|
128
|
+
* pressable in this library shares, or from `pressedScale` / `pressedOpacity`
|
|
129
|
+
* for a value the named modes do not cover.
|
|
130
|
+
*
|
|
131
|
+
* Built on the Gesture API rather than a ready-made pressable so the animation,
|
|
132
|
+
* the haptic and the gesture stay on one thread, and so nothing depends on
|
|
133
|
+
* Gesture Handler's own `Pressable`, which was renamed in its v3.
|
|
134
|
+
*/
|
|
135
|
+
export function Pressable({
|
|
136
|
+
children,
|
|
137
|
+
className,
|
|
138
|
+
disabled = false,
|
|
139
|
+
busy = false,
|
|
140
|
+
onPress,
|
|
141
|
+
onLongPress,
|
|
142
|
+
haptic = false,
|
|
143
|
+
feedback,
|
|
144
|
+
pressedScale,
|
|
145
|
+
pressedOpacity,
|
|
146
|
+
asChild = false,
|
|
147
|
+
accessibilityState,
|
|
148
|
+
ref,
|
|
149
|
+
...props
|
|
150
|
+
}: PressableProps): ReactElement {
|
|
151
|
+
const pressed = useSharedValue(0);
|
|
152
|
+
const interactive = !disabled && !busy;
|
|
153
|
+
// Destructured to primitives on purpose. Reanimated's plugin collects what
|
|
154
|
+
// the worklet closes over, and a fresh object each render would rebuild the
|
|
155
|
+
// animated style every render.
|
|
156
|
+
const { opacity: targetOpacity, scale: targetScale } = resolvePressedState(feedback, pressedScale, pressedOpacity);
|
|
157
|
+
|
|
158
|
+
const gesture = useMemo(() => {
|
|
159
|
+
const tap = Gesture.Tap()
|
|
160
|
+
.enabled(interactive)
|
|
161
|
+
.shouldCancelWhenOutside(true)
|
|
162
|
+
.onBegin(() => {
|
|
163
|
+
"worklet";
|
|
164
|
+
pressed.value = withSpring(1, PRESS_SPRING);
|
|
165
|
+
if (haptic) playHaptic(haptic);
|
|
166
|
+
})
|
|
167
|
+
.onEnd(() => {
|
|
168
|
+
"worklet";
|
|
169
|
+
if (onPress) scheduleOnRN(onPress);
|
|
170
|
+
})
|
|
171
|
+
.onFinalize(() => {
|
|
172
|
+
"worklet";
|
|
173
|
+
pressed.value = withSpring(0, PRESS_SPRING);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!onLongPress) return tap;
|
|
177
|
+
|
|
178
|
+
const longPress = Gesture.LongPress()
|
|
179
|
+
.enabled(interactive)
|
|
180
|
+
.shouldCancelWhenOutside(true)
|
|
181
|
+
.onStart(() => {
|
|
182
|
+
"worklet";
|
|
183
|
+
scheduleOnRN(onLongPress);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return Gesture.Simultaneous(tap, longPress);
|
|
187
|
+
}, [haptic, interactive, onLongPress, onPress, pressed]);
|
|
188
|
+
|
|
189
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
190
|
+
opacity: 1 - pressed.value * (1 - targetOpacity),
|
|
191
|
+
transform: [{ scale: 1 - pressed.value * (1 - targetScale) }],
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
// Built once and spread at both render sites: a caller-supplied
|
|
195
|
+
// `accessibilityState` merges into it rather than being clobbered by the
|
|
196
|
+
// `{...props}` spread that lands after it.
|
|
197
|
+
const state: AccessibilityState = { ...accessibilityState, busy, disabled };
|
|
198
|
+
|
|
199
|
+
const content = asChild ? (
|
|
200
|
+
renderAsChild(children, {
|
|
201
|
+
accessibilityState: state,
|
|
202
|
+
accessible: true,
|
|
203
|
+
className,
|
|
204
|
+
ref,
|
|
205
|
+
style: animatedStyle,
|
|
206
|
+
...props,
|
|
207
|
+
})
|
|
208
|
+
) : (
|
|
209
|
+
<Animated.View
|
|
210
|
+
accessibilityRole="button"
|
|
211
|
+
accessibilityState={state}
|
|
212
|
+
// Without this the view is not an accessibility element on iOS, and the
|
|
213
|
+
// role, state and label above never reach VoiceOver — a composed label
|
|
214
|
+
// on an icon-sized button included. It also merges the children into one
|
|
215
|
+
// element, which is what a control should be.
|
|
216
|
+
accessible
|
|
217
|
+
className={className}
|
|
218
|
+
ref={ref}
|
|
219
|
+
style={animatedStyle}
|
|
220
|
+
{...props}
|
|
221
|
+
>
|
|
222
|
+
{children}
|
|
223
|
+
</Animated.View>
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return <GestureDetector gesture={gesture}>{content}</GestureDetector>;
|
|
227
|
+
}
|
|
228
|
+
Pressable.displayName = "DelacourUI.Pressable";
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Renders the pressable's props into its single child, with no wrapper element.
|
|
232
|
+
*
|
|
233
|
+
* The child is rendered through the animated counterpart of its own type rather
|
|
234
|
+
* than cloned as-is. A Reanimated style handed to a plain component is deep
|
|
235
|
+
* frozen by React Native in development, and Reanimated's own effect then fails
|
|
236
|
+
* writing to it — so the element receiving the style has to be an animated one.
|
|
237
|
+
*/
|
|
238
|
+
function renderAsChild(children: ReactNode, slotProps: Record<string, unknown>): ReactElement {
|
|
239
|
+
const count = Children.count(children);
|
|
240
|
+
if (count !== 1) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Pressable asChild expects exactly one child element, received ${count}. Drop \`asChild\` to render a View.`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const child = Children.only(children);
|
|
247
|
+
if (!isValidElement(child)) {
|
|
248
|
+
throw new Error("Pressable asChild expects a single React element child; text and fragments cannot take props.");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const childProps = child.props as Record<string, unknown> & { ref?: Ref<unknown> };
|
|
252
|
+
const merged = mergeProps<Record<string, unknown>>(slotProps, childProps);
|
|
253
|
+
merged.ref = composeRefs(slotProps.ref as Ref<unknown> | undefined, childProps.ref);
|
|
254
|
+
|
|
255
|
+
return createElement(resolveAnimatedType(child.type), merged);
|
|
256
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
PRESSABLE_FEEDBACK,
|
|
4
|
+
PRESSABLE_FEEDBACK_FALLBACK,
|
|
5
|
+
PRESSABLE_FEEDBACKS,
|
|
6
|
+
resolvePressedState,
|
|
7
|
+
} from "./pressable.variants";
|
|
8
|
+
|
|
9
|
+
describe("PRESSABLE_FEEDBACK", () => {
|
|
10
|
+
test("covers every named feedback", () => {
|
|
11
|
+
for (const feedback of PRESSABLE_FEEDBACKS) {
|
|
12
|
+
expect(PRESSABLE_FEEDBACK[feedback]).toBeDefined();
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("gives every named feedback a distinct pair", () => {
|
|
17
|
+
const seen = new Set(PRESSABLE_FEEDBACKS.map((feedback) => JSON.stringify(PRESSABLE_FEEDBACK[feedback])));
|
|
18
|
+
expect(seen.size).toBe(PRESSABLE_FEEDBACKS.length);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// 1 is the neutral value on either axis, so a single-axis mode leaves the
|
|
22
|
+
// other one alone rather than nudging it.
|
|
23
|
+
test("scale shrinks without dimming", () => {
|
|
24
|
+
expect(PRESSABLE_FEEDBACK.scale.opacity).toBe(1);
|
|
25
|
+
expect(PRESSABLE_FEEDBACK.scale.scale).toBeLessThan(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("fade dims without scaling", () => {
|
|
29
|
+
expect(PRESSABLE_FEEDBACK.fade.scale).toBe(1);
|
|
30
|
+
expect(PRESSABLE_FEEDBACK.fade.opacity).toBeLessThan(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("none moves neither axis", () => {
|
|
34
|
+
expect(PRESSABLE_FEEDBACK.none).toEqual({ opacity: 1, scale: 1 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// The load-bearing one: `scale-fade` is the union of the two single-axis
|
|
38
|
+
// modes, not a third set of numbers. Tuning `scale` or `fade` has to carry
|
|
39
|
+
// through, or the name stops describing what the mode does.
|
|
40
|
+
test("scale-fade takes each axis from the mode that owns it", () => {
|
|
41
|
+
expect(PRESSABLE_FEEDBACK["scale-fade"]).toEqual({
|
|
42
|
+
opacity: PRESSABLE_FEEDBACK.fade.opacity,
|
|
43
|
+
scale: PRESSABLE_FEEDBACK.scale.scale,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("every mode stays within the range the press interpolates over", () => {
|
|
48
|
+
for (const feedback of PRESSABLE_FEEDBACKS) {
|
|
49
|
+
const { opacity, scale } = PRESSABLE_FEEDBACK[feedback];
|
|
50
|
+
expect(opacity).toBeGreaterThan(0);
|
|
51
|
+
expect(opacity).toBeLessThanOrEqual(1);
|
|
52
|
+
expect(scale).toBeGreaterThan(0);
|
|
53
|
+
expect(scale).toBeLessThanOrEqual(1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("resolvePressedState", () => {
|
|
59
|
+
test("maps each named feedback to its pair", () => {
|
|
60
|
+
for (const feedback of PRESSABLE_FEEDBACKS) {
|
|
61
|
+
expect(resolvePressedState(feedback, undefined, undefined)).toEqual(PRESSABLE_FEEDBACK[feedback]);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Naming the fallback would either change what a bare Pressable has always
|
|
66
|
+
// done or make `scale-fade` fade less than `fade`. It stays unnamed instead.
|
|
67
|
+
test("falls back to the pair a bare pressable has always used", () => {
|
|
68
|
+
expect(resolvePressedState(undefined, undefined, undefined)).toEqual(PRESSABLE_FEEDBACK_FALLBACK);
|
|
69
|
+
expect(PRESSABLE_FEEDBACK_FALLBACK).toEqual({ opacity: 0.9, scale: 0.97 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("an explicit value beats the fallback on its own axis", () => {
|
|
73
|
+
expect(resolvePressedState(undefined, 1, undefined)).toEqual({
|
|
74
|
+
opacity: PRESSABLE_FEEDBACK_FALLBACK.opacity,
|
|
75
|
+
scale: 1,
|
|
76
|
+
});
|
|
77
|
+
expect(resolvePressedState(undefined, undefined, 1)).toEqual({
|
|
78
|
+
opacity: 1,
|
|
79
|
+
scale: PRESSABLE_FEEDBACK_FALLBACK.scale,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("an explicit value beats a named feedback on its own axis", () => {
|
|
84
|
+
expect(resolvePressedState("scale-fade", undefined, 0.2)).toEqual({
|
|
85
|
+
opacity: 0.2,
|
|
86
|
+
scale: PRESSABLE_FEEDBACK["scale-fade"].scale,
|
|
87
|
+
});
|
|
88
|
+
expect(resolvePressedState("none", 0.5, undefined)).toEqual({ opacity: 1, scale: 0.5 });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("both explicit values leave nothing of the named feedback", () => {
|
|
92
|
+
expect(resolvePressedState("scale", 0.5, 0.5)).toEqual({ opacity: 0.5, scale: 0.5 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 0 is a legitimate value on either axis and must not read as "unset".
|
|
96
|
+
test("treats an explicit zero as a value, not an absence", () => {
|
|
97
|
+
expect(resolvePressedState("scale", 0, 0)).toEqual({ opacity: 0, scale: 0 });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const PRESSABLE_FEEDBACKS = ["scale", "fade", "scale-fade", "none"] as const;
|
|
2
|
+
|
|
3
|
+
export type PressableFeedback = (typeof PRESSABLE_FEEDBACKS)[number];
|
|
4
|
+
|
|
5
|
+
/** What a press interpolates towards. 1 is the neutral value on either axis. */
|
|
6
|
+
export type PressedState = {
|
|
7
|
+
opacity: number;
|
|
8
|
+
scale: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How a press moves each axis, by name.
|
|
13
|
+
*
|
|
14
|
+
* `scale-fade` is composed from the two single-axis modes rather than given
|
|
15
|
+
* numbers of its own, so tuning `scale` or `fade` carries through to it and the
|
|
16
|
+
* name keeps describing what the mode does.
|
|
17
|
+
*
|
|
18
|
+
* There is no mode involving a ripple, ink or highlight overlay — AGENTS.md
|
|
19
|
+
* rules those out for every pressable in this library.
|
|
20
|
+
*
|
|
21
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
22
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
23
|
+
*/
|
|
24
|
+
export const PRESSABLE_FEEDBACK: Record<PressableFeedback, PressedState> = {
|
|
25
|
+
scale: { opacity: 1, scale: 0.97 },
|
|
26
|
+
fade: { opacity: 0.6, scale: 1 },
|
|
27
|
+
"scale-fade": { opacity: 0.6, scale: 0.97 },
|
|
28
|
+
none: { opacity: 1, scale: 1 },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The pair a pressable uses with no `feedback` and no explicit values.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately not one of the named modes. It fades less than `fade` does, so
|
|
35
|
+
* naming it would either change what a bare `Pressable` has always done or
|
|
36
|
+
* force `scale-fade` to fade less than `fade` — and a caller who wants a named
|
|
37
|
+
* mode can just say so.
|
|
38
|
+
*/
|
|
39
|
+
export const PRESSABLE_FEEDBACK_FALLBACK: PressedState = { opacity: 0.9, scale: 0.97 };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Folds a named feedback and any explicit values into the pair a press
|
|
43
|
+
* interpolates towards.
|
|
44
|
+
*
|
|
45
|
+
* An explicit `pressedScale` / `pressedOpacity` wins on its own axis and leaves
|
|
46
|
+
* the other one to the named mode, so `feedback="scale-fade"` with
|
|
47
|
+
* `pressedOpacity={0.2}` is a deeper fade at the mode's scale rather than an
|
|
48
|
+
* all-or-nothing choice between the two APIs.
|
|
49
|
+
*
|
|
50
|
+
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
51
|
+
*/
|
|
52
|
+
export function resolvePressedState(
|
|
53
|
+
feedback: PressableFeedback | undefined,
|
|
54
|
+
pressedScale: number | undefined,
|
|
55
|
+
pressedOpacity: number | undefined
|
|
56
|
+
): PressedState {
|
|
57
|
+
const named = feedback ? PRESSABLE_FEEDBACK[feedback] : PRESSABLE_FEEDBACK_FALLBACK;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
opacity: pressedOpacity ?? named.opacity,
|
|
61
|
+
scale: pressedScale ?? named.scale,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# DelacourProvider
|
|
2
|
+
|
|
3
|
+
Every provider an app needs at its root, in one component. Mount it once, around
|
|
4
|
+
everything — a root layout, an `App.tsx`.
|
|
5
|
+
|
|
6
|
+
`import { DelacourProvider } from "@delacour/react-native-ui/provider";`
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | What it holds |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `index.ts` | → `@delacour/react-native-ui/provider` |
|
|
13
|
+
| `provider.tsx` | `DelacourProvider` — the app's root layer stack; its `displayName` is `DelacourUI.Provider`, because prefix-plus-symbol would stutter and this matches its export subpath |
|
|
14
|
+
|
|
15
|
+
## Design
|
|
16
|
+
|
|
17
|
+
- **Five layers, outermost first**: `GestureHandlerRootView` →
|
|
18
|
+
`SafeAreaProvider` → `KeyboardProvider` → `<KeyboardStateSync />` beside the
|
|
19
|
+
children → `BottomSheetModalProvider` around them. The order is not stylistic.
|
|
20
|
+
The gesture root has to be an ancestor native view of every handler a
|
|
21
|
+
`Pressable` creates, and its absence is *silent* — no error, no warning,
|
|
22
|
+
presses simply stop landing. `KeyboardStateSync` has to be a CHILD of
|
|
23
|
+
`KeyboardProvider`, because it calls `useKeyboardContext()`, and it stays a
|
|
24
|
+
SIBLING of the modal provider rather than moving inside it: the repair is
|
|
25
|
+
global and has to outlive any layer that can remount.
|
|
26
|
+
- **`initialMetrics` defaults to `initialWindowMetrics`, and that is
|
|
27
|
+
load-bearing.** `SafeAreaProvider` renders `null` — not unstyled children,
|
|
28
|
+
*nothing* — until its native view reports the first `onInsetsChange`, so
|
|
29
|
+
without the seed every cold start shows a blank frame. The seed is a snapshot
|
|
30
|
+
taken at native module init, so it is stale when the app launches into a
|
|
31
|
+
rotated or split-screen window — stale for exactly one commit, because the
|
|
32
|
+
native measurement overwrites it. A blank frame on every launch is the worse
|
|
33
|
+
trade. `initialMetrics={null}` opts out: a default parameter only fires on
|
|
34
|
+
`undefined`, so `null` is a value rather than an absence, the same rule
|
|
35
|
+
`pressedScale` follows.
|
|
36
|
+
- **`style` reaches the gesture root and carries no default.**
|
|
37
|
+
`GestureHandlerRootView` applies its own `{ flex: 1 }` whenever `style` is
|
|
38
|
+
undefined, so merging one in here would both duplicate it and make the prop
|
|
39
|
+
behave differently than it does upstream. Pass a style and that `flex: 1` is
|
|
40
|
+
gone — include it yourself.
|
|
41
|
+
- **No per-layer escape hatches, and no layer-named props.** No
|
|
42
|
+
`gestureHandler={false}`, no `safeAreaProps`, no `keyboardProps`. A boolean
|
|
43
|
+
that turns off the gesture root has "nothing responds to a press" as its
|
|
44
|
+
failure mode, which is the least debuggable outcome in the package. And a prop
|
|
45
|
+
surface that names the layers changes shape every time a layer is added:
|
|
46
|
+
`children`, `style` and `initialMetrics` say nothing about what is inside, so a
|
|
47
|
+
future `BottomSheetModalProvider` or portal host is an edit to one file rather
|
|
48
|
+
than a breaking change. An app that genuinely needs a different stack composes
|
|
49
|
+
the providers by hand — they are all public from their own packages, and this
|
|
50
|
+
is a convenience, not a gate.
|
|
51
|
+
- **A new layer goes innermost.** Anything that draws above the app — a
|
|
52
|
+
bottom-sheet modal provider, a portal host, a toast host — has to sit inside
|
|
53
|
+
every provider it reads, so it wraps `{children}` and nothing else moves. A
|
|
54
|
+
layer that brings a new native peer is a peer-dependency decision first.
|
|
55
|
+
`BottomSheetModalProvider` is the case this rule was written for and now the
|
|
56
|
+
case it governs: it reads the gesture root for its pan, the safe area for its
|
|
57
|
+
insets and the keyboard values a sheet's footer rides, so it is last.
|
|
58
|
+
- **`@gorhom/bottom-sheet` is a required peer because of this component**, on
|
|
59
|
+
exactly the argument `react-native-keyboard-controller` already carries. It was
|
|
60
|
+
optional while nothing imported it. The recommended root now does, so every app
|
|
61
|
+
resolves it, and a flag saying otherwise would only suppress the install
|
|
62
|
+
warning that explains the Metro resolution error coming out of the app's root
|
|
63
|
+
layout. It is pure JavaScript over Reanimated and Gesture Handler — both
|
|
64
|
+
required peers already — so this costs an install and no native build. Rule 3's
|
|
65
|
+
promise survives intact: `/button` still pulls nothing sheet-related.
|
|
66
|
+
- **Deliberately not idempotent.** It does not detect an enclosing copy of
|
|
67
|
+
itself. Nesting `GestureHandlerRootView` costs a `View`; nesting
|
|
68
|
+
`SafeAreaProvider` seeds from the parent's insets and costs a native view;
|
|
69
|
+
nesting `KeyboardProvider` is the one that actually breaks — two pairs of
|
|
70
|
+
shared values, two sets of native observers, and the outer `KeyboardStateSync`
|
|
71
|
+
repairing values nobody reads. That is also the only layer that cannot be
|
|
72
|
+
detected: `useKeyboardContext()` returns a module-private default object
|
|
73
|
+
outside a provider, that object is not exported, and the hook `console.warn`s
|
|
74
|
+
whenever it hands one back, so a detection read would print a warning in every
|
|
75
|
+
correctly-mounted app. A guard covering the two harmless layers and missing the
|
|
76
|
+
harmful one is worse than none — it teaches callers that nesting is fine.
|
|
77
|
+
- **`react-native-keyboard-controller` is a required peer because of this
|
|
78
|
+
component.** It was optional while [`Screen`](../screen/AGENTS.md) was the only importer: an app that
|
|
79
|
+
never imported `Screen` never resolved it. The recommended root now imports it,
|
|
80
|
+
so every app resolves it, and the flag had stopped describing reality — all it
|
|
81
|
+
still did was suppress the install warning that would have explained the Metro
|
|
82
|
+
resolution error coming out of the app's root layout. Rule 3's promise is
|
|
83
|
+
per-subpath and survives intact: `/button` still pulls nothing
|
|
84
|
+
keyboard-related.
|
|
85
|
+
- **Nothing here for `bun test`, and no `provider.variants.ts` to give it
|
|
86
|
+
some.** The component is four nested elements and one default parameter;
|
|
87
|
+
extracting a `resolveInitialMetrics()` would be a unit test of `??`. The rule
|
|
88
|
+
that pure decisions live in a `*.variants.ts` has no decision here to
|
|
89
|
+
relocate.
|
|
90
|
+
- **No gallery route.** See [Adding a component](../../../AGENTS.md#adding-a-component).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DelacourProvider, type DelacourProviderProps } from "./provider";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
|
+
import type { StyleProp, ViewStyle } from "react-native";
|
|
4
|
+
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
5
|
+
import { KeyboardProvider } from "react-native-keyboard-controller";
|
|
6
|
+
import { initialWindowMetrics, type Metrics, SafeAreaProvider } from "react-native-safe-area-context";
|
|
7
|
+
import { KeyboardStateSync } from "../../hooks/use-keyboard-state-sync";
|
|
8
|
+
|
|
9
|
+
export type DelacourProviderProps = {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/**
|
|
12
|
+
* Safe-area insets and frame to render the first frame against, before the
|
|
13
|
+
* native provider has measured anything.
|
|
14
|
+
*
|
|
15
|
+
* Defaults to `initialWindowMetrics`, the snapshot the native module captured
|
|
16
|
+
* at launch, because `SafeAreaProvider` renders NOTHING — not unstyled
|
|
17
|
+
* children, `null` — until its first `onInsetsChange` lands. Without a seed
|
|
18
|
+
* every cold start shows a blank frame.
|
|
19
|
+
*
|
|
20
|
+
* Pass `null` to opt out. A default parameter only fires on `undefined`, so
|
|
21
|
+
* `null` is a value here rather than an absence.
|
|
22
|
+
*/
|
|
23
|
+
initialMetrics?: Metrics | null;
|
|
24
|
+
/**
|
|
25
|
+
* Style for the outermost `GestureHandlerRootView`.
|
|
26
|
+
*
|
|
27
|
+
* Forwarded untouched, with no default merged in: the gesture root applies
|
|
28
|
+
* its own `{ flex: 1 }` whenever `style` is undefined. Pass one and that
|
|
29
|
+
* default is gone, so include `flex: 1` unless the root genuinely should not
|
|
30
|
+
* fill the window.
|
|
31
|
+
*/
|
|
32
|
+
style?: StyleProp<ViewStyle>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every provider this library needs at an app's root, in one component.
|
|
37
|
+
*
|
|
38
|
+
* Mount it ONCE, around everything — a root layout, an `App.tsx`. It is not
|
|
39
|
+
* idempotent and does not detect an enclosing copy of itself; see AGENTS.md.
|
|
40
|
+
*
|
|
41
|
+
* Five layers, outermost first, and the order is not stylistic:
|
|
42
|
+
*
|
|
43
|
+
* 1. `GestureHandlerRootView` — an ancestor native view every gesture handler
|
|
44
|
+
* `Pressable` creates has to attach to. Its absence is silent: no error, no
|
|
45
|
+
* warning, presses simply stop landing.
|
|
46
|
+
* 2. `SafeAreaProvider` — the insets `Screen`'s navbar, footer and scroll
|
|
47
|
+
* reserves are all computed from, seeded so the first frame is not blank.
|
|
48
|
+
* 3. `KeyboardProvider` — the shared animation values `Screen.Footer` rides.
|
|
49
|
+
* 4. `KeyboardStateSync` — a child of the keyboard provider, because it calls
|
|
50
|
+
* `useKeyboardContext()`. It repairs the one pair of animation values that
|
|
51
|
+
* provider shares with the whole app: on iOS they are written only from the
|
|
52
|
+
* `will` events, so a keyboard that vanishes without one — an interactive
|
|
53
|
+
* dismiss interrupted by navigation, a stack pop, an app suspend — leaves
|
|
54
|
+
* every screen in the app believing it is still open.
|
|
55
|
+
* 5. `BottomSheetModalProvider` — the host every `BottomSheet` portals into. It
|
|
56
|
+
* goes innermost because it draws ABOVE the app and reads every layer over
|
|
57
|
+
* it: the gesture root for the pan, the safe area for its insets, and the
|
|
58
|
+
* keyboard values a sheet's footer rides. It wraps `{children}` and nothing
|
|
59
|
+
* else moves, which is what a new layer here always looks like.
|
|
60
|
+
*
|
|
61
|
+
* `KeyboardStateSync` stays a SIBLING of it rather than a child. The repair is
|
|
62
|
+
* global and has to run for the whole app's lifetime; inside a layer that can
|
|
63
|
+
* remount it would be torn down with it.
|
|
64
|
+
*
|
|
65
|
+
* There are no per-layer escape hatches and no layer-named props on purpose.
|
|
66
|
+
* An app that needs a different stack composes the providers by hand; they are
|
|
67
|
+
* all public from their own packages, and this is a convenience, not a gate.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* // expo-router root layout. The css import must stay the first statement.
|
|
71
|
+
* import "../styles/global.css";
|
|
72
|
+
* import { DelacourProvider } from "@delacour/react-native-ui/provider";
|
|
73
|
+
* import { Stack } from "expo-router";
|
|
74
|
+
*
|
|
75
|
+
* export default function RootLayout() {
|
|
76
|
+
* return (
|
|
77
|
+
* <DelacourProvider>
|
|
78
|
+
* <Stack screenOptions={{ headerShown: false }} />
|
|
79
|
+
* </DelacourProvider>
|
|
80
|
+
* );
|
|
81
|
+
* }
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* // Measure the safe area from scratch, accepting the blank first frame — an
|
|
85
|
+
* // app that launches into a rotated or split-screen window and cannot
|
|
86
|
+
* // tolerate one stale frame.
|
|
87
|
+
* <DelacourProvider initialMetrics={null}>{children}</DelacourProvider>
|
|
88
|
+
*/
|
|
89
|
+
export function DelacourProvider({
|
|
90
|
+
children,
|
|
91
|
+
initialMetrics = initialWindowMetrics,
|
|
92
|
+
style,
|
|
93
|
+
}: DelacourProviderProps): ReactElement {
|
|
94
|
+
return (
|
|
95
|
+
<GestureHandlerRootView style={style}>
|
|
96
|
+
<SafeAreaProvider initialMetrics={initialMetrics}>
|
|
97
|
+
<KeyboardProvider>
|
|
98
|
+
<KeyboardStateSync />
|
|
99
|
+
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
|
100
|
+
</KeyboardProvider>
|
|
101
|
+
</SafeAreaProvider>
|
|
102
|
+
</GestureHandlerRootView>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
DelacourProvider.displayName = "DelacourUI.Provider";
|