react-native-terra-ui 0.10.0 → 0.10.2

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/module/components/button/Button.js +21 -13
  3. package/lib/module/components/button/Button.js.map +1 -1
  4. package/lib/module/components/button/index.js.map +1 -1
  5. package/lib/module/components/divider/Divider.js +127 -0
  6. package/lib/module/components/divider/Divider.js.map +1 -0
  7. package/lib/module/components/divider/index.js +4 -0
  8. package/lib/module/components/divider/index.js.map +1 -0
  9. package/lib/module/components/index.js +2 -0
  10. package/lib/module/components/index.js.map +1 -1
  11. package/lib/module/components/pressable/index.js +4 -0
  12. package/lib/module/components/pressable/index.js.map +1 -0
  13. package/lib/module/components/pressable/variants/PressableScale/index.js +110 -0
  14. package/lib/module/components/pressable/variants/PressableScale/index.js.map +1 -0
  15. package/lib/module/components/toast/ToastProvider.js +9 -236
  16. package/lib/module/components/toast/ToastProvider.js.map +1 -1
  17. package/lib/module/components/toast/hooks/use-toast-manager.js +96 -0
  18. package/lib/module/components/toast/hooks/use-toast-manager.js.map +1 -0
  19. package/lib/module/components/toast/parts/ToastAnimatedItem.js +34 -50
  20. package/lib/module/components/toast/parts/ToastAnimatedItem.js.map +1 -1
  21. package/lib/module/components/toast/parts/ToastOverlay.ios.js +36 -0
  22. package/lib/module/components/toast/parts/ToastOverlay.ios.js.map +1 -0
  23. package/lib/module/components/toast/parts/ToastOverlay.js +34 -0
  24. package/lib/module/components/toast/parts/ToastOverlay.js.map +1 -0
  25. package/lib/module/components/toast/parts/ToastViewport.js +91 -0
  26. package/lib/module/components/toast/parts/ToastViewport.js.map +1 -0
  27. package/lib/typescript/src/components/button/Button.d.ts +7 -0
  28. package/lib/typescript/src/components/button/Button.d.ts.map +1 -1
  29. package/lib/typescript/src/components/button/index.d.ts +1 -1
  30. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/divider/Divider.d.ts +49 -0
  32. package/lib/typescript/src/components/divider/Divider.d.ts.map +1 -0
  33. package/lib/typescript/src/components/divider/index.d.ts +3 -0
  34. package/lib/typescript/src/components/divider/index.d.ts.map +1 -0
  35. package/lib/typescript/src/components/index.d.ts +2 -0
  36. package/lib/typescript/src/components/index.d.ts.map +1 -1
  37. package/lib/typescript/src/components/pressable/index.d.ts +3 -0
  38. package/lib/typescript/src/components/pressable/index.d.ts.map +1 -0
  39. package/lib/typescript/src/components/pressable/variants/PressableScale/index.d.ts +47 -0
  40. package/lib/typescript/src/components/pressable/variants/PressableScale/index.d.ts.map +1 -0
  41. package/lib/typescript/src/components/toast/ToastProvider.d.ts.map +1 -1
  42. package/lib/typescript/src/components/toast/hooks/use-toast-manager.d.ts +6 -0
  43. package/lib/typescript/src/components/toast/hooks/use-toast-manager.d.ts.map +1 -0
  44. package/lib/typescript/src/components/toast/parts/ToastAnimatedItem.d.ts +3 -2
  45. package/lib/typescript/src/components/toast/parts/ToastAnimatedItem.d.ts.map +1 -1
  46. package/lib/typescript/src/components/toast/parts/ToastOverlay.d.ts +28 -0
  47. package/lib/typescript/src/components/toast/parts/ToastOverlay.d.ts.map +1 -0
  48. package/lib/typescript/src/components/toast/parts/ToastOverlay.ios.d.ts +28 -0
  49. package/lib/typescript/src/components/toast/parts/ToastOverlay.ios.d.ts.map +1 -0
  50. package/lib/typescript/src/components/toast/parts/ToastViewport.d.ts +11 -0
  51. package/lib/typescript/src/components/toast/parts/ToastViewport.d.ts.map +1 -0
  52. package/package.json +1 -1
  53. package/src/components/button/Button.tsx +32 -13
  54. package/src/components/button/index.ts +1 -0
  55. package/src/components/divider/Divider.tsx +164 -0
  56. package/src/components/divider/index.ts +7 -0
  57. package/src/components/index.ts +2 -0
  58. package/src/components/pressable/index.ts +5 -0
  59. package/src/components/pressable/variants/PressableScale/index.tsx +166 -0
  60. package/src/components/toast/ToastProvider.tsx +7 -288
  61. package/src/components/toast/hooks/use-toast-manager.tsx +114 -0
  62. package/src/components/toast/parts/ToastAnimatedItem.tsx +32 -50
  63. package/src/components/toast/parts/ToastOverlay.ios.tsx +30 -0
  64. package/src/components/toast/parts/ToastOverlay.tsx +28 -0
  65. package/src/components/toast/parts/ToastViewport.tsx +104 -0
