@appius-fr/apx 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,374 @@
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} ToastConfig
7
+ * @property {'bottom-right'|'bottom-left'|'top-right'|'top-left'} [position]
8
+ * @property {number} [maxToasts]
9
+ * @property {number} [defaultDurationMs]
10
+ * @property {number} [zIndex]
11
+ * @property {'polite'|'assertive'|'off'} [ariaLive]
12
+ * @property {number} [gap]
13
+ * @property {boolean} [dedupe]
14
+ * @property {string} [containerClass]
15
+ * @property {number} [offset]
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} ToastOptions
20
+ * @property {string|Node} message
21
+ * @property {'info'|'success'|'warning'|'danger'} [type]
22
+ * @property {number} [durationMs]
23
+ * @property {boolean} [dismissible]
24
+ * @property {string} [id]
25
+ * @property {(ref: ToastRef, ev: MouseEvent) => void} [onClick]
26
+ * @property {(ref: ToastRef, reason: 'timeout'|'close'|'api'|'overflow') => void} [onClose]
27
+ * @property {string} [className]
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} ToastRef
32
+ * @property {string} id
33
+ * @property {HTMLElement} el
34
+ * @property {(reason?: 'api'|'close') => void} close
35
+ * @property {(partial: Partial<ToastOptions>) => void} update
36
+ * @property {() => Promise<void>} whenClosed
37
+ * @property {(event: 'close'|'click', handler: Function) => () => void} on
38
+ * @property {(event: 'close'|'click', handler: Function) => void} off
39
+ */
40
+
41
+ const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
42
+
43
+ const DEFAULT_CONFIG = {
44
+ position: 'bottom-right',
45
+ maxToasts: 5,
46
+ defaultDurationMs: 5000,
47
+ zIndex: 11000,
48
+ ariaLive: 'polite',
49
+ gap: 8,
50
+ dedupe: false,
51
+ containerClass: '',
52
+ offset: 0
53
+ };
54
+
55
+ /**
56
+ * Create an element with classes
57
+ */
58
+ function createEl(tag, classNames) {
59
+ const el = document.createElement(tag);
60
+ if (classNames) {
61
+ classNames.split(' ').filter(Boolean).forEach(c => el.classList.add(c));
62
+ }
63
+ return el;
64
+ }
65
+
66
+ /**
67
+ * ToastManager class
68
+ */
69
+ class ToastManager {
70
+ /** @param {Partial<ToastConfig>=} config */
71
+ constructor(config) {
72
+ /** @type {ToastConfig} */
73
+ this.config = { ...DEFAULT_CONFIG, ...(config || {}) };
74
+ /** @type {HTMLElement|null} */
75
+ this.container = null;
76
+ /** @type {Map<string, ToastRef>} */
77
+ this.idToRef = new Map();
78
+ /** @type {ToastRef[]} */
79
+ this.open = [];
80
+ }
81
+
82
+ /** @param {Partial<ToastConfig>} config */
83
+ configure(config) {
84
+ this.config = { ...this.config, ...(config || {}) };
85
+ if (this.container) this.applyContainerConfig();
86
+ }
87
+
88
+ /** @param {'polite'|'assertive'|'off'} mode */
89
+ setAriaLive(mode) { this.configure({ ariaLive: mode }); }
90
+
91
+ /** @returns {ToastRef[]} */
92
+ getOpenToasts() { return this.open.slice(); }
93
+
94
+ /** @param {ToastOptions} opts */
95
+ show(opts) {
96
+ if (!isBrowser) return /** @type {any} */(null);
97
+ const options = this.normalizeOptions(opts);
98
+
99
+ if (this.config.dedupe && options.id && this.idToRef.has(options.id)) {
100
+ const ref = this.idToRef.get(options.id);
101
+ ref.update(options);
102
+ return ref;
103
+ }
104
+
105
+ this.ensureContainer();
106
+
107
+ const toastEl = createEl('div', `APX-toast APX-toast--${options.type}`);
108
+ toastEl.setAttribute('role', 'status');
109
+ const toastId = options.id || `t_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
110
+ toastEl.dataset.toastId = toastId;
111
+ if (options.className) toastEl.className += ` ${options.className}`;
112
+
113
+ const contentEl = createEl('div', 'APX-toast__content');
114
+ if (typeof options.message === 'string') {
115
+ contentEl.textContent = options.message;
116
+ } else if (options.message) {
117
+ contentEl.appendChild(options.message);
118
+ }
119
+ toastEl.appendChild(contentEl);
120
+
121
+ let closeBtn = null;
122
+ if (options.dismissible !== false) {
123
+ closeBtn = createEl('button', 'APX-toast__close');
124
+ closeBtn.setAttribute('aria-label', 'Close');
125
+ closeBtn.type = 'button';
126
+ toastEl.appendChild(closeBtn);
127
+ }
128
+
129
+ this.container.appendChild(toastEl);
130
+
131
+ // Enter animation
132
+ toastEl.classList.add('APX-toast--enter');
133
+ requestAnimationFrame(() => {
134
+ toastEl.classList.add('APX-toast--enter-active');
135
+ });
136
+
137
+ // Event handling and timers
138
+ let remaining = options.durationMs;
139
+ let timerId = null;
140
+ let startTs = null;
141
+ const handlers = { click: new Set(), close: new Set() };
142
+
143
+ const startTimer = () => {
144
+ if (!remaining || remaining <= 0) return; // sticky
145
+ startTs = Date.now();
146
+ timerId = window.setTimeout(() => ref.close('timeout'), remaining);
147
+ };
148
+ const pauseTimer = () => {
149
+ if (timerId != null) {
150
+ window.clearTimeout(timerId);
151
+ timerId = null;
152
+ if (startTs != null) remaining -= (Date.now() - startTs);
153
+ }
154
+ };
155
+
156
+ /** @type {ToastRef} */
157
+ const ref = {
158
+ id: toastId,
159
+ el: toastEl,
160
+ close: (reason) => {
161
+ cleanup(reason || 'api');
162
+ },
163
+ update: (partial) => {
164
+ const merged = this.normalizeOptions({ ...options, ...partial });
165
+ // update content
166
+ if (typeof merged.message === 'string') {
167
+ contentEl.textContent = merged.message;
168
+ } else if (merged.message) {
169
+ contentEl.innerHTML = '';
170
+ contentEl.appendChild(merged.message);
171
+ }
172
+ // update type class
173
+ ['info','success','warning','danger'].forEach(t => toastEl.classList.remove(`APX-toast--${t}`));
174
+ toastEl.classList.add(`APX-toast--${merged.type}`);
175
+ // update classes
176
+ if (options.className !== merged.className) {
177
+ if (options.className) toastEl.classList.remove(...options.className.split(' ').filter(Boolean));
178
+ if (merged.className) toastEl.classList.add(...merged.className.split(' ').filter(Boolean));
179
+ }
180
+ // update duration logic
181
+ options.durationMs = merged.durationMs;
182
+ remaining = merged.durationMs;
183
+ pauseTimer();
184
+ startTimer();
185
+ },
186
+ whenClosed: () => closedPromise,
187
+ on: (event, handler) => { handlers[event].add(handler); return () => ref.off(event, handler); },
188
+ off: (event, handler) => { handlers[event].delete(handler); }
189
+ };
190
+
191
+ const notify = (event, arg) => handlers[event].forEach(fn => { try { fn(arg); } catch (_) {} });
192
+
193
+ const closedPromise = new Promise(resolve => {
194
+ const finish = (reason) => {
195
+ notify('close', reason);
196
+ if (typeof options.onClose === 'function') {
197
+ try { options.onClose(ref, reason); } catch(_){}
198
+ }
199
+ resolve();
200
+ };
201
+
202
+ const cleanup = (reason) => {
203
+ if (!toastEl) return;
204
+ pauseTimer();
205
+ // If overflow, remove immediately to enforce hard cap
206
+ if (reason === 'overflow') {
207
+ if (toastEl.parentElement) toastEl.parentElement.removeChild(toastEl);
208
+ const idx = this.open.indexOf(ref);
209
+ if (idx >= 0) this.open.splice(idx, 1);
210
+ this.idToRef.delete(toastId);
211
+ finish(reason);
212
+ return;
213
+ }
214
+
215
+ // Otherwise, animate out
216
+ toastEl.classList.add('APX-toast--exit');
217
+ requestAnimationFrame(() => toastEl.classList.add('APX-toast--exit-active'));
218
+
219
+ const removeEl = () => {
220
+ toastEl.removeEventListener('transitionend', removeEl);
221
+ if (toastEl.parentElement) toastEl.parentElement.removeChild(toastEl);
222
+ const idx = this.open.indexOf(ref);
223
+ if (idx >= 0) this.open.splice(idx, 1);
224
+ this.idToRef.delete(toastId);
225
+ finish(reason);
226
+ };
227
+ toastEl.addEventListener('transitionend', removeEl);
228
+ };
229
+
230
+ // attach close behavior
231
+ ref.close = (reason) => cleanup(reason || 'api');
232
+ });
233
+
234
+ // Click handling
235
+ toastEl.addEventListener('click', (ev) => {
236
+ notify('click', ev);
237
+ if (typeof options.onClick === 'function') {
238
+ try { options.onClick(ref, ev); } catch(_){}
239
+ }
240
+ });
241
+
242
+ // Hover pause
243
+ toastEl.addEventListener('mouseenter', pauseTimer);
244
+ toastEl.addEventListener('mouseleave', () => startTimer());
245
+
246
+ if (closeBtn) closeBtn.addEventListener('click', (ev) => { ev.stopPropagation(); ref.close('close'); });
247
+
248
+ // Track
249
+ this.open.push(ref);
250
+ this.idToRef.set(toastId, ref);
251
+
252
+ // Overflow policy
253
+ if (this.open.length > this.config.maxToasts) {
254
+ const oldest = this.open[0];
255
+ oldest.close('overflow');
256
+ }
257
+
258
+ startTimer();
259
+ return ref;
260
+ }
261
+
262
+ /**
263
+ * Convenience helpers
264
+ */
265
+ info(message, opts) { return this.show({ ...(opts||{}), message, type: 'info' }); }
266
+ success(message, opts) { return this.show({ ...(opts||{}), message, type: 'success' }); }
267
+ warning(message, opts) { return this.show({ ...(opts||{}), message, type: 'warning' }); }
268
+ danger(message, opts) { return this.show({ ...(opts||{}), message, type: 'danger' }); }
269
+
270
+ /** @param {'api'|'overflow'} [reason] */
271
+ closeAll(reason) {
272
+ // copy to avoid mutation during iteration
273
+ const all = this.open.slice();
274
+ all.forEach(r => r.close(reason || 'api'));
275
+ }
276
+
277
+ /** @param {ToastOptions} opts */
278
+ normalizeOptions(opts) {
279
+ const o = { ...opts };
280
+ if (!o.type) o.type = 'info';
281
+ if (typeof o.dismissible !== 'boolean') o.dismissible = true;
282
+ if (typeof o.durationMs !== 'number') o.durationMs = this.config.defaultDurationMs;
283
+ return o;
284
+ }
285
+
286
+ ensureContainer() {
287
+ 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;
302
+ this.applyContainerConfig();
303
+ }
304
+
305
+ applyContainerConfig() {
306
+ if (!this.container) return;
307
+ this.container.style.zIndex = String(this.config.zIndex);
308
+ this.container.style.gap = `${this.config.gap}px`;
309
+ 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}`);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * @param {Partial<ToastConfig>=} config
319
+ * @returns {ToastManager}
320
+ */
321
+ function createToastManager(config) {
322
+ return new ToastManager(config);
323
+ }
324
+
325
+ // High-level APX.toast API (default & named managers)
326
+ let _defaultManager = null;
327
+ const _getDefault = () => {
328
+ if (!_defaultManager) _defaultManager = createToastManager();
329
+ return _defaultManager;
330
+ };
331
+
332
+ /**
333
+ * Toast API surface to be attached as APX.toast.
334
+ * Callable form proxies to defaultManager.show(opts): APX.toast({...})
335
+ */
336
+ function toast(opts) {
337
+ return _getDefault().show(opts);
338
+ }
339
+
340
+ Object.assign(toast, {
341
+ /**
342
+ * Create a manager. If first arg is string, register as named under toast.custom[name]
343
+ * @param {string|Partial<ToastConfig>} nameOrConfig
344
+ * @param {Partial<ToastConfig>=} maybeConfig
345
+ * @returns {ToastManager}
346
+ */
347
+ create: (nameOrConfig, maybeConfig) => {
348
+ if (typeof nameOrConfig === 'string') {
349
+ const name = nameOrConfig;
350
+ const manager = new ToastManager({ ...(maybeConfig || {}) });
351
+ if (!toast.custom) toast.custom = {};
352
+ toast.custom[name] = manager;
353
+ return manager;
354
+ }
355
+ return new ToastManager({ ...(nameOrConfig || {}) });
356
+ },
357
+ /** @type {Record<string, ToastManager>} */
358
+ custom: {},
359
+ /** @param {string} name */
360
+ use: (name) => (toast.custom && toast.custom[name]) || null,
361
+ Manager: ToastManager,
362
+ show: (opts) => _getDefault().show(opts),
363
+ info: (message, opts) => _getDefault().info(message, opts),
364
+ success: (message, opts) => _getDefault().success(message, opts),
365
+ warning: (message, opts) => _getDefault().warning(message, opts),
366
+ danger: (message, opts) => _getDefault().danger(message, opts),
367
+ configure: (config) => _getDefault().configure(config),
368
+ setAriaLive: (mode) => _getDefault().setAriaLive(mode),
369
+ closeAll: (reason) => _getDefault().closeAll(reason),
370
+ getOpenToasts: () => _getDefault().getOpenToasts()
371
+ });
372
+
373
+ export default toast;
374
+ export { ToastManager, createToastManager, toast };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appius-fr/apx",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Appius Extended JS - A powerful JavaScript extension library",
5
5
  "main": "dist/APX.prod.mjs",
6
6
  "module": "dist/APX.prod.mjs",
@@ -30,7 +30,8 @@
30
30
  "build": "webpack --config webpack.config.js",
31
31
  "build:prod": "webpack --config webpack.prod.config.js",
32
32
  "build:dev": "webpack --config webpack.dev.config.js",
33
- "build:all": "npm run build && npm run build:prod && npm run build:dev",
33
+ "build:standalone": "webpack --config webpack.standalone.config.js",
34
+ "build:all": "npm run build && npm run build:prod && npm run build:dev && npm run build:standalone",
34
35
  "prepare": "npm run build:all"
35
36
  },
36
37
  "repository": {