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
@@ -0,0 +1,169 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/editable_controller.ts
4
+
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
52
+ // src/controllers/editable_controller.ts
53
+ var EditableController = class extends Controller {
54
+ static targets = ["display", "input"];
55
+ static values = {
56
+ submitOnBlur: { type: Boolean, default: true }
57
+ };
58
+ static actions = ["edit", "onBlur", "onDisplayKeydown", "onKeydown"];
59
+ static events = ["cancel", "change"];
60
+ /** The value captured when edit mode began, used to detect real changes. */
61
+ #previousValue = "";
62
+ /**
63
+ * Owns IME lifecycle state for the edit surface, so a keydown that belongs to
64
+ * a composition (cancel or confirm) is never treated as an edit command.
65
+ */
66
+ #composition = new CompositionTracker();
67
+ /** Establishes the initial display mode (display shown, input hidden). */
68
+ connect() {
69
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
70
+ this.#setMode("display");
71
+ }
72
+ /** Releases the composition listeners so nothing outlives the element. */
73
+ disconnect() {
74
+ this.#composition.disconnect();
75
+ }
76
+ /** Tracks an input added initially or after connect (e.g. a Turbo swap). */
77
+ inputTargetConnected(input) {
78
+ this.#composition.observe(input);
79
+ }
80
+ /** Removes composition listeners when the active input is replaced or removed. */
81
+ inputTargetDisconnected(input) {
82
+ this.#composition.unobserve(input);
83
+ }
84
+ /** Enters edit mode: seeds the input from the display text, focuses, selects. */
85
+ edit() {
86
+ if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
87
+ this.#previousValue = this.#currentValue;
88
+ this.inputTarget.value = this.#previousValue;
89
+ this.#setMode("editing");
90
+ this.inputTarget.focus();
91
+ this.inputTarget.select();
92
+ }
93
+ /** Adds `F2` as an editing entry point alongside the button's native activation. */
94
+ onDisplayKeydown(event) {
95
+ if (event.key === "F2") {
96
+ event.preventDefault();
97
+ this.edit();
98
+ }
99
+ }
100
+ /** Commits on `Enter` (or `Ctrl+Enter` when multiline) and cancels on `Escape`. */
101
+ onKeydown(event) {
102
+ if (this.#composition.isComposing(event)) return;
103
+ if (event.key === "Escape") {
104
+ if (event.defaultPrevented) return;
105
+ event.preventDefault();
106
+ this.#cancel();
107
+ return;
108
+ }
109
+ if (event.key === "Enter") {
110
+ if (event.defaultPrevented) return;
111
+ if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
112
+ event.preventDefault();
113
+ this.#save(true);
114
+ }
115
+ }
116
+ /** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */
117
+ onBlur() {
118
+ if (!this.#isEditing) return;
119
+ if (this.submitOnBlurValue) this.#save(false);
120
+ }
121
+ /**
122
+ * Returns to display mode, reflecting the input into the display text and
123
+ * dispatching `change` when the value differs from where editing began.
124
+ *
125
+ * @param restoreFocus - Move focus back to the display element (explicit
126
+ * keyboard commit) rather than honoring the user's new focus target (blur).
127
+ */
128
+ #save(restoreFocus) {
129
+ if (!this.#isEditing) return;
130
+ const value = this.inputTarget.value;
131
+ const previous = this.#previousValue;
132
+ this.displayTarget.textContent = value;
133
+ this.#setMode("display");
134
+ if (restoreFocus) this.displayTarget.focus();
135
+ if (value !== previous) {
136
+ this.dispatch("change", { detail: { value, previous } });
137
+ }
138
+ }
139
+ /** Discards edits, returns to display mode, and dispatches `cancel`. */
140
+ #cancel() {
141
+ if (!this.#isEditing) return;
142
+ this.#setMode("display");
143
+ this.displayTarget.focus();
144
+ this.dispatch("cancel", { detail: {} });
145
+ }
146
+ /** Toggles the `data-mode` flag and the `hidden` state of both elements. */
147
+ #setMode(mode) {
148
+ this.element.dataset.mode = mode;
149
+ const editing = mode === "editing";
150
+ if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
151
+ if (this.hasInputTarget) this.inputTarget.hidden = !editing;
152
+ }
153
+ /** Current display text, trimmed — the value shown when not editing. */
154
+ get #currentValue() {
155
+ return (this.displayTarget.textContent ?? "").trim();
156
+ }
157
+ /** Whether the editing control is a multi-line `<textarea>`. */
158
+ get #isMultiline() {
159
+ return this.inputTarget.tagName === "TEXTAREA";
160
+ }
161
+ /** Whether the controller is currently in edit mode. */
162
+ get #isEditing() {
163
+ return this.element.dataset.mode === "editing";
164
+ }
165
+ };
166
+
167
+ export { EditableController };
168
+ //# sourceMappingURL=editable_controller.js.map
169
+ //# sourceMappingURL=editable_controller.js.map
@@ -0,0 +1,165 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/file_dropzone_controller.ts
4
+ var FileDropzoneController = class extends Controller {
5
+ static targets = ["zone", "trigger", "input", "list", "item", "itemTemplate", "status"];
6
+ static values = {
7
+ maxSize: { type: Number, default: 0 },
8
+ maxFiles: { type: Number, default: 0 },
9
+ dragLabel: { type: String, default: "Drop files to add them" }
10
+ };
11
+ static actions = ["onChange", "onDragLeave", "onDragOver", "onDrop", "openDialog"];
12
+ static events = ["change", "reject"];
13
+ /** Selected files paired with their rendered item and any preview objectURL. */
14
+ #entries = [];
15
+ /** Wires file removal as a delegated listener on the list container. */
16
+ connect() {
17
+ if (this.hasListTarget) this.listTarget.addEventListener("click", this.#onItemClick);
18
+ }
19
+ /** Revokes any outstanding preview URLs so none leaks across navigations. */
20
+ disconnect() {
21
+ if (this.hasListTarget) this.listTarget.removeEventListener("click", this.#onItemClick);
22
+ for (const entry of this.#entries) {
23
+ if (entry.url) URL.revokeObjectURL(entry.url);
24
+ }
25
+ this.#entries.length = 0;
26
+ }
27
+ /** Opens the native file dialog. Bound via `data-action` (trigger click). */
28
+ openDialog() {
29
+ this.inputTarget.click();
30
+ }
31
+ /** Adds the files chosen through the native dialog. */
32
+ onChange() {
33
+ if (this.inputTarget.files) this.#addFiles(this.inputTarget.files);
34
+ this.inputTarget.value = "";
35
+ }
36
+ /** Marks the zone as a drop target and announces the affordance in words. */
37
+ onDragOver(event) {
38
+ event.preventDefault();
39
+ if (this.hasZoneTarget) this.zoneTarget.setAttribute("data-dragover", "");
40
+ this.#setStatus(this.dragLabelValue);
41
+ }
42
+ /** Clears the drag-over flag when the pointer leaves the zone. */
43
+ onDragLeave() {
44
+ if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-dragover");
45
+ }
46
+ /** Accepts dropped files, clearing the drag-over state. */
47
+ onDrop(event) {
48
+ event.preventDefault();
49
+ if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-dragover");
50
+ if (event.dataTransfer?.files) this.#addFiles(event.dataTransfer.files);
51
+ }
52
+ /**
53
+ * Removes the file whose remove button was clicked. Delegated on the list
54
+ * container rather than bound per item via `data-action`, so it works the instant
55
+ * an item is appended without waiting on Stimulus to wire a freshly created element.
56
+ */
57
+ #onItemClick = (event) => {
58
+ const button = event.target.closest("button");
59
+ if (!button || !this.hasListTarget || !this.listTarget.contains(button)) return;
60
+ const index = this.#entries.findIndex((entry) => entry.item.contains(button));
61
+ if (index !== -1) this.#removeAt(index);
62
+ };
63
+ /** Validates each incoming file and renders the accepted ones. */
64
+ #addFiles(files) {
65
+ let changed = false;
66
+ if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-stimeo--file-dropzone-invalid");
67
+ for (const file of Array.from(files)) {
68
+ const reason = this.#validate(file);
69
+ if (reason) {
70
+ if (this.hasZoneTarget) {
71
+ this.zoneTarget.setAttribute("data-stimeo--file-dropzone-invalid", "");
72
+ }
73
+ this.#setStatus(file.name);
74
+ this.dispatch("reject", { detail: { file, reason } });
75
+ continue;
76
+ }
77
+ this.#appendFile(file);
78
+ this.#setStatus(file.name);
79
+ changed = true;
80
+ }
81
+ if (changed) this.dispatch("change", { detail: { files: this.#files } });
82
+ }
83
+ /** Returns the rejection reason for `file`, or `null` when it is acceptable. */
84
+ #validate(file) {
85
+ const limit = this.#effectiveMaxFiles;
86
+ if (limit > 0 && this.#entries.length >= limit) return "count";
87
+ if (!this.#matchesAccept(file)) return "type";
88
+ if (this.maxSizeValue > 0 && file.size > this.maxSizeValue) return "size";
89
+ return null;
90
+ }
91
+ /** Builds one preview item (name, optional thumbnail, remove button). */
92
+ #appendFile(file) {
93
+ if (!this.hasItemTemplateTarget || !this.hasListTarget) return;
94
+ const fragment = this.itemTemplateTarget.content.cloneNode(true);
95
+ const item = fragment.querySelector('[data-stimeo--file-dropzone-target="item"]');
96
+ const name = fragment.querySelector('[data-file-dropzone-slot="name"]');
97
+ const thumb = fragment.querySelector('[data-file-dropzone-slot="thumb"]');
98
+ const button = fragment.querySelector("button");
99
+ if (!item) return;
100
+ if (name) name.textContent = file.name;
101
+ if (button) button.setAttribute("aria-label", `Remove ${file.name}`);
102
+ let url;
103
+ if (thumb && file.type.startsWith("image/")) {
104
+ url = URL.createObjectURL(file);
105
+ thumb.src = url;
106
+ thumb.alt = file.name;
107
+ thumb.hidden = false;
108
+ } else if (thumb) {
109
+ thumb.hidden = true;
110
+ }
111
+ this.listTarget.appendChild(fragment);
112
+ this.#entries.push({ file, item, url });
113
+ }
114
+ /** Removes entry `index`, revokes its preview, and re-homes focus. */
115
+ #removeAt(index) {
116
+ const entry = this.#entries[index];
117
+ if (!entry) return;
118
+ if (entry.url) URL.revokeObjectURL(entry.url);
119
+ entry.item.remove();
120
+ this.#entries.splice(index, 1);
121
+ this.#setStatus(entry.file.name);
122
+ this.dispatch("change", { detail: { files: this.#files } });
123
+ const buttons = this.#removeButtons;
124
+ if (buttons.length === 0) {
125
+ if (this.hasTriggerTarget) this.triggerTarget.focus();
126
+ } else {
127
+ (buttons[index] ?? buttons[buttons.length - 1])?.focus();
128
+ }
129
+ }
130
+ /** Whether `file` satisfies the input's `accept` list (empty accepts all). */
131
+ #matchesAccept(file) {
132
+ const accept = this.inputTarget.accept.trim();
133
+ if (accept === "") return true;
134
+ const name = file.name.toLowerCase();
135
+ const type = file.type.toLowerCase();
136
+ return accept.split(",").some((raw) => {
137
+ const token = raw.trim().toLowerCase();
138
+ if (token === "") return false;
139
+ if (token.startsWith(".")) return name.endsWith(token);
140
+ if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
141
+ return type === token;
142
+ });
143
+ }
144
+ /** Updates the live region so assistive tech announces the change. */
145
+ #setStatus(text) {
146
+ if (this.hasStatusTarget) this.statusTarget.textContent = text;
147
+ }
148
+ /** Effective file cap: `maxFiles`, or 1 when the input is single-select. */
149
+ get #effectiveMaxFiles() {
150
+ if (this.maxFilesValue > 0) return this.maxFilesValue;
151
+ return this.inputTarget.multiple ? 0 : 1;
152
+ }
153
+ /** The remove buttons currently in the list, in order. */
154
+ get #removeButtons() {
155
+ return Array.from(this.listTarget.querySelectorAll("button"));
156
+ }
157
+ /** The accepted files in selection order. */
158
+ get #files() {
159
+ return this.#entries.map((entry) => entry.file);
160
+ }
161
+ };
162
+
163
+ export { FileDropzoneController };
164
+ //# sourceMappingURL=file_dropzone_controller.js.map
165
+ //# sourceMappingURL=file_dropzone_controller.js.map
@@ -0,0 +1,86 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/filter_controller.ts
4
+ var FilterController = class extends Controller {
5
+ static targets = ["item", "control", "group", "empty"];
6
+ static values = { match: { type: String, default: "all" } };
7
+ static actions = ["apply", "clear"];
8
+ static events = ["change"];
9
+ #onChange = () => {
10
+ this.apply();
11
+ };
12
+ connect() {
13
+ this.apply();
14
+ this.element.addEventListener("change", this.#onChange);
15
+ }
16
+ disconnect() {
17
+ this.element.removeEventListener("change", this.#onChange);
18
+ }
19
+ /** Re-derives every item's visibility from the active tokens and syncs groups/empty. */
20
+ apply() {
21
+ const active = this.#activeTokens();
22
+ let visibleCount = 0;
23
+ for (const item of this.itemTargets) {
24
+ const visible = this.#matches(item, active);
25
+ item.hidden = !visible;
26
+ if (visible) visibleCount += 1;
27
+ }
28
+ for (const group of this.groupTargets) {
29
+ group.hidden = !this.#hasVisibleItem(group);
30
+ }
31
+ for (const empty of this.emptyTargets) {
32
+ empty.hidden = visibleCount > 0;
33
+ }
34
+ this.dispatch("change", {
35
+ detail: { active, visible: visibleCount, total: this.itemTargets.length }
36
+ });
37
+ }
38
+ /** Turns every control off (uncheck / aria-pressed="false") and re-applies. */
39
+ clear() {
40
+ for (const control of this.controlTargets) {
41
+ if (control instanceof HTMLInputElement) {
42
+ control.checked = false;
43
+ } else if (control.hasAttribute("aria-pressed")) {
44
+ control.setAttribute("aria-pressed", "false");
45
+ }
46
+ }
47
+ this.apply();
48
+ }
49
+ /** The tokens of every control currently "on" (checked or aria-pressed="true"). */
50
+ #activeTokens() {
51
+ const tokens = [];
52
+ for (const control of this.controlTargets) {
53
+ if (this.#isOn(control)) tokens.push(this.#tokenOf(control));
54
+ }
55
+ return tokens.filter(Boolean);
56
+ }
57
+ /** Whether a control is in its "on" state. */
58
+ #isOn(control) {
59
+ if (control instanceof HTMLInputElement) return control.checked;
60
+ return control.getAttribute("aria-pressed") === "true";
61
+ }
62
+ /** A control's token: explicit `data-stimeo--filter-token`, else `data-value` / value. */
63
+ #tokenOf(control) {
64
+ const explicit = control.getAttribute("data-stimeo--filter-token") ?? control.dataset.value;
65
+ if (explicit) return explicit;
66
+ return control instanceof HTMLInputElement ? control.value : "";
67
+ }
68
+ /** Whether an item satisfies the active token set per `match` (empty active → shown). */
69
+ #matches(item, active) {
70
+ if (active.length === 0) return true;
71
+ const tokens = this.#tokensOf(item);
72
+ return this.matchValue === "any" ? active.some((token) => tokens.includes(token)) : active.every((token) => tokens.includes(token));
73
+ }
74
+ /** An item's declared tokens (`data-stimeo--filter-tokens`, space-separated). */
75
+ #tokensOf(item) {
76
+ return (item.getAttribute("data-stimeo--filter-tokens") ?? "").split(/\s+/).filter(Boolean);
77
+ }
78
+ /** Whether a group still contains at least one non-hidden item. */
79
+ #hasVisibleItem(group) {
80
+ return this.itemTargets.some((item) => !item.hidden && group.contains(item));
81
+ }
82
+ };
83
+
84
+ export { FilterController };
85
+ //# sourceMappingURL=filter_controller.js.map
86
+ //# sourceMappingURL=filter_controller.js.map
@@ -55,6 +55,40 @@ var SafeTimeout = class extends TimerRegistry {
55
55
  }