@@ -0,0 +1,164 @@
1
+ import { Children, type ComponentRef, forwardRef, type ReactNode } from "react";
2
+ import { StyleSheet as RNStyleSheet, View, type ViewProps } from "react-native";
3
+
4
+ import { StyleSheet, useUnistyles } from "react-native-unistyles";
5
+
6
+ import type { ColorToken, SpacingKey } from "#theme/types";
7
+ import { resolveThemeColor } from "#utils/resolve-theme-color";
8
+ import { Text } from "../text";
9
+
10
+ export type DividerOrientation = "horizontal" | "vertical";
11
+ export type DividerLabelAlign = "start" | "center" | "end";
12
+
13
+ /** `number | 'hairline'`, mirroring `Box`'s `borderWidth`. */
14
+ export type DividerThickness = number | "hairline";
15
+
16
+ export interface DividerProps extends ViewProps {
17
+ /** Rule direction. Defaults to `'horizontal'`. */
18
+ orientation?: DividerOrientation;
19
+ /** Rule color. Defaults to `'border.default'`. */
20
+ color?: ColorToken;
21
+ /**
22
+ * Rule weight. `'hairline'` is the thinnest line the display can draw —
23
+ * a true 1px rule at any density. Defaults to `'hairline'`.
24
+ */
25
+ thickness?: DividerThickness;
26
+ /**
27
+ * Symmetric margin on the cross axis — above and below a horizontal rule,
28
+ * either side of a vertical one. Defaults to `undefined` (no margin).
29
+ */
30
+ spacing?: SpacingKey;
31
+ /**
32
+ * Where the label sits along the rule. Ignored without `children`.
33
+ * Defaults to `'center'`.
34
+ */
35
+ labelAlign?: DividerLabelAlign;
36
+ /**
37
+ * An optional label, drawn with the rule running either side of it. Strings
38
+ * and numbers are wrapped in `Text` automatically. Horizontal only — a
39
+ * vertical divider ignores children.
40
+ */
41
+ children?: ReactNode;
42
+ }
43
+
44
+ function resolveThickness(thickness: DividerThickness): number {
45
+ return thickness === "hairline" ? RNStyleSheet.hairlineWidth : thickness;
46
+ }
47
+
48
+ function renderLabel(children: ReactNode): ReactNode {
49
+ return Children.map(children, (child) => {
50
+ if (typeof child === "string") {
51
+ return child.trim().length > 0 ? (
52
+ <Text variant="label-sm" color="text.secondary">
53
+ {child}
54
+ </Text>
55
+ ) : null;
56
+ }
57
+ if (typeof child === "number") {
58
+ return (
59
+ <Text variant="label-sm" color="text.secondary">
60
+ {child}
61
+ </Text>
62
+ );
63
+ }
64
+ return child;
65
+ });
66
+ }
67
+
68
+ /**
69
+ * A rule that separates content.
70
+ *
71
+ * Decorative by default — screen readers skip it, because the separation it
72
+ * expresses is already carried by the content either side. A labelled divider
73
+ * keeps its label readable.
74
+ *
75
+ * @example
76
+ * <Divider />
77
+ * <Divider spacing="4" />
78
+ * <Divider orientation="vertical" />
79
+ * <Divider>OR</Divider>
80
+ */
81
+ export const Divider = forwardRef<ComponentRef<typeof View>, DividerProps>(function Divider(
82
+ {
83
+ orientation = "horizontal",
84
+ color = "border.default",
85
+ thickness = "hairline",
86
+ spacing,
87
+ labelAlign = "center",
88
+ children,
89
+ style,
90
+ ...rest
91
+ },
92
+ ref
93
+ ) {
94
+ const { theme } = useUnistyles();
95
+ const isVertical = orientation === "vertical";
96
+ // A vertical rule has no room to run either side of a label, so children are
97
+ // dropped rather than silently laid out in a direction they were not written for.
98
+ const label = isVertical ? null : renderLabel(children);
99
+ const hasLabel = Children.count(label) > 0;
100
+
101
+ const lineColor = resolveThemeColor(color, theme);
102
+ const weight = resolveThickness(thickness);
103
+ const margin = spacing !== undefined ? theme.spacing[spacing] : undefined;
104
+
105
+ const line = {
106
+ backgroundColor: lineColor,
107
+ ...(isVertical ? { width: weight } : { height: weight }),
108
+ };
109
+
110
+ if (!hasLabel) {
111
+ return (
112
+ <View
113
+ ref={ref}
114
+ accessibilityElementsHidden
115
+ importantForAccessibility="no-hide-descendants"
116
+ aria-hidden
117
+ {...rest}
118
+ style={[
119
+ styles.rule,
120
+ line,
121
+ margin !== undefined
122
+ ? isVertical
123
+ ? { marginHorizontal: margin }
124
+ : { marginVertical: margin }
125
+ : null,
126
+ style,
127
+ ]}
128
+ />
129
+ );
130
+ }
131
+
132
+ // The label stays readable, so the wrapper is not hidden from accessibility —
133
+ // only the two rule segments are, and each is a bare decorative View.
134
+ return (
135
+ <View
136
+ ref={ref}
137
+ {...rest}
138
+ style={[styles.labelledRoot, margin !== undefined ? { marginVertical: margin } : null, style]}
139
+ >
140
+ {labelAlign !== "start" && <View style={[styles.labelledSegment, line]} />}
141
+ {label}
142
+ {labelAlign !== "end" && <View style={[styles.labelledSegment, line]} />}
143
+ </View>
144
+ );
145
+ });
146
+
147
+ // ─── Styles ───────────────────────────────────────────────────────────────────
148
+
149
+ const styles = StyleSheet.create((theme) => ({
150
+ // Stretches across its parent's cross axis: full width in a column, full
151
+ // height in a row. The `line` style supplies the other dimension.
152
+ rule: {
153
+ alignSelf: "stretch",
154
+ },
155
+ labelledRoot: {
156
+ alignSelf: "stretch",
157
+ flexDirection: "row",
158
+ alignItems: "center",
159
+ gap: theme.spacing["3"],
160
+ },
161
+ labelledSegment: {
162
+ flex: 1,
163
+ },
164
+ }));
@@ -0,0 +1,7 @@
1
+ export type {
2
+ DividerLabelAlign,
3
+ DividerOrientation,
4
+ DividerProps,
5
+ DividerThickness,
6
+ } from "./Divider";
7
+ export { Divider } from "./Divider";
@@ -2,12 +2,14 @@ export * from "./avatar";
2
2
  export * from "./box";
