stimeo-ui 0.1.0.pre.alpha.1

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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +72 -0
  4. data/dist/controllers/accordion_controller.js +76 -0
  5. data/dist/controllers/announcer_controller.js +184 -0
  6. data/dist/controllers/aspect_ratio_controller.js +36 -0
  7. data/dist/controllers/auto_submit_controller.js +147 -0
  8. data/dist/controllers/avatar_controller.js +66 -0
  9. data/dist/controllers/breadcrumb_controller.js +123 -0
  10. data/dist/controllers/bulk_select_controller.js +104 -0
  11. data/dist/controllers/calendar_controller.js +394 -0
  12. data/dist/controllers/character_counter_controller.js +179 -0
  13. data/dist/controllers/checkbox_controller.js +73 -0
  14. data/dist/controllers/combobox_controller.js +186 -0
  15. data/dist/controllers/command_palette_controller.js +381 -0
  16. data/dist/controllers/conditional_fields_controller.js +112 -0
  17. data/dist/controllers/confirm_controller.js +276 -0
  18. data/dist/controllers/context_menu_controller.js +112 -0
  19. data/dist/controllers/countdown_controller.js +202 -0
  20. data/dist/controllers/dialog_controller.js +207 -0
  21. data/dist/controllers/direct_upload_controller.js +212 -0
  22. data/dist/controllers/dirty_form_controller.js +128 -0
  23. data/dist/controllers/dropdown_controller.js +66 -0
  24. data/dist/controllers/empty_state_controller.js +67 -0
  25. data/dist/controllers/flash_controller.js +221 -0
  26. data/dist/controllers/focus_controller.js +216 -0
  27. data/dist/controllers/form_field_controller.js +154 -0
  28. data/dist/controllers/form_validation_controller.js +202 -0
  29. data/dist/controllers/frame_loading_controller.js +177 -0
  30. data/dist/controllers/highlight_controller.js +107 -0
  31. data/dist/controllers/hover_card_controller.js +165 -0
  32. data/dist/controllers/idle_controller.js +141 -0
  33. data/dist/controllers/input_mask_controller.js +166 -0
  34. data/dist/controllers/lazy_frame_controller.js +68 -0
  35. data/dist/controllers/listbox_controller.js +256 -0
  36. data/dist/controllers/local_time_controller.js +81 -0
  37. data/dist/controllers/menu_controller.js +134 -0
  38. data/dist/controllers/meter_controller.js +96 -0
  39. data/dist/controllers/nested_form_controller.js +131 -0
  40. data/dist/controllers/network_status_controller.js +126 -0
  41. data/dist/controllers/number_input_controller.js +306 -0
  42. data/dist/controllers/otp_controller.js +201 -0
  43. data/dist/controllers/overflow_indicator_controller.js +169 -0
  44. data/dist/controllers/overflow_menu_controller.js +274 -0
  45. data/dist/controllers/pagination_controller.js +89 -0
  46. data/dist/controllers/password_strength_controller.js +175 -0
  47. data/dist/controllers/persist_controller.js +259 -0
  48. data/dist/controllers/popover_controller.js +94 -0
  49. data/dist/controllers/portal_controller.js +63 -0
  50. data/dist/controllers/preview_guard_controller.js +69 -0
  51. data/dist/controllers/progress_controller.js +93 -0
  52. data/dist/controllers/radio_group_controller.js +128 -0
  53. data/dist/controllers/rating_controller.js +179 -0
  54. data/dist/controllers/relative_time_controller.js +129 -0
  55. data/dist/controllers/reset_before_cache_controller.js +62 -0
  56. data/dist/controllers/resizable_controller.js +163 -0
  57. data/dist/controllers/roving_controller.js +116 -0
  58. data/dist/controllers/scroll_area_controller.js +183 -0
  59. data/dist/controllers/scroll_visibility_controller.js +103 -0
  60. data/dist/controllers/scrollspy_controller.js +171 -0
  61. data/dist/controllers/skeleton_controller.js +125 -0
  62. data/dist/controllers/slider_controller.js +109 -0
  63. data/dist/controllers/spinner_controller.js +164 -0
  64. data/dist/controllers/step_indicator_controller.js +55 -0
  65. data/dist/controllers/stepper_controller.js +78 -0
  66. data/dist/controllers/stick_to_bottom_controller.js +100 -0
  67. data/dist/controllers/sticky_observer_controller.js +53 -0
  68. data/dist/controllers/submit_once_controller.js +206 -0
  69. data/dist/controllers/switch_controller.js +50 -0
  70. data/dist/controllers/tabs_controller.js +63 -0
  71. data/dist/controllers/textarea_autosize_controller.js +72 -0
  72. data/dist/controllers/theme_controller.js +154 -0
  73. data/dist/controllers/toast_controller.js +310 -0
  74. data/dist/controllers/toggle_group_controller.js +130 -0
  75. data/dist/controllers/toolbar_controller.js +113 -0
  76. data/dist/controllers/tooltip_controller.js +165 -0
  77. data/dist/controllers/transition_controller.js +203 -0
  78. data/dist/index.js +12241 -0
  79. data/dist/positioning/index.js +145 -0
  80. data/lib/generators/stimeo/install/install_generator.rb +114 -0
  81. data/lib/generators/stimeo/install/templates/stimeo.js +12 -0
  82. data/lib/stimeo/ui/version.rb +10 -0
  83. data/lib/stimeo/ui.rb +19 -0
  84. data/lib/stimeo-ui.rb +4 -0
  85. metadata +152 -0