56
56
  };
57
57
 
58
+ // src/utils/transition_completion.ts
59
+ function timeMs(value) {
60
+ const trimmed = value.trim();
61
+ const amount = Number.parseFloat(trimmed);
62
+ if (!Number.isFinite(amount)) return 0;
63
+ if (trimmed.endsWith("ms")) return amount;
64
+ if (trimmed.endsWith("s")) return amount * 1e3;
65
+ return 0;
66
+ }
67
+ function cssList(value) {
68
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
69
+ }
70
+ function transitionTimings(style) {
71
+ const properties = cssList(style.transitionProperty);
72
+ const durations = cssList(style.transitionDuration).map(timeMs);
73
+ const delays = cssList(style.transitionDelay).map(timeMs);
74
+ const effectiveProperties = properties.length > 0 ? properties : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => "all");
75
+ const effectiveDurations = durations.length > 0 ? durations : [0];
76
+ const effectiveDelays = delays.length > 0 ? delays : [0];
77
+ return effectiveProperties.filter((property) => property !== "none").map((property, index) => ({
78
+ property,
79
+ totalMs: Math.max(
80
+ 0,
81
+ (effectiveDurations[index % effectiveDurations.length] ?? 0) + (effectiveDelays[index % effectiveDelays.length] ?? 0)
82
+ )
83
+ }));
84
+ }
85
+ function maxTotalMs(timings) {
86
+ return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);
87
+ }
88
+ function maxTransitionTotalMs(style) {
89
+ return maxTotalMs(transitionTimings(style));
90
+ }
91
+
58
92
  // src/controllers/flash_controller.ts
