@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.
@@ -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 {'bottom-right'|'bottom-left'|'top-right'|'top-left'} [position]
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.container.appendChild(toastEl);
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
- const c = createEl('div', 'APX-toast-container');
289
- const pos = this.config.position || 'bottom-right';
290
- c.classList.add(`APX-toast-container--${pos}`);
291
- if (this.config.containerClass) c.classList.add(this.config.containerClass);
292
- c.style.zIndex = String(this.config.zIndex);
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
- // Update position class
311
- const posClasses = ['bottom-right','bottom-left','top-right','top-left'].map(p => `APX-toast-container--${p}`);
312
- this.container.classList.remove(...posClasses);
313
- this.container.classList.add(`APX-toast-container--${this.config.position}`);
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