@appius-fr/apx 2.6.0 → 2.6.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.
@@ -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
 
@@ -123,12 +123,14 @@ The module intelligently handles mixed structures (objects with both numeric and
123
123
 
124
124
  ## API
125
125
 
126
- ### `APX.tools.packFormToJSON(form)`
126
+ ### `APX.tools.packFormToJSON(form, options?)`
127
127
 
128
128
  Converts an HTML form element into a JSON object.
129
129
 
130
130
  **Parameters:**
131
131
  - `form` (HTMLFormElement): The form element to convert
132
+ - `options` (Object, optional):
133
+ - `numericKeysAlwaysArray` (boolean, default `false`): If `true`, every numeric bracket key (e.g. `[26]`) is treated as an array index (pre-2.6.2 behaviour). If `false`, the heuristic applies: array only when indices are dense 0,1,2,…,n; otherwise object with string keys.
132
134
 
133
135
  **Returns:** (Object) The JSON object representation of the form data
134
136
 
@@ -137,30 +139,26 @@ Converts an HTML form element into a JSON object.
137
139
  **Example:**
138
140
  ```javascript
139
141
  const form = document.querySelector('#myForm');
140
- try {
141
- const data = APX.tools.packFormToJSON(form);
142
- console.log(data);
143
- } catch (error) {
144
- console.error('Error:', error.message);
145
- }
142
+ const data = APX.tools.packFormToJSON(form);
143
+ // Legacy: always use arrays for numeric keys
144
+ const dataLegacy = APX.tools.packFormToJSON(form, { numericKeysAlwaysArray: true });
146
145
  ```
147
146
 
148
- ### `APX('form').pack()`
147
+ ### `APX('form').pack(options?)`
149
148
 
150
149
  Converts the first selected form element into a JSON object. This is a chainable method on APX objects.
151
150
 
151
+ **Parameters:**
152
+ - `options` (Object, optional): Same as the second argument of `packFormToJSON` (e.g. `{ numericKeysAlwaysArray: true }`).
153
+
152
154
  **Returns:** (Object) The JSON object representation of the form data
153
155
 
154
156
  **Throws:** (Error) If no element is found or the first element is not a form
155
157
 
156
158
  **Example:**
157
159
  ```javascript
158
- try {
159
- const data = APX('form#myForm').pack();
160
- console.log(data);
161
- } catch (error) {
162
- console.error('Error:', error.message);
163
- }
160
+ const data = APX('form#myForm').pack();
161
+ const dataLegacy = APX('form#myForm').pack({ numericKeysAlwaysArray: true });
164
162
  ```
165
163
 
166
164
  ---
@@ -8,12 +8,14 @@ import { packFormToJSON } from './packToJson.mjs';
8
8
  export default function (apx) {
9
9
  /**
10
10
  * Convertit le premier formulaire sélectionné en objet JSON
11
+ * @param {Object} [options] - Options passées à packFormToJSON (ex. { numericKeysAlwaysArray: true })
11
12
  * @returns {Object} L'objet JSON résultant
12
13
  * @throws {Error} Si aucun formulaire n'est trouvé ou si le premier élément n'est pas un formulaire
13
14
  * @example
14
15
  * const data = APX('form.myformclass').pack();
16
+ * const dataLegacy = APX('form').pack({ numericKeysAlwaysArray: true });
15
17
  */
16
- apx.pack = function () {
18
+ apx.pack = function (options) {
17
19
  const firstElement = this.first();
18
20
  if (!firstElement) {
19
21
  throw new Error('No element found');
@@ -21,7 +23,7 @@ export default function (apx) {
21
23
  if (firstElement.tagName !== 'FORM') {
22
24
  throw new Error('Element is not a form');
23
25
  }
24
- return packFormToJSON(firstElement);
26
+ return packFormToJSON(firstElement, options);
25
27
  };
26
28
 
27
29
  return apx;
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * Convertit un formulaire HTML en objet JSON
3
3
  * @param {HTMLFormElement} form - Le formulaire à convertir
4
+ * @param {Object} [options] - Options de conversion
5
+ * @param {boolean} [options.numericKeysAlwaysArray=false] - Si true, toute clé numérique est traitée comme index de tableau (comportement d'avant 2.6.2). Sinon, heuristique : tableau seulement si indices denses 0,1,2,…,n.
4
6
  * @returns {Object} L'objet JSON résultant
5
7
  * @throws {TypeError} Si form n'est pas un HTMLFormElement
6
8
  */
7
- export const packFormToJSON = (form) => {
9
+ export const packFormToJSON = (form, options = {}) => {
8
10
  // Validation de l'entrée
9
11
  if (!form || !(form instanceof HTMLFormElement)) {
10
12
  throw new TypeError('packFormToJSON expects an HTMLFormElement');
11
13
  }
12
14
 
15
+ const numericKeysAlwaysArray = options.numericKeysAlwaysArray === true;
16
+
13
17
  const formData = new FormData(form);
14
18
  const jsonData = {};
15
19
 
@@ -48,6 +52,7 @@ export const packFormToJSON = (form) => {
48
52
 
49
53
  const pathUsage = new Map(); // Map<pathString, PathUsage>
50
54
  const keyAnalysis = new Map(); // Map<basePath, {hasNumeric: boolean, hasString: boolean}>
55
+ const numericIndicesByPath = new Map(); // Map<pathString, Set<number>> — indices under each parent path
51
56
  const allEntries = [];
52
57
 
53
58
  /**
@@ -100,6 +105,13 @@ export const packFormToJSON = (form) => {
100
105
  if (part.type === 'key') {
101
106
  currentPath = currentPath ? `${currentPath}[${part.name}]` : part.name;
102
107
  } else if (part.type === 'numeric') {
108
+ const parentPath = currentPath;
109
+ let set = numericIndicesByPath.get(parentPath);
110
+ if (!set) {
111
+ set = new Set();
112
+ numericIndicesByPath.set(parentPath, set);
113
+ }
114
+ set.add(part.index);
103
115
  currentPath = `${currentPath}[${part.index}]`;
104
116
  } else if (part.type === 'array') {
105
117
  currentPath = `${currentPath}[]`;
@@ -190,6 +202,34 @@ export const packFormToJSON = (form) => {
190
202
  return analysis?.hasNumeric && analysis?.hasString;
191
203
  };
192
204
 
205
+ /**
206
+ * True if indices are exactly 0, 1, 2, ..., n (dense, sequential from 0).
207
+ * @param {number[]} indices - Sorted array of indices
208
+ * @returns {boolean}
209
+ */
210
+ const isDenseSequential = (indices) => {
211
+ if (indices.length === 0) return false;
212
+ const sorted = [...indices].sort((a, b) => a - b);
213
+ for (let j = 0; j < sorted.length; j++) {
214
+ if (sorted[j] !== j) return false;
215
+ }
216
+ return true;
217
+ };
218
+
219
+ /**
220
+ * True if numeric keys under this path should be treated as array indices.
221
+ * With numericKeysAlwaysArray: always true when path has numeric children.
222
+ * Otherwise: true only when indices are dense 0..n.
223
+ * @param {string} parentPath - Path to the container that has numeric children
224
+ * @returns {boolean}
225
+ */
226
+ const useArrayForNumericPath = (parentPath) => {
227
+ const set = numericIndicesByPath.get(parentPath);
228
+ if (!set || set.size === 0) return true;
229
+ if (numericKeysAlwaysArray) return true;
230
+ return isDenseSequential([...set]);
231
+ };
232
+
193
233
  /**
194
234
  * Convertit un tableau en objet en préservant les indices numériques comme propriétés
195
235
  * @param {Array} arr - Le tableau à convertir
@@ -285,8 +325,9 @@ export const packFormToJSON = (form) => {
285
325
  * @param {*} value - La valeur à assigner
286
326
  * @param {Object} parent - Le conteneur parent
287
327
  * @param {string} basePath - Le chemin de base pour la détection de conflit
328
+ * @param {string} parentPath - Chemin du conteneur actuel (pour heuristique numeric)
288
329
  */
289
- const processFinalValue = (container, part, value, parent, basePath) => {
330
+ const processFinalValue = (container, part, value, parent, basePath, parentPath) => {
290
331
  if (part.type === 'array') {
291
332
  // Tableau explicite avec []
292
333
  container[part.name] ??= [];
@@ -296,13 +337,18 @@ export const packFormToJSON = (form) => {
296
337
  container[part.name] = [container[part.name], value];
297
338
  }
298
339
  } else if (part.type === 'numeric') {
299
- // Indice numérique final
300
340
  const { index } = part;
301
- container = ensureArray(container, parent.key, parent.container);
302
- while (container.length <= index) {
303
- container.push(undefined);
341
+ const useArray = useArrayForNumericPath(parentPath);
342
+ if (useArray) {
343
+ container = ensureArray(container, parent.key, parent.container);
344
+ while (container.length <= index) {
345
+ container.push(undefined);
346
+ }
347
+ container[index] = value;
348
+ } else {
349
+ container = ensureObject(container, parent.key, parent.container, true);
350
+ container[index] = value;
304
351
  }
305
- container[index] = value;
306
352
  } else {
307
353
  // Clé simple finale
308
354
  const conflict = hasConflict(basePath);
@@ -348,16 +394,14 @@ export const packFormToJSON = (form) => {
348
394
  */
349
395
  const processIntermediatePart = (container, part, nextPart, parent, basePath, parts, i, key) => {
350
396
  if (part.type === 'numeric') {
351
- // Indice numérique : le container doit être un tableau ou un objet (selon conflit)
352
397
  const { index } = part;
353
- const conflict = hasConflict(basePath);
398
+ const parentPath = buildPathString(parts.slice(0, i));
399
+ const useArray = useArrayForNumericPath(parentPath) && !hasConflict(basePath);
354
400
 
355
- if (conflict) {
356
- // Conflit : utiliser un objet (les indices seront des propriétés)
401
+ if (!useArray) {
357
402
  container = ensureObject(container, parent.key, parent.container, true);
358
403
  container[index] ??= {};
359
404
  if (typeof container[index] !== 'object' || container[index] === null) {
360
- // Cette erreur ne devrait jamais se produire si la détection fonctionne correctement
361
405
  const pathParts = parts.slice(0, i + 1);
362
406
  const currentPath = buildPathString(pathParts);
363
407
  throw new Error(
@@ -367,14 +411,12 @@ export const packFormToJSON = (form) => {
367
411
  );
368
412
  }
369
413
  } else {
370
- // Pas de conflit : utiliser un tableau
371
414
  container = ensureArray(container, parent.key, parent.container);
372
415
  while (container.length <= index) {
373
416
  container.push(undefined);
374
417
  }
375
418
  container[index] ??= {};
376
419
  if (typeof container[index] !== 'object' || container[index] === null) {
377
- // Cette erreur ne devrait jamais se produire si la détection fonctionne correctement
378
420
  const pathParts = parts.slice(0, i + 1);
379
421
  const currentPath = buildPathString(pathParts);
380
422
  throw new Error(
@@ -459,8 +501,8 @@ export const packFormToJSON = (form) => {
459
501
  const parent = path[path.length - 1];
460
502
 
461
503
  if (isLast) {
462
- // Dernière partie : assigner la valeur
463
- processFinalValue(container, part, value, parent, basePath);
504
+ const parentPath = buildPathString(parts.slice(0, i));
505
+ processFinalValue(container, part, value, parent, basePath, parentPath);
464
506
  } else {
465
507
  // Partie intermédiaire : créer la structure
466
508
  const newContainer = processIntermediatePart(container, part, nextPart, parent, basePath, parts, i, key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appius-fr/apx",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
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",