59
93
  var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
60
94
  var MESSAGE_SELECTOR = '[data-stimeo--flash-target="message"]';
@@ -206,13 +240,10 @@ var FlashController = class extends Controller {
206
240
  finalize();
207
241
  }
208
242
  }
209
- /** First `transition-duration` of `el` in ms (0 when none / unsupported). */
243
+ /** Maximum transition total (duration + delay) of `el` in ms (0 when none / unsupported). */
210
244
  #transitionMs(el) {
211
245
  if (typeof window.getComputedStyle !== "function") return 0;
212
- const first = window.getComputedStyle(el).transitionDuration.split(",")[0]?.trim() ?? "";
213
- const amount = Number.parseFloat(first);
214
- if (Number.isNaN(amount)) return 0;
215
- return first.endsWith("ms") ? amount : amount * 1e3;
246
+ return maxTransitionTotalMs(window.getComputedStyle(el));
216
247
  }
217
248
  };
218
249
 
@@ -216,7 +216,7 @@ var FormValidationController = class _FormValidationController extends Controlle
216
216
  }
217
217
  /**
218
218
  * Where focus should land for an invalid control. A visible control is focused
219
- * directly (status quo for native fields and radios). A validatable mirror
219
+ * directly the case for native fields and radios. A validatable mirror
220
220
  * (the `hidden` attribute) cannot receive focus, so focus is delegated to the
221
221
  * visible widget: the owning field's `control` target when it is itself
222
222
  * focusable, else its first focusable descendant (e.g. a roving-tabindex
@@ -2,6 +2,11 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/highlight_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). */
@@ -89,7 +94,7 @@ var HighlightController = class extends Controller {
89
94
  }
