@gryt/ui-native 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,7 +69,6 @@ goal, so the honest list is worth more than a claim of parity that isn't one.
69
69
  | `Button` | Press scale is kept, hover scale is dropped | The web scales to `0.96` on press and `1.03` on hover. Press maps directly onto `onPressIn`/`onPressOut`, so it is here, spring-animated and skipped when reduce-motion is on. |
70
70
  | `Button` | `hasPopup` is a prop, not inferred | The web reads `aria-haspopup`, which Base UI sets on a trigger, and skips the scale so the trigger does not drag its own popup sideways while it is being measured. React Native has no such attribute, so a menu trigger has to say so. |
71
71
  | `TextField` | Focus changes the border colour instead of drawing an outline | The web's `outline` sits outside the box and shifts nothing. React Native has no outline, and a second ring would move the layout on focus. |
72
- | `Progress` | Indeterminate renders as an empty track | The web sweeps a partial bar with a CSS keyframe. Doing it here needs an `Animated` loop plus a reduce-motion check, which deserves its own pass rather than a footnote. |
73
72
  | `Tooltip` | Opens on long press, closes on release, instead of appearing on hover after a delay | A phone has no hover. This is the same name on a different interaction, and it is the component where 1:1 is least achievable. An interface that needs tooltips to be usable will not survive the port. |
74
73
  | *(positioned overlays)* | The popup does not follow a trigger that moves | Floating UI keeps watching the reference element. React Native measures on demand and reports nothing afterwards, so the position is taken once, when the popup opens. If a list scrolls underneath an open menu, the menu stays put. |
75
74
  | `Popover` | `Arrow` renders nothing | Base UI positions an arrow against the popup's edge once Floating UI has settled. Here it means a rotated square, a border on two sides only, and knowing which side the popup landed on. Doable, not free, and not done. |
@@ -89,6 +88,15 @@ goal, so the honest list is worth more than a claim of parity that isn't one.
89
88
  | `Accordion` | Two glyphs instead of a rotating chevron | Same reasoning as the checkbox tick: rotating one needs a transform and a measurement, and there is no icon set here yet. |
90
89
  | `Skeleton` | Does not pulse | A loop running for the length of a request costs battery, and it is the sort of motion reduce-motion users switch off first. A flat block still reads as loading. |
91
90
 
91
+ ## Additions
92
+
93
+ The other direction, and a shorter list. Something here that the web does not
94
+ have needs the same justification as something missing.
95
+
96
+ | Component | Why there is no web counterpart |
97
+ |---|---|
98
+ | `Sheet` | A sheet is what a phone does where the web opens a dialog or slides a drawer, and the two are not the same interaction: it is dragged, it settles at heights the user chooses, and dismissing it is a flick rather than a click on an X. Shipping the web's Dialog on a phone would be 1:1 and wrong; inventing a sheet on the web would be worse. |
99
+
92
100
  ## Not Node-importable
93
101
 
94
102
  The build is unbundled `tsc` output, which is what Metro wants: it resolves
@@ -21,13 +21,6 @@ export interface DrawerPopupProps {
21
21
  dismissible?: boolean;
22
22
  style?: StyleProp<ViewStyle>;
23
23
  }
24
- /**
25
- * Slides in from an edge, which is the one place an animation is not decoration.
26
- *
27
- * A drawer that appears without moving reads as a screen change rather than a
28
- * panel, so this one animates even though Collapsible and Skeleton do not.
29
- * Reduce-motion still turns it off: the drawer arrives in place instead.
30
- */
31
24
  declare function Popup({ children, side, size, dismissible, style }: DrawerPopupProps): import("react").JSX.Element;
