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.
- package/CHANGELOG.md +13 -0
- package/lib/module/components/button/Button.js +21 -13
- package/lib/module/components/button/Button.js.map +1 -1
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/divider/Divider.js +127 -0
- package/lib/module/components/divider/Divider.js.map +1 -0
- package/lib/module/components/divider/index.js +4 -0
- package/lib/module/components/divider/index.js.map +1 -0
- package/lib/module/components/index.js +2 -0
- package/lib/module/components/index.js.map +1 -1
- package/lib/module/components/pressable/index.js +4 -0
- package/lib/module/components/pressable/index.js.map +1 -0
- package/lib/module/components/pressable/variants/PressableScale/index.js +110 -0
- package/lib/module/components/pressable/variants/PressableScale/index.js.map +1 -0
- package/lib/module/components/toast/ToastProvider.js +9 -236
- package/lib/module/components/toast/ToastProvider.js.map +1 -1
- package/lib/module/components/toast/hooks/use-toast-manager.js +96 -0
- package/lib/module/components/toast/hooks/use-toast-manager.js.map +1 -0
- package/lib/module/components/toast/parts/ToastAnimatedItem.js +34 -50
- package/lib/module/components/toast/parts/ToastAnimatedItem.js.map +1 -1
- package/lib/module/components/toast/parts/ToastOverlay.ios.js +36 -0
- package/lib/module/components/toast/parts/ToastOverlay.ios.js.map +1 -0
- package/lib/module/components/toast/parts/ToastOverlay.js +34 -0
- package/lib/module/components/toast/parts/ToastOverlay.js.map +1 -0
- package/lib/module/components/toast/parts/ToastViewport.js +91 -0
- package/lib/module/components/toast/parts/ToastViewport.js.map +1 -0
- package/lib/typescript/src/components/button/Button.d.ts +7 -0
- package/lib/typescript/src/components/button/Button.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +1 -1
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/divider/Divider.d.ts +49 -0
- package/lib/typescript/src/components/divider/Divider.d.ts.map +1 -0
- package/lib/typescript/src/components/divider/index.d.ts +3 -0
- package/lib/typescript/src/components/divider/index.d.ts.map +1 -0
- package/lib/typescript/src/components/index.d.ts +2 -0
- package/lib/typescript/src/components/index.d.ts.map +1 -1
- package/lib/typescript/src/components/pressable/index.d.ts +3 -0
- package/lib/typescript/src/components/pressable/index.d.ts.map +1 -0
- package/lib/typescript/src/components/pressable/variants/PressableScale/index.d.ts +47 -0
- package/lib/typescript/src/components/pressable/variants/PressableScale/index.d.ts.map +1 -0
- package/lib/typescript/src/components/toast/ToastProvider.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/hooks/use-toast-manager.d.ts +6 -0
- package/lib/typescript/src/components/toast/hooks/use-toast-manager.d.ts.map +1 -0
- package/lib/typescript/src/components/toast/parts/ToastAnimatedItem.d.ts +3 -2
- package/lib/typescript/src/components/toast/parts/ToastAnimatedItem.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/parts/ToastOverlay.d.ts +28 -0
- package/lib/typescript/src/components/toast/parts/ToastOverlay.d.ts.map +1 -0
- package/lib/typescript/src/components/toast/parts/ToastOverlay.ios.d.ts +28 -0
- package/lib/typescript/src/components/toast/parts/ToastOverlay.ios.d.ts.map +1 -0
- package/lib/typescript/src/components/toast/parts/ToastViewport.d.ts +11 -0
- package/lib/typescript/src/components/toast/parts/ToastViewport.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/button/Button.tsx +32 -13
- package/src/components/button/index.ts +1 -0
- package/src/components/divider/Divider.tsx +164 -0
- package/src/components/divider/index.ts +7 -0
- package/src/components/index.ts +2 -0
- package/src/components/pressable/index.ts +5 -0
- package/src/components/pressable/variants/PressableScale/index.tsx +166 -0
- package/src/components/toast/ToastProvider.tsx +7 -288
- package/src/components/toast/hooks/use-toast-manager.tsx +114 -0
- package/src/components/toast/parts/ToastAnimatedItem.tsx +32 -50
- package/src/components/toast/parts/ToastOverlay.ios.tsx +30 -0
- package/src/components/toast/parts/ToastOverlay.tsx +28 -0
- 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
|
+
}));
|
package/src/components/index.ts
CHANGED
|
@@ -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,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,
|
|
2
|
-
import { Keyboard, Platform } from "react-native";
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
3
2
|
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
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 =
|
|
83
|
-
maxVisibleToasts =
|
|
12
|
+
defaultDuration = 4000,
|
|
13
|
+
maxVisibleToasts = 3,
|
|
84
14
|
offset,
|
|
85
15
|
}: ToastProviderProps) {
|
|
86
|
-
const
|
|
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) {
|