@@ -0,0 +1,202 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/form_validation_controller.ts
4
+
5
+ // src/utils/focus_trap.ts
6
+ var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
+
8
+ // src/controllers/form_validation_controller.ts
9
+ var FormValidationController = class _FormValidationController extends Controller {
10
+ static outlets = ["stimeo--form-field"];
11
+ static values = {
12
+ validateOnBlur: { type: Boolean, default: true },
13
+ validateOnChange: { type: Boolean, default: true },
14
+ revalidateOnInput: { type: Boolean, default: true },
15
+ focusInvalid: { type: Boolean, default: true }
16
+ };
17
+ static actions = ["validate"];
18
+ static events = ["valid", "invalid"];
19
+ /** Marker recording that we added `novalidate`, so we only remove our own. */
20
+ static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
21
+ /** Controls already interacted with — the gate for blur / input (re)validation. */
22
+ #touched = /* @__PURE__ */ new WeakSet();
23
+ #onSubmit = (event) => {
24
+ if (event.target !== this.element) return;
25
+ const invalid = this.#validateAll();
26
+ if (invalid.length === 0) {
27
+ this.dispatch("valid", { detail: {} });
28
+ return;
29
+ }
30
+ event.preventDefault();
31
+ event.stopImmediatePropagation();
32
+ const first = invalid[0];
33
+ if (this.focusInvalidValue && first) this.#focusTargetFor(first)?.focus();
34
+ this.dispatch("invalid", { detail: { invalid } });
35
+ };
36
+ #onFocusOut = (event) => {
37
+ if (!this.validateOnBlurValue) return;
38
+ const control = this.#controlFrom(event.target);
39
+ if (!control) return;
40
+ const field = this.#fieldFor(control);
41
+ const related = event.relatedTarget;
42
+ if (field && related instanceof Node && field.element.contains(related)) return;
43
+ this.#touched.add(control);
44
+ this.#validateControl(control);
45
+ };
46
+ #onInput = (event) => {
47
+ if (!this.revalidateOnInputValue) return;
48
+ const control = this.#controlFrom(event.target);
49
+ if (!control || !this.#touched.has(control)) return;
50
+ this.#validateControl(control);
51
+ };
52
+ #onChange = (event) => {
53
+ if (!this.validateOnChangeValue) return;
54
+ const control = this.#controlFrom(event.target);
55
+ if (!control) return;
56
+ this.#touched.add(control);
57
+ this.#validateControl(control);
58
+ };
59
+ /** Suppresses native bubbles and binds the submit / blur / input listeners. */
60
+ connect() {
61
+ if (!this.element.hasAttribute("novalidate")) {
62
+ this.element.setAttribute("novalidate", "");
63
+ this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
64
+ }
65
+ document.addEventListener("submit", this.#onSubmit, true);
66
+ this.element.addEventListener("focusout", this.#onFocusOut);
67
+ this.element.addEventListener("input", this.#onInput);
68
+ this.element.addEventListener("change", this.#onChange);
69
+ }
70
+ /** Tears down listeners and restores `novalidate` if we added it. */
71
+ disconnect() {
72
+ document.removeEventListener("submit", this.#onSubmit, true);
73
+ this.element.removeEventListener("focusout", this.#onFocusOut);
74
+ this.element.removeEventListener("input", this.#onInput);
75
+ this.element.removeEventListener("change", this.#onChange);
76
+ if (this.element.hasAttribute(_FormValidationController.#NOVALIDATE_MARKER)) {
77
+ this.element.removeAttribute("novalidate");
78
+ this.element.removeAttribute(_FormValidationController.#NOVALIDATE_MARKER);
79
+ }
80
+ }
81
+ /**
82
+ * Validates every control now, rendering or clearing each field's message, and
83
+ * returns whether the whole form is valid. Marks every control touched so a
84
+ * later input re-validates it. Bound via `data-action`
85
+ * (`#validate`) or callable directly (e.g. before a programmatic submit).
86
+ */
87
+ validate() {
88
+ return this.#validateAll().length === 0;
89
+ }
90
+ /**
91
+ * Validates every control and returns one invalid control per field. Controls
92
+ * are grouped by field first (see {@link #keyFor}) so a field with several
93
+ * controls — a radio group, or a mirror plus its visible control — reflects
94
+ * *all* of them: a valid sibling must never clear an invalid one's message.
95
+ * Each group's first invalid control supplies the message and the focus target.
96
+ */
97
+ #validateAll() {
98
+ const groups = /* @__PURE__ */ new Map();
99
+ for (const control of this.#controls) {
100
+ this.#touched.add(control);
101
+ const field = this.#fieldFor(control);
102
+ const key = this.#keyFor(control, field);
103
+ const group = groups.get(key);
104
+ if (group) {
105
+ group.controls.push(control);
106
+ } else {
107
+ groups.set(key, { field, controls: [control] });
108
+ }
109
+ }
110
+ const invalid = [];
111
+ for (const group of groups.values()) {
112
+ const firstInvalid = this.#applyGroup(group);
113
+ if (firstInvalid) invalid.push(firstInvalid);
114
+ }
115
+ return invalid;
116
+ }
117
+ /** Re-validates the whole field a single control belongs to (or that control). */
118
+ #validateControl(control) {
119
+ const field = this.#fieldFor(control);
120
+ const key = this.#keyFor(control, field);
121
+ const controls = this.#controls.filter(
122
+ (other) => this.#keyFor(other, this.#fieldFor(other)) === key
123
+ );
124
+ this.#applyGroup({ field, controls });
125
+ }
126
+ /**
127
+ * Runs native constraint validation across a field's controls and routes the
128
+ * result to its `stimeo--form-field` outlet: the first invalid control's
129
+ * `validationMessage` is shown, an all-valid field is cleared. Returns the
130
+ * first invalid control (for the invalid list / focus), or `null` when valid.
131
+ * Routing goes through the outlet, so the ARIA wiring is never duplicated here.
132
+ */
133
+ #applyGroup(group) {
134
+ const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;
135
+ if (group.field) {
136
+ if (firstInvalid) {
137
+ group.field.setError(firstInvalid.validationMessage);
138
+ } else {
139
+ group.field.clearError();
140
+ }
141
+ }
142
+ return firstInvalid;
143
+ }
144
+ /**
145
+ * A grouping key that collects controls belonging to the same field: the owning
146
+ * `stimeo--form-field` when present, else a radio group's shared `name`, else
147
+ * the control itself (always distinct).
148
+ */
149
+ #keyFor(control, field) {
150
+ if (field) return field;
151
+ if (control instanceof HTMLInputElement && control.type === "radio" && control.name) {
152
+ return `radio:${control.name}`;
153
+ }
154
+ return control;
155
+ }
156
+ /**
157
+ * Where focus should land for an invalid control. A visible control is focused
158
+ * directly (status quo for native fields and radios). A validatable mirror
159
+ * (the `hidden` attribute) cannot receive focus, so focus is delegated to the
160
+ * visible widget: the owning field's `control` target when it is itself
161
+ * focusable, else its first focusable descendant (e.g. a roving-tabindex
162
+ * member). Resolved structurally — never by probing `focus()` — so behavior
163
+ * is deterministic and CSS-independent.
164
+ */
165
+ #focusTargetFor(control) {
166
+ if (!control.hidden) return control;
167
+ const field = this.#fieldFor(control);
168
+ if (!field?.hasControlTarget) return null;
169
+ const root = field.controlTarget;
170
+ if (root.matches(FOCUSABLE)) return root;
171
+ return root.querySelector(FOCUSABLE);
172
+ }
173
+ /** The `stimeo--form-field` outlet whose element contains `control`, if any. */
174
+ #fieldFor(control) {
175
+ const elements = this.stimeoFormFieldOutletElements;
176
+ for (let index = 0; index < elements.length; index++) {
177
+ if (elements[index]?.contains(control)) return this.stimeoFormFieldOutlets[index];
178
+ }
179
+ return void 0;
180
+ }
181
+ /** This form's native controls that participate in constraint validation. */
182
+ get #controls() {
183
+ const controls = [];
184
+ for (const element of Array.from(this.element.elements)) {
185
+ if (this.#isValidatable(element)) controls.push(element);
186
+ }
187
+ return controls;
188
+ }
189
+ /** Narrows an event target to a validatable control. */
190
+ #controlFrom(target) {
191
+ return target instanceof Element && this.#isValidatable(target) ? target : null;
192
+ }
193
+ #isValidatable(element) {
194
+ return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, hidden, and button
195
+ // controls — the exact set barred from constraint validation.
196
+ element.willValidate;
197
+ }
198
+ };
199
+
200
+ export { FormValidationController };
201
+ //# sourceMappingURL=form_validation_controller.js.map
202
+ //# sourceMappingURL=form_validation_controller.js.map
@@ -0,0 +1,177 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/frame_loading_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+
58
+ // src/controllers/frame_loading_controller.ts
59
+ var FrameLoadingController = class extends Controller {
60
+ static targets = ["content", "skeleton", "overlay"];
61
+ static values = {
62
+ minDuration: { type: Number, default: 0 },
63
+ restoreFocus: { type: Boolean, default: true }
64
+ };
65
+ static events = ["start", "end"];
66
+ #timeouts = new SafeTimeout();
67
+ #loading = false;
68
+ #startedAt = 0;
69
+ #inertApplied = false;
70
+ #previousFocus = null;
71
+ /** The id of the retreated element, used to re-find it if the load replaced it. */
72
+ #previousFocusId = "";
73
+ #onStart = () => {
74
+ this.#timeouts.clearAll();
75
+ if (!this.#loading) this.#begin();
76
+ };
77
+ #onEnd = () => {
78
+ if (!this.#loading) return;
79
+ const remaining = this.minDurationValue - (Date.now() - this.#startedAt);
80
+ if (remaining > 0) {
81
+ this.#timeouts.clearAll();
82
+ this.#timeouts.set(() => this.#finish(), remaining);
83
+ } else {
84
+ this.#finish();
85
+ }
86
+ };
87
+ connect() {
88
+ this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
89
+ this.element.addEventListener("turbo:frame-load", this.#onEnd);
90
+ this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
91
+ }
92
+ disconnect() {
93
+ this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
94
+ this.element.removeEventListener("turbo:frame-load", this.#onEnd);
95
+ this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
96
+ this.#timeouts.clearAll();
97
+ if (this.#loading) {
98
+ this.element.removeAttribute("aria-busy");
99
+ this.element.removeAttribute("data-frame-loading");
100
+ this.#clearInert();
101
+ }
102
+ this.#loading = false;
103
+ this.#previousFocus = null;
104
+ }
105
+ /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
106
+ #begin() {
107
+ this.#loading = true;
108
+ this.#startedAt = Date.now();
109
+ this.element.setAttribute("aria-busy", "true");
110
+ this.element.setAttribute("data-frame-loading", "true");
111
+ if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
112
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
113
+ this.#applyInert();
114
+ this.#retreatFocus();
115
+ this.dispatch("start", { detail: {} });
116
+ }
117
+ /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
118
+ #finish() {
119
+ this.#loading = false;
120
+ this.element.removeAttribute("aria-busy");
121
+ this.element.removeAttribute("data-frame-loading");
122
+ if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
123
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
124
+ this.#clearInert();
125
+ this.#restoreFocus();
126
+ this.dispatch("end", { detail: {} });
127
+ }
128
+ /** Marks the content inert to block double-submits while stale (if we own it). */
129
+ #applyInert() {
130
+ if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
131
+ this.contentTarget.setAttribute("inert", "");
132
+ this.#inertApplied = true;
133
+ }
134
+ #clearInert() {
135
+ if (!this.#inertApplied) return;
136
+ this.#inertApplied = false;
137
+ if (this.hasContentTarget) this.contentTarget.removeAttribute("inert");
138
+ }
139
+ /** Saves and blurs focus if it sits inside the frame about to go stale. */
140
+ #retreatFocus() {
141
+ this.#previousFocus = null;
142
+ this.#previousFocusId = "";
143
+ if (!this.restoreFocusValue) return;
144
+ const active = document.activeElement;
145
+ if (active instanceof HTMLElement && active !== this.element && this.element.contains(active)) {
146
+ this.#previousFocus = active;
147
+ this.#previousFocusId = active.id;
148
+ active.blur();
149
+ }
150
+ }
151
+ /**
152
+ * Restores focus after the load. The same node when it survived (e.g. a
153
+ * non-replacing update), else the element re-rendered with the same id inside the
154
+ * frame — Turbo frames typically re-emit the same controls. When neither is present
155
+ * (an anonymous control was replaced) focus is left where the browser put it, to
156
+ * avoid an unexpected jump (WCAG 3.2.x).
157
+ */
158
+ #restoreFocus() {
159
+ const target = this.#previousFocus;
160
+ const id = this.#previousFocusId;
161
+ this.#previousFocus = null;
162
+ this.#previousFocusId = "";
163
+ if (!this.restoreFocusValue) return;
164
+ if (target?.isConnected) {
165
+ target.focus();
166
+ return;
167
+ }
168
+ if (id) {
169
+ const replacement = document.getElementById(id);
170
+ if (replacement && this.element.contains(replacement)) replacement.focus();
171
+ }
172
+ }
173
+ };
174
+
175
+ export { FrameLoadingController };
176
+ //# sourceMappingURL=frame_loading_controller.js.map
177
+ //# sourceMappingURL=frame_loading_controller.js.map
@@ -0,0 +1,107 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/highlight_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+
58
+ // src/controllers/highlight_controller.ts
59
+ var HighlightController = class extends Controller {
60
+ static values = {
61
+ duration: { type: Number, default: 1500 },
62
+ observe: { type: Boolean, default: false }
63
+ };
64
+ static events = ["start", "end"];
65
+ #timeouts = new SafeTimeout();
66
+ #observer = null;
67
+ connect() {
68
+ if (this.observeValue) {
69
+ if (typeof MutationObserver !== "undefined") {
70
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
71
+ this.#observer.observe(this.element, { childList: true });
72
+ }
73
+ return;
74
+ }
75
+ this.#highlight(this.element);
76
+ }
77
+ disconnect() {
78
+ this.#observer?.disconnect();
79
+ this.#observer = null;
80
+ this.#timeouts.clearAll();
81
+ }
82
+ /** Highlights every element child added by a childList mutation. */
83
+ #onMutations(mutations) {
84
+ for (const mutation of mutations) {
85
+ for (const node of mutation.addedNodes) {
86
+ if (node instanceof HTMLElement) this.#highlight(node);
87
+ }
88
+ }
89
+ }
90
+ /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
91
+ #highlight(el) {
92
+ if (this.#prefersReducedMotion()) return;
93
+ el.setAttribute("data-highlight", "true");
94
+ this.dispatch("start", { target: el, detail: { element: el } });
95
+ this.#timeouts.set(() => {
96
+ el.removeAttribute("data-highlight");
97
+ this.dispatch("end", { target: el, detail: { element: el } });
98
+ }, this.durationValue);
99
+ }
100
+ #prefersReducedMotion() {
101
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
102
+ }
103
+ };
104
+
105
+ export { HighlightController };
106
+ //# sourceMappingURL=highlight_controller.js.map
107
+ //# sourceMappingURL=highlight_controller.js.map
@@ -0,0 +1,165 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/hover_card_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+
58
+ // src/controllers/hover_card_controller.ts
59
+ var HoverCardController = class extends Controller {
60
+ static targets = ["trigger", "card"];
61
+ static values = {
62
+ openDelay: { type: Number, default: 300 },
63
+ closeDelay: { type: Number, default: 200 }
64
+ };
65
+ static actions = ["close", "onKeydown", "open"];
66
+ /** Pending open/close timers, torn down together on disconnect. */
67
+ #timers = new SafeTimeout();
68
+ #pendingOpen = null;
69
+ #pendingClose = null;
70
+ /** Starts closed. */
71
+ connect() {
72
+ this.#conceal();
73
+ }
74
+ /** Clears timers and the document `Escape` listener so nothing outlives the element. */
75
+ disconnect() {
76
+ this.#timers.clearAll();
77
+ document.removeEventListener("keydown", this.#onDocumentKeydown);
78
+ }
79
+ /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */
80
+ open() {
81
+ this.#cancelClose();
82
+ if (this.#isOpen || this.#pendingOpen !== null) return;
83
+ if (this.openDelayValue <= 0) {
84
+ this.#reveal();
85
+ return;
86
+ }
87
+ this.#pendingOpen = this.#timers.set(() => {
88
+ this.#pendingOpen = null;
89
+ this.#reveal();
90
+ }, this.openDelayValue);
91
+ }
92
+ /**
93
+ * Schedules the card to close after `closeDelay`. Cancels a pending open. The
94
+ * delayed callback re-checks whether focus has landed inside the controller
95
+ * (e.g. a link in the card) and, if so, aborts the close — covering keyboard
96
+ * traversal that the pointer-only hoverable bridge cannot.
97
+ */
98
+ close() {
99
+ this.#cancelOpen();
100
+ if (!this.#isOpen || this.#pendingClose !== null) return;
101
+ this.#pendingClose = this.#timers.set(() => {
102
+ this.#pendingClose = null;
103
+ if (this.element.contains(document.activeElement)) return;
104
+ this.#conceal();
105
+ }, this.closeDelayValue);
106
+ }
107
+ /** Closes immediately on `Escape` while open (keyboard dismissal from the trigger). */
108
+ onKeydown(event) {
109
+ if (event.key === "Escape" && this.#isOpen) {
110
+ event.preventDefault();
111
+ this.#dismiss();
112
+ }
113
+ }
114
+ /** Reveals the card, reflects state, and starts watching for a dismissing `Escape`. */
115
+ #reveal() {
116
+ if (!this.hasCardTarget) return;
117
+ this.cardTarget.hidden = false;
118
+ this.cardTarget.setAttribute("data-state", "open");
119
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
120
+ document.addEventListener("keydown", this.#onDocumentKeydown);
121
+ }
122
+ /** Hides the card, reflects state, and stops watching for `Escape`. */
123
+ #conceal() {
124
+ if (!this.hasCardTarget) return;
125
+ this.cardTarget.hidden = true;
126
+ this.cardTarget.setAttribute("data-state", "closed");
127
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
128
+ document.removeEventListener("keydown", this.#onDocumentKeydown);
129
+ }
130
+ /** Cancels pending timers and conceals immediately (shared Escape path). */
131
+ #dismiss() {
132
+ this.#cancelOpen();
133
+ this.#cancelClose();
134
+ this.#conceal();
135
+ }
136
+ /** Document-level `Escape` watcher (active only while open). */
137
+ #onDocumentKeydown = (event) => {
138
+ if (event.key === "Escape") {
139
+ event.preventDefault();
140
+ this.#dismiss();
141
+ }
142
+ };
143
+ /** Cancels any pending open timer. */
144
+ #cancelOpen() {
145
+ if (this.#pendingOpen !== null) {
146
+ this.#timers.clear(this.#pendingOpen);
147
+ this.#pendingOpen = null;
148
+ }
149
+ }
150
+ /** Cancels any pending close timer. */
151
+ #cancelClose() {
152
+ if (this.#pendingClose !== null) {
153
+ this.#timers.clear(this.#pendingClose);
154
+ this.#pendingClose = null;
155
+ }
156
+ }
157
+ /** Whether the card is currently visible. */
158
+ get #isOpen() {
159
+ return this.hasCardTarget && !this.cardTarget.hidden;
160
+ }
161
+ };
162
+
163
+ export { HoverCardController };
164
+ //# sourceMappingURL=hover_card_controller.js.map
165
+ //# sourceMappingURL=hover_card_controller.js.map