react-native-toast-message-library 0.0.0-stage → 1.0.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.
@@ -0,0 +1,399 @@
1
+ import { DEFAULT_CONFIG } from '../utils/constants';
2
+ import generateToastId from '../utils/generateToastId';
3
+ import normalizeToastOptions from '../utils/normalizeToastOptions';
4
+
5
+ /**
6
+ * ToastManager is a framework-agnostic store. It owns:
7
+ * - the list of toasts currently rendered ("entries", visible or exiting),
8
+ * - the FIFO queue of toasts waiting for a free slot,
9
+ * - the auto-dismiss timers (including pause / resume).
10
+ *
11
+ * React components only render snapshots and report animation completion via
12
+ * `remove(id)`. Keeping all timing logic here makes it deterministic and testable.
13
+ *
14
+ * Slot rules: a toast occupies one of the `maxVisible` slots from the moment it
15
+ * is activated until its exit animation has finished (`remove`). A queued toast
16
+ * is promoted as soon as a slot frees up.
17
+ */
18
+
19
+ // Extra time (ms) after which an exiting toast is force-removed if the view
20
+ // never reported that its exit animation finished (e.g. the view was unmounted).
21
+ const EXIT_SAFETY_MARGIN = 400;
22
+
23
+ function reportError(error) {
24
+ // Errors thrown by user callbacks must not corrupt the manager state.
25
+ console.error('[react-native-toast-message-library] Error in toast callback:', error);
26
+ }
27
+
28
+ function safeCall(fn, ...args) {
29
+ if (typeof fn !== 'function') {
30
+ return;
31
+ }
32
+ try {
33
+ fn(...args);
34
+ } catch (error) {
35
+ reportError(error);
36
+ }
37
+ }
38
+
39
+ export default class ToastManager {
40
+ constructor(config = {}) {
41
+ this._config = { ...DEFAULT_CONFIG, ...config };
42
+ this._entries = [];
43
+ this._queue = [];
44
+ this._autoTimers = new Map();
45
+ this._exitTimers = new Map();
46
+ this._listeners = new Set();
47
+ this._destroyed = false;
48
+ this._snapshot = Object.freeze({ toasts: [], queued: 0 });
49
+
50
+ this.subscribe = this.subscribe.bind(this);
51
+ this.getSnapshot = this.getSnapshot.bind(this);
52
+ }
53
+
54
+ // ---------------------------------------------------------------- store API
55
+
56
+ subscribe(listener) {
57
+ this._listeners.add(listener);
58
+ return () => {
59
+ this._listeners.delete(listener);
60
+ };
61
+ }
62
+
63
+ getSnapshot() {
64
+ return this._snapshot;
65
+ }
66
+
67
+ _emit() {
68
+ this._snapshot = Object.freeze({
69
+ toasts: this._entries.slice(),
70
+ queued: this._queue.length,
71
+ });
72
+ Array.from(this._listeners).forEach(listener => listener());
73
+ }
74
+
75
+ // ------------------------------------------------------------ configuration
76
+
77
+ configure(config = {}) {
78
+ const previousMax = this._config.maxVisible;
79
+ this._config = { ...this._config, ...config };
80
+ if (this._config.maxVisible > previousMax && this._promote()) {
81
+ this._emit();
82
+ }
83
+ }
84
+
85
+ getConfig() {
86
+ return this._config;
87
+ }
88
+
89
+ // -------------------------------------------------------------- public API
90
+
91
+ /**
92
+ * Shows (or queues) a toast.
93
+ * @returns {string|null} the toast id, or null when the manager was destroyed
94
+ * @throws {TypeError} when the options are invalid
95
+ */
96
+ show(input) {
97
+ const options = normalizeToastOptions(input, this._config);
98
+
99
+ if (this._destroyed) {
100
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
101
+ console.warn(
102
+ '[react-native-toast-message-library] A toast was requested after its ToastProvider unmounted. It was ignored.',
103
+ );
104
+ }
105
+ return null;
106
+ }
107
+
108
+ const id = this._resolveId(options.id);
109
+ options.id = id;
110
+
111
+ // Showing an id that already exists updates that toast in place.
112
+ const entryIndex = this._entries.findIndex(entry => entry.id === id);
113
+ if (entryIndex !== -1) {
114
+ this._clearExitTimer(id);
115
+ this._clearAutoTimer(id);
116
+ this._entries[entryIndex] = this._createEntry(id, options);
117
+ this._startAutoTimer(this._entries[entryIndex]);
118
+ this._emit();
119
+ return id;
120
+ }
121
+
122
+ const queueIndex = this._queue.findIndex(item => item.id === id);
123
+ if (queueIndex !== -1) {
124
+ this._queue[queueIndex] = { id, options };
125
+ return id;
126
+ }
127
+
128
+ if (this._entries.length < this._config.maxVisible) {
129
+ this._activate({ id, options });
130
+ this._emit();
131
+ } else {
132
+ this._queue.push({ id, options });
133
+ this._emit();
134
+ }
135
+ return id;
136
+ }
137
+
138
+ /**
139
+ * Dismisses a toast by id. Works for visible and queued toasts.
140
+ * @returns {boolean} true if a toast was dismissed by this call
141
+ */
142
+ hide(id, reason = 'manual') {
143
+ if (id == null) {
144
+ return false;
145
+ }
146
+ const key = String(id);
147
+
148
+ const queueIndex = this._queue.findIndex(item => item.id === key);
149
+ if (queueIndex !== -1) {
150
+ const [removed] = this._queue.splice(queueIndex, 1);
151
+ this._emit();
152
+ safeCall(removed.options.onDismiss, reason, key);
153
+ return true;
154
+ }
155
+
156
+ const entry = this._entries.find(item => item.id === key);
157
+ if (!entry || entry.status !== 'visible') {
158
+ return false;
159
+ }
160
+ this._beginExit(entry, reason);
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * Dismisses every visible and queued toast.
166
+ * @returns {number} how many toasts were dismissed
167
+ */
168
+ hideAll() {
169
+ const queued = this._queue;
170
+ this._queue = [];
171
+ const visible = this._entries.filter(entry => entry.status === 'visible');
172
+
173
+ visible.forEach(entry => this._beginExit(entry, 'hideAll', false));
174
+ this._emit();
175
+
176
+ queued.forEach(item =>
177
+ safeCall(item.options.onDismiss, 'hideAll', item.id),
178
+ );
179
+ visible.forEach(entry =>
180
+ safeCall(entry.options.onDismiss, 'hideAll', entry.id),
181
+ );
182
+ return queued.length + visible.length;
183
+ }
184
+
185
+ /** true if the toast `id` (or any toast, when omitted) is currently shown. */
186
+ isVisible(id) {
187
+ if (id == null) {
188
+ return this._entries.some(entry => entry.status === 'visible');
189
+ }
190
+ const key = String(id);
191
+ return this._entries.some(
192
+ entry => entry.id === key && entry.status === 'visible',
193
+ );
194
+ }
195
+
196
+ /** Whether `id` is waiting in the queue. */
197
+ isQueued(id) {
198
+ const key = String(id);
199
+ return this._queue.some(item => item.id === key);
200
+ }
201
+
202
+ getQueueLength() {
203
+ return this._queue.length;
204
+ }
205
+
206
+ // ------------------------------------------------- view <-> manager contract
207
+
208
+ /** Pauses the auto-dismiss countdown (user is interacting with the toast). */
209
+ pause(id) {
210
+ const key = String(id);
211
+ const index = this._entries.findIndex(entry => entry.id === key);
212
+ const entry = this._entries[index];
213
+ const timer = this._autoTimers.get(key);
214
+ if (!entry || entry.status !== 'visible' || entry.paused || !timer) {
215
+ return;
216
+ }
217
+ clearTimeout(timer.handle);
218
+ timer.handle = null;
219
+ timer.remaining = Math.max(
220
+ 0,
221
+ timer.remaining - (Date.now() - timer.startedAt),
222
+ );
223
+ this._entries[index] = {
224
+ ...entry,
225
+ paused: true,
226
+ remaining: timer.remaining,
227
+ };
228
+ this._emit();
229
+ }
230
+
231
+ /** Resumes the countdown with the time that was left when it was paused. */
232
+ resume(id) {
233
+ const key = String(id);
234
+ const index = this._entries.findIndex(entry => entry.id === key);
235
+ const entry = this._entries[index];
236
+ const timer = this._autoTimers.get(key);
237
+ if (!entry || entry.status !== 'visible' || !entry.paused || !timer) {
238
+ return;
239
+ }
240
+ this._entries[index] = { ...entry, paused: false };
241
+ this._armTimer(key, timer);
242
+ this._emit();
243
+ }
244
+
245
+ /**
246
+ * Called by the view once the exit animation has finished. Frees the slot
247
+ * and promotes the next queued toast. Safe to call more than once.
248
+ */
249
+ remove(id) {
250
+ const key = String(id);
251
+ const index = this._entries.findIndex(entry => entry.id === key);
252
+ if (index === -1 || this._entries[index].status !== 'exiting') {
253
+ return;
254
+ }
255
+ this._clearExitTimer(key);
256
+ this._clearAutoTimer(key);
257
+ this._entries.splice(index, 1);
258
+ this._promote();
259
+ this._emit();
260
+ }
261
+
262
+ /** Re-enables a destroyed manager (React StrictMode re-runs effects). */
263
+ revive() {
264
+ this._destroyed = false;
265
+ }
266
+
267
+ /** Clears every timer and listener. Called when the provider unmounts. */
268
+ destroy() {
269
+ this._autoTimers.forEach(timer => clearTimeout(timer.handle));
270
+ this._exitTimers.forEach(handle => clearTimeout(handle));
271
+ this._autoTimers.clear();
272
+ this._exitTimers.clear();
273
+ this._entries = [];
274
+ this._queue = [];
275
+ this._listeners.clear();
276
+ this._destroyed = true;
277
+ this._snapshot = Object.freeze({ toasts: [], queued: 0 });
278
+ }
279
+
280
+ // ------------------------------------------------------------------ internals
281
+
282
+ _resolveId(requested) {
283
+ if (requested != null) {
284
+ return String(requested);
285
+ }
286
+ let id = generateToastId();
287
+ while (this._hasId(id)) {
288
+ id = generateToastId();
289
+ }
290
+ return id;
291
+ }
292
+
293
+ _hasId(id) {
294
+ return (
295
+ this._entries.some(entry => entry.id === id) ||
296
+ this._queue.some(item => item.id === id)
297
+ );
298
+ }
299
+
300
+ _createEntry(id, options) {
301
+ return {
302
+ id,
303
+ options,
304
+ status: 'visible',
305
+ paused: false,
306
+ remaining: options.duration,
307
+ };
308
+ }
309
+
310
+ _activate(item) {
311
+ const entry = this._createEntry(item.id, item.options);
312
+ this._entries.push(entry);
313
+ this._startAutoTimer(entry);
314
+ // onShow runs after the state is consistent.
315
+ safeCall(item.options.onShow, item.id);
316
+ }
317
+
318
+ _promote() {
319
+ let promoted = false;
320
+ while (
321
+ this._queue.length > 0 &&
322
+ this._entries.length < this._config.maxVisible
323
+ ) {
324
+ this._activate(this._queue.shift());
325
+ promoted = true;
326
+ }
327
+ return promoted;
328
+ }
329
+
330
+ _startAutoTimer(entry) {
331
+ if (!(entry.options.duration > 0)) {
332
+ return;
333
+ }
334
+ const timer = {
335
+ handle: null,
336
+ remaining: entry.options.duration,
337
+ startedAt: 0,
338
+ };
339
+ this._autoTimers.set(entry.id, timer);
340
+ this._armTimer(entry.id, timer);
341
+ }
342
+
343
+ _armTimer(id, timer) {
344
+ timer.startedAt = Date.now();
345
+ timer.handle = setTimeout(() => {
346
+ // Ignore timers that were replaced or cleared in the meantime.
347
+ if (this._autoTimers.get(id) !== timer) {
348
+ return;
349
+ }
350
+ this._autoTimers.delete(id);
351
+ this.hide(id, 'timeout');
352
+ }, timer.remaining);
353
+ }
354
+
355
+ _clearAutoTimer(id) {
356
+ const timer = this._autoTimers.get(id);
357
+ if (timer) {
358
+ clearTimeout(timer.handle);
359
+ this._autoTimers.delete(id);
360
+ }
361
+ }
362
+
363
+ _clearExitTimer(id) {
364
+ const handle = this._exitTimers.get(id);
365
+ if (handle != null) {
366
+ clearTimeout(handle);
367
+ this._exitTimers.delete(id);
368
+ }
369
+ }
370
+
371
+ _beginExit(entry, reason, notify = true) {
372
+ this._clearAutoTimer(entry.id);
373
+ const index = this._entries.indexOf(entry);
374
+ if (index === -1) {
375
+ return;
376
+ }
377
+ this._entries[index] = {
378
+ ...entry,
379
+ status: 'exiting',
380
+ paused: false,
381
+ reason,
382
+ };
383
+
384
+ const wait = (entry.options.animationDuration || 0) + EXIT_SAFETY_MARGIN;
385
+ this._clearExitTimer(entry.id);
386
+ this._exitTimers.set(
387
+ entry.id,
388
+ setTimeout(() => {
389
+ this._exitTimers.delete(entry.id);
390
+ this.remove(entry.id);
391
+ }, wait),
392
+ );
393
+
394
+ if (notify) {
395
+ this._emit();
396
+ safeCall(entry.options.onDismiss, reason, entry.id);
397
+ }
398
+ }
399
+ }
@@ -0,0 +1,33 @@
1
+ function isObject(value) {
2
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+
5
+ /**
6
+ * Builds the object returned by useToast() from a ToastManager. The returned
7
+ * object is stable for the lifetime of the manager, so consumers do not
8
+ * re-render when toasts appear or disappear.
9
+ */
10
+ export default function createToastApi(manager) {
11
+ const typed = type => (message, options) => {
12
+ let input;
13
+ if (typeof message === 'string' || typeof message === 'number') {
14
+ input = { ...options, message: String(message) };
15
+ } else if (isObject(message)) {
16
+ input = { ...message, ...options };
17
+ } else {
18
+ input = { ...options };
19
+ }
20
+ return manager.show({ ...input, type });
21
+ };
22
+
23
+ return {
24
+ show: input => manager.show(input),
25
+ success: typed('success'),
26
+ error: typed('error'),
27
+ warning: typed('warning'),
28
+ info: typed('info'),
29
+ hide: id => manager.hide(id),
30
+ hideAll: () => manager.hideAll(),
31
+ isVisible: id => manager.isVisible(id),
32
+ };
33
+ }
@@ -0,0 +1,64 @@
1
+ const dark = {
2
+ name: 'dark',
3
+ colors: {
4
+ success: {
5
+ background: '#0B2E1F',
6
+ text: '#D1FADF',
7
+ border: '#05603A',
8
+ icon: '#32D583',
9
+ iconText: '#052E1B',
10
+ },
11
+ error: {
12
+ background: '#3A1210',
13
+ text: '#FEE4E2',
14
+ border: '#912018',
15
+ icon: '#F97066',
16
+ iconText: '#3A0C09',
17
+ },
18
+ warning: {
19
+ background: '#3B2008',
20
+ text: '#FEF0C7',
21
+ border: '#93370D',
22
+ icon: '#FDB022',
23
+ iconText: '#3B1E04',
24
+ },
25
+ info: {
26
+ background: '#0B2447',
27
+ text: '#D1E9FF',
28
+ border: '#175CD3',
29
+ icon: '#53B1FD',
30
+ iconText: '#06224A',
31
+ },
32
+ custom: {
33
+ background: '#1D2939',
34
+ text: '#F2F4F7',
35
+ border: '#344054',
36
+ icon: '#D0D5DD',
37
+ iconText: '#1D2939',
38
+ },
39
+ },
40
+ actionColor: undefined,
41
+ closeColor: undefined,
42
+ progressTrack: 'rgba(255,255,255,0.14)',
43
+ borderRadius: 14,
44
+ borderWidth: 1,
45
+ padding: 14,
46
+ spacing: 10,
47
+ iconSize: 24,
48
+ typography: {
49
+ titleSize: 15,
50
+ titleWeight: '700',
51
+ messageSize: 14,
52
+ messageWeight: '400',
53
+ fontFamily: undefined,
54
+ },
55
+ shadow: {
56
+ color: '#000000',
57
+ opacity: 0.4,
58
+ radius: 12,
59
+ offsetY: 4,
60
+ elevation: 8,
61
+ },
62
+ };
63
+
64
+ export default dark;
@@ -0,0 +1,73 @@
1
+ import light from './light';
2
+ import dark from './dark';
3
+
4
+ const builtIn = { light, dark };
5
+
6
+ function isPlainObject(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+
10
+ /** Recursively merges `override` into `base` without mutating either. */
11
+ export function deepMerge(base, override) {
12
+ if (!isPlainObject(override)) {
13
+ return base;
14
+ }
15
+ const result = { ...base };
16
+ Object.keys(override).forEach(key => {
17
+ const value = override[key];
18
+ if (value === undefined) {
19
+ return;
20
+ }
21
+ result[key] =
22
+ isPlainObject(value) && isPlainObject(base[key])
23
+ ? deepMerge(base[key], value)
24
+ : value;
25
+ });
26
+ return result;
27
+ }
28
+
29
+ /**
30
+ * Builds a theme from a built-in base and partial overrides.
31
+ * createTheme('dark', { borderRadius: 4 })
32
+ */
33
+ export function createTheme(base = 'light', overrides = {}) {
34
+ const baseTheme = builtIn[base];
35
+ if (!baseTheme) {
36
+ throw new TypeError(
37
+ `[react-native-toast-message-library] Unknown base theme "${String(
38
+ base,
39
+ )}". Expected "light" or "dark".`,
40
+ );
41
+ }
42
+ return deepMerge(baseTheme, overrides);
43
+ }
44
+
45
+ /**
46
+ * Resolves the provider `theme` prop to a complete theme object.
47
+ * - 'light' | 'dark' -> built-in theme
48
+ * - 'system' -> built-in theme matching `colorScheme`
49
+ * - object -> merged over the light theme, or over the theme named
50
+ * by its optional `extends` key.
51
+ */
52
+ export function resolveTheme(theme = 'light', colorScheme = 'light') {
53
+ if (theme === 'system') {
54
+ return colorScheme === 'dark' ? dark : light;
55
+ }
56
+ if (typeof theme === 'string') {
57
+ if (!builtIn[theme]) {
58
+ throw new TypeError(
59
+ `[react-native-toast-message-library] Unknown theme "${theme}". Use "light", "dark", "system" or a theme object.`,
60
+ );
61
+ }
62
+ return builtIn[theme];
63
+ }
64
+ if (isPlainObject(theme)) {
65
+ const { extends: baseName, ...overrides } = theme;
66
+ return createTheme(baseName || 'light', overrides);
67
+ }
68
+ throw new TypeError(
69
+ '[react-native-toast-message-library] "theme" must be "light", "dark", "system" or an object.',
70
+ );
71
+ }
72
+
73
+ export { light, dark };
@@ -0,0 +1,64 @@
1
+ const light = {
2
+ name: 'light',
3
+ colors: {
4
+ success: {
5
+ background: '#ECFDF3',
6
+ text: '#05603A',
7
+ border: '#ABEFC6',
8
+ icon: '#079455',
9
+ iconText: '#FFFFFF',
10
+ },
11
+ error: {
12
+ background: '#FEF3F2',
13
+ text: '#912018',
14
+ border: '#FECDCA',
15
+ icon: '#D92D20',
16
+ iconText: '#FFFFFF',
17
+ },
18
+ warning: {
19
+ background: '#FFFAEB',
20
+ text: '#93370D',
21
+ border: '#FEDF89',
22
+ icon: '#B54708',
23
+ iconText: '#FFFFFF',
24
+ },
25
+ info: {
26
+ background: '#EFF8FF',
27
+ text: '#175CD3',
28
+ border: '#B2DDFF',
29
+ icon: '#1570EF',
30
+ iconText: '#FFFFFF',
31
+ },
32
+ custom: {
33
+ background: '#FFFFFF',
34
+ text: '#101828',
35
+ border: '#E4E7EC',
36
+ icon: '#475467',
37
+ iconText: '#FFFFFF',
38
+ },
39
+ },
40
+ actionColor: undefined,
41
+ closeColor: undefined,
42
+ progressTrack: 'rgba(0,0,0,0.08)',
43
+ borderRadius: 14,
44
+ borderWidth: 1,
45
+ padding: 14,
46
+ spacing: 10,
47
+ iconSize: 24,
48
+ typography: {
49
+ titleSize: 15,
50
+ titleWeight: '700',
51
+ messageSize: 14,
52
+ messageWeight: '400',
53
+ fontFamily: undefined,
54
+ },
55
+ shadow: {
56
+ color: '#101828',
57
+ opacity: 0.14,
58
+ radius: 12,
59
+ offsetY: 4,
60
+ elevation: 6,
61
+ },
62
+ };
63
+
64
+ export default light;
@@ -0,0 +1,40 @@
1
+ export const TOAST_TYPES = ['success', 'error', 'warning', 'info', 'custom'];
2
+ export const POSITIONS = [
3
+ 'top',
4
+ 'bottom',
5
+ 'center',
6
+ 'top-left',
7
+ 'top-right',
8
+ 'bottom-left',
9
+ 'bottom-right',
10
+ ];
11
+ export const ANIMATIONS = ['fade', 'slide', 'zoom', 'none'];
12
+ export const DISMISS_REASONS = [
13
+ 'timeout',
14
+ 'manual',
15
+ 'swipe',
16
+ 'close',
17
+ 'action',
18
+ 'press',
19
+ 'hideAll',
20
+ ];
21
+
22
+ export const TYPE_LABELS = {
23
+ success: 'Success',
24
+ error: 'Error',
25
+ warning: 'Warning',
26
+ info: 'Info',
27
+ custom: '',
28
+ };
29
+
30
+ export const DEFAULT_CONFIG = {
31
+ position: 'top',
32
+ duration: 3000,
33
+ animation: 'slide',
34
+ animationDuration: 250,
35
+ maxVisible: 3,
36
+ closeButton: false,
37
+ swipeToDismiss: true,
38
+ progress: false,
39
+ haptics: false,
40
+ };