stimeo-ui 0.14.0 → 0.16.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +81 -12
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +85 -17
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +46 -13
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +432 -71
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -20,6 +20,39 @@ function canTakeFocus(element) {
20
20
  return !inheritsFieldsetDisabled(element);
21
21
  }
22
22
 
23
+ // src/utils/microtask_coalescer.ts
24
+ var MicrotaskCoalescer = class {
25
+ #run;
26
+ #queued = false;
27
+ #active = false;
28
+ #generation = 0;
29
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
30
+ constructor(run) {
31
+ this.#run = run;
32
+ }
33
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
34
+ activate() {
35
+ this.#active = true;
36
+ }
37
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
38
+ cancel() {
39
+ this.#active = false;
40
+ this.#queued = false;
41
+ this.#generation += 1;
42
+ }
43
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
44
+ schedule() {
45
+ if (!this.#active || this.#queued) return;
46
+ this.#queued = true;
47
+ const generation = this.#generation;
48
+ queueMicrotask(() => {
49
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
50
+ this.#queued = false;
51
+ this.#run();
52
+ });
53
+ }
54
+ };
55
+
23
56
  // src/utils/before_cache_reset.ts
24
57
  var BeforeCacheReset = class _BeforeCacheReset {
25
58
  /** Every subscribed instance, iterated by the one shared document listener. */
@@ -81,53 +114,57 @@ var TabindexLoan = class {
81
114
  };
82
115
 
83
116
  // src/controllers/pagination_controller.ts
84
- var PaginationController = class _PaginationController extends Controller {
117
+ var PaginationController = class extends Controller {
85
118
  static targets = ["page", "prev", "next"];
86
119
  static values = {
87
120
  page: { type: Number, default: 1 },
88
121
  total: { type: Number, default: 1 }
89
122
  };
90
123
  static actions = ["next", "prev", "select"];
91
- static events = ["change"];
92
- /** Marks a `disabled` this controller applied at a boundary (ownership flag). */
93
- static #BOUNDARY_ATTR = "data-stimeo--pagination-boundary-disabled";
94
- #isConnected = false;
124
+ static events = ["change", "reconcile"];
125
+ /** Marks a `disabled` this controller applied at a boundary, in its own namespace. */
126
+ get #boundaryAttribute() {
127
+ return `data-${this.identifier}-boundary-disabled`;
128
+ }
95
129
  /** The `tabindex` this instance lends the root for the focus fallback. */
96
130
  #tabindex = new TabindexLoan();
97
- /** Normalizes out-of-range initial values and renders the initial state. */
131
+ /**
132
+ * Collapses the Value and target callbacks of one mutation into one pass, and
133
+ * refuses the ones Stimulus delivers before `connect()`, which renders itself.
134
+ */
135
+ #repaint = new MicrotaskCoalescer(() => this.#reconcilePage());
136
+ /** The page shown last, which the next move of the current page is measured from. */
137
+ #shown = 1;
138
+ /** Renders the initial state from the clamped `page` and `total`. */
98
139
  connect() {
99
- this.#isConnected = true;
100
- this.#normalizeAndRender();
140
+ this.#repaint.activate();
141
+ this.#shown = this.#page;
142
+ this.#render();
101
143
  }
102
- /** Reverts the one attribute the controller adds outside its state hooks. */
144
+ /** Drops a pending pass and reverts the one attribute added outside the state hooks. */
103
145
  disconnect() {
104
- this.#isConnected = false;
146
+ this.#repaint.cancel();
105
147
  this.#tabindex.returnAll();
106
148
  }
107
149
  /** Re-renders when application code (or a Turbo morph) changes `page` at runtime. */
108
150
  pageValueChanged() {
109
- if (!this.#isConnected) return;
110
- this.#normalizeAndRender();
151
+ this.#repaint.schedule();
111
152
  }
112
153
  /** Re-renders when application code (or a Turbo morph) changes `total` at runtime. */
113
154
  totalValueChanged() {
114
- if (!this.#isConnected) return;
115
- this.#normalizeAndRender();
155
+ this.#repaint.schedule();
116
156
  }
117
157
  /** Syncs a page button appended/replaced at runtime (the consumer owns the list). */
118
158
  pageTargetConnected() {
119
- if (!this.#isConnected) return;
120
- this.#render();
159
+ this.#repaint.schedule();
121
160
  }
122
161
  /** Syncs a `prev` button appended/replaced at runtime. */
123
162
  prevTargetConnected() {
124
- if (!this.#isConnected) return;
125
- this.#render();
163
+ this.#repaint.schedule();
126
164
  }
127
165
  /** Syncs a `next` button appended/replaced at runtime. */
128
166
  nextTargetConnected() {
129
- if (!this.#isConnected) return;
130
- this.#render();
167
+ this.#repaint.schedule();
131
168
  }
132
169
  /** Makes the clicked page button (its `data-page`) current. */
133
170
  select(event) {
@@ -153,22 +190,31 @@ var PaginationController = class _PaginationController extends Controller {
153
190
  const target = this.#clamp(page);
154
191
  if (target === previous) return;
155
192
  this.pageValue = target;
193
+ this.#shown = target;
156
194
  this.#render();
157
195
  this.dispatch("change", {
158
196
  detail: { page: target, total: this.#total, previous }
159
197
  });
160
198
  }
161
- /** Writes the normalized Values back (only when they differ) and renders. */
162
- #normalizeAndRender() {
163
- const total = this.#total;
164
- if (!Object.is(total, this.totalValue)) this.totalValue = total;
199
+ /**
200
+ * Renders one settled batch of Value and target changes, and reports a current
201
+ * page that moved from the one shown before as `reconcile`.
202
+ */
203
+ #reconcilePage() {
204
+ const previous = this.#shown;
165
205
  const page = this.#page;
166
- if (!Object.is(page, this.pageValue)) this.pageValue = page;
206
+ this.#shown = page;
167
207
  this.#render();
208
+ if (page !== previous) {
209
+ this.dispatch("reconcile", { detail: { page, total: this.#total, previous } });
210
+ }
168
211
  }
169
212
  /**
170
213
  * Syncs `aria-current` on the page buttons and the prev/next `disabled` state.
171
214
  *
215
+ * It reads `page` and `total` through their clamps and never writes either Value,
216
+ * so a declaration outside the range stays in the attributes as the page wrote it.
217
+ *
172
218
  * @stimeoRenderRoot
173
219
  */
174
220
  #render() {
@@ -201,7 +247,7 @@ var PaginationController = class _PaginationController extends Controller {
201
247
  #release(button, atBoundary) {
202
248
  if (!button || atBoundary || !this.#owns(button)) return;
203
249
  button.disabled = false;
204
- button.removeAttribute(_PaginationController.#BOUNDARY_ATTR);
250
+ button.removeAttribute(this.#boundaryAttribute);
205
251
  }
206
252
  /**
207
253
  * Disables a boundary button, first moving focus off it when it is the active
@@ -212,7 +258,7 @@ var PaginationController = class _PaginationController extends Controller {
212
258
  if (button.disabled && !this.#owns(button)) return;
213
259
  if (button === document.activeElement) this.#moveFocusAwayFrom(opposite);
214
260
  button.disabled = true;
215
- button.setAttribute(_PaginationController.#BOUNDARY_ATTR, "");
261
+ button.setAttribute(this.#boundaryAttribute, "");
216
262
  }
217
263
  /**
218
264
  * Moves focus to `opposite` (already resolved to `null` when it will stay
@@ -249,7 +295,7 @@ var PaginationController = class _PaginationController extends Controller {
249
295
  }
250
296
  /** Whether the button's current `disabled` was applied by boundary control. */
251
297
  #owns(button) {
252
- return button.hasAttribute(_PaginationController.#BOUNDARY_ATTR);
298
+ return button.hasAttribute(this.#boundaryAttribute);
253
299
  }
254
300
  };
255
301
 
@@ -84,10 +84,51 @@ var SafeTimeout = class extends TimerRegistry {
84
84
  }
85
85
  };
86
86
 
87
+ // src/utils/state_regions.ts
88
+ var StateRegions = class {
89
+ #whenTrue;
90
+ #whenFalse;
91
+ #taken = /* @__PURE__ */ new WeakSet();
92
+ constructor(options) {
93
+ this.#whenTrue = options.whenTrue;
94
+ this.#whenFalse = options.whenFalse ?? null;
95
+ }
96
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
97
+ reflect(host, isTrue) {
98
+ const shown = this.#inside(host, this.#whenTrue());
99
+ if (!this.#whenFalse) {
100
+ for (const region of shown) this.#write(region, !isTrue);
101
+ return;
102
+ }
103
+ const hiddenSide = this.#inside(host, this.#whenFalse());
104
+ if (shown.length === 0 || hiddenSide.length === 0) {
105
+ for (const region of shown) this.#give(region);
106
+ for (const region of hiddenSide) this.#give(region);
107
+ return;
108
+ }
109
+ for (const region of shown) this.#write(region, !isTrue);
110
+ for (const region of hiddenSide) this.#write(region, isTrue);
111
+ }
112
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
113
+ #write(region, hidden) {
114
+ if (hidden) this.#taken.add(region);
115
+ else this.#taken.delete(region);
116
+ if (region.hidden !== hidden) region.hidden = hidden;
117
+ }
118
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
119
+ #give(region) {
120
+ if (this.#taken.delete(region)) region.hidden = false;
121
+ }
122
+ /** The declared regions that sit within `host`, which may be the host itself. */
123
+ #inside(host, regions) {
124
+ return regions.filter((region) => host.contains(region));
125
+ }
126
+ };
127
+
87
128
  // src/controllers/password_reveal_controller.ts
88
129
  var MAX_DELAY = 2 ** 31 - 1;
89
130
  var PasswordRevealController = class extends Controller {
90
- static targets = ["input", "toggle"];
131
+ static targets = ["input", "toggle", "onLabel", "offLabel"];
91
132
  static values = {
92
133
  autoHide: { type: Number, default: 0 }
93
134
  };
@@ -105,6 +146,11 @@ var PasswordRevealController = class extends Controller {
105
146
  * document does not answer this — unloading the controller leaves it there.
106
147
  */
107
148
  #connected = false;
149
+ /** Owns `hidden` on the label pair the toggle button carries. */
150
+ #labels = new StateRegions({
151
+ whenTrue: () => this.onLabelTargets,
152
+ whenFalse: () => this.offLabelTargets
153
+ });
108
154
  /** Masks the field before Turbo copies the page into its snapshot. */
109
155
  #beforeCache = new BeforeCacheReset(() => this.#rewindToMasked());
110
156
  connect() {
@@ -144,6 +190,14 @@ var PasswordRevealController = class extends Controller {
144
190
  toggleTargetConnected() {
145
191
  this.#reflect(this.#isVisible);
146
192
  }
193
+ /** Describes the state to a revealed-side label that arrives after connect. */
194
+ onLabelTargetConnected() {
195
+ this.#reflect(this.#isVisible);
196
+ }
197
+ /** Describes the state to a masked-side label that arrives after connect. */
198
+ offLabelTargetConnected() {
199
+ this.#reflect(this.#isVisible);
200
+ }
147
201
  /** Toggles the input between masked and revealed. Bound via `data-action`. */
148
202
  toggle() {
149
203
  this.#setVisible(!this.#isVisible);
@@ -187,6 +241,8 @@ var PasswordRevealController = class extends Controller {
187
241
  * the opposite of what the declaration asked for. A value that is no delay at
188
242
  * all stays out of the positive range {@link PasswordRevealController.#arm}
189
243
  * requires, so it schedules nothing.
244
+ *
245
+ * @stimeoRuntimeOnly `autoHide` is the delay of the one re-masking timer it arms.
190
246
  */
191
247
  get #autoHideDelay() {
192
248
  return Math.min(this.autoHideValue, MAX_DELAY);
@@ -202,10 +258,11 @@ var PasswordRevealController = class extends Controller {
202
258
  this.inputTarget.type = "password";
203
259
  this.#reflect(false);
204
260
  }
205
- /** Reflects the visible state onto `aria-pressed` and `data-state`. */
261
+ /** Reflects the visible state onto `aria-pressed`, `data-state` and the labels. */
206
262
  #reflect(visible) {
207
263
  if (this.hasToggleTarget) {
208
264
  this.toggleTarget.setAttribute("aria-pressed", visible ? "true" : "false");
265
+ this.#labels.reflect(this.toggleTarget, visible);
209
266
  }
210
267
  this.element.setAttribute("data-state", visible ? "visible" : "hidden");
211
268
  }
@@ -127,7 +127,20 @@ function parseStringList(raw, fallback = []) {
127
127
  return parsed.filter((entry) => typeof entry === "string");
128
128
  }
129
129
 
130
+ // src/utils/transient_hooks.ts
131
+ var TransientHooks = class {
132
+ #attributes;
133
+ constructor(options) {
134
+ this.#attributes = options.attributes;
135
+ }
136
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
137
+ reset(element) {
138
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
139
+ }
140
+ };
141
+
130
142
  // src/controllers/persist_controller.ts
143
+ var TRANSIENT = new TransientHooks({ attributes: ["data-persist-restored"] });
131
144
  var NON_VALUE_TYPES = /* @__PURE__ */ new Set(["file", "submit", "reset", "button", "image"]);
132
145
  var SENSITIVE_TYPES = /* @__PURE__ */ new Set(["password"]);
133
146
  var DEFAULT_EXCLUDE = ["authenticity_token", "_method", "utf8"];
@@ -197,7 +210,6 @@ var PersistController = class extends Controller {
197
210
  this.#excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
198
211
  this.#knownFields = /* @__PURE__ */ new WeakSet();
199
212
  this.#forcedRestore.clear();
200
- this.element.removeAttribute("data-persist-restored");
201
213
  this.element.addEventListener("input", this.#onInput);
202
214
  this.element.addEventListener("change", this.#onInput);
203
215
  this.#syncClearOn();
@@ -207,7 +219,7 @@ var PersistController = class extends Controller {
207
219
  attributeFilter: [
208
220
  "checked",
209
221
  "data-controller",
210
- "data-stimeo--persist-target",
222
+ `data-${this.identifier}-target`,
211
223
  "id",
212
224
  "multiple",
213
225
  "name",
@@ -230,7 +242,7 @@ var PersistController = class extends Controller {
230
242
  this.#restoreDynamic.cancel();
231
243
  this.#forcedRestore.clear();
232
244
  this.#timeouts.clearAll();
233
- this.element.removeAttribute("data-persist-restored");
245
+ TRANSIENT.reset(this.element);
234
246
  this.#payload = null;
235
247
  }
236
248
  /** Drops the active draft after storage confirms the removal. */
@@ -329,7 +341,7 @@ var PersistController = class extends Controller {
329
341
  }
330
342
  /** Reads, validates, and restores the active namespace. */
331
343
  #loadActiveDraft() {
332
- this.element.removeAttribute("data-persist-restored");
344
+ TRANSIENT.reset(this.element);
333
345
  this.#payload = null;
334
346
  this.#knownFields = /* @__PURE__ */ new WeakSet();
335
347
  this.#forcedRestore.clear();
@@ -481,7 +493,7 @@ var PersistController = class extends Controller {
481
493
  }
482
494
  /** Whether this instance, rather than a nested Persist host, owns a field. */
483
495
  #ownsField(field) {
484
- return field.closest('[data-controller~="stimeo--persist"]') === this.element;
496
+ return field.closest(`[data-controller~="${this.identifier}"]`) === this.element;
485
497
  }
486
498
  /** Marks all currently eligible fields without applying a payload. */
487
499
  #markCurrentFieldsKnown() {
@@ -515,7 +527,12 @@ var PersistController = class extends Controller {
515
527
  const name = value.trim();
516
528
  return name.length > 0 && !/\s/.test(name) ? name : null;
517
529
  }
518
- /** Collects dynamic controls and select-option changes into one restore pass. */
530
+ /**
531
+ * Collects dynamic controls and select-option changes into one restore pass.
532
+ *
533
+ * @stimeoRuntimeOnly `key` decides whether an id change moves the storage namespace; it decides
534
+ * nothing shown.
535
+ */
519
536
  #onMutations(records) {
520
537
  let rootIdChanged = false;
521
538
  for (const record of records) {
@@ -563,7 +580,12 @@ var PersistController = class extends Controller {
563
580
  this.#forcedRestore.add(field);
564
581
  }
565
582
  }
566
- /** Resolves the logical key from the Value, falling back to the host id. */
583
+ /**
584
+ * Resolves the logical key from the Value, falling back to the host id.
585
+ *
586
+ * @stimeoRuntimeOnly `key` names the storage namespace; the restored fields come from what is
587
+ * stored there.
588
+ */
567
589
  #resolveLogicalKey() {
568
590
  const key = this.keyValue || this.element.id;
569
591
  return key.length > 0 ? key : null;
@@ -572,7 +594,7 @@ var PersistController = class extends Controller {
572
594
  #storageKey(logicalKey) {
573
595
  return `${STORAGE_PREFIX}${logicalKey}`;
574
596
  }
575
- /** Normalizes invalid debounce Values to the documented default. */
597
+ /** Normalizes an invalid `debounce` Value to the `debounce` default. */
576
598
  get #debounceDelay() {
577
599
  const value = this.debounceValue;
578
600
  return Number.isFinite(value) && value >= 0 ? value : DEFAULT_DEBOUNCE;
@@ -72,7 +72,66 @@ function ownerOf(candidates, node) {
72
72
  return candidates[ownerIndex(candidates, node)] ?? null;
73
73
  }
74
74
 
75
+ // src/utils/owned_pointer_session.ts
76
+ var OwnedPointerSession = class {
77
+ #pointerId;
78
+ #owner;
79
+ #handlers;
80
+ #abort = new AbortController();
81
+ #active = true;
82
+ constructor(start, owner, handlers) {
83
+ this.#pointerId = start.pointerId;
84
+ this.#owner = owner;
85
+ this.#handlers = handlers;
86
+ const { signal } = this.#abort;
87
+ owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
88
+ owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
89
+ owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
90
+ try {
91
+ owner.setPointerCapture?.(this.#pointerId);
92
+ } catch {
93
+ }
94
+ }
95
+ /** Releases capture and listeners, then reports the end exactly once. */
96
+ end() {
97
+ this.#finish("teardown");
98
+ }
99
+ #finish(kind) {
100
+ if (!this.#active) return;
101
+ this.#active = false;
102
+ this.#abort.abort();
103
+ try {
104
+ this.#owner.releasePointerCapture?.(this.#pointerId);
105
+ } catch {
106
+ }
107
+ this.#handlers.end?.(kind);
108
+ }
109
+ #owns(event) {
110
+ return this.#active && event.pointerId === this.#pointerId;
111
+ }
112
+ #onMove = (event) => {
113
+ if (this.#owns(event)) this.#handlers.move(event);
114
+ };
115
+ #onEndEvent = (event) => {
116
+ if (this.#owns(event)) this.#finish(event.type === "pointerup" ? "up" : "cancel");
117
+ };
118
+ };
119
+
120
+ // src/utils/transient_hooks.ts
121
+ var TransientHooks = class {
122
+ #attributes;
123
+ constructor(options) {
124
+ this.#attributes = options.attributes;
125
+ }
126
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
127
+ reset(element) {
128
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
129
+ }
130
+ };
131
+
75
132
  // src/controllers/pointer_drag_controller.ts
133
+ var TRANSIENT_DRAG = new TransientHooks({ attributes: ["data-dragging"] });
134
+ var TRANSIENT_GRAB = new TransientHooks({ attributes: ["data-grabbed"] });
76
135
  var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
77
136
  var PointerDragController = class _PointerDragController extends Controller {
78
137
  static targets = ["handle"];
@@ -93,14 +152,12 @@ var PointerDragController = class _PointerDragController extends Controller {
93
152
  #keyboard = null;
94
153
  /** Committed follow offset from past drops; a new drag's deltas add onto it. */
95
154
  #followBase = { x: 0, y: 0 };
96
- /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
97
- #dragAbort = null;
98
155
  /** Decides whether a mid-session disconnect() is an in-page move or a detach. */
99
156
  #gate = new DetachGate();
100
157
  connect() {
101
158
  this.#gate.cancel();
102
- if (!this.#pointer) this.element.removeAttribute("data-dragging");
103
- if (!this.#keyboard) this.element.removeAttribute("data-grabbed");
159
+ if (!this.#pointer) TRANSIENT_DRAG.reset(this.element);
160
+ if (!this.#keyboard) TRANSIENT_GRAB.reset(this.element);
104
161
  if (this.followValue && !this.#pointer && !this.#keyboard) {
105
162
  this.#followBase = this.#parseFollowBase();
106
163
  }
@@ -170,15 +227,19 @@ var PointerDragController = class _PointerDragController extends Controller {
170
227
  }
171
228
  this.#teardownSessions();
172
229
  }
173
- /** Arms a pointer drag on a handle; `start` waits for the threshold. */
230
+ /**
231
+ * Arms a pointer drag on a handle; `start` waits for the threshold.
232
+ *
233
+ * @stimeoRuntimeOnly `disabled` decides whether this one pointer press starts a gesture.
234
+ */
174
235
  #onPointerDown = (event) => {
175
236
  if (this.disabledValue || this.#pointer || this.#keyboard || event.button !== 0) return;
176
237
  const handle = this.#handleFor(event.target);
177
238
  if (!handle) return;
178
239
  event.preventDefault();
179
240
  handle.focus();
180
- this.#pointer = {
181
- pointerId: event.pointerId,
241
+ const session = {
242
+ pointer: null,
182
243
  handle,
183
244
  originX: event.clientX,
184
245
  originY: event.clientY,
@@ -187,17 +248,18 @@ var PointerDragController = class _PointerDragController extends Controller {
187
248
  dy: 0,
188
249
  pointerType: this.#pointerTypeOf(event)
189
250
  };
190
- handle.setPointerCapture(event.pointerId);
191
- this.#dragAbort?.abort();
192
- const abort = new AbortController();
193
- this.#dragAbort = abort;
194
- document.addEventListener("pointermove", this.#onPointerMove, { signal: abort.signal });
195
- document.addEventListener("pointerup", this.#onPointerUp, { signal: abort.signal });
196
- document.addEventListener("pointercancel", this.#onPointerCancel, { signal: abort.signal });
251
+ session.pointer = new OwnedPointerSession(event, handle, {
252
+ move: (move) => this.#trackMove(session, move),
253
+ end: (kind) => this.#finishPointer(session, kind)
254
+ });
255
+ this.#pointer = session;
197
256
  };
198
- #onPointerMove = (event) => {
199
- const session = this.#pointer;
200
- if (!session || event.pointerId !== session.pointerId) return;
257
+ /**
258
+ * Applies one tracked move: threshold, hooks, follow offset, events.
259
+ *
260
+ * @stimeoRuntimeOnly `threshold` decides when this one pointer move starts the drag.
261
+ */
262
+ #trackMove(session, event) {
201
263
  const [dx, dy] = this.#filterAxis(
202
264
  event.clientX - session.originX,
203
265
  event.clientY - session.originY
@@ -216,25 +278,32 @@ var PointerDragController = class _PointerDragController extends Controller {
216
278
  this.dispatch("move", {
217
279
  detail: { dx, dy, x: event.clientX, y: event.clientY, pointerType: session.pointerType }
218
280
  });
219
- };
220
- #onPointerUp = (event) => {
221
- const session = this.#pointer;
222
- if (!session || event.pointerId !== session.pointerId) return;
281
+ }
282
+ /**
283
+ * Closes the session and reports only the outcome the caller does not own.
284
+ *
285
+ * A teardown (detach, Escape, mid-drag disable, `reset`) is announced by the
286
+ * path that asked for it, so this one stays silent; `pointerup` below the
287
+ * threshold was a plain click, and `pointercancel` is an OS gesture takeover.
288
+ */
289
+ #finishPointer(session, kind) {
223
290
  const { started, dx, dy, pointerType } = session;
224
- this.#endPointerSession();
225
- if (!started) return;
226
- this.#followCommit(dx, dy);
227
- this.dispatch("end", { detail: { dx, dy, pointerType } });
228
- };
229
- /** OS gesture / scroll takeover interrupted the drag: cancel, don't drop. */
230
- #onPointerCancel = (event) => {
231
- const session = this.#pointer;
232
- if (!session || event.pointerId !== session.pointerId) return;
233
- const { started, pointerType } = session;
234
- this.#endPointerSession();
235
- if (started) this.#dispatchCancel(pointerType);
236
- };
237
- /** Keyboard alternative: Space/Enter grab & drop, arrows move, Escape cancels. */
291
+ this.#pointer = null;
292
+ this.element.removeAttribute("data-dragging");
293
+ if (kind === "teardown" || !started) return;
294
+ if (kind === "up") {
295
+ this.#followCommit(dx, dy);
296
+ this.dispatch("end", { detail: { dx, dy, pointerType } });
297
+ return;
298
+ }
299
+ this.#dispatchCancel(pointerType);
300
+ }
301
+ /**
302
+ * Keyboard alternative: Space/Enter grab & drop, arrows move, Escape cancels.
303
+ *
304
+ * @stimeoRuntimeOnly `disabled` decides whether this one key is taken and `keyboardStep` is the
305
+ * distance of one keyboard move.
306
+ */
238
307
  #onKeydown = (event) => {
239
308
  if (this.disabledValue) return;
240
309
  const handle = this.#handleFor(event.target);
@@ -316,26 +385,19 @@ var PointerDragController = class _PointerDragController extends Controller {
316
385
  return null;
317
386
  }
318
387
  }
319
- /** Zeroes the delta on the locked axis (`axis` = x | y | both). */
388
+ /**
389
+ * Zeroes the delta on the locked axis (`axis` = x | y | both).
390
+ *
391
+ * @stimeoRuntimeOnly `axis` filters the offset of one move to the allowed direction.
392
+ */
320
393
  #filterAxis(dx, dy) {
321
394
  if (this.axisValue === "x") return [dx, 0];
322
395
  if (this.axisValue === "y") return [0, dy];
323
396
  return [dx, dy];
324
397
  }
325
- /** Releases capture + listeners and clears the pointer session and its hook. */
398
+ /** Silently closes a live pointer session; the caller announces the outcome. */
326
399
  #endPointerSession() {
327
- this.#releasePointerCapture();
328
- this.#pointer = null;
329
- this.#dragAbort?.abort();
330
- this.#dragAbort = null;
331
- this.element.removeAttribute("data-dragging");
332
- }
333
- /** Releases the pointer capture the active session set (idempotent, safe). */
334
- #releasePointerCapture() {
335
- const session = this.#pointer;
336
- if (session?.handle.hasPointerCapture(session.pointerId)) {
337
- session.handle.releasePointerCapture(session.pointerId);
338
- }
400
+ this.#pointer?.pointer?.end();
339
401
  }
340
402
  #clearKeyboardSession() {
341
403
  this.#keyboard?.handle.removeAttribute("data-grabbed");
@@ -372,18 +434,33 @@ var PointerDragController = class _PointerDragController extends Controller {
372
434
  this.#followReset();
373
435
  this.dispatch("cancel", { detail: { pointerType } });
374
436
  }
375
- /** Applies the in-flight offset to the element's `translate` (follow only). */
437
+ /**
438
+ * Applies the in-flight offset to the element's `translate` (follow only).
439
+ *
440
+ * @stimeoRuntimeOnly `follow` decides whether this one move writes the offset as a translate; the
441
+ * offset itself comes from the gesture.
442
+ */
376
443
  #followMove(dx, dy) {
377
444
  if (!this.followValue) return;
378
445
  this.#applyFollow(this.#followBase.x + dx, this.#followBase.y + dy);
379
446
  }
380
- /** Folds a drop's deltas into the committed base offset (follow only). */
447
+ /**
448
+ * Folds a drop's deltas into the committed base offset (follow only).
449
+ *
450
+ * @stimeoRuntimeOnly `follow` decides whether the end of this one gesture keeps the offset as a
451
+ * translate.
452
+ */
381
453
  #followCommit(dx, dy) {
382
454
  if (!this.followValue) return;
383
455
  this.#followBase = { x: this.#followBase.x + dx, y: this.#followBase.y + dy };
384
456
  this.#applyFollow(this.#followBase.x, this.#followBase.y);
385
457
  }
386
- /** Snaps back to the committed position (follow only) — the cancel contract. */
458
+ /**
459
+ * Snaps back to the committed position (follow only) — the cancel contract.
460
+ *
461
+ * @stimeoRuntimeOnly `follow` decides whether cancelling this one gesture clears the translate it
462
+ * wrote.
463
+ */
387
464
  #followReset() {
388
465
  if (!this.followValue) return;
389
466
  this.#applyFollow(this.#followBase.x, this.#followBase.y);
@@ -438,6 +515,8 @@ var PointerDragController = class _PointerDragController extends Controller {
438
515
  * idempotent) and focusability for the keyboard path (`tabindex="0"` only when
439
516
  * the author supplied none — prefer a real `<button>` handle). Both additions
440
517
  * are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
518
+ *
519
+ * @stimeoRenderRoot
441
520
  */
442
521
  #prepareHandle(handle) {
443
522
  const lent = this.#touchActionForAxis();