react-native-toast-signal 0.1.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.
Files changed (40) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +353 -0
  3. package/lib/module/components/signal-item.js +482 -0
  4. package/lib/module/components/signal-item.js.map +1 -0
  5. package/lib/module/constants.js +62 -0
  6. package/lib/module/constants.js.map +1 -0
  7. package/lib/module/core/store.js +158 -0
  8. package/lib/module/core/store.js.map +1 -0
  9. package/lib/module/hooks/useSignal.js +71 -0
  10. package/lib/module/hooks/useSignal.js.map +1 -0
  11. package/lib/module/index.js +6 -0
  12. package/lib/module/index.js.map +1 -0
  13. package/lib/module/package.json +1 -0
  14. package/lib/module/provider/signal-provider.js +72 -0
  15. package/lib/module/provider/signal-provider.js.map +1 -0
  16. package/lib/module/types.js +4 -0
  17. package/lib/module/types.js.map +1 -0
  18. package/lib/typescript/package.json +1 -0
  19. package/lib/typescript/src/components/signal-item.d.ts +7 -0
  20. package/lib/typescript/src/components/signal-item.d.ts.map +1 -0
  21. package/lib/typescript/src/constants.d.ts +12 -0
  22. package/lib/typescript/src/constants.d.ts.map +1 -0
  23. package/lib/typescript/src/core/store.d.ts +48 -0
  24. package/lib/typescript/src/core/store.d.ts.map +1 -0
  25. package/lib/typescript/src/hooks/useSignal.d.ts +46 -0
  26. package/lib/typescript/src/hooks/useSignal.d.ts.map +1 -0
  27. package/lib/typescript/src/index.d.ts +4 -0
  28. package/lib/typescript/src/index.d.ts.map +1 -0
  29. package/lib/typescript/src/provider/signal-provider.d.ts +7 -0
  30. package/lib/typescript/src/provider/signal-provider.d.ts.map +1 -0
  31. package/lib/typescript/src/types.d.ts +73 -0
  32. package/lib/typescript/src/types.d.ts.map +1 -0
  33. package/package.json +174 -0
  34. package/src/components/signal-item.tsx +548 -0
  35. package/src/constants.ts +62 -0
  36. package/src/core/store.ts +171 -0
  37. package/src/hooks/useSignal.ts +116 -0
  38. package/src/index.tsx +3 -0
  39. package/src/provider/signal-provider.tsx +89 -0
  40. package/src/types.ts +88 -0
