stimeo-ui 0.4.0 → 0.6.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/dist/controllers/aspect_ratio_controller.js +19 -11
  4. data/dist/controllers/avatar_controller.js +195 -40
  5. data/dist/controllers/breadcrumb_controller.js +5 -1
  6. data/dist/controllers/carousel_controller.js +90 -10
  7. data/dist/controllers/checkbox_controller.js +136 -25
  8. data/dist/controllers/clipboard_controller.js +8 -3
  9. data/dist/controllers/collapsible_controller.js +4 -1
  10. data/dist/controllers/color_picker_controller.js +41 -11
  11. data/dist/controllers/context_menu_controller.js +2 -2
  12. data/dist/controllers/countdown_controller.js +5 -1
  13. data/dist/controllers/date_range_picker_controller.js +162 -31
  14. data/dist/controllers/direct_upload_controller.js +3 -3
  15. data/dist/controllers/empty_state_controller.js +107 -16
  16. data/dist/controllers/file_dropzone_controller.js +26 -3
  17. data/dist/controllers/flash_controller.js +161 -21
  18. data/dist/controllers/form_validation_controller.js +8 -2
  19. data/dist/controllers/frame_loading_controller.js +94 -22
  20. data/dist/controllers/highlight_controller.js +38 -1
  21. data/dist/controllers/idle_controller.js +39 -6
  22. data/dist/controllers/local_time_controller.js +2 -0
  23. data/dist/controllers/masonry_controller.js +1 -1
  24. data/dist/controllers/menubar_controller.js +5 -3
  25. data/dist/controllers/meter_controller.js +3 -1
  26. data/dist/controllers/multi_select_controller.js +460 -151
  27. data/dist/controllers/network_status_controller.js +1 -3
  28. data/dist/controllers/number_input_controller.js +455 -64
  29. data/dist/controllers/overflow_menu_controller.js +5 -1
  30. data/dist/controllers/pagination_controller.js +38 -1
  31. data/dist/controllers/password_strength_controller.js +21 -3
  32. data/dist/controllers/persist_controller.js +24 -5
  33. data/dist/controllers/pointer_drag_controller.js +10 -0
  34. data/dist/controllers/portal_controller.js +10 -0
  35. data/dist/controllers/progress_controller.js +7 -3
  36. data/dist/controllers/radio_group_controller.js +540 -56
  37. data/dist/controllers/range_slider_controller.js +385 -94
  38. data/dist/controllers/rating_controller.js +274 -89
  39. data/dist/controllers/relative_time_controller.js +2 -0
  40. data/dist/controllers/resizable_controller.js +33 -0
  41. data/dist/controllers/roving_controller.js +60 -5
  42. data/dist/controllers/scroll_area_controller.js +155 -23
  43. data/dist/controllers/scroll_visibility_controller.js +33 -0
  44. data/dist/controllers/separator_controller.js +13 -17
  45. data/dist/controllers/skeleton_controller.js +71 -3
  46. data/dist/controllers/slider_controller.js +325 -48
  47. data/dist/controllers/spinner_controller.js +18 -3
  48. data/dist/controllers/step_indicator_controller.js +3 -1
  49. data/dist/controllers/stepper_controller.js +2 -0
  50. data/dist/controllers/switch_controller.js +162 -18
  51. data/dist/controllers/tags_input_controller.js +356 -120
  52. data/dist/controllers/textarea_autosize_controller.js +1 -1
  53. data/dist/controllers/time_picker_controller.js +296 -104
  54. data/dist/controllers/toggle_group_controller.js +378 -55
  55. data/dist/controllers/toolbar_controller.js +5 -3
  56. data/dist/controllers/tree_view_controller.js +24 -4
  57. data/dist/index.js +3811 -1048
  58. data/lib/stimeo/ui/version.rb +1 -1
  59. metadata +2 -2
@@ -124,9 +124,7 @@ var NetworkStatusController = class extends Controller {
124
124
  #showOffline() {
125
125
  this.#timers.clearAll();
126
126
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
127
- if (this.hasOfflineTarget) {
128
- this.offlineTarget.hidden = false;
129
- }
127
+ if (this.hasOfflineTarget) this.offlineTarget.hidden = false;
130
128
  }
131
129
  /** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */
132
130
  #showOnline() {