3
3
  export * from "./button";
4
4
  export * from "./chip";
5
+ export * from "./divider";
5
6
  export * from "./empty-state";
6
7
  export * from "./header";
7
8
  export * from "./icon";
8
9
  export * from "./image";
9
10
  export * from "./page-indicator";
10
11
  export * from "./portal";
12
+ export * from "./pressable";
11
13
  export * from "./screen";
12
14
  export * from "./slot";
13
15
  export * from "./spinner";
@@ -0,0 +1,5 @@
1
+ export type {
2
+ PressableScaleAnimationConfig,
3
+ PressableScaleProps,
4
+ } from "./variants/PressableScale";
5
+ export { PressableScale } from "./variants/PressableScale";
@@ -0,0 +1,166 @@
1
+ import { type ComponentRef, forwardRef, useCallback, useEffect, useState } from "react";
2
+ import type {
3
+ GestureResponderEvent,
4
+ PressableProps,
5
+ PressableStateCallbackType,
6
+ StyleProp,
7
+ ViewStyle,
8
+ } from "react-native";
9
+ import { Pressable } from "react-native";
10
+
11
+ import Animated, {
12
+ Easing,
13
+ interpolate,
14
+ useAnimatedStyle,
15
+ useSharedValue,
16
+ type WithSpringConfig,
17
+ type WithTimingConfig,
18
+ withSpring,
19
+ withTiming,
20
+ } from "react-native-reanimated";
21
+ import { StyleSheet } from "react-native-unistyles";
22
+
23
+ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
24
+
25
+ /** A 250ms ease that reads as instant on press-in but never snaps back on release. */
26
+ const DEFAULT_TIMING: WithTimingConfig = {
27
+ duration: 250,
28
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1),
29
+ };
30
+
31
+ export type PressableScaleAnimationConfig =
32
+ | { type: "timing"; config?: WithTimingConfig }
33
+ | { type: "spring"; config?: WithSpringConfig };
34
+
35
+ export interface PressableScaleProps extends Omit<PressableProps, "style" | "disabled"> {
36
+ /** Scale held at rest. Defaults to `1`. */
37
+ baseScale?: number;
38
+ /**
39
+ * Scale held while pressed. Values below `baseScale` shrink, above it grow.
40
+ * Defaults to `0.96`.
41
+ */
42
+ minScale?: number;
43
+ /**
44
+ * Opacity held while pressed, animated on the same curve as the scale.
45
+ * Defaults to `undefined` — opacity is left untouched so `style` keeps
46
+ * ownership of it.
47
+ */
48
+ pressedOpacity?: number;
49
+ /**
50
+ * Drives the press animation. Defaults to `{ type: 'timing' }` with a 250ms
51
+ * ease; pass `{ type: 'spring' }` for a springy release.
52
+ */
53
+ animationConfig?: PressableScaleAnimationConfig;
54
+ /** Blocks presses and holds the resting scale. Defaults to `false`. */
55
+ isDisabled?: boolean;
56
+ /**
57
+ * Styles for the pressable itself. The function form receives `pressed`
58
+ * only — `hovered` and `focused` are not tracked, because the animated
59
+ * style has to be merged outside of `Pressable`'s own callback.
60
+ */
61
+ style?: StyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>);
62
+ }
63
+
64
+ /**
65
+ * A `Pressable` that animates a scale (and optionally an opacity) between its
66
+ * resting and pressed states, driven by a single shared `progress` value on the
67
+ * UI thread.
68
+ *
69
+ * Use it anywhere a press deserves physical feedback — cards, list rows, custom
70
+ * controls. `Button` renders one internally via its `animation` prop.
71
+ */
72
+ export const PressableScale = forwardRef<ComponentRef<typeof Pressable>, PressableScaleProps>(
73
+ function PressableScale(
74
+ {
75
+ baseScale = 1,
76
+ minScale = 0.96,
77
+ pressedOpacity,
78
+ animationConfig,
79
+ isDisabled = false,
80
+ style,
81
+ onPressIn,
82
+ onPressOut,
83
+ ...rest
84
+ },
85
+ ref
86
+ ) {
87
+ const progress = useSharedValue(0);
88
+
89
+ // Only the function form of `style` needs a re-render to resolve; tracking
90
+ // `pressed` unconditionally would re-render every consumer on every touch.
91
+ const isStyleFunction = typeof style === "function";
92
+ const [pressed, setPressed] = useState(false);
93
+
94
+ const animateTo = useCallback(
95
+ (to: number) => {
96
+ if (animationConfig?.type === "spring") {
97
+ progress.value = withSpring(to, animationConfig.config);
98
+ return;
99
+ }
100
+ progress.value = withTiming(to, { ...DEFAULT_TIMING, ...animationConfig?.config });
101
+ },
102
+ [animationConfig, progress]
103
+ );
104
+
105
+ const handlePressIn = useCallback(
106
+ (event: GestureResponderEvent) => {
107
+ animateTo(1);
108
+ if (isStyleFunction) setPressed(true);
109
+ onPressIn?.(event);
110
+ },
111
+ [animateTo, isStyleFunction, onPressIn]
112
+ );
113
+
114
+ const handlePressOut = useCallback(
115
+ (event: GestureResponderEvent) => {
116
+ animateTo(0);
117
+ if (isStyleFunction) setPressed(false);
118
+ onPressOut?.(event);
119
+ },
120
+ [animateTo, isStyleFunction, onPressOut]
121
+ );
122
+
123
+ // A press that disables its own pressable — `onPress` flipping a button into
124
+ // a loading state — never gets an `onPressOut`, so release it here instead
125
+ // of leaving it stuck at the pressed scale.
126
+ useEffect(() => {
127
+ if (!isDisabled) return;
128
+ animateTo(0);
129
+ setPressed(false);
130
+ }, [isDisabled, animateTo]);
131
+
132
+ const animatedStyle = useAnimatedStyle(() => {
133
+ const scale = interpolate(progress.value, [0, 1], [baseScale, minScale]);
134
+ if (pressedOpacity === undefined) {
135
+ return { transform: [{ scale }] };
136
+ }
137
+ return {
138
+ transform: [{ scale }],
139
+ opacity: interpolate(progress.value, [0, 1], [1, pressedOpacity]),
140
+ };
141
+ });
142
+
143
+ const pressableState: PressableStateCallbackType = { pressed };
144
+
145
+ return (
146
+ <AnimatedPressable
147
+ ref={ref}
148
+ disabled={isDisabled}
149
+ {...rest}
150
+ onPressIn={handlePressIn}
151
+ onPressOut={handlePressOut}
152
+ // The animated style goes first so anything the caller sets — a disabled
153
+ // opacity, say — still wins.
154
+ style={[styles.root, animatedStyle, isStyleFunction ? style(pressableState) : style]}
155
+ />
156
+ );
157
+ }
158
+ );
159
+
160
+ // ─── Styles ───────────────────────────────────────────────────────────────────
161
+ //
162
+ // PressableScale has no style of its own — this stylesheet exists so the
163
+ // Unistyles Babel plugin swaps the `Pressable` imported above for its
164
+ // Unistyles-aware counterpart. Without it a themed style handed in through
165
+ // `style` freezes at whichever theme was active when it was created.
166
+ const styles = StyleSheet.create(() => ({ root: {} }));
@@ -1,207 +1,19 @@
1
- import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from "react";
2
- import { Keyboard, Platform } from "react-native";
1
+ import { createContext, useContext } from "react";
3
2
 
