@appius-fr/apx 2.5.2 → 2.6.1

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,235 @@
1
+ # APX Listen Module
2
+
3
+ The `listen` module provides a powerful event handling system for APX objects. It enables event delegation, multiple callback chaining, debouncing via timeouts, and manual event triggering.
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ The `listen` module is automatically included when you import APX. It augments all APX objects with the `.listen()` and `.trigger()` methods.
10
+
11
+ ```javascript
12
+ import APX from './APX.mjs';
13
+ // .listen() and .trigger() are now available on all APX objects
14
+ ```
15
+
16
+ ---
17
+
18
+ ## Usage
19
+
20
+ ### Basic Event Listening
21
+
22
+ ```javascript
23
+ // Listen to a single event type
24
+ APX('.my-button').listen('click').do((event) => {
25
+ console.log('Button clicked!', event.target);
26
+ });
27
+
28
+ // Listen to multiple event types
29
+ APX('.my-input').listen(['input', 'change']).do((event) => {
30
+ console.log('Input changed:', event.target.value);
31
+ });
32
+ ```
33
+
34
+ ### Event Delegation
35
+
36
+ Use event delegation to handle events on dynamically added elements:
37
+
38
+ ```javascript
39
+ // Listen for clicks on any button inside the container (even if added later)
40
+ APX('#container').listen('click', '.button').do((event) => {
41
+ console.log('Button clicked:', event.target);
42
+ });
43
+ ```
44
+
45
+ ### Multiple Callbacks (Chaining)
46
+
47
+ Chain multiple callbacks that execute sequentially:
48
+
49
+ ```javascript
50
+ APX('.my-button').listen('click').do((event) => {
51
+ console.log('First callback');
52
+ return fetch('/api/data'); // Can return promises
53
+ }).do((event) => {
54
+ console.log('Second callback (runs after first completes)');
55
+ }).do((event) => {
56
+ console.log('Third callback');
57
+ });
58
+ ```
59
+
60
+ ### Debouncing with Timeout
61
+
62
+ Add a delay before executing callbacks:
63
+
64
+ ```javascript
65
+ // Wait 300ms after the last event before executing callbacks
66
+ APX('.search-input').listen('input', { timeout: 300 }).do((event) => {
67
+ console.log('Searching for:', event.target.value);
68
+ // This will only fire 300ms after the user stops typing
69
+ });
70
+ ```
71
+
72
+ ### Manual Event Triggering
73
+
74
+ Manually trigger events and their registered callbacks:
75
+
76
+ ```javascript
77
+ // Trigger a click event
78
+ APX('.my-button').trigger('click');
79
+
80
+ // Or trigger with an actual Event object
81
+ const customEvent = new CustomEvent('myevent', { detail: { data: 'value' } });
82
+ APX('.my-element').trigger(customEvent);
83
+ ```
84
+
85
+ ---
86
+
87
+ ## API
88
+
89
+ ### `.listen(eventTypes, selector?, options?)`
90
+
91
+ Adds event listeners to the APX-wrapped elements.
92
+
93
+ **Parameters:**
94
+ - `eventTypes` (string | string[]): The event type(s) to listen for (e.g., `'click'`, `['input', 'change']`)
95
+ - `selector` (string, optional): CSS selector for event delegation
96
+ - `options` (object, optional): Configuration options
97
+ - `timeout` (number): Delay in milliseconds before executing callbacks (default: `0`)
98
+
99
+ **Returns:** An object with a `.do()` method for chaining callbacks.
100
+
101
+ **Example:**
102
+ ```javascript
103
+ APX('.element')
104
+ .listen('click', '.button', { timeout: 100 })
105
+ .do((event) => { /* callback 1 */ })
106
+ .do((event) => { /* callback 2 */ });
107
+ ```
108
+
109
+ ### `.do(callback)`
110
+
111
+ Adds a callback function to the event listener chain. Callbacks execute sequentially, and each callback can return a Promise to delay the next callback.
112
+
113
+ **Parameters:**
114
+ - `callback` (Function): The callback function that receives the event object
115
+
116
+ **Returns:** The same object (for chaining)
117
+
118
+ **Example:**
119
+ ```javascript
120
+ APX('.button').listen('click').do((event) => {
121
+ // Callback is bound to the matched element
122
+ console.log(this); // The matched element
123
+ return fetch('/api').then(res => res.json());
124
+ }).do((event) => {
125
+ // This runs after the fetch completes
126
+ console.log('Fetch completed');
127
+ });
128
+ ```
129
+
130
+ ### `.trigger(event)`
131
+
132
+ Manually triggers an event on all wrapped elements and executes registered callbacks.
133
+
134
+ **Parameters:**
135
+ - `event` (string | Event): Event type string or an Event object
136
+
137
+ **Example:**
138
+ ```javascript
139
+ // Trigger with string
140
+ APX('.button').trigger('click');
141
+
142
+ // Trigger with Event object
143
+ const event = new CustomEvent('custom', { detail: { foo: 'bar' } });
144
+ APX('.element').trigger(event);
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Examples
150
+
151
+ ### Form Validation with Debouncing
152
+
153
+ ```javascript
154
+ APX('#email-input').listen('input', { timeout: 500 }).do((event) => {
155
+ const email = event.target.value;
156
+ if (email.includes('@')) {
157
+ validateEmail(email);
158
+ }
159
+ });
160
+ ```
161
+
162
+ ### Dynamic Content with Event Delegation
163
+
164
+ ```javascript
165
+ // Handle clicks on buttons added dynamically
166
+ APX('#dynamic-container').listen('click', '.action-button').do((event) => {
167
+ const button = event.target;
168
+ const action = button.dataset.action;
169
+ handleAction(action);
170
+ });
171
+ ```
172
+
173
+ ### Sequential Async Operations
174
+
175
+ ```javascript
176
+ APX('#submit-button').listen('click').do(async (event) => {
177
+ // First: Validate form
178
+ const isValid = await validateForm();
179
+ if (!isValid) throw new Error('Validation failed');
180
+ }).do(async (event) => {
181
+ // Second: Submit form (only if validation passed)
182
+ const result = await submitForm();
183
+ return result;
184
+ }).do((event) => {
185
+ // Third: Show success message
186
+ showSuccessMessage();
187
+ });
188
+ ```
189
+
190
+ ### Multiple Event Types
191
+
192
+ ```javascript
193
+ APX('.input-field').listen(['focus', 'blur', 'input']).do((event) => {
194
+ switch (event.type) {
195
+ case 'focus':
196
+ highlightField(event.target);
197
+ break;
198
+ case 'blur':
199
+ validateField(event.target);
200
+ break;
201
+ case 'input':
202
+ updatePreview(event.target.value);
203
+ break;
204
+ }
205
+ });
206
+ ```
207
+
208
+ ---
209
+
210
+ ## Features
211
+
212
+ - ✅ **Event Delegation**: Handle events on dynamically added elements
213
+ - ✅ **Multiple Callbacks**: Chain multiple callbacks that execute sequentially
214
+ - ✅ **Promise Support**: Callbacks can return Promises for async operations
215
+ - ✅ **Debouncing**: Add timeouts to delay callback execution
216
+ - ✅ **Multiple Event Types**: Listen to multiple event types at once
217
+ - ✅ **Manual Triggering**: Programmatically trigger events and callbacks
218
+ - ✅ **Context Binding**: Callbacks are bound to the matched element (`this`)
219
+
220
+ ---
221
+
222
+ ## Notes
223
+
224
+ - Callbacks execute sequentially; if a callback returns a Promise, the next callback waits for it to resolve
225
+ - Event delegation uses `closest()` to find the matching element, so it works with nested elements
226
+ - Timeouts are cleared and reset on each event, making it perfect for debouncing
227
+ - The `trigger()` method respects event delegation selectors when executing callbacks
228
+
229
+ ---
230
+
231
+ ## License
232
+
233
+ Author : Thibault SAELEN
234
+ Copyright Appius SARL.
235
+
@@ -66,13 +66,14 @@ APX.toast.use('admin').closeAll();
66
66
  {
67
67
  position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left', // default 'bottom-right'
68
68
  maxToasts: number, // default 5
69
- defaultDurationMs: number, // default 5000
69
+ defaultDurationMs: number, // default 5000
70
70
  zIndex: number, // default 11000
71
71
  ariaLive: 'polite'|'assertive'|'off', // default 'polite'
72
72
  gap: number, // default 8
73
- dedupe: boolean, // default false
73
+ dedupe: boolean, // default false
74
74
  containerClass: string, // extra class on container
75
- offset: number // px offset from screen edges
75
+ offset: number, // px offset from screen edges
76
+ progress: false | true | { enable, position?, pauseButton? } // default false; pauseButton default false (v2.6.1)
76
77
  }
77
78
 
78
79
  // ToastOptions (per toast)
@@ -83,6 +84,7 @@ APX.toast.use('admin').closeAll();
83
84
  dismissible: boolean, // default true
84
85
  id: string, // stable id for dedupe updates
85
86
  className: string, // extra classes on the toast element
87
+ progress: true | { enable: boolean, position?: 'top'|'bottom', pauseButton?: boolean }, // v2.6.1; pauseButton default false
86
88
  onClick: (ref, ev) => void,
87
89
  onClose: (ref, reason) => void // reason: 'timeout'|'close'|'api'|'overflow'
88
90
  }
@@ -106,15 +108,39 @@ Class structure (BEM‑like):
106
108
  - Container: `APX-toast-container APX-toast-container--{corner}`
107
109
  - Toast: `APX-toast APX-toast--{type}`
108
110
  - Children: `APX-toast__content`, optional `APX-toast__close`
111
+ - Progress (v2.6.1): `APX-toast__progress`, `APX-toast__progress-track`, `APX-toast__progress-bar`, optional `APX-toast__progress-pause`
109
112
  - Animations: `APX-toast--enter/--enter-active`, `APX-toast--exit/--exit-active`
110
113
 
114
+ ## Progress bar (v2.6.1)
115
+
116
+ Optional visual countdown for toasts with a duration. Bar shows time remaining (100% → 0%); sync with timer and hover pause.
117
+
118
+ ```js
119
+ // Bar on top (default), no pause button by default
120
+ APX.toast.show({ message: 'Saving…', type: 'info', progress: true });
121
+
122
+ // Bar with pause/resume button
123
+ APX.toast.show({ message: 'Pausable', type: 'info', progress: { enable: true, position: 'top', pauseButton: true } });
124
+
125
+ // Bar at bottom
126
+ APX.toast.show({ message: 'Done', type: 'success', progress: { enable: true, position: 'bottom' } });
127
+
128
+ // Default progress for all toasts from a manager
129
+ APX.toast.configure({ progress: true });
130
+ ```
131
+
132
+ - `progress: true` → bar at top, no pause button.
133
+ - `progress: { enable, position: 'top'|'bottom', pauseButton?: boolean }` — `pauseButton` defaults to `false`; set `pauseButton: true` to show the round pause/resume button on the toast corner.
134
+ - No bar if `durationMs === 0` (sticky). Extra spacing is applied when the button is present so stacked toasts do not overlap.
135
+
111
136
  ## Behavior
112
137
 
113
138
  - Lazy container creation (first `show`).
114
139
  - `maxToasts` enforced; oldest removed with reason `'overflow'`.
115
- - Hover pauses timer; resumes on mouse leave.
116
- - `durationMs = 0` makes the toast sticky.
140
+ - Hover pauses timer; resumes on mouse leave (unless user clicked pause).
141
+ - `durationMs = 0` makes the toast sticky (no progress bar).
117
142
  - If `dedupe: true` and `id` matches an open toast, it updates instead of creating a new one.
143
+ - With `progress`, a bar and optional pause/resume button show; button toggles pause (same as hover).
118
144
 
119
145
  ## Accessibility & SSR
120
146
 
@@ -51,6 +51,91 @@
51
51
  font-size: 16px; line-height: 1; text-align: center;
52
52
  }
53
53
 
54
+ /* Progress bar (outside content, flush to toast edges) */
55
+ .APX-toast__progress {
56
+ position: absolute;
57
+ left: 0;
58
+ right: 0;
59
+ height: var(--apx-toast-progress-height, 4px);
60
+ display: flex;
61
+ flex-direction: row;
62
+ align-items: stretch;
63
+ flex-shrink: 0;
64
+ border-radius: inherit;
65
+ overflow: visible;
66
+ }
67
+ .APX-toast__progress--top {
68
+ top: 0;
69
+ border-radius: var(--apx-toast-radius, 6px) var(--apx-toast-radius, 6px) 0 0;
70
+ }
71
+ .APX-toast__progress--bottom {
72
+ bottom: 0;
73
+ border-radius: 0 0 var(--apx-toast-radius, 6px) var(--apx-toast-radius, 6px);
74
+ }
75
+ /* Track contains the bar; no margin (pause button is on the corner, overlapping) */
76
+ .APX-toast__progress-track {
77
+ flex: 1;
78
+ min-width: 0;
79
+ overflow: hidden;
80
+ }
81
+ .APX-toast__progress-bar {
82
+ height: 100%;
83
+ width: 100%;
84
+ max-width: 100%;
85
+ transition: width 80ms linear;
86
+ background: rgba(0, 0, 0, 0.25);
87
+ }
88
+ .APX-toast--info .APX-toast__progress-bar { background: rgba(5, 44, 101, 0.5); }
89
+ .APX-toast--success .APX-toast__progress-bar { background: rgba(0, 0, 0, 0.25); }
90
+ .APX-toast--warning .APX-toast__progress-bar { background: rgba(102, 77, 3, 0.5); }
91
+ .APX-toast--danger .APX-toast__progress-bar { background: rgba(0, 0, 0, 0.25); }
92
+ /* Pause/Resume button: round, center exactly on toast corner (top-left or bottom-left) */
93
+ .APX-toast__progress-pause {
94
+ position: absolute;
95
+ width: var(--apx-toast-progress-pause-size, 18px);
96
+ height: var(--apx-toast-progress-pause-size, 18px);
97
+ left: 0;
98
+ display: inline-flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ background: #eee;
102
+ color: currentColor;
103
+ border: 1px solid rgba(0, 0, 0, 0.35);
104
+ border-radius: 50%;
105
+ padding: 0;
106
+ margin: 0;
107
+ cursor: pointer;
108
+ line-height: 1;
109
+ opacity: 0.95;
110
+ transition: opacity 120ms ease;
111
+ flex-shrink: 0;
112
+ }
113
+ .APX-toast__progress--top .APX-toast__progress-pause {
114
+ top: 0;
115
+ transform: translate(-50%, -50%);
116
+ }
117
+ .APX-toast__progress--bottom .APX-toast__progress-pause {
118
+ bottom: 0;
119
+ transform: translate(-50%, 50%);
120
+ }
121
+ .APX-toast--info .APX-toast__progress-pause { background: var(--apx-toast-info-bg, #0dcaf0); border-color: rgba(5, 44, 101, 0.55); }
122
+ .APX-toast--success .APX-toast__progress-pause { background: var(--apx-toast-success-bg, #198754); border-color: rgba(0, 0, 0, 0.3); }
123
+ .APX-toast--warning .APX-toast__progress-pause { background: var(--apx-toast-warning-bg, #ffc107); border-color: rgba(102, 77, 3, 0.6); }
124
+ .APX-toast--danger .APX-toast__progress-pause { background: var(--apx-toast-danger-bg, #dc3545); border-color: rgba(0, 0, 0, 0.35); }
125
+ .APX-toast__progress-pause:hover { opacity: 1; }
126
+ .APX-toast__progress-pause:focus { outline: 2px solid rgba(0,0,0,.4); outline-offset: -2px; }
127
+ .APX-toast__progress-pause svg {
128
+ width: 10px;
129
+ height: 10px;
130
+ display: block;
131
+ }
132
+ /* Extra top/bottom padding when progress bar is present (default content padding 10px + bar height) */
133
+ .APX-toast--has-progress-top { padding-top: calc(10px + var(--apx-toast-progress-height, 4px)); }
134
+ .APX-toast--has-progress-bottom { padding-bottom: calc(10px + var(--apx-toast-progress-height, 4px)); }
135
+ /* Reserve space for stacking when pause button is present (button extends outside toast corner) */
136
+ .APX-toast--has-progress-top.APX-toast--has-progress-pause { margin-top: calc(var(--apx-toast-progress-pause-size, 18px) / 2); }
137
+ .APX-toast--has-progress-bottom.APX-toast--has-progress-pause { margin-bottom: calc(var(--apx-toast-progress-pause-size, 18px) / 2); }
138
+
54
139
  /* Animations */
55
140
  .APX-toast--enter { opacity: 0; transform: translateY(8px); }
56
141
  .APX-toast--enter.APX-toast--enter-active { opacity: 1; transform: translateY(0); }
@@ -30,6 +30,11 @@ import './css/toast.css';
30
30
  * @property {string} [containerClass]
31
31
  * @property {number} [offset]
32
32
  * @property {string} [id]
33
+ * @property {boolean|{enable: boolean, position: 'top'|'bottom'}} [progress] Default: false. Show progress bar when true or { enable: true, position: 'top'|'bottom' }.
34
+ */
35
+
36
+ /**
37
+ * @typedef {{enable: boolean, position: 'top'|'bottom', pauseButton: boolean}} ProgressOpt
33
38
  */
34
39
 
35
40
  /**
@@ -44,6 +49,7 @@ import './css/toast.css';
44
49
  * @property {string} [className]
45
50
  * @property {Position} [position]
46
51
  * @property {'up'|'down'|'auto'} [flow] Flow direction for stacking toasts. 'auto' determines based on position. Default: 'auto'
52
+ * @property {boolean|{enable: boolean, position: 'top'|'bottom', pauseButton?: boolean}} [progress] Show progress bar: true = top, or { enable, position, pauseButton } (pauseButton default false).
47
53
  */
48
54
 
49
55
  /**
@@ -104,7 +110,8 @@ const DEFAULT_CONFIG = {
104
110
  gap: 8,
105
111
  dedupe: false,
106
112
  containerClass: '',
107
- offset: 0
113
+ offset: 0,
114
+ progress: false
108
115
  };
109
116
 
110
117
  /**
@@ -118,6 +125,101 @@ function createEl(tag, classNames) {
118
125
  return el;
119
126
  }
120
127
 
128
+ /** Minimal line-based pause (two bars) / resume (triangle right) icon SVG. Uses currentColor. */
129
+ function getProgressPauseResumeIconSVG(paused) {
130
+ if (paused) {
131
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M8 6L8 18L17 12Z"/></svg>`;
132
+ }
133
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><line x1="9" y1="6" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="18"/></svg>`;
134
+ }
135
+
136
+ /**
137
+ * Create and insert progress block (track + bar, optional pause button) into toast. Button is fixed at left via track layout.
138
+ * @param {HTMLElement} toastEl
139
+ * @param {HTMLElement} contentEl
140
+ * @param {HTMLElement|null} closeBtn
141
+ * @param {ProgressOpt} progressOpt
142
+ * @returns {{ wrap: HTMLElement, bar: HTMLElement, pauseBtn: HTMLElement|null }}
143
+ */
144
+ function createProgressBlock(toastEl, contentEl, closeBtn, progressOpt) {
145
+ const pos = progressOpt.position;
146
+ const showPauseButton = progressOpt.pauseButton === true;
147
+ const wrap = createEl('div', `APX-toast__progress APX-toast__progress--${pos}`);
148
+ const track = createEl('div', 'APX-toast__progress-track');
149
+ const bar = createEl('div', 'APX-toast__progress-bar');
150
+ bar.setAttribute('role', 'progressbar');
151
+ bar.setAttribute('aria-valuemin', '0');
152
+ bar.setAttribute('aria-valuemax', '100');
153
+ bar.setAttribute('aria-label', 'Temps restant');
154
+ bar.setAttribute('aria-valuenow', '100');
155
+ bar.style.width = '100%';
156
+ track.appendChild(bar);
157
+ wrap.appendChild(track);
158
+ let pauseBtn = null;
159
+ if (showPauseButton) {
160
+ pauseBtn = createEl('button', 'APX-toast__progress-pause');
161
+ pauseBtn.type = 'button';
162
+ pauseBtn.setAttribute('aria-label', 'Pause');
163
+ pauseBtn.setAttribute('title', 'Pause');
164
+ pauseBtn.innerHTML = getProgressPauseResumeIconSVG(false); // running = show pause icon
165
+ wrap.insertBefore(pauseBtn, track);
166
+ }
167
+ if (pos === 'top') {
168
+ toastEl.insertBefore(wrap, contentEl);
169
+ toastEl.classList.add('APX-toast--has-progress-top');
170
+ } else {
171
+ toastEl.insertBefore(wrap, closeBtn);
172
+ toastEl.classList.add('APX-toast--has-progress-bottom');
173
+ }
174
+ if (showPauseButton) toastEl.classList.add('APX-toast--has-progress-pause');
175
+ return { wrap, bar, pauseBtn };
176
+ }
177
+
178
+ /**
179
+ * Update progress block position (classes + DOM order)
180
+ * @param {HTMLElement} toastEl
181
+ * @param {HTMLElement} progressWrap
182
+ * @param {HTMLElement} contentEl
183
+ * @param {HTMLElement|null} closeBtn
184
+ * @param {'top'|'bottom'} pos
185
+ */
186
+ function applyProgressPosition(toastEl, progressWrap, contentEl, closeBtn, pos) {
187
+ toastEl.classList.remove('APX-toast--has-progress-top', 'APX-toast--has-progress-bottom');
188
+ toastEl.classList.add(pos === 'top' ? 'APX-toast--has-progress-top' : 'APX-toast--has-progress-bottom');
189
+ progressWrap.classList.remove('APX-toast__progress--top', 'APX-toast__progress--bottom');
190
+ progressWrap.classList.add(`APX-toast__progress--${pos}`);
191
+ const target = pos === 'top' ? contentEl : closeBtn;
192
+ if (progressWrap.nextElementSibling !== target) toastEl.insertBefore(progressWrap, target);
193
+ }
194
+
195
+ /**
196
+ * Resolve progress option from per-call option and config default
197
+ * @param {boolean|{enable: boolean, position: 'top'|'bottom'}|undefined} option
198
+ * @param {boolean|{enable: boolean, position: 'top'|'bottom'}|undefined} configDefault
199
+ * @returns {ProgressOpt}
200
+ */
201
+ function resolveProgress(option, configDefault) {
202
+ const fromConfig = configDefault === true
203
+ ? { enable: true, position: 'top', pauseButton: false }
204
+ : (configDefault && typeof configDefault === 'object'
205
+ ? {
206
+ enable: !!configDefault.enable,
207
+ position: configDefault.position === 'bottom' ? 'bottom' : 'top',
208
+ pauseButton: configDefault.pauseButton === true
209
+ }
210
+ : { enable: false, position: 'top', pauseButton: false });
211
+ if (option === undefined || option === null) return fromConfig;
212
+ if (option === true) return { enable: true, position: 'top', pauseButton: false };
213
+ if (typeof option === 'object') {
214
+ return {
215
+ enable: !!option.enable,
216
+ position: option.position === 'bottom' ? 'bottom' : 'top',
217
+ pauseButton: option.pauseButton === true
218
+ };
219
+ }
220
+ return { enable: false, position: 'top', pauseButton: false };
221
+ }
222
+
121
223
  /**
122
224
  * Normalize placement synonyms to CSS values
123
225
  * @param {string} placement
@@ -388,6 +490,16 @@ class ToastManager {
388
490
  toastEl.appendChild(closeBtn);
389
491
  }
390
492
 
493
+ let progressWrap = null;
494
+ let progressBarEl = null;
495
+ let progressPauseBtn = null;
496
+ if (options.progress.enable && options.durationMs > 0) {
497
+ const created = createProgressBlock(toastEl, contentEl, closeBtn, options.progress);
498
+ progressWrap = created.wrap;
499
+ progressBarEl = created.bar;
500
+ progressPauseBtn = created.pauseBtn;
501
+ }
502
+
391
503
  // Get or create container for this specific position
392
504
  let container = null;
393
505
  let positionUpdateFn = null;
@@ -564,12 +676,27 @@ class ToastManager {
564
676
  let remaining = options.durationMs;
565
677
  let timerId = null;
566
678
  let startTs = null;
679
+ let userPaused = false;
680
+ let progressRafId = null;
567
681
  const handlers = { click: new Set(), close: new Set() };
568
682
 
569
683
  const startTimer = () => {
570
684
  if (!remaining || remaining <= 0) return; // sticky
571
685
  startTs = Date.now();
572
686
  timerId = window.setTimeout(() => ref.close('timeout'), remaining);
687
+ if (progressBarEl) {
688
+ const tick = () => {
689
+ if (timerId == null || startTs == null) return;
690
+ const elapsed = Date.now() - startTs;
691
+ const remainingMs = Math.max(0, remaining - elapsed);
692
+ const durationMs = options.durationMs;
693
+ const pct = durationMs > 0 ? Math.max(0, Math.min(100, (remainingMs / durationMs) * 100)) : 0;
694
+ progressBarEl.style.width = `${pct}%`;
695
+ progressBarEl.setAttribute('aria-valuenow', String(Math.round(pct)));
696
+ progressRafId = requestAnimationFrame(tick);
697
+ };
698
+ progressRafId = requestAnimationFrame(tick);
699
+ }
573
700
  };
574
701
  const pauseTimer = () => {
575
702
  if (timerId != null) {
@@ -577,6 +704,22 @@ class ToastManager {
577
704
  timerId = null;
578
705
  if (startTs != null) remaining -= (Date.now() - startTs);
579
706
  }
707
+ if (progressRafId != null) {
708
+ cancelAnimationFrame(progressRafId);
709
+ progressRafId = null;
710
+ }
711
+ };
712
+
713
+ const attachProgressPauseHandler = (btn) => {
714
+ btn.addEventListener('click', (ev) => {
715
+ ev.stopPropagation();
716
+ userPaused = !userPaused;
717
+ if (userPaused) pauseTimer(); else startTimer();
718
+ const label = userPaused ? 'Reprendre' : 'Pause';
719
+ btn.setAttribute('aria-label', label);
720
+ btn.setAttribute('title', label);
721
+ btn.innerHTML = getProgressPauseResumeIconSVG(userPaused); // paused = resume (triangle)
722
+ });
580
723
  };
581
724
 
582
725
  /** @type {ToastRef} */
@@ -603,9 +746,34 @@ class ToastManager {
603
746
  if (options.className) toastEl.classList.remove(...options.className.split(' ').filter(Boolean));
604
747
  if (merged.className) toastEl.classList.add(...merged.className.split(' ').filter(Boolean));
605
748
  }
749
+ // update progress visibility/position
750
+ options.progress = merged.progress;
751
+ if (progressWrap) {
752
+ if (!merged.progress.enable || merged.durationMs <= 0) {
753
+ progressWrap.style.display = 'none';
754
+ } else {
755
+ progressWrap.style.display = '';
756
+ applyProgressPosition(toastEl, progressWrap, contentEl, closeBtn, merged.progress.position);
757
+ }
758
+ } else if (merged.progress.enable && merged.durationMs > 0) {
759
+ const created = createProgressBlock(toastEl, contentEl, closeBtn, merged.progress);
760
+ progressWrap = created.wrap;
761
+ progressBarEl = created.bar;
762
+ progressPauseBtn = created.pauseBtn;
763
+ if (progressPauseBtn) attachProgressPauseHandler(progressPauseBtn);
764
+ }
765
+ if (progressWrap && progressWrap.style.display !== 'none' && merged.progress.pauseButton === true) {
766
+ toastEl.classList.add('APX-toast--has-progress-pause');
767
+ } else {
768
+ toastEl.classList.remove('APX-toast--has-progress-pause');
769
+ }
606
770
  // update duration logic
607
771
  options.durationMs = merged.durationMs;
608
772
  remaining = merged.durationMs;
773
+ if (progressBarEl) {
774
+ progressBarEl.style.width = '100%';
775
+ progressBarEl.setAttribute('aria-valuenow', '100');
776
+ }
609
777
  pauseTimer();
610
778
  startTimer();
611
779
  },
@@ -628,7 +796,10 @@ class ToastManager {
628
796
  const cleanup = (reason) => {
629
797
  if (!toastEl) return;
630
798
  pauseTimer();
631
-
799
+ if (progressRafId != null) {
800
+ cancelAnimationFrame(progressRafId);
801
+ progressRafId = null;
802
+ }
632
803
  // Cleanup position listeners and restore styles
633
804
  if (positionCleanup) {
634
805
  positionCleanup();
@@ -678,9 +849,10 @@ class ToastManager {
678
849
 
679
850
  // Hover pause
680
851
  toastEl.addEventListener('mouseenter', pauseTimer);
681
- toastEl.addEventListener('mouseleave', () => startTimer());
852
+ toastEl.addEventListener('mouseleave', () => { if (!userPaused) startTimer(); });
682
853
 
683
854
  if (closeBtn) closeBtn.addEventListener('click', (ev) => { ev.stopPropagation(); ref.close('close'); });
855
+ if (progressPauseBtn) attachProgressPauseHandler(progressPauseBtn);
684
856
 
685
857
  // Track
686
858
  this.open.push(ref);
@@ -719,6 +891,7 @@ class ToastManager {
719
891
  if (typeof o.durationMs !== 'number') o.durationMs = this.config.defaultDurationMs;
720
892
  // Use id from options if provided, otherwise use id from config, otherwise undefined (will be auto-generated)
721
893
  if (!o.id && this.config.id) o.id = this.config.id;
894
+ o.progress = resolveProgress(opts.progress, this.config.progress);
722
895
  return o;
723
896
  }
724
897