@appius-fr/apx 2.4.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/APX.mjs +2 -0
- package/README.md +35 -0
- package/dist/APX.dev.mjs +1189 -3
- package/dist/APX.mjs +1 -1
- package/dist/APX.prod.mjs +1 -1
- package/dist/APX.standalone.js +3170 -0
- package/dist/APX.standalone.js.map +1 -0
- package/modules/toast/README.md +127 -0
- package/modules/toast/css/toast.css +60 -0
- package/modules/toast/toast.mjs +1025 -0
- package/package.json +3 -2
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
// Minimal, framework-agnostic ToastManager for APX
|
|
2
|
+
// ESM-first, no side effects on import. DOM only when used.
|
|
3
|
+
import './css/toast.css';
|
|
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
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} ToastConfig
|
|
22
|
+
* @property {Position} [position]
|
|
23
|
+
* @property {'up'|'down'|'auto'} [flow] Flow direction for stacking toasts. 'auto' determines based on position. Default: 'auto'
|
|
24
|
+
* @property {number} [maxToasts]
|
|
25
|
+
* @property {number} [defaultDurationMs]
|
|
26
|
+
* @property {number} [zIndex]
|
|
27
|
+
* @property {'polite'|'assertive'|'off'} [ariaLive]
|
|
28
|
+
* @property {number} [gap]
|
|
29
|
+
* @property {boolean} [dedupe]
|
|
30
|
+
* @property {string} [containerClass]
|
|
31
|
+
* @property {number} [offset]
|
|
32
|
+
* @property {string} [id]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} ToastOptions
|
|
37
|
+
* @property {string|Node} message
|
|
38
|
+
* @property {'info'|'success'|'warning'|'danger'} [type]
|
|
39
|
+
* @property {number} [durationMs]
|
|
40
|
+
* @property {boolean} [dismissible]
|
|
41
|
+
* @property {string} [id]
|
|
42
|
+
* @property {(ref: ToastRef, ev: MouseEvent) => void} [onClick]
|
|
43
|
+
* @property {(ref: ToastRef, reason: 'timeout'|'close'|'api'|'overflow') => void} [onClose]
|
|
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'
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} ToastRef
|
|
51
|
+
* @property {string} id
|
|
52
|
+
* @property {HTMLElement} el
|
|
53
|
+
* @property {(reason?: 'api'|'close') => void} close
|
|
54
|
+
* @property {(partial: Partial<ToastOptions>) => void} update
|
|
55
|
+
* @property {() => Promise<void>} whenClosed
|
|
56
|
+
* @property {(event: 'close'|'click', handler: Function) => () => void} on
|
|
57
|
+
* @property {(event: 'close'|'click', handler: Function) => void} off
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
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
|
+
|
|
98
|
+
const DEFAULT_CONFIG = {
|
|
99
|
+
position: 'bottom-right',
|
|
100
|
+
maxToasts: 5,
|
|
101
|
+
defaultDurationMs: 5000,
|
|
102
|
+
zIndex: 11000,
|
|
103
|
+
ariaLive: 'polite',
|
|
104
|
+
gap: 8,
|
|
105
|
+
dedupe: false,
|
|
106
|
+
containerClass: '',
|
|
107
|
+
offset: 0
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create an element with classes
|
|
112
|
+
*/
|
|
113
|
+
function createEl(tag, classNames) {
|
|
114
|
+
const el = document.createElement(tag);
|
|
115
|
+
if (classNames) {
|
|
116
|
+
classNames.split(' ').filter(Boolean).forEach(c => el.classList.add(c));
|
|
117
|
+
}
|
|
118
|
+
return el;
|
|
119
|
+
}
|
|
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
|
+
|
|
321
|
+
/**
|
|
322
|
+
* ToastManager class
|
|
323
|
+
*/
|
|
324
|
+
class ToastManager {
|
|
325
|
+
/** @param {Partial<ToastConfig>=} config */
|
|
326
|
+
constructor(config) {
|
|
327
|
+
/** @type {ToastConfig} */
|
|
328
|
+
this.config = { ...DEFAULT_CONFIG, ...(config || {}) };
|
|
329
|
+
/** @type {HTMLElement|null} */
|
|
330
|
+
this.container = null;
|
|
331
|
+
/** @type {Map<string, ToastRef>} */
|
|
332
|
+
this.idToRef = new Map();
|
|
333
|
+
/** @type {ToastRef[]} */
|
|
334
|
+
this.open = [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** @param {Partial<ToastConfig>} config */
|
|
338
|
+
configure(config) {
|
|
339
|
+
this.config = { ...this.config, ...(config || {}) };
|
|
340
|
+
if (this.container) this.applyContainerConfig();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** @param {'polite'|'assertive'|'off'} mode */
|
|
344
|
+
setAriaLive(mode) { this.configure({ ariaLive: mode }); }
|
|
345
|
+
|
|
346
|
+
/** @returns {ToastRef[]} */
|
|
347
|
+
getOpenToasts() { return this.open.slice(); }
|
|
348
|
+
|
|
349
|
+
/** @param {ToastOptions} opts */
|
|
350
|
+
show(opts) {
|
|
351
|
+
if (!isBrowser) return /** @type {any} */(null);
|
|
352
|
+
const options = this.normalizeOptions(opts);
|
|
353
|
+
|
|
354
|
+
if (this.config.dedupe && options.id && this.idToRef.has(options.id)) {
|
|
355
|
+
const ref = this.idToRef.get(options.id);
|
|
356
|
+
ref.update(options);
|
|
357
|
+
return ref;
|
|
358
|
+
}
|
|
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)
|
|
366
|
+
this.ensureContainer();
|
|
367
|
+
|
|
368
|
+
const toastEl = createEl('div', `APX-toast APX-toast--${options.type}`);
|
|
369
|
+
toastEl.setAttribute('role', 'status');
|
|
370
|
+
// Priority: options.id > config.id > auto-generated
|
|
371
|
+
const toastId = options.id || `t_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
372
|
+
toastEl.dataset.toastId = toastId;
|
|
373
|
+
if (options.className) toastEl.className += ` ${options.className}`;
|
|
374
|
+
|
|
375
|
+
const contentEl = createEl('div', 'APX-toast__content');
|
|
376
|
+
if (typeof options.message === 'string') {
|
|
377
|
+
contentEl.textContent = options.message;
|
|
378
|
+
} else if (options.message) {
|
|
379
|
+
contentEl.appendChild(options.message);
|
|
380
|
+
}
|
|
381
|
+
toastEl.appendChild(contentEl);
|
|
382
|
+
|
|
383
|
+
let closeBtn = null;
|
|
384
|
+
if (options.dismissible !== false) {
|
|
385
|
+
closeBtn = createEl('button', 'APX-toast__close');
|
|
386
|
+
closeBtn.setAttribute('aria-label', 'Close');
|
|
387
|
+
closeBtn.type = 'button';
|
|
388
|
+
toastEl.appendChild(closeBtn);
|
|
389
|
+
}
|
|
390
|
+
|
|
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
|
+
}
|
|
556
|
+
|
|
557
|
+
// Enter animation
|
|
558
|
+
toastEl.classList.add('APX-toast--enter');
|
|
559
|
+
requestAnimationFrame(() => {
|
|
560
|
+
toastEl.classList.add('APX-toast--enter-active');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Event handling and timers
|
|
564
|
+
let remaining = options.durationMs;
|
|
565
|
+
let timerId = null;
|
|
566
|
+
let startTs = null;
|
|
567
|
+
const handlers = { click: new Set(), close: new Set() };
|
|
568
|
+
|
|
569
|
+
const startTimer = () => {
|
|
570
|
+
if (!remaining || remaining <= 0) return; // sticky
|
|
571
|
+
startTs = Date.now();
|
|
572
|
+
timerId = window.setTimeout(() => ref.close('timeout'), remaining);
|
|
573
|
+
};
|
|
574
|
+
const pauseTimer = () => {
|
|
575
|
+
if (timerId != null) {
|
|
576
|
+
window.clearTimeout(timerId);
|
|
577
|
+
timerId = null;
|
|
578
|
+
if (startTs != null) remaining -= (Date.now() - startTs);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/** @type {ToastRef} */
|
|
583
|
+
const ref = {
|
|
584
|
+
id: toastId,
|
|
585
|
+
el: toastEl,
|
|
586
|
+
close: (reason) => {
|
|
587
|
+
cleanup(reason || 'api');
|
|
588
|
+
},
|
|
589
|
+
update: (partial) => {
|
|
590
|
+
const merged = this.normalizeOptions({ ...options, ...partial });
|
|
591
|
+
// update content
|
|
592
|
+
if (typeof merged.message === 'string') {
|
|
593
|
+
contentEl.textContent = merged.message;
|
|
594
|
+
} else if (merged.message) {
|
|
595
|
+
contentEl.innerHTML = '';
|
|
596
|
+
contentEl.appendChild(merged.message);
|
|
597
|
+
}
|
|
598
|
+
// update type class
|
|
599
|
+
['info','success','warning','danger'].forEach(t => toastEl.classList.remove(`APX-toast--${t}`));
|
|
600
|
+
toastEl.classList.add(`APX-toast--${merged.type}`);
|
|
601
|
+
// update classes
|
|
602
|
+
if (options.className !== merged.className) {
|
|
603
|
+
if (options.className) toastEl.classList.remove(...options.className.split(' ').filter(Boolean));
|
|
604
|
+
if (merged.className) toastEl.classList.add(...merged.className.split(' ').filter(Boolean));
|
|
605
|
+
}
|
|
606
|
+
// update duration logic
|
|
607
|
+
options.durationMs = merged.durationMs;
|
|
608
|
+
remaining = merged.durationMs;
|
|
609
|
+
pauseTimer();
|
|
610
|
+
startTimer();
|
|
611
|
+
},
|
|
612
|
+
whenClosed: () => closedPromise,
|
|
613
|
+
on: (event, handler) => { handlers[event].add(handler); return () => ref.off(event, handler); },
|
|
614
|
+
off: (event, handler) => { handlers[event].delete(handler); }
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const notify = (event, arg) => handlers[event].forEach(fn => { try { fn(arg); } catch (_) {} });
|
|
618
|
+
|
|
619
|
+
const closedPromise = new Promise(resolve => {
|
|
620
|
+
const finish = (reason) => {
|
|
621
|
+
notify('close', reason);
|
|
622
|
+
if (typeof options.onClose === 'function') {
|
|
623
|
+
try { options.onClose(ref, reason); } catch(_){}
|
|
624
|
+
}
|
|
625
|
+
resolve();
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const cleanup = (reason) => {
|
|
629
|
+
if (!toastEl) return;
|
|
630
|
+
pauseTimer();
|
|
631
|
+
|
|
632
|
+
// Cleanup position listeners and restore styles
|
|
633
|
+
if (positionCleanup) {
|
|
634
|
+
positionCleanup();
|
|
635
|
+
positionCleanup = null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// If overflow, remove immediately to enforce hard cap
|
|
639
|
+
if (reason === 'overflow') {
|
|
640
|
+
if (toastEl.parentElement) toastEl.parentElement.removeChild(toastEl);
|
|
641
|
+
const idx = this.open.indexOf(ref);
|
|
642
|
+
if (idx >= 0) this.open.splice(idx, 1);
|
|
643
|
+
this.idToRef.delete(toastId);
|
|
644
|
+
finish(reason);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Otherwise, animate out
|
|
649
|
+
toastEl.classList.add('APX-toast--exit');
|
|
650
|
+
requestAnimationFrame(() => toastEl.classList.add('APX-toast--exit-active'));
|
|
651
|
+
|
|
652
|
+
const removeEl = () => {
|
|
653
|
+
toastEl.removeEventListener('transitionend', removeEl);
|
|
654
|
+
if (toastEl.parentElement) toastEl.parentElement.removeChild(toastEl);
|
|
655
|
+
const idx = this.open.indexOf(ref);
|
|
656
|
+
if (idx >= 0) this.open.splice(idx, 1);
|
|
657
|
+
this.idToRef.delete(toastId);
|
|
658
|
+
|
|
659
|
+
// Schedule garbage collection for unmanaged containers
|
|
660
|
+
scheduleGarbageCollection();
|
|
661
|
+
|
|
662
|
+
finish(reason);
|
|
663
|
+
};
|
|
664
|
+
toastEl.addEventListener('transitionend', removeEl);
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// attach close behavior
|
|
668
|
+
ref.close = (reason) => cleanup(reason || 'api');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Click handling
|
|
672
|
+
toastEl.addEventListener('click', (ev) => {
|
|
673
|
+
notify('click', ev);
|
|
674
|
+
if (typeof options.onClick === 'function') {
|
|
675
|
+
try { options.onClick(ref, ev); } catch(_){}
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Hover pause
|
|
680
|
+
toastEl.addEventListener('mouseenter', pauseTimer);
|
|
681
|
+
toastEl.addEventListener('mouseleave', () => startTimer());
|
|
682
|
+
|
|
683
|
+
if (closeBtn) closeBtn.addEventListener('click', (ev) => { ev.stopPropagation(); ref.close('close'); });
|
|
684
|
+
|
|
685
|
+
// Track
|
|
686
|
+
this.open.push(ref);
|
|
687
|
+
this.idToRef.set(toastId, ref);
|
|
688
|
+
|
|
689
|
+
// Overflow policy
|
|
690
|
+
if (this.open.length > this.config.maxToasts) {
|
|
691
|
+
const oldest = this.open[0];
|
|
692
|
+
oldest.close('overflow');
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
startTimer();
|
|
696
|
+
return ref;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Convenience helpers
|
|
701
|
+
*/
|
|
702
|
+
info(message, opts) { return this.show({ ...(opts||{}), message, type: 'info' }); }
|
|
703
|
+
success(message, opts) { return this.show({ ...(opts||{}), message, type: 'success' }); }
|
|
704
|
+
warning(message, opts) { return this.show({ ...(opts||{}), message, type: 'warning' }); }
|
|
705
|
+
danger(message, opts) { return this.show({ ...(opts||{}), message, type: 'danger' }); }
|
|
706
|
+
|
|
707
|
+
/** @param {'api'|'overflow'} [reason] */
|
|
708
|
+
closeAll(reason) {
|
|
709
|
+
// copy to avoid mutation during iteration
|
|
710
|
+
const all = this.open.slice();
|
|
711
|
+
all.forEach(r => r.close(reason || 'api'));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** @param {ToastOptions} opts */
|
|
715
|
+
normalizeOptions(opts) {
|
|
716
|
+
const o = { ...opts };
|
|
717
|
+
if (!o.type) o.type = 'info';
|
|
718
|
+
if (typeof o.dismissible !== 'boolean') o.dismissible = true;
|
|
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;
|
|
722
|
+
return o;
|
|
723
|
+
}
|
|
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
|
+
|
|
801
|
+
ensureContainer() {
|
|
802
|
+
if (this.container || !isBrowser) return;
|
|
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);
|
|
808
|
+
this.applyContainerConfig();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
applyContainerConfig() {
|
|
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)
|
|
817
|
+
this.container.style.zIndex = String(this.config.zIndex);
|
|
818
|
+
this.container.style.gap = `${this.config.gap}px`;
|
|
819
|
+
this.container.setAttribute('aria-live', String(this.config.ariaLive));
|
|
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 {};
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* @param {Partial<ToastConfig>=} config
|
|
970
|
+
* @returns {ToastManager}
|
|
971
|
+
*/
|
|
972
|
+
function createToastManager(config) {
|
|
973
|
+
return new ToastManager(config);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// High-level APX.toast API (default & named managers)
|
|
977
|
+
let _defaultManager = null;
|
|
978
|
+
const _getDefault = () => {
|
|
979
|
+
if (!_defaultManager) _defaultManager = createToastManager();
|
|
980
|
+
return _defaultManager;
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Toast API surface to be attached as APX.toast.
|
|
985
|
+
* Callable form proxies to defaultManager.show(opts): APX.toast({...})
|
|
986
|
+
*/
|
|
987
|
+
function toast(opts) {
|
|
988
|
+
return _getDefault().show(opts);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
Object.assign(toast, {
|
|
992
|
+
/**
|
|
993
|
+
* Create a manager. If first arg is string, register as named under toast.custom[name]
|
|
994
|
+
* @param {string|Partial<ToastConfig>} nameOrConfig
|
|
995
|
+
* @param {Partial<ToastConfig>=} maybeConfig
|
|
996
|
+
* @returns {ToastManager}
|
|
997
|
+
*/
|
|
998
|
+
create: (nameOrConfig, maybeConfig) => {
|
|
999
|
+
if (typeof nameOrConfig === 'string') {
|
|
1000
|
+
const name = nameOrConfig;
|
|
1001
|
+
const manager = new ToastManager({ ...(maybeConfig || {}) });
|
|
1002
|
+
if (!toast.custom) toast.custom = {};
|
|
1003
|
+
toast.custom[name] = manager;
|
|
1004
|
+
return manager;
|
|
1005
|
+
}
|
|
1006
|
+
return new ToastManager({ ...(nameOrConfig || {}) });
|
|
1007
|
+
},
|
|
1008
|
+
/** @type {Record<string, ToastManager>} */
|
|
1009
|
+
custom: {},
|
|
1010
|
+
/** @param {string} name */
|
|
1011
|
+
use: (name) => (toast.custom && toast.custom[name]) || null,
|
|
1012
|
+
Manager: ToastManager,
|
|
1013
|
+
show: (opts) => _getDefault().show(opts),
|
|
1014
|
+
info: (message, opts) => _getDefault().info(message, opts),
|
|
1015
|
+
success: (message, opts) => _getDefault().success(message, opts),
|
|
1016
|
+
warning: (message, opts) => _getDefault().warning(message, opts),
|
|
1017
|
+
danger: (message, opts) => _getDefault().danger(message, opts),
|
|
1018
|
+
configure: (config) => _getDefault().configure(config),
|
|
1019
|
+
setAriaLive: (mode) => _getDefault().setAriaLive(mode),
|
|
1020
|
+
closeAll: (reason) => _getDefault().closeAll(reason),
|
|
1021
|
+
getOpenToasts: () => _getDefault().getOpenToasts()
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
export default toast;
|
|
1025
|
+
export { ToastManager, createToastManager, toast };
|