stimeo-ui 0.2.0 → 0.3.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/breadcrumb_controller.js +225 -13
  6. data/dist/controllers/calendar_controller.js +89 -22
  7. data/dist/controllers/carousel_controller.js +313 -0
  8. data/dist/controllers/clipboard_controller.js +144 -0
  9. data/dist/controllers/collapsible_controller.js +327 -0
  10. data/dist/controllers/color_picker_controller.js +252 -0
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +194 -17
  13. data/dist/controllers/context_menu_controller.js +32 -10
  14. data/dist/controllers/count_up_controller.js +8 -1
  15. data/dist/controllers/currency_input_controller.js +147 -0
  16. data/dist/controllers/data_grid_controller.js +246 -0
  17. data/dist/controllers/date_range_picker_controller.js +441 -0
  18. data/dist/controllers/dismissible_controller.js +117 -0
  19. data/dist/controllers/drawer_controller.js +630 -0
  20. data/dist/controllers/editable_controller.js +169 -0
  21. data/dist/controllers/file_dropzone_controller.js +165 -0
  22. data/dist/controllers/filter_controller.js +86 -0
  23. data/dist/controllers/flash_controller.js +36 -5
  24. data/dist/controllers/form_validation_controller.js +1 -1
  25. data/dist/controllers/highlight_controller.js +6 -4
  26. data/dist/controllers/intersection_controller.js +67 -19
  27. data/dist/controllers/lazy_frame_controller.js +54 -11
  28. data/dist/controllers/listbox_controller.js +257 -53
  29. data/dist/controllers/local_time_controller.js +2 -2
  30. data/dist/controllers/masonry_controller.js +142 -0
  31. data/dist/controllers/menu_controller.js +104 -17
  32. data/dist/controllers/menubar_controller.js +785 -0
  33. data/dist/controllers/multi_select_controller.js +755 -0
  34. data/dist/controllers/navigation_menu_controller.js +511 -0
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +246 -27
  38. data/dist/controllers/overflow_menu_controller.js +381 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/password_reveal_controller.js +117 -0
  41. data/dist/controllers/persist_controller.js +6 -6
  42. data/dist/controllers/pointer_drag_controller.js +9 -1
  43. data/dist/controllers/popover_controller.js +2 -2
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +192 -0
  46. data/dist/controllers/rating_controller.js +16 -2
  47. data/dist/controllers/read_more_controller.js +238 -0
  48. data/dist/controllers/resizable_controller.js +65 -1
  49. data/dist/controllers/roving_controller.js +17 -2
  50. data/dist/controllers/scroll_area_controller.js +101 -14
  51. data/dist/controllers/scroll_restore_controller.js +93 -0
  52. data/dist/controllers/scroll_visibility_controller.js +40 -6
  53. data/dist/controllers/scrollspy_controller.js +369 -74
  54. data/dist/controllers/separator_controller.js +96 -0
  55. data/dist/controllers/sidebar_controller.js +761 -0
  56. data/dist/controllers/skeleton_controller.js +1 -1
  57. data/dist/controllers/slider_controller.js +32 -6
  58. data/dist/controllers/sortable_controller.js +34 -3
  59. data/dist/controllers/spinner_controller.js +1 -1
  60. data/dist/controllers/stepper_controller.js +28 -12
  61. data/dist/controllers/stick_to_bottom_controller.js +9 -4
  62. data/dist/controllers/sticky_observer_controller.js +109 -20
  63. data/dist/controllers/switch_controller.js +1 -0
  64. data/dist/controllers/tabs_controller.js +26 -3
  65. data/dist/controllers/tags_input_controller.js +295 -0
  66. data/dist/controllers/theme_controller.js +42 -13
  67. data/dist/controllers/time_picker_controller.js +231 -0
  68. data/dist/controllers/toast_controller.js +40 -14
  69. data/dist/controllers/toggle_group_controller.js +23 -2
  70. data/dist/controllers/toolbar_controller.js +230 -31
  71. data/dist/controllers/transition_controller.js +153 -38
  72. data/dist/controllers/tree_view_controller.js +691 -0
  73. data/dist/index.js +4256 -915
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +28 -2
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/toggle_group_controller.ts
4
4
 
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function isReservedArrowChord(event, allow = []) {
12
+ if (!event.key.startsWith("Arrow")) return false;
13
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
+ }
15
+
5
16
  // src/utils/roving_tabindex.ts