@@ -0,0 +1,62 @@
1
+ import type { SignalTheme } from './types';
2
+
3
+ export const MAX_VISIBLE = 3;
4
+ export const DEFAULT_DURATION = 3000;
5
+ export const STACK_OFFSET = 14;
6
+ export const SCALE_STEP = 0.05;
7
+ export const ENTRY_OFFSET_Y = 80;
8
+ export const SWIPE_THRESHOLD = 100;
9
+ export const TIMING_MS = 280;
10
+
11
+ export const DEFAULT_THEME: SignalTheme = {
12
+ success: {
13
+ background: '#0a0a0a',
14
+ border: '#22c55e40',
15
+ titleColor: '#ffffff',
16
+ descriptionColor: '#d1fae5',
17
+ iconColor: '#22c55e',
18
+ },
19
+ error: {
20
+ background: '#0a0a0a',
21
+ border: '#ef444440',
22
+ titleColor: '#ffffff',
23
+ descriptionColor: '#fee2e2',
24
+ iconColor: '#ef4444',
25
+ },
26
+ warning: {
27
+ background: '#0a0a0a',
28
+ border: '#f59e0b40',
29
+ titleColor: '#ffffff',
30
+ descriptionColor: '#fef3c7',
31
+ iconColor: '#f59e0b',
32
+ },
33
+ info: {
34
+ background: '#0a0a0a',
35
+ border: '#ffffff26',
36
+ titleColor: '#ffffff',
37
+ descriptionColor: '#ffffffcc',
38
+ iconColor: '#60a5fa',
39
+ },
40
+ loading: {
41
+ background: '#0a0a0a',
42
+ border: '#a78bfa40',
43
+ titleColor: '#ffffff',
44
+ descriptionColor: '#ede9fe',
45
+ iconColor: '#a78bfa',
46
+ },
47
+ custom: {
48
+ background: '#0a0a0a',
49
+ border: '#ffffff26',
50
+ titleColor: '#ffffff',
51
+ descriptionColor: '#ffffffcc',
52
+ iconColor: '#ffffff',
53
+ },
54
+ };
55
+
56
+ /** Unicode icons for non-loading, non-custom types */
57
+ export const TYPE_ICONS: Partial<Record<string, string>> = {
58
+ success: '✓',
59
+ error: '✕',
60
+ warning: '⚠',
61
+ info: 'ℹ',
62
+ };
@@ -0,0 +1,171 @@
1
+ import { MAX_VISIBLE, DEFAULT_DURATION } from '../constants';
2
+ import type {
3
+ SignalListener,
4
+ SignalOptions,
5
+ SignalPromiseMessages,
6
+ } from '../types';
7
+
8
+ class SignalStore {
9
+ private listener: SignalListener | null = null;
10
+ private signals: SignalOptions[] = [];
11
+ private maxVisible: number = MAX_VISIBLE;
12
+ private defaultDuration: number = DEFAULT_DURATION;
13
+
14
+ configure(options: { maxVisible?: number; defaultDuration?: number }) {
15
+ if (options.maxVisible !== undefined) this.maxVisible = options.maxVisible;
16
+ if (options.defaultDuration !== undefined)
17
+ this.defaultDuration = options.defaultDuration;
18
+ }
19
+
20
+ getMaxVisible() {
21
+ return this.maxVisible;
22
+ }
23
+
24
+ setListener(listener: SignalListener) {
25
+ this.listener = listener;
26
+ }
27
+
28
+ removeListener() {
29
+ this.listener = null;
30
+ }
31
+
32
+ show(options: SignalOptions): string {
33
+ const now = Date.now();
34
+ const id =
35
+ options.id ?? `signal_${now}_${Math.random().toString(36).slice(2, 7)}`;
36
+
37
+ if (this.signals.some((s) => s.id === id)) return id;
38
+
39
+ const isLoading = options.type === 'loading';
40
+
41
+ const signal: SignalOptions = {
42
+ type: 'info',
43
+ position: 'top',
44
+ swipeToDismiss: !isLoading, // loading toasts shouldn't be accidentally swiped away
45
+ autoHide: !isLoading, // loading toasts stay until manually dismissed
46
+ duration: this.defaultDuration,
47
+ ...options,
48
+ id,
49
+ createdAt: now,
50
+ };
51
+
52
+ this.signals.unshift(signal);
53
+ this.emit();
54
+ return id;
55
+ }
56
+
57
+ /** Remove a toast by id */
58
+ hide(id: string) {
59
+ const len = this.signals.length;
60
+ this.signals = this.signals.filter((s) => s.id !== id);
61
+ if (this.signals.length !== len) this.emit();
62
+ }
63
+
64
+ /** Alias for hide() */
65
+ dismiss(id: string) {
66
+ this.hide(id);
67
+ }
68
+
69
+ /** Update any fields of an existing toast in-place */
70
+ update(
71
+ id: string,
72
+ options: Partial<Omit<SignalOptions, 'id' | 'createdAt'>>
73
+ ) {
74
+ const idx = this.signals.findIndex((s) => s.id === id);
75
+ if (idx === -1) return;
76
+ this.signals[idx] = { ...this.signals[idx]!, ...options };
77
+ this.emit();
78
+ }
79
+
80
+ /** Remove all toasts */
81
+ clear() {
82
+ if (this.signals.length === 0) return;
83
+ this.signals = [];
84
+ this.emit();
85
+ }
86
+
87
+ // ─── Convenience shorthands ───────────────────────────────────────────────
88
+
89
+ success(description: string, options?: Partial<SignalOptions>) {
90
+ return this.show({ ...options, description, type: 'success' });
91
+ }
92
+
93
+ error(description: string, options?: Partial<SignalOptions>) {
94
+ return this.show({ ...options, description, type: 'error' });
95
+ }
96
+
97
+ warning(description: string, options?: Partial<SignalOptions>) {
98
+ return this.show({ ...options, description, type: 'warning' });
99
+ }
100
+
101
+ info(description: string, options?: Partial<SignalOptions>) {
102
+ return this.show({ ...options, description, type: 'info' });
103
+ }
104
+
105
+ /**
106
+ * Show an infinite loading toast.
107
+ * Returns the id — call Signal.dismiss(id) when the operation completes.
108
+ */
109
+ loading(description: string, options?: Partial<SignalOptions>) {
110
+ return this.show({ ...options, description, type: 'loading' });
111
+ }
112
+
113
+ /**
114
+ * Attach a Promise to a toast lifecycle.
115
+ * Automatically transitions loading → success or error.
116
+ *
117
+ * @example
118
+ * Signal.promise(uploadFile(), {
119
+ * loading: 'Uploading…',
120
+ * success: (data) => `Uploaded ${data.name}!`,
121
+ * error: (err) => err.message ?? 'Upload failed',
122
+ * });
123
+ */
124
+ promise<T = unknown>(
125
+ promise: Promise<T>,
126
+ messages: SignalPromiseMessages<T>,
127
+ options?: Partial<
128
+ Omit<SignalOptions, 'type' | 'autoHide' | 'swipeToDismiss'>
129
+ >
130
+ ): Promise<T> {
131
+ const id = this.loading(messages.loading, { ...options });
132
+
133
+ promise
134
+ .then((data) => {
135
+ const description =
136
+ typeof messages.success === 'function'
137
+ ? messages.success(data)
138
+ : messages.success;
139
+ this.update(id, {
140
+ type: 'success',
141
+ description,
142
+ autoHide: true,
143
+ swipeToDismiss: true,
144
+
145
+ duration: options?.duration ?? this.defaultDuration,
146
+ });
147
+ })
148
+ .catch((err: unknown) => {
149
+ const description =
150
+ typeof messages.error === 'function'
151
+ ? messages.error(err)
152
+ : messages.error;
153
+ this.update(id, {
154
+ type: 'error',
155
+ description,
156
+ autoHide: true,
157
+ swipeToDismiss: true,
158
+
159
+ duration: options?.duration ?? this.defaultDuration,
160
+ });
161
+ });
162
+
163
+ return promise;
164
+ }
165
+
166
+ private emit() {
167
+ this.listener?.([...this.signals]);
168
+ }
169
+ }
170
+
171
+ export const Signal = new SignalStore();
@@ -0,0 +1,116 @@
1
+ import { useCallback } from 'react';
2
+ import { Signal } from '../core/store';
3
+ import type { SignalOptions, SignalPromiseMessages } from '../types';
4
+
5
+ /**
6
+ * Hook to imperatively control Signal toasts from any component.
7
+ *
8
+ * @example
9
+ * const signal = useSignal();
10
+ *
11
+ * // Simple
12
+ * signal.success('Saved!');
13
+ * signal.error('Failed', { title: 'Oops' });
14
+ *
15
+ * // Loading → dismiss manually
16
+ * const id = signal.loading('Uploading…');
17
+ * await upload();
18
+ * signal.dismiss(id);
19
+ * signal.success('Done!');
20
+ *
21
+ * // Promise shorthand (auto loading → success/error)
22
+ * signal.promise(uploadFile(), {
23
+ * loading: 'Uploading…',
24
+ * success: (data) => `Uploaded ${data.name}!`,
25
+ * error: (err) => err.message ?? 'Upload failed',
26
+ * });
27
+ *
28
+ * // With action button
29
+ * signal.error('Payment failed', {
30
+ * action: { label: 'Retry', onPress: (dismiss) => { retry(); dismiss(); } },
31
+ * });
32
+ *
33
+ * // Custom icon
34
+ * signal.show({ description: 'New follower!', icon: <Avatar uri={url} size={20} /> });
35
+ */
36
+ export const useSignal = () => {
37
+ const show = useCallback(
38
+ (options: SignalOptions): string => Signal.show(options),
39
+ []
40
+ );
41
+
42
+ const success = useCallback(
43
+ (description: string, options?: Partial<SignalOptions>): string =>
44
+ Signal.success(description, options),
45
+ []
46
+ );
47
+
48
+ const error = useCallback(
49
+ (description: string, options?: Partial<SignalOptions>): string =>
50
+ Signal.error(description, options),
51
+ []
52
+ );
53
+
54
+ const warning = useCallback(
55
+ (description: string, options?: Partial<SignalOptions>): string =>
56
+ Signal.warning(description, options),
57
+ []
58
+ );
59
+
60
+ const info = useCallback(
61
+ (description: string, options?: Partial<SignalOptions>): string =>
62
+ Signal.info(description, options),
63
+ []
64
+ );
65
+
66
+ /** Show an infinite loading toast. Returns id — call dismiss(id) when done. */
67
+ const loading = useCallback(
68
+ (description: string, options?: Partial<SignalOptions>): string =>
69
+ Signal.loading(description, options),
70
+ []
71
+ );
72
+
73
+ /**
74
+ * Attach a Promise to a toast lifecycle.
75
+ * Automatically transitions loading → success or error when the promise settles.
76
+ */
77
+ const promise = useCallback(
78
+ <T = unknown>(
79
+ p: Promise<T>,
80
+ messages: SignalPromiseMessages<T>,
81
+ options?: Partial<
82
+ Omit<SignalOptions, 'type' | 'autoHide' | 'swipeToDismiss'>
83
+ >
84
+ ): Promise<T> => Signal.promise(p, messages, options),
85
+ []
86
+ );
87
+
88
+ const hide = useCallback((id: string): void => Signal.hide(id), []);
89
+
90
+ /** Alias for hide() */
91
+ const dismiss = useCallback((id: string): void => Signal.dismiss(id), []);
92
+
93
+ const update = useCallback(
94
+ (
95
+ id: string,
96
+ options: Partial<Omit<SignalOptions, 'id' | 'createdAt'>>
97
+ ): void => Signal.update(id, options),
98
+ []
99
+ );
100
+
101
+ const clear = useCallback((): void => Signal.clear(), []);
102
+
103
+ return {
104
+ show,
105
+ success,
106
+ error,
107
+ warning,
108
+ info,
109
+ loading,
110
+ promise,
111
+ hide,
112
+ dismiss,
113
+ update,
114
+ clear,
115
+ };
116
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,3 @@
1
+ export { SignalProvider } from './provider/signal-provider';
2
+ export { useSignal } from './hooks/useSignal';
3
+ export { Signal } from './core/store';
@@ -0,0 +1,89 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { Signal } from '../core/store';
5
+ import { SignalItem } from '../components/signal-item';
6
+ import { MAX_VISIBLE, DEFAULT_DURATION } from '../constants';
7
+ import type { SignalOptions, SignalProviderProps, SignalTheme } from '../types';
8
+
9
+ interface Props extends SignalProviderProps {
10
+ theme?: Partial<SignalTheme>;
11
+ }
12
+
13
+ export const SignalProvider = ({
14
+ children,
15
+ maxVisible = MAX_VISIBLE,
16
+ defaultDuration = DEFAULT_DURATION,
17
+ theme,
18
+ }: Props) => {
19
+ const insets = useSafeAreaInsets();
20
+ const [signals, setSignals] = useState<SignalOptions[]>([]);
21
+
22
+ useEffect(() => {
23
+ Signal.configure({ maxVisible, defaultDuration });
24
+ }, [maxVisible, defaultDuration]);
25
+
26
+ useEffect(() => {
27
+ Signal.setListener(setSignals);
28
+ return () => Signal.removeListener();
29
+ }, []);
30
+
31
+ const handleHide = useCallback((id: string) => {
32
+ Signal.hide(id);
33
+ }, []);
34
+
35
+ const topSignals = signals.filter((s) => (s.position ?? 'top') === 'top');
36
+ const bottomSignals = signals.filter((s) => s.position === 'bottom');
37
+
38
+ return (
39
+ <>
40
+ {children}
41
+
42
+ {/* Top stack */}
43
+ <View
44
+ pointerEvents="box-none"
45
+ style={[styles.wrapper, { top: insets.top + 8 }]}
46
+ >
47
+ {topSignals.map((signal, idx) => (
48
+ <SignalItem
49
+ key={signal.id}
50
+ signal={signal}
51
+ index={idx}
52
+ maxVisible={maxVisible}
53
+ theme={theme}
54
+ onHide={() => handleHide(signal.id!)}
55
+ />
56
+ ))}
57
+ </View>
58
+
59
+ {/* Bottom stack */}
60
+ <View
61
+ pointerEvents="box-none"
62
+ style={[styles.wrapper, { bottom: insets.bottom + 8 }]}
63
+ >
64
+ {bottomSignals.map((signal, idx) => (
65
+ <SignalItem
66
+ key={signal.id}
67
+ signal={signal}
68
+ index={idx}
69
+ maxVisible={maxVisible}
70
+ theme={theme}
71
+ onHide={() => handleHide(signal.id!)}
72
+ />
73
+ ))}
74
+ </View>
75
+ </>
76
+ );
77
+ };
78
+
79
+ const styles = StyleSheet.create({
80
+ wrapper: {
81
+ position: 'absolute',
82
+ left: 0,
83
+ right: 0,
84
+ // height: 0 so wrapper doesn't block touches outside the toasts
85
+ height: 0,
86
+ zIndex: 9999,
87
+ elevation: 9999, // Android needs elevation, not just zIndex
88
+ },
89
+ });
package/src/types.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type SignalType =
4
+ | 'success'
5
+ | 'error'
6
+ | 'warning'
7
+ | 'info'
8
+ | 'loading'
9
+ | 'custom';
10
+
11
+ export type SignalPosition = 'top' | 'bottom';
12
+
13
+ export interface SignalAction {
14
+ /** Button label */
15
+ label: string;
16
+ /** Called when tapped. Receives a dismiss callback so you control whether to close. */
17
+ onPress: (dismiss: () => void) => void;
18
+ }
19
+
20
+ export interface SignalOptions {
21
+ /** Unique ID — auto-generated if omitted */
22
+ id?: string;
23
+ /** Optional bold headline above the description */
24
+ title?: string;
25
+ /** Main body text (required) */
26
+ description: string;
27
+ /** Duration in ms before auto-dismiss. Default: 3000. Loading defaults to 0 (infinite). */
28
+ duration?: number;
29
+ type?: SignalType;
30
+ /** Default: 'top' */
31
+ position?: SignalPosition;
32
+ /** Set automatically */
33
+ createdAt?: number;
34
+ /** Allow swipe-to-dismiss. Default: true */
35
+ swipeToDismiss?: boolean;
36
+ /** Auto-dismiss after duration. Default: true. Loading defaults to false. */
37
+ autoHide?: boolean;
38
+ /** Called once entry animation finishes */
39
+ onShow?: () => void;
40
+ /** Called after toast fully exits */
41
+ onHide?: () => void;
42
+ /** Called when the toast body is tapped. Receives a dismiss callback. */
43
+ onPress?: (dismiss: () => void) => void;
44
+ /**
45
+ * Optional CTA button rendered below the description.
46
+ * @example action={{ label: 'Retry', onPress: (dismiss) => { retry(); dismiss(); } }}
47
+ */
48
+ action?: SignalAction;
49
+ /**
50
+ * Replace the default type icon with any ReactNode.
51
+ * @example icon={<MyIcon size={16} />}
52
+ */
53
+ icon?: ReactNode;
54
+ }
55
+
56
+ export interface SignalItemProps {
57
+ signal: SignalOptions;
58
+ onHide: () => void;
59
+ index: number;
60
+ maxVisible: number;
61
+ }
62
+
63
+ export interface SignalProviderProps {
64
+ children: React.ReactNode;
65
+ /** Maximum toasts visible at once. Default: 3 */
66
+ maxVisible?: number;
67
+ /** Default auto-dismiss duration in ms. Default: 3000 */
68
+ defaultDuration?: number;
69
+ }
70
+
71
+ export type SignalListener = (signals: SignalOptions[]) => void;
72
+
73
+ export interface SignalTypeTheme {
74
+ background: string;
75
+ border: string;
76
+ titleColor: string;
77
+ descriptionColor: string;
78
+ iconColor: string;
79
+ }
80
+
81
+ /** Full theme map — all six types */
82
+ export type SignalTheme = Record<SignalType, SignalTypeTheme>;
83
+
84
+ export interface SignalPromiseMessages<T = unknown> {
85
+ loading: string;
86
+ success: string | ((data: T) => string);
87
+ error: string | ((err: unknown) => string);
88
+ }