90
95
  /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
91
96
  #highlight(el) {
92
- if (this.#prefersReducedMotion()) return;
97
+ if (prefersReducedMotion()) return;
93
98
  el.setAttribute("data-highlight", "true");
94
99
  this.dispatch("start", { target: el, detail: { element: el } });
95
100
  this.#timeouts.set(() => {
@@ -97,9 +102,6 @@ var HighlightController = class extends Controller {
97
102
  this.dispatch("end", { target: el, detail: { element: el } });
98
103
  }, this.durationValue);
99
104
  }
100
- #prefersReducedMotion() {
101
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
102
- }
103
105
  };
104
106
 
105
107
  export { HighlightController };
@@ -3,10 +3,17 @@ import { Controller } from '@hotwired/stimulus';
3
3
  // src/controllers/intersection_controller.ts
4
4
 
5
5
  // src/utils/intersection_watcher.ts
6
+ function isBeforeRootStart(entry) {
7
+ const rect = entry.boundingClientRect;
8
+ if (rect.width === 0 && rect.height === 0) return false;
9
+ const rootTop = entry.rootBounds?.top ?? 0;
10
+ return rect.bottom <= rootTop;
11
+ }
6
12
  var IntersectionWatcher = class {
7
13
  #onEntries;
8
14
  #observer = null;
9
15
  #active = false;
16
+ #usingPlatformDefaults = false;
10
17
  constructor(onEntries) {
11
18
  this.#onEntries = onEntries;
12
19
  }
@@ -14,10 +21,22 @@ var IntersectionWatcher = class {
14
21
  get active() {
15
22
  return this.#active;
16
23
  }
24
+ /** Whether the live observer discarded configured options after construction failed. */
25
+ get usingPlatformDefaults() {
26
+ return this.#usingPlatformDefaults;
27
+ }
17
28
  /**
18
29
  * (Re)creates the observer and observes `targets`. Returns `false` — leaving
19
30
  * the watcher inert — without `IntersectionObserver` support (very old
20
31
  * browsers; the caller's no-JS fallback stays in charge) or with no targets.
32
+ * If initial construction with the configured options fails, the watcher
33
+ * warns and retries once with the same root and platform defaults.
34
+ *
35
+ * @throws The fallback constructor error if both construction attempts fail,
36
+ * or whatever the platform throws from `observe()`. The exception is passed
37
+ * through unchanged, but the watcher rolls back first: every target observed
38
+ * so far is released and `active` stays `false`, so a caller that retries
39
+ * starts from a clean slate.
21
40
  */
22
41
  start(targets, options = {}) {
23
42
  this.stop();
@@ -25,31 +44,61 @@ var IntersectionWatcher = class {
25
44
  const list = Array.isArray(targets) ? targets : [targets];
26
45
  if (list.length === 0) return false;
27
46
  const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
28
- this.#active = true;
29
- this.#observer = new IntersectionObserver(
30
- (entries) => {
31
- if (this.#active) this.#onEntries(entries);
32
- },
33
- { root, rootMargin: options.rootMargin, threshold: options.threshold }
34
- );
35
- for (const target of list) this.#observer.observe(target);
36
- return true;
47
+ let observer = null;
48
+ try {
49
+ const onEntries = (entries) => {
50
+ if (this.#active && this.#observer === observer) this.#onEntries(entries);
51
+ };
52
+ try {
53
+ observer = new IntersectionObserver(onEntries, {
54
+ root,
55
+ rootMargin: options.rootMargin,
56
+ threshold: options.threshold
57
+ });
58
+ } catch (error) {
59
+ console.warn(
60
+ "Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
61
+ error
62
+ );
63
+ observer = new IntersectionObserver(onEntries, { root });
64
+ this.#usingPlatformDefaults = true;
65
+ }
66
+ for (const target of list) observer.observe(target);
67
+ this.#observer = observer;
68
+ this.#active = true;
69
+ return true;
70
+ } catch (error) {
71
+ observer?.disconnect();
72
+ this.#observer = null;
73
+ this.#active = false;
74
+ this.#usingPlatformDefaults = false;
75
+ throw error;
76
+ }
37
77
  }
38
78
  /**
39
79
  * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
40
80
  * only reports *changes*, but `observe()` always reports the present state,
41
81
  * so unobserve→observe turns "still intersecting" into a fresh callback.
82
+ *
83
+ * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
84
+ * first, so it never stays live with a half-rearmed target.
42
85
  */
43
86
  rearm(target) {
44
87
  if (!this.#observer) return;
45
- this.#observer.unobserve(target);
46
- this.#observer.observe(target);
88
+ try {
89
+ this.#observer.unobserve(target);
90
+ this.#observer.observe(target);
91
+ } catch (error) {
92
+ this.stop();
93
+ throw error;
94
+ }
47
95
  }
48
96
  /** Severs the observer; late queued callbacks become no-ops via the guard. */
49
97
  stop() {
50
98
  this.#active = false;
51
99
  this.#observer?.disconnect();
52
100
  this.#observer = null;
101
+ this.#usingPlatformDefaults = false;
53
102
  }
54
103
  };
55
104
 
@@ -68,6 +117,8 @@ var IntersectionController = class extends Controller {
68
117
  static events = ["enter", "exit", "change", "passed"];
69
118
  /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */
70
119
  #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
120
+ /** Threshold actually installed in the live observer (0 after option fallback). */
121
+ #effectiveThreshold = 0;
71
122
  /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */
72
123
  #generation = 0;
73
124
  #onIntersect(entries) {
@@ -75,21 +126,23 @@ var IntersectionController = class extends Controller {
75
126
  for (const entry of entries) {
76
127
  if (!this.#watcher.active || this.#generation !== generation) return;
77
128
  const ratio = entry.intersectionRatio;
78
- const threshold = this.#clampedThreshold();
129
+ const threshold = this.#effectiveThreshold;
79
130
  const intersecting = threshold > 0 ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON : entry.isIntersecting;
80
131
  this.element.style.setProperty(RATIO_PROPERTY, String(ratio));
81
132
  this.dispatch("change", { detail: { intersecting, ratio } });
82
133
  this.#syncIntersecting(intersecting, ratio, entry);
83
- this.#syncPassed(!intersecting && this.#isBefore(entry));
134
+ this.#syncPassed(!intersecting && isBeforeRootStart(entry));
84
135
  }
85
136
  }
86
137
  connect() {
87
138
  if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") return;
139
+ this.#effectiveThreshold = this.#clampedThreshold();
88
140
  this.#watcher.start(this.element, {
89
141
  rootSelector: this.rootSelectorValue,
90
142
  rootMargin: this.rootMarginValue,
91
143
  threshold: this.#thresholds()
92
144
  });
145
+ if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;
93
146
  }
94
147
  disconnect() {
95
148
  this.#watcher.stop();
@@ -128,7 +181,7 @@ var IntersectionController = class extends Controller {
128
181
  if (this.onceValue) this.#watcher.stop();
129
182
  } else if (!intersecting && previous === "true") {
130
183
  this.dispatch("exit", {
131
- detail: { ratio, position: this.#isBefore(entry) ? "before" : "after" }
184
+ detail: { ratio, position: isBeforeRootStart(entry) ? "before" : "after" }
132
185
  });
133
186
  }
134
187
  }
@@ -144,11 +197,6 @@ var IntersectionController = class extends Controller {
144
197
  const changed = previous === null ? passed : previous === "true" !== passed;
145
198
  if (changed) this.dispatch("passed", { detail: { passed } });
146
199
  }
147
- /** True when the element sits entirely before the root's start (top) edge. */
148
- #isBefore(entry) {
149
- const rootTop = entry.rootBounds?.top ?? 0;
150
- return entry.boundingClientRect.bottom <= rootTop;
151
- }
152
200
  /** The configured `threshold`, clamped to the 0..1 the observer accepts. */
153
201
  #clampedThreshold() {
154
202
  return Math.min(1, Math.max(0, this.thresholdValue));