32
25
  declare function Close({ children, style }: {
33
26
  children?: ReactNode;
@@ -1 +1 @@
1
- {"version":3,"file":"Drawer.d.ts","sourceRoot":"","sources":["../../../src/components/Drawer/Drawer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAStB,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAG/E,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAgBrD,MAAM,WAAW,eAAgB,SAAQ,cAAc;IACrD,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED,iBAAS,IAAI,CAAC,EAAE,QAAQ,EAAE,GAAG,SAAS,EAAE,EAAE,eAAe,+BAKxD;AAED,iBAAS,OAAO,CAAC,EACf,QAAQ,EACR,KAAK,EACN,EAAE;IACD,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,+BAWA;AAED,iBAAS,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,CAAC,EAAE,SAAS,CAAA;CAAE,+BAErD;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,iBAAS,KAAK,CAAC,EACb,QAAQ,EACR,IAAa,EACb,IAAU,EACV,WAAkB,EAClB,KAAK,EACN,EAAE,gBAAgB,+BAqGlB;AAED,iBAAS,KAAK,CAAC,EACb,QAAQ,EACR,KAAK,EACN,EAAE;IACD,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,+BAWA;AAED,eAAO,MAAM,MAAM;;;;;;CAA0C,CAAC"}
1
+ {"version":3,"file":"Drawer.d.ts","sourceRoot":"","sources":["../../../src/components/Drawer/Drawer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,SAAS,EAA2C,MAAM,OAAO,CAAC;AAC/F,OAAO,EAKL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAWtB,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAG/E,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAgBrD,MAAM,WAAW,eAAgB,SAAQ,cAAc;IACrD,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED,iBAAS,IAAI,CAAC,EAAE,QAAQ,EAAE,GAAG,SAAS,EAAE,EAAE,eAAe,+BAKxD;AAED,iBAAS,OAAO,CAAC,EACf,QAAQ,EACR,KAAK,EACN,EAAE;IACD,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,+BAWA;AAED,iBAAS,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,CAAC,EAAE,SAAS,CAAA;CAAE,+BAErD;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAgBD,iBAAS,KAAK,CAAC,EACb,QAAQ,EACR,IAAa,EACb,IAAU,EACV,WAAkB,EAClB,KAAK,EACN,EAAE,gBAAgB,+BAiSlB;AAED,iBAAS,KAAK,CAAC,EACb,QAAQ,EACR,KAAK,EACN,EAAE;IACD,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,+BAWA;AAED,eAAO,MAAM,MAAM;;;;;;CAA0C,CAAC"}
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { createContext, useContext, useEffect } from "react";
3
- import { Dimensions, Modal, Pressable } from "react-native";
4
- import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
2
+ import { createContext, useContext, useEffect, useRef, useState } from "react";
3
+ import { Dimensions, PanResponder, Modal, Pressable } from "react-native";
4
+ import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
5
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
5
6
  import { useReducedMotion } from "../../hooks/useReducedMotion.js";
6
7
  import { grytDrawerBleed } from "@gryt/theme";
7
- import { durations, springy } from "../../motion/index.js";
8
+ import { durations, travel as travelTo } from "../../motion/index.js";
8
9
  import { useOpenState } from "../../overlay/useOpenState.js";
9
10
  import { useTheme } from "../../theme/index.js";
10
11
  const DrawerContext = createContext(null);
@@ -32,9 +33,28 @@ function Portal({ children }) {
32
33
  * panel, so this one animates even though Collapsible and Skeleton do not.
33
34
  * Reduce-motion still turns it off: the drawer arrives in place instead.
34
35
  */
36
+ /**
37
+ * The scrim takes an animated opacity, so it has to be an animated component.
38
+ * Declared once at module scope — `createAnimatedComponent` inside a render
39
+ * makes a new component type every time, which remounts the subtree.
40
+ */
41
+ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
35
42
  function Popup({ children, side = "left", size = 0.8, dismissible = true, style }) {
36
43
  const { open, setOpen } = useDrawer("Popup");
37
44
  const theme = useTheme();
45
+ /**
46
+ * The phone's own furniture, which a drawer has to stay clear of.
47
+ *
48
+ * A panel from the side is full height by definition, so its first row sits
49
+ * under the Dynamic Island and its last under the home indicator unless it
50
+ * says otherwise. A panel from the bottom only has the second problem.
51
+ *
52
+ * This is the same call GRYT-402 made for `Sheet`, and it belongs here for
53
+ * the same reason: every caller would otherwise write the same two lines, and
54
+ * the one that forgets ships a drawer with its heading under the clock. That
55
+ * is how the mobile shell's server switcher first looked.
56
+ */
57
+ const insets = useSafeAreaInsets();
38
58
  const reducedMotion = useReducedMotion();
39
59
  const screen = Dimensions.get("window");
40
60
  const vertical = side === "bottom";
@@ -60,40 +80,191 @@ function Popup({ children, side = "left", size = 0.8, dismissible = true, style
60
80
  // Measured against the bleed panel, so the panel's *visible* edge lands
61
81
  // exactly off-screen rather than the overhang doing it.
62
82
  const hidden = side === "right" ? panelExtent : side === "left" ? -panelExtent : panelExtent;
63
- // 700ms, the web's `--gryt-dur-spring-soft`, on the overshooting curve —
64
- // which is what the web uses here despite the panel travelling its own
65
- // width. Matching it rather than substituting the tight curve, because the
66
- // brief is 1:1 with the web and not a second opinion about it.
83
+ /**
84
+ * 700ms on the *tight* curve, and this changed on both platforms at once.
85
+ *
86
+ * It used to be the overshooting one, matching the web, with a note saying
87
+ * that a panel travelling its own width is exactly the case
88
+ * `--ease-spring-tight` was added for — and that if it ever felt wrong on a
89
+ * device, the web was what was wrong and both should change together.
90
+ *
91
+ * It felt wrong on a device. A full-height panel arriving with a 12%
92
+ * overshoot reads as aggressive rather than lively, because the overshoot is
93
+ * a percentage of the travel and the travel here is most of the screen. The
94
+ * same 12% on a 20px switch thumb is texture; on a 320pt panel it is a slam.
95
+ *
96
+ * `@gryt/ui`'s Drawer moved to `ease-spring-tight` in the same change.
97
+ */
98
+ /**
99
+ * Mounted for longer than it is open, so the panel can animate out.
100
+ *
101
+ * React Native's `Modal` unmounts the moment `visible` goes false, and the
102
+ * close was also snapping `progress` straight to 0 rather than animating it
103
+ * — so a dismissed drawer did not slide away, it simply stopped existing.
104
+ * Both halves had to change: animate the close, and stay mounted until that
105
+ * animation has finished.
106
+ */
107
+ const [mounted, setMounted] = useState(open);
67
108
  useEffect(() => {
109
+ if (open)
110
+ setMounted(true);
111
+ }, [open]);
112
+ useEffect(() => {
113
+ if (!mounted)
114
+ return;
115
+ if (reducedMotion) {
116
+ // eslint-disable-next-line react-hooks/immutability
117
+ progress.value = open ? 1 : 0;
118
+ if (!open)
119
+ setMounted(false);
120
+ return;
121
+ }
68
122
  // eslint-disable-next-line react-hooks/immutability
69
- progress.value =
70
- !open || reducedMotion
71
- ? open
72
- ? 1
73
- : 0
74
- : springy(1, { duration: durations.springSoft });
75
- }, [open, progress, reducedMotion]);
76
- const panelStyle = useAnimatedStyle(() => ({
77
- transform: [
78
- vertical
79
- ? { translateY: hidden + (0 - hidden) * progress.value }
80
- : { translateX: hidden + (0 - hidden) * progress.value }
81
- ],
82
- opacity: progress.value
123
+ progress.value = travelTo(open ? 1 : 0, { duration: durations.springSoft }, (finished) => {
124
+ "worklet";
125
+ // Unmount only once the panel is actually gone, and only if the
126
+ // animation ran to the end — an interrupted close means it was
127
+ // reopened, and unmounting then would take the drawer away as it
128
+ // arrives.
129
+ if (finished && !open)
130
+ runOnJS(setMounted)(false);
131
+ });
132
+ }, [open, mounted, progress, reducedMotion]);
133
+ /**
134
+ * How far the finger has dragged the panel away from open, in points.
135
+ *
136
+ * Separate from `progress` rather than folded into it. `progress` is
137
+ * animated on the spring, and a drag has to track the finger exactly —
138
+ * feeding a gesture through an overshooting curve makes the panel lead and
139
+ * lag the thumb, which reads as the drawer being slippery.
140
+ */
141
+ const drag = useSharedValue(0);
142
+ const panelStyle = useAnimatedStyle(() => {
143
+ const travel = hidden + (0 - hidden) * progress.value + drag.value;
144
+ return {
145
+ // Transform only. The panel slides; it does not fade. This used to carry
146
+ // `opacity: progress.value`, which the web's Popup does not — it declares
147
+ // `transition-transform` and nothing else — and a panel that fades as it
148
+ // arrives reads as a dialog rather than a drawer.
149
+ transform: [vertical ? { translateY: travel } : { translateX: travel }]
150
+ };
151
+ });
152
+ /**
153
+ * The scrim thins as the panel is dragged away, and does not fade in or out.
154
+ *
155
+ * A drawer opens by sliding and nothing about it fades — that is the whole
156
+ * distinction from a dialog, and a scrim that fades up alongside the panel
157
+ * puts a second, slower animation on top of the one that matters. It is up
158
+ * when the drawer is up.
159
+ *
160
+ * The drag term stays, and it is a different thing: pushing the panel off
161
+ * lightens the scrim in proportion, so a half-gone drawer does not sit under
162
+ * a full-strength one. The web's own rule, in its own words.
163
+ *
164
+ * The Modal unmounts once the panel has finished travelling, which is what
165
+ * takes the scrim with it. Earlier this was a flat colour that vanished with
166
+ * the Modal *before* the panel had moved, so a drawer animating out sat under
167
+ * a full-strength scrim for 700ms and then blinked off — that was the bug,
168
+ * and keeping the scrim up for exactly as long as the panel is on screen is
169
+ * the fix rather than fading it.
170
+ */
171
+ const scrimStyle = useAnimatedStyle(() => {
172
+ const dragged = extent > 0 ? Math.min(1, Math.abs(drag.value) / extent) : 0;
173
+ return { opacity: 1 - dragged };
174
+ });
175
+ /**
176
+ * Swipe to dismiss, which the web has had from Base UI's drawer primitive
177
+ * and this did not have at all (GRYT-395). A drawer you cannot push back is
178
+ * the thing a drawer is for.
179
+ *
180
+ * `PanResponder` rather than react-native-gesture-handler: the Slider proved
181
+ * this path works from this package's prebuilt output today, and an attempt
182
+ * at gesture-handler from here failed for reasons still not understood
183
+ * (GRYT-393). This is not the place to retry that.
184
+ */
185
+ const gesture = useRef({ extent, side, vertical, dismissible, setOpen });
186
+ useEffect(() => {
187
+ gesture.current = { extent, side, vertical, dismissible, setOpen };
188
+ }, [extent, side, vertical, dismissible, setOpen]);
189
+ /* eslint-disable react-hooks/refs */
190
+ const [responder] = useState(() => PanResponder.create({
191
+ // Claimed on move, not on start. Claiming on start would swallow every
192
+ // tap on the panel's own content — buttons inside a drawer would stop
193
+ // working, which is a far worse bug than not being able to swipe.
194
+ onStartShouldSetPanResponder: () => false,
195
+ onMoveShouldSetPanResponder: (_e, g) => {
196
+ if (!gesture.current.dismissible)
197
+ return false;
198
+ // Only once the drag is clearly along the axis the panel closes on,
199
+ // and clearly a drag rather than a slow press. Anything else belongs
200
+ // to whatever is inside.
201
+ const along = gesture.current.vertical ? g.dy : g.dx;
202
+ const across = gesture.current.vertical ? g.dx : g.dy;
203
+ return Math.abs(along) > 8 && Math.abs(along) > Math.abs(across);
204
+ },
205
+ // Once it is ours it stays ours, for the same reason the Slider needs
206
+ // this: the default is to grant, and a ScrollView inside the drawer
207
+ // would take the drag back mid-swipe.
208
+ onPanResponderTerminationRequest: () => false,
209
+ onPanResponderMove: (_e, g) => {
210
+ const { vertical: v, side: sd } = gesture.current;
211
+ const raw = v ? g.dy : g.dx;
212
+ // Clamped to the closing direction. Dragging a left drawer rightwards
213
+ // should do nothing, not tear it off its edge.
214
+ const closing = sd === "left" ? Math.min(0, raw) : Math.max(0, raw);
215
+ // eslint-disable-next-line react-hooks/immutability
216
+ drag.value = closing;
217
+ },
218
+ onPanResponderRelease: (_e, g) => {
219
+ const { vertical: v, side: sd, extent: ex } = gesture.current;
220
+ const moved = Math.abs(v ? g.dy : g.dx);
221
+ const speed = Math.abs(v ? g.vy : g.vx);
222
+ // Half the panel, or a flick. The velocity term is what makes a short
223
+ // sharp swipe work — without it you have to drag the whole way, which
224
+ // is the difference between a drawer and a slow puzzle.
225
+ const dismiss = moved > ex / 2 || speed > 0.5;
226
+ if (dismiss) {
227
+ gesture.current.setOpen(false);
228
+ // Left where it is: the close animation runs from here, and resetting
229
+ // it first would snap the panel back before it left.
230
+ return;
231
+ }
232
+ // eslint-disable-next-line react-hooks/immutability
233
+ drag.value = travelTo(0, { duration: durations.springSoft });
234
+ },
235
+ onPanResponderTerminate: () => {
236
+ // eslint-disable-next-line react-hooks/immutability
237
+ drag.value = travelTo(0, { duration: durations.springSoft });
238
+ }
83
239
  }));
84
- return (_jsx(Modal, { visible: open, transparent: true, animationType: "none", onRequestClose: dismissible ? () => setOpen(false) : undefined, children: _jsx(Pressable, { onPress: dismissible ? () => setOpen(false) : undefined, style: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)" }, children: _jsx(Animated.View, { accessibilityViewIsModal: true, style: [
240
+ /* eslint-enable react-hooks/refs */
241
+ // Cleared once the panel is closed, so the next open does not start from
242
+ // wherever the last swipe left it.
243
+ useEffect(() => {
244
+ if (!open) {
245
+ // eslint-disable-next-line react-hooks/immutability
246
+ drag.value = 0;
247
+ }
248
+ }, [open, drag]);
249
+ return (_jsx(Modal, { visible: mounted, transparent: true, animationType: "none", onRequestClose: dismissible ? () => setOpen(false) : undefined, children: _jsx(AnimatedPressable, { onPress: dismissible ? () => setOpen(false) : undefined, style: [{ flex: 1, backgroundColor: "rgba(0,0,0,0.6)" }, scrimStyle], children: _jsx(Animated.View, { accessibilityViewIsModal: true, ...responder.panHandlers, style: [
85
250
  {
86
251
  position: "absolute",
252
+ // The overhang hangs *off* the edge the panel comes from, which
253
+ // is the only direction that helps. It used to be added to the
254
+ // width instead, so the panel grew inwards — 64pt more of the
255
+ // screen covered, and the seam it was meant to hide still there,
256
+ // because the gap opens on the entering edge and that is the edge
257
+ // the extra material was not on.
87
258
  top: side === "bottom" ? undefined : 0,
88
- bottom: 0,
89
- left: side === "right" ? undefined : 0,
90
- right: side === "left" ? undefined : 0,
259
+ bottom: vertical ? -bleed : 0,
260
+ left: side === "right" ? undefined : side === "left" ? -bleed : 0,
261
+ right: side === "left" ? undefined : side === "right" ? -bleed : 0,
91
262
  width: vertical ? "100%" : panelExtent,
92
263
  height: vertical ? panelExtent : "100%",
93
- // The overhang, and the padding that puts the content back where
94
- // it would have been without it.
95
- paddingLeft: side === "right" ? bleed : 0,
96
- paddingRight: side === "left" ? bleed : 0,
264
+ // Puts the content back where it would have been without the
265
+ // overhang, so `size` still means what it says.
266
+ paddingLeft: side === "left" ? bleed : 0,
267
+ paddingRight: side === "right" ? bleed : 0,
97
268
  paddingBottom: vertical ? bleed : 0,
98
269
  backgroundColor: theme.color.surface,
99
270
  borderColor: theme.color.border,
@@ -104,7 +275,16 @@ function Popup({ children, side = "left", size = 0.8, dismissible = true, style
104
275
  borderTopRightRadius: vertical ? theme.radius.lg : 0
105
276
  },
106
277
  panelStyle
107
- ], children: _jsx(Pressable, { onPress: () => { }, style: [{ flex: 1 }, style], children: children }) }) }) }));
278
+ ], children: _jsx(Pressable, { onPress: () => { }, style: [
279
+ {
280
+ flex: 1,
281
+ // Before the caller's style, so a drawer that wants to run
282
+ // under the island — a full-bleed image, say — still can.
283
+ paddingTop: vertical ? 0 : insets.top,
284
+ paddingBottom: insets.bottom
285
+ },
286
+ style
287
+ ], children: children }) }) }) }));
108
288
  }
109
289
  function Close({ children, style }) {
110
290
  const { setOpen } = useDrawer("Close");
@@ -6,24 +6,18 @@ export interface ProgressProps {
6
6
  style?: StyleProp<ViewStyle>;
7
7
  }
8
8
  /**
9
- * Determinate only and so is the web, which is not what this used to say.
9
+ * Determinate travel follows the web deliberately: 300ms on ease-out rather
10
+ * than the spring. A progress bar is a reading, and the spring's 12% overshoot
11
+ * on a jump to 100% runs past the end of the track and comes back — which reads
12
+ * as the job finishing, unfinishing, then finishing.
10
13
  *
11
- * The previous note here claimed the web's indeterminate state was a CSS
12
- * keyframe sweeping a bar across the track, and listed this as a parity
13
- * exception. It is not one. `@gryt/ui`'s Progress passes `value={null}` to
14
- * Base UI and styles nothing for it: there is no `@keyframes` anywhere in the
15
- * package, no rule for `data-indeterminate`, and the compiled stylesheet has
16
- * zero rules matching `gryt-progress`. An indeterminate Progress renders an
17
- * empty track on the web too.
18
- *
19
- * So both platforms agree, and both are missing the feature. Implementing it
20
- * belongs on both at once — GRYT-382 — rather than here, where it would make
21
- * React Native the one that behaves differently.
22
- *
23
- * The travel that *is* implemented follows the web deliberately: 300ms on
24
- * ease-out rather than the spring. A progress bar is a reading, and the
25
- * spring's 12% overshoot on a jump to 100% runs past the end of the track and
26
- * comes back — which reads as the job finishing, unfinishing, then finishing.
14
+ * Indeterminate is a partial bar sweeping the track on a loop, at
15
+ * `grytDurations.sweep`, from the same description the web's keyframe follows.
16
+ * Both were empty until GRYT-382: the web passed `value={null}` to Base UI,
17
+ * which leaves the indicator's width unset, and nothing styled it. This file
18
+ * previously called that a parity exception, which it was not — both platforms
19
+ * were missing the same feature, and writing it down as one-sided is what
20
+ * closed the question for a while.
27
21
  */
28
22
  export declare function Progress({ value, tone, style }: ProgressProps): import("react").JSX.Element;
29
23
  //# sourceMappingURL=Progress.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Progress.d.ts","sourceRoot":"","sources":["../../../src/components/Progress/Progress.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAIpE,MAAM,WAAW,aAAa;IAC5B,0DAA0D;IAC1D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;IACnD,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAe,EAAE,KAAK,EAAE,EAAE,aAAa,+BA4BxE"}
1
+ {"version":3,"file":"Progress.d.ts","sourceRoot":"","sources":["../../../src/components/Progress/Progress.tsx"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAatB,MAAM,WAAW,aAAa;IAC5B,0DAA0D;IAC1D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;IACnD,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAKD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAe,EAAE,KAAK,EAAE,EAAE,aAAa,+BA0FxE"}
@@ -1,30 +1,62 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { View } from "react-native";
2
+ import { useEffect, useState } from "react";
3
+ import { View, } from "react-native";
4
+ import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from "react-native-reanimated";
5
+ import { grytDurations } from "@gryt/theme";
6
+ import { useReducedMotion } from "../../hooks/useReducedMotion.js";
3
7
  import { useTheme } from "../../theme/index.js";
8
+ /** How much of the track the sweeping bar covers. Matches the web's `width: 40%`. */
9
+ const SWEEP_FRACTION = 0.4;
4
10
  /**
5
- * Determinate only and so is the web, which is not what this used to say.
11
+ * Determinate travel follows the web deliberately: 300ms on ease-out rather
12
+ * than the spring. A progress bar is a reading, and the spring's 12% overshoot
13
+ * on a jump to 100% runs past the end of the track and comes back — which reads
14
+ * as the job finishing, unfinishing, then finishing.
6
15
  *
7
- * The previous note here claimed the web's indeterminate state was a CSS
8
- * keyframe sweeping a bar across the track, and listed this as a parity
9
- * exception. It is not one. `@gryt/ui`'s Progress passes `value={null}` to
10
- * Base UI and styles nothing for it: there is no `@keyframes` anywhere in the
11
- * package, no rule for `data-indeterminate`, and the compiled stylesheet has
12
- * zero rules matching `gryt-progress`. An indeterminate Progress renders an
13
- * empty track on the web too.
14
- *
15
- * So both platforms agree, and both are missing the feature. Implementing it
16
- * belongs on both at once — GRYT-382 — rather than here, where it would make
17
- * React Native the one that behaves differently.
18
- *
19
- * The travel that *is* implemented follows the web deliberately: 300ms on
20
- * ease-out rather than the spring. A progress bar is a reading, and the
21
- * spring's 12% overshoot on a jump to 100% runs past the end of the track and
22
- * comes back — which reads as the job finishing, unfinishing, then finishing.
16
+ * Indeterminate is a partial bar sweeping the track on a loop, at
17
+ * `grytDurations.sweep`, from the same description the web's keyframe follows.
18
+ * Both were empty until GRYT-382: the web passed `value={null}` to Base UI,
19
+ * which leaves the indicator's width unset, and nothing styled it. This file
20
+ * previously called that a parity exception, which it was not — both platforms
21
+ * were missing the same feature, and writing it down as one-sided is what
22
+ * closed the question for a while.
23
23
  */
24
24
  export function Progress({ value, tone = "accent", style }) {
25
25
  const theme = useTheme();
26
- const clamped = value === undefined ? 0 : Math.max(0, Math.min(100, value));
27
- return (_jsx(View, { accessibilityRole: "progressbar", accessibilityValue: value === undefined ? undefined : { now: clamped, min: 0, max: 100 }, style: [
26
+ const reducedMotion = useReducedMotion();
27
+ const indeterminate = value === undefined;
28
+ const clamped = indeterminate ? 0 : Math.max(0, Math.min(100, value));
29
+ // Measured rather than expressed as a percentage. The web animates
30
+ // `translateX` in percentages of the bar's own width, which React Native does
31
+ // not accept in a transform, so the same travel is computed in points from
32
+ // the track's own layout.
33
+ const [trackWidth, setTrackWidth] = useState(0);
34
+ const offset = useSharedValue(0);
35
+ const barWidth = trackWidth * SWEEP_FRACTION;
36
+ useEffect(() => {
37
+ if (!indeterminate || reducedMotion || trackWidth === 0)
38
+ return;
39
+ // Starts fully off the left edge and ends fully off the right. Leaving the
40
+ // track completely at both ends is what makes the loop seam invisible — a
41
+ // bar that restarts while still on screen flicks backwards once per pass.
42
+ // eslint-disable-next-line react-hooks/immutability
43
+ offset.value = -barWidth;
44
+ // eslint-disable-next-line react-hooks/immutability
45
+ offset.value = withRepeat(withTiming(trackWidth, {
46
+ duration: grytDurations.sweep,
47
+ easing: Easing.inOut(Easing.ease),
48
+ }), -1, false);
49
+ }, [indeterminate, reducedMotion, trackWidth, barWidth, offset]);
50
+ const sweepStyle = useAnimatedStyle(() => ({
51
+ transform: [{ translateX: offset.value }],
52
+ }));
53
+ const onLayout = (e) => {
54
+ const next = e.nativeEvent.layout.width;
55
+ // Guarded because onLayout fires on every re-render in some trees, and
56
+ // setting state unconditionally from it is an infinite loop.
57
+ setTrackWidth((prev) => (prev === next ? prev : next));
58
+ };
59
+ return (_jsx(View, { accessibilityRole: "progressbar", accessibilityValue: indeterminate ? undefined : { now: clamped, min: 0, max: 100 }, onLayout: indeterminate ? onLayout : undefined, style: [
28
60
  {
29
61
  height: 6,
30
62
  borderRadius: theme.radius.full,
@@ -32,10 +64,22 @@ export function Progress({ value, tone = "accent", style }) {
32
64
  overflow: "hidden",
33
65
  },
34
66
  style,
35
- ], children: _jsx(View, { style: {
67
+ ], children: indeterminate ? (_jsx(Animated.View, { style: [
68
+ {
69
+ // Full width and dimmed under reduce-motion rather than a frozen
70
+ // partial bar, which reads as a job that stalled 40% in. Matches
71
+ // what the web's `prefers-reduced-motion` rule does.
72
+ width: reducedMotion ? "100%" : barWidth,
73
+ opacity: reducedMotion ? 0.4 : 1,
74
+ height: "100%",
75
+ borderRadius: theme.radius.full,
76
+ backgroundColor: theme.scales[tone][8],
77
+ },
78
+ reducedMotion ? null : sweepStyle,
79
+ ] })) : (_jsx(View, { style: {
36
80
  width: `${clamped}%`,
37
81
  height: "100%",
38
82
  borderRadius: theme.radius.full,
39
83
  backgroundColor: theme.scales[tone][8],
40
- } }) }));
84
+ } })) }));
41
85
  }
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type StyleProp, type ViewStyle } from "react-native";
3
+ import { type OpenStateProps } from "../../overlay/useOpenState.js";
3
4
  /**
4
5
  * A bottom sheet, which is the phone's modal.
5
6
  *
@@ -9,7 +10,7 @@ import { type StyleProp, type ViewStyle } from "react-native";
9
10
  * a sheet is dragged, it settles at heights the user chooses, and dismissing it
10
11
  * is a flick rather than a click on an X. Shipping the web's Dialog on a phone
11
12
  * would be 1:1 and wrong; inventing a sheet on the web would be worse. So this
12
- * is an addition, and it is in the parity exceptions table as one.
13
+ * is an addition, and the README lists it as one.
13
14
  *
14
15
  * Built on `@gorhom/bottom-sheet` rather than by hand, which is the opposite of
15
16
  * the call made for Slider and Tabs, and the reason is the shape of the
@@ -40,7 +41,7 @@ export interface SheetProviderProps {
40
41
  }
41
42
  /** Mount once, at the root. See the note above for why it is required. */
42
43
  export declare function SheetProvider({ children }: SheetProviderProps): import("react").JSX.Element;
43
- export interface SheetProps {
44
+ export interface SheetProps extends OpenStateProps {
44
45
  /**
45
46
  * Heights the sheet settles at, as percentages or points.
46
47
  *
@@ -48,12 +49,20 @@ export interface SheetProps {
48
49
  * why this is its own component rather than a Drawer with a prop.
49
50
  */
50
51
  snapPoints?: (string | number)[];
51
- /** Starts open. Uncontrolled; use the Trigger for the usual case. */
52
- defaultOpen?: boolean;
53
- onOpenChange?: (open: boolean) => void;
54
52
  children?: ReactNode;
55
53
  }
56
- declare function Root({ snapPoints, defaultOpen, onOpenChange, children, }: SheetProps): import("react").JSX.Element;
54
+ /**
55
+ * `open` with `onOpenChange` drives it from outside; `defaultOpen` alone lets
56
+ * it manage itself. That is `useOpenState`, which every other overlay in this
57
+ * package already uses — Sheet was the one that did not, and took `defaultOpen`
58
+ * and a `Trigger` and nothing else.
59
+ *
60
+ * What that cost a caller: a sheet opened by something that is not a Pressable
61
+ * — a tab bar item, a notification, a deep link — had to be unmounted and
62
+ * remounted with `defaultOpen` to open it at all, which throws the body away on
63
+ * every open. The mobile app shell did exactly that, twice.
64
+ */
65
+ declare function Root({ snapPoints, children, ...openProps }: SheetProps): import("react").JSX.Element;
57
66
  export interface SheetTriggerProps {
58
67
  children?: ReactNode;
59
68
  style?: StyleProp<ViewStyle>;
@@ -1 +1 @@
1
- {"version":3,"file":"Sheet.d.ts","sourceRoot":"","sources":["../../../src/components/Sheet/Sheet.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,EAAyB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAerF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,EAAE,QAAQ,EAAE,EAAE,kBAAkB,+BAE7D;AAeD,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IACjC,qEAAqE;IACrE,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED,iBAAS,IAAI,CAAC,EACZ,UAAoB,EACpB,WAAmB,EACnB,YAAY,EACZ,QAAQ,GACT,EAAE,UAAU,+BAyBZ;AAWD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED;;;;;GAKG;AACH,iBAAS,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,iBAAiB,+BAOtD;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,iBAAS,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,iBAAiB,+BA+HtD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,iBAAS,KAAK,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,eAAe,+BAOlD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,iBAAS,KAAK,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,eAAe,+BAiBlD;AAED,eAAO,MAAM,KAAK;;;;;CAA0D,CAAC"}
1
+ {"version":3,"file":"Sheet.d.ts","sourceRoot":"","sources":["../../../src/components/Sheet/Sheet.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,EAAyB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAerF,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAG/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,EAAE,QAAQ,EAAE,EAAE,kBAAkB,+BAE7D;AAeD,MAAM,WAAW,UAAW,SAAQ,cAAc;IAChD;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IACjC,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED;;;;;;;;;;GAUG;AACH,iBAAS,IAAI,CAAC,EACZ,UAAoB,EACpB,QAAQ,EACR,GAAG,SAAS,EACb,EAAE,UAAU,+BAqBZ;AAWD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED;;;;;GAKG;AACH,iBAAS,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,iBAAiB,+BAOtD;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,iBAAS,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,iBAAiB,+BA8LtD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,iBAAS,KAAK,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,eAAe,+BAOlD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,iBAAS,KAAK,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,eAAe,+BAiBlD;AAED,eAAO,MAAM,KAAK;;;;;CAA0D,CAAC"}
@@ -2,8 +2,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, } from "react";
3
3
  import { Pressable, Text, View } from "react-native";
4
4
  import { BottomSheetBackdrop, BottomSheetModal, BottomSheetModalProvider, BottomSheetView, useBottomSheetTimingConfigs, } from "@gorhom/bottom-sheet";
5
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
5
6
  import { grytDrawerBleed } from "@gryt/theme";
6
7
  import { durations, easeSpring } from "../../motion/index.js";
8
+ import { useOpenState } from "../../overlay/useOpenState.js";
7
9
  import { useTheme } from "../../theme/index.js";
8
10
  /** Mount once, at the root. See the note above for why it is required. */
9
11
  export function SheetProvider({ children }) {
@@ -16,21 +18,29 @@ function useSheet(part) {
16
18
  throw new Error(`Sheet.${part} must be rendered inside Sheet.`);
17
19
  return value;
18
20
  }
19
- function Root({ snapPoints = ["50%"], defaultOpen = false, onOpenChange, children, }) {
21
+ /**
22
+ * `open` with `onOpenChange` drives it from outside; `defaultOpen` alone lets
23
+ * it manage itself. That is `useOpenState`, which every other overlay in this
24
+ * package already uses — Sheet was the one that did not, and took `defaultOpen`
25
+ * and a `Trigger` and nothing else.
26
+ *
27
+ * What that cost a caller: a sheet opened by something that is not a Pressable
28
+ * — a tab bar item, a notification, a deep link — had to be unmounted and
29
+ * remounted with `defaultOpen` to open it at all, which throws the body away on
30
+ * every open. The mobile app shell did exactly that, twice.
31
+ */
32
+ function Root({ snapPoints = ["50%"], children, ...openProps }) {
20
33
  const ref = useRef(null);
21
- // `present` and `dismiss` rather than mounting and unmounting by hand: the
22
- // modal is always rendered and the provider decides whether it is on screen,
23
- // which is what removes the mount-then-snap-on-the-next-frame dance the
24
- // inline version needed.
25
- const open = useCallback(() => {
26
- ref.current?.present();
27
- onOpenChange?.(true);
28
- }, [onOpenChange]);
29
- const close = useCallback(() => {
30
- ref.current?.dismiss();
31
- }, []);
34
+ const state = useOpenState(openProps);
35
+ const { open: isOpen, setOpen } = state;
36
+ // Asking for the state rather than reaching for the ref, so a controlled
37
+ // Sheet's parent gets the final say — `setOpen` on a controlled overlay
38
+ // reports and does not decide. The effect in `Content` is what actually
39
+ // presents, once the modal it presents exists.
40
+ const open = useCallback(() => setOpen(true), [setOpen]);
41
+ const close = useCallback(() => setOpen(false), [setOpen]);
32
42
  const context = useMemo(() => ({ open, close }), [open, close]);
33
- return (_jsx(SheetContext.Provider, { value: context, children: _jsx(SheetRefContext.Provider, { value: { ref, defaultOpen, snapPoints, onOpenChange }, children: children }) }));
43
+ return (_jsx(SheetContext.Provider, { value: context, children: _jsx(SheetRefContext.Provider, { value: { ref, isOpen, setOpen, snapPoints }, children: children }) }));
34
44
  }
35
45
  const SheetRefContext = createContext(null);
36
46
  /**
@@ -45,6 +55,16 @@ function Trigger({ children, style }) {
45
55
  }
46
56
  function Content({ children, style }) {
47
57
  const theme = useTheme();
58
+ /**
59
+ * The phone's own furniture, which the sheet has to stay clear of at both
60
+ * ends.
61
+ *
62
+ * At the tall snap point the sheet reaches the top of the screen, and without
63
+ * `topInset` its content runs under the Dynamic Island. At the other end the
64
+ * home indicator sits over the last few points, and a control row ending
65
+ * flush with the sheet is clipped by it.
66
+ */
67
+ const insets = useSafeAreaInsets();
48
68
  const state = useContext(SheetRefContext);
49
69
  // Read here, in the normal tree, and handed back down below — see the note
50
70
  // on the provider inside the modal.
@@ -52,11 +72,33 @@ function Content({ children, style }) {
52
72
  if (!state || !sheet) {
53
73
  throw new Error("Sheet.Content must be rendered inside Sheet.");
54
74
  }
55
- const { ref, defaultOpen, snapPoints, onOpenChange } = state;
75
+ const { ref, isOpen, setOpen, snapPoints } = state;
76
+ /**
77
+ * `present` and `dismiss` rather than mounting and unmounting by hand: the
78
+ * modal is always rendered and the provider decides whether it is on screen,
79
+ * which is what removes the mount-then-snap-on-the-next-frame dance the
80
+ * inline version needed.
81
+ *
82
+ * Here rather than in `Root` because `ref` is attached to the modal below,
83
+ * and `Root` runs before there is one.
84
+ *
85
+ * `presented` is not bookkeeping that could be dropped. A closed Sheet runs
86
+ * this effect once on mount with `isOpen` false, and dismissing a modal that
87
+ * has never been presented takes it *out* of the provider's registry — after
88
+ * which `present()` is a no-op and the sheet never opens again. It looks like
89
+ * the open prop being ignored, and it is not: it is the close that ran first.
90
+ */
91
+ const presented = useRef(false);
56
92
  useEffect(() => {
57
- if (defaultOpen)
93
+ if (isOpen) {
94
+ presented.current = true;
58
95
  ref.current?.present();
59
- }, [defaultOpen, ref]);
96
+ }
97
+ else if (presented.current) {
98
+ presented.current = false;
99
+ ref.current?.dismiss();
100
+ }
101
+ }, [isOpen, ref]);
60
102
  // The modal renders nothing until it is presented, so there is no invisible
61
103
  // backdrop sitting over the screen eating taps while it is closed. That was
62
104
  // a real hazard with the inline version and had to be handled by unmounting.
@@ -99,23 +141,52 @@ function Content({ children, style }) {
99
141
  * engine would be approximating an exact curve with the thing it was chosen
100
142
  * over.
101
143
  *
102
- * `springSoft` — 700msbecause a sheet travels its own height, which is the
103
- * case that duration was shaped for. It is what Drawer uses and for the same
104
- * reason.
144
+ * `springSlow` — 900msrather than the 700 a Drawer uses, because a sheet
145
+ * travels further than a drawer does. A drawer crosses its own width, which
146
+ * is most of the screen only on a phone; a sheet at 82% comes up from off the
147
+ * bottom edge and covers nearly all of it. The same duration over a longer
148
+ * distance is a faster animation, and at 700 this arrived quickly enough to
149
+ * read as a snap rather than a slide.
105
150
  */
106
151
  const animationConfigs = useBottomSheetTimingConfigs({
107
- duration: durations.springSoft,
152
+ duration: durations.springSlow,
108
153
  easing: easeSpring,
109
154
  });
110
155
  const renderBackdrop = useCallback((props) => (_jsx(BottomSheetBackdrop, { ...props, appearsOnIndex: 0, disappearsOnIndex: -1,
111
156
  // Tracks the drag, so dragging the sheet down lightens the scrim
112
157
  // rather than holding full opacity until it snaps shut.
113
158
  opacity: 0.6, pressBehavior: "close" })), []);
114
- return (_jsx(BottomSheetModal, { ref: ref, snapPoints: snapPoints, enablePanDownToClose: true, onDismiss: () => onOpenChange?.(false), animationConfigs: animationConfigs, backdropComponent: renderBackdrop, backgroundComponent: renderBackground, handleIndicatorStyle: {
159
+ return (_jsx(BottomSheetModal, { ref: ref, snapPoints: snapPoints,
160
+ // So a sheet at 100% stops below the Dynamic Island rather than running
161
+ // its content under it.
162
+ topInset: insets.top,
163
+ // Off, because `snapPoints` is the whole point of this component.
164
+ //
165
+ // gorhom v5 defaults dynamic sizing on, which measures the content and
166
+ // sizes the sheet to it — overriding the snap points entirely. A sheet
167
+ // asked for 70% whose content had no intrinsic height collapsed to the
168
+ // height of its own footer, which looks like the snap points being
169
+ // ignored because they were.
170
+ enableDynamicSizing: false, enablePanDownToClose: true,
171
+ // A flick down or a tap on the backdrop closes it without anything in
172
+ // React asking, so the state has to be told. Uncontrolled, this is what
173
+ // makes the next `present` work; controlled, it is how the parent finds
174
+ // out its sheet is gone.
175
+ onDismiss: () => setOpen(false), animationConfigs: animationConfigs, backdropComponent: renderBackdrop, backgroundComponent: renderBackground, handleIndicatorStyle: {
115
176
  backgroundColor: theme.color.border,
116
177
  width: 36,
117
178
  height: 4,
118
- }, children: _jsx(BottomSheetView, { style: [{ flex: 1, padding: theme.space(4) }, style], children: _jsx(SheetContext.Provider, { value: sheet, children: children }) }) }));
179
+ }, children: _jsx(BottomSheetView, { style: [
180
+ {
181
+ flex: 1,
182
+ padding: theme.space(4),
183
+ // The home indicator's strip, on top of whatever padding the caller
184
+ // asked for. Without it the last row of content is clipped by it —
185
+ // reported as the voice controls being cut off at the bottom.
186
+ paddingBottom: theme.space(4) + insets.bottom,
187
+ },
188
+ style,
189
+ ], children: _jsx(SheetContext.Provider, { value: sheet, children: children }) }) }));
119
190
  }
120
191
  function Close({ children, style }) {
121
192
  const sheet = useSheet("Close");
@@ -1,15 +1,26 @@
1
1
  import { type WithTimingConfig } from "react-native-reanimated";
2
+ /**
3
+ * Run when the animation settles, with whether it reached the end.
4
+ *
5
+ * A worklet, like everything Reanimated calls back into — hop with `runOnJS`
6
+ * to touch React state. Forwarded by every helper below because the alternative
7
+ * is a caller reaching past them to `withTiming`, which is how the sampled
8
+ * curve stops being the thing that runs.
9
+ */
10
+ type Settled = (finished?: boolean) => void;
2
11
  import { easeSpring, easeSpringTight } from "./easing.js";
3
12
  export { easeSpring, easeSpringTight };
4
13
  export declare const durations: {
5
14
  readonly spring: 500;
6
15
  readonly springSoft: 700;
16
+ readonly springSlow: 900;
7
17
  readonly fast: 150;
18
+ readonly sweep: 1400;
8
19
  };
9
20
  /** `withTiming` on the overshooting spring, at the standard duration. */
10
- export declare function springy(to: number, config?: WithTimingConfig): number;
21
+ export declare function springy(to: number, config?: WithTimingConfig, settled?: Settled): number;
11
22
  /** `withTiming` on the critically damped spring — travel that must stay in bounds. */
12
- export declare function travel(to: number, config?: WithTimingConfig): number;
23
+ export declare function travel(to: number, config?: WithTimingConfig, settled?: Settled): number;
13
24
  /** A colour or opacity change, which should not feel sprung. */
14
- export declare function fade(to: number, config?: WithTimingConfig): number;
25
+ export declare function fade(to: number, config?: WithTimingConfig, settled?: Settled): number;
15
26
  //# sourceMappingURL=motion.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"motion.d.ts","sourceRoot":"","sources":["../../src/motion/motion.ts"],"names":[],"mappings":"AAYA,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAEpF,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAEvD,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,CAAC;AAEvC,eAAO,MAAM,SAAS;;;;CAAgB,CAAC;AAEvC,yEAAyE;AACzE,wBAAgB,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,gBAAgB,UAG5D;AAED,sFAAsF;AACtF,wBAAgB,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,gBAAgB,UAG3D;AAED,gEAAgE;AAChE,wBAAgB,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,gBAAgB,UAGzD"}
1
+ {"version":3,"file":"motion.d.ts","sourceRoot":"","sources":["../../src/motion/motion.ts"],"names":[],"mappings":"AAYA,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAEpF;;;;;;;GAOG;AACH,KAAK,OAAO,GAAG,CAAC,QAAQ,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;AAE5C,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAEvD,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,CAAC;AAEvC,eAAO,MAAM,SAAS;;;;;;CAAgB,CAAC;AAEvC,yEAAyE;AACzE,wBAAgB,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,EAAE,OAAO,UAO/E;AAED,sFAAsF;AACtF,wBAAgB,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,EAAE,OAAO,UAO9E;AAED,gEAAgE;AAChE,wBAAgB,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,EAAE,OAAO,UAO5E"}
@@ -16,17 +16,17 @@ import { easeSpring, easeSpringTight } from "./easing.js";
16
16
  export { easeSpring, easeSpringTight };
17
17
  export const durations = grytDurations;
18
18
  /** `withTiming` on the overshooting spring, at the standard duration. */
19
- export function springy(to, config) {
19
+ export function springy(to, config, settled) {
20
20
  "worklet";
21
- return withTiming(to, { duration: durations.spring, easing: easeSpring, ...config });
21
+ return withTiming(to, { duration: durations.spring, easing: easeSpring, ...config }, settled);
22
22
  }
23
23
  /** `withTiming` on the critically damped spring — travel that must stay in bounds. */
24
- export function travel(to, config) {
24
+ export function travel(to, config, settled) {
25
25
  "worklet";
26
- return withTiming(to, { duration: durations.spring, easing: easeSpringTight, ...config });
26
+ return withTiming(to, { duration: durations.spring, easing: easeSpringTight, ...config }, settled);
27
27
  }
28
28
  /** A colour or opacity change, which should not feel sprung. */
29
- export function fade(to, config) {
29
+ export function fade(to, config, settled) {
30
30
  "worklet";
31
- return withTiming(to, { duration: durations.fast, easing: Easing.out(Easing.quad), ...config });
31
+ return withTiming(to, { duration: durations.fast, easing: Easing.out(Easing.quad), ...config }, settled);
32
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/ui-native",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Gryt's design system on React Native. Same tokens as @gryt/ui, a different renderer.",
@@ -35,10 +35,11 @@
35
35
  "react-native": ">=0.76.0",
36
36
  "react-native-reanimated": ">=4.0.0",
37
37
  "@gorhom/bottom-sheet": ">=5.2.0",
38
- "react-native-gesture-handler": ">=2.16.1"
38
+ "react-native-gesture-handler": ">=2.16.1",
39
+ "react-native-safe-area-context": ">=5.0.0"
39
40
  },
40
41
  "dependencies": {
41
- "@gryt/theme": "^0.3.0"
42
+ "@gryt/theme": "^0.5.0"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@gorhom/bottom-sheet": "^5.2.14",
@@ -47,6 +48,7 @@
47
48
  "react-native": "^0.76.0",
48
49
  "react-native-gesture-handler": "^2.32.0",
49
50
  "react-native-reanimated": "^4.5.1",
51
+ "react-native-safe-area-context": "^5.7.0",
50
52
  "vitest": "^3.0.0"
51
53
  },
52
54
  "peerDependenciesMeta": {}