@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.
- package/APX.mjs +121 -118
- package/README.md +55 -22
- package/dist/APX.dev.mjs +1027 -123
- package/dist/APX.mjs +1 -1
- package/dist/APX.prod.mjs +1 -1
- package/dist/APX.standalone.js +1008 -40
- package/dist/APX.standalone.js.map +1 -1
- package/modules/listen/README.md +235 -0
- package/modules/toast/README.md +31 -5
- package/modules/toast/css/toast.css +85 -0
- package/modules/toast/toast.mjs +176 -3
- package/modules/tools/README.md +165 -0
- package/modules/tools/exports.mjs +16 -0
- package/modules/tools/form-packer/README.md +315 -0
- package/modules/tools/form-packer/augment-apx.mjs +30 -0
- package/modules/tools/form-packer/packToJson.mjs +549 -0
- package/package.json +1 -1
|
@@ -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
|
+
|
package/modules/toast/README.md
CHANGED
|
@@ -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,
|
|
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,
|
|
73
|
+
dedupe: boolean, // default false
|
|
74
74
|
containerClass: string, // extra class on container
|
|
75
|
-
offset: number
|
|
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); }
|
package/modules/toast/toast.mjs
CHANGED
|
@@ -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
|
|