@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 +9 -1
- package/dist/components/Drawer/Drawer.d.ts +0 -7
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.js +211 -31
- package/dist/components/Progress/Progress.d.ts +11 -17
- package/dist/components/Progress/Progress.d.ts.map +1 -1
- package/dist/components/Progress/Progress.js +66 -22
- package/dist/components/Sheet/Sheet.d.ts +15 -6
- package/dist/components/Sheet/Sheet.d.ts.map +1 -1
- package/dist/components/Sheet/Sheet.js +93 -22
- package/dist/motion/motion.d.ts +14 -3
- package/dist/motion/motion.d.ts.map +1 -1
- package/dist/motion/motion.js +6 -6
- package/package.json +5 -3
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,
|
|
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,
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
94
|
-
//
|
|
95
|
-
paddingLeft: side === "
|
|
96
|
-
paddingRight: side === "
|
|
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: [
|
|
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
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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":"
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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,
|
|
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,
|
|
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 (
|
|
93
|
+
if (isOpen) {
|
|
94
|
+
presented.current = true;
|
|
58
95
|
ref.current?.present();
|
|
59
|
-
|
|
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
|
-
* `
|
|
103
|
-
*
|
|
104
|
-
*
|
|
144
|
+
* `springSlow` — 900ms — rather 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.
|
|
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,
|
|
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: [
|
|
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");
|
package/dist/motion/motion.d.ts
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/motion/motion.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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": {}
|