panelui-native 0.2.0 → 0.4.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 +251 -0
- package/lib/module/components/alert/index.js +106 -13
- package/lib/module/components/alert/index.js.map +1 -1
- package/lib/module/components/badge/index.js +3 -3
- package/lib/module/components/badge/index.js.map +1 -1
- package/lib/module/components/button/index.js +44 -15
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/checkbox/index.js +20 -7
- package/lib/module/components/checkbox/index.js.map +1 -1
- package/lib/module/components/empty-state/index.js +178 -0
- package/lib/module/components/empty-state/index.js.map +1 -0
- package/lib/module/components/input-group/index.js +132 -0
- package/lib/module/components/input-group/index.js.map +1 -0
- package/lib/module/components/label/index.js +117 -0
- package/lib/module/components/label/index.js.map +1 -0
- package/lib/module/components/progress/index.js +138 -0
- package/lib/module/components/progress/index.js.map +1 -0
- package/lib/module/components/steps/index.js +354 -0
- package/lib/module/components/steps/index.js.map +1 -0
- package/lib/module/components/tabs/index.js +9 -3
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/toast/index.js +479 -0
- package/lib/module/components/toast/index.js.map +1 -0
- package/lib/module/components/toast/toast-store.js +81 -0
- package/lib/module/components/toast/toast-store.js.map +1 -0
- package/lib/module/components/typography/index.js +126 -0
- package/lib/module/components/typography/index.js.map +1 -0
- package/lib/module/icons/index.js +223 -2
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +9 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/providers/panel-ui-provider.js +2 -1
- package/lib/module/providers/panel-ui-provider.js.map +1 -1
- package/lib/module/theme/use-theme.js +90 -3
- package/lib/module/theme/use-theme.js.map +1 -1
- package/lib/typescript/src/components/alert/index.d.ts +56 -4
- package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +39 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
- package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/empty-state/index.d.ts +73 -0
- package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -0
- package/lib/typescript/src/components/input-group/index.d.ts +34 -0
- package/lib/typescript/src/components/input-group/index.d.ts.map +1 -0
- package/lib/typescript/src/components/label/index.d.ts +28 -0
- package/lib/typescript/src/components/label/index.d.ts.map +1 -0
- package/lib/typescript/src/components/progress/index.d.ts +132 -0
- package/lib/typescript/src/components/progress/index.d.ts.map +1 -0
- package/lib/typescript/src/components/steps/index.d.ts +62 -0
- package/lib/typescript/src/components/steps/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/index.d.ts +176 -0
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -0
- package/lib/typescript/src/components/toast/toast-store.d.ts +65 -0
- package/lib/typescript/src/components/toast/toast-store.d.ts.map +1 -0
- package/lib/typescript/src/components/typography/index.d.ts +77 -0
- package/lib/typescript/src/components/typography/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +25 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +10 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/lib/typescript/src/theme/use-theme.d.ts +64 -4
- package/lib/typescript/src/theme/use-theme.d.ts.map +1 -1
- package/package.json +22 -4
- package/src/components/alert/index.tsx +102 -16
- package/src/components/badge/index.tsx +3 -3
- package/src/components/button/index.tsx +47 -7
- package/src/components/checkbox/index.tsx +17 -3
- package/src/components/empty-state/index.tsx +136 -0
- package/src/components/input-group/index.tsx +164 -0
- package/src/components/label/index.tsx +128 -0
- package/src/components/progress/index.tsx +147 -0
- package/src/components/steps/index.tsx +343 -0
- package/src/components/tabs/index.tsx +10 -2
- package/src/components/toast/index.tsx +447 -0
- package/src/components/toast/toast-store.ts +120 -0
- package/src/components/typography/index.tsx +121 -0
- package/src/icons/index.tsx +152 -1
- package/src/index.ts +76 -3
- package/src/providers/panel-ui-provider.tsx +3 -0
- package/src/theme/use-theme.ts +119 -4
- package/theme.css +330 -7
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toast — transient notification.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from: heroui-inc/heroui-native src/components/toast/
|
|
5
|
+
* (the three usage patterns — string, config object, custom component — the
|
|
6
|
+
* Title / Description / Action / Close anatomy, placement, and swipe-to-
|
|
7
|
+
* dismiss). Styled with Coss UI tokens and PanelUI's Alert status palette.
|
|
8
|
+
*
|
|
9
|
+
* The viewport is mounted for you by PanelUIProvider; `useToast()` works
|
|
10
|
+
* anywhere below it.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
createContext,
|
|
14
|
+
useCallback,
|
|
15
|
+
useContext,
|
|
16
|
+
useMemo,
|
|
17
|
+
useSyncExternalStore,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
} from 'react';
|
|
20
|
+
import { View, type ViewProps } from 'react-native';
|
|
21
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
22
|
+
import Animated, {
|
|
23
|
+
Easing,
|
|
24
|
+
Extrapolation,
|
|
25
|
+
FadeInDown,
|
|
26
|
+
FadeInUp,
|
|
27
|
+
Keyframe,
|
|
28
|
+
interpolate,
|
|
29
|
+
runOnJS,
|
|
30
|
+
useAnimatedStyle,
|
|
31
|
+
useSharedValue,
|
|
32
|
+
withDecay,
|
|
33
|
+
withSpring,
|
|
34
|
+
withTiming,
|
|
35
|
+
} from 'react-native-reanimated';
|
|
36
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
37
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
38
|
+
import { useCSSVariable } from 'uniwind';
|
|
39
|
+
import { Portal } from '../../primitives/portal';
|
|
40
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
41
|
+
import { cn } from '../../utils/cn';
|
|
42
|
+
import { AlertTriangleIcon, CheckCircleIcon, InfoIcon, XIcon } from '../../icons';
|
|
43
|
+
import { Button, type ButtonProps } from '../button';
|
|
44
|
+
import {
|
|
45
|
+
toast,
|
|
46
|
+
toastStore,
|
|
47
|
+
type ToastHandle,
|
|
48
|
+
type ToastItem,
|
|
49
|
+
type ToastPlacement,
|
|
50
|
+
type ToastVariant,
|
|
51
|
+
} from './toast-store';
|
|
52
|
+
|
|
53
|
+
const SPRING = { damping: 20, stiffness: 260, mass: 0.6 } as const;
|
|
54
|
+
/** Drag distance past which a release dismisses. */
|
|
55
|
+
const DISMISS_DISTANCE = 50;
|
|
56
|
+
/** Fling speed past which a release dismisses regardless of distance. */
|
|
57
|
+
const DISMISS_VELOCITY = 500;
|
|
58
|
+
/** How far a toast can be dragged away from its edge before it stops moving. */
|
|
59
|
+
const RUBBER_BAND_LIMIT = 40;
|
|
60
|
+
/** How far a toast travels on entry. Short, so the spring has little to settle. */
|
|
61
|
+
const ENTER_OFFSET = 100;
|
|
62
|
+
/** Newest toasts only; older ones are dropped rather than stacking off-screen. */
|
|
63
|
+
const MAX_VISIBLE = 3;
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
* Entering/exiting animations, from heroui-inc/heroui-native
|
|
67
|
+
* src/components/toast/toast.animation.ts.
|
|
68
|
+
*
|
|
69
|
+
* `mass(3)` is doing the important work: a heavy spring settles without the
|
|
70
|
+
* visible overshoot a default-mass one gives. `withInitialValues` keeps
|
|
71
|
+
* opacity at 1 so the toast slides rather than fades, and starts it
|
|
72
|
+
* ENTER_OFFSET px away instead of off-screen.
|
|
73
|
+
*/
|
|
74
|
+
const enteringTop = FadeInUp.springify()
|
|
75
|
+
.withInitialValues({ opacity: 1, transform: [{ translateY: -ENTER_OFFSET }] })
|
|
76
|
+
.mass(3);
|
|
77
|
+
|
|
78
|
+
const enteringBottom = FadeInDown.springify()
|
|
79
|
+
.withInitialValues({ opacity: 1, transform: [{ translateY: ENTER_OFFSET }] })
|
|
80
|
+
.mass(3);
|
|
81
|
+
|
|
82
|
+
const exitKeyframe = (offset: number) =>
|
|
83
|
+
new Keyframe({
|
|
84
|
+
0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] },
|
|
85
|
+
100: {
|
|
86
|
+
opacity: 0.5,
|
|
87
|
+
transform: [{ translateY: offset }, { scale: 0.97 }],
|
|
88
|
+
easing: Easing.bezier(0.4, 0, 1, 1),
|
|
89
|
+
},
|
|
90
|
+
}).duration(150);
|
|
91
|
+
|
|
92
|
+
const exitingTop = exitKeyframe(-ENTER_OFFSET);
|
|
93
|
+
const exitingBottom = exitKeyframe(ENTER_OFFSET);
|
|
94
|
+
|
|
95
|
+
const toastVariants = tv({
|
|
96
|
+
slots: {
|
|
97
|
+
root: 'w-full flex-row items-start gap-3 rounded-2xl border px-4 py-3.5 shadow-lg',
|
|
98
|
+
indicator: 'pt-px',
|
|
99
|
+
content: 'flex-1 gap-0.5',
|
|
100
|
+
title: 'text-sm font-medium',
|
|
101
|
+
description: 'text-sm text-muted-foreground',
|
|
102
|
+
},
|
|
103
|
+
variants: {
|
|
104
|
+
variant: {
|
|
105
|
+
default: { root: 'border-border bg-popover', title: 'text-popover-foreground' },
|
|
106
|
+
info: { root: 'border-info/32 bg-popover', title: 'text-info-foreground' },
|
|
107
|
+
success: { root: 'border-success/32 bg-popover', title: 'text-success-foreground' },
|
|
108
|
+
warning: { root: 'border-warning/32 bg-popover', title: 'text-warning-foreground' },
|
|
109
|
+
destructive: {
|
|
110
|
+
root: 'border-destructive/32 bg-popover',
|
|
111
|
+
title: 'text-destructive-foreground',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
defaultVariants: {
|
|
116
|
+
variant: 'default',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const INDICATOR_COLOR_VAR: Record<ToastVariant, string> = {
|
|
121
|
+
default: '--color-muted-foreground',
|
|
122
|
+
info: '--color-info-foreground',
|
|
123
|
+
success: '--color-success-foreground',
|
|
124
|
+
warning: '--color-warning-foreground',
|
|
125
|
+
destructive: '--color-destructive-foreground',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const INDICATOR_ICON: Record<ToastVariant, typeof InfoIcon> = {
|
|
129
|
+
default: InfoIcon,
|
|
130
|
+
info: InfoIcon,
|
|
131
|
+
success: CheckCircleIcon,
|
|
132
|
+
warning: AlertTriangleIcon,
|
|
133
|
+
destructive: AlertTriangleIcon,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
interface ToastContextValue {
|
|
137
|
+
variant: ToastVariant;
|
|
138
|
+
hide: () => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ToastContext = createContext<ToastContextValue>({
|
|
142
|
+
variant: 'default',
|
|
143
|
+
hide: () => {},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
export interface ToastProps extends ViewProps, VariantProps<typeof toastVariants> {
|
|
147
|
+
className?: string;
|
|
148
|
+
/** Called when the close button is pressed or the toast is swiped away. */
|
|
149
|
+
onHide?: () => void;
|
|
150
|
+
children?: ReactNode;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Presentational toast surface. Render it yourself only for custom toasts —
|
|
155
|
+
* `toast.show(...)` builds one for you from a config object.
|
|
156
|
+
*/
|
|
157
|
+
const ToastRoot = ({ className, variant = 'default', onHide, children, ...props }: ToastProps) => {
|
|
158
|
+
const context = useMemo<ToastContextValue>(
|
|
159
|
+
() => ({ variant, hide: onHide ?? (() => {}) }),
|
|
160
|
+
[variant, onHide]
|
|
161
|
+
);
|
|
162
|
+
const { root } = toastVariants({ variant });
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<ToastContext.Provider value={context}>
|
|
166
|
+
<View accessibilityRole="alert" accessibilityLiveRegion="polite" className={root({ className })} {...props}>
|
|
167
|
+
{children}
|
|
168
|
+
</View>
|
|
169
|
+
</ToastContext.Provider>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
ToastRoot.displayName = 'Toast';
|
|
173
|
+
|
|
174
|
+
export interface ToastIndicatorProps extends ViewProps {
|
|
175
|
+
className?: string;
|
|
176
|
+
iconProps?: { size?: number; color?: string };
|
|
177
|
+
children?: ReactNode;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ToastIndicator = ({ className, iconProps, children, ...props }: ToastIndicatorProps) => {
|
|
181
|
+
const { variant } = useContext(ToastContext);
|
|
182
|
+
const { indicator } = toastVariants({ variant });
|
|
183
|
+
const themeColor = useCSSVariable(INDICATOR_COLOR_VAR[variant]);
|
|
184
|
+
const Icon = INDICATOR_ICON[variant];
|
|
185
|
+
const color = iconProps?.color ?? (typeof themeColor === 'string' ? themeColor : undefined);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<View className={indicator({ className })} {...props}>
|
|
189
|
+
{children ?? <Icon size={iconProps?.size ?? 18} color={color} />}
|
|
190
|
+
</View>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
ToastIndicator.displayName = 'Toast.Indicator';
|
|
194
|
+
|
|
195
|
+
const ToastContent = ({ className, ...props }: ViewProps & { className?: string }) => {
|
|
196
|
+
const { variant } = useContext(ToastContext);
|
|
197
|
+
const { content } = toastVariants({ variant });
|
|
198
|
+
return <View className={content({ className })} {...props} />;
|
|
199
|
+
};
|
|
200
|
+
ToastContent.displayName = 'Toast.Content';
|
|
201
|
+
|
|
202
|
+
const ToastTitle = ({ className, ...props }: TextProps) => {
|
|
203
|
+
const { variant } = useContext(ToastContext);
|
|
204
|
+
const { title } = toastVariants({ variant });
|
|
205
|
+
return <Text className={cn(title(), className)} {...props} />;
|
|
206
|
+
};
|
|
207
|
+
ToastTitle.displayName = 'Toast.Title';
|
|
208
|
+
|
|
209
|
+
const ToastDescription = ({ className, ...props }: TextProps) => {
|
|
210
|
+
const { variant } = useContext(ToastContext);
|
|
211
|
+
const { description } = toastVariants({ variant });
|
|
212
|
+
return <Text className={cn(description(), className)} {...props} />;
|
|
213
|
+
};
|
|
214
|
+
ToastDescription.displayName = 'Toast.Description';
|
|
215
|
+
|
|
216
|
+
/** Trailing action button. Defaults to a subtle variant that reads on the popover surface. */
|
|
217
|
+
const ToastAction = ({ variant = 'secondary', size = 'sm', onPress, ...props }: ButtonProps) => {
|
|
218
|
+
const { hide } = useContext(ToastContext);
|
|
219
|
+
return (
|
|
220
|
+
<Button
|
|
221
|
+
variant={variant}
|
|
222
|
+
size={size}
|
|
223
|
+
onPress={(event) => {
|
|
224
|
+
onPress?.(event);
|
|
225
|
+
hide();
|
|
226
|
+
}}
|
|
227
|
+
{...props}
|
|
228
|
+
/>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
ToastAction.displayName = 'Toast.Action';
|
|
232
|
+
|
|
233
|
+
export interface ToastCloseProps extends ViewProps {
|
|
234
|
+
className?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const ToastClose = ({ className, ...props }: ToastCloseProps) => {
|
|
238
|
+
const { hide } = useContext(ToastContext);
|
|
239
|
+
const color = useCSSVariable('--color-muted-foreground');
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<View className={cn('pt-px', className)} {...props}>
|
|
243
|
+
<Button
|
|
244
|
+
variant="ghost"
|
|
245
|
+
size="icon"
|
|
246
|
+
accessibilityLabel="Dismiss notification"
|
|
247
|
+
className="h-6 w-6 rounded-full"
|
|
248
|
+
onPress={hide}
|
|
249
|
+
>
|
|
250
|
+
<XIcon size={14} color={typeof color === 'string' ? color : undefined} />
|
|
251
|
+
</Button>
|
|
252
|
+
</View>
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
ToastClose.displayName = 'Toast.Close';
|
|
256
|
+
|
|
257
|
+
export const Toast = Object.assign(ToastRoot, {
|
|
258
|
+
Indicator: ToastIndicator,
|
|
259
|
+
Content: ToastContent,
|
|
260
|
+
Title: ToastTitle,
|
|
261
|
+
Description: ToastDescription,
|
|
262
|
+
Action: ToastAction,
|
|
263
|
+
Close: ToastClose,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/* -------------------------------------------------------------------------- */
|
|
267
|
+
/* Viewport */
|
|
268
|
+
/* -------------------------------------------------------------------------- */
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* One queued toast.
|
|
272
|
+
*
|
|
273
|
+
* The entering spring is deliberately heavy (`mass(3)`) and travels only
|
|
274
|
+
* ENTER_OFFSET px rather than the full screen height. A default-mass spring
|
|
275
|
+
* across that distance overshoots visibly — it reads as a bounce, and arrives
|
|
276
|
+
* too fast to follow.
|
|
277
|
+
*
|
|
278
|
+
* Swiping is vertical and direction-aware: dragging toward the screen edge the
|
|
279
|
+
* toast came from dismisses it, dragging the other way rubber-bands against a
|
|
280
|
+
* hard limit. Matches heroui-native's toast.animation.ts.
|
|
281
|
+
*/
|
|
282
|
+
function ToastSlot({ item, depth }: { item: ToastItem; depth: number }) {
|
|
283
|
+
const placement = item.placement ?? 'bottom';
|
|
284
|
+
const translateY = useSharedValue(0);
|
|
285
|
+
const scale = useSharedValue(1);
|
|
286
|
+
const hide = useCallback(() => toastStore.hide(item.id), [item.id]);
|
|
287
|
+
|
|
288
|
+
/** +1 when a downward drag dismisses (bottom), -1 when an upward one does. */
|
|
289
|
+
const dismissSign = placement === 'top' ? -1 : 1;
|
|
290
|
+
|
|
291
|
+
const pan = Gesture.Pan()
|
|
292
|
+
.activeOffsetY([-12, 12])
|
|
293
|
+
.onBegin(() => {
|
|
294
|
+
scale.value = withTiming(0.995, { duration: 120 });
|
|
295
|
+
})
|
|
296
|
+
.onChange((event) => {
|
|
297
|
+
const next = translateY.value + event.changeY;
|
|
298
|
+
// Toward the edge: follow the finger. Away from it: rubber-band, so the
|
|
299
|
+
// toast never detaches from where it belongs.
|
|
300
|
+
translateY.value =
|
|
301
|
+
next * dismissSign > 0
|
|
302
|
+
? next
|
|
303
|
+
: interpolate(
|
|
304
|
+
Math.abs(next),
|
|
305
|
+
[0, 400],
|
|
306
|
+
[0, RUBBER_BAND_LIMIT],
|
|
307
|
+
Extrapolation.CLAMP
|
|
308
|
+
) * -dismissSign;
|
|
309
|
+
})
|
|
310
|
+
.onFinalize((event) => {
|
|
311
|
+
scale.value = withTiming(1, { duration: 120 });
|
|
312
|
+
|
|
313
|
+
const towardEdge = translateY.value * dismissSign > 0;
|
|
314
|
+
const shouldDismiss =
|
|
315
|
+
towardEdge &&
|
|
316
|
+
(Math.abs(translateY.value) > DISMISS_DISTANCE ||
|
|
317
|
+
Math.abs(event.velocityY) > DISMISS_VELOCITY);
|
|
318
|
+
|
|
319
|
+
if (shouldDismiss) {
|
|
320
|
+
// Carry the flick's momentum instead of stopping dead, clamped so it
|
|
321
|
+
// can only continue toward the edge.
|
|
322
|
+
translateY.value = withDecay({
|
|
323
|
+
velocity: event.velocityY * 1.5,
|
|
324
|
+
clamp:
|
|
325
|
+
dismissSign > 0
|
|
326
|
+
? [0, Number.POSITIVE_INFINITY]
|
|
327
|
+
: [Number.NEGATIVE_INFINITY, 0],
|
|
328
|
+
});
|
|
329
|
+
runOnJS(hide)();
|
|
330
|
+
} else {
|
|
331
|
+
translateY.value = withSpring(0, SPRING);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Gesture transform only — the layout animations below own opacity and
|
|
336
|
+
// their own translate, and Reanimated cannot drive a property from both.
|
|
337
|
+
const gestureStyle = useAnimatedStyle(() => ({
|
|
338
|
+
transform: [{ translateY: translateY.value }, { scale: scale.value }],
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
const handle: ToastHandle = { id: item.id, hide };
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<Animated.View
|
|
345
|
+
entering={placement === 'top' ? enteringTop : enteringBottom}
|
|
346
|
+
exiting={placement === 'top' ? exitingTop : exitingBottom}
|
|
347
|
+
// Toasts further down the stack sit back a little.
|
|
348
|
+
style={{ opacity: 1 - depth * 0.15, transform: [{ scale: 1 - depth * 0.03 }] }}
|
|
349
|
+
>
|
|
350
|
+
<GestureDetector gesture={pan}>
|
|
351
|
+
<Animated.View style={gestureStyle}>
|
|
352
|
+
{item.component ? (
|
|
353
|
+
item.component(handle)
|
|
354
|
+
) : (
|
|
355
|
+
<Toast variant={item.variant ?? 'default'} onHide={hide}>
|
|
356
|
+
{item.icon === null ? null : (
|
|
357
|
+
<Toast.Indicator>{item.icon ?? undefined}</Toast.Indicator>
|
|
358
|
+
)}
|
|
359
|
+
<Toast.Content>
|
|
360
|
+
{item.label ? <Toast.Title>{item.label}</Toast.Title> : null}
|
|
361
|
+
{item.description ? (
|
|
362
|
+
<Toast.Description>{item.description}</Toast.Description>
|
|
363
|
+
) : null}
|
|
364
|
+
</Toast.Content>
|
|
365
|
+
{item.actionLabel ? (
|
|
366
|
+
<Toast.Action onPress={() => item.onActionPress?.(handle)}>
|
|
367
|
+
{item.actionLabel}
|
|
368
|
+
</Toast.Action>
|
|
369
|
+
) : null}
|
|
370
|
+
{(item.closable ?? !item.actionLabel) ? <Toast.Close /> : null}
|
|
371
|
+
</Toast>
|
|
372
|
+
)}
|
|
373
|
+
</Animated.View>
|
|
374
|
+
</GestureDetector>
|
|
375
|
+
</Animated.View>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Absolutely positioned stack for one edge of the screen. */
|
|
380
|
+
function ToastStack({
|
|
381
|
+
items,
|
|
382
|
+
placement,
|
|
383
|
+
}: {
|
|
384
|
+
items: readonly ToastItem[];
|
|
385
|
+
placement: ToastPlacement;
|
|
386
|
+
}) {
|
|
387
|
+
const insets = useSafeAreaInsets();
|
|
388
|
+
if (items.length === 0) return null;
|
|
389
|
+
|
|
390
|
+
// Only the newest few are shown; older ones are dropped rather than piling
|
|
391
|
+
// up off-screen. Newest sits closest to the edge it entered from.
|
|
392
|
+
const visible = items.slice(-MAX_VISIBLE);
|
|
393
|
+
const ordered = placement === 'top' ? visible : [...visible].reverse();
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<View
|
|
397
|
+
pointerEvents="box-none"
|
|
398
|
+
className={cn(
|
|
399
|
+
'absolute left-0 right-0 gap-2 px-4',
|
|
400
|
+
placement === 'top' ? 'top-0' : 'bottom-0'
|
|
401
|
+
)}
|
|
402
|
+
style={{
|
|
403
|
+
paddingTop: placement === 'top' ? insets.top + 8 : 0,
|
|
404
|
+
paddingBottom: placement === 'bottom' ? insets.bottom + 8 : 0,
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
{ordered.map((item, index) => (
|
|
408
|
+
<ToastSlot key={item.id} item={item} depth={index} />
|
|
409
|
+
))}
|
|
410
|
+
</View>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Renders the toast queue. PanelUIProvider mounts this automatically — you
|
|
416
|
+
* only need it directly if you build your own provider.
|
|
417
|
+
*/
|
|
418
|
+
export function ToastViewport() {
|
|
419
|
+
const items = useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot);
|
|
420
|
+
|
|
421
|
+
if (items.length === 0) return null;
|
|
422
|
+
|
|
423
|
+
const top = items.filter((item) => item.placement === 'top');
|
|
424
|
+
const bottom = items.filter((item) => item.placement !== 'top');
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<Portal>
|
|
428
|
+
<ToastStack items={top} placement="top" />
|
|
429
|
+
<ToastStack items={bottom} placement="bottom" />
|
|
430
|
+
</Portal>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Access the toast API.
|
|
436
|
+
*
|
|
437
|
+
* ```tsx
|
|
438
|
+
* const { toast } = useToast();
|
|
439
|
+
* toast.show({ variant: 'success', label: 'Saved' });
|
|
440
|
+
* ```
|
|
441
|
+
*/
|
|
442
|
+
export function useToast() {
|
|
443
|
+
return { toast };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export { toast, type ToastItem, type ToastPlacement, type ToastVariant, type ToastHandle };
|
|
447
|
+
export type { ToastOptions } from './toast-store';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toast queue.
|
|
3
|
+
*
|
|
4
|
+
* A module-level store rather than React state, so `toast.show()` works from
|
|
5
|
+
* anywhere — event handlers, API clients, code outside the component tree —
|
|
6
|
+
* without threading a context through. The viewport subscribes with
|
|
7
|
+
* `useSyncExternalStore`.
|
|
8
|
+
*/
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
export type ToastVariant = 'default' | 'info' | 'success' | 'warning' | 'destructive';
|
|
12
|
+
export type ToastPlacement = 'top' | 'bottom';
|
|
13
|
+
|
|
14
|
+
/** Handle given to action callbacks so they can dismiss their own toast. */
|
|
15
|
+
export interface ToastHandle {
|
|
16
|
+
id: string;
|
|
17
|
+
hide: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ToastOptions {
|
|
21
|
+
variant?: ToastVariant;
|
|
22
|
+
placement?: ToastPlacement;
|
|
23
|
+
/** Title line. */
|
|
24
|
+
label?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
/** Overrides the variant's default status icon. Pass `null` for no icon. */
|
|
27
|
+
icon?: ReactNode;
|
|
28
|
+
/** Renders a trailing action button when set. */
|
|
29
|
+
actionLabel?: string;
|
|
30
|
+
onActionPress?: (handle: ToastHandle) => void;
|
|
31
|
+
/** Show a close button. Defaults to true when there is no action. */
|
|
32
|
+
closable?: boolean;
|
|
33
|
+
/** Milliseconds before auto-dismiss. `0` keeps it up until dismissed. */
|
|
34
|
+
duration?: number;
|
|
35
|
+
/** Fully custom rendering. Receives the same handle as action callbacks. */
|
|
36
|
+
component?: (handle: ToastHandle) => ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ToastItem extends ToastOptions {
|
|
40
|
+
id: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_DURATION = 4000;
|
|
44
|
+
|
|
45
|
+
let counter = 0;
|
|
46
|
+
|
|
47
|
+
class ToastStore {
|
|
48
|
+
private items: ToastItem[] = [];
|
|
49
|
+
private snapshot: readonly ToastItem[] = this.items;
|
|
50
|
+
private listeners = new Set<() => void>();
|
|
51
|
+
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
52
|
+
|
|
53
|
+
subscribe = (listener: () => void) => {
|
|
54
|
+
this.listeners.add(listener);
|
|
55
|
+
return () => {
|
|
56
|
+
this.listeners.delete(listener);
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
getSnapshot = (): readonly ToastItem[] => this.snapshot;
|
|
61
|
+
|
|
62
|
+
show = (options: ToastOptions | string): string => {
|
|
63
|
+
const resolved: ToastOptions =
|
|
64
|
+
typeof options === 'string' ? { label: options } : options;
|
|
65
|
+
const id = `toast-${++counter}`;
|
|
66
|
+
|
|
67
|
+
this.items = [...this.items, { ...resolved, id }];
|
|
68
|
+
this.emit();
|
|
69
|
+
|
|
70
|
+
const duration = resolved.duration ?? DEFAULT_DURATION;
|
|
71
|
+
if (duration > 0) {
|
|
72
|
+
this.timers.set(
|
|
73
|
+
id,
|
|
74
|
+
setTimeout(() => this.hide(id), duration)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return id;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
hide = (id: string) => {
|
|
82
|
+
const timer = this.timers.get(id);
|
|
83
|
+
if (timer) {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
this.timers.delete(id);
|
|
86
|
+
}
|
|
87
|
+
if (!this.items.some((item) => item.id === id)) return;
|
|
88
|
+
this.items = this.items.filter((item) => item.id !== id);
|
|
89
|
+
this.emit();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
hideAll = () => {
|
|
93
|
+
this.timers.forEach(clearTimeout);
|
|
94
|
+
this.timers.clear();
|
|
95
|
+
if (this.items.length === 0) return;
|
|
96
|
+
this.items = [];
|
|
97
|
+
this.emit();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
private emit() {
|
|
101
|
+
this.snapshot = this.items;
|
|
102
|
+
this.listeners.forEach((listener) => listener());
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const toastStore = new ToastStore();
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Imperative toast API. Also returned from `useToast()`.
|
|
110
|
+
*
|
|
111
|
+
* ```ts
|
|
112
|
+
* toast.show('Saved');
|
|
113
|
+
* toast.show({ variant: 'success', label: 'Deployed', actionLabel: 'View' });
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export const toast = {
|
|
117
|
+
show: toastStore.show,
|
|
118
|
+
hide: toastStore.hide,
|
|
119
|
+
hideAll: toastStore.hideAll,
|
|
120
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typography — semantic text presets.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from: heroui-inc/heroui-native (Typography `type` scale and the
|
|
5
|
+
* Heading / Paragraph / Code compound parts), built on PanelUI's Text
|
|
6
|
+
* primitive so every preset keeps `className` passthrough and theme colours.
|
|
7
|
+
*/
|
|
8
|
+
import { forwardRef } from 'react';
|
|
9
|
+
import { View, type Text as RNText, type ViewProps } from 'react-native';
|
|
10
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
11
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
12
|
+
|
|
13
|
+
const typographyVariants = tv({
|
|
14
|
+
base: 'text-foreground',
|
|
15
|
+
variants: {
|
|
16
|
+
type: {
|
|
17
|
+
h1: 'text-4xl font-bold tracking-tight',
|
|
18
|
+
h2: 'text-3xl font-semibold tracking-tight',
|
|
19
|
+
h3: 'text-2xl font-semibold tracking-tight',
|
|
20
|
+
h4: 'text-xl font-semibold',
|
|
21
|
+
h5: 'text-lg font-semibold',
|
|
22
|
+
h6: 'text-base font-semibold',
|
|
23
|
+
body: 'text-base font-normal',
|
|
24
|
+
'body-sm': 'text-sm font-normal',
|
|
25
|
+
'body-xs': 'text-xs font-normal',
|
|
26
|
+
code: 'font-mono text-sm text-foreground',
|
|
27
|
+
},
|
|
28
|
+
muted: {
|
|
29
|
+
true: 'text-muted-foreground',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: {
|
|
33
|
+
type: 'body',
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type TypographyType = NonNullable<
|
|
38
|
+
VariantProps<typeof typographyVariants>['type']
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
export interface TypographyProps
|
|
42
|
+
extends Omit<TextProps, 'size' | 'weight'>,
|
|
43
|
+
VariantProps<typeof typographyVariants> {
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Heading levels, for `Typography.Heading`. */
|
|
48
|
+
type HeadingType = Extract<TypographyType, 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>;
|
|
49
|
+
/** Body sizes, for `Typography.Paragraph`. */
|
|
50
|
+
type ParagraphType = Extract<TypographyType, 'body' | 'body-sm' | 'body-xs'>;
|
|
51
|
+
|
|
52
|
+
/** Maps a heading preset to its accessibility heading level. */
|
|
53
|
+
const HEADING_LEVEL: Record<HeadingType, number> = {
|
|
54
|
+
h1: 1,
|
|
55
|
+
h2: 2,
|
|
56
|
+
h3: 3,
|
|
57
|
+
h4: 4,
|
|
58
|
+
h5: 5,
|
|
59
|
+
h6: 6,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const TypographyRoot = forwardRef<RNText, TypographyProps>(
|
|
63
|
+
({ className, type, muted, ...props }, ref) => (
|
|
64
|
+
<Text ref={ref} className={typographyVariants({ type, muted, className })} {...props} />
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
TypographyRoot.displayName = 'Typography';
|
|
68
|
+
|
|
69
|
+
export interface TypographyHeadingProps extends Omit<TypographyProps, 'type'> {
|
|
70
|
+
type?: HeadingType;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Heading text, wired up with the matching accessibility heading level. */
|
|
74
|
+
const TypographyHeading = forwardRef<RNText, TypographyHeadingProps>(
|
|
75
|
+
({ className, type = 'h2', muted, ...props }, ref) => (
|
|
76
|
+
<Text
|
|
77
|
+
ref={ref}
|
|
78
|
+
accessibilityRole="header"
|
|
79
|
+
aria-level={HEADING_LEVEL[type]}
|
|
80
|
+
className={typographyVariants({ type, muted, className })}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
TypographyHeading.displayName = 'Typography.Heading';
|
|
86
|
+
|
|
87
|
+
export interface TypographyParagraphProps extends Omit<TypographyProps, 'type'> {
|
|
88
|
+
type?: ParagraphType;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const TypographyParagraph = forwardRef<RNText, TypographyParagraphProps>(
|
|
92
|
+
({ className, type = 'body', muted, ...props }, ref) => (
|
|
93
|
+
<Text ref={ref} className={typographyVariants({ type, muted, className })} {...props} />
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
TypographyParagraph.displayName = 'Typography.Paragraph';
|
|
97
|
+
|
|
98
|
+
export interface TypographyCodeProps extends Omit<TypographyProps, 'type'> {
|
|
99
|
+
/** Classes for the surface behind the code text. */
|
|
100
|
+
containerClassName?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Inline code on a muted surface. */
|
|
104
|
+
const TypographyCode = forwardRef<View, TypographyCodeProps & Pick<ViewProps, 'testID'>>(
|
|
105
|
+
({ className, containerClassName, muted, testID, ...props }, ref) => (
|
|
106
|
+
<View
|
|
107
|
+
ref={ref}
|
|
108
|
+
testID={testID}
|
|
109
|
+
className={`self-start rounded-md bg-muted px-1.5 py-1 ${containerClassName ?? ''}`}
|
|
110
|
+
>
|
|
111
|
+
<Text className={typographyVariants({ type: 'code', muted, className })} {...props} />
|
|
112
|
+
</View>
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
TypographyCode.displayName = 'Typography.Code';
|
|
116
|
+
|
|
117
|
+
export const Typography = Object.assign(TypographyRoot, {
|
|
118
|
+
Heading: TypographyHeading,
|
|
119
|
+
Paragraph: TypographyParagraph,
|
|
120
|
+
Code: TypographyCode,
|
|
121
|
+
});
|