@appius-fr/apx 2.5.0 → 2.5.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/dist/APX.dev.mjs +672 -21
- package/dist/APX.mjs +1 -1
- package/dist/APX.prod.mjs +1 -1
- package/dist/APX.standalone.js +651 -24
- package/dist/APX.standalone.js.map +1 -1
- package/modules/toast/toast.mjs +671 -20
- package/package.json +1 -1
package/modules/toast/toast.mjs
CHANGED
|
@@ -2,9 +2,25 @@
|
|
|
2
2
|
// ESM-first, no side effects on import. DOM only when used.
|
|
3
3
|
import './css/toast.css';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} PositionObject
|
|
7
|
+
* @property {'sticky'|'relative'|'anchored'} [type]
|
|
8
|
+
* @property {string} [x]
|
|
9
|
+
* @property {string} [y]
|
|
10
|
+
* @property {HTMLElement} [element]
|
|
11
|
+
* @property {'top'|'right'|'bottom'|'left'|'above'|'below'|'before'|'after'} [placement]
|
|
12
|
+
* @property {string} [gap]
|
|
13
|
+
* @property {boolean} [useNativeCSS]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {string|PositionObject} Position
|
|
18
|
+
*/
|
|
19
|
+
|
|
5
20
|
/**
|
|
6
21
|
* @typedef {Object} ToastConfig
|
|
7
|
-
* @property {
|
|
22
|
+
* @property {Position} [position]
|
|
23
|
+
* @property {'up'|'down'|'auto'} [flow] Flow direction for stacking toasts. 'auto' determines based on position. Default: 'auto'
|
|
8
24
|
* @property {number} [maxToasts]
|
|
9
25
|
* @property {number} [defaultDurationMs]
|
|
10
26
|
* @property {number} [zIndex]
|
|
@@ -13,6 +29,7 @@ import './css/toast.css';
|
|
|
13
29
|
* @property {boolean} [dedupe]
|
|
14
30
|
* @property {string} [containerClass]
|
|
15
31
|
* @property {number} [offset]
|
|
32
|
+
* @property {string} [id]
|
|
16
33
|
*/
|
|
17
34
|
|
|
18
35
|
/**
|
|
@@ -25,6 +42,8 @@ import './css/toast.css';
|
|
|
25
42
|
* @property {(ref: ToastRef, ev: MouseEvent) => void} [onClick]
|
|
26
43
|
* @property {(ref: ToastRef, reason: 'timeout'|'close'|'api'|'overflow') => void} [onClose]
|
|
27
44
|
* @property {string} [className]
|
|
45
|
+
* @property {Position} [position]
|
|
46
|
+
* @property {'up'|'down'|'auto'} [flow] Flow direction for stacking toasts. 'auto' determines based on position. Default: 'auto'
|
|
28
47
|
*/
|
|
29
48
|
|
|
30
49
|
/**
|
|
@@ -40,6 +59,42 @@ import './css/toast.css';
|
|
|
40
59
|
|
|
41
60
|
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
42
61
|
|
|
62
|
+
// Shared container cache: Map<position, HTMLElement>
|
|
63
|
+
// Containers are shared between managers with the same position
|
|
64
|
+
const _containerCache = new Map();
|
|
65
|
+
|
|
66
|
+
// Garbage collection: cleanup empty unmanaged containers after a delay
|
|
67
|
+
const GC_DELAY_MS = 20000; // 20 seconds
|
|
68
|
+
let _gcTimeoutId = null;
|
|
69
|
+
|
|
70
|
+
// Wrapper for all toast containers (keeps DOM clean)
|
|
71
|
+
let _toastWrapper = null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get or create the toast containers wrapper
|
|
75
|
+
* @returns {HTMLElement|null}
|
|
76
|
+
*/
|
|
77
|
+
function getToastWrapper() {
|
|
78
|
+
if (!isBrowser) return null;
|
|
79
|
+
|
|
80
|
+
if (!_toastWrapper) {
|
|
81
|
+
_toastWrapper = document.querySelector('.APX-toast-wrapper');
|
|
82
|
+
if (!_toastWrapper) {
|
|
83
|
+
_toastWrapper = createEl('div', 'APX-toast-wrapper');
|
|
84
|
+
_toastWrapper.style.position = 'fixed';
|
|
85
|
+
_toastWrapper.style.top = '0';
|
|
86
|
+
_toastWrapper.style.left = '0';
|
|
87
|
+
_toastWrapper.style.width = '0';
|
|
88
|
+
_toastWrapper.style.height = '0';
|
|
89
|
+
_toastWrapper.style.pointerEvents = 'none';
|
|
90
|
+
_toastWrapper.style.zIndex = '10000'; // Below containers but above most content
|
|
91
|
+
document.body.appendChild(_toastWrapper);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return _toastWrapper;
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
const DEFAULT_CONFIG = {
|
|
44
99
|
position: 'bottom-right',
|
|
45
100
|
maxToasts: 5,
|
|
@@ -63,6 +118,206 @@ function createEl(tag, classNames) {
|
|
|
63
118
|
return el;
|
|
64
119
|
}
|
|
65
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Normalize placement synonyms to CSS values
|
|
123
|
+
* @param {string} placement
|
|
124
|
+
* @returns {'top'|'right'|'bottom'|'left'}
|
|
125
|
+
*/
|
|
126
|
+
function normalizePlacement(placement) {
|
|
127
|
+
const synonyms = {
|
|
128
|
+
'above': 'top',
|
|
129
|
+
'below': 'bottom',
|
|
130
|
+
'before': 'left',
|
|
131
|
+
'after': 'right'
|
|
132
|
+
};
|
|
133
|
+
return synonyms[placement] || placement;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Determine flow direction based on position
|
|
138
|
+
* @param {Position} position
|
|
139
|
+
* @returns {'up'|'down'}
|
|
140
|
+
*/
|
|
141
|
+
function determineFlow(position) {
|
|
142
|
+
if (typeof position === 'string') {
|
|
143
|
+
// String positions: top = up, bottom = down
|
|
144
|
+
if (position.includes('top')) return 'up';
|
|
145
|
+
if (position.includes('bottom')) return 'down';
|
|
146
|
+
return 'down'; // default
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof position === 'object' && position !== null) {
|
|
150
|
+
const type = position.type || (position.x || position.y ? 'sticky' : null);
|
|
151
|
+
|
|
152
|
+
if (type === 'sticky' || (!type && (position.x || position.y))) {
|
|
153
|
+
// Sticky: determine based on y coordinate
|
|
154
|
+
if (position.y !== undefined) {
|
|
155
|
+
// If y starts with '-' or is a small value, likely top (up)
|
|
156
|
+
// If y is a large value or percentage, likely bottom (down)
|
|
157
|
+
if (position.y.startsWith('-')) {
|
|
158
|
+
// Negative = from bottom, so flow down
|
|
159
|
+
return 'down';
|
|
160
|
+
}
|
|
161
|
+
const num = parseFloat(position.y);
|
|
162
|
+
if (!isNaN(num)) {
|
|
163
|
+
// If y < 50% of viewport height, likely top (up)
|
|
164
|
+
// Otherwise likely bottom (down)
|
|
165
|
+
if (position.y.includes('%')) {
|
|
166
|
+
return num < 50 ? 'up' : 'down';
|
|
167
|
+
}
|
|
168
|
+
// For px values, assume < 400px is top (up)
|
|
169
|
+
return num < 400 ? 'up' : 'down';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return 'down'; // default
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (type === 'anchored' && position.placement) {
|
|
176
|
+
// Anchored: placement determines flow
|
|
177
|
+
const placement = normalizePlacement(position.placement);
|
|
178
|
+
if (placement === 'top' || placement === 'above') return 'up';
|
|
179
|
+
if (placement === 'bottom' || placement === 'below') return 'down';
|
|
180
|
+
// For left/right, default to down
|
|
181
|
+
return 'down';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (type === 'relative') {
|
|
185
|
+
// Relative: determine based on y offset
|
|
186
|
+
if (position.y !== undefined) {
|
|
187
|
+
const num = parseFloat(position.y);
|
|
188
|
+
if (!isNaN(num)) {
|
|
189
|
+
// Negative y = above element = flow up
|
|
190
|
+
// Positive y = below element = flow down
|
|
191
|
+
return num < 0 ? 'up' : 'down';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return 'down'; // default
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return 'down'; // default
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Garbage collection: remove empty unmanaged containers
|
|
203
|
+
*/
|
|
204
|
+
function cleanupEmptyContainers() {
|
|
205
|
+
if (!isBrowser) return;
|
|
206
|
+
|
|
207
|
+
const containers = document.querySelectorAll('.APX-toast-container:not([data-apx-toast-managed="true"])');
|
|
208
|
+
containers.forEach(container => {
|
|
209
|
+
// Check if container is empty (no toasts)
|
|
210
|
+
if (container.children.length === 0) {
|
|
211
|
+
const positionKey = container.getAttribute('data-apx-toast-position');
|
|
212
|
+
if (positionKey) {
|
|
213
|
+
_containerCache.delete(positionKey);
|
|
214
|
+
}
|
|
215
|
+
container.remove();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Clean up wrapper if it's empty (all containers removed)
|
|
220
|
+
if (_toastWrapper && _toastWrapper.children.length === 0) {
|
|
221
|
+
_toastWrapper.remove();
|
|
222
|
+
_toastWrapper = null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_gcTimeoutId = null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Schedule garbage collection for empty unmanaged containers
|
|
230
|
+
*/
|
|
231
|
+
function scheduleGarbageCollection() {
|
|
232
|
+
if (_gcTimeoutId) {
|
|
233
|
+
clearTimeout(_gcTimeoutId);
|
|
234
|
+
}
|
|
235
|
+
_gcTimeoutId = setTimeout(cleanupEmptyContainers, GC_DELAY_MS);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Find all scrollable parent elements
|
|
240
|
+
* @param {HTMLElement} element
|
|
241
|
+
* @returns {HTMLElement[]}
|
|
242
|
+
*/
|
|
243
|
+
function findScrollableParents(element) {
|
|
244
|
+
const scrollables = [];
|
|
245
|
+
let current = element.parentElement;
|
|
246
|
+
|
|
247
|
+
while (current && current !== document.body && current !== document.documentElement) {
|
|
248
|
+
const style = window.getComputedStyle(current);
|
|
249
|
+
const overflow = style.overflow + style.overflowY + style.overflowX;
|
|
250
|
+
|
|
251
|
+
if (overflow.includes('scroll') || overflow.includes('auto')) {
|
|
252
|
+
scrollables.push(current);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
current = current.parentElement;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return scrollables;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Hash an element to create a unique identifier
|
|
263
|
+
* @param {HTMLElement} el
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
function hashElement(el) {
|
|
267
|
+
const rect = el.getBoundingClientRect();
|
|
268
|
+
const str = `${el.tagName}_${rect.left}_${rect.top}_${rect.width}_${rect.height}`;
|
|
269
|
+
let hash = 0;
|
|
270
|
+
for (let i = 0; i < str.length; i++) {
|
|
271
|
+
const char = str.charCodeAt(i);
|
|
272
|
+
hash = ((hash << 5) - hash) + char;
|
|
273
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
274
|
+
}
|
|
275
|
+
return Math.abs(hash).toString(36);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Serialize position options into a unique key
|
|
280
|
+
* @param {Position} position
|
|
281
|
+
* @returns {string}
|
|
282
|
+
*/
|
|
283
|
+
function serializePosition(position) {
|
|
284
|
+
if (typeof position === 'string') {
|
|
285
|
+
return `s:${position}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (typeof position === 'object' && position !== null) {
|
|
289
|
+
const parts = [];
|
|
290
|
+
|
|
291
|
+
// Type (default: sticky if x/y provided)
|
|
292
|
+
const type = position.type || (position.x || position.y ? 'sticky' : null);
|
|
293
|
+
if (type) parts.push(`t:${type}`);
|
|
294
|
+
|
|
295
|
+
// Coordinates
|
|
296
|
+
if (position.x !== undefined) parts.push(`x:${position.x}`);
|
|
297
|
+
if (position.y !== undefined) parts.push(`y:${position.y}`);
|
|
298
|
+
|
|
299
|
+
// For relative/anchored: use element ID or hash
|
|
300
|
+
if (position.element) {
|
|
301
|
+
const elementId = position.element.id ||
|
|
302
|
+
position.element.dataset?.apxToastAnchorId ||
|
|
303
|
+
`el_${hashElement(position.element)}`;
|
|
304
|
+
parts.push(`el:${elementId}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (position.placement) {
|
|
308
|
+
// Normalize placement for serialization (use CSS values)
|
|
309
|
+
const normalized = normalizePlacement(position.placement);
|
|
310
|
+
parts.push(`p:${normalized}`);
|
|
311
|
+
}
|
|
312
|
+
if (position.gap !== undefined) parts.push(`g:${position.gap}`);
|
|
313
|
+
if (position.useNativeCSS) parts.push(`native:true`);
|
|
314
|
+
|
|
315
|
+
return `o:${parts.join('|')}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return 's:bottom-right';
|
|
319
|
+
}
|
|
320
|
+
|
|
66
321
|
/**
|
|
67
322
|
* ToastManager class
|
|
68
323
|
*/
|
|
@@ -102,10 +357,17 @@ class ToastManager {
|
|
|
102
357
|
return ref;
|
|
103
358
|
}
|
|
104
359
|
|
|
360
|
+
// Determine position and flow for this toast (options take precedence over config)
|
|
361
|
+
const position = options.position || this.config.position || 'bottom-right';
|
|
362
|
+
const flow = options.flow !== undefined ? options.flow : (this.config.flow !== undefined ? this.config.flow : 'auto');
|
|
363
|
+
const finalFlow = flow === 'auto' ? determineFlow(position) : flow;
|
|
364
|
+
|
|
365
|
+
// Ensure default container is set (for backward compatibility)
|
|
105
366
|
this.ensureContainer();
|
|
106
367
|
|
|
107
368
|
const toastEl = createEl('div', `APX-toast APX-toast--${options.type}`);
|
|
108
369
|
toastEl.setAttribute('role', 'status');
|
|
370
|
+
// Priority: options.id > config.id > auto-generated
|
|
109
371
|
const toastId = options.id || `t_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
110
372
|
toastEl.dataset.toastId = toastId;
|
|
111
373
|
if (options.className) toastEl.className += ` ${options.className}`;
|
|
@@ -126,7 +388,171 @@ class ToastManager {
|
|
|
126
388
|
toastEl.appendChild(closeBtn);
|
|
127
389
|
}
|
|
128
390
|
|
|
129
|
-
this
|
|
391
|
+
// Get or create container for this specific position
|
|
392
|
+
let container = null;
|
|
393
|
+
let positionUpdateFn = null;
|
|
394
|
+
let positionCleanup = null;
|
|
395
|
+
let originalElementStyle = null;
|
|
396
|
+
|
|
397
|
+
if (position && typeof position === 'object' && position !== null) {
|
|
398
|
+
const type = position.type || (position.x || position.y ? 'sticky' : null);
|
|
399
|
+
|
|
400
|
+
if (position.useNativeCSS && (type === 'relative' || type === 'anchored') && position.element) {
|
|
401
|
+
// useNativeCSS: true - create container in target element using native CSS
|
|
402
|
+
const targetEl = position.element;
|
|
403
|
+
originalElementStyle = targetEl.style.position;
|
|
404
|
+
targetEl.style.position = 'relative';
|
|
405
|
+
|
|
406
|
+
// Create a container for stacking toasts (reuse if exists)
|
|
407
|
+
const positionKey = serializePosition(position);
|
|
408
|
+
let nativeContainer = targetEl.querySelector(`[data-apx-toast-position="${positionKey}"]`);
|
|
409
|
+
if (!nativeContainer) {
|
|
410
|
+
nativeContainer = createEl('div', 'APX-toast-container APX-toast-container-native');
|
|
411
|
+
nativeContainer.setAttribute('data-apx-toast-position', positionKey);
|
|
412
|
+
nativeContainer.style.position = 'absolute';
|
|
413
|
+
nativeContainer.style.zIndex = String(this.config.zIndex);
|
|
414
|
+
nativeContainer.style.gap = `${this.config.gap}px`;
|
|
415
|
+
nativeContainer.setAttribute('aria-live', String(this.config.ariaLive));
|
|
416
|
+
nativeContainer.style.flexDirection = finalFlow === 'up' ? 'column-reverse' : 'column';
|
|
417
|
+
|
|
418
|
+
// Apply positioning to container
|
|
419
|
+
if (type === 'relative') {
|
|
420
|
+
// Relative: use x/y directly
|
|
421
|
+
if (position.x !== undefined) {
|
|
422
|
+
if (position.x.startsWith('-')) {
|
|
423
|
+
nativeContainer.style.right = position.x.substring(1);
|
|
424
|
+
} else {
|
|
425
|
+
nativeContainer.style.left = position.x;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (position.y !== undefined) {
|
|
429
|
+
if (position.y.startsWith('-')) {
|
|
430
|
+
nativeContainer.style.bottom = position.y.substring(1);
|
|
431
|
+
} else {
|
|
432
|
+
nativeContainer.style.top = position.y;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} else if (type === 'anchored') {
|
|
436
|
+
// Anchored: position outside the element using 100% (element size) + gap
|
|
437
|
+
const placement = normalizePlacement(position.placement);
|
|
438
|
+
const gap = position.gap || '1em';
|
|
439
|
+
|
|
440
|
+
switch (placement) {
|
|
441
|
+
case 'top':
|
|
442
|
+
nativeContainer.style.bottom = `calc(100% + ${gap})`;
|
|
443
|
+
nativeContainer.style.left = '0';
|
|
444
|
+
break;
|
|
445
|
+
case 'bottom':
|
|
446
|
+
nativeContainer.style.top = `calc(100% + ${gap})`;
|
|
447
|
+
nativeContainer.style.left = '0';
|
|
448
|
+
break;
|
|
449
|
+
case 'left':
|
|
450
|
+
nativeContainer.style.right = `calc(100% + ${gap})`;
|
|
451
|
+
nativeContainer.style.top = '0';
|
|
452
|
+
break;
|
|
453
|
+
case 'right':
|
|
454
|
+
nativeContainer.style.left = `calc(100% + ${gap})`;
|
|
455
|
+
nativeContainer.style.top = '0';
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
targetEl.appendChild(nativeContainer);
|
|
461
|
+
} else {
|
|
462
|
+
// Update flow direction if container exists
|
|
463
|
+
nativeContainer.style.flexDirection = finalFlow === 'up' ? 'column-reverse' : 'column';
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
container = nativeContainer;
|
|
467
|
+
|
|
468
|
+
positionCleanup = () => {
|
|
469
|
+
if (targetEl && targetEl.parentElement) {
|
|
470
|
+
targetEl.style.position = originalElementStyle || '';
|
|
471
|
+
// Remove native container if empty
|
|
472
|
+
if (nativeContainer && nativeContainer.children.length === 0) {
|
|
473
|
+
const positionKey = nativeContainer.getAttribute('data-apx-toast-position');
|
|
474
|
+
if (positionKey) {
|
|
475
|
+
_containerCache.delete(positionKey);
|
|
476
|
+
}
|
|
477
|
+
nativeContainer.remove();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
} else {
|
|
482
|
+
// Default: get or create container for this position
|
|
483
|
+
container = this.getContainerForPosition(position, finalFlow);
|
|
484
|
+
|
|
485
|
+
const updateContainerPosition = () => {
|
|
486
|
+
const styles = this.calculatePosition(position, container);
|
|
487
|
+
Object.assign(container.style, styles);
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
updateContainerPosition();
|
|
491
|
+
|
|
492
|
+
// For relative/anchored, listen to scroll/resize
|
|
493
|
+
if ((type === 'relative' || type === 'anchored') && position.element) {
|
|
494
|
+
let rafId = null;
|
|
495
|
+
let lastUpdate = 0;
|
|
496
|
+
const throttleMs = 16; // ~60fps
|
|
497
|
+
|
|
498
|
+
const throttledUpdate = () => {
|
|
499
|
+
const now = Date.now();
|
|
500
|
+
if (now - lastUpdate < throttleMs) {
|
|
501
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
502
|
+
rafId = requestAnimationFrame(() => {
|
|
503
|
+
updateContainerPosition();
|
|
504
|
+
lastUpdate = Date.now();
|
|
505
|
+
});
|
|
506
|
+
} else {
|
|
507
|
+
updateContainerPosition();
|
|
508
|
+
lastUpdate = now;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// Find all scrollable parents
|
|
513
|
+
const scrollableParents = findScrollableParents(position.element);
|
|
514
|
+
|
|
515
|
+
// Listen to scroll on window and all scrollable parents
|
|
516
|
+
window.addEventListener('scroll', throttledUpdate, { passive: true });
|
|
517
|
+
window.addEventListener('resize', throttledUpdate, { passive: true });
|
|
518
|
+
|
|
519
|
+
// Listen to scroll on all scrollable parent elements
|
|
520
|
+
scrollableParents.forEach(parent => {
|
|
521
|
+
parent.addEventListener('scroll', throttledUpdate, { passive: true });
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Watch for element removal
|
|
525
|
+
const observer = new MutationObserver(() => {
|
|
526
|
+
if (!position.element.parentElement) {
|
|
527
|
+
ref.close('api');
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
531
|
+
|
|
532
|
+
positionUpdateFn = updateContainerPosition;
|
|
533
|
+
positionCleanup = () => {
|
|
534
|
+
window.removeEventListener('scroll', throttledUpdate);
|
|
535
|
+
window.removeEventListener('resize', throttledUpdate);
|
|
536
|
+
scrollableParents.forEach(parent => {
|
|
537
|
+
parent.removeEventListener('scroll', throttledUpdate);
|
|
538
|
+
});
|
|
539
|
+
observer.disconnect();
|
|
540
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
// String position - get or create container for this position
|
|
546
|
+
container = this.getContainerForPosition(position, finalFlow);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Append toast to the appropriate container
|
|
550
|
+
if (container) {
|
|
551
|
+
container.appendChild(toastEl);
|
|
552
|
+
} else {
|
|
553
|
+
// Fallback to default container
|
|
554
|
+
this.container.appendChild(toastEl);
|
|
555
|
+
}
|
|
130
556
|
|
|
131
557
|
// Enter animation
|
|
132
558
|
toastEl.classList.add('APX-toast--enter');
|
|
@@ -202,6 +628,13 @@ class ToastManager {
|
|
|
202
628
|
const cleanup = (reason) => {
|
|
203
629
|
if (!toastEl) return;
|
|
204
630
|
pauseTimer();
|
|
631
|
+
|
|
632
|
+
// Cleanup position listeners and restore styles
|
|
633
|
+
if (positionCleanup) {
|
|
634
|
+
positionCleanup();
|
|
635
|
+
positionCleanup = null;
|
|
636
|
+
}
|
|
637
|
+
|
|
205
638
|
// If overflow, remove immediately to enforce hard cap
|
|
206
639
|
if (reason === 'overflow') {
|
|
207
640
|
if (toastEl.parentElement) toastEl.parentElement.removeChild(toastEl);
|
|
@@ -222,6 +655,10 @@ class ToastManager {
|
|
|
222
655
|
const idx = this.open.indexOf(ref);
|
|
223
656
|
if (idx >= 0) this.open.splice(idx, 1);
|
|
224
657
|
this.idToRef.delete(toastId);
|
|
658
|
+
|
|
659
|
+
// Schedule garbage collection for unmanaged containers
|
|
660
|
+
scheduleGarbageCollection();
|
|
661
|
+
|
|
225
662
|
finish(reason);
|
|
226
663
|
};
|
|
227
664
|
toastEl.addEventListener('transitionend', removeEl);
|
|
@@ -280,37 +717,251 @@ class ToastManager {
|
|
|
280
717
|
if (!o.type) o.type = 'info';
|
|
281
718
|
if (typeof o.dismissible !== 'boolean') o.dismissible = true;
|
|
282
719
|
if (typeof o.durationMs !== 'number') o.durationMs = this.config.defaultDurationMs;
|
|
720
|
+
// Use id from options if provided, otherwise use id from config, otherwise undefined (will be auto-generated)
|
|
721
|
+
if (!o.id && this.config.id) o.id = this.config.id;
|
|
283
722
|
return o;
|
|
284
723
|
}
|
|
285
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Find or create a container for a specific position
|
|
727
|
+
* @param {Position} position
|
|
728
|
+
* @param {'up'|'down'} [flow] Flow direction (already determined, not 'auto')
|
|
729
|
+
* @param {boolean} [managed] Whether this container is managed by a manager (default: false)
|
|
730
|
+
* @returns {HTMLElement|null}
|
|
731
|
+
*/
|
|
732
|
+
getContainerForPosition(position, flow, managed = false) {
|
|
733
|
+
if (!isBrowser) return null;
|
|
734
|
+
|
|
735
|
+
const positionKey = serializePosition(position);
|
|
736
|
+
|
|
737
|
+
// Flow should already be determined ('up' or 'down'), but fallback to auto if needed
|
|
738
|
+
const finalFlow = flow && flow !== 'auto' ? flow : determineFlow(position);
|
|
739
|
+
|
|
740
|
+
// 1. Check memory cache
|
|
741
|
+
let c = _containerCache.get(positionKey);
|
|
742
|
+
|
|
743
|
+
// 2. If not in cache, search in DOM by data attribute
|
|
744
|
+
if (!c && isBrowser) {
|
|
745
|
+
c = document.querySelector(`[data-apx-toast-position="${positionKey}"]`);
|
|
746
|
+
if (c) {
|
|
747
|
+
_containerCache.set(positionKey, c);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// 3. If still not found, create new container
|
|
752
|
+
if (!c) {
|
|
753
|
+
c = createEl('div', 'APX-toast-container');
|
|
754
|
+
c.setAttribute('data-apx-toast-position', positionKey);
|
|
755
|
+
|
|
756
|
+
// Mark as managed if created by a manager
|
|
757
|
+
if (managed) {
|
|
758
|
+
c.setAttribute('data-apx-toast-managed', 'true');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Determine position class (for CSS)
|
|
762
|
+
const posClass = typeof position === 'string'
|
|
763
|
+
? position
|
|
764
|
+
: (position.type || 'bottom-right');
|
|
765
|
+
c.classList.add(`APX-toast-container--${posClass}`);
|
|
766
|
+
c.setAttribute('aria-live', String(this.config.ariaLive));
|
|
767
|
+
c.style.zIndex = String(this.config.zIndex);
|
|
768
|
+
c.style.gap = `${this.config.gap}px`;
|
|
769
|
+
|
|
770
|
+
// Apply flow direction
|
|
771
|
+
c.style.flexDirection = finalFlow === 'up' ? 'column-reverse' : 'column';
|
|
772
|
+
|
|
773
|
+
// Apply position styles if object position
|
|
774
|
+
if (typeof position === 'object' && position !== null) {
|
|
775
|
+
c.style.position = 'fixed';
|
|
776
|
+
const styles = this.calculatePosition(position, c);
|
|
777
|
+
Object.assign(c.style, styles);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Append to wrapper for clean DOM organization
|
|
781
|
+
const wrapper = getToastWrapper();
|
|
782
|
+
if (wrapper) {
|
|
783
|
+
wrapper.appendChild(c);
|
|
784
|
+
} else {
|
|
785
|
+
document.body.appendChild(c);
|
|
786
|
+
}
|
|
787
|
+
_containerCache.set(positionKey, c);
|
|
788
|
+
} else {
|
|
789
|
+
// Update flow direction if container exists (may be shared)
|
|
790
|
+
c.style.flexDirection = finalFlow === 'up' ? 'column-reverse' : 'column';
|
|
791
|
+
|
|
792
|
+
// If container exists and is now managed, mark it
|
|
793
|
+
if (managed && !c.hasAttribute('data-apx-toast-managed')) {
|
|
794
|
+
c.setAttribute('data-apx-toast-managed', 'true');
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return c;
|
|
799
|
+
}
|
|
800
|
+
|
|
286
801
|
ensureContainer() {
|
|
287
802
|
if (this.container || !isBrowser) return;
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
c.style.gap = `${this.config.gap}px`;
|
|
294
|
-
if (this.config.offset) {
|
|
295
|
-
const offset = `${this.config.offset}px`;
|
|
296
|
-
if (pos.includes('bottom')) c.style.bottom = offset; else c.style.top = offset;
|
|
297
|
-
if (pos.includes('right')) c.style.right = offset; else c.style.left = offset;
|
|
298
|
-
}
|
|
299
|
-
c.setAttribute('aria-live', String(this.config.ariaLive));
|
|
300
|
-
document.body.appendChild(c);
|
|
301
|
-
this.container = c;
|
|
803
|
+
|
|
804
|
+
const position = this.config.position || 'bottom-right';
|
|
805
|
+
const flow = this.config.flow !== undefined ? this.config.flow : 'auto';
|
|
806
|
+
// Containers created by ensureContainer are managed
|
|
807
|
+
this.container = this.getContainerForPosition(position, flow, true);
|
|
302
808
|
this.applyContainerConfig();
|
|
303
809
|
}
|
|
304
810
|
|
|
305
811
|
applyContainerConfig() {
|
|
306
812
|
if (!this.container) return;
|
|
813
|
+
const position = this.config.position || 'bottom-right';
|
|
814
|
+
const pos = typeof position === 'string' ? position : (position.type || 'bottom-right');
|
|
815
|
+
|
|
816
|
+
// Apply styles (these may be overridden by other managers sharing the container)
|
|
307
817
|
this.container.style.zIndex = String(this.config.zIndex);
|
|
308
818
|
this.container.style.gap = `${this.config.gap}px`;
|
|
309
819
|
this.container.setAttribute('aria-live', String(this.config.ariaLive));
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
820
|
+
|
|
821
|
+
// Update position class (only for string positions)
|
|
822
|
+
if (typeof position === 'string') {
|
|
823
|
+
const posClasses = ['bottom-right','bottom-left','top-right','top-left'].map(p => `APX-toast-container--${p}`);
|
|
824
|
+
this.container.classList.remove(...posClasses);
|
|
825
|
+
this.container.classList.add(`APX-toast-container--${pos}`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Apply container class if specified
|
|
829
|
+
if (this.config.containerClass) {
|
|
830
|
+
this.config.containerClass.split(' ').filter(Boolean).forEach(cls => {
|
|
831
|
+
this.container.classList.add(cls);
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Apply offset (only for string positions)
|
|
836
|
+
if (typeof position === 'string') {
|
|
837
|
+
if (this.config.offset) {
|
|
838
|
+
const offset = `${this.config.offset}px`;
|
|
839
|
+
if (pos.includes('bottom')) this.container.style.bottom = offset;
|
|
840
|
+
else this.container.style.top = offset;
|
|
841
|
+
if (pos.includes('right')) this.container.style.right = offset;
|
|
842
|
+
else this.container.style.left = offset;
|
|
843
|
+
} else {
|
|
844
|
+
// Reset offset if not specified
|
|
845
|
+
if (pos.includes('bottom')) this.container.style.bottom = '';
|
|
846
|
+
else this.container.style.top = '';
|
|
847
|
+
if (pos.includes('right')) this.container.style.right = '';
|
|
848
|
+
else this.container.style.left = '';
|
|
849
|
+
}
|
|
850
|
+
} else if (typeof position === 'object' && position !== null) {
|
|
851
|
+
// For object positions, ensure container has position: fixed
|
|
852
|
+
this.container.style.position = 'fixed';
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Calculate position for a container based on position config
|
|
858
|
+
* @param {Position} position
|
|
859
|
+
* @param {HTMLElement} containerEl
|
|
860
|
+
* @returns {{left?: string, top?: string, right?: string, bottom?: string}}
|
|
861
|
+
*/
|
|
862
|
+
calculatePosition(position, containerEl) {
|
|
863
|
+
if (typeof position === 'string') {
|
|
864
|
+
// String positions are handled by container CSS
|
|
865
|
+
return {};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (typeof position === 'object' && position !== null) {
|
|
869
|
+
const type = position.type || (position.x || position.y ? 'sticky' : null);
|
|
870
|
+
|
|
871
|
+
if (type === 'sticky' || (!type && (position.x || position.y))) {
|
|
872
|
+
// Sticky: fixed position relative to viewport
|
|
873
|
+
const styles = {};
|
|
874
|
+
if (position.x !== undefined) {
|
|
875
|
+
if (position.x.startsWith('-')) {
|
|
876
|
+
styles.right = position.x.substring(1);
|
|
877
|
+
} else {
|
|
878
|
+
styles.left = position.x;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (position.y !== undefined) {
|
|
882
|
+
if (position.y.startsWith('-')) {
|
|
883
|
+
styles.bottom = position.y.substring(1);
|
|
884
|
+
} else {
|
|
885
|
+
styles.top = position.y;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return styles;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (type === 'relative' && position.element) {
|
|
892
|
+
// Relative: position relative to element with x/y offset
|
|
893
|
+
// Use fixed positioning, so getBoundingClientRect() is relative to viewport
|
|
894
|
+
const rect = position.element.getBoundingClientRect();
|
|
895
|
+
|
|
896
|
+
// Parse x/y offsets (can be px, em, etc.)
|
|
897
|
+
const parseOffset = (val) => {
|
|
898
|
+
if (!val) return 0;
|
|
899
|
+
const num = parseFloat(val);
|
|
900
|
+
if (val.includes('em')) {
|
|
901
|
+
// Convert em to px (approximate: 1em = 16px)
|
|
902
|
+
return num * 16;
|
|
903
|
+
}
|
|
904
|
+
return num;
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
const offsetX = parseOffset(position.x || '0');
|
|
908
|
+
const offsetY = parseOffset(position.y || '0');
|
|
909
|
+
|
|
910
|
+
const styles = {
|
|
911
|
+
left: `${rect.left + offsetX}px`,
|
|
912
|
+
top: `${rect.top + offsetY}px`
|
|
913
|
+
};
|
|
914
|
+
return styles;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (type === 'anchored' && position.element) {
|
|
918
|
+
// Anchored: position relative to element with placement
|
|
919
|
+
const rect = position.element.getBoundingClientRect();
|
|
920
|
+
const gap = position.gap || '1em';
|
|
921
|
+
|
|
922
|
+
// Parse gap (can be px, em, etc.)
|
|
923
|
+
const parseGap = (val) => {
|
|
924
|
+
const num = parseFloat(val);
|
|
925
|
+
if (val.includes('em')) {
|
|
926
|
+
return num * 16; // Approximate: 1em = 16px
|
|
927
|
+
}
|
|
928
|
+
return num;
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
const gapPx = parseGap(gap);
|
|
932
|
+
const styles = {};
|
|
933
|
+
|
|
934
|
+
// Normalize placement synonyms (above/below/before/after) to CSS values
|
|
935
|
+
const placement = normalizePlacement(position.placement);
|
|
936
|
+
|
|
937
|
+
switch (placement) {
|
|
938
|
+
case 'top':
|
|
939
|
+
// Toast above element: bottom of toast = top of element - gap
|
|
940
|
+
// bottom = viewport height - (element top - gap) = viewport height - element top + gap
|
|
941
|
+
styles.bottom = `${window.innerHeight - rect.top + gapPx}px`;
|
|
942
|
+
styles.left = `${rect.left}px`;
|
|
943
|
+
break;
|
|
944
|
+
case 'bottom':
|
|
945
|
+
// Toast below element: top of toast = bottom of element + gap
|
|
946
|
+
styles.top = `${rect.bottom + gapPx}px`;
|
|
947
|
+
styles.left = `${rect.left}px`;
|
|
948
|
+
break;
|
|
949
|
+
case 'left':
|
|
950
|
+
// Toast to the left: right of toast = left of element - gap
|
|
951
|
+
styles.right = `${window.innerWidth - rect.left + gapPx}px`;
|
|
952
|
+
styles.top = `${rect.top}px`;
|
|
953
|
+
break;
|
|
954
|
+
case 'right':
|
|
955
|
+
// Toast to the right: left of toast = right of element + gap
|
|
956
|
+
styles.left = `${rect.right + gapPx}px`;
|
|
957
|
+
styles.top = `${rect.top}px`;
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
return styles;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return {};
|
|
314
965
|
}
|
|
315
966
|
}
|
|
316
967
|
|