4
- import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
5
- import { useSafeAreaInsets } from "react-native-safe-area-context";
6
-
7
- import { ToastAnimatedItem } from "./parts/ToastAnimatedItem";
8
- import { DefaultToast } from "./Toast";
9
- import type {
10
- ToastComponentProps,
11
- ToastDuration,
12
- ToastId,
13
- ToastItem,
14
- ToastManager,
15
- ToastPlacement,
16
- ToastProviderProps,
17
- ToastShowConfig,
18
- ToastShowOptions,
19
- ToastShowOptionsWithComponent,
20
- } from "./types";
21
- import { setToastManager } from "./utils/controller";
22
-
23
- type ToastAction =
24
- | { type: "show"; item: ToastItem }
25
- | { type: "hide"; ids: ToastId[] }
26
- | { type: "hideAll" }
27
- | { type: "hidePlacement"; placement: ToastPlacement };
28
-
29
- const DEFAULT_DURATION = 4000;
30
- const DEFAULT_MAX_VISIBLE_TOASTS = 3;
3
+ import { useToastManager } from "./hooks/use-toast-manager";
4
+ import { ToastViewport } from "./parts/ToastViewport";
5
+ import type { ToastManager, ToastProviderProps } from "./types";
31
6
 
32
7
  const ToastManagerContext = createContext<ToastManager | null>(null);