6
17
  var RovingTabindex = class {
7
18
  /** Returns the current ordered item elements; called on every operation. */
@@ -60,17 +71,27 @@ var ToggleGroupController = class extends Controller {
60
71
  }
61
72
  /** Arrow/Home/End move focus only; Space/Enter toggle non-button hosts. */
62
73
  onKeydown(event) {
74
+ if (event.defaultPrevented) return;
75
+ if (isReservedArrowChord(event)) return;
63
76
  const current = this.itemTargets.indexOf(event.currentTarget);
64
77
  if (current === -1) return;
65
78
  let next = null;
79
+ const rtl = isRtl(this.element);
80
+ const horizontalStep = (key) => rtl ? key === "ArrowRight" ? -1 : 1 : key === "ArrowRight" ? 1 : -1;
66
81
  switch (event.key) {
67
82
  case "ArrowRight":
68
83
  case "ArrowDown":
69
- next = rovingMove(current, this.itemTargets.length, 1);
84
+ next = rovingMove(
85
+ current,
86
+ this.itemTargets.length,
87
+ event.key === "ArrowDown" ? 1 : horizontalStep(event.key));
70
88
  break;
71
89
  case "ArrowLeft":
72
90
  case "ArrowUp":
73
- next = rovingMove(current, this.itemTargets.length, -1);
91
+ next = rovingMove(
92
+ current,
93
+ this.itemTargets.length,
94
+ event.key === "ArrowUp" ? -1 : horizontalStep(event.key));
74
95
  break;
75
96
  case "Home":
76
97
  next = 0;
@@ -2,6 +2,75 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/toolbar_controller.ts
4
4
 
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function isReservedArrowChord(event, allow = []) {
12
+ if (!event.key.startsWith("Arrow")) return false;
13
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
+ }
15
+
16
+ // src/utils/composition_tracker.ts
17
+ var CompositionTracker = class {
18
+ #observedTargets = /* @__PURE__ */ new Set();
19
+ #activeTargets = /* @__PURE__ */ new Set();
20
+ #onStart;
21
+ #onEnd;
22
+ constructor(options = {}) {
23
+ this.#onStart = options.onStart;
24
+ this.#onEnd = options.onEnd;
25
+ }
26
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
27
+ observe(target) {
28
+ if (this.#observedTargets.has(target)) return;
29
+ target.addEventListener("compositionstart", this.#handleStart);
30
+ target.addEventListener("compositionend", this.#handleEnd);
31
+ this.#observedTargets.add(target);
32
+ }
33
+ /** Stops tracking one target and clears any active composition it owned. */
34
+ unobserve(target) {
35
+ if (!this.#observedTargets.delete(target)) return;
36
+ target.removeEventListener("compositionstart", this.#handleStart);
37
+ target.removeEventListener("compositionend", this.#handleEnd);
38
+ this.#activeTargets.delete(target);
39
+ }
40
+ /** Releases every listener and clears state so reconnect starts cleanly. */
41
+ disconnect() {
42
+ for (const target of this.#observedTargets) {
43
+ target.removeEventListener("compositionstart", this.#handleStart);
44
+ target.removeEventListener("compositionend", this.#handleEnd);
45
+ }
46
+ this.#observedTargets.clear();
47
+ this.#activeTargets.clear();
48
+ }
49
+ /** True when lifecycle tracking or the current event reports composition. */
50
+ isComposing(event) {
51
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
52
+ }
53
+ #handleStart = (event) => {
54
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
55
+ this.#onStart?.(event);
56
+ };
57
+ #handleEnd = (event) => {
58
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
59
+ this.#onEnd?.(event);
60
+ };
61
+ };
62
+
63
+ // src/utils/focus_candidate.ts
64
+ function inheritsFieldsetDisabled(control) {
65
+ let fieldset = control.closest("fieldset[disabled]");
66
+ while (fieldset) {
67
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
68
+ if (!legend?.contains(control)) return true;
69
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
70
+ }
71
+ return false;
72
+ }
73
+
5
74
  // src/utils/roving_tabindex.ts
6
75
  var RovingTabindex = class {
7
76
  /** Returns the current ordered item elements; called on every operation. */
@@ -42,6 +111,7 @@ function rovingMove(current, length, delta, wrap) {
42
111
  }
43
112
 
44
113
  // src/controllers/toolbar_controller.ts
114
+ var STATE_ATTRIBUTES = ["disabled", "hidden"];
45
115
  var ToolbarController = class extends Controller {
46
116
  static targets = ["control"];
47
117
  static values = {
@@ -50,61 +120,190 @@ var ToolbarController = class extends Controller {
50
120
  };
51
121
  static actions = ["onKeydown"];
52
122
  #roving = new RovingTabindex(() => this.controlTargets);
123
+ #composition = new CompositionTracker();
124
+ #observer = null;
53
125
  /**
54
- * Establishes the single tab stop: keep an existing one if it is still
55
- * navigable, else the first navigable control. Disabled / hidden controls are
56
- * skipped so the toolbar's lone Tab stop never lands on an unfocusable element
57
- * (which would make the whole group unreachable on Tab re-entry).
126
+ * Live between `connect()` and `disconnect()`. Stimulus reports the *initial*
127
+ * targets before `connect()` and re-reports them as disconnected after
128
+ * `disconnect()`; gating on this keeps the target callbacks from clobbering
129
+ * the authored Tab stop on mount and from resurrecting one after teardown.
58
130
  */
131
+ #connected = false;
59
132
  connect() {
60
- const active = this.#roving.activeIndex;
61
- const activeEl = active === -1 ? null : this.controlTargets[active];
62
- if (activeEl && this.#isNavigable(activeEl)) {
63
- this.#roving.setActive(active);
64
- return;
133
+ this.#ensureTabStop();
134
+ this.element.addEventListener("keydown", this.#onKeydown);
135
+ this.element.addEventListener("focusin", this.#onFocusin);
136
+ this.#composition.observe(this.element);
137
+ if (typeof MutationObserver !== "undefined") {
138
+ const observer = new MutationObserver(() => this.#ensureTabStop());
139
+ observer.observe(this.element, {
140
+ subtree: true,
141
+ childList: true,
142
+ attributes: true,
143
+ attributeFilter: STATE_ATTRIBUTES
144
+ });
145
+ for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
146
+ observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
147
+ }
148
+ this.#observer = observer;
65
149
  }
66
- const first = this.#navigableControls[0];
67
- this.#roving.setActive(first ? this.controlTargets.indexOf(first) : -1);
150
+ this.#connected = true;
151
+ }
152
+ disconnect() {
153
+ this.#connected = false;
154
+ this.element.removeEventListener("keydown", this.#onKeydown);
155
+ this.element.removeEventListener("focusin", this.#onFocusin);
156
+ this.#composition.disconnect();
157
+ this.#observer?.disconnect();
158
+ this.#observer = null;
68
159
  }
69
- /** Arrow/Home/End move focus and the single tab stop. Bound via `data-action`. */
160
+ /**
161
+ * A control added at runtime is dropped out of the Tab sequence first — a
162
+ * fresh `<button>` is tabbable by default, which would make the group two Tab
163
+ * stops — and then the lone stop is re-established.
164
+ */
165
+ controlTargetConnected(control) {
166
+ if (!this.#connected) return;
167
+ control.tabIndex = -1;
168
+ this.#ensureTabStop();
169
+ }
170
+ /** Removing the control that held the Tab stop must not orphan the group. */
171
+ controlTargetDisconnected() {
172
+ if (!this.#connected) return;
173
+ this.#ensureTabStop();
174
+ }
175
+ /**
176
+ * Arrow/Home/End move focus and the single tab stop.
177
+ *
178
+ * Binding this per control with `data-action` is **optional** — the same
179
+ * handling runs from the container's delegated listener. It stays a declared
180
+ * action so per-control wiring keeps working; wiring both does not
181
+ * double-move, because the delegated pass then yields on `defaultPrevented`.
182
+ */
70
183
  onKeydown(event) {
71
- const navigable = this.#navigableControls;
72
- const current = navigable.indexOf(event.currentTarget);
73
- if (current === -1) return;
74
- const length = navigable.length;
75
- const wrap = this.wrapValue ? "wrap" : "clamp";
184
+ this.#handleKeydown(event);
185
+ }
186
+ /** Delegated counterpart of {@link onKeydown}; bound on the container. */
187
+ #onKeydown = (event) => {
188
+ this.#handleKeydown(event);
189
+ };
190
+ /**
191
+ * Syncs the Tab stop to a control focused by other means (click, programmatic
192
+ * `focus()`) so Tab re-entry returns there. A non-navigable control is ignored:
193
+ * the lone Tab stop must never sit on something the user cannot operate.
194
+ */
195
+ #onFocusin = (event) => {
196
+ const index = this.#indexOf(event.target);
197
+ const control = index === -1 ? void 0 : this.controlTargets[index];
198
+ if (!control || !this.#isNavigable(control)) return;
199
+ this.#roving.setActive(index);
200
+ };
201
+ #handleKeydown(event) {
202
+ if (event.defaultPrevented) return;
203
+ if (isReservedArrowChord(event)) return;
204
+ if (this.#composition.isComposing(event)) return;
205
+ this.#ensureTabStop();
206
+ const from = this.#indexOf(event.target);
207
+ if (from === -1) return;
76
208
  const vertical = this.orientationValue === "vertical";
77
- const forwardKey = vertical ? "ArrowDown" : "ArrowRight";
78
- const backwardKey = vertical ? "ArrowUp" : "ArrowLeft";
79
- let next;
209
+ const rtl = !vertical && isRtl(this.element);
210
+ const forwardKey = vertical ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight";
211
+ const backwardKey = vertical ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
212
+ let target;
80
213
  if (event.key === forwardKey) {
81
- next = rovingMove(current, length, 1, wrap);
214
+ target = this.#nextNavigable(from, 1);
82
215
  } else if (event.key === backwardKey) {
83
- next = rovingMove(current, length, -1, wrap);
216
+ target = this.#nextNavigable(from, -1);
84
217
  } else if (event.key === "Home") {
85
- next = 0;
218
+ target = this.#navigableControls[0];
86
219
  } else if (event.key === "End") {
87
- next = length - 1;
220
+ const navigable = this.#navigableControls;
221
+ target = navigable[navigable.length - 1];
88
222
  } else {
89
223
  return;
90
224
  }
91
225
  event.preventDefault();
92
- const target = navigable[next];
93
226
  if (target) this.#roving.setActive(this.controlTargets.indexOf(target), { focus: true });
94
227
  }
95
- /** Controls eligible for the roving tab stop (excludes disabled / hidden). */
228
+ /**
229
+ * Re-establishes the single Tab stop: keep the current one while it is still
230
+ * navigable, else hand it to the first navigable control. `-1` (no Tab stop at
231
+ * all) is reached only when every control is unavailable, and is recovered
232
+ * from as soon as one becomes navigable again. Idempotent by construction, so
233
+ * connect, the target callbacks, the observer, and keydown can all call it.
234
+ */
235
+ #ensureTabStop() {
236
+ const active = this.#roving.activeIndex;
237
+ const activeEl = active === -1 ? null : this.controlTargets[active];
238
+ if (activeEl && this.#isNavigable(activeEl)) {
239
+ this.#roving.setActive(active);
240
+ return;
241
+ }
242
+ const first = this.#navigableControls[0];
243
+ this.#roving.setActive(first ? this.controlTargets.indexOf(first) : -1);
244
+ }
245
+ /**
246
+ * First navigable control strictly in the `delta` direction from `fromIndex`,
247
+ * scanning the **full** control list rather than the navigable subset so a
248
+ * non-navigable origin is never a dead end. When the direction yields nothing
249
+ * the origin keeps focus; when the origin itself is not navigable the first
250
+ * navigable control is used instead, so an arrow key always escapes.
251
+ */
252
+ #nextNavigable(fromIndex, delta) {
253
+ const controls = this.controlTargets;
254
+ const length = controls.length;
255
+ const wrap = this.wrapValue ? "wrap" : "clamp";
256
+ let index = fromIndex;
257
+ for (let step = 0; step < length; step++) {
258
+ const next = rovingMove(index, length, delta, wrap);
259
+ if (next === index) break;
260
+ index = next;
261
+ const candidate = controls[index];
262
+ if (candidate && this.#isNavigable(candidate)) return candidate;
263
+ }
264
+ const origin = controls[fromIndex];
265
+ return origin && this.#isNavigable(origin) ? origin : this.#navigableControls[0];
266
+ }
267
+ /** Index in `controlTargets` of the control owning `target` (it or a descendant). */
268
+ #indexOf(target) {
269
+ const node = target;
270
+ if (!node) return -1;
271
+ return this.controlTargets.findIndex((control) => control === node || control.contains(node));
272
+ }
273
+ /** Controls eligible for the roving tab stop (excludes native disabled / hidden). */
96
274
  get #navigableControls() {
97
275
  return this.controlTargets.filter((control) => this.#isNavigable(control));
98
276
  }
99
277
  /**
100
- * A control can hold the tab stop unless it is `hidden`, `aria-disabled`, or a
101
- * natively `disabled` form control. CSS-only visibility cannot be detected
278
+ * A control can hold the tab stop unless it is `hidden` or a disabled form
279
+ * control. `aria-disabled` remains navigable per the APG; activation suppression
280
+ * belongs to the control itself or its owning behavior. Native disabledness is
281
+ * read from the `disabled` property, narrowed by an `in` check rather than
282
+ * asserted since `<a>` and `<div role="button">` are legitimate controls that
283
+ * are unaffected by a disabled fieldset. CSS-only visibility cannot be detected
102
284
  * headlessly and stays the consumer's responsibility.
103
285
  */
104
286
  #isNavigable(control) {
105
- if (control.hasAttribute("hidden")) return false;
106
- if (control.getAttribute("aria-disabled") === "true") return false;
107
- return !control.disabled;
287
+ if (this.#isHidden(control)) return false;
288
+ if (!("disabled" in control)) return true;
289
+ if (control.disabled === true) return false;
290
+ return !inheritsFieldsetDisabled(control);
291
+ }
292
+ /**
293
+ * Whether `control`, or anything between it and the toolbar root, is `hidden`.
294
+ *
295
+ * Reading only the control's own attribute lets an invisible control hold the
296
+ * single Tab stop, which takes the *whole* toolbar out of the Tab sequence — an
297
+ * ordinary `hidden` wrapper is enough. The walk stops at the root: a toolbar
298
+ * inside a `hidden` region is already out of the page's Tab order.
299
+ */
300
+ #isHidden(control) {
301
+ let node = control;
302
+ while (node && node !== this.element) {
303
+ if (node.hasAttribute("hidden")) return true;
304
+ node = node.parentElement;
305
+ }
306
+ return false;
108
307
  }
109
308
  };
110
309
 
@@ -2,6 +2,11 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/transition_controller.ts
4
4
 
5
+ // src/utils/reduced_motion.ts
6
+ function prefersReducedMotion() {
7
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
+ }
9
+
5
10
  // src/utils/safe_timeout.ts
6
11
  var TimerRegistry = class {
7
12
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -55,14 +60,149 @@ var SafeTimeout = class extends TimerRegistry {
55
60
  }
56
61
  };
57
62
 
63
+ // src/utils/transition_completion.ts
64
+ function timeMs(value) {
65
+ const trimmed = value.trim();
66
+ const amount = Number.parseFloat(trimmed);
67
+ if (!Number.isFinite(amount)) return 0;
68
+ if (trimmed.endsWith("ms")) return amount;
69
+ if (trimmed.endsWith("s")) return amount * 1e3;
70
+ return 0;
71
+ }
72
+ function cssList(value) {
73
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
74
+ }
75
+ function transitionTimings(style) {
76
+ const properties = cssList(style.transitionProperty);
77
+ const durations = cssList(style.transitionDuration).map(timeMs);
78
+ const delays = cssList(style.transitionDelay).map(timeMs);
79
+ const effectiveProperties = properties.length > 0 ? properties : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => "all");
80
+ const effectiveDurations = durations.length > 0 ? durations : [0];
81
+ const effectiveDelays = delays.length > 0 ? delays : [0];
82
+ return effectiveProperties.filter((property) => property !== "none").map((property, index) => ({
83
+ property,
84
+ totalMs: Math.max(
85
+ 0,
86
+ (effectiveDurations[index % effectiveDurations.length] ?? 0) + (effectiveDelays[index % effectiveDelays.length] ?? 0)
87
+ )
88
+ }));
89
+ }
90
+ function maxTotalMs(timings) {
91
+ return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);
92
+ }
93
+ var TransitionCompletion = class {
94
+ #timers = new SafeTimeout();
95
+ #element = null;
96
+ #complete = null;
97
+ #pendingProperties = null;
98
+ #deadline = 0;
99
+ /**
100
+ * Replaces any prior wait and invokes `complete` synchronously for a 0ms
101
+ * transition (including when `getComputedStyle` is unavailable).
102
+ *
103
+ * With a positive `options.timeoutMs` the synchronous fast-path is skipped and
104
+ * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
105
+ */
106
+ wait(element, complete, options = {}) {
107
+ this.cancel();
108
+ const requested = options.timeoutMs ?? 0;
109
+ const override = Number.isFinite(requested) && requested > 0 ? requested : 0;
110
+ const timings = typeof window.getComputedStyle === "function" ? transitionTimings(window.getComputedStyle(element)) : [];
111
+ const maximum = maxTotalMs(timings);
112
+ if (maximum <= 0 && override <= 0) {
113
+ complete();
114
+ return;
115
+ }
116
+ this.#element = element;
117
+ this.#complete = complete;
118
+ this.#deadline = Date.now() + maximum;
119
+ const activeProperties = this.#activeTransitionProperties(element);
120
+ this.#pendingProperties = activeProperties.size > 0 ? activeProperties : this.#explicitPendingProperties(timings);
121
+ element.addEventListener("transitionend", this.#onTerminal);
122
+ element.addEventListener("transitioncancel", this.#onTerminal);
123
+ this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
124
+ }
125
+ /** Cancels the pending wait without invoking its completion callback. */
126
+ cancel() {
127
+ this.#complete = null;
128
+ this.#teardown();
129
+ }
130
+ /**
131
+ * Handles terminal events from the observed element only.
132
+ *
133
+ * For explicit property lists, every declared positive-time property must
134
+ * settle. For `all`, no reliable property set exists, so an event can finish
135
+ * only after the computed maximum time; the safety timeout owns the usual path.
136
+ */
137
+ #onTerminal = (event) => {
138
+ if (event.target !== this.#element) return;
139
+ const transitionEvent = event;
140
+ if (transitionEvent.pseudoElement) return;
141
+ if (this.#pendingProperties) {
142
+ const propertyName = transitionEvent.propertyName;
143
+ const active = this.#activeTransitionProperties(this.#element);
144
+ if (active.has(propertyName)) return;
145
+ if (!this.#pendingProperties.delete(propertyName)) return;
146
+ if (this.#pendingProperties.size > 0) return;
147
+ if (active.size > 0) {
148
+ this.#pendingProperties = active;
149
+ return;
150
+ }
151
+ this.#finish();
152
+ return;
153
+ }
154
+ if (Date.now() >= this.#deadline) this.#finish();
155
+ };
156
+ /**
157
+ * Returns active CSS transition properties expanded to the names reported by
158
+ * terminal events. CSS animations and pseudo-element effects are excluded.
159
+ */
160
+ #activeTransitionProperties(element) {
161
+ if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
162
+ try {
163
+ const properties = element.getAnimations().flatMap((animation) => {
164
+ if (animation.playState === "idle" || animation.playState === "finished") return [];
165
+ const effect = animation.effect;
166
+ if (effect?.pseudoElement) return [];
167
+ const target = effect?.target;
168
+ if (target && target !== element) return [];
169
+ const property = animation.transitionProperty;
170
+ return typeof property === "string" && property.length > 0 ? [property] : [];
171
+ });
172
+ return new Set(properties);
173
+ } catch {
174
+ return /* @__PURE__ */ new Set();
175
+ }
176
+ }
177
+ /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
178
+ #explicitPendingProperties(timings) {
179
+ if (timings.some(({ property }) => property === "all")) return null;
180
+ const pending = new Set(
181
+ timings.filter(({ totalMs }) => totalMs > 0).map(({ property }) => property)
182
+ );
183
+ return pending.size > 0 ? pending : null;
184
+ }
185
+ /** Completes exactly once, releasing listeners and the fallback before the callback. */
186
+ #finish() {
187
+ const complete = this.#complete;
188
+ if (!complete) return;
189
+ this.#complete = null;
190
+ this.#teardown();
191
+ complete();
192
+ }
193
+ /** Releases the exact element listeners and timer owned by the current wait. */
194
+ #teardown() {
195
+ this.#timers.clearAll();
196
+ this.#element?.removeEventListener("transitionend", this.#onTerminal);
197
+ this.#element?.removeEventListener("transitioncancel", this.#onTerminal);
198
+ this.#element = null;
199
+ this.#pendingProperties = null;
200
+ this.#deadline = 0;
201
+ }
202
+ };
203
+
58
204
  // src/controllers/transition_controller.ts
59
205
  var tokensOf = (value) => value.split(/\s+/).filter(Boolean);
60
- var firstTimeMs = (value) => {
61
- const first = value.split(",")[0]?.trim() ?? "";
62
- const amount = Number.parseFloat(first);
63
- if (Number.isNaN(amount)) return 0;
64
- return first.endsWith("ms") ? amount : amount * 1e3;
65
- };
66
206
  var TransitionController = class extends Controller {
67
207
  static values = {
68
208
  enter: { type: String, default: "" },
@@ -75,9 +215,9 @@ var TransitionController = class extends Controller {
75
215
  };
76
216
  static actions = ["enter", "leave", "toggle"];
77
217
  static events = ["entered", "left"];
78
- #timers = new SafeTimeout();
218
+ /** Owns the cancellable completion wait (terminal events + bounded fallback). */
219
+ #transition = new TransitionCompletion();
79
220
  #rafId = null;
80
- #endListener = null;
81
221
  connect() {
82
222
  this.#strip();
83
223
  this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
@@ -104,7 +244,7 @@ var TransitionController = class extends Controller {
104
244
  const isEnter = kind === "enter";
105
245
  if (isEnter) this.element.hidden = false;
106
246
  this.element.setAttribute("data-transition-state", isEnter ? "entering" : "leaving");
107
- if (this.#prefersReducedMotion()) {
247
+ if (prefersReducedMotion()) {
108
248
  this.#finish(kind);
109
249
  return;
110
250
  }
@@ -116,12 +256,13 @@ var TransitionController = class extends Controller {
116
256
  this.#rafId = null;
117
257
  this.#remove(from);
118
258
  this.#add(to);
119
- this.#awaitEnd(() => this.#finish(kind));
259
+ this.#transition.wait(this.element, () => this.#finish(kind), {
260
+ timeoutMs: this.timeoutValue
261
+ });
120
262
  });
121
263
  }
122
264
  /** Settles the element into the completed state, clearing the stage classes. */
123
265
  #finish(kind) {
124
- this.#cleanupEnd();
125
266
  this.#strip();
126
267
  if (kind === "enter") {
127
268
  this.element.setAttribute("data-transition-state", "entered");
@@ -132,29 +273,13 @@ var TransitionController = class extends Controller {
132
273
  this.dispatch("left", { detail: {} });
133
274
  }
134
275
  }
135
- /** Resolves on the element's own `transitionend`, with a safety timeout fallback. */
136
- #awaitEnd(done) {
137
- this.#endListener = (event) => {
138
- if (event.target === this.element) done();
139
- };
140
- this.element.addEventListener("transitionend", this.#endListener);
141
- const ms = this.timeoutValue > 0 ? this.timeoutValue : this.#duration();
142
- this.#timers.set(done, ms);
143
- }
144
- #cleanupEnd() {
145
- if (this.#endListener) {
146
- this.element.removeEventListener("transitionend", this.#endListener);
147
- this.#endListener = null;
148
- }
149
- this.#timers.clearAll();
150
- }
151
276
  /** Cancels any in-flight transition (interruption / teardown). */
152
277
  #cancel() {
153
278
  if (this.#rafId !== null) {
154
279
  this.#cancelRaf(this.#rafId);
155
280
  this.#rafId = null;
156
281
  }
157
- this.#cleanupEnd();
282
+ this.#transition.cancel();
158
283
  this.#strip();
159
284
  }
160
285
  #add(...lists) {
@@ -176,13 +301,6 @@ var TransitionController = class extends Controller {
176
301
  this.leaveToValue
177
302
  );
178
303
  }
179
- /** Auto-computed safety duration (transition time + delay, with a small buffer). */
180
- #duration() {
181
- if (typeof window.getComputedStyle !== "function") return 0;
182
- const style = window.getComputedStyle(this.element);
183
- const total = firstTimeMs(style.transitionDuration) + firstTimeMs(style.transitionDelay);
184
- return total > 0 ? total + 50 : 0;
185
- }
186
304
  #raf(callback) {
187
305
  if (typeof window.requestAnimationFrame === "function") {
188
306
  return window.requestAnimationFrame(() => callback());
@@ -193,9 +311,6 @@ var TransitionController = class extends Controller {
193
311
  if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(id);
194
312
  else window.clearTimeout(id);
195
313
  }
196
- #prefersReducedMotion() {
197
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
198
- }
199
314
  };
200
315
 
201
316
  export { TransitionController };