@@ -8,6 +8,123 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/attribute_lease.ts
12
+ var AttributeLease = class {
13
+ #attribute;
14
+ #records = /* @__PURE__ */ new Map();
15
+ /** @param attribute - The attribute whose temporary values this lease owns. */
16
+ constructor(attribute) {
17
+ this.#attribute = attribute;
18
+ }
19
+ /** Writes or removes the leased attribute while preserving its authored value. */
20
+ write(element, value) {
21
+ const existing = this.#records.get(element);
22
+ if (existing) {
23
+ existing.written = value;
24
+ } else {
25
+ this.#records.set(element, {
26
+ original: element.getAttribute(this.#attribute),
27
+ written: value
28
+ });
29
+ }
30
+ if (value === null) element.removeAttribute(this.#attribute);
31
+ else element.setAttribute(this.#attribute, value);
32
+ }
33
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
34
+ return(element) {
35
+ const record = this.#records.get(element);
36
+ if (!record) return;
37
+ this.#records.delete(element);
38
+ if (element.getAttribute(this.#attribute) !== record.written) return;
39
+ if (record.original === null) element.removeAttribute(this.#attribute);
40
+ else element.setAttribute(this.#attribute, record.original);
41
+ }
42
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
43
+ returnAll() {
44
+ for (const element of Array.from(this.#records.keys())) this.return(element);
45
+ }
46
+ };
47
+
48
+ // src/utils/composition_tracker.ts
49
+ var CompositionTracker = class {
50
+ #observedTargets = /* @__PURE__ */ new Set();
51
+ #activeTargets = /* @__PURE__ */ new Set();
52
+ #onStart;
53
+ #onEnd;
54
+ constructor(options = {}) {
55
+ this.#onStart = options.onStart;
56
+ this.#onEnd = options.onEnd;
57
+ }
58
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
59
+ observe(target) {
60
+ if (this.#observedTargets.has(target)) return;
61
+ target.addEventListener("compositionstart", this.#handleStart);
62
+ target.addEventListener("compositionend", this.#handleEnd);
63
+ this.#observedTargets.add(target);
64
+ }
65
+ /** Stops tracking one target and clears any active composition it owned. */
66
+ unobserve(target) {
67
+ if (!this.#observedTargets.delete(target)) return;
68
+ target.removeEventListener("compositionstart", this.#handleStart);
69
+ target.removeEventListener("compositionend", this.#handleEnd);
70
+ this.#activeTargets.delete(target);
71
+ }
72
+ /** Releases every listener and clears state so reconnect starts cleanly. */
73
+ disconnect() {
74
+ for (const target of this.#observedTargets) {
75
+ target.removeEventListener("compositionstart", this.#handleStart);
76
+ target.removeEventListener("compositionend", this.#handleEnd);
77
+ }
78
+ this.#observedTargets.clear();
79
+ this.#activeTargets.clear();
80
+ }
81
+ /** True when lifecycle tracking or the current event reports composition. */
82
+ isComposing(event) {
83
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
84
+ }
85
+ #handleStart = (event) => {
86
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
87
+ this.#onStart?.(event);
88
+ };
89
+ #handleEnd = (event) => {
90
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
91
+ this.#onEnd?.(event);
92
+ };
93
+ };
94
+
95
+ // src/utils/microtask_coalescer.ts
96
+ var MicrotaskCoalescer = class {
97
+ #run;
98
+ #queued = false;
99
+ #active = false;
100
+ #generation = 0;
101
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
102
+ constructor(run) {
103
+ this.#run = run;
104
+ }
105
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
106
+ activate() {
107
+ this.#active = true;
108
+ }
109
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
110
+ cancel() {
111
+ this.#active = false;
112
+ this.#queued = false;
113
+ this.#generation += 1;
114
+ }
115
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
116
+ schedule() {
117
+ if (!this.#active || this.#queued) return;
118
+ this.#queued = true;
119
+ const generation = this.#generation;
120
+ queueMicrotask(() => {
121
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
122
+ this.#queued = false;
123
+ this.#run();
124
+ });
125
+ }
126
+ };
127
+
11
128
  // src/utils/safe_timeout.ts
12
129
  var TimerRegistry = class {
13
130
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -75,7 +192,114 @@ var SafeInterval = class extends TimerRegistry {
75
192
  }
76
193
  };
77
194
 
195
+ // src/utils/stepped_value.ts
196
+ function effectiveStep(step) {
197
+ return Number.isFinite(step) && step > 0 ? step : 1;
198
+ }
199
+ function snapSteppedValue(raw, range) {
200
+ if (!(range.min <= range.max)) return finiteFallback(range.min, range.max);
201
+ const input = Number.isNaN(raw) ? finiteFallback(range.min, range.max) : raw;
202
+ const clamped = Math.min(range.max, Math.max(range.min, input));
203
+ const step = effectiveStep(range.step);
204
+ const base = stepBase(range);
205
+ const candidates = [];
206
+ if (Number.isFinite(range.min)) candidates.push(range.min);
207
+ if (Number.isFinite(range.max)) candidates.push(range.max);
208
+ const gridPosition = (clamped - base) / step;
209
+ if (Number.isFinite(gridPosition)) {
210
+ addGridCandidate(candidates, Math.floor(gridPosition), range, base, step);
211
+ addGridCandidate(candidates, Math.ceil(gridPosition), range, base, step);
212
+ }
213
+ if (candidates.length === 0) return clamped;
214
+ let nearest = candidates[0];
215
+ let nearestDistance = Math.abs(clamped - nearest);
216
+ for (const candidate of candidates.slice(1)) {
217
+ const distance = Math.abs(clamped - candidate);
218
+ if (distance < nearestDistance || nearlyEqual(distance, nearestDistance) && candidate > nearest) {
219
+ nearest = candidate;
220
+ nearestDistance = distance;
221
+ }
222
+ }
223
+ return nearest;
224
+ }
225
+ function stepSteppedValue(current, count, range) {
226
+ const value = snapSteppedValue(current, range);
227
+ const distance = Math.abs(Math.trunc(count));
228
+ if (!Number.isFinite(distance) || distance === 0) return value;
229
+ const direction = Math.sign(count);
230
+ const adjacent = adjacentSteppedValue(value, direction, range);
231
+ const raw = adjacent + direction * (distance - 1) * effectiveStep(range.step);
232
+ return snapSteppedValue(raw, range);
233
+ }
234
+ function adjacentSteppedValue(current, direction, range) {
235
+ const step = effectiveStep(range.step);
236
+ const base = stepBase(range);
237
+ const candidates = [];
238
+ if (direction > 0) {
239
+ const position2 = (current - base) / step;
240
+ if (Number.isFinite(position2)) {
241
+ let gridIndex = Math.floor(position2) + 1;
242
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
243
+ if (candidate < current || nearlyEqual(candidate, current)) {
244
+ gridIndex += 1;
245
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
246
+ }
247
+ if (candidate > current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
248
+ candidates.push(clamp(candidate, range));
249
+ }
250
+ }
251
+ return clamp(Math.min(...candidates), range);
252
+ }
253
+ const position = (current - base) / step;
254
+ if (Number.isFinite(position)) {
255
+ let gridIndex = Math.ceil(position) - 1;
256
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
257
+ if (candidate > current || nearlyEqual(candidate, current)) {
258
+ gridIndex -= 1;
259
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
260
+ }
261
+ if (candidate < current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
262
+ candidates.push(clamp(candidate, range));
263
+ }
264
+ }
265
+ return clamp(Math.max(...candidates), range);
266
+ }
267
+ function addGridCandidate(candidates, index, range, base, step) {
268
+ const candidate = cleanGridValue(base + index * step, base, step);
269
+ if (within(candidate, range.min, range.max)) candidates.push(clamp(candidate, range));
270
+ }
271
+ function stepBase(range) {
272
+ if (range.base !== void 0 && Number.isFinite(range.base)) return range.base;
273
+ return Number.isFinite(range.min) ? range.min : 0;
274
+ }
275
+ function cleanGridValue(value, base, step) {
276
+ const precision = Math.max(decimalPlaces(base), decimalPlaces(step));
277
+ return precision <= 100 ? Number(value.toFixed(precision)) : value;
278
+ }
279
+ function decimalPlaces(value) {
280
+ const [coefficient = "", exponentText] = Math.abs(value).toString().toLowerCase().split("e");
281
+ const fractionLength = coefficient.split(".")[1]?.length ?? 0;
282
+ const exponent = exponentText === void 0 ? 0 : Number(exponentText);
283
+ return Math.max(0, fractionLength - exponent);
284
+ }
285
+ function within(value, min, max) {
286
+ return (value > min || nearlyEqual(value, min)) && (value < max || nearlyEqual(value, max));
287
+ }
288
+ function clamp(value, range) {
289
+ return Math.min(range.max, Math.max(range.min, value));
290
+ }
291
+ function nearlyEqual(left, right) {
292
+ const scale = Math.max(Math.abs(left), Math.abs(right));
293
+ return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) <= Number.EPSILON * scale;
294
+ }
295
+ function finiteFallback(min, max) {
296
+ if (Number.isFinite(min)) return min;
297
+ if (Number.isFinite(max)) return max;
298
+ return 0;
299
+ }
300
+
78
301
  // src/controllers/number_input_controller.ts
302
+ var OWNED_DISABLED = "data-number-input-disabled";
79
303
  var NumberInputController = class _NumberInputController extends Controller {
80
304
  static targets = ["input", "increment", "decrement"];
81
305
  static values = {
@@ -85,7 +309,7 @@ var NumberInputController = class _NumberInputController extends Controller {
85
309
  pageStep: { type: Number, default: 0 }
86
310
  };
87
311
  static actions = ["decrement", "increment", "onInput", "onKeydown"];
88
- static events = ["change"];
312
+ static events = ["change", "reconcile"];
89
313
  /** Delay (ms) a button must be held before auto-repeat starts. */
90
314
  static #HOLD_DELAY_MS = 400;
91
315
  /** Interval (ms) between auto-repeat steps once a hold has started. */
@@ -97,8 +321,18 @@ var NumberInputController = class _NumberInputController extends Controller {
97
321
  * of poisoning the next legitimate click.
98
322
  */
99
323
  static #SUPPRESS_RESET_MS = 250;
100
- /** Aborts the pointer listeners on disconnect so none outlive the element. */
101
- #guards = null;
324
+ /** Aborts global pointer guards on disconnect so none outlive the element. */
325
+ #globalGuards = null;
326
+ /** Per-target guards allow an old button to be released without touching its replacement. */
327
+ #buttonGuards = /* @__PURE__ */ new Map();
328
+ /** Tracks IME lifecycle on the current input, including confirming keys without a signal. */
329
+ #composition = new CompositionTracker();
330
+ /** Restores authored custom-spinbutton ARIA when a target leaves or the controller stops. */
331
+ #ariaValueNow = new AttributeLease("aria-valuenow");
332
+ #ariaValueMin = new AttributeLease("aria-valuemin");
333
+ #ariaValueMax = new AttributeLease("aria-valuemax");
334
+ /** Last reconciled or user-committed numeric value. */
335
+ #lastValue = null;
102
336
  /** Timers for the hold delay and the suppress-reset safety net. */
103
337
  #holdTimeouts = new SafeTimeout();
104
338
  /** The running auto-repeat interval (one at a time). */
@@ -109,66 +343,134 @@ var NumberInputController = class _NumberInputController extends Controller {
109
343
  #repeatedDuringHold = false;
110
344
  /** True when the next `click` is the trailing one after a hold and must be ignored. */
111
345
  #suppressNextClick = false;
346
+ /** Button and pointer that own the current hold, so other instances/pointers cannot end it. */
347
+ #holdButton = null;
348
+ #holdPointerId = null;
349
+ /** Collapses runtime range/step changes into one input reconciliation. */
350
+ #repaint = new MicrotaskCoalescer(() => this.#reconcile());
112
351
  /** Normalizes any initial value and wires the focus/hold pointer guards. */
113
352
  connect() {
114
- if (!this.hasInputTarget) return;
115
- if (this.inputTarget.value.trim() !== "") {
116
- this.#write(this.#normalize(this.#currentValue()));
117
- } else {
118
- this.#updateButtons(this.#currentValue());
119
- }
120
- this.#guards = new AbortController();
121
- const { signal } = this.#guards;
122
- if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, this.stepValue, signal);
123
- if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -this.stepValue, signal);
124
- window.addEventListener("pointerup", this.#stopHold, { signal });
125
- window.addEventListener("pointercancel", this.#stopHold, { signal });
126
- window.addEventListener("blur", this.#stopHold, { signal });
127
- }
128
- /** Releases the pointer guards and tears down every pending timer and hold state. */
353
+ this.#repaint.activate();
354
+ this.#globalGuards = new AbortController();
355
+ const { signal } = this.#globalGuards;
356
+ for (const button of this.incrementTargets) this.#wireButton(button, 1);
357
+ for (const button of this.decrementTargets) this.#wireButton(button, -1);
358
+ window.addEventListener("pointerup", this.#onPointerEnd, { signal });
359
+ window.addEventListener("pointercancel", this.#onPointerEnd, { signal });
360
+ window.addEventListener("blur", this.#onWindowBlur, { signal });
361
+ this.#reconcile();
362
+ }
363
+ /** Releases listeners, derived attributes, timers, and transient value state. */
129
364
  disconnect() {
130
- this.#guards?.abort();
131
- this.#guards = null;
132
- this.#holdActive = false;
365
+ this.#repaint.cancel();
366
+ this.#composition.disconnect();
367
+ this.#globalGuards?.abort();
368
+ this.#globalGuards = null;
369
+ const buttons = /* @__PURE__ */ new Set([
370
+ ...this.#buttonGuards.keys(),
371
+ ...this.incrementTargets,
372
+ ...this.decrementTargets
373
+ ]);
374
+ for (const guard of this.#buttonGuards.values()) guard.abort();
375
+ this.#buttonGuards.clear();
376
+ for (const button of buttons) this.#releaseButtonState(button);
377
+ this.#stopHold(false);
133
378
  this.#repeatedDuringHold = false;
134
379
  this.#suppressNextClick = false;
135
380
  this.#holdTimeouts.clearAll();
136
381
  this.#holdIntervals.clearAll();
382
+ this.#ariaValueNow.returnAll();
383
+ this.#ariaValueMin.returnAll();
384
+ this.#ariaValueMax.returnAll();
385
+ this.#lastValue = null;
386
+ }
387
+ /** Reconciles the value against a minimum changed by application code or a Turbo morph. */
388
+ minValueChanged() {
389
+ this.#repaint.schedule();
390
+ }
391
+ /** Reconciles the value against a maximum changed by application code or a Turbo morph. */
392
+ maxValueChanged() {
393
+ this.#repaint.schedule();
394
+ }
395
+ /** Reconciles the value against a step changed by application code or a Turbo morph. */
396
+ stepValueChanged() {
397
+ this.#repaint.schedule();
398
+ }
399
+ /** Tracks and reconciles an input inserted or replaced at runtime. */
400
+ inputTargetConnected(input) {
401
+ this.#composition.observe(input);
402
+ this.#repaint.schedule();
403
+ }
404
+ /** Releases ARIA and composition state owned by an input that left the controller. */
405
+ inputTargetDisconnected(input) {
406
+ this.#composition.unobserve(input);
407
+ this.#releaseInputAria(input);
408
+ this.#stopHold(false);
409
+ this.#lastValue = null;
410
+ this.#repaint.schedule();
411
+ }
412
+ /** Wires focus preservation and hold behavior on a runtime increment target. */
413
+ incrementTargetConnected(button) {
414
+ this.#wireButton(button, 1);
415
+ this.#repaint.schedule();
416
+ }
417
+ /** Releases only the increment target that actually disconnected. */
418
+ incrementTargetDisconnected(button) {
419
+ this.#unwireButton(button);
420
+ this.#repaint.schedule();
421
+ }
422
+ /** Wires focus preservation and hold behavior on a runtime decrement target. */
423
+ decrementTargetConnected(button) {
424
+ this.#wireButton(button, -1);
425
+ this.#repaint.schedule();
426
+ }
427
+ /** Releases only the decrement target that actually disconnected. */
428
+ decrementTargetDisconnected(button) {
429
+ this.#unwireButton(button);
430
+ this.#repaint.schedule();
137
431
  }
138
432
  /** Increases by one step. Bound via `data-action` (click). */
139
433
  increment() {
140
434
  if (this.#consumeSuppressedClick()) return;
141
- this.#commit(this.#currentValue() + this.stepValue);
435
+ if (!this.hasInputTarget) return;
436
+ this.#commitStep(1);
142
437
  this.inputTarget.focus();
143
438
  }
144
439
  /** Decreases by one step. Bound via `data-action` (click). */
145
440
  decrement() {
146
441
  if (this.#consumeSuppressedClick()) return;
147
- this.#commit(this.#currentValue() - this.stepValue);
442
+ if (!this.hasInputTarget) return;
443
+ this.#commitStep(-1);
148
444
  this.inputTarget.focus();
149
445
  }
150
446
  /** Clamps and snaps a typed value. Bound via `data-action` (change). */
151
447
  onInput() {
152
- if (this.inputTarget.value.trim() === "") return;
448
+ if (!this.hasInputTarget) return;
449
+ if (this.inputTarget.value.trim() === "") {
450
+ this.#lastValue = null;
451
+ this.#reflectEmpty();
452
+ return;
453
+ }
153
454
  this.#commit(this.#currentValue());
154
455
  }
155
456
  /** Keyboard stepping per the APG spinbutton model. */
156
457
  onKeydown(event) {
458
+ if (!this.hasInputTarget) return;
459
+ if (this.#composition.isComposing(event)) return;
157
460
  if (isReservedArrowChord(event)) return;
158
- const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
159
461
  let next = null;
160
462
  switch (event.key) {
161
463
  case "ArrowUp":
162
- next = this.#currentValue() + this.stepValue;
464
+ next = stepSteppedValue(this.#currentValue(), 1, this.#steppedRange);
163
465
  break;
164
466
  case "ArrowDown":
165
- next = this.#currentValue() - this.stepValue;
467
+ next = stepSteppedValue(this.#currentValue(), -1, this.#steppedRange);
166
468
  break;
167
469
  case "PageUp":
168
- next = this.#currentValue() + page;
470
+ next = this.pageStepValue > 0 ? this.#currentValue() + this.pageStepValue : stepSteppedValue(this.#currentValue(), 10, this.#steppedRange);
169
471
  break;
170
472
  case "PageDown":
171
- next = this.#currentValue() - page;
473
+ next = this.pageStepValue > 0 ? this.#currentValue() - this.pageStepValue : stepSteppedValue(this.#currentValue(), -10, this.#steppedRange);
172
474
  break;
173
475
  case "Home":
174
476
  if (!Number.isFinite(this.minValue)) return;
@@ -189,31 +491,49 @@ var NumberInputController = class _NumberInputController extends Controller {
189
491
  * hold; leaving the button while held stops it (the global listeners cover
190
492
  * release/cancel/blur).
191
493
  */
192
- #wireButton(button, delta, signal) {
193
- button.addEventListener("pointerdown", (event) => this.#armHold(event, button, delta), {
194
- signal
494
+ #wireButton(button, direction) {
495
+ if (this.#buttonGuards.has(button)) return;
496
+ const guard = new AbortController();
497
+ this.#buttonGuards.set(button, guard);
498
+ button.addEventListener("pointerdown", (event) => this.#armHold(event, button, direction), {
499
+ signal: guard.signal
195
500
  });
196
- button.addEventListener("pointerleave", this.#stopHold, { signal });
501
+ button.addEventListener(
502
+ "pointerleave",
503
+ (event) => {
504
+ if (this.#holdButton === button && this.#ownsPointer(event)) this.#stopHold();
505
+ },
506
+ { signal: guard.signal }
507
+ );
508
+ }
509
+ /** Aborts one target's listeners and returns any state derived onto that button. */
510
+ #unwireButton(button) {
511
+ this.#buttonGuards.get(button)?.abort();
512
+ this.#buttonGuards.delete(button);
513
+ if (this.#holdButton === button) this.#stopHold(false);
514
+ this.#releaseButtonState(button);
197
515
  }
198
516
  /** Starts a hold: focus the input, then schedule the first repeat after a delay. */
199
- #armHold(event, button, delta) {
517
+ #armHold(event, button, direction) {
200
518
  const pointerButton = event.button;
201
519
  if (typeof pointerButton === "number" && pointerButton !== 0) return;
202
- if (button.disabled) return;
520
+ if (button.disabled || !this.hasInputTarget) return;
203
521
  event.preventDefault();
204
522
  this.inputTarget.focus();
205
- this.#stopHold();
523
+ this.#stopHold(false);
206
524
  this.#holdActive = true;
525
+ this.#holdButton = button;
526
+ this.#holdPointerId = this.#pointerId(event);
207
527
  this.#repeatedDuringHold = false;
208
528
  this.#suppressNextClick = false;
209
529
  this.#holdTimeouts.set(() => {
210
- if (!this.#commit(this.#currentValue() + delta)) {
530
+ if (!this.#commitStep(direction)) {
211
531
  this.#stopHold();
212
532
  return;
213
533
  }
214
534
  this.#repeatedDuringHold = true;
215
535
  this.#holdIntervals.set(() => {
216
- if (!this.#commit(this.#currentValue() + delta)) this.#stopHold();
536
+ if (!this.#commitStep(direction)) this.#stopHold();
217
537
  }, _NumberInputController.#HOLD_REPEAT_MS);
218
538
  }, _NumberInputController.#HOLD_DELAY_MS);
219
539
  }
@@ -222,18 +542,39 @@ var NumberInputController = class _NumberInputController extends Controller {
222
542
  * trailing synthetic `click` must be ignored, so it is suppressed until the
223
543
  * click consumes it or a short safety-net timeout clears it.
224
544
  */
225
- #stopHold = () => {
545
+ #stopHold(suppressTrailingClick = true) {
226
546
  if (!this.#holdActive) return;
227
547
  this.#holdActive = false;
548
+ this.#holdButton = null;
549
+ this.#holdPointerId = null;
228
550
  this.#holdTimeouts.clearAll();
229
551
  this.#holdIntervals.clearAll();
230
- if (this.#repeatedDuringHold) {
552
+ if (suppressTrailingClick && this.#repeatedDuringHold) {
231
553
  this.#suppressNextClick = true;
232
554
  this.#holdTimeouts.set(() => {
233
555
  this.#suppressNextClick = false;
234
556
  }, _NumberInputController.#SUPPRESS_RESET_MS);
557
+ } else if (!suppressTrailingClick) {
558
+ this.#suppressNextClick = false;
235
559
  }
560
+ }
561
+ /** Ends a hold only for its initiating pointer (legacy/synthetic events remain accepted). */
562
+ #onPointerEnd = (event) => {
563
+ if (this.#ownsPointer(event)) this.#stopHold();
236
564
  };
565
+ /** Window deactivation is global and always ends the active hold. */
566
+ #onWindowBlur = () => {
567
+ this.#stopHold();
568
+ };
569
+ /** Extracts pointer identity when the runtime event exposes it. */
570
+ #pointerId(event) {
571
+ return event.pointerId ?? null;
572
+ }
573
+ /** Whether `event` belongs to the active pointer, with a synthetic-event fallback. */
574
+ #ownsPointer(event) {
575
+ const pointerId = this.#pointerId(event);
576
+ return this.#holdActive && (this.#holdPointerId === null || pointerId === null || pointerId === this.#holdPointerId);
577
+ }
237
578
  /** Consumes a pending trailing-click suppression; returns true if the click was swallowed. */
238
579
  #consumeSuppressedClick() {
239
580
  if (!this.#suppressNextClick) return false;
@@ -242,36 +583,80 @@ var NumberInputController = class _NumberInputController extends Controller {
242
583
  return true;
243
584
  }
244
585
  /**
245
- * Normalizes `raw`, reflects it, and dispatches `change` only when the
246
- * displayed value actually changes.
586
+ * Normalizes `raw`, reflects it, and dispatches `{ value: number }` only when
587
+ * the committed numeric value differs from the last reconciled value.
247
588
  *
248
589
  * @returns Whether the value changed (drives the auto-repeat's bound stop).
249
590
  */
250
591
  #commit(raw) {
251
592
  const value = this.#normalize(raw);
252
- const changed = this.inputTarget.value !== String(value);
593
+ const changed = this.#lastValue === null || value !== this.#lastValue;
253
594
  this.#write(value);
595
+ this.#lastValue = value;
254
596
  if (changed) this.dispatch("change", { detail: { value } });
255
597
  return changed;
256
598
  }
257
- /** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
258
- #write(value) {
259
- this.inputTarget.value = String(value);
260
- if (this.inputTarget.getAttribute("role") === "spinbutton") {
261
- this.inputTarget.setAttribute("aria-valuenow", String(value));
262
- if (Number.isFinite(this.minValue)) {
263
- this.inputTarget.setAttribute("aria-valuemin", String(this.minValue));
264
- }
265
- if (Number.isFinite(this.maxValue)) {
266
- this.inputTarget.setAttribute("aria-valuemax", String(this.maxValue));
599
+ /** Commits an adjacent endpoint/grid value and reports whether it moved. */
600
+ #commitStep(count) {
601
+ if (!this.hasInputTarget) return false;
602
+ return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange));
603
+ }
604
+ /**
605
+ * Reflects the current value after a range or step morph, reporting a moved
606
+ * value as `reconcile`.
607
+ *
608
+ * @stimeoRenderRoot
609
+ */
610
+ #reconcile() {
611
+ if (!this.hasInputTarget) {
612
+ this.#lastValue = null;
613
+ return;
614
+ }
615
+ if (this.inputTarget.value.trim() !== "") {
616
+ const previous = this.#lastValue;
617
+ const value = this.#normalize(this.#currentValue());
618
+ this.#write(value);
619
+ this.#lastValue = value;
620
+ if (previous !== null && value !== previous) {
621
+ this.dispatch("reconcile", { detail: { value } });
267
622
  }
623
+ } else {
624
+ this.#lastValue = null;
625
+ this.#reflectEmpty();
268
626
  }
627
+ }
628
+ /** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
629
+ #write(value) {
630
+ const input = this.inputTarget;
631
+ input.value = String(value);
632
+ this.#syncInputAria(input, value);
269
633
  this.#updateButtons(value);
270
634
  }
635
+ /** Reflects a blank value without inventing a numeric `aria-valuenow`. */
636
+ #reflectEmpty() {
637
+ this.#syncInputAria(this.inputTarget, null);
638
+ this.#updateButtons(this.#currentValue());
639
+ }
640
+ /** Synchronizes controller-derived ARIA while preserving authored teardown values. */
641
+ #syncInputAria(input, value) {
642
+ if (input.getAttribute("role") !== "spinbutton") {
643
+ this.#releaseInputAria(input);
644
+ return;
645
+ }
646
+ this.#ariaValueNow.write(input, value === null ? null : String(value));
647
+ this.#ariaValueMin.write(input, Number.isFinite(this.minValue) ? String(this.minValue) : null);
648
+ this.#ariaValueMax.write(input, Number.isFinite(this.maxValue) ? String(this.maxValue) : null);
649
+ }
650
+ /** Returns all custom-spinbutton ARIA leased on `input`. */
651
+ #releaseInputAria(input) {
652
+ this.#ariaValueNow.return(input);
653
+ this.#ariaValueMin.return(input);
654
+ this.#ariaValueMax.return(input);
655
+ }
271
656
  /** Disables a step button at its bound, returning focus to the input first. */
272
657
  #updateButtons(value) {
273
- if (this.hasIncrementTarget) this.#toggleButton(this.incrementTarget, value < this.maxValue);
274
- if (this.hasDecrementTarget) this.#toggleButton(this.decrementTarget, value > this.minValue);
658
+ for (const button of this.incrementTargets) this.#toggleButton(button, value < this.maxValue);
659
+ for (const button of this.decrementTargets) this.#toggleButton(button, value > this.minValue);
275
660
  }
276
661
  /**
277
662
  * Enables or disables a step button at its bound, never disabling one while it
@@ -281,16 +666,22 @@ var NumberInputController = class _NumberInputController extends Controller {
281
666
  */
282
667
  #toggleButton(button, enabled) {
283
668
  if (enabled) {
284
- if (button.hasAttribute("data-number-input-disabled")) {
669
+ if (button.hasAttribute(OWNED_DISABLED)) {
285
670
  button.disabled = false;
286
- button.removeAttribute("data-number-input-disabled");
671
+ button.removeAttribute(OWNED_DISABLED);
287
672
  }
288
673
  return;
289
674
  }
290
675
  if (button.disabled) return;
291
- if (document.activeElement === button) this.inputTarget.focus();
676
+ if (document.activeElement === button && this.hasInputTarget) this.inputTarget.focus();
292
677
  button.disabled = true;
293
- button.setAttribute("data-number-input-disabled", "");
678
+ button.setAttribute(OWNED_DISABLED, "");
679
+ }
680
+ /** Returns `disabled` only when its explicit marker still identifies controller ownership. */
681
+ #releaseButtonState(button) {
682
+ if (!button.hasAttribute(OWNED_DISABLED)) return;
683
+ button.disabled = false;
684
+ button.removeAttribute(OWNED_DISABLED);
294
685
  }
295
686
  /** The current numeric value, falling back to a finite min (else 0) when blank. */
296
687
  #currentValue() {
@@ -300,11 +691,11 @@ var NumberInputController = class _NumberInputController extends Controller {
300
691
  }
301
692
  /** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */
302
693
  #normalize(raw) {
303
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
304
- if (this.stepValue <= 0) return clamped;
305
- const base = Number.isFinite(this.minValue) ? this.minValue : 0;
306
- const stepped = Math.round((clamped - base) / this.stepValue) * this.stepValue + base;
307
- return Math.min(this.maxValue, Math.max(this.minValue, stepped));
694
+ return snapSteppedValue(raw, this.#steppedRange);
695
+ }
696
+ /** Shared range configuration; finite endpoints remain allowed off the grid. */
697
+ get #steppedRange() {
698
+ return { min: this.minValue, max: this.maxValue, step: this.stepValue };
308
699
  }
309
700
  };
310
701