33
8
 
34
- function toastReducer(state: ToastItem[], action: ToastAction): ToastItem[] {
35
- switch (action.type) {
36
- case "show":
37
- return [...state.filter((toast) => toast.id !== action.item.id), action.item];
38
- case "hide":
39
- return state.filter((toast) => !action.ids.some((id) => id === toast.id));
40
- case "hideAll":
41
- return [];
42
- case "hidePlacement":
43
- return state.filter((toast) => toast.placement !== action.placement);
44
- }
45
- }
46
-
47
- function createDefaultToastComponent(config: ToastShowConfig, manager: ToastManager) {
48
- return ({ id, hide }: ToastComponentProps) => (
49
- <DefaultToast
50
- id={id}
51
- hide={(toastId) => hide(String(toastId))}
52
- variant={config.variant}
53
- label={config.label}
54
- description={config.description}
55
- icon={config.icon}
56
- actionLabel={config.actionLabel}
57
- showCloseButton={config.showCloseButton}
58
- onActionPress={() => config.onActionPress?.(manager)}
59
- />
60
- );
61
- }
62
-
63
- function isCustomToast(options: ToastShowOptions): options is ToastShowOptionsWithComponent {
64
- return typeof options === "object" && options !== null && "component" in options;
65
- }
66
-
67
- function normalizePlacement(placement: ToastPlacement | undefined, fallback: ToastPlacement): ToastPlacement {
68
- return placement ?? fallback;
69
- }
70
-
71
- function normalizeDuration(duration: ToastDuration | undefined, fallback: number): ToastDuration {
72
- return duration ?? fallback;
73
- }
74
-
75
- function toastId(counter: number): ToastId {
76
- return `toast-${Date.now()}-${counter}`;
77
- }
78
-
79
9
  export function ToastProvider({
80
10
  children,
81
11
  defaultPlacement = "top",
82
- defaultDuration = DEFAULT_DURATION,
83
- maxVisibleToasts = DEFAULT_MAX_VISIBLE_TOASTS,
12
+ defaultDuration = 4000,
13
+ maxVisibleToasts = 3,
84
14
  offset,
85
15
  }: ToastProviderProps) {
86
- const [toasts, dispatch] = useReducer(toastReducer, []);
87
- const toastsRef = useRef<ToastItem[]>([]);
88
- const counterRef = useRef(0);
89
- const timeoutsRef = useRef<Map<ToastId, ReturnType<typeof setTimeout>>>(new Map());
90
-
91
- useEffect(() => {
92
- toastsRef.current = toasts;
93
- }, [toasts]);
94
-
95
- const clearToastTimeout = useCallback((id: ToastId) => {
96
- const timeout = timeoutsRef.current.get(id);
97
- if (timeout) clearTimeout(timeout);
98
- timeoutsRef.current.delete(id);
99
- }, []);
100
-
101
- const hide = useCallback<ToastManager["hide"]>(
102
- (id) => {
103
- if (id === "all") {
104
- for (const toast of toastsRef.current) {
105
- clearToastTimeout(toast.id);
106
- toast.onHide?.();
107
- }
108
- toastsRef.current = [];
109
- dispatch({ type: "hideAll" });
110
- return;
111
- }
112
- const lastToastId = toastsRef.current[toastsRef.current.length - 1]?.id;
113
- const ids = id === undefined ? (lastToastId ? [lastToastId] : []) : Array.isArray(id) ? id : [id];
114
-
115
- if (ids.length === 0) return;
116
-
117
- for (const toastId of ids) {
118
- clearToastTimeout(toastId);
119
- toastsRef.current.find((toast) => toast.id === toastId)?.onHide?.();
120
- }
121
- toastsRef.current = toastsRef.current.filter((toast) => !ids.some((toastId) => toastId === toast.id));
122
- dispatch({ type: "hide", ids });
123
- },
124
- [clearToastTimeout]
125
- );
126
-
127
- const managerRef = useRef<ToastManager>({
128
- show: () => "",
129
- hide,
130
- });
131
-
132
- const show = useCallback<ToastManager["show"]>(
133
- (options) => {
134
- const nextId =
135
- typeof options === "object" && options !== null && "id" in options
136
- ? (options.id ?? toastId(counterRef.current++))
137
- : toastId(counterRef.current++);
138
-
139
- const item =
140
- typeof options === "string"
141
- ? {
142
- id: nextId,
143
- component: createDefaultToastComponent({ label: options }, managerRef.current),
144
- duration: defaultDuration,
145
- placement: defaultPlacement,
146
- }
147
- : isCustomToast(options)
148
- ? {
149
- id: nextId,
150
- component: options.component,
151
- duration: normalizeDuration(options.duration, defaultDuration),
152
- placement: normalizePlacement(options.placement, defaultPlacement),
153
- onHide: options.onHide,
154
- }
155
- : {
156
- id: nextId,
157
- component: createDefaultToastComponent(options, managerRef.current),
158
- duration: normalizeDuration(options.duration, defaultDuration),
159
- placement: normalizePlacement(options.placement, defaultPlacement),
160
- onHide: options.onHide,
161
- };
162
-
163
- const oppositePlacement = item.placement === "top" ? "bottom" : "top";
164
- const oppositeToasts = toastsRef.current.filter((t) => t.placement === oppositePlacement);
165
- if (oppositeToasts.length > 0) {
166
- for (const t of oppositeToasts) {
167
- clearToastTimeout(t.id);
168
- t.onHide?.();
169
- }
170
- toastsRef.current = toastsRef.current.filter((t) => t.placement !== oppositePlacement);
171
- dispatch({ type: "hidePlacement", placement: oppositePlacement });
172
- }
173
-
174
- clearToastTimeout(item.id);
175
- toastsRef.current = [...toastsRef.current.filter((toast) => toast.id !== item.id), item];
176
- dispatch({ type: "show", item });
177
- if (typeof options === "object" && options !== null && "onShow" in options) {
178
- options.onShow?.();
179
- }
180
-
181
- if (item.duration !== "persistent" && Number.isFinite(item.duration) && item.duration > 0) {
182
- const timeout = setTimeout(() => hide(item.id), item.duration);
183
- timeoutsRef.current.set(item.id, timeout);
184
- }
185
-
186
- return item.id;
187
- },
188
- [clearToastTimeout, defaultDuration, defaultPlacement, hide]
189
- );
190
-
191
- const manager = useMemo<ToastManager>(() => ({ show, hide }), [show, hide]);
192
- managerRef.current = manager;
193
-
194
- useEffect(() => {
195
- setToastManager(manager);
196
- return () => {
197
- setToastManager(null);
198
- for (const timeout of timeoutsRef.current.values()) {
199
- clearTimeout(timeout);
200
- }
201
- timeoutsRef.current.clear();
202
- };
203
- }, [manager]);
204
-
16
+ const { toasts, manager } = useToastManager(defaultPlacement, defaultDuration);
205
17
  const topToasts = toasts.filter((toast) => toast.placement === "top");
206
18
  const bottomToasts = toasts.filter((toast) => toast.placement === "bottom");
207
19
 
@@ -226,99 +38,6 @@ export function ToastProvider({
226
38
  );
227
39
  }
228
40
 
229
- interface ToastViewportProps {
230
- placement: ToastPlacement;
231
- toasts: ToastItem[];
232
- manager: ToastManager;
233
- maxVisibleToasts: number;
234
- offset?: ToastProviderProps["offset"];
235
- }
236
-
237
- function ToastViewport({ placement, toasts, manager, maxVisibleToasts, offset }: ToastViewportProps) {
238
- const safeAreaInsets = useSafeAreaInsets();
239
- const heights = useSharedValue<Record<ToastId, number>>({});
240
- const total = useSharedValue(0);
241
- const keyboardHeight = useSharedValue(0);
242
-
243
- const baseBottom = offset?.bottom ?? safeAreaInsets.bottom + (Platform.OS === "ios" ? 6 : 12);
244
- const baseTop = offset?.top ?? safeAreaInsets.top + (Platform.OS === "ios" ? 0 : 12);
245
-
246
- useEffect(() => {
247
- if (placement !== "bottom") return;
248
- const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
249
- const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
250
- const showSub = Keyboard.addListener(showEvent, (e) => {
251
- keyboardHeight.value = withTiming(e.endCoordinates.height);
252
- });
253
- const hideSub = Keyboard.addListener(hideEvent, () => {
254
- keyboardHeight.value = withTiming(0);
255
- });
256
- return () => {
257
- showSub.remove();
258
- hideSub.remove();
259
- };
260
- }, [keyboardHeight, placement]);
261
-
262
- const gap = Platform.OS === "ios" ? 6 : 12;
263
-
264
- const keyboardStyle = useAnimatedStyle(() => {
265
- if (placement === "top") return { top: baseTop };
266
- if (keyboardHeight.value > 0) {
267
- return { bottom: keyboardHeight.value + gap };
268
- }
269
- return { bottom: baseBottom };
270
- });
271
-
272
- useEffect(() => {
273
- total.value = toasts.length;
274
- const activeIds = new Set(toasts.map((toast) => toast.id));
275
- const nextHeights: Record<ToastId, number> = {};
276
- for (const [id, height] of Object.entries(heights.value)) {
277
- if (activeIds.has(id)) {
278
- nextHeights[id] = height;
279
- }
280
- }
281
- heights.value = nextHeights;
282
- }, [heights, toasts, total]);
283
-
284
- if (toasts.length === 0) return null;
285
-
286
- return (
287
- <Animated.View
288
- pointerEvents="box-none"
289
- style={[
290
- {
291
- position: "absolute",
292
- zIndex: 9999,
293
- elevation: 9999,
294
- left: offset?.left ?? safeAreaInsets.left + 12,
295
- right: offset?.right ?? safeAreaInsets.right + 12,
296
- },
297
- keyboardStyle,
298
- ]}
299
- >
300
- {toasts.map((toast, index) => (
301
- <ToastAnimatedItem
302
- key={toast.id}
303
- heights={heights}
304
- id={toast.id}
305
- index={index}
306
- maxVisibleToasts={maxVisibleToasts}
307
- placement={placement}
308
- total={total}
309
- >
310
- {toast.component({
311
- id: toast.id,
312
- index,
313
- hide: manager.hide,
314
- show: manager.show,
315
- })}
316
- </ToastAnimatedItem>
317
- ))}
318
- </Animated.View>
319
- );
320
- }
321
-
322
41
  export function useToast(): ToastManager {
323
42
  const manager = useContext(ToastManagerContext);
324
43
  if (!manager) {