@expokit/cli 0.4.3 → 0.5.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 +30 -3
- package/bin/expo-kit.js +749 -288
- package/bin/lib.mjs +313 -4
- package/package.json +1 -1
- package/registry/_docs/action-sheet.json +1 -1
- package/registry/_docs/alert.json +6 -0
- package/registry/_docs/bottom-sheet.json +6 -0
- package/registry/_docs/bullet.json +6 -0
- package/registry/_docs/dialog.json +1 -1
- package/registry/_docs/drawer.json +3 -3
- package/registry/_docs/flip-calendar.json +106 -0
- package/registry/_docs/glass-search-bar.json +1 -1
- package/registry/_docs/grain.json +6 -1
- package/registry/_docs/isometric-bar.json +6 -0
- package/registry/_docs/liquidglass-tab.json +1 -1
- package/registry/_docs/password-input.json +1 -1
- package/registry/_docs/paywall.json +144 -0
- package/registry/_docs/pixel-bar.json +6 -0
- package/registry/_docs/polar-area.json +6 -0
- package/registry/_docs/qr-code-button.json +9 -3
- package/registry/_docs/ruler-time-picker.json +147 -0
- package/registry/_docs/scratch-card.json +7 -1
- package/registry/_docs/search-bar.json +1 -1
- package/registry/_docs/select.json +2 -2
- package/registry/_docs/slide-to-confirm.json +2 -2
- package/registry/_docs/spinner.json +1 -1
- package/registry/_docs/stateful-button.json +2 -2
- package/registry/_docs/swipe-deck.json +1 -1
- package/registry/_docs/time-picker.json +2 -2
- package/registry/_docs/timeline-chart.json +6 -0
- package/registry/_docs/toast.json +15 -3
- package/registry/_docs/treemap.json +6 -0
- package/registry/_docs/waterfall.json +6 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
- package/registry/accordion/files/src/lib/kit.tsx +462 -12
- package/registry/accordion.json +1 -0
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +50 -58
- package/registry/action-sheet/files/src/lib/kit.tsx +462 -12
- package/registry/action-sheet.json +1 -0
- package/registry/alert/files/src/components/ui/alert.tsx +20 -13
- package/registry/alert/files/src/lib/kit.tsx +462 -12
- package/registry/alert.json +3 -4
- package/registry/assistant-app.json +1 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +89 -74
- package/registry/avatar/files/src/lib/kit.tsx +462 -12
- package/registry/avatar.json +1 -0
- package/registry/badge/files/src/components/ui/badge.tsx +36 -36
- package/registry/badge/files/src/lib/kit.tsx +462 -12
- package/registry/badge.json +1 -0
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +39 -44
- package/registry/bottom-nav/files/src/lib/kit.tsx +462 -12
- package/registry/bottom-nav.json +5 -0
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +357 -300
- package/registry/bottom-sheet/files/src/lib/kit.tsx +462 -12
- package/registry/bottom-sheet.json +5 -1
- package/registry/bullet.json +1 -0
- package/registry/button/files/src/components/ui/button.tsx +23 -34
- package/registry/button/files/src/lib/kit.tsx +462 -12
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +6 -9
- package/registry/button-3d/files/src/lib/kit.tsx +462 -12
- package/registry/button-3d.json +1 -0
- package/registry/button.json +1 -0
- package/registry/calendar/files/src/components/ui/calendar.tsx +77 -79
- package/registry/calendar/files/src/lib/kit.tsx +462 -12
- package/registry/calendar.json +1 -0
- package/registry/card/files/src/components/ui/card.tsx +7 -6
- package/registry/card/files/src/lib/kit.tsx +462 -12
- package/registry/card-stack.json +1 -0
- package/registry/card.json +1 -0
- package/registry/carousel.json +1 -0
- package/registry/chat.json +7 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
- package/registry/checkbox/files/src/lib/kit.tsx +462 -12
- package/registry/checkbox.json +1 -0
- package/registry/checkout.json +1 -0
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +24 -24
- package/registry/chip-group/files/src/lib/kit.tsx +462 -12
- package/registry/chip-group.json +1 -0
- package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
- package/registry/confetti.json +3 -6
- package/registry/coupon/files/src/components/ui/coupon.tsx +47 -52
- package/registry/coupon/files/src/components/ui/flip-card.tsx +9 -14
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +80 -101
- package/registry/coupon/files/src/lib/kit.tsx +462 -12
- package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
- package/registry/coupon.json +1 -0
- package/registry/cube-pager.json +1 -0
- package/registry/dashboard.json +1 -0
- package/registry/date-picker/files/src/components/ui/calendar.tsx +77 -79
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +64 -71
- package/registry/date-picker/files/src/lib/kit.tsx +462 -12
- package/registry/date-picker.json +1 -0
- package/registry/dialog/files/src/components/ui/dialog.tsx +66 -68
- package/registry/dialog/files/src/lib/kit.tsx +462 -12
- package/registry/dialog.json +1 -0
- package/registry/donut-shop.json +1 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +76 -88
- package/registry/drawer/files/src/lib/kit.tsx +462 -12
- package/registry/drawer.json +1 -0
- package/registry/fab.json +1 -0
- package/registry/fintech-app.json +1 -0
- package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +632 -0
- package/registry/flip-calendar/files/src/lib/kit.tsx +672 -0
- package/registry/flip-calendar.json +24 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +9 -14
- package/registry/flip-card/files/src/lib/kit.tsx +462 -12
- package/registry/flip-card.json +1 -0
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
- package/registry/floating-input/files/src/lib/kit.tsx +462 -12
- package/registry/floating-input.json +1 -0
- package/registry/food-app.json +1 -0
- package/registry/gift-card.json +1 -0
- package/registry/glass-button.json +5 -0
- package/registry/glass-nav-bar.json +1 -0
- package/registry/glass-search-bar.json +1 -0
- package/registry/glass-segmented.json +1 -0
- package/registry/glass-sheet.json +1 -0
- package/registry/glass-tab-bar.json +5 -0
- package/registry/glass.json +1 -0
- package/registry/grain/files/src/components/ui/grain.tsx +16 -14
- package/registry/grain.json +8 -6
- package/registry/inbox.json +1 -0
- package/registry/isometric-bar.json +1 -0
- package/registry/kit/files/src/lib/kit.tsx +462 -12
- package/registry/kit.json +1 -0
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +29 -16
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +33 -19
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
- package/registry/liquidglass-tab.json +1 -0
- package/registry/list-item/files/src/components/ui/list-item.tsx +43 -43
- package/registry/list-item/files/src/lib/kit.tsx +462 -12
- package/registry/list-item.json +1 -0
- package/registry/loyalty-card.json +1 -0
- package/registry/morphing-button-group.json +1 -0
- package/registry/number-ticker.json +1 -0
- package/registry/offers.json +1 -0
- package/registry/onboarding.json +1 -0
- package/registry/orb-reveal.json +1 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +49 -30
- package/registry/otp-input/files/src/lib/kit.tsx +462 -12
- package/registry/otp-input.json +1 -0
- package/registry/parallax-layers.json +1 -0
- package/registry/parallax-scroll.json +1 -0
- package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
- package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
- package/registry/password-input/files/src/lib/kit.tsx +462 -12
- package/registry/password-input.json +1 -0
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +9 -14
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +26 -20
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +80 -101
- package/registry/payment-card/files/src/lib/kit.tsx +462 -12
- package/registry/payment-card.json +1 -0
- package/registry/paywall.json +23 -0
- package/registry/pixel-bar.json +1 -0
- package/registry/polar-area.json +1 -0
- package/registry/profile.json +1 -0
- package/registry/progress/files/src/components/ui/progress.tsx +31 -29
- package/registry/progress/files/src/lib/kit.tsx +462 -12
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +21 -21
- package/registry/progress-steps/files/src/lib/kit.tsx +462 -12
- package/registry/progress-steps.json +1 -0
- package/registry/progress.json +1 -0
- package/registry/qr-code-button.json +1 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
- package/registry/radio-group/files/src/lib/kit.tsx +462 -12
- package/registry/radio-group.json +1 -0
- package/registry/rating.json +1 -0
- package/registry/ring-carousel.json +1 -0
- package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +832 -0
- package/registry/ruler-time-picker/files/src/lib/kit.tsx +672 -0
- package/registry/ruler-time-picker.json +26 -0
- package/registry/scratch-card.json +1 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +28 -28
- package/registry/search-bar/files/src/lib/kit.tsx +462 -12
- package/registry/search-bar.json +1 -0
- package/registry/search-results.json +4 -0
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +11 -17
- package/registry/segmented-control/files/src/lib/kit.tsx +462 -12
- package/registry/segmented-control.json +1 -0
- package/registry/select/files/src/components/ui/select.tsx +107 -104
- package/registry/select/files/src/lib/kit.tsx +462 -12
- package/registry/select.json +6 -0
- package/registry/settings.json +1 -0
- package/registry/sign-in.json +1 -0
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
- package/registry/skeleton/files/src/lib/kit.tsx +462 -12
- package/registry/skeleton.json +1 -0
- package/registry/slide-to-confirm.json +1 -0
- package/registry/slider/files/src/components/ui/slider.tsx +106 -108
- package/registry/slider/files/src/lib/kit.tsx +462 -12
- package/registry/slider.json +5 -0
- package/registry/spin-wheel.json +1 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
- package/registry/spinner/files/src/lib/kit.tsx +462 -12
- package/registry/spinner.json +6 -0
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
- package/registry/stateful-button/files/src/lib/kit.tsx +462 -12
- package/registry/stateful-button.json +5 -0
- package/registry/stepper/files/src/components/ui/stepper.tsx +40 -38
- package/registry/stepper/files/src/lib/kit.tsx +462 -12
- package/registry/stepper.json +1 -0
- package/registry/swipe-deck.json +1 -0
- package/registry/swipeable-row.json +4 -0
- package/registry/switch/files/src/components/ui/switch.tsx +41 -47
- package/registry/switch/files/src/lib/kit.tsx +462 -12
- package/registry/switch.json +1 -0
- package/registry/tabs/files/src/components/ui/tabs.tsx +61 -64
- package/registry/tabs/files/src/lib/kit.tsx +462 -12
- package/registry/tabs.json +1 -0
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
- package/registry/text-animation/files/src/lib/kit.tsx +462 -12
- package/registry/text-animation.json +7 -0
- package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
- package/registry/textarea/files/src/lib/kit.tsx +462 -12
- package/registry/textarea.json +1 -0
- package/registry/ticket-card.json +1 -0
- package/registry/ticket-shape/files/src/lib/kit.tsx +462 -12
- package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
- package/registry/ticket-shape.json +1 -0
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +80 -101
- package/registry/tilt-card/files/src/lib/kit.tsx +462 -12
- package/registry/tilt-card.json +1 -0
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +136 -131
- package/registry/time-picker/files/src/lib/kit.tsx +462 -12
- package/registry/time-picker.json +1 -0
- package/registry/timeline/files/src/components/ui/avatar.tsx +89 -74
- package/registry/timeline/files/src/components/ui/timeline.tsx +49 -57
- package/registry/timeline/files/src/lib/kit.tsx +462 -12
- package/registry/timeline-chart.json +1 -0
- package/registry/timeline.json +1 -0
- package/registry/toast/files/src/components/ui/toast.tsx +543 -465
- package/registry/toast/files/src/lib/kit.tsx +462 -12
- package/registry/toast.json +7 -5
- package/registry/treemap.json +1 -0
- package/registry/waterfall.json +1 -0
- package/registry/wheel-list.json +1 -0
- package/schema/components.schema.json +1 -0
- package/registry/alert/files/src/components/ui/button.tsx +0 -303
- package/registry/confetti/files/src/lib/kit.tsx +0 -222
- package/registry/grain/files/src/lib/kit.tsx +0 -222
- package/registry/toast/files/src/components/ui/button.tsx +0 -303
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import * as Haptics from 'expo-haptics';
|
|
2
2
|
import React, { createContext, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { AccessibilityInfo, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
4
|
+
import { GestureDetector, GestureHandlerRootView, usePanGesture } from 'react-native-gesture-handler';
|
|
5
|
+
import Animated, {
|
|
6
6
|
Easing,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
Extrapolation,
|
|
8
|
+
ReduceMotion,
|
|
9
|
+
cancelAnimation,
|
|
10
|
+
interpolate,
|
|
11
|
+
useAnimatedProps,
|
|
12
|
+
useAnimatedStyle,
|
|
13
|
+
useSharedValue,
|
|
14
|
+
withDelay,
|
|
15
|
+
withRepeat,
|
|
16
|
+
withSequence,
|
|
17
|
+
withSpring,
|
|
18
|
+
withTiming,
|
|
19
|
+
type SharedValue,
|
|
20
|
+
} from 'react-native-reanimated';
|
|
21
|
+
import { scheduleOnRN } from 'react-native-worklets';
|
|
15
22
|
import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
|
|
16
23
|
|
|
17
24
|
import { alpha, motion, readableOn, useKit, type Colors } from '@/lib/kit';
|
|
@@ -27,15 +34,21 @@ export interface ToastAction {
|
|
|
27
34
|
|
|
28
35
|
export interface ToastOptions {
|
|
29
36
|
/** Reuse an id to update a toast in place (e.g. loading → success). */
|
|
30
|
-
id?: string;
|
|
31
|
-
|
|
37
|
+
id?: string | undefined;
|
|
38
|
+
|
|
39
|
+
description?: string | undefined;
|
|
40
|
+
|
|
32
41
|
/** Milliseconds before it auto-dismisses. `Infinity` keeps it until dismissed. */
|
|
33
|
-
duration?: number;
|
|
34
|
-
|
|
42
|
+
duration?: number | undefined;
|
|
43
|
+
|
|
44
|
+
action?: ToastAction | undefined;
|
|
45
|
+
|
|
35
46
|
/** Replace the type icon (or `false` for none). */
|
|
36
|
-
icon?: React.ReactNode | false;
|
|
47
|
+
icon?: React.ReactNode | false | undefined;
|
|
48
|
+
|
|
37
49
|
/** Send to the `<Toaster id="…">` with this id. Toasts without one go to the Toaster without an id. */
|
|
38
|
-
toaster?: string;
|
|
50
|
+
toaster?: string | undefined;
|
|
51
|
+
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
export interface ToastData extends ToastOptions {
|
|
@@ -44,7 +57,8 @@ export interface ToastData extends ToastOptions {
|
|
|
44
57
|
title: string;
|
|
45
58
|
/** Bumped on every update, restarts the timer. */
|
|
46
59
|
updatedAt: number;
|
|
47
|
-
dismissed?: boolean;
|
|
60
|
+
dismissed?: boolean | undefined;
|
|
61
|
+
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
type Message<T> = string | ((value: T) => string);
|
|
@@ -174,6 +188,11 @@ function useReduceMotion() {
|
|
|
174
188
|
return reduce;
|
|
175
189
|
}
|
|
176
190
|
|
|
191
|
+
/** Toasts handle Reduce Motion themselves, so Reanimated must not also skip their animations. */
|
|
192
|
+
const KEEP = ReduceMotion.Never;
|
|
193
|
+
const SNAPPY = { ...motion.snappy, reduceMotion: KEEP };
|
|
194
|
+
const BOUNCY = { ...motion.bouncy, reduceMotion: KEEP };
|
|
195
|
+
|
|
177
196
|
const HAPTIC: Partial<Record<ToastType, Haptics.NotificationFeedbackType>> = {
|
|
178
197
|
success: Haptics.NotificationFeedbackType.Success,
|
|
179
198
|
warning: Haptics.NotificationFeedbackType.Warning,
|
|
@@ -182,7 +201,7 @@ const HAPTIC: Partial<Record<ToastType, Haptics.NotificationFeedbackType>> = {
|
|
|
182
201
|
|
|
183
202
|
/** One notification tap when a toast appears or changes tone. After a spinner, it lands as the circle closes. */
|
|
184
203
|
function useToneHaptic(id: string | undefined, type: ToastType | undefined, enabled: boolean) {
|
|
185
|
-
const prev = useRef<{ id?: string; type?: ToastType }>({});
|
|
204
|
+
const prev = useRef<{ id?: string | undefined; type?: ToastType | undefined }>({});
|
|
186
205
|
useEffect(() => {
|
|
187
206
|
const from = prev.current;
|
|
188
207
|
prev.current = { id, type };
|
|
@@ -198,11 +217,11 @@ function useToneHaptic(id: string | undefined, type: ToastType | undefined, enab
|
|
|
198
217
|
}
|
|
199
218
|
|
|
200
219
|
/**
|
|
201
|
-
* Auto-dismiss clock. Drives a
|
|
220
|
+
* Auto-dismiss clock. Drives a UI-thread 1 → 0 `life` value (the drain bar) and fires `onDone`.
|
|
202
221
|
* Both freeze while `paused` and resume where they stopped.
|
|
203
222
|
*/
|
|
204
223
|
function useLifetime(key: number | undefined, duration: number, running: boolean, onDone: () => void) {
|
|
205
|
-
const life =
|
|
224
|
+
const life = useSharedValue(1);
|
|
206
225
|
const remaining = useRef(duration);
|
|
207
226
|
const done = useRef(onDone);
|
|
208
227
|
useEffect(() => {
|
|
@@ -210,24 +229,20 @@ function useLifetime(key: number | undefined, duration: number, running: boolean
|
|
|
210
229
|
});
|
|
211
230
|
useEffect(() => {
|
|
212
231
|
remaining.current = duration;
|
|
213
|
-
life.
|
|
232
|
+
life.set(1);
|
|
214
233
|
}, [key, duration, life]);
|
|
215
234
|
useEffect(() => {
|
|
216
235
|
if (!running || !Number.isFinite(duration) || duration <= 0) return;
|
|
217
236
|
const startedAt = Date.now();
|
|
218
|
-
life.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
duration: Math.max(0, remaining.current),
|
|
222
|
-
easing: Easing.linear,
|
|
223
|
-
useNativeDriver: true,
|
|
224
|
-
}).start();
|
|
237
|
+
life.set(Math.max(0, remaining.current / duration));
|
|
238
|
+
// The drain is a clock, not decoration: it runs at full length even with Reduce Motion on.
|
|
239
|
+
life.set(withTiming(0, { duration: Math.max(0, remaining.current), easing: Easing.linear, reduceMotion: KEEP }));
|
|
225
240
|
const timer = setTimeout(() => done.current(), Math.max(0, remaining.current));
|
|
226
241
|
return () => {
|
|
227
242
|
clearTimeout(timer);
|
|
228
|
-
life
|
|
243
|
+
cancelAnimation(life);
|
|
229
244
|
remaining.current -= Date.now() - startedAt;
|
|
230
|
-
life.
|
|
245
|
+
life.set(Math.max(0, remaining.current / duration));
|
|
231
246
|
};
|
|
232
247
|
}, [key, duration, running, life]);
|
|
233
248
|
return life;
|
|
@@ -240,33 +255,82 @@ export type ToasterVariant = 'default' | 'rich' | 'minimal' | 'island';
|
|
|
240
255
|
|
|
241
256
|
export interface ToasterProps {
|
|
242
257
|
/** Only show toasts sent with `{ toaster: id }`. */
|
|
243
|
-
id?: string;
|
|
258
|
+
id?: string | undefined;
|
|
259
|
+
|
|
244
260
|
/** Edge the stack sits on. Ignored by `variant="island"`, which always lives at the top centre. */
|
|
245
|
-
position?: ToasterPosition;
|
|
261
|
+
position?: ToasterPosition | undefined;
|
|
262
|
+
|
|
246
263
|
/**
|
|
247
264
|
* 'default' cards with a tone glow · 'rich' cards filled with the tone · 'minimal' compact inverted pills
|
|
248
265
|
* (a second line for the description when there is one) ·
|
|
249
266
|
* 'island' a black capsule at the top centre that springs open like the iPhone Dynamic Island.
|
|
250
267
|
*/
|
|
251
|
-
variant?: ToasterVariant;
|
|
268
|
+
variant?: ToasterVariant | undefined;
|
|
269
|
+
|
|
252
270
|
/** Default auto-dismiss time in ms (default 4000). */
|
|
253
|
-
duration?: number;
|
|
271
|
+
duration?: number | undefined;
|
|
272
|
+
|
|
254
273
|
/** How many cards peek out of the stack (default 3). */
|
|
255
|
-
visibleToasts?: number;
|
|
274
|
+
visibleToasts?: number | undefined;
|
|
275
|
+
|
|
256
276
|
/** Always show the stack fanned out instead of collapsed. */
|
|
257
|
-
expand?: boolean;
|
|
277
|
+
expand?: boolean | undefined;
|
|
278
|
+
|
|
279
|
+
/** Distance from the top / bottom edge (default 16, island 10), added to `safeArea` and `bottomInset`. */
|
|
280
|
+
offset?: number | undefined;
|
|
281
|
+
|
|
258
282
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
283
|
+
* Keep clear of the notch / Dynamic Island and the home indicator: adds the safe-area insets from
|
|
284
|
+
* react-native-safe-area-context when the app has it (every Expo Router app does). Without it, insets are 0.
|
|
261
285
|
*/
|
|
262
|
-
|
|
286
|
+
safeArea?: boolean | undefined;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Extra space below a bottom stack, for UI that covers the screen's foot — a tab bar, a composer.
|
|
290
|
+
* With a tab bar: `<Toaster safeArea bottomInset={49} />` (49 = the standard bar above the home indicator).
|
|
291
|
+
*/
|
|
292
|
+
bottomInset?: number | undefined;
|
|
293
|
+
|
|
263
294
|
/** Space between fanned-out cards (default 10). */
|
|
264
|
-
gap?: number;
|
|
295
|
+
gap?: number | undefined;
|
|
296
|
+
|
|
265
297
|
/** Show a ✕ on every toast. */
|
|
266
|
-
closeButton?: boolean;
|
|
298
|
+
closeButton?: boolean | undefined;
|
|
299
|
+
|
|
267
300
|
/** Success / warning / error toasts tap the matching notification haptic (default true). */
|
|
268
|
-
haptics?: boolean;
|
|
269
|
-
|
|
301
|
+
haptics?: boolean | undefined;
|
|
302
|
+
|
|
303
|
+
style?: StyleProp<ViewStyle> | undefined;
|
|
304
|
+
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface Insets {
|
|
308
|
+
top: number;
|
|
309
|
+
bottom: number;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const NO_INSETS: Insets = { top: 0, bottom: 0 };
|
|
313
|
+
|
|
314
|
+
declare const require: (id: string) => unknown;
|
|
315
|
+
|
|
316
|
+
/** react-native-safe-area-context when installed (Expo's Metro allows a missing optional require), else null. */
|
|
317
|
+
const safeAreaLib = (() => {
|
|
318
|
+
try {
|
|
319
|
+
return require('react-native-safe-area-context') as {
|
|
320
|
+
SafeAreaInsetsContext?: React.Context<Insets | null>;
|
|
321
|
+
initialWindowMetrics?: { insets: Insets } | null;
|
|
322
|
+
};
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
})();
|
|
327
|
+
const InsetsContext = safeAreaLib?.SafeAreaInsetsContext ?? createContext<Insets | null>(null);
|
|
328
|
+
|
|
329
|
+
/** The screen's safe-area insets when `on` (from SafeAreaProvider, else the launch metrics), else 0. */
|
|
330
|
+
function useSafeInsets(on: boolean): Insets {
|
|
331
|
+
const fromProvider = useContext(InsetsContext);
|
|
332
|
+
if (!on) return NO_INSETS;
|
|
333
|
+
return fromProvider ?? safeAreaLib?.initialWindowMetrics?.insets ?? NO_INSETS;
|
|
270
334
|
}
|
|
271
335
|
|
|
272
336
|
const PEEK = 12;
|
|
@@ -289,12 +353,15 @@ export function Toaster({
|
|
|
289
353
|
visibleToasts = 3,
|
|
290
354
|
expand = false,
|
|
291
355
|
offset,
|
|
356
|
+
safeArea = false,
|
|
357
|
+
bottomInset = 0,
|
|
292
358
|
gap = 10,
|
|
293
359
|
closeButton = false,
|
|
294
360
|
haptics = true,
|
|
295
361
|
style,
|
|
296
362
|
}: ToasterProps) {
|
|
297
363
|
const store = useToastStore();
|
|
364
|
+
const insets = useSafeInsets(safeArea);
|
|
298
365
|
const all = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
|
|
299
366
|
// Inside ToastProvider, show the whole scoped stack (id filter is for the global store only).
|
|
300
367
|
const mine = store === globalStore ? all.filter((t) => t.toaster === id) : all;
|
|
@@ -317,7 +384,7 @@ export function Toaster({
|
|
|
317
384
|
store={store}
|
|
318
385
|
toasts={mine}
|
|
319
386
|
duration={duration}
|
|
320
|
-
offset={offset ?? 10}
|
|
387
|
+
offset={(offset ?? 10) + insets.top}
|
|
321
388
|
haptics={haptics}
|
|
322
389
|
reduceMotion={reduceMotion}
|
|
323
390
|
style={style}
|
|
@@ -325,7 +392,8 @@ export function Toaster({
|
|
|
325
392
|
);
|
|
326
393
|
}
|
|
327
394
|
|
|
328
|
-
const
|
|
395
|
+
const frontId = active[0]?.id;
|
|
396
|
+
const frontH = (frontId === undefined ? undefined : heights[frontId]) ?? GUESS_HEIGHT;
|
|
329
397
|
const layout: Record<string, StackLayout> = {};
|
|
330
398
|
let acc = 0;
|
|
331
399
|
active.forEach((t, i) => {
|
|
@@ -347,14 +415,14 @@ export function Toaster({
|
|
|
347
415
|
});
|
|
348
416
|
|
|
349
417
|
return (
|
|
350
|
-
<
|
|
418
|
+
<GestureHandlerRootView pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
|
|
351
419
|
{[...mine].reverse().map((t) => (
|
|
352
420
|
<ToastItem
|
|
353
421
|
key={t.id}
|
|
354
422
|
data={t}
|
|
355
423
|
layout={layout[t.id]}
|
|
356
424
|
edge={position}
|
|
357
|
-
edgeOffset={offset ?? 16}
|
|
425
|
+
edgeOffset={(offset ?? 16) + (position === 'bottom' ? insets.bottom + bottomInset : insets.top)}
|
|
358
426
|
variant={variant}
|
|
359
427
|
duration={t.duration ?? (t.type === 'loading' ? Infinity : duration)}
|
|
360
428
|
paused={expanded}
|
|
@@ -369,7 +437,7 @@ export function Toaster({
|
|
|
369
437
|
}}
|
|
370
438
|
/>
|
|
371
439
|
))}
|
|
372
|
-
</
|
|
440
|
+
</GestureHandlerRootView>
|
|
373
441
|
);
|
|
374
442
|
}
|
|
375
443
|
|
|
@@ -379,14 +447,19 @@ interface StackLayout {
|
|
|
379
447
|
y: number;
|
|
380
448
|
scale: number;
|
|
381
449
|
hidden: boolean;
|
|
382
|
-
clamp?: number;
|
|
450
|
+
clamp?: number | undefined;
|
|
451
|
+
|
|
383
452
|
faded: boolean;
|
|
384
453
|
}
|
|
385
454
|
|
|
455
|
+
const sameLayout = (a: StackLayout, b: StackLayout) =>
|
|
456
|
+
a.y === b.y && a.scale === b.scale && a.hidden === b.hidden && a.clamp === b.clamp && a.faded === b.faded;
|
|
457
|
+
|
|
386
458
|
interface ToastItemProps {
|
|
387
459
|
data: ToastData;
|
|
388
460
|
/** Undefined while the toast is leaving. */
|
|
389
|
-
layout?: StackLayout;
|
|
461
|
+
layout?: StackLayout | undefined;
|
|
462
|
+
|
|
390
463
|
edge: ToasterPosition;
|
|
391
464
|
edgeOffset: number;
|
|
392
465
|
variant: Exclude<ToasterVariant, 'island'>;
|
|
@@ -401,6 +474,7 @@ interface ToastItemProps {
|
|
|
401
474
|
}
|
|
402
475
|
|
|
403
476
|
function rubber(d: number) {
|
|
477
|
+
'worklet';
|
|
404
478
|
return (d * 40) / (40 + d);
|
|
405
479
|
}
|
|
406
480
|
|
|
@@ -420,117 +494,113 @@ function ToastItem({
|
|
|
420
494
|
onRemoved,
|
|
421
495
|
}: ToastItemProps) {
|
|
422
496
|
const toast = useToast();
|
|
423
|
-
const { colors, radius, dark, font } = useKit();
|
|
497
|
+
const { colors, radius, dark, font, labels } = useKit();
|
|
424
498
|
const ff = font.family ? { fontFamily: font.family } : null;
|
|
425
499
|
const toward = edge === 'bottom' ? 1 : -1; // swipe direction that dismisses toward the edge
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
const
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
const
|
|
500
|
+
// The last layout the stack gave this toast: it keeps its place while it leaves (layout turns undefined).
|
|
501
|
+
const [lastLayout, setLastLayout] = useState<StackLayout>(layout ?? { y: 0, scale: 1, hidden: false, faded: false });
|
|
502
|
+
if (layout && !sameLayout(layout, lastLayout)) setLastLayout(layout);
|
|
503
|
+
const L = layout ?? lastLayout;
|
|
504
|
+
|
|
505
|
+
// Every value here lives on the UI thread and ends up in transform / opacity.
|
|
506
|
+
const enter = useSharedValue(0);
|
|
507
|
+
const y = useSharedValue(L.y);
|
|
508
|
+
const scale = useSharedValue(L.scale);
|
|
509
|
+
const vis = useSharedValue(L.hidden ? 0 : 1);
|
|
510
|
+
const content = useSharedValue(L.faded ? 0 : 1);
|
|
511
|
+
const dragX = useSharedValue(0);
|
|
512
|
+
const dragY = useSharedValue(0);
|
|
513
|
+
const axis = useSharedValue<'x' | 'y' | null>(null);
|
|
514
|
+
const gone = useSharedValue(false);
|
|
515
|
+
const [pressing, setPressing] = useState(false);
|
|
516
|
+
const [dragging, setDragging] = useState(false);
|
|
440
517
|
|
|
441
518
|
useToneHaptic(data.id, data.type, haptics && !data.dismissed);
|
|
442
519
|
|
|
443
520
|
useEffect(() => {
|
|
444
|
-
|
|
521
|
+
enter.set(withSpring(1, SNAPPY));
|
|
445
522
|
}, [enter]);
|
|
446
523
|
|
|
447
524
|
const target = layout ?? L;
|
|
448
525
|
useEffect(() => {
|
|
449
526
|
if (!layout || data.dismissed) return;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
Animated.timing(content, { toValue: target.faded ? 0 : 1, duration: 200, useNativeDriver: true }),
|
|
455
|
-
]).start();
|
|
527
|
+
y.set(withSpring(target.y, SNAPPY));
|
|
528
|
+
scale.set(withSpring(target.scale, SNAPPY));
|
|
529
|
+
vis.set(withTiming(target.hidden ? 0 : 1, { duration: 200, reduceMotion: KEEP }));
|
|
530
|
+
content.set(withTiming(target.faded ? 0 : 1, { duration: 200, reduceMotion: KEEP }));
|
|
456
531
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-run on the numbers, not the object identity
|
|
457
532
|
}, [target.y, target.scale, target.hidden, target.faded, data.dismissed]);
|
|
458
533
|
|
|
459
534
|
// Leave: fade + slide toward the edge, then drop out of the store.
|
|
460
535
|
useEffect(() => {
|
|
461
|
-
if (!data.dismissed || gone.
|
|
462
|
-
gone.
|
|
463
|
-
|
|
536
|
+
if (!data.dismissed || gone.get()) return;
|
|
537
|
+
gone.set(true);
|
|
538
|
+
enter.set(
|
|
539
|
+
withTiming(0, { duration: 200, easing: Easing.in(Easing.quad), reduceMotion: KEEP }, () => {
|
|
540
|
+
scheduleOnRN(onRemoved);
|
|
541
|
+
}),
|
|
542
|
+
);
|
|
464
543
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
465
544
|
}, [data.dismissed]);
|
|
466
545
|
|
|
467
546
|
// Auto-dismiss timer + drain line; both pause while the stack is open or a finger is down.
|
|
468
|
-
const life = useLifetime(data.updatedAt, duration, !data.dismissed && !paused && !
|
|
469
|
-
|
|
470
|
-
const flyOut = (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
useNativeDriver: true,
|
|
547
|
+
const life = useLifetime(data.updatedAt, duration, !data.dismissed && !paused && !pressing && !dragging, () => toast.dismiss(data.id));
|
|
548
|
+
|
|
549
|
+
const flyOut = (along: 'x' | 'y', to: number) => {
|
|
550
|
+
'worklet';
|
|
551
|
+
gone.set(true);
|
|
552
|
+
(along === 'x' ? dragX : dragY).set(withTiming(to, { duration: 180, easing: Easing.out(Easing.quad), reduceMotion: KEEP }));
|
|
553
|
+
enter.set(
|
|
554
|
+
withTiming(0, { duration: 180, reduceMotion: KEEP }, () => {
|
|
555
|
+
scheduleOnRN(onRemoved);
|
|
478
556
|
}),
|
|
479
|
-
|
|
480
|
-
]).start(onRemoved);
|
|
557
|
+
);
|
|
481
558
|
};
|
|
482
559
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
|
|
560
|
+
// Swipe sideways, or toward the edge, to dismiss. Runs on the UI thread.
|
|
561
|
+
const pan = usePanGesture({
|
|
562
|
+
minDistance: 6,
|
|
563
|
+
onActivate: () => {
|
|
564
|
+
axis.set(null);
|
|
565
|
+
scheduleOnRN(setDragging, true);
|
|
566
|
+
},
|
|
567
|
+
onUpdate: (e) => {
|
|
568
|
+
if (!axis.get()) axis.set(Math.abs(e.translationX) > Math.abs(e.translationY) ? 'x' : 'y');
|
|
569
|
+
if (axis.get() === 'x') dragX.set(e.translationX);
|
|
570
|
+
else {
|
|
571
|
+
const d = e.translationY * toward;
|
|
572
|
+
dragY.set(d >= 0 ? e.translationY : -toward * rubber(-d));
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
onDeactivate: (e) => {
|
|
576
|
+
// Velocities arrive in points per second.
|
|
577
|
+
const { translationX: dx, translationY: dy, velocityX: vx, velocityY: vy } = e;
|
|
578
|
+
if (!e.canceled && axis.get() === 'x' && (Math.abs(dx) > 90 || Math.abs(vx) > 700)) return flyOut('x', Math.sign(dx || vx) * 520);
|
|
579
|
+
if (!e.canceled && axis.get() === 'y' && (dy * toward > 36 || vy * toward > 600)) return flyOut('y', toward * 140);
|
|
580
|
+
dragX.set(withSpring(0, SNAPPY));
|
|
581
|
+
dragY.set(withSpring(0, SNAPPY));
|
|
582
|
+
},
|
|
583
|
+
onFinalize: () => {
|
|
584
|
+
scheduleOnRN(setDragging, false);
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const laneStyle = useAnimatedStyle(() => ({
|
|
589
|
+
opacity: enter.get() * vis.get() * interpolate(dragX.get(), [-260, 0, 260], [0, 1, 0], Extrapolation.CLAMP),
|
|
590
|
+
transform: [
|
|
591
|
+
{ translateY: y.get() + dragY.get() + toward * 28 * (1 - enter.get()) },
|
|
592
|
+
{ translateX: dragX.get() },
|
|
593
|
+
{ scale: scale.get() * (0.96 + 0.04 * enter.get()) },
|
|
594
|
+
],
|
|
595
|
+
}));
|
|
596
|
+
const contentStyle = useAnimatedStyle(() => ({ opacity: content.get() }));
|
|
597
|
+
const barStyle = useAnimatedStyle(() => ({ opacity: content.get(), transform: [{ scaleX: life.get() }] }));
|
|
518
598
|
|
|
519
599
|
const look = theme(variant, data.type, colors, dark);
|
|
520
600
|
const minimal = variant === 'minimal';
|
|
521
601
|
// A one-line pill stays a capsule; with a description it becomes a soft rounded card.
|
|
522
602
|
const r = minimal ? (data.description ? radius.scale(20) : radius.capsule(44)) : radius.lg;
|
|
523
603
|
|
|
524
|
-
const opacity = Animated.multiply(
|
|
525
|
-
Animated.multiply(enter, vis),
|
|
526
|
-
dragX.interpolate({ inputRange: [-260, 0, 260], outputRange: [0, 1, 0], extrapolate: 'clamp' }),
|
|
527
|
-
);
|
|
528
|
-
const translateY = Animated.add(
|
|
529
|
-
Animated.add(y, dragY),
|
|
530
|
-
enter.interpolate({ inputRange: [0, 1], outputRange: [toward * 28, 0] }),
|
|
531
|
-
);
|
|
532
|
-
const s = Animated.multiply(scale, enter.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }));
|
|
533
|
-
|
|
534
604
|
const typed = data.type !== 'default';
|
|
535
605
|
const icon =
|
|
536
606
|
data.icon === false ? null : (data.icon ??
|
|
@@ -544,88 +614,83 @@ function ToastItem({
|
|
|
544
614
|
style={[
|
|
545
615
|
styles.lane,
|
|
546
616
|
edge === 'bottom' ? { bottom: edgeOffset } : { top: edgeOffset },
|
|
547
|
-
{
|
|
548
|
-
|
|
549
|
-
transform: [{ translateY }, { translateX: dragX }, { scale: s }],
|
|
550
|
-
transformOrigin: edge === 'bottom' ? 'center top' : 'center bottom',
|
|
551
|
-
},
|
|
617
|
+
{ transformOrigin: edge === 'bottom' ? 'center top' : 'center bottom' },
|
|
618
|
+
laneStyle,
|
|
552
619
|
]}
|
|
553
620
|
>
|
|
554
621
|
{/* Shadow layer (unclipped) */}
|
|
555
|
-
<
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
</Text>
|
|
582
|
-
{data.description ? (
|
|
583
|
-
<Text numberOfLines={minimal ? 2 : 4} style={[styles.desc, minimal && styles.descMinimal, ff, { color: look.desc }]}>
|
|
584
|
-
{data.description}
|
|
622
|
+
<GestureDetector gesture={pan}>
|
|
623
|
+
<View
|
|
624
|
+
style={[
|
|
625
|
+
minimal ? styles.cardMinimal : styles.cardFull,
|
|
626
|
+
{ borderRadius: r, backgroundColor: look.bg, maxHeight: L.clamp, boxShadow: shadowFor(dark, minimal) },
|
|
627
|
+
]}
|
|
628
|
+
>
|
|
629
|
+
{/* Surface (clips the glow and drain line to the corners) */}
|
|
630
|
+
<View style={[styles.clip, { borderRadius: r, borderColor: look.border, maxHeight: L.clamp }]}>
|
|
631
|
+
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, contentStyle]}>
|
|
632
|
+
{showGlow ? <Glow color={look.glowColor} strength={look.glow} x={32} y={32} /> : null}
|
|
633
|
+
</Animated.View>
|
|
634
|
+
<Pressable
|
|
635
|
+
onPress={onPress}
|
|
636
|
+
onPressIn={() => setPressing(true)}
|
|
637
|
+
onPressOut={() => setPressing(false)}
|
|
638
|
+
accessibilityRole="alert"
|
|
639
|
+
accessibilityLiveRegion="polite"
|
|
640
|
+
accessibilityLabel={[data.title, data.description].filter(Boolean).join('. ')}
|
|
641
|
+
onLayout={(e) => onHeight(Math.round(e.nativeEvent.layout.height))}
|
|
642
|
+
>
|
|
643
|
+
<Animated.View style={[styles.row, minimal ? styles.padMinimal : icon ? styles.padIcon : styles.pad, contentStyle]}>
|
|
644
|
+
{icon ? <View style={!minimal && data.description ? styles.iconTop : null}>{icon}</View> : null}
|
|
645
|
+
<View style={minimal ? styles.textMinimal : styles.text}>
|
|
646
|
+
<Text numberOfLines={minimal ? 1 : 3} style={[styles.title, ff, { color: look.title }]}>
|
|
647
|
+
{data.title}
|
|
585
648
|
</Text>
|
|
649
|
+
{data.description ? (
|
|
650
|
+
<Text numberOfLines={minimal ? 2 : 4} style={[styles.desc, minimal && styles.descMinimal, ff, { color: look.desc }]}>
|
|
651
|
+
{data.description}
|
|
652
|
+
</Text>
|
|
653
|
+
) : null}
|
|
654
|
+
</View>
|
|
655
|
+
{data.action ? (
|
|
656
|
+
<Pressable
|
|
657
|
+
accessibilityRole="button"
|
|
658
|
+
hitSlop={8}
|
|
659
|
+
onPress={() => {
|
|
660
|
+
data.action?.onPress();
|
|
661
|
+
toast.dismiss(data.id);
|
|
662
|
+
}}
|
|
663
|
+
style={({ pressed }) => [
|
|
664
|
+
styles.action,
|
|
665
|
+
{ borderRadius: radius.capsule(32), backgroundColor: look.actionBg, opacity: pressed ? 0.7 : 1 },
|
|
666
|
+
]}
|
|
667
|
+
>
|
|
668
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.actionText, ff, { color: look.actionFg }]}>
|
|
669
|
+
{data.action.label}
|
|
670
|
+
</Text>
|
|
671
|
+
</Pressable>
|
|
586
672
|
) : null}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
{closeButton ? (
|
|
607
|
-
<Pressable
|
|
608
|
-
accessibilityRole="button"
|
|
609
|
-
accessibilityLabel="Dismiss"
|
|
610
|
-
hitSlop={12}
|
|
611
|
-
onPress={() => toast.dismiss(data.id)}
|
|
612
|
-
style={styles.close}
|
|
613
|
-
>
|
|
614
|
-
<Svg width={14} height={14} viewBox="0 0 24 24">
|
|
615
|
-
<Path d="M6 6l12 12M18 6L6 18" stroke={look.desc} strokeWidth={2.4} strokeLinecap="round" />
|
|
616
|
-
</Svg>
|
|
617
|
-
</Pressable>
|
|
618
|
-
) : null}
|
|
619
|
-
</Animated.View>
|
|
620
|
-
</Pressable>
|
|
621
|
-
{showBar ? (
|
|
622
|
-
<Animated.View
|
|
623
|
-
pointerEvents="none"
|
|
624
|
-
style={[styles.bar, { backgroundColor: look.bar, opacity: content, transform: [{ scaleX: life }] }]}
|
|
625
|
-
/>
|
|
626
|
-
) : null}
|
|
673
|
+
{closeButton ? (
|
|
674
|
+
<Pressable
|
|
675
|
+
accessibilityRole="button"
|
|
676
|
+
accessibilityLabel={labels.dismiss}
|
|
677
|
+
hitSlop={12}
|
|
678
|
+
onPress={() => toast.dismiss(data.id)}
|
|
679
|
+
style={styles.close}
|
|
680
|
+
>
|
|
681
|
+
<Svg width={14} height={14} viewBox="0 0 24 24">
|
|
682
|
+
<Path d="M6 6l12 12M18 6L6 18" stroke={look.desc} strokeWidth={2.4} strokeLinecap="round" />
|
|
683
|
+
</Svg>
|
|
684
|
+
</Pressable>
|
|
685
|
+
) : null}
|
|
686
|
+
</Animated.View>
|
|
687
|
+
</Pressable>
|
|
688
|
+
{showBar ? (
|
|
689
|
+
<Animated.View pointerEvents="none" style={[styles.bar, { backgroundColor: look.bar }, barStyle]} />
|
|
690
|
+
) : null}
|
|
691
|
+
</View>
|
|
627
692
|
</View>
|
|
628
|
-
</
|
|
693
|
+
</GestureDetector>
|
|
629
694
|
</Animated.View>
|
|
630
695
|
);
|
|
631
696
|
}
|
|
@@ -643,18 +708,19 @@ interface IslandToasterProps {
|
|
|
643
708
|
offset: number;
|
|
644
709
|
haptics: boolean;
|
|
645
710
|
reduceMotion: boolean;
|
|
646
|
-
style?: StyleProp<ViewStyle
|
|
711
|
+
style?: StyleProp<ViewStyle> | undefined;
|
|
712
|
+
|
|
647
713
|
}
|
|
648
714
|
|
|
649
715
|
/**
|
|
650
716
|
* Dynamic-Island host: one black capsule at the top centre. It widens, then grows taller into a card,
|
|
651
717
|
* holds, and folds back into the capsule. A newer toast squashes the island and swaps its content in place.
|
|
652
718
|
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
719
|
+
* Everything animates on the UI thread: `w` / `h` (size, corner) on `frame` + `clip`; presence, squash,
|
|
720
|
+
* drag and content fade on their own wrapper views.
|
|
655
721
|
*/
|
|
656
722
|
function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion, style }: IslandToasterProps) {
|
|
657
|
-
const { colors, radius, dark, font } = useKit();
|
|
723
|
+
const { colors, radius, dark, font, labels } = useKit();
|
|
658
724
|
const ff = font.family ? { fontFamily: font.family } : null;
|
|
659
725
|
const active = toasts.filter((t) => !t.dismissed);
|
|
660
726
|
const front = active[0];
|
|
@@ -663,27 +729,25 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
|
|
|
663
729
|
const stageRef = useRef<Stage>('hidden');
|
|
664
730
|
const [shownId, setShownIdState] = useState<string | null>(null);
|
|
665
731
|
const shownRef = useRef<string | null>(null);
|
|
666
|
-
const lastShown =
|
|
732
|
+
const [lastShown, setLastShown] = useState<ToastData | null>(null);
|
|
667
733
|
const [laneW, setLaneW] = useState(0);
|
|
668
734
|
const [contentH, setContentH] = useState(0);
|
|
669
735
|
const [expanded, setExpanded] = useState(false);
|
|
670
|
-
const [
|
|
736
|
+
const [pressing, setPressing] = useState(false);
|
|
737
|
+
const [dragging, setDragging] = useState(false);
|
|
671
738
|
|
|
672
739
|
const live = shownId ? toasts.find((t) => t.id === shownId) : undefined;
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
const
|
|
683
|
-
const
|
|
684
|
-
const sqY = useRef(new Animated.Value(1)).current;
|
|
685
|
-
const dragY = useRef(new Animated.Value(0)).current;
|
|
686
|
-
const content = useRef(new Animated.Value(0)).current;
|
|
740
|
+
// Keep showing the last version of a toast after the store drops it, while the island closes.
|
|
741
|
+
if (live && live !== lastShown) setLastShown(live);
|
|
742
|
+
const shown = live ?? (shownId ? lastShown : null);
|
|
743
|
+
|
|
744
|
+
const w = useSharedValue(CAP_W);
|
|
745
|
+
const h = useSharedValue(CAP_H);
|
|
746
|
+
const presence = useSharedValue(0);
|
|
747
|
+
const sqX = useSharedValue(1);
|
|
748
|
+
const sqY = useSharedValue(1);
|
|
749
|
+
const dragY = useSharedValue(0);
|
|
750
|
+
const content = useSharedValue(0);
|
|
687
751
|
|
|
688
752
|
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
689
753
|
const later = (ms: number, fn: () => void) => {
|
|
@@ -706,16 +770,17 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
|
|
|
706
770
|
|
|
707
771
|
const squash = () => {
|
|
708
772
|
if (reduceMotion) return;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
773
|
+
const press = { duration: 90, easing: Easing.out(Easing.quad), reduceMotion: KEEP };
|
|
774
|
+
sqX.set(withSequence(withTiming(1.05, press), withSpring(1, BOUNCY)));
|
|
775
|
+
sqY.set(withSequence(withTiming(0.9, press), withSpring(1, BOUNCY)));
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
// Swap the island's content to another toast once the old content has faded out.
|
|
779
|
+
const swapTo = (id: string) => {
|
|
780
|
+
setShown(id);
|
|
781
|
+
setExpanded(false);
|
|
782
|
+
squash();
|
|
783
|
+
content.set(withDelay(30, withTiming(1, { duration: 200, reduceMotion: KEEP })));
|
|
719
784
|
};
|
|
720
785
|
|
|
721
786
|
// Lifecycle: react to the front toast changing.
|
|
@@ -724,7 +789,7 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
|
|
|
724
789
|
if (front) {
|
|
725
790
|
if (!current || stageRef.current === 'hidden') {
|
|
726
791
|
clearLater();
|
|
727
|
-
dragY.
|
|
792
|
+
dragY.set(0);
|
|
728
793
|
setShown(front.id);
|
|
729
794
|
setExpanded(false);
|
|
730
795
|
setStage('capsule');
|
|
@@ -734,12 +799,12 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
|
|
|
734
799
|
// Replace in place: older toasts are superseded.
|
|
735
800
|
toasts.forEach((t) => t.id !== front.id && store.remove(t.id));
|
|
736
801
|
if (stageRef.current === 'open') {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
802
|
+
const next = front.id;
|
|
803
|
+
content.set(
|
|
804
|
+
withTiming(0, { duration: 90, reduceMotion: KEEP }, () => {
|
|
805
|
+
scheduleOnRN(swapTo, next);
|
|
806
|
+
}),
|
|
807
|
+
);
|
|
743
808
|
} else {
|
|
744
809
|
setShown(front.id);
|
|
745
810
|
setExpanded(false);
|
|
@@ -772,21 +837,23 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
|
|
|
772
837
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
773
838
|
}, [shownType]);
|
|
774
839
|
|
|
775
|
-
// Declarative size / presence per stage.
|
|
840
|
+
// Declarative size / presence per stage: opening widens first, closing shortens first.
|
|
776
841
|
const openW = laneW ? Math.min(laneW - 24, 380) : CAP_W;
|
|
777
842
|
useEffect(() => {
|
|
778
843
|
const isOpen = stage === 'open';
|
|
779
844
|
const tw = isOpen ? openW : CAP_W;
|
|
780
845
|
const th = isOpen ? Math.max(CAP_H, contentH || 64) : CAP_H;
|
|
781
|
-
const wA =
|
|
782
|
-
const hA =
|
|
783
|
-
|
|
846
|
+
const wA = withSpring(tw, isOpen ? BOUNCY : SNAPPY);
|
|
847
|
+
const hA = withSpring(th, SNAPPY);
|
|
848
|
+
presence.set(withSpring(stage === 'hidden' ? 0 : 1, SNAPPY));
|
|
784
849
|
if (isOpen) {
|
|
785
|
-
|
|
786
|
-
|
|
850
|
+
w.set(wA);
|
|
851
|
+
h.set(withDelay(reduceMotion ? 0 : 90, hA));
|
|
852
|
+
content.set(withDelay(reduceMotion ? 0 : 160, withTiming(1, { duration: 220, reduceMotion: KEEP })));
|
|
787
853
|
} else {
|
|
788
|
-
|
|
789
|
-
|
|
854
|
+
content.set(withTiming(0, { duration: 110, reduceMotion: KEEP }));
|
|
855
|
+
h.set(hA);
|
|
856
|
+
w.set(withDelay(reduceMotion ? 0 : 70, wA));
|
|
790
857
|
}
|
|
791
858
|
}, [stage, openW, contentH, reduceMotion, w, h, presence, content]);
|
|
792
859
|
|
|
@@ -796,53 +863,75 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
|
|
|
796
863
|
const life = useLifetime(
|
|
797
864
|
shown ? shown.updatedAt : undefined,
|
|
798
865
|
dur,
|
|
799
|
-
!!shown && !shown.dismissed && stage === 'open' && !
|
|
866
|
+
!!shown && !shown.dismissed && stage === 'open' && !pressing && !dragging && !expanded,
|
|
800
867
|
() => shownRef.current && store.toast.dismiss(shownRef.current),
|
|
801
868
|
);
|
|
802
869
|
|
|
803
|
-
//
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
870
|
+
// After a flick: reset to a closed capsule and drop the toast.
|
|
871
|
+
const flicked = () => {
|
|
872
|
+
const id = shownRef.current;
|
|
873
|
+
w.set(CAP_W);
|
|
874
|
+
h.set(CAP_H);
|
|
875
|
+
dragY.set(0);
|
|
876
|
+
setExpanded(false);
|
|
877
|
+
setStage('hidden');
|
|
878
|
+
setShown(null);
|
|
879
|
+
if (id) store.remove(id);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
// Swipe up to dismiss. The drag and the flick run on the UI thread.
|
|
883
|
+
const pan = usePanGesture({
|
|
884
|
+
activeOffsetY: [-6, 6],
|
|
885
|
+
failOffsetX: [-6, 6],
|
|
886
|
+
onActivate: () => {
|
|
887
|
+
scheduleOnRN(setDragging, true);
|
|
888
|
+
},
|
|
889
|
+
onUpdate: (e) => {
|
|
890
|
+
dragY.set(e.translationY < 0 ? e.translationY : rubber(e.translationY));
|
|
891
|
+
},
|
|
892
|
+
onDeactivate: (e) => {
|
|
893
|
+
if (!e.canceled && (e.translationY < -28 || e.velocityY < -500)) {
|
|
894
|
+
scheduleOnRN(clearLater);
|
|
895
|
+
dragY.set(
|
|
896
|
+
withTiming(-90, { duration: 180, easing: Easing.out(Easing.quad), reduceMotion: KEEP }, () => {
|
|
897
|
+
scheduleOnRN(flicked);
|
|
898
|
+
}),
|
|
899
|
+
);
|
|
900
|
+
presence.set(withTiming(0, { duration: 160, reduceMotion: KEEP }));
|
|
901
|
+
content.set(withTiming(0, { duration: 100, reduceMotion: KEEP }));
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
dragY.set(withSpring(0, SNAPPY));
|
|
905
|
+
},
|
|
906
|
+
onFinalize: () => {
|
|
907
|
+
scheduleOnRN(setDragging, false);
|
|
908
|
+
},
|
|
909
|
+
});
|
|
837
910
|
|
|
838
911
|
const bg = dark ? mix('#000000', colors.foreground, 0.05) : '#000000';
|
|
839
912
|
const fg = readableOn(bg);
|
|
840
913
|
const tone = !shown || shown.type === 'default' || shown.type === 'loading' ? mix(fg, bg, 0.25) : toneOf(shown.type, colors, true);
|
|
841
914
|
const capR = radius.capsule(CAP_H);
|
|
842
915
|
const cardR = Math.max(capR, radius.scale(30));
|
|
843
|
-
|
|
844
|
-
const
|
|
845
|
-
|
|
916
|
+
|
|
917
|
+
const outerStyle = useAnimatedStyle(() => {
|
|
918
|
+
const p = 0.5 + 0.5 * presence.get();
|
|
919
|
+
return {
|
|
920
|
+
opacity: presence.get(),
|
|
921
|
+
transform: [{ translateY: dragY.get() }, { scaleX: p * sqX.get() }, { scaleY: p * sqY.get() }],
|
|
922
|
+
};
|
|
923
|
+
});
|
|
924
|
+
const cornerStyle = useAnimatedStyle(() => ({
|
|
925
|
+
borderRadius: interpolate(h.get(), [CAP_H, CAP_H + 28], [capR, cardR], Extrapolation.CLAMP),
|
|
926
|
+
}));
|
|
927
|
+
const frameStyle = useAnimatedStyle(() => ({ width: w.get(), height: h.get() }));
|
|
928
|
+
const placeStyle = useAnimatedStyle(() => ({ left: (w.get() - openW) * 0.5 }));
|
|
929
|
+
const contentStyle = useAnimatedStyle(() => ({
|
|
930
|
+
opacity: content.get(),
|
|
931
|
+
transform: [{ scale: 0.92 + 0.08 * content.get() }],
|
|
932
|
+
}));
|
|
933
|
+
const fillStyle = useAnimatedStyle(() => ({ transform: [{ scaleX: life.get() }] }));
|
|
934
|
+
|
|
846
935
|
const typed = !!shown && shown.type !== 'default';
|
|
847
936
|
const icon =
|
|
848
937
|
!shown || shown.icon === false
|
|
@@ -851,106 +940,94 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
|
|
|
851
940
|
const showBar = Number.isFinite(dur);
|
|
852
941
|
|
|
853
942
|
return (
|
|
854
|
-
<
|
|
943
|
+
<GestureHandlerRootView pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
|
|
855
944
|
<View
|
|
856
945
|
pointerEvents="box-none"
|
|
857
946
|
style={[styles.islandLane, { top: offset }]}
|
|
858
947
|
onLayout={(e) => setLaneW(Math.round(e.nativeEvent.layout.width))}
|
|
859
948
|
>
|
|
860
949
|
{shown ? (
|
|
861
|
-
<
|
|
862
|
-
{
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
accessibilityRole="alert"
|
|
895
|
-
accessibilityLiveRegion="polite"
|
|
896
|
-
accessibilityLabel={[shown.title, shown.description].filter(Boolean).join('. ')}
|
|
897
|
-
accessibilityHint={shown.description || shown.action ? (expanded ? 'Collapses the notification' : 'Expands the notification') : undefined}
|
|
898
|
-
accessibilityState={{ expanded }}
|
|
899
|
-
style={[styles.row, icon ? styles.islandPadIcon : styles.islandPad]}
|
|
900
|
-
>
|
|
901
|
-
{icon ? <View style={expanded && shown.description ? styles.iconTop : null}>{icon}</View> : null}
|
|
902
|
-
<View style={styles.text}>
|
|
903
|
-
<Text numberOfLines={expanded ? 3 : 1} style={[styles.title, ff, { color: fg }]}>
|
|
904
|
-
{shown.title}
|
|
905
|
-
</Text>
|
|
906
|
-
{shown.description ? (
|
|
907
|
-
<Text numberOfLines={expanded ? undefined : 1} style={[styles.desc, ff, { color: alpha(fg, 0.62) }]}>
|
|
908
|
-
{shown.description}
|
|
950
|
+
<GestureDetector gesture={pan}>
|
|
951
|
+
<Animated.View style={[{ transformOrigin: 'center top' }, outerStyle]}>
|
|
952
|
+
<Animated.View
|
|
953
|
+
style={[
|
|
954
|
+
{
|
|
955
|
+
backgroundColor: bg,
|
|
956
|
+
boxShadow: dark
|
|
957
|
+
? '0 0 0 1px rgba(255,255,255,0.08), 0 10px 30px -6px rgba(0,0,0,0.6)'
|
|
958
|
+
: '0 2px 6px rgba(0,0,0,0.12), 0 14px 36px -8px rgba(0,0,0,0.35)',
|
|
959
|
+
},
|
|
960
|
+
frameStyle,
|
|
961
|
+
cornerStyle,
|
|
962
|
+
]}
|
|
963
|
+
>
|
|
964
|
+
<Animated.View style={[styles.islandClip, cornerStyle]}>
|
|
965
|
+
<Animated.View style={[styles.islandPlace, { width: openW }, placeStyle]}>
|
|
966
|
+
<Animated.View style={contentStyle} onLayout={(e) => setContentH(Math.round(e.nativeEvent.layout.height))}>
|
|
967
|
+
{typed && shown.icon !== false ? <Glow color={tone} strength={0.3} x={33} y={32} /> : null}
|
|
968
|
+
<Pressable
|
|
969
|
+
onPress={() => setExpanded((x) => !x)}
|
|
970
|
+
onPressIn={() => setPressing(true)}
|
|
971
|
+
onPressOut={() => setPressing(false)}
|
|
972
|
+
accessibilityRole="alert"
|
|
973
|
+
accessibilityLiveRegion="polite"
|
|
974
|
+
accessibilityLabel={[shown.title, shown.description].filter(Boolean).join('. ')}
|
|
975
|
+
accessibilityHint={shown.description || shown.action ? (expanded ? labels.collapseNotification : labels.expandNotification) : undefined}
|
|
976
|
+
accessibilityState={{ expanded }}
|
|
977
|
+
style={[styles.row, icon ? styles.islandPadIcon : styles.islandPad]}
|
|
978
|
+
>
|
|
979
|
+
{icon ? <View style={expanded && shown.description ? styles.iconTop : null}>{icon}</View> : null}
|
|
980
|
+
<View style={styles.text}>
|
|
981
|
+
<Text numberOfLines={expanded ? 3 : 1} style={[styles.title, ff, { color: fg }]}>
|
|
982
|
+
{shown.title}
|
|
909
983
|
</Text>
|
|
984
|
+
{shown.description ? (
|
|
985
|
+
<Text numberOfLines={expanded ? undefined : 1} style={[styles.desc, ff, { color: alpha(fg, 0.62) }]}>
|
|
986
|
+
{shown.description}
|
|
987
|
+
</Text>
|
|
988
|
+
) : null}
|
|
989
|
+
</View>
|
|
990
|
+
{!expanded && shown.action ? (
|
|
991
|
+
<View style={[styles.islandChip, { borderRadius: radius.capsule(24), backgroundColor: alpha(fg, 0.14) }]}>
|
|
992
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.islandChipText, ff, { color: fg }]}>
|
|
993
|
+
{shown.action.label}
|
|
994
|
+
</Text>
|
|
995
|
+
</View>
|
|
910
996
|
) : null}
|
|
911
|
-
</
|
|
912
|
-
{
|
|
913
|
-
<View style={
|
|
914
|
-
<
|
|
915
|
-
|
|
916
|
-
|
|
997
|
+
</Pressable>
|
|
998
|
+
{expanded && shown.action ? (
|
|
999
|
+
<View style={styles.islandActions}>
|
|
1000
|
+
<Pressable
|
|
1001
|
+
accessibilityRole="button"
|
|
1002
|
+
onPress={() => {
|
|
1003
|
+
shown.action?.onPress();
|
|
1004
|
+
store.toast.dismiss(shown.id);
|
|
1005
|
+
}}
|
|
1006
|
+
style={({ pressed }) => [
|
|
1007
|
+
styles.islandAction,
|
|
1008
|
+
{ borderRadius: radius.capsule(44), backgroundColor: alpha(fg, pressed ? 0.24 : 0.16) },
|
|
1009
|
+
]}
|
|
1010
|
+
>
|
|
1011
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.title, ff, { color: fg }]}>
|
|
1012
|
+
{shown.action.label}
|
|
1013
|
+
</Text>
|
|
1014
|
+
</Pressable>
|
|
917
1015
|
</View>
|
|
918
1016
|
) : null}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
shown.action?.onPress();
|
|
926
|
-
store.toast.dismiss(shown.id);
|
|
927
|
-
}}
|
|
928
|
-
style={({ pressed }) => [
|
|
929
|
-
styles.islandAction,
|
|
930
|
-
{ borderRadius: radius.capsule(44), backgroundColor: alpha(fg, pressed ? 0.24 : 0.16) },
|
|
931
|
-
]}
|
|
932
|
-
>
|
|
933
|
-
<Text maxFontSizeMultiplier={FONT_CAP} style={[styles.title, ff, { color: fg }]}>
|
|
934
|
-
{shown.action.label}
|
|
935
|
-
</Text>
|
|
936
|
-
</Pressable>
|
|
937
|
-
</View>
|
|
938
|
-
) : null}
|
|
939
|
-
{showBar ? (
|
|
940
|
-
<View style={[styles.islandTrack, { backgroundColor: alpha(fg, 0.1) }]}>
|
|
941
|
-
<Animated.View
|
|
942
|
-
style={[styles.islandFill, { backgroundColor: typed ? tone : alpha(fg, 0.5), transform: [{ scaleX: life }] }]}
|
|
943
|
-
/>
|
|
944
|
-
</View>
|
|
945
|
-
) : null}
|
|
1017
|
+
{showBar ? (
|
|
1018
|
+
<View style={[styles.islandTrack, { backgroundColor: alpha(fg, 0.1) }]}>
|
|
1019
|
+
<Animated.View style={[styles.islandFill, { backgroundColor: typed ? tone : alpha(fg, 0.5) }, fillStyle]} />
|
|
1020
|
+
</View>
|
|
1021
|
+
) : null}
|
|
1022
|
+
</Animated.View>
|
|
946
1023
|
</Animated.View>
|
|
947
1024
|
</Animated.View>
|
|
948
1025
|
</Animated.View>
|
|
949
1026
|
</Animated.View>
|
|
950
|
-
</
|
|
1027
|
+
</GestureDetector>
|
|
951
1028
|
) : null}
|
|
952
1029
|
</View>
|
|
953
|
-
</
|
|
1030
|
+
</GestureHandlerRootView>
|
|
954
1031
|
);
|
|
955
1032
|
}
|
|
956
1033
|
|
|
@@ -973,7 +1050,7 @@ function toHex(v: number[]) {
|
|
|
973
1050
|
function mix(a: string, b: string, t: number) {
|
|
974
1051
|
if (!a.startsWith('#') || !b.startsWith('#')) return a;
|
|
975
1052
|
const [x, y] = [rgb(a), rgb(b)];
|
|
976
|
-
return toHex(x.map((v, i) => v + (y[i] - v) * t));
|
|
1053
|
+
return toHex(x.map((v, i) => v + ((y[i] ?? v) - v) * t));
|
|
977
1054
|
}
|
|
978
1055
|
|
|
979
1056
|
function toneOf(type: ToastType, c: Colors, dark: boolean) {
|
|
@@ -1087,6 +1164,28 @@ const ARC = 0.28; // spinner arc as a fraction of the ring
|
|
|
1087
1164
|
|
|
1088
1165
|
const TRIANGLE_D = 'M10.3 4.2a2 2 0 0 1 3.4 0l7.1 12.6a2 2 0 0 1-1.7 3H4.9a2 2 0 0 1-1.7-3z';
|
|
1089
1166
|
|
|
1167
|
+
type IconStroke = {
|
|
1168
|
+
stroke: string;
|
|
1169
|
+
strokeWidth: number;
|
|
1170
|
+
strokeLinecap: 'round';
|
|
1171
|
+
strokeLinejoin: 'round';
|
|
1172
|
+
fill: 'none';
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
/** A glyph stroke that draws itself in as `draw` runs from `from` to `to`. */
|
|
1176
|
+
function DrawPath({ d, len, draw, from = 0, to = 1, stroke }: { d: string; len: number; draw: SharedValue<number>; from?: number; to?: number; stroke: IconStroke }) {
|
|
1177
|
+
const animatedProps = useAnimatedProps(() => ({
|
|
1178
|
+
strokeDashoffset: interpolate(draw.get(), [from, to], [len, 0], Extrapolation.CLAMP),
|
|
1179
|
+
}));
|
|
1180
|
+
return <AnimatedPath d={d} {...stroke} strokeDasharray={[len, len]} animatedProps={animatedProps} />;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** The dot of an ! or i, fading in near the end of the draw. */
|
|
1184
|
+
function DrawDot({ cy, color, draw }: { cy: number; color: string; draw: SharedValue<number> }) {
|
|
1185
|
+
const animatedProps = useAnimatedProps(() => ({ opacity: interpolate(draw.get(), [0, 0.6, 1], [0, 0, 1]) }));
|
|
1186
|
+
return <AnimatedCircle cx={12} cy={cy} r={1.25} fill={color} animatedProps={animatedProps} />;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1090
1189
|
/**
|
|
1091
1190
|
* Tone icon that draws itself: the outline strokes in, then the glyph draws and springs. A loading
|
|
1092
1191
|
* spinner's arc keeps turning while it closes into the success / error circle, then the mark draws in.
|
|
@@ -1099,121 +1198,100 @@ function LivingIcon({
|
|
|
1099
1198
|
}: {
|
|
1100
1199
|
type: ToastType;
|
|
1101
1200
|
color: string;
|
|
1102
|
-
halo?: boolean;
|
|
1103
|
-
|
|
1201
|
+
halo?: boolean | undefined;
|
|
1202
|
+
|
|
1203
|
+
reduceMotion?: boolean | undefined;
|
|
1204
|
+
|
|
1104
1205
|
}) {
|
|
1105
1206
|
const { radius } = useKit();
|
|
1106
|
-
|
|
1107
|
-
const
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
const
|
|
1111
|
-
const
|
|
1112
|
-
const ripple = useRef(new Animated.Value(1)).current;
|
|
1113
|
-
const loopRef = useRef<Animated.CompositeAnimation | null>(null);
|
|
1207
|
+
const outline = useSharedValue(0);
|
|
1208
|
+
const draw = useSharedValue(0);
|
|
1209
|
+
const pop = useSharedValue(0.5);
|
|
1210
|
+
const spin = useSharedValue(0);
|
|
1211
|
+
const spinning = useSharedValue(false);
|
|
1212
|
+
const ripple = useSharedValue(1);
|
|
1114
1213
|
const prev = useRef<ToastType | null>(null);
|
|
1115
1214
|
|
|
1116
1215
|
useEffect(() => {
|
|
1117
1216
|
const from = prev.current;
|
|
1118
1217
|
prev.current = type;
|
|
1119
1218
|
const stopSpin = () => {
|
|
1120
|
-
|
|
1121
|
-
|
|
1219
|
+
'worklet';
|
|
1220
|
+
cancelAnimation(spin);
|
|
1221
|
+
spinning.set(false);
|
|
1122
1222
|
};
|
|
1123
1223
|
|
|
1124
1224
|
if (type === 'loading') {
|
|
1125
|
-
if (!
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
);
|
|
1130
|
-
loopRef.current.start();
|
|
1225
|
+
if (!spinning.get()) {
|
|
1226
|
+
spinning.set(true);
|
|
1227
|
+
spin.set(0);
|
|
1228
|
+
spin.set(withRepeat(withTiming(1, { duration: 800, easing: Easing.linear, reduceMotion: KEEP }), -1, false));
|
|
1131
1229
|
}
|
|
1132
|
-
draw.
|
|
1133
|
-
if (reduceMotion) outline.
|
|
1134
|
-
else
|
|
1230
|
+
draw.set(0);
|
|
1231
|
+
if (reduceMotion) outline.set(ARC);
|
|
1232
|
+
else outline.set(withTiming(ARC, { duration: 300, easing: Easing.out(Easing.cubic), reduceMotion: KEEP }));
|
|
1135
1233
|
return;
|
|
1136
1234
|
}
|
|
1137
1235
|
|
|
1138
1236
|
if (reduceMotion) {
|
|
1139
1237
|
stopSpin();
|
|
1140
|
-
outline.
|
|
1141
|
-
draw.
|
|
1142
|
-
pop.
|
|
1238
|
+
outline.set(1);
|
|
1239
|
+
draw.set(1);
|
|
1240
|
+
pop.set(1);
|
|
1143
1241
|
return;
|
|
1144
1242
|
}
|
|
1145
1243
|
|
|
1146
1244
|
const morph = from === 'loading';
|
|
1147
|
-
if (!morph) outline.
|
|
1148
|
-
draw.
|
|
1149
|
-
pop.
|
|
1245
|
+
if (!morph) outline.set(0);
|
|
1246
|
+
draw.set(0);
|
|
1247
|
+
pop.set(0.5);
|
|
1150
1248
|
// The arc keeps spinning while it closes; a full ring looks the same at any angle, so it stops invisibly.
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
const stroke = { stroke: color, strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
|
|
1171
|
-
const dash = (len: number, from = 0, to = 1) => ({
|
|
1172
|
-
strokeDasharray: [len, len],
|
|
1173
|
-
strokeDashoffset: draw.interpolate({ inputRange: [from, to], outputRange: [len, 0], extrapolate: 'clamp' }),
|
|
1174
|
-
});
|
|
1175
|
-
const dot = draw.interpolate({ inputRange: [0, 0.6, 1], outputRange: [0, 0, 1] });
|
|
1249
|
+
outline.set(
|
|
1250
|
+
withTiming(
|
|
1251
|
+
1,
|
|
1252
|
+
{ duration: morph ? 380 : 420, easing: morph ? Easing.inOut(Easing.cubic) : Easing.out(Easing.cubic), reduceMotion: KEEP },
|
|
1253
|
+
(finished) => {
|
|
1254
|
+
if (!finished) return;
|
|
1255
|
+
stopSpin();
|
|
1256
|
+
ripple.set(0);
|
|
1257
|
+
draw.set(withTiming(1, { duration: 280, easing: Easing.out(Easing.cubic), reduceMotion: KEEP }));
|
|
1258
|
+
pop.set(withSpring(1, BOUNCY));
|
|
1259
|
+
ripple.set(withTiming(1, { duration: 700, easing: Easing.out(Easing.quad), reduceMotion: KEEP }));
|
|
1260
|
+
},
|
|
1261
|
+
),
|
|
1262
|
+
);
|
|
1263
|
+
}, [type, reduceMotion, outline, draw, pop, spin, spinning, ripple]);
|
|
1264
|
+
|
|
1265
|
+
useEffect(() => () => cancelAnimation(spin), [spin]);
|
|
1266
|
+
|
|
1267
|
+
const stroke: IconStroke = { stroke: color, strokeWidth: 2.2, strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none' };
|
|
1176
1268
|
const triangle = type === 'warning';
|
|
1177
1269
|
const per = triangle ? TRIANGLE : RING;
|
|
1178
1270
|
const box = halo ? HALO_OUT : ICON;
|
|
1179
1271
|
const inset = (box - ICON) / 2;
|
|
1180
1272
|
const circle = (d: number) => ({ width: d, height: d, borderRadius: radius.full, left: (box - d) / 2, top: (box - d) / 2 });
|
|
1181
1273
|
|
|
1274
|
+
const outlineProps = useAnimatedProps(() => ({ strokeDashoffset: per * (1 - outline.get()) }));
|
|
1275
|
+
const rippleStyle = useAnimatedStyle(() => ({
|
|
1276
|
+
opacity: 0.9 * (1 - ripple.get()),
|
|
1277
|
+
transform: [{ scale: 0.8 + 0.75 * ripple.get() }],
|
|
1278
|
+
}));
|
|
1279
|
+
const spinStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${spin.get() * 360}deg` }] }));
|
|
1280
|
+
const popStyle = useAnimatedStyle(() => ({ transform: [{ scale: pop.get() }] }));
|
|
1281
|
+
|
|
1182
1282
|
return (
|
|
1183
1283
|
<View style={{ width: box, height: box }} importantForAccessibility="no-hide-descendants">
|
|
1184
1284
|
{halo ? (
|
|
1185
1285
|
<>
|
|
1186
1286
|
<View style={[styles.abs, circle(HALO_OUT), { backgroundColor: alpha(color, 0.08) }]} />
|
|
1187
1287
|
<View style={[styles.abs, circle(HALO), { backgroundColor: alpha(color, 0.14) }]} />
|
|
1188
|
-
<Animated.View
|
|
1189
|
-
style={[
|
|
1190
|
-
styles.abs,
|
|
1191
|
-
circle(HALO_OUT),
|
|
1192
|
-
{
|
|
1193
|
-
borderWidth: 1.5,
|
|
1194
|
-
borderColor: alpha(color, 0.35),
|
|
1195
|
-
opacity: ripple.interpolate({ inputRange: [0, 1], outputRange: [0.9, 0] }),
|
|
1196
|
-
transform: [{ scale: ripple.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1.55] }) }],
|
|
1197
|
-
},
|
|
1198
|
-
]}
|
|
1199
|
-
/>
|
|
1288
|
+
<Animated.View style={[styles.abs, circle(HALO_OUT), { borderWidth: 1.5, borderColor: alpha(color, 0.35) }, rippleStyle]} />
|
|
1200
1289
|
</>
|
|
1201
1290
|
) : null}
|
|
1202
|
-
<Animated.View
|
|
1203
|
-
style={[
|
|
1204
|
-
styles.abs,
|
|
1205
|
-
{ left: inset, top: inset },
|
|
1206
|
-
{ transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }] },
|
|
1207
|
-
]}
|
|
1208
|
-
>
|
|
1291
|
+
<Animated.View style={[styles.abs, { left: inset, top: inset }, spinStyle]}>
|
|
1209
1292
|
<Svg width={ICON} height={ICON} viewBox="0 0 24 24">
|
|
1210
1293
|
{triangle ? (
|
|
1211
|
-
<AnimatedPath
|
|
1212
|
-
d={TRIANGLE_D}
|
|
1213
|
-
{...stroke}
|
|
1214
|
-
strokeDasharray={[per, per]}
|
|
1215
|
-
strokeDashoffset={outline.interpolate({ inputRange: [0, 1], outputRange: [per, 0] })}
|
|
1216
|
-
/>
|
|
1294
|
+
<AnimatedPath d={TRIANGLE_D} {...stroke} strokeDasharray={[per, per]} animatedProps={outlineProps} />
|
|
1217
1295
|
) : (
|
|
1218
1296
|
<>
|
|
1219
1297
|
{type === 'loading' ? <Circle cx={12} cy={12} r={9} {...stroke} stroke={alpha(color, 0.2)} /> : null}
|
|
@@ -1225,32 +1303,32 @@ function LivingIcon({
|
|
|
1225
1303
|
rotation={-90}
|
|
1226
1304
|
origin="12, 12"
|
|
1227
1305
|
strokeDasharray={[per, per]}
|
|
1228
|
-
|
|
1306
|
+
animatedProps={outlineProps}
|
|
1229
1307
|
/>
|
|
1230
1308
|
</>
|
|
1231
1309
|
)}
|
|
1232
1310
|
</Svg>
|
|
1233
1311
|
</Animated.View>
|
|
1234
1312
|
{type !== 'loading' ? (
|
|
1235
|
-
<Animated.View style={[styles.abs, { left: inset, top: inset
|
|
1313
|
+
<Animated.View style={[styles.abs, { left: inset, top: inset }, popStyle]}>
|
|
1236
1314
|
<Svg width={ICON} height={ICON} viewBox="0 0 24 24">
|
|
1237
|
-
{type === 'success' ? <
|
|
1315
|
+
{type === 'success' ? <DrawPath d="M8 12.4l2.7 2.7L16.2 9.4" len={12} draw={draw} stroke={stroke} /> : null}
|
|
1238
1316
|
{type === 'error' ? (
|
|
1239
1317
|
<>
|
|
1240
|
-
<
|
|
1241
|
-
<
|
|
1318
|
+
<DrawPath d="M9.2 9.2l5.6 5.6" len={8} to={0.55} draw={draw} stroke={stroke} />
|
|
1319
|
+
<DrawPath d="M14.8 9.2l-5.6 5.6" len={8} from={0.45} draw={draw} stroke={stroke} />
|
|
1242
1320
|
</>
|
|
1243
1321
|
) : null}
|
|
1244
1322
|
{type === 'warning' ? (
|
|
1245
1323
|
<>
|
|
1246
|
-
<
|
|
1247
|
-
<
|
|
1324
|
+
<DrawPath d="M12 9.5v4" len={4.2} draw={draw} stroke={stroke} />
|
|
1325
|
+
<DrawDot cy={16.6} color={color} draw={draw} />
|
|
1248
1326
|
</>
|
|
1249
1327
|
) : null}
|
|
1250
1328
|
{type === 'info' ? (
|
|
1251
1329
|
<>
|
|
1252
|
-
<
|
|
1253
|
-
<
|
|
1330
|
+
<DrawDot cy={7.9} color={color} draw={draw} />
|
|
1331
|
+
<DrawPath d="M12 11v5" len={5.2} draw={draw} stroke={stroke} />
|
|
1254
1332
|
</>
|
|
1255
1333
|
) : null}
|
|
1256
1334
|
</Svg>
|
|
@@ -1277,7 +1355,7 @@ const styles = StyleSheet.create({
|
|
|
1277
1355
|
desc: { fontSize: 13.5, fontWeight: '400', lineHeight: 19 },
|
|
1278
1356
|
descMinimal: { fontSize: 13, lineHeight: 17 },
|
|
1279
1357
|
action: { paddingHorizontal: 12, minHeight: 32, alignItems: 'center', justifyContent: 'center' },
|
|
1280
|
-
actionText: { fontSize: 13, fontWeight: '400' },
|
|
1358
|
+
actionText: { fontSize: 13, fontWeight: '400', includeFontPadding: false, textAlignVertical: 'center' },
|
|
1281
1359
|
close: { alignSelf: 'flex-start', padding: 2, marginRight: -4 },
|
|
1282
1360
|
bar: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 2, transformOrigin: 'left' },
|
|
1283
1361
|
abs: { position: 'absolute' },
|
|
@@ -1287,7 +1365,7 @@ const styles = StyleSheet.create({
|
|
|
1287
1365
|
islandPad: { paddingVertical: 14, paddingHorizontal: 20 },
|
|
1288
1366
|
islandPadIcon: { paddingTop: 13, paddingBottom: 15, paddingLeft: 14, paddingRight: 18 },
|
|
1289
1367
|
islandChip: { paddingHorizontal: 10, minHeight: 24, justifyContent: 'center' },
|
|
1290
|
-
islandChipText: { fontSize: 12.5, fontWeight: '400' },
|
|
1368
|
+
islandChipText: { fontSize: 12.5, fontWeight: '400', includeFontPadding: false, textAlignVertical: 'center' },
|
|
1291
1369
|
islandActions: { paddingHorizontal: 14, paddingBottom: 16, marginTop: -2 },
|
|
1292
1370
|
islandAction: { minHeight: 44, alignItems: 'center', justifyContent: 'center' },
|
|
1293
1371
|
islandTrack: { position: 'absolute', left: 28, right: 28, bottom: 6, height: 2, borderRadius: 1, overflow: 'hidden' },
|