@expokit/cli 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +32 -2
- package/bin/expo-kit.js +1204 -0
- package/bin/lib.mjs +343 -0
- package/bin/mcp.mjs +338 -0
- package/package.json +44 -4
- package/registry/_docs/accordion.json +99 -0
- package/registry/_docs/action-sheet.json +110 -0
- package/registry/_docs/alert.json +127 -0
- package/registry/_docs/avatar.json +203 -0
- package/registry/_docs/badge.json +117 -0
- package/registry/_docs/bottom-nav.json +119 -0
- package/registry/_docs/bottom-sheet.json +158 -0
- package/registry/_docs/bullet.json +24 -0
- package/registry/_docs/button-3d.json +122 -0
- package/registry/_docs/button.json +115 -0
- package/registry/_docs/calendar.json +182 -0
- package/registry/_docs/card-stack.json +108 -0
- package/registry/_docs/card.json +111 -0
- package/registry/_docs/carousel.json +102 -0
- package/registry/_docs/checkbox.json +93 -0
- package/registry/_docs/checkout.json +102 -0
- package/registry/_docs/chip-group.json +116 -0
- package/registry/_docs/color-swatch.json +66 -0
- package/registry/_docs/confetti.json +82 -0
- package/registry/_docs/coupon.json +176 -0
- package/registry/_docs/cube-pager.json +84 -0
- package/registry/_docs/dashboard.json +120 -0
- package/registry/_docs/date-picker.json +249 -0
- package/registry/_docs/dialog.json +162 -0
- package/registry/_docs/drawer.json +171 -0
- package/registry/_docs/fab.json +120 -0
- package/registry/_docs/flip-card.json +111 -0
- package/registry/_docs/floating-input.json +191 -0
- package/registry/_docs/gift-card.json +108 -0
- package/registry/_docs/glass-button.json +118 -0
- package/registry/_docs/glass-nav-bar.json +78 -0
- package/registry/_docs/glass-search-bar.json +90 -0
- package/registry/_docs/glass-segmented.json +66 -0
- package/registry/_docs/glass-sheet.json +108 -0
- package/registry/_docs/glass-tab-bar.json +76 -0
- package/registry/_docs/glass.json +78 -0
- package/registry/_docs/inbox.json +72 -0
- package/registry/_docs/isometric-bar.json +72 -0
- package/registry/_docs/kit.json +11 -0
- package/registry/_docs/kpi-card.json +11 -0
- package/registry/_docs/like-button.json +72 -0
- package/registry/_docs/liquidglass-tab.json +27 -0
- package/registry/_docs/list-item.json +200 -0
- package/registry/_docs/loyalty-card.json +108 -0
- package/registry/_docs/morph-button.json +108 -0
- package/registry/_docs/number-ticker.json +96 -0
- package/registry/_docs/offers.json +78 -0
- package/registry/_docs/onboarding.json +66 -0
- package/registry/_docs/otp-input.json +134 -0
- package/registry/_docs/pagination-dots.json +88 -0
- package/registry/_docs/parallax-layers.json +84 -0
- package/registry/_docs/parallax-scroll.json +186 -0
- package/registry/_docs/password-input.json +92 -0
- package/registry/_docs/payment-card.json +153 -0
- package/registry/_docs/pixel-bar.json +66 -0
- package/registry/_docs/polar-area.json +78 -0
- package/registry/_docs/profile.json +84 -0
- package/registry/_docs/progress-steps.json +92 -0
- package/registry/_docs/progress.json +132 -0
- package/registry/_docs/radio-group.json +92 -0
- package/registry/_docs/rating.json +90 -0
- package/registry/_docs/ring-carousel.json +102 -0
- package/registry/_docs/scratch-card.json +90 -0
- package/registry/_docs/search-bar.json +140 -0
- package/registry/_docs/search-results.json +120 -0
- package/registry/_docs/segmented-control.json +103 -0
- package/registry/_docs/select.json +252 -0
- package/registry/_docs/settings.json +72 -0
- package/registry/_docs/shimmer-button.json +78 -0
- package/registry/_docs/sign-in.json +198 -0
- package/registry/_docs/skeleton.json +142 -0
- package/registry/_docs/slide-to-confirm.json +84 -0
- package/registry/_docs/slider.json +157 -0
- package/registry/_docs/spin-wheel.json +114 -0
- package/registry/_docs/spinner.json +91 -0
- package/registry/_docs/stepper.json +168 -0
- package/registry/_docs/swipe-deck.json +102 -0
- package/registry/_docs/swipeable-row.json +72 -0
- package/registry/_docs/switch.json +104 -0
- package/registry/_docs/tabs.json +105 -0
- package/registry/_docs/tag-input.json +128 -0
- package/registry/_docs/textarea.json +175 -0
- package/registry/_docs/ticket-card.json +174 -0
- package/registry/_docs/ticket-shape.json +11 -0
- package/registry/_docs/tilt-card.json +141 -0
- package/registry/_docs/time-picker.json +253 -0
- package/registry/_docs/timeline-chart.json +30 -0
- package/registry/_docs/timeline.json +156 -0
- package/registry/_docs/toast.json +161 -0
- package/registry/_docs/treemap.json +30 -0
- package/registry/_docs/waterfall.json +36 -0
- package/registry/_docs/wheel-list.json +102 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
- package/registry/accordion/files/src/lib/kit.tsx +219 -0
- package/registry/accordion.json +17 -0
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
- package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/action-sheet.json +18 -0
- package/registry/alert/files/src/components/ui/alert.tsx +654 -0
- package/registry/alert/files/src/components/ui/button.tsx +274 -0
- package/registry/alert/files/src/lib/kit.tsx +219 -0
- package/registry/alert.json +20 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
- package/registry/avatar/files/src/lib/kit.tsx +219 -0
- package/registry/avatar.json +18 -0
- package/registry/badge/files/src/components/ui/badge.tsx +316 -0
- package/registry/badge/files/src/lib/kit.tsx +219 -0
- package/registry/badge.json +17 -0
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
- package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-nav.json +18 -0
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
- package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-sheet.json +17 -0
- package/registry/bullet.json +14 -0
- package/registry/button/files/src/components/ui/button.tsx +274 -0
- package/registry/button/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
- package/registry/button-3d/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d.json +18 -0
- package/registry/button.json +18 -0
- package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
- package/registry/calendar/files/src/lib/kit.tsx +219 -0
- package/registry/calendar.json +17 -0
- package/registry/card/files/src/components/ui/card.tsx +300 -0
- package/registry/card/files/src/lib/kit.tsx +219 -0
- package/registry/card-stack.json +15 -0
- package/registry/card.json +17 -0
- package/registry/carousel.json +15 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
- package/registry/checkbox/files/src/lib/kit.tsx +219 -0
- package/registry/checkbox.json +17 -0
- package/registry/checkout.json +26 -0
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
- package/registry/chip-group/files/src/lib/kit.tsx +219 -0
- package/registry/chip-group.json +17 -0
- package/registry/color-swatch.json +17 -0
- package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
- package/registry/confetti/files/src/lib/kit.tsx +219 -0
- package/registry/confetti.json +15 -0
- package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
- package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/coupon/files/src/lib/kit.tsx +219 -0
- package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/coupon.json +23 -0
- package/registry/cube-pager.json +15 -0
- package/registry/dashboard.json +32 -0
- package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
- package/registry/date-picker/files/src/lib/kit.tsx +219 -0
- package/registry/date-picker.json +19 -0
- package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
- package/registry/dialog/files/src/lib/kit.tsx +219 -0
- package/registry/dialog.json +18 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
- package/registry/drawer/files/src/lib/kit.tsx +219 -0
- package/registry/drawer.json +18 -0
- package/registry/fab.json +18 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/flip-card/files/src/lib/kit.tsx +219 -0
- package/registry/flip-card.json +15 -0
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/floating-input/files/src/lib/kit.tsx +219 -0
- package/registry/floating-input.json +17 -0
- package/registry/gift-card.json +21 -0
- package/registry/glass-button.json +22 -0
- package/registry/glass-nav-bar.json +21 -0
- package/registry/glass-search-bar.json +21 -0
- package/registry/glass-segmented.json +22 -0
- package/registry/glass-sheet.json +22 -0
- package/registry/glass-tab-bar.json +22 -0
- package/registry/glass.json +19 -0
- package/registry/inbox.json +30 -0
- package/registry/isometric-bar.json +14 -0
- package/registry/kit/files/src/lib/kit.tsx +219 -0
- package/registry/kit.json +11 -0
- package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
- package/registry/kpi-card.json +14 -0
- package/registry/like-button.json +18 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
- package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
- package/registry/liquidglass-tab.json +23 -0
- package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
- package/registry/list-item/files/src/lib/kit.tsx +219 -0
- package/registry/list-item.json +17 -0
- package/registry/loyalty-card.json +23 -0
- package/registry/morph-button.json +18 -0
- package/registry/number-ticker.json +15 -0
- package/registry/offers.json +24 -0
- package/registry/onboarding.json +17 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
- package/registry/otp-input/files/src/lib/kit.tsx +219 -0
- package/registry/otp-input.json +17 -0
- package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
- package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
- package/registry/pagination-dots.json +15 -0
- package/registry/parallax-layers.json +15 -0
- package/registry/parallax-scroll.json +17 -0
- package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
- package/registry/password-input/files/src/lib/kit.tsx +219 -0
- package/registry/password-input.json +19 -0
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/payment-card/files/src/lib/kit.tsx +219 -0
- package/registry/payment-card.json +21 -0
- package/registry/pixel-bar.json +14 -0
- package/registry/polar-area.json +14 -0
- package/registry/profile.json +17 -0
- package/registry/progress/files/src/components/ui/progress.tsx +883 -0
- package/registry/progress/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
- package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps.json +18 -0
- package/registry/progress.json +17 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
- package/registry/radio-group/files/src/lib/kit.tsx +219 -0
- package/registry/radio-group.json +18 -0
- package/registry/rating.json +18 -0
- package/registry/ring-carousel.json +15 -0
- package/registry/scratch-card.json +18 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
- package/registry/search-bar/files/src/lib/kit.tsx +219 -0
- package/registry/search-bar.json +17 -0
- package/registry/search-results.json +30 -0
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
- package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
- package/registry/segmented-control.json +18 -0
- package/registry/select/files/src/components/ui/select.tsx +1398 -0
- package/registry/select/files/src/lib/kit.tsx +219 -0
- package/registry/select.json +17 -0
- package/registry/settings.json +26 -0
- package/registry/shimmer-button.json +17 -0
- package/registry/sign-in.json +26 -0
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
- package/registry/skeleton/files/src/lib/kit.tsx +219 -0
- package/registry/skeleton.json +17 -0
- package/registry/slide-to-confirm.json +18 -0
- package/registry/slider/files/src/components/ui/slider.tsx +716 -0
- package/registry/slider/files/src/lib/kit.tsx +219 -0
- package/registry/slider.json +17 -0
- package/registry/spin-wheel.json +18 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +335 -0
- package/registry/spinner/files/src/lib/kit.tsx +219 -0
- package/registry/spinner.json +17 -0
- package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
- package/registry/stepper/files/src/lib/kit.tsx +219 -0
- package/registry/stepper.json +17 -0
- package/registry/swipe-deck.json +15 -0
- package/registry/swipeable-row.json +15 -0
- package/registry/switch/files/src/components/ui/switch.tsx +344 -0
- package/registry/switch/files/src/lib/kit.tsx +219 -0
- package/registry/switch.json +18 -0
- package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
- package/registry/tabs/files/src/lib/kit.tsx +219 -0
- package/registry/tabs.json +17 -0
- package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
- package/registry/tag-input/files/src/lib/kit.tsx +219 -0
- package/registry/tag-input.json +17 -0
- package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
- package/registry/textarea/files/src/lib/kit.tsx +219 -0
- package/registry/textarea.json +17 -0
- package/registry/ticket-card.json +24 -0
- package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
- package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/ticket-shape.json +17 -0
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
- package/registry/tilt-card.json +17 -0
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
- package/registry/time-picker/files/src/lib/kit.tsx +219 -0
- package/registry/time-picker.json +17 -0
- package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
- package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
- package/registry/timeline/files/src/lib/kit.tsx +219 -0
- package/registry/timeline-chart.json +14 -0
- package/registry/timeline.json +20 -0
- package/registry/toast/files/src/components/ui/button.tsx +274 -0
- package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
- package/registry/toast/files/src/lib/kit.tsx +219 -0
- package/registry/toast.json +20 -0
- package/registry/treemap.json +14 -0
- package/registry/waterfall.json +14 -0
- package/registry/wheel-list.json +17 -0
- package/schema/components.schema.json +13 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
import * as Haptics from 'expo-haptics';
|
|
2
|
+
import React, { createContext, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
AccessibilityInfo,
|
|
5
|
+
Animated,
|
|
6
|
+
Easing,
|
|
7
|
+
PanResponder,
|
|
8
|
+
Pressable,
|
|
9
|
+
StyleSheet,
|
|
10
|
+
Text,
|
|
11
|
+
View,
|
|
12
|
+
type StyleProp,
|
|
13
|
+
type ViewStyle,
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
|
|
16
|
+
|
|
17
|
+
import { alpha, motion, readableOn, useKit, type Colors } from '@/lib/kit';
|
|
18
|
+
|
|
19
|
+
// ─── Store ────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' | 'loading';
|
|
22
|
+
|
|
23
|
+
export interface ToastAction {
|
|
24
|
+
label: string;
|
|
25
|
+
onPress: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToastOptions {
|
|
29
|
+
/** Reuse an id to update a toast in place (e.g. loading → success). */
|
|
30
|
+
id?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
/** Milliseconds before it auto-dismisses. `Infinity` keeps it until dismissed. */
|
|
33
|
+
duration?: number;
|
|
34
|
+
action?: ToastAction;
|
|
35
|
+
/** Replace the type icon (or `false` for none). */
|
|
36
|
+
icon?: React.ReactNode | false;
|
|
37
|
+
/** Send to the `<Toaster id="…">` with this id. Toasts without one go to the Toaster without an id. */
|
|
38
|
+
toaster?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ToastData extends ToastOptions {
|
|
42
|
+
id: string;
|
|
43
|
+
type: ToastType;
|
|
44
|
+
title: string;
|
|
45
|
+
/** Bumped on every update, restarts the timer. */
|
|
46
|
+
updatedAt: number;
|
|
47
|
+
dismissed?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Message<T> = string | ((value: T) => string);
|
|
51
|
+
|
|
52
|
+
export type ToastApi = ((title: string, opts?: ToastOptions) => string) & {
|
|
53
|
+
success: (title: string, opts?: ToastOptions) => string;
|
|
54
|
+
error: (title: string, opts?: ToastOptions) => string;
|
|
55
|
+
warning: (title: string, opts?: ToastOptions) => string;
|
|
56
|
+
info: (title: string, opts?: ToastOptions) => string;
|
|
57
|
+
loading: (title: string, opts?: ToastOptions) => string;
|
|
58
|
+
promise: <T>(
|
|
59
|
+
promise: Promise<T> | (() => Promise<T>),
|
|
60
|
+
msgs: { loading: string; success: Message<T>; error: Message<unknown> },
|
|
61
|
+
opts?: ToastOptions,
|
|
62
|
+
) => Promise<T>;
|
|
63
|
+
dismiss: (id?: string) => void;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
interface ToastStore {
|
|
67
|
+
subscribe: (listener: () => void) => () => void;
|
|
68
|
+
snapshot: () => ToastData[];
|
|
69
|
+
toast: ToastApi;
|
|
70
|
+
remove: (id: string) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createToastStore(): ToastStore {
|
|
74
|
+
let toasts: ToastData[] = [];
|
|
75
|
+
let counter = 0;
|
|
76
|
+
const listeners = new Set<() => void>();
|
|
77
|
+
|
|
78
|
+
const emit = () => listeners.forEach((l) => l());
|
|
79
|
+
const subscribe = (l: () => void) => {
|
|
80
|
+
listeners.add(l);
|
|
81
|
+
return () => {
|
|
82
|
+
listeners.delete(l);
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
const snapshot = () => toasts;
|
|
86
|
+
|
|
87
|
+
function upsert(type: ToastType, title: string, opts: ToastOptions = {}) {
|
|
88
|
+
const id = opts.id ?? `t${++counter}`;
|
|
89
|
+
const next: ToastData = { ...opts, id, type, title, updatedAt: Date.now() + Math.random(), dismissed: false };
|
|
90
|
+
const exists = toasts.some((t) => t.id === id);
|
|
91
|
+
toasts = exists ? toasts.map((t) => (t.id === id ? { ...t, ...next } : t)) : [next, ...toasts];
|
|
92
|
+
emit();
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function remove(id: string) {
|
|
97
|
+
if (!toasts.some((t) => t.id === id)) return;
|
|
98
|
+
toasts = toasts.filter((t) => t.id !== id);
|
|
99
|
+
emit();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const toastApi = Object.assign((title: string, opts?: ToastOptions) => upsert('default', title, opts), {
|
|
103
|
+
success: (title: string, opts?: ToastOptions) => upsert('success', title, opts),
|
|
104
|
+
error: (title: string, opts?: ToastOptions) => upsert('error', title, opts),
|
|
105
|
+
warning: (title: string, opts?: ToastOptions) => upsert('warning', title, opts),
|
|
106
|
+
info: (title: string, opts?: ToastOptions) => upsert('info', title, opts),
|
|
107
|
+
loading: (title: string, opts?: ToastOptions) => upsert('loading', title, opts),
|
|
108
|
+
promise<T>(
|
|
109
|
+
promise: Promise<T> | (() => Promise<T>),
|
|
110
|
+
msgs: { loading: string; success: Message<T>; error: Message<unknown> },
|
|
111
|
+
opts?: ToastOptions,
|
|
112
|
+
): Promise<T> {
|
|
113
|
+
const id = upsert('loading', msgs.loading, opts);
|
|
114
|
+
const p = typeof promise === 'function' ? promise() : promise;
|
|
115
|
+
p.then(
|
|
116
|
+
(v) => upsert('success', typeof msgs.success === 'function' ? msgs.success(v) : msgs.success, { ...opts, id }),
|
|
117
|
+
(e) => upsert('error', typeof msgs.error === 'function' ? msgs.error(e) : msgs.error, { ...opts, id }),
|
|
118
|
+
);
|
|
119
|
+
return p;
|
|
120
|
+
},
|
|
121
|
+
dismiss(id?: string) {
|
|
122
|
+
toasts = toasts.map((t) => (id === undefined || t.id === id ? { ...t, dismissed: true } : t));
|
|
123
|
+
emit();
|
|
124
|
+
},
|
|
125
|
+
}) as ToastApi;
|
|
126
|
+
|
|
127
|
+
return { subscribe, snapshot, toast: toastApi, remove };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const globalStore = createToastStore();
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Show a toast from anywhere — no hooks. Render one `<Toaster />` near the root.
|
|
134
|
+
* Prefer `useToast()` inside a `<ToastProvider>` when you need an isolated stack (e.g. demos).
|
|
135
|
+
*
|
|
136
|
+
* toast('Event created')
|
|
137
|
+
* toast.success('Saved', { description: 'All changes synced.' })
|
|
138
|
+
* toast.promise(upload(), { loading: 'Uploading…', success: 'Uploaded', error: 'Upload failed' })
|
|
139
|
+
*/
|
|
140
|
+
export const toast = globalStore.toast;
|
|
141
|
+
|
|
142
|
+
const ToastStoreContext = createContext<ToastStore | null>(null);
|
|
143
|
+
|
|
144
|
+
/** Scoped toast queue — nest a `<Toaster />` (and `useToast()`) inside so demos / sheets don't share the global store. */
|
|
145
|
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
146
|
+
const store = useMemo(() => createToastStore(), []);
|
|
147
|
+
return <ToastStoreContext.Provider value={store}>{children}</ToastStoreContext.Provider>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Toast API for the nearest `ToastProvider`, or the global `toast` if none. */
|
|
151
|
+
export function useToast(): ToastApi {
|
|
152
|
+
return useContext(ToastStoreContext)?.toast ?? globalStore.toast;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function useToastStore(): ToastStore {
|
|
156
|
+
return useContext(ToastStoreContext) ?? globalStore;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Shared behaviour ─────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function useReduceMotion() {
|
|
162
|
+
const [reduce, setReduce] = useState(false);
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
let alive = true;
|
|
165
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
166
|
+
.then((r) => alive && setReduce(r))
|
|
167
|
+
.catch(() => {});
|
|
168
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
169
|
+
return () => {
|
|
170
|
+
alive = false;
|
|
171
|
+
sub.remove();
|
|
172
|
+
};
|
|
173
|
+
}, []);
|
|
174
|
+
return reduce;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const HAPTIC: Partial<Record<ToastType, Haptics.NotificationFeedbackType>> = {
|
|
178
|
+
success: Haptics.NotificationFeedbackType.Success,
|
|
179
|
+
warning: Haptics.NotificationFeedbackType.Warning,
|
|
180
|
+
error: Haptics.NotificationFeedbackType.Error,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** One notification tap when a toast appears or changes tone. After a spinner, it lands as the circle closes. */
|
|
184
|
+
function useToneHaptic(id: string | undefined, type: ToastType | undefined, enabled: boolean) {
|
|
185
|
+
const prev = useRef<{ id?: string; type?: ToastType }>({});
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const from = prev.current;
|
|
188
|
+
prev.current = { id, type };
|
|
189
|
+
if (!enabled || !id || !type || (from.id === id && from.type === type)) return;
|
|
190
|
+
const kind = HAPTIC[type];
|
|
191
|
+
if (kind === undefined) return;
|
|
192
|
+
const t = setTimeout(
|
|
193
|
+
() => Haptics.notificationAsync(kind).catch(() => {}),
|
|
194
|
+
from.id === id && from.type === 'loading' ? 380 : 0,
|
|
195
|
+
);
|
|
196
|
+
return () => clearTimeout(t);
|
|
197
|
+
}, [id, type, enabled]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Auto-dismiss clock. Drives a native-driven 1 → 0 `life` value (the drain bar) and fires `onDone`.
|
|
202
|
+
* Both freeze while `paused` and resume where they stopped.
|
|
203
|
+
*/
|
|
204
|
+
function useLifetime(key: number | undefined, duration: number, running: boolean, onDone: () => void) {
|
|
205
|
+
const life = useRef(new Animated.Value(1)).current;
|
|
206
|
+
const remaining = useRef(duration);
|
|
207
|
+
const done = useRef(onDone);
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
done.current = onDone;
|
|
210
|
+
});
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
remaining.current = duration;
|
|
213
|
+
life.setValue(1);
|
|
214
|
+
}, [key, duration, life]);
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (!running || !Number.isFinite(duration) || duration <= 0) return;
|
|
217
|
+
const startedAt = Date.now();
|
|
218
|
+
life.setValue(Math.max(0, remaining.current / duration));
|
|
219
|
+
Animated.timing(life, {
|
|
220
|
+
toValue: 0,
|
|
221
|
+
duration: Math.max(0, remaining.current),
|
|
222
|
+
easing: Easing.linear,
|
|
223
|
+
useNativeDriver: true,
|
|
224
|
+
}).start();
|
|
225
|
+
const timer = setTimeout(() => done.current(), Math.max(0, remaining.current));
|
|
226
|
+
return () => {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
life.stopAnimation();
|
|
229
|
+
remaining.current -= Date.now() - startedAt;
|
|
230
|
+
life.setValue(Math.max(0, remaining.current / duration));
|
|
231
|
+
};
|
|
232
|
+
}, [key, duration, running, life]);
|
|
233
|
+
return life;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Toaster ──────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
export type ToasterPosition = 'top' | 'bottom';
|
|
239
|
+
export type ToasterVariant = 'default' | 'rich' | 'minimal' | 'island';
|
|
240
|
+
|
|
241
|
+
export interface ToasterProps {
|
|
242
|
+
/** Only show toasts sent with `{ toaster: id }`. */
|
|
243
|
+
id?: string;
|
|
244
|
+
/** Edge the stack sits on. Ignored by `variant="island"`, which always lives at the top centre. */
|
|
245
|
+
position?: ToasterPosition;
|
|
246
|
+
/**
|
|
247
|
+
* 'default' cards with a tone glow · 'rich' cards filled with the tone · 'minimal' compact inverted pills
|
|
248
|
+
* (a second line for the description when there is one) ·
|
|
249
|
+
* 'island' a black capsule at the top centre that springs open like the iPhone Dynamic Island.
|
|
250
|
+
*/
|
|
251
|
+
variant?: ToasterVariant;
|
|
252
|
+
/** Default auto-dismiss time in ms (default 4000). */
|
|
253
|
+
duration?: number;
|
|
254
|
+
/** How many cards peek out of the stack (default 3). */
|
|
255
|
+
visibleToasts?: number;
|
|
256
|
+
/** Always show the stack fanned out instead of collapsed. */
|
|
257
|
+
expand?: boolean;
|
|
258
|
+
/**
|
|
259
|
+
* Distance from the top / bottom edge (default 16, island 10). There is no safe-area dependency:
|
|
260
|
+
* pass `insets.top + 8` (react-native-safe-area-context) to sit the island below a notch.
|
|
261
|
+
*/
|
|
262
|
+
offset?: number;
|
|
263
|
+
/** Space between fanned-out cards (default 10). */
|
|
264
|
+
gap?: number;
|
|
265
|
+
/** Show a ✕ on every toast. */
|
|
266
|
+
closeButton?: boolean;
|
|
267
|
+
/** Success / warning / error toasts tap the matching notification haptic (default true). */
|
|
268
|
+
haptics?: boolean;
|
|
269
|
+
style?: StyleProp<ViewStyle>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const PEEK = 12;
|
|
273
|
+
const GUESS_HEIGHT = 64;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Hosts the toasts. It fills its parent absolutely (touches pass through), so drop it at the
|
|
277
|
+
* root of your app — or inside any container to scope it there. Toasts stack like cards, fan out
|
|
278
|
+
* when tapped, swipe away sideways or toward the edge, and pause their timers (and the draining
|
|
279
|
+
* time line) while you touch them. `variant="island"` turns it into a Dynamic Island.
|
|
280
|
+
*/
|
|
281
|
+
export function Toaster({
|
|
282
|
+
id,
|
|
283
|
+
position = 'bottom',
|
|
284
|
+
variant = 'default',
|
|
285
|
+
duration = 4000,
|
|
286
|
+
visibleToasts = 3,
|
|
287
|
+
expand = false,
|
|
288
|
+
offset,
|
|
289
|
+
gap = 10,
|
|
290
|
+
closeButton = false,
|
|
291
|
+
haptics = true,
|
|
292
|
+
style,
|
|
293
|
+
}: ToasterProps) {
|
|
294
|
+
const store = useToastStore();
|
|
295
|
+
const all = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
|
|
296
|
+
// Inside ToastProvider, show the whole scoped stack (id filter is for the global store only).
|
|
297
|
+
const mine = store === globalStore ? all.filter((t) => t.toaster === id) : all;
|
|
298
|
+
const reduceMotion = useReduceMotion();
|
|
299
|
+
|
|
300
|
+
const active = mine.filter((t) => !t.dismissed);
|
|
301
|
+
const [heights, setHeights] = useState<Record<string, number>>({});
|
|
302
|
+
const [open, setOpen] = useState(false);
|
|
303
|
+
const expanded = expand || (open && active.length > 1);
|
|
304
|
+
const s = position === 'bottom' ? -1 : 1; // direction the stack grows in
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- collapse once the stack is down to one card
|
|
308
|
+
if (active.length <= 1 && open) setOpen(false);
|
|
309
|
+
}, [active.length, open]);
|
|
310
|
+
|
|
311
|
+
if (variant === 'island') {
|
|
312
|
+
return (
|
|
313
|
+
<IslandToaster
|
|
314
|
+
store={store}
|
|
315
|
+
toasts={mine}
|
|
316
|
+
duration={duration}
|
|
317
|
+
offset={offset ?? 10}
|
|
318
|
+
haptics={haptics}
|
|
319
|
+
reduceMotion={reduceMotion}
|
|
320
|
+
style={style}
|
|
321
|
+
/>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const frontH = heights[active[0]?.id] ?? GUESS_HEIGHT;
|
|
326
|
+
const layout: Record<string, StackLayout> = {};
|
|
327
|
+
let acc = 0;
|
|
328
|
+
active.forEach((t, i) => {
|
|
329
|
+
const h = heights[t.id] ?? GUESS_HEIGHT;
|
|
330
|
+
if (expanded) {
|
|
331
|
+
layout[t.id] = { y: s * acc, scale: 1, hidden: i >= visibleToasts, faded: false };
|
|
332
|
+
} else {
|
|
333
|
+
const clamped = Math.min(h, frontH);
|
|
334
|
+
layout[t.id] = {
|
|
335
|
+
// Line up the far edges, then let each older card peek out a little further.
|
|
336
|
+
y: s * (frontH - clamped + PEEK * i),
|
|
337
|
+
scale: 1 - 0.05 * i,
|
|
338
|
+
hidden: i >= visibleToasts,
|
|
339
|
+
clamp: i > 0 ? frontH : undefined,
|
|
340
|
+
faded: i > 0,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
acc += h + gap;
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<View pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
|
|
348
|
+
{[...mine].reverse().map((t) => (
|
|
349
|
+
<ToastItem
|
|
350
|
+
key={t.id}
|
|
351
|
+
data={t}
|
|
352
|
+
layout={layout[t.id]}
|
|
353
|
+
edge={position}
|
|
354
|
+
edgeOffset={offset ?? 16}
|
|
355
|
+
variant={variant}
|
|
356
|
+
duration={t.duration ?? (t.type === 'loading' ? Infinity : duration)}
|
|
357
|
+
paused={expanded}
|
|
358
|
+
closeButton={closeButton}
|
|
359
|
+
haptics={haptics}
|
|
360
|
+
reduceMotion={reduceMotion}
|
|
361
|
+
onHeight={(h) => setHeights((prev) => (prev[t.id] === h ? prev : { ...prev, [t.id]: h }))}
|
|
362
|
+
onPress={() => active.length > 1 && !expand && setOpen((o) => !o)}
|
|
363
|
+
onRemoved={() => {
|
|
364
|
+
store.remove(t.id);
|
|
365
|
+
setHeights(({ [t.id]: _, ...rest }) => rest);
|
|
366
|
+
}}
|
|
367
|
+
/>
|
|
368
|
+
))}
|
|
369
|
+
</View>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── Toast card ───────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
interface StackLayout {
|
|
376
|
+
y: number;
|
|
377
|
+
scale: number;
|
|
378
|
+
hidden: boolean;
|
|
379
|
+
clamp?: number;
|
|
380
|
+
faded: boolean;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
interface ToastItemProps {
|
|
384
|
+
data: ToastData;
|
|
385
|
+
/** Undefined while the toast is leaving. */
|
|
386
|
+
layout?: StackLayout;
|
|
387
|
+
edge: ToasterPosition;
|
|
388
|
+
edgeOffset: number;
|
|
389
|
+
variant: Exclude<ToasterVariant, 'island'>;
|
|
390
|
+
duration: number;
|
|
391
|
+
paused: boolean;
|
|
392
|
+
closeButton: boolean;
|
|
393
|
+
haptics: boolean;
|
|
394
|
+
reduceMotion: boolean;
|
|
395
|
+
onHeight: (h: number) => void;
|
|
396
|
+
onPress: () => void;
|
|
397
|
+
onRemoved: () => void;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function rubber(d: number) {
|
|
401
|
+
return (d * 40) / (40 + d);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function ToastItem({
|
|
405
|
+
data,
|
|
406
|
+
layout,
|
|
407
|
+
edge,
|
|
408
|
+
edgeOffset,
|
|
409
|
+
variant,
|
|
410
|
+
duration,
|
|
411
|
+
paused,
|
|
412
|
+
closeButton,
|
|
413
|
+
haptics,
|
|
414
|
+
reduceMotion,
|
|
415
|
+
onHeight,
|
|
416
|
+
onPress,
|
|
417
|
+
onRemoved,
|
|
418
|
+
}: ToastItemProps) {
|
|
419
|
+
const toast = useToast();
|
|
420
|
+
const { colors, radius, dark } = useKit();
|
|
421
|
+
const toward = edge === 'bottom' ? 1 : -1; // swipe direction that dismisses toward the edge
|
|
422
|
+
const last = useRef(layout ?? { y: 0, scale: 1, hidden: false, faded: false });
|
|
423
|
+
if (layout) last.current = layout;
|
|
424
|
+
const L = last.current;
|
|
425
|
+
|
|
426
|
+
// Every value here is native-driven: they all end up in transform / opacity.
|
|
427
|
+
const enter = useRef(new Animated.Value(0)).current;
|
|
428
|
+
const y = useRef(new Animated.Value(L.y)).current;
|
|
429
|
+
const scale = useRef(new Animated.Value(L.scale)).current;
|
|
430
|
+
const vis = useRef(new Animated.Value(L.hidden ? 0 : 1)).current;
|
|
431
|
+
const content = useRef(new Animated.Value(L.faded ? 0 : 1)).current;
|
|
432
|
+
const dragX = useRef(new Animated.Value(0)).current;
|
|
433
|
+
const dragY = useRef(new Animated.Value(0)).current;
|
|
434
|
+
const [touching, setTouching] = useState(false);
|
|
435
|
+
const gone = useRef(false);
|
|
436
|
+
|
|
437
|
+
useToneHaptic(data.id, data.type, haptics && !data.dismissed);
|
|
438
|
+
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
|
|
441
|
+
}, [enter]);
|
|
442
|
+
|
|
443
|
+
const target = layout ?? L;
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (!layout || data.dismissed) return;
|
|
446
|
+
Animated.parallel([
|
|
447
|
+
Animated.spring(y, { toValue: target.y, useNativeDriver: true, ...motion.snappy }),
|
|
448
|
+
Animated.spring(scale, { toValue: target.scale, useNativeDriver: true, ...motion.snappy }),
|
|
449
|
+
Animated.timing(vis, { toValue: target.hidden ? 0 : 1, duration: 200, useNativeDriver: true }),
|
|
450
|
+
Animated.timing(content, { toValue: target.faded ? 0 : 1, duration: 200, useNativeDriver: true }),
|
|
451
|
+
]).start();
|
|
452
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-run on the numbers, not the object identity
|
|
453
|
+
}, [target.y, target.scale, target.hidden, target.faded, data.dismissed]);
|
|
454
|
+
|
|
455
|
+
// Leave: fade + slide toward the edge, then drop out of the store.
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
if (!data.dismissed || gone.current) return;
|
|
458
|
+
gone.current = true;
|
|
459
|
+
Animated.timing(enter, { toValue: 0, duration: 200, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(onRemoved);
|
|
460
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
461
|
+
}, [data.dismissed]);
|
|
462
|
+
|
|
463
|
+
// Auto-dismiss timer + drain line; both pause while the stack is open or a finger is down.
|
|
464
|
+
const life = useLifetime(data.updatedAt, duration, !data.dismissed && !paused && !touching, () => toast.dismiss(data.id));
|
|
465
|
+
|
|
466
|
+
const flyOut = (axis: 'x' | 'y', to: number) => {
|
|
467
|
+
gone.current = true;
|
|
468
|
+
Animated.parallel([
|
|
469
|
+
Animated.timing(axis === 'x' ? dragX : dragY, {
|
|
470
|
+
toValue: to,
|
|
471
|
+
duration: 180,
|
|
472
|
+
easing: Easing.out(Easing.quad),
|
|
473
|
+
useNativeDriver: true,
|
|
474
|
+
}),
|
|
475
|
+
Animated.timing(enter, { toValue: 0, duration: 180, useNativeDriver: true }),
|
|
476
|
+
]).start(onRemoved);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const pan = useRef(
|
|
480
|
+
(() => {
|
|
481
|
+
const gesture = { axis: null as 'x' | 'y' | null };
|
|
482
|
+
return PanResponder.create({
|
|
483
|
+
onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 6 || Math.abs(g.dy) > 6,
|
|
484
|
+
onPanResponderGrant: () => {
|
|
485
|
+
gesture.axis = null;
|
|
486
|
+
setTouching(true);
|
|
487
|
+
},
|
|
488
|
+
onPanResponderMove: (_, g) => {
|
|
489
|
+
if (!gesture.axis) gesture.axis = Math.abs(g.dx) > Math.abs(g.dy) ? 'x' : 'y';
|
|
490
|
+
if (gesture.axis === 'x') dragX.setValue(g.dx);
|
|
491
|
+
else {
|
|
492
|
+
const d = g.dy * toward;
|
|
493
|
+
dragY.setValue(d >= 0 ? g.dy : -toward * rubber(-d));
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
onPanResponderRelease: (_, g) => {
|
|
497
|
+
setTouching(false);
|
|
498
|
+
const { axis } = gesture;
|
|
499
|
+
if (axis === 'x' && (Math.abs(g.dx) > 90 || Math.abs(g.vx) > 0.7)) return flyOut('x', Math.sign(g.dx || g.vx) * 520);
|
|
500
|
+
if (axis === 'y' && (g.dy * toward > 36 || g.vy * toward > 0.6)) return flyOut('y', toward * 140);
|
|
501
|
+
Animated.parallel([
|
|
502
|
+
Animated.spring(dragX, { toValue: 0, useNativeDriver: true, ...motion.snappy }),
|
|
503
|
+
Animated.spring(dragY, { toValue: 0, useNativeDriver: true, ...motion.snappy }),
|
|
504
|
+
]).start();
|
|
505
|
+
},
|
|
506
|
+
onPanResponderTerminate: () => {
|
|
507
|
+
setTouching(false);
|
|
508
|
+
Animated.spring(dragX, { toValue: 0, useNativeDriver: true }).start();
|
|
509
|
+
Animated.spring(dragY, { toValue: 0, useNativeDriver: true }).start();
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
})(),
|
|
513
|
+
).current;
|
|
514
|
+
|
|
515
|
+
const look = theme(variant, data.type, colors, dark);
|
|
516
|
+
const minimal = variant === 'minimal';
|
|
517
|
+
// A one-line pill stays a capsule; with a description it becomes a soft rounded card.
|
|
518
|
+
const r = minimal ? (data.description ? radius.scale(20) : radius.capsule(44)) : radius.lg;
|
|
519
|
+
|
|
520
|
+
const opacity = Animated.multiply(
|
|
521
|
+
Animated.multiply(enter, vis),
|
|
522
|
+
dragX.interpolate({ inputRange: [-260, 0, 260], outputRange: [0, 1, 0], extrapolate: 'clamp' }),
|
|
523
|
+
);
|
|
524
|
+
const translateY = Animated.add(
|
|
525
|
+
Animated.add(y, dragY),
|
|
526
|
+
enter.interpolate({ inputRange: [0, 1], outputRange: [toward * 28, 0] }),
|
|
527
|
+
);
|
|
528
|
+
const s = Animated.multiply(scale, enter.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }));
|
|
529
|
+
|
|
530
|
+
const typed = data.type !== 'default';
|
|
531
|
+
const icon =
|
|
532
|
+
data.icon === false ? null : (data.icon ??
|
|
533
|
+
(typed ? <LivingIcon type={data.type} color={look.icon} halo={!minimal} reduceMotion={reduceMotion} /> : null));
|
|
534
|
+
const showGlow = !minimal && typed && data.icon !== false && look.glow > 0;
|
|
535
|
+
const showBar = !minimal && Number.isFinite(duration);
|
|
536
|
+
|
|
537
|
+
return (
|
|
538
|
+
<Animated.View
|
|
539
|
+
pointerEvents="box-none"
|
|
540
|
+
style={[
|
|
541
|
+
styles.lane,
|
|
542
|
+
edge === 'bottom' ? { bottom: edgeOffset } : { top: edgeOffset },
|
|
543
|
+
{
|
|
544
|
+
opacity,
|
|
545
|
+
transform: [{ translateY }, { translateX: dragX }, { scale: s }],
|
|
546
|
+
transformOrigin: edge === 'bottom' ? 'center top' : 'center bottom',
|
|
547
|
+
},
|
|
548
|
+
]}
|
|
549
|
+
>
|
|
550
|
+
{/* Shadow layer (unclipped) */}
|
|
551
|
+
<View
|
|
552
|
+
{...pan.panHandlers}
|
|
553
|
+
style={[
|
|
554
|
+
minimal ? styles.cardMinimal : styles.cardFull,
|
|
555
|
+
{ borderRadius: r, backgroundColor: look.bg, maxHeight: L.clamp, boxShadow: shadowFor(dark, minimal) },
|
|
556
|
+
]}
|
|
557
|
+
>
|
|
558
|
+
{/* Surface (clips the glow and drain line to the corners) */}
|
|
559
|
+
<View style={[styles.clip, { borderRadius: r, borderColor: look.border, maxHeight: L.clamp }]}>
|
|
560
|
+
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: content }]}>
|
|
561
|
+
{showGlow ? <Glow color={look.glowColor} strength={look.glow} x={32} y={32} /> : null}
|
|
562
|
+
</Animated.View>
|
|
563
|
+
<Pressable
|
|
564
|
+
onPress={onPress}
|
|
565
|
+
onPressIn={() => setTouching(true)}
|
|
566
|
+
onPressOut={() => setTouching(false)}
|
|
567
|
+
accessibilityRole="alert"
|
|
568
|
+
accessibilityLiveRegion="polite"
|
|
569
|
+
accessibilityLabel={[data.title, data.description].filter(Boolean).join('. ')}
|
|
570
|
+
onLayout={(e) => onHeight(Math.round(e.nativeEvent.layout.height))}
|
|
571
|
+
>
|
|
572
|
+
<Animated.View style={[styles.row, minimal ? styles.padMinimal : icon ? styles.padIcon : styles.pad, { opacity: content }]}>
|
|
573
|
+
{icon ? <View style={!minimal && data.description ? styles.iconTop : null}>{icon}</View> : null}
|
|
574
|
+
<View style={minimal ? styles.textMinimal : styles.text}>
|
|
575
|
+
<Text numberOfLines={minimal ? 1 : 3} style={[styles.title, { color: look.title }]}>
|
|
576
|
+
{data.title}
|
|
577
|
+
</Text>
|
|
578
|
+
{data.description ? (
|
|
579
|
+
<Text numberOfLines={minimal ? 2 : 4} style={[styles.desc, minimal && styles.descMinimal, { color: look.desc }]}>
|
|
580
|
+
{data.description}
|
|
581
|
+
</Text>
|
|
582
|
+
) : null}
|
|
583
|
+
</View>
|
|
584
|
+
{data.action ? (
|
|
585
|
+
<Pressable
|
|
586
|
+
accessibilityRole="button"
|
|
587
|
+
hitSlop={8}
|
|
588
|
+
onPress={() => {
|
|
589
|
+
data.action?.onPress();
|
|
590
|
+
toast.dismiss(data.id);
|
|
591
|
+
}}
|
|
592
|
+
style={({ pressed }) => [
|
|
593
|
+
styles.action,
|
|
594
|
+
{ borderRadius: radius.capsule(32), backgroundColor: look.actionBg, opacity: pressed ? 0.7 : 1 },
|
|
595
|
+
]}
|
|
596
|
+
>
|
|
597
|
+
<Text style={[styles.actionText, { color: look.actionFg }]}>{data.action.label}</Text>
|
|
598
|
+
</Pressable>
|
|
599
|
+
) : null}
|
|
600
|
+
{closeButton ? (
|
|
601
|
+
<Pressable
|
|
602
|
+
accessibilityRole="button"
|
|
603
|
+
accessibilityLabel="Dismiss"
|
|
604
|
+
hitSlop={12}
|
|
605
|
+
onPress={() => toast.dismiss(data.id)}
|
|
606
|
+
style={styles.close}
|
|
607
|
+
>
|
|
608
|
+
<Svg width={14} height={14} viewBox="0 0 24 24">
|
|
609
|
+
<Path d="M6 6l12 12M18 6L6 18" stroke={look.desc} strokeWidth={2.4} strokeLinecap="round" />
|
|
610
|
+
</Svg>
|
|
611
|
+
</Pressable>
|
|
612
|
+
) : null}
|
|
613
|
+
</Animated.View>
|
|
614
|
+
</Pressable>
|
|
615
|
+
{showBar ? (
|
|
616
|
+
<Animated.View
|
|
617
|
+
pointerEvents="none"
|
|
618
|
+
style={[styles.bar, { backgroundColor: look.bar, opacity: content, transform: [{ scaleX: life }] }]}
|
|
619
|
+
/>
|
|
620
|
+
) : null}
|
|
621
|
+
</View>
|
|
622
|
+
</View>
|
|
623
|
+
</Animated.View>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ─── Island ───────────────────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
const CAP_W = 124;
|
|
630
|
+
const CAP_H = 36;
|
|
631
|
+
type Stage = 'hidden' | 'capsule' | 'open';
|
|
632
|
+
|
|
633
|
+
interface IslandToasterProps {
|
|
634
|
+
store: ToastStore;
|
|
635
|
+
toasts: ToastData[];
|
|
636
|
+
duration: number;
|
|
637
|
+
offset: number;
|
|
638
|
+
haptics: boolean;
|
|
639
|
+
reduceMotion: boolean;
|
|
640
|
+
style?: StyleProp<ViewStyle>;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Dynamic-Island host: one black capsule at the top centre. It widens, then grows taller into a card,
|
|
645
|
+
* holds, and folds back into the capsule. A newer toast squashes the island and swaps its content in place.
|
|
646
|
+
*
|
|
647
|
+
* Drivers: `w` / `h` (size, corner) are JS-driven on `frame` + `clip`; presence, squash, drag and content
|
|
648
|
+
* fade are native-driven on their own wrapper views.
|
|
649
|
+
*/
|
|
650
|
+
function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion, style }: IslandToasterProps) {
|
|
651
|
+
const { colors, radius, dark } = useKit();
|
|
652
|
+
const active = toasts.filter((t) => !t.dismissed);
|
|
653
|
+
const front = active[0];
|
|
654
|
+
|
|
655
|
+
const [stage, setStageState] = useState<Stage>('hidden');
|
|
656
|
+
const stageRef = useRef<Stage>('hidden');
|
|
657
|
+
const [shownId, setShownIdState] = useState<string | null>(null);
|
|
658
|
+
const shownRef = useRef<string | null>(null);
|
|
659
|
+
const lastShown = useRef<ToastData | null>(null);
|
|
660
|
+
const [laneW, setLaneW] = useState(0);
|
|
661
|
+
const [contentH, setContentH] = useState(0);
|
|
662
|
+
const [expanded, setExpanded] = useState(false);
|
|
663
|
+
const [touching, setTouching] = useState(false);
|
|
664
|
+
|
|
665
|
+
const live = shownId ? toasts.find((t) => t.id === shownId) : undefined;
|
|
666
|
+
useEffect(() => {
|
|
667
|
+
if (live) lastShown.current = live;
|
|
668
|
+
});
|
|
669
|
+
const shown = live ?? (shownId ? lastShown.current : null);
|
|
670
|
+
|
|
671
|
+
// JS-driven (layout)
|
|
672
|
+
const w = useRef(new Animated.Value(CAP_W)).current;
|
|
673
|
+
const h = useRef(new Animated.Value(CAP_H)).current;
|
|
674
|
+
// Native-driven (transform / opacity)
|
|
675
|
+
const presence = useRef(new Animated.Value(0)).current;
|
|
676
|
+
const sqX = useRef(new Animated.Value(1)).current;
|
|
677
|
+
const sqY = useRef(new Animated.Value(1)).current;
|
|
678
|
+
const dragY = useRef(new Animated.Value(0)).current;
|
|
679
|
+
const content = useRef(new Animated.Value(0)).current;
|
|
680
|
+
|
|
681
|
+
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
682
|
+
const later = (ms: number, fn: () => void) => {
|
|
683
|
+
timers.current.push(setTimeout(fn, ms));
|
|
684
|
+
};
|
|
685
|
+
const clearLater = () => {
|
|
686
|
+
timers.current.forEach(clearTimeout);
|
|
687
|
+
timers.current = [];
|
|
688
|
+
};
|
|
689
|
+
useEffect(() => () => timers.current.forEach(clearTimeout), []);
|
|
690
|
+
|
|
691
|
+
const setStage = (s: Stage) => {
|
|
692
|
+
stageRef.current = s;
|
|
693
|
+
setStageState(s);
|
|
694
|
+
};
|
|
695
|
+
const setShown = (id: string | null) => {
|
|
696
|
+
shownRef.current = id;
|
|
697
|
+
setShownIdState(id);
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const squash = () => {
|
|
701
|
+
if (reduceMotion) return;
|
|
702
|
+
Animated.sequence([
|
|
703
|
+
Animated.parallel([
|
|
704
|
+
Animated.timing(sqX, { toValue: 1.05, duration: 90, easing: Easing.out(Easing.quad), useNativeDriver: true }),
|
|
705
|
+
Animated.timing(sqY, { toValue: 0.9, duration: 90, easing: Easing.out(Easing.quad), useNativeDriver: true }),
|
|
706
|
+
]),
|
|
707
|
+
Animated.parallel([
|
|
708
|
+
Animated.spring(sqX, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
|
|
709
|
+
Animated.spring(sqY, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
|
|
710
|
+
]),
|
|
711
|
+
]).start();
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// Lifecycle: react to the front toast changing.
|
|
715
|
+
useEffect(() => {
|
|
716
|
+
const current = shownRef.current;
|
|
717
|
+
if (front) {
|
|
718
|
+
if (!current || stageRef.current === 'hidden') {
|
|
719
|
+
clearLater();
|
|
720
|
+
dragY.setValue(0);
|
|
721
|
+
setShown(front.id);
|
|
722
|
+
setExpanded(false);
|
|
723
|
+
setStage('capsule');
|
|
724
|
+
later(reduceMotion ? 0 : 140, () => setStage('open'));
|
|
725
|
+
} else if (front.id !== current) {
|
|
726
|
+
clearLater();
|
|
727
|
+
// Replace in place: older toasts are superseded.
|
|
728
|
+
toasts.forEach((t) => t.id !== front.id && store.remove(t.id));
|
|
729
|
+
if (stageRef.current === 'open') {
|
|
730
|
+
Animated.timing(content, { toValue: 0, duration: 90, useNativeDriver: true }).start(() => {
|
|
731
|
+
setShown(front.id);
|
|
732
|
+
setExpanded(false);
|
|
733
|
+
squash();
|
|
734
|
+
Animated.timing(content, { toValue: 1, duration: 200, delay: 30, useNativeDriver: true }).start();
|
|
735
|
+
});
|
|
736
|
+
} else {
|
|
737
|
+
setShown(front.id);
|
|
738
|
+
setExpanded(false);
|
|
739
|
+
setStage('open');
|
|
740
|
+
}
|
|
741
|
+
} else if (stageRef.current !== 'open') {
|
|
742
|
+
clearLater();
|
|
743
|
+
setStage('open');
|
|
744
|
+
}
|
|
745
|
+
} else if (current && stageRef.current !== 'hidden') {
|
|
746
|
+
// Fold back into the capsule, then vanish.
|
|
747
|
+
clearLater();
|
|
748
|
+
setExpanded(false);
|
|
749
|
+
setStage('capsule');
|
|
750
|
+
later(reduceMotion ? 120 : 360, () => setStage('hidden'));
|
|
751
|
+
later(reduceMotion ? 300 : 620, () => {
|
|
752
|
+
setShown(null);
|
|
753
|
+
store.remove(current);
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- driven by the front toast only
|
|
757
|
+
}, [front?.id]);
|
|
758
|
+
|
|
759
|
+
// Same toast, new tone (loading → success): a small squash as the icon morphs.
|
|
760
|
+
const shownType = shown?.type;
|
|
761
|
+
const prevType = useRef(shownType);
|
|
762
|
+
useEffect(() => {
|
|
763
|
+
if (prevType.current && shownType && prevType.current !== shownType && stageRef.current === 'open') squash();
|
|
764
|
+
prevType.current = shownType;
|
|
765
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
766
|
+
}, [shownType]);
|
|
767
|
+
|
|
768
|
+
// Declarative size / presence per stage.
|
|
769
|
+
const openW = laneW ? Math.min(laneW - 24, 380) : CAP_W;
|
|
770
|
+
useEffect(() => {
|
|
771
|
+
const isOpen = stage === 'open';
|
|
772
|
+
const tw = isOpen ? openW : CAP_W;
|
|
773
|
+
const th = isOpen ? Math.max(CAP_H, contentH || 64) : CAP_H;
|
|
774
|
+
const wA = Animated.spring(w, { toValue: tw, useNativeDriver: false, ...(isOpen ? motion.bouncy : motion.snappy) });
|
|
775
|
+
const hA = Animated.spring(h, { toValue: th, useNativeDriver: false, ...motion.snappy });
|
|
776
|
+
Animated.spring(presence, { toValue: stage === 'hidden' ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
|
|
777
|
+
if (isOpen) {
|
|
778
|
+
Animated.stagger(reduceMotion ? 0 : 90, [wA, hA]).start();
|
|
779
|
+
Animated.timing(content, { toValue: 1, duration: 220, delay: reduceMotion ? 0 : 160, useNativeDriver: true }).start();
|
|
780
|
+
} else {
|
|
781
|
+
Animated.timing(content, { toValue: 0, duration: 110, useNativeDriver: true }).start();
|
|
782
|
+
Animated.stagger(reduceMotion ? 0 : 70, [hA, wA]).start();
|
|
783
|
+
}
|
|
784
|
+
}, [stage, openW, contentH, reduceMotion, w, h, presence, content]);
|
|
785
|
+
|
|
786
|
+
useToneHaptic(shown?.id, shown?.type, haptics && stage === 'open' && !shown?.dismissed);
|
|
787
|
+
|
|
788
|
+
const dur = shown ? (shown.duration ?? (shown.type === 'loading' ? Infinity : duration)) : Infinity;
|
|
789
|
+
const life = useLifetime(
|
|
790
|
+
shown ? shown.updatedAt : undefined,
|
|
791
|
+
dur,
|
|
792
|
+
!!shown && !shown.dismissed && stage === 'open' && !touching && !expanded,
|
|
793
|
+
() => shownRef.current && store.toast.dismiss(shownRef.current),
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
// Swipe up to dismiss.
|
|
797
|
+
const pan = useRef(
|
|
798
|
+
PanResponder.create({
|
|
799
|
+
onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 6 && Math.abs(g.dy) > Math.abs(g.dx),
|
|
800
|
+
onPanResponderGrant: () => setTouching(true),
|
|
801
|
+
onPanResponderMove: (_, g) => dragY.setValue(g.dy < 0 ? g.dy : rubber(g.dy)),
|
|
802
|
+
onPanResponderRelease: (_, g) => {
|
|
803
|
+
setTouching(false);
|
|
804
|
+
const id = shownRef.current;
|
|
805
|
+
if (id && (g.dy < -28 || g.vy < -0.5)) {
|
|
806
|
+
clearLater();
|
|
807
|
+
Animated.parallel([
|
|
808
|
+
Animated.timing(dragY, { toValue: -90, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: true }),
|
|
809
|
+
Animated.timing(presence, { toValue: 0, duration: 160, useNativeDriver: true }),
|
|
810
|
+
Animated.timing(content, { toValue: 0, duration: 100, useNativeDriver: true }),
|
|
811
|
+
]).start(() => {
|
|
812
|
+
w.setValue(CAP_W);
|
|
813
|
+
h.setValue(CAP_H);
|
|
814
|
+
dragY.setValue(0);
|
|
815
|
+
setExpanded(false);
|
|
816
|
+
setStage('hidden');
|
|
817
|
+
setShown(null);
|
|
818
|
+
store.remove(id);
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
Animated.spring(dragY, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
823
|
+
},
|
|
824
|
+
onPanResponderTerminate: () => {
|
|
825
|
+
setTouching(false);
|
|
826
|
+
Animated.spring(dragY, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
827
|
+
},
|
|
828
|
+
}),
|
|
829
|
+
).current;
|
|
830
|
+
|
|
831
|
+
const bg = dark ? mix('#000000', colors.foreground, 0.05) : '#000000';
|
|
832
|
+
const fg = readableOn(bg);
|
|
833
|
+
const tone = !shown || shown.type === 'default' || shown.type === 'loading' ? mix(fg, bg, 0.25) : toneOf(shown.type, colors, true);
|
|
834
|
+
const capR = radius.capsule(CAP_H);
|
|
835
|
+
const cardR = Math.max(capR, radius.scale(30));
|
|
836
|
+
const corner = h.interpolate({ inputRange: [CAP_H, CAP_H + 28], outputRange: [capR, cardR], extrapolate: 'clamp' });
|
|
837
|
+
const left = Animated.multiply(Animated.subtract(w, openW), 0.5);
|
|
838
|
+
const pScale = presence.interpolate({ inputRange: [0, 1], outputRange: [0.5, 1] });
|
|
839
|
+
const typed = !!shown && shown.type !== 'default';
|
|
840
|
+
const icon =
|
|
841
|
+
!shown || shown.icon === false
|
|
842
|
+
? null
|
|
843
|
+
: (shown.icon ?? (typed ? <LivingIcon type={shown.type} color={tone} reduceMotion={reduceMotion} /> : null));
|
|
844
|
+
const showBar = Number.isFinite(dur);
|
|
845
|
+
|
|
846
|
+
return (
|
|
847
|
+
<View pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
|
|
848
|
+
<View
|
|
849
|
+
pointerEvents="box-none"
|
|
850
|
+
style={[styles.islandLane, { top: offset }]}
|
|
851
|
+
onLayout={(e) => setLaneW(Math.round(e.nativeEvent.layout.width))}
|
|
852
|
+
>
|
|
853
|
+
{shown ? (
|
|
854
|
+
<Animated.View
|
|
855
|
+
{...pan.panHandlers}
|
|
856
|
+
style={{
|
|
857
|
+
opacity: presence,
|
|
858
|
+
transform: [{ translateY: dragY }, { scaleX: Animated.multiply(pScale, sqX) }, { scaleY: Animated.multiply(pScale, sqY) }],
|
|
859
|
+
transformOrigin: 'center top',
|
|
860
|
+
}}
|
|
861
|
+
>
|
|
862
|
+
<Animated.View
|
|
863
|
+
style={{
|
|
864
|
+
width: w,
|
|
865
|
+
height: h,
|
|
866
|
+
borderRadius: corner,
|
|
867
|
+
backgroundColor: bg,
|
|
868
|
+
boxShadow: dark
|
|
869
|
+
? '0 0 0 1px rgba(255,255,255,0.08), 0 10px 30px -6px rgba(0,0,0,0.6)'
|
|
870
|
+
: '0 2px 6px rgba(0,0,0,0.12), 0 14px 36px -8px rgba(0,0,0,0.35)',
|
|
871
|
+
}}
|
|
872
|
+
>
|
|
873
|
+
<Animated.View style={[styles.islandClip, { borderRadius: corner }]}>
|
|
874
|
+
<Animated.View style={[styles.islandPlace, { width: openW, left }]}>
|
|
875
|
+
<Animated.View
|
|
876
|
+
style={{
|
|
877
|
+
opacity: content,
|
|
878
|
+
transform: [{ scale: content.interpolate({ inputRange: [0, 1], outputRange: [0.92, 1] }) }],
|
|
879
|
+
}}
|
|
880
|
+
onLayout={(e) => setContentH(Math.round(e.nativeEvent.layout.height))}
|
|
881
|
+
>
|
|
882
|
+
{typed && shown.icon !== false ? <Glow color={tone} strength={0.3} x={33} y={32} /> : null}
|
|
883
|
+
<Pressable
|
|
884
|
+
onPress={() => setExpanded((x) => !x)}
|
|
885
|
+
onPressIn={() => setTouching(true)}
|
|
886
|
+
onPressOut={() => setTouching(false)}
|
|
887
|
+
accessibilityRole="alert"
|
|
888
|
+
accessibilityLiveRegion="polite"
|
|
889
|
+
accessibilityLabel={[shown.title, shown.description].filter(Boolean).join('. ')}
|
|
890
|
+
accessibilityHint={shown.description || shown.action ? (expanded ? 'Collapses the notification' : 'Expands the notification') : undefined}
|
|
891
|
+
accessibilityState={{ expanded }}
|
|
892
|
+
style={[styles.row, icon ? styles.islandPadIcon : styles.islandPad]}
|
|
893
|
+
>
|
|
894
|
+
{icon ? <View style={expanded && shown.description ? styles.iconTop : null}>{icon}</View> : null}
|
|
895
|
+
<View style={styles.text}>
|
|
896
|
+
<Text numberOfLines={expanded ? 3 : 1} style={[styles.title, { color: fg }]}>
|
|
897
|
+
{shown.title}
|
|
898
|
+
</Text>
|
|
899
|
+
{shown.description ? (
|
|
900
|
+
<Text numberOfLines={expanded ? undefined : 1} style={[styles.desc, { color: alpha(fg, 0.62) }]}>
|
|
901
|
+
{shown.description}
|
|
902
|
+
</Text>
|
|
903
|
+
) : null}
|
|
904
|
+
</View>
|
|
905
|
+
{!expanded && shown.action ? (
|
|
906
|
+
<View style={[styles.islandChip, { borderRadius: radius.capsule(24), backgroundColor: alpha(fg, 0.14) }]}>
|
|
907
|
+
<Text style={[styles.islandChipText, { color: fg }]}>{shown.action.label}</Text>
|
|
908
|
+
</View>
|
|
909
|
+
) : null}
|
|
910
|
+
</Pressable>
|
|
911
|
+
{expanded && shown.action ? (
|
|
912
|
+
<View style={styles.islandActions}>
|
|
913
|
+
<Pressable
|
|
914
|
+
accessibilityRole="button"
|
|
915
|
+
onPress={() => {
|
|
916
|
+
shown.action?.onPress();
|
|
917
|
+
store.toast.dismiss(shown.id);
|
|
918
|
+
}}
|
|
919
|
+
style={({ pressed }) => [
|
|
920
|
+
styles.islandAction,
|
|
921
|
+
{ borderRadius: radius.capsule(44), backgroundColor: alpha(fg, pressed ? 0.24 : 0.16) },
|
|
922
|
+
]}
|
|
923
|
+
>
|
|
924
|
+
<Text style={[styles.title, { color: fg }]}>{shown.action.label}</Text>
|
|
925
|
+
</Pressable>
|
|
926
|
+
</View>
|
|
927
|
+
) : null}
|
|
928
|
+
{showBar ? (
|
|
929
|
+
<View style={[styles.islandTrack, { backgroundColor: alpha(fg, 0.1) }]}>
|
|
930
|
+
<Animated.View
|
|
931
|
+
style={[styles.islandFill, { backgroundColor: typed ? tone : alpha(fg, 0.5), transform: [{ scaleX: life }] }]}
|
|
932
|
+
/>
|
|
933
|
+
</View>
|
|
934
|
+
) : null}
|
|
935
|
+
</Animated.View>
|
|
936
|
+
</Animated.View>
|
|
937
|
+
</Animated.View>
|
|
938
|
+
</Animated.View>
|
|
939
|
+
</Animated.View>
|
|
940
|
+
) : null}
|
|
941
|
+
</View>
|
|
942
|
+
</View>
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ─── Look ─────────────────────────────────────────────────────────────────────
|
|
947
|
+
|
|
948
|
+
function hex6(h: string) {
|
|
949
|
+
return h.length === 4 ? '#' + [...h.slice(1)].map((c) => c + c).join('') : h;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function rgb(h: string) {
|
|
953
|
+
const n = parseInt(hex6(h).slice(1, 7), 16);
|
|
954
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function toHex(v: number[]) {
|
|
958
|
+
return '#' + v.map((x) => Math.round(Math.max(0, Math.min(255, x))).toString(16).padStart(2, '0')).join('');
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Opaque blend of two #rrggbb colours (stacked cards must not see through each other). */
|
|
962
|
+
function mix(a: string, b: string, t: number) {
|
|
963
|
+
if (!a.startsWith('#') || !b.startsWith('#')) return a;
|
|
964
|
+
const [x, y] = [rgb(a), rgb(b)];
|
|
965
|
+
return toHex(x.map((v, i) => v + (y[i] - v) * t));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function toneOf(type: ToastType, c: Colors, dark: boolean) {
|
|
969
|
+
const tones: Record<ToastType, string> = {
|
|
970
|
+
default: c.foreground,
|
|
971
|
+
loading: c.mutedForeground,
|
|
972
|
+
success: c.success,
|
|
973
|
+
error: c.danger,
|
|
974
|
+
warning: c.warning,
|
|
975
|
+
info: dark ? '#60a5fa' : '#2563eb',
|
|
976
|
+
};
|
|
977
|
+
return tones[type];
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function shadowFor(dark: boolean, minimal: boolean) {
|
|
981
|
+
if (minimal) return dark ? '0 6px 20px -4px rgba(0,0,0,0.6)' : '0 6px 20px -6px rgba(0,0,0,0.3)';
|
|
982
|
+
return dark
|
|
983
|
+
? '0 1px 2px rgba(0,0,0,0.5), 0 8px 20px -6px rgba(0,0,0,0.55), 0 24px 48px -16px rgba(0,0,0,0.6)'
|
|
984
|
+
: '0 1px 2px rgba(0,0,0,0.05), 0 6px 14px -4px rgba(0,0,0,0.08), 0 22px 44px -14px rgba(0,0,0,0.18)';
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
interface Look {
|
|
988
|
+
bg: string;
|
|
989
|
+
border: string;
|
|
990
|
+
title: string;
|
|
991
|
+
desc: string;
|
|
992
|
+
icon: string;
|
|
993
|
+
glowColor: string;
|
|
994
|
+
glow: number;
|
|
995
|
+
bar: string;
|
|
996
|
+
actionBg: string;
|
|
997
|
+
actionFg: string;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function theme(variant: Exclude<ToasterVariant, 'island'>, type: ToastType, c: Colors, dark: boolean): Look {
|
|
1001
|
+
const tone = toneOf(type, c, dark);
|
|
1002
|
+
if (variant === 'minimal') {
|
|
1003
|
+
return {
|
|
1004
|
+
bg: c.foreground,
|
|
1005
|
+
border: c.foreground,
|
|
1006
|
+
title: c.background,
|
|
1007
|
+
desc: alpha(c.background, 0.7),
|
|
1008
|
+
icon: type === 'default' || type === 'loading' ? c.background : tone,
|
|
1009
|
+
glowColor: tone,
|
|
1010
|
+
glow: 0,
|
|
1011
|
+
bar: 'transparent',
|
|
1012
|
+
actionBg: alpha(c.background, 0.16),
|
|
1013
|
+
actionFg: c.background,
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
if (variant === 'rich' && type !== 'default' && type !== 'loading') {
|
|
1017
|
+
// Filled: the solid tone with readable text on it; no second hue and no light-leak.
|
|
1018
|
+
const fg = readableOn(tone);
|
|
1019
|
+
return {
|
|
1020
|
+
bg: tone,
|
|
1021
|
+
border: alpha(fg, 0.12),
|
|
1022
|
+
title: fg,
|
|
1023
|
+
desc: alpha(fg, 0.78),
|
|
1024
|
+
icon: fg,
|
|
1025
|
+
glowColor: tone,
|
|
1026
|
+
glow: 0,
|
|
1027
|
+
bar: alpha(fg, 0.5),
|
|
1028
|
+
actionBg: alpha(fg, 0.18),
|
|
1029
|
+
actionFg: fg,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
bg: c.popover,
|
|
1034
|
+
border: alpha(c.foreground, dark ? 0.1 : 0.08),
|
|
1035
|
+
title: c.foreground,
|
|
1036
|
+
desc: c.mutedForeground,
|
|
1037
|
+
icon: tone,
|
|
1038
|
+
glowColor: tone,
|
|
1039
|
+
glow: dark ? 0.18 : 0.12,
|
|
1040
|
+
bar: type === 'default' || type === 'loading' ? alpha(c.foreground, 0.22) : alpha(tone, 0.6),
|
|
1041
|
+
actionBg: c.primary,
|
|
1042
|
+
actionFg: c.primaryForeground,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** Light leak: a radial tone glow centred on (x, y) that spills across the top-left of the surface. */
|
|
1047
|
+
function Glow({ color, strength, x, y }: { color: string; strength: number; x: number; y: number }) {
|
|
1048
|
+
const id = 'gl' + useId().replace(/[^a-zA-Z0-9]/g, '');
|
|
1049
|
+
const W = 320;
|
|
1050
|
+
const H = 200;
|
|
1051
|
+
return (
|
|
1052
|
+
<Svg pointerEvents="none" width={W} height={H} style={{ position: 'absolute', left: x - W / 2, top: y - H / 2 }}>
|
|
1053
|
+
<Defs>
|
|
1054
|
+
<RadialGradient id={id} cx="50%" cy="50%" rx="50%" ry="50%">
|
|
1055
|
+
<Stop offset="0" stopColor={color} stopOpacity={strength} />
|
|
1056
|
+
<Stop offset="0.35" stopColor={color} stopOpacity={strength * 0.45} />
|
|
1057
|
+
<Stop offset="1" stopColor={color} stopOpacity={0} />
|
|
1058
|
+
</RadialGradient>
|
|
1059
|
+
</Defs>
|
|
1060
|
+
<Rect width={W} height={H} fill={`url(#${id})`} />
|
|
1061
|
+
</Svg>
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ─── Living icon ──────────────────────────────────────────────────────────────
|
|
1066
|
+
|
|
1067
|
+
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|
1068
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
1069
|
+
|
|
1070
|
+
const ICON = 20;
|
|
1071
|
+
const HALO = 30;
|
|
1072
|
+
const HALO_OUT = 38;
|
|
1073
|
+
const RING = 2 * Math.PI * 9; // r = 9
|
|
1074
|
+
const TRIANGLE = 57; // perimeter of the rounded warning triangle
|
|
1075
|
+
const ARC = 0.28; // spinner arc as a fraction of the ring
|
|
1076
|
+
|
|
1077
|
+
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';
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Tone icon that draws itself: the outline strokes in, then the glyph draws and springs. A loading
|
|
1081
|
+
* spinner's arc keeps turning while it closes into the success / error circle, then the mark draws in.
|
|
1082
|
+
*/
|
|
1083
|
+
function LivingIcon({
|
|
1084
|
+
type,
|
|
1085
|
+
color,
|
|
1086
|
+
halo = true,
|
|
1087
|
+
reduceMotion = false,
|
|
1088
|
+
}: {
|
|
1089
|
+
type: ToastType;
|
|
1090
|
+
color: string;
|
|
1091
|
+
halo?: boolean;
|
|
1092
|
+
reduceMotion?: boolean;
|
|
1093
|
+
}) {
|
|
1094
|
+
const { radius } = useKit();
|
|
1095
|
+
// JS-driven SVG props
|
|
1096
|
+
const outline = useRef(new Animated.Value(0)).current;
|
|
1097
|
+
const draw = useRef(new Animated.Value(0)).current;
|
|
1098
|
+
// Native-driven transforms / opacity (separate views)
|
|
1099
|
+
const pop = useRef(new Animated.Value(0.5)).current;
|
|
1100
|
+
const spin = useRef(new Animated.Value(0)).current;
|
|
1101
|
+
const ripple = useRef(new Animated.Value(1)).current;
|
|
1102
|
+
const loopRef = useRef<Animated.CompositeAnimation | null>(null);
|
|
1103
|
+
const prev = useRef<ToastType | null>(null);
|
|
1104
|
+
|
|
1105
|
+
useEffect(() => {
|
|
1106
|
+
const from = prev.current;
|
|
1107
|
+
prev.current = type;
|
|
1108
|
+
const stopSpin = () => {
|
|
1109
|
+
loopRef.current?.stop();
|
|
1110
|
+
loopRef.current = null;
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
if (type === 'loading') {
|
|
1114
|
+
if (!loopRef.current) {
|
|
1115
|
+
spin.setValue(0);
|
|
1116
|
+
loopRef.current = Animated.loop(
|
|
1117
|
+
Animated.timing(spin, { toValue: 1, duration: 800, easing: Easing.linear, useNativeDriver: true }),
|
|
1118
|
+
);
|
|
1119
|
+
loopRef.current.start();
|
|
1120
|
+
}
|
|
1121
|
+
draw.setValue(0);
|
|
1122
|
+
if (reduceMotion) outline.setValue(ARC);
|
|
1123
|
+
else Animated.timing(outline, { toValue: ARC, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (reduceMotion) {
|
|
1128
|
+
stopSpin();
|
|
1129
|
+
outline.setValue(1);
|
|
1130
|
+
draw.setValue(1);
|
|
1131
|
+
pop.setValue(1);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const morph = from === 'loading';
|
|
1136
|
+
if (!morph) outline.setValue(0);
|
|
1137
|
+
draw.setValue(0);
|
|
1138
|
+
pop.setValue(0.5);
|
|
1139
|
+
// The arc keeps spinning while it closes; a full ring looks the same at any angle, so it stops invisibly.
|
|
1140
|
+
Animated.timing(outline, {
|
|
1141
|
+
toValue: 1,
|
|
1142
|
+
duration: morph ? 380 : 420,
|
|
1143
|
+
easing: morph ? Easing.inOut(Easing.cubic) : Easing.out(Easing.cubic),
|
|
1144
|
+
useNativeDriver: false,
|
|
1145
|
+
}).start(({ finished }) => {
|
|
1146
|
+
if (!finished) return;
|
|
1147
|
+
stopSpin();
|
|
1148
|
+
ripple.setValue(0);
|
|
1149
|
+
Animated.parallel([
|
|
1150
|
+
Animated.timing(draw, { toValue: 1, duration: 280, easing: Easing.out(Easing.cubic), useNativeDriver: false }),
|
|
1151
|
+
Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
|
|
1152
|
+
Animated.timing(ripple, { toValue: 1, duration: 700, easing: Easing.out(Easing.quad), useNativeDriver: true }),
|
|
1153
|
+
]).start();
|
|
1154
|
+
});
|
|
1155
|
+
}, [type, reduceMotion, outline, draw, pop, spin, ripple]);
|
|
1156
|
+
|
|
1157
|
+
useEffect(() => () => loopRef.current?.stop(), []);
|
|
1158
|
+
|
|
1159
|
+
const stroke = { stroke: color, strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
|
|
1160
|
+
const dash = (len: number, from = 0, to = 1) => ({
|
|
1161
|
+
strokeDasharray: [len, len],
|
|
1162
|
+
strokeDashoffset: draw.interpolate({ inputRange: [from, to], outputRange: [len, 0], extrapolate: 'clamp' }),
|
|
1163
|
+
});
|
|
1164
|
+
const dot = draw.interpolate({ inputRange: [0, 0.6, 1], outputRange: [0, 0, 1] });
|
|
1165
|
+
const triangle = type === 'warning';
|
|
1166
|
+
const per = triangle ? TRIANGLE : RING;
|
|
1167
|
+
const box = halo ? HALO_OUT : ICON;
|
|
1168
|
+
const inset = (box - ICON) / 2;
|
|
1169
|
+
const circle = (d: number) => ({ width: d, height: d, borderRadius: radius.full, left: (box - d) / 2, top: (box - d) / 2 });
|
|
1170
|
+
|
|
1171
|
+
return (
|
|
1172
|
+
<View style={{ width: box, height: box }} importantForAccessibility="no-hide-descendants">
|
|
1173
|
+
{halo ? (
|
|
1174
|
+
<>
|
|
1175
|
+
<View style={[styles.abs, circle(HALO_OUT), { backgroundColor: alpha(color, 0.08) }]} />
|
|
1176
|
+
<View style={[styles.abs, circle(HALO), { backgroundColor: alpha(color, 0.14) }]} />
|
|
1177
|
+
<Animated.View
|
|
1178
|
+
style={[
|
|
1179
|
+
styles.abs,
|
|
1180
|
+
circle(HALO_OUT),
|
|
1181
|
+
{
|
|
1182
|
+
borderWidth: 1.5,
|
|
1183
|
+
borderColor: alpha(color, 0.35),
|
|
1184
|
+
opacity: ripple.interpolate({ inputRange: [0, 1], outputRange: [0.9, 0] }),
|
|
1185
|
+
transform: [{ scale: ripple.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1.55] }) }],
|
|
1186
|
+
},
|
|
1187
|
+
]}
|
|
1188
|
+
/>
|
|
1189
|
+
</>
|
|
1190
|
+
) : null}
|
|
1191
|
+
<Animated.View
|
|
1192
|
+
style={[
|
|
1193
|
+
styles.abs,
|
|
1194
|
+
{ left: inset, top: inset },
|
|
1195
|
+
{ transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }] },
|
|
1196
|
+
]}
|
|
1197
|
+
>
|
|
1198
|
+
<Svg width={ICON} height={ICON} viewBox="0 0 24 24">
|
|
1199
|
+
{triangle ? (
|
|
1200
|
+
<AnimatedPath
|
|
1201
|
+
d={TRIANGLE_D}
|
|
1202
|
+
{...stroke}
|
|
1203
|
+
strokeDasharray={[per, per]}
|
|
1204
|
+
strokeDashoffset={outline.interpolate({ inputRange: [0, 1], outputRange: [per, 0] })}
|
|
1205
|
+
/>
|
|
1206
|
+
) : (
|
|
1207
|
+
<>
|
|
1208
|
+
{type === 'loading' ? <Circle cx={12} cy={12} r={9} {...stroke} stroke={alpha(color, 0.2)} /> : null}
|
|
1209
|
+
<AnimatedCircle
|
|
1210
|
+
cx={12}
|
|
1211
|
+
cy={12}
|
|
1212
|
+
r={9}
|
|
1213
|
+
{...stroke}
|
|
1214
|
+
rotation={-90}
|
|
1215
|
+
origin="12, 12"
|
|
1216
|
+
strokeDasharray={[per, per]}
|
|
1217
|
+
strokeDashoffset={outline.interpolate({ inputRange: [0, 1], outputRange: [per, 0] })}
|
|
1218
|
+
/>
|
|
1219
|
+
</>
|
|
1220
|
+
)}
|
|
1221
|
+
</Svg>
|
|
1222
|
+
</Animated.View>
|
|
1223
|
+
{type !== 'loading' ? (
|
|
1224
|
+
<Animated.View style={[styles.abs, { left: inset, top: inset, transform: [{ scale: pop }] }]}>
|
|
1225
|
+
<Svg width={ICON} height={ICON} viewBox="0 0 24 24">
|
|
1226
|
+
{type === 'success' ? <AnimatedPath d="M8 12.4l2.7 2.7L16.2 9.4" {...stroke} {...dash(12)} /> : null}
|
|
1227
|
+
{type === 'error' ? (
|
|
1228
|
+
<>
|
|
1229
|
+
<AnimatedPath d="M9.2 9.2l5.6 5.6" {...stroke} {...dash(8, 0, 0.55)} />
|
|
1230
|
+
<AnimatedPath d="M14.8 9.2l-5.6 5.6" {...stroke} {...dash(8, 0.45, 1)} />
|
|
1231
|
+
</>
|
|
1232
|
+
) : null}
|
|
1233
|
+
{type === 'warning' ? (
|
|
1234
|
+
<>
|
|
1235
|
+
<AnimatedPath d="M12 9.5v4" {...stroke} {...dash(4.2)} />
|
|
1236
|
+
<AnimatedCircle cx={12} cy={16.6} r={1.25} fill={color} opacity={dot} />
|
|
1237
|
+
</>
|
|
1238
|
+
) : null}
|
|
1239
|
+
{type === 'info' ? (
|
|
1240
|
+
<>
|
|
1241
|
+
<AnimatedCircle cx={12} cy={7.9} r={1.25} fill={color} opacity={dot} />
|
|
1242
|
+
<AnimatedPath d="M12 11v5" {...stroke} {...dash(5.2)} />
|
|
1243
|
+
</>
|
|
1244
|
+
) : null}
|
|
1245
|
+
</Svg>
|
|
1246
|
+
</Animated.View>
|
|
1247
|
+
) : null}
|
|
1248
|
+
</View>
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const styles = StyleSheet.create({
|
|
1253
|
+
host: { zIndex: 1000, elevation: 1000 },
|
|
1254
|
+
lane: { position: 'absolute', left: 0, right: 0, alignItems: 'center', paddingHorizontal: 16 },
|
|
1255
|
+
cardFull: { width: '100%', maxWidth: 420 },
|
|
1256
|
+
cardMinimal: { maxWidth: 360 },
|
|
1257
|
+
clip: { overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth * 2 },
|
|
1258
|
+
row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
|
1259
|
+
pad: { paddingVertical: 14, paddingHorizontal: 16 },
|
|
1260
|
+
padIcon: { paddingVertical: 12, paddingLeft: 12, paddingRight: 16 },
|
|
1261
|
+
padMinimal: { paddingVertical: 10, paddingLeft: 14, paddingRight: 16, gap: 10 },
|
|
1262
|
+
iconTop: { alignSelf: 'flex-start' },
|
|
1263
|
+
text: { flex: 1, gap: 2 },
|
|
1264
|
+
textMinimal: { flexShrink: 1 },
|
|
1265
|
+
title: { fontSize: 14.5, fontWeight: '400', lineHeight: 20, letterSpacing: -0.1 },
|
|
1266
|
+
desc: { fontSize: 13.5, fontWeight: '400', lineHeight: 19 },
|
|
1267
|
+
descMinimal: { fontSize: 13, lineHeight: 17 },
|
|
1268
|
+
action: { paddingHorizontal: 12, height: 32, alignItems: 'center', justifyContent: 'center' },
|
|
1269
|
+
actionText: { fontSize: 13, fontWeight: '400' },
|
|
1270
|
+
close: { alignSelf: 'flex-start', padding: 2, marginRight: -4 },
|
|
1271
|
+
bar: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 2, transformOrigin: 'left' },
|
|
1272
|
+
abs: { position: 'absolute' },
|
|
1273
|
+
islandLane: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
|
|
1274
|
+
islandClip: { flex: 1, overflow: 'hidden' },
|
|
1275
|
+
islandPlace: { position: 'absolute', top: 0 },
|
|
1276
|
+
islandPad: { paddingVertical: 14, paddingHorizontal: 20 },
|
|
1277
|
+
islandPadIcon: { paddingTop: 13, paddingBottom: 15, paddingLeft: 14, paddingRight: 18 },
|
|
1278
|
+
islandChip: { paddingHorizontal: 10, height: 24, justifyContent: 'center' },
|
|
1279
|
+
islandChipText: { fontSize: 12.5, fontWeight: '400' },
|
|
1280
|
+
islandActions: { paddingHorizontal: 14, paddingBottom: 16, marginTop: -2 },
|
|
1281
|
+
islandAction: { height: 44, alignItems: 'center', justifyContent: 'center' },
|
|
1282
|
+
islandTrack: { position: 'absolute', left: 28, right: 28, bottom: 6, height: 2, borderRadius: 1, overflow: 'hidden' },
|
|
1283
|
+
islandFill: { flex: 1, transformOrigin: 'left' },
|
|
1284
|
+
});
|