stimeo-ui 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +81 -12
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +85 -17
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +46 -13
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +432 -71
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -96,13 +96,19 @@ var CONSTRAINT_MESSAGE_KEYS = [
96
96
  ["stepMismatch", "step-mismatch"],
97
97
  ["badInput", "bad-input"]
98
98
  ];
99
- var MESSAGE_ATTR_PREFIX = "data-stimeo--form-validation-message-";
100
- var MESSAGE_ATTR_GENERIC = "data-stimeo--form-validation-message";
101
- var DISALLOW_ATTR = "data-stimeo--form-validation-disallow";
102
- var DISALLOW_WHITESPACE_MESSAGE = `${MESSAGE_ATTR_PREFIX}whitespace`;
99
+ var MESSAGE_PART = "message";
100
+ var DISALLOW_PART = "disallow";
103
101
  var DISALLOW_WHITESPACE_DEFAULT = "Please enter a value that is not only whitespace.";
104
102
  var FORM_FIELD_INVALID_ATTR = "data-stimeo--form-field-invalid";
105
103
  var FormValidationController = class _FormValidationController extends Controller {
104
+ /** The generic message override, in the namespace this controller is registered under. */
105
+ get #messageAttribute() {
106
+ return `data-${this.identifier}-${MESSAGE_PART}`;
107
+ }
108
+ /** Marks the `novalidate` this controller applied, so an authored one is left alone. */
109
+ get #novalidateMarker() {
110
+ return `data-${this.identifier}-${_FormValidationController.#NOVALIDATE_PART}`;
111
+ }
106
112
  static outlets = ["stimeo--form-field"];
107
113
  static values = {
108
114
  validateOnBlur: { type: Boolean, default: true },
@@ -112,8 +118,8 @@ var FormValidationController = class _FormValidationController extends Controlle
112
118
  };
113
119
  static actions = ["validate"];
114
120
  static events = ["valid", "invalid"];
115
- /** Marker recording that we added `novalidate`, so we only remove our own. */
116
- static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
121
+ /** Suffix of the marker recording that we added `novalidate`, so we only remove our own. */
122
+ static #NOVALIDATE_PART = "novalidate";
117
123
  /** Object-backed groups already interacted with — the input revalidation gate. */
118
124
  #touchedGroups = /* @__PURE__ */ new WeakSet();
119
125
  /** String-backed fallback radio groups already interacted with. */
@@ -169,7 +175,7 @@ var FormValidationController = class _FormValidationController extends Controlle
169
175
  /** Suppresses native bubbles and binds the submit / blur / input listeners. */
170
176
  connect() {
171
177
  if (setDefaultAttribute(this.element, "novalidate", "")) {
172
- this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
178
+ this.element.setAttribute(this.#novalidateMarker, "");
173
179
  }
174
180
  this.#listenerDocument = this.element.ownerDocument;
175
181
  this.#listenerDocument.addEventListener("submit", this.#onSubmit, true);
@@ -189,9 +195,9 @@ var FormValidationController = class _FormValidationController extends Controlle
189
195
  }
190
196
  this.#ownedCustomErrors.clear();
191
197
  this.#touchedRadioGroups.clear();
192
- if (this.element.hasAttribute(_FormValidationController.#NOVALIDATE_MARKER)) {
198
+ if (this.element.hasAttribute(this.#novalidateMarker)) {
193
199
  this.element.removeAttribute("novalidate");
194
- this.element.removeAttribute(_FormValidationController.#NOVALIDATE_MARKER);
200
+ this.element.removeAttribute(this.#novalidateMarker);
195
201
  }
196
202
  }
197
203
  /**
@@ -283,10 +289,10 @@ var FormValidationController = class _FormValidationController extends Controlle
283
289
  if (control.validity.customError) return control.validationMessage;
284
290
  for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {
285
291
  if (control.validity[flag]) {
286
- return this.#authoredMessage(control, `${MESSAGE_ATTR_PREFIX}${key}`) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
292
+ return this.#authoredMessage(control, `${this.#messageAttribute}-${key}`) ?? this.#authoredMessage(control, this.#messageAttribute) ?? control.validationMessage;
287
293
  }
288
294
  }
289
- return control.validationMessage || this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) || "";
295
+ return control.validationMessage || this.#authoredMessage(control, this.#messageAttribute) || "";
290
296
  }
291
297
  /**
292
298
  * Applies (or clears) a declarative custom constraint via `setCustomValidity`,
@@ -302,14 +308,14 @@ var FormValidationController = class _FormValidationController extends Controlle
302
308
  #syncCustomValidity(control) {
303
309
  const recorded = this.#ownedCustomErrors.get(control);
304
310
  const ownsCurrent = recorded !== void 0 && control.validationMessage === recorded;
305
- const violates = control.getAttribute(DISALLOW_ATTR) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
311
+ const violates = control.getAttribute(`data-${this.identifier}-${DISALLOW_PART}`) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
306
312
  if (!violates) {
307
313
  if (ownsCurrent) control.setCustomValidity("");
308
314
  this.#ownedCustomErrors.delete(control);
309
315
  return;
310
316
  }
311
317
  if (control.validity.customError && !ownsCurrent) return;
312
- const message = this.#authoredMessage(control, DISALLOW_WHITESPACE_MESSAGE) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT;
318
+ const message = this.#authoredMessage(control, `${this.#messageAttribute}-whitespace`) ?? this.#authoredMessage(control, this.#messageAttribute) ?? DISALLOW_WHITESPACE_DEFAULT;
313
319
  if (!ownsCurrent || recorded !== message) control.setCustomValidity(message);
314
320
  this.#ownedCustomErrors.set(control, message);
315
321
  }
@@ -103,6 +103,30 @@ var DetachGate = class _DetachGate {
103
103
  }
104
104
  };
105
105
 
106
+ // src/utils/listener_set.ts
107
+ var ListenerSet = class {
108
+ /** The generation every `add` joins until the next `dispose()`. */
109
+ #abort = new AbortController();
110
+ /**
111
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
112
+ *
113
+ * The set supplies the signal, so `options` carries everything else the DOM
114
+ * accepts — `capture` included, which has to match at release time and no
115
+ * longer has a second place to drift from.
116
+ */
117
+ add(target, type, handler, options) {
118
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
119
+ }
120
+ /**
121
+ * Releases every listener of the open generation, synchronously, and opens the
122
+ * next one. Idempotent, and safe before anything has been added.
123
+ */
124
+ dispose() {
125
+ this.#abort.abort();
126
+ this.#abort = new AbortController();
127
+ }
128
+ };
129
+
106
130
  // src/utils/min_duration_floor.ts
107
131
  var MinDurationFloor = class {
108
132
  #timers;
@@ -218,6 +242,7 @@ var FrameLoadingController = class extends Controller {
218
242
  #floor = new MinDurationFloor(this.#timeouts);
219
243
  #gate = new DetachGate();
220
244
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
245
+ #listeners = new ListenerSet();
221
246
  #loading = false;
222
247
  /**
223
248
  * The optional targets this controller revealed, and the content it marked inert.
@@ -237,6 +262,10 @@ var FrameLoadingController = class extends Controller {
237
262
  this.#floor.cancel();
238
263
  if (!this.#loading) this.#begin();
239
264
  };
265
+ /**
266
+ * @stimeoRuntimeOnly `minDuration` holds the loading state at least this long for the one load
267
+ * that ended.
268
+ */
240
269
  #onEnd = () => {
241
270
  if (!this.#loading) return;
242
271
  this.#floor.schedule(this.minDurationValue, () => this.#finish());
@@ -244,14 +273,12 @@ var FrameLoadingController = class extends Controller {
244
273
  connect() {
245
274
  this.#gate.cancel();
246
275
  this.#beforeCache.activate();
247
- this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
248
- this.element.addEventListener("turbo:frame-load", this.#onEnd);
249
- this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
276
+ this.#listeners.add(this.element, "turbo:before-fetch-request", this.#onStart);
277
+ this.#listeners.add(this.element, "turbo:frame-load", this.#onEnd);
278
+ this.#listeners.add(this.element, "turbo:fetch-request-error", this.#onEnd);
250
279
  }
251
280
  disconnect() {
252
- this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
253
- this.element.removeEventListener("turbo:frame-load", this.#onEnd);
254
- this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
281
+ this.#listeners.dispose();
255
282
  this.#beforeCache.deactivate();
256
283
  this.#gate.disconnected(this, () => this.#teardown());
257
284
  }
@@ -340,7 +367,12 @@ var FrameLoadingController = class extends Controller {
340
367
  this.#revealedOverlay = this.overlayTarget;
341
368
  this.overlayTarget.hidden = false;
342
369
  }
343
- /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
370
+ /**
371
+ * Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat.
372
+ *
373
+ * @stimeoRuntimeOnly `announceText` words the one announcement of this load and `restoreFocus`
374
+ * decides whether focus leaves the frame it covers.
375
+ */
344
376
  #begin() {
345
377
  this.#loading = true;
346
378
  this.#floor.begin();
@@ -353,7 +385,12 @@ var FrameLoadingController = class extends Controller {
353
385
  this.dispatch("start", { detail: {} });
354
386
  announce(fillTemplate(this.announceTextValue, {}));
355
387
  }
356
- /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
388
+ /**
389
+ * Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus.
390
+ *
391
+ * @stimeoRuntimeOnly `announceReadyText` words the one announcement of this finished load and
392
+ * `restoreFocus` decides whether focus goes back.
393
+ */
357
394
  #finish() {
358
395
  this.#loading = false;
359
396
  this.#rewindHooks();
@@ -2,11 +2,6 @@ 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
-
10
5
  // src/utils/safe_timeout.ts
11
6
  var TimerRegistry = class {
12
7
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -60,7 +55,68 @@ var SafeTimeout = class extends TimerRegistry {
60
55
  }
61
56
  };
62
57
 
58
+ // src/utils/keyed_timers.ts
59
+ var KeyedTimers = class {
60
+ #timers = new SafeTimeout();
61
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
62
+ #pending = /* @__PURE__ */ new Map();
63
+ /**
64
+ * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
65
+ * pending. The entry is dropped before the callback runs, so the callback sees
66
+ * the key unarmed and may arm it again for the next round.
67
+ */
68
+ set(key, callback, delay) {
69
+ this.clear(key);
70
+ const id = this.#timers.set(() => {
71
+ this.#pending.delete(key);
72
+ callback();
73
+ }, delay);
74
+ this.#pending.set(key, id);
75
+ }
76
+ /**
77
+ * Cancels `key`'s pending timer, if it has one.
78
+ *
79
+ * A timer id is a positive integer, so `-1` stands for "nothing pending" and
80
+ * the registry ignores an id it does not own — the unarmed case needs no
81
+ * branch of its own, and no other key's timer can be reached from here.
82
+ */
83
+ clear(key) {
84
+ this.#timers.clear(this.#pending.get(key) ?? -1);
85
+ this.#pending.delete(key);
86
+ }
87
+ /**
88
+ * Cancels every pending timer and forgets every key. Call this from a
89
+ * controller's `disconnect()` so no timer, and no entry, outlives the element.
90
+ */
91
+ clearAll() {
92
+ this.#timers.clearAll();
93
+ this.#pending.clear();
94
+ }
95
+ /** Whether `key` has a timer pending. */
96
+ has(key) {
97
+ return this.#pending.has(key);
98
+ }
99
+ };
100
+
101
+ // src/utils/reduced_motion.ts
102
+ function prefersReducedMotion() {
103
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
104
+ }
105
+
106
+ // src/utils/transient_hooks.ts
107
+ var TransientHooks = class {
108
+ #attributes;
109
+ constructor(options) {
110
+ this.#attributes = options.attributes;
111
+ }
112
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
113
+ reset(element) {
114
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
115
+ }
116
+ };
117
+
63
118
  // src/controllers/highlight_controller.ts
119
+ var TRANSIENT = new TransientHooks({ attributes: ["data-highlight"] });
64
120
  var hookOwners = /* @__PURE__ */ new WeakMap();
65
121
  var HighlightController = class extends Controller {
66
122
  static values = {
@@ -68,14 +124,11 @@ var HighlightController = class extends Controller {
68
124
  observe: { type: Boolean, default: false }
69
125
  };
70
126
  static events = ["start", "end"];
71
- #timeouts = new SafeTimeout();
72
127
  /**
73
- * The removal timer this connection has outstanding for an element. Held weakly so
74
- * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
75
- * so a cleared id can never be matched against a recycled one. Which connection owns
76
- * an element's hook is answered by the shared owner registry above.
128
+ * The removal timer this connection has outstanding for each element. Which
129
+ * connection owns an element's hook is answered by the shared owner registry above.
77
130
  */
78
- #pending = /* @__PURE__ */ new WeakMap();
131
+ #removal = new KeyedTimers();
79
132
  #observer = null;
80
133
  connect() {
81
134
  this.#clearArrivedHook(this.element);
@@ -92,13 +145,12 @@ var HighlightController = class extends Controller {
92
145
  disconnect() {
93
146
  this.#observer?.disconnect();
94
147
  this.#observer = null;
95
- this.#timeouts.clearAll();
96
- this.#pending = /* @__PURE__ */ new WeakMap();
148
+ this.#removal.clearAll();
97
149
  }
98
150
  /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
99
151
  #clearArrivedHook(el) {
100
152
  if (hookOwners.get(el) === this) hookOwners.delete(el);
101
- el.removeAttribute("data-highlight");
153
+ TRANSIENT.reset(el);
102
154
  }
103
155
  /** Highlights every element child added by a childList mutation. */
104
156
  #onMutations(mutations) {
@@ -108,19 +160,25 @@ var HighlightController = class extends Controller {
108
160
  }
109
161
  }
110
162
  }
111
- /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
163
+ /**
164
+ * Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion).
165
+ *
166
+ * @stimeoRuntimeOnly `duration` is the length of the one emphasis this call arms.
167
+ */
112
168
  #highlight(el) {
113
169
  if (prefersReducedMotion()) return;
114
170
  this.#releasePending(el);
115
171
  el.setAttribute("data-highlight", "true");
116
172
  this.dispatch("start", { target: el, detail: { element: el } });
117
- const id = this.#timeouts.set(() => {
118
- this.#pending.delete(el);
119
- hookOwners.delete(el);
120
- el.removeAttribute("data-highlight");
121
- this.dispatch("end", { target: el, detail: { element: el } });
122
- }, this.durationValue);
123
- this.#pending.set(el, id);
173
+ this.#removal.set(
174
+ el,
175
+ () => {
176
+ hookOwners.delete(el);
177
+ el.removeAttribute("data-highlight");
178
+ this.dispatch("end", { target: el, detail: { element: el } });
179
+ },
180
+ this.durationValue
181
+ );
124
182
  hookOwners.set(el, this);
125
183
  }
126
184
  /**
@@ -133,10 +191,9 @@ var HighlightController = class extends Controller {
133
191
  if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
134
192
  this.#cancelPending(el);
135
193
  }
136
- /** Releases `el`'s pending removal timer, if it has one. */
194
+ /** Releases `el`'s pending removal timer, if it has one, and this connection's claim. */
137
195
  #cancelPending(el) {
138
- this.#timeouts.clear(this.#pending.get(el) ?? -1);
139
- this.#pending.delete(el);
196
+ this.#removal.clear(el);
140
197
  hookOwners.delete(el);
141
198
  }
142
199
  };
@@ -164,6 +164,16 @@ function isScrollable(element) {
164
164
  return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
165
165
  }
166
166
 
167
+ // src/utils/state_reason.ts
168
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
169
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
170
+ function stateReasonFor(event) {
171
+ if (!event) return "api";
172
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
173
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
174
+ return "user";
175
+ }
176
+
167
177
  // src/controllers/hover_card_controller.ts
168
178
  var HoverCardController = class extends Controller {
169
179
  static targets = ["trigger", "card"];
@@ -173,6 +183,7 @@ var HoverCardController = class extends Controller {
173
183
  closeOnScroll: { type: Boolean, default: false }
174
184
  };
175
185
  static actions = ["close", "open"];
186
+ static events = ["close", "open"];
176
187
  /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
177
188
  #timers = new SafeTimeout();
178
189
  /** Escape-stack membership while open; the shared resolver dismisses via it. */
@@ -181,14 +192,18 @@ var HoverCardController = class extends Controller {
181
192
  #pendingClose = null;
182
193
  /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
183
194
  #stopScrollDismiss = null;
195
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
196
+ #reporting = false;
184
197
  /** Starts closed and discards any stale pending state from a prior connection. */
185
198
  connect() {
186
199
  this.#cancelOpen();
187
200
  this.#cancelClose();
188
- this.#conceal();
201
+ this.#conceal("api");
202
+ this.#reporting = true;
189
203
  }
190
204
  /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
191
205
  disconnect() {
206
+ this.#reporting = false;
192
207
  this.#cancelOpen();
193
208
  this.#cancelClose();
194
209
  this.#escapeLayer.deactivate();
@@ -196,16 +211,17 @@ var HoverCardController = class extends Controller {
196
211
  this.#stopScrollDismiss = null;
197
212
  }
198
213
  /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */
199
- open() {
214
+ open(event) {
215
+ const reason = stateReasonFor(event);
200
216
  this.#cancelClose();
201
217
  if (this.#isOpen || this.#pendingOpen !== null) return;
202
218
  if (this.openDelayValue <= 0) {
203
- this.#reveal();
219
+ this.#reveal(reason);
204
220
  return;
205
221
  }
206
222
  this.#pendingOpen = this.#timers.set(() => {
207
223
  this.#pendingOpen = null;
208
- this.#reveal();
224
+ this.#reveal(reason);
209
225
  }, this.openDelayValue);
210
226
  }
211
227
  /**
@@ -214,28 +230,37 @@ var HoverCardController = class extends Controller {
214
230
  * (e.g. a link in the card) and, if so, aborts the close — covering keyboard
215
231
  * traversal that the pointer-only hoverable bridge cannot.
216
232
  */
217
- close() {
233
+ close(event) {
234
+ const reason = stateReasonFor(event);
218
235
  this.#cancelOpen();
219
236
  if (!this.#isOpen || this.#pendingClose !== null) return;
220
237
  this.#pendingClose = this.#timers.set(() => {
221
238
  this.#pendingClose = null;
222
239
  if (this.element.contains(document.activeElement)) return;
223
- this.#conceal();
240
+ this.#conceal(reason);
224
241
  }, this.closeDelayValue);
225
242
  }
226
- /** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */
227
- #reveal() {
243
+ /**
244
+ * Reveals the card, reflects state, reports a move, and joins the Escape stack.
245
+ *
246
+ * @stimeoRuntimeOnly `closeOnScroll` decides whether this reveal wires the scroll dismissal; what
247
+ * is shown does not depend on it.
248
+ */
249
+ #reveal(reason) {
228
250
  if (!this.hasCardTarget) return;
251
+ const was = this.#isOpen;
229
252
  this.cardTarget.hidden = false;
230
253
  this.cardTarget.setAttribute("data-state", "open");
231
254
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
232
- this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
255
+ if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
256
+ this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss("escape") });
233
257
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
234
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
258
+ this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss("scroll"));
235
259
  }
236
260
  }
237
- /** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */
238
- #conceal() {
261
+ /** Hides the card, reflects state, reports a move, and leaves the Escape stack. */
262
+ #conceal(reason) {
263
+ const was = this.#isOpen;
239
264
  this.#escapeLayer.deactivate();
240
265
  this.#stopScrollDismiss?.();
241
266
  this.#stopScrollDismiss = null;
@@ -244,12 +269,13 @@ var HoverCardController = class extends Controller {
244
269
  this.cardTarget.setAttribute("data-state", "closed");
245
270
  }
246
271
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
272
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
247
273
  }
248
274
  /** Cancels pending timers and conceals immediately (shared Escape path). */
249
- #dismiss() {
275
+ #dismiss(reason) {
250
276
  this.#cancelOpen();
251
277
  this.#cancelClose();
252
- this.#conceal();
278
+ this.#conceal(reason);
253
279
  }
254
280
  /** Cancels any pending open timer. */
255
281
  #cancelOpen() {
@@ -55,6 +55,47 @@ var SafeTimeout = class extends TimerRegistry {
55
55
  }
56
56
  };
57
57
 
58
+ // src/utils/state_regions.ts
59
+ var StateRegions = class {
60
+ #whenTrue;
61
+ #whenFalse;
62
+ #taken = /* @__PURE__ */ new WeakSet();
63
+ constructor(options) {
64
+ this.#whenTrue = options.whenTrue;
65
+ this.#whenFalse = options.whenFalse ?? null;
66
+ }
67
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
68
+ reflect(host, isTrue) {
69
+ const shown = this.#inside(host, this.#whenTrue());
70
+ if (!this.#whenFalse) {
71
+ for (const region of shown) this.#write(region, !isTrue);
72
+ return;
73
+ }
74
+ const hiddenSide = this.#inside(host, this.#whenFalse());
75
+ if (shown.length === 0 || hiddenSide.length === 0) {
76
+ for (const region of shown) this.#give(region);
77
+ for (const region of hiddenSide) this.#give(region);
78
+ return;
79
+ }
80
+ for (const region of shown) this.#write(region, !isTrue);
81
+ for (const region of hiddenSide) this.#write(region, isTrue);
82
+ }
83
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
84
+ #write(region, hidden) {
85
+ if (hidden) this.#taken.add(region);
86
+ else this.#taken.delete(region);
87
+ if (region.hidden !== hidden) region.hidden = hidden;
88
+ }
89
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
90
+ #give(region) {
91
+ if (this.#taken.delete(region)) region.hidden = false;
92
+ }
93
+ /** The declared regions that sit within `host`, which may be the host itself. */
94
+ #inside(host, regions) {
95
+ return regions.filter((region) => host.contains(region));
96
+ }
97
+ };
98
+
58
99
  // src/utils/string_list.ts
59
100
  function parseStringList(raw, fallback = []) {
60
101
  const text = raw.trim();
@@ -87,7 +128,12 @@ var IdleController = class extends Controller {
87
128
  // runs, so one malformed attribute would stop the detector connecting.
88
129
  events: { type: String, default: "" }
89
130
  };
131
+ static targets = ["prompt", "idle"];
90
132
  static events = ["prompt", "idle", "active"];
133
+ /** The regions of the warning window, revealed alongside `data-prompt`. */
134
+ #promptRegions = new StateRegions({ whenTrue: () => this.promptTargets });
135
+ /** The regions of the elapsed timeout, revealed alongside `data-idle`. */
136
+ #idleRegions = new StateRegions({ whenTrue: () => this.idleTargets });
91
137
  #timeouts = new SafeTimeout();
92
138
  #idle = false;
93
139
  #prompted = false;
@@ -104,7 +150,7 @@ var IdleController = class extends Controller {
104
150
  if (this.#idle || this.#prompted) {
105
151
  this.#idle = false;
106
152
  this.#prompted = false;
107
- this.element.removeAttribute("data-idle");
153
+ this.#reflect();
108
154
  this.dispatch("active", { detail: {} });
109
155
  this.#arm();
110
156
  }
@@ -115,7 +161,7 @@ var IdleController = class extends Controller {
115
161
  connect() {
116
162
  this.#idle = false;
117
163
  this.#prompted = false;
118
- this.element.removeAttribute("data-idle");
164
+ this.#reflect();
119
165
  this.#boundEvents = parseStringList(this.eventsValue, DEFAULT_ACTIVITY_EVENTS);
120
166
  for (const type of this.#boundEvents) {
121
167
  document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
@@ -131,7 +177,11 @@ var IdleController = class extends Controller {
131
177
  document.removeEventListener("visibilitychange", this.#onVisibility);
132
178
  this.#timeouts.clearAll();
133
179
  }
134
- /** Schedules the prompt and idle checks from the current activity baseline. */
180
+ /**
181
+ * Schedules the prompt and idle checks from the current activity baseline.
182
+ *
183
+ * @stimeoRuntimeOnly `timeout` and `promptBefore` time the checks this call arms.
184
+ */
135
185
  #arm() {
136
186
  this.#timeouts.clearAll();
137
187
  this.#lastActivity = Date.now();
@@ -145,6 +195,9 @@ var IdleController = class extends Controller {
145
195
  * Idle-timer callback: go idle only if there has genuinely been no activity for
146
196
  * `timeout`; otherwise reschedule for the remaining time. This lets activity events
147
197
  * stay O(1) (a timestamp write) while the deadline still tracks the last activity.
198
+ *
199
+ * @stimeoRuntimeOnly `timeout` sets the deadline this one check compares against; the phase it
200
+ * shows follows the elapsed time.
148
201
  */
149
202
  #checkIdle() {
150
203
  const remaining = this.timeoutValue - (Date.now() - this.#lastActivity);
@@ -154,19 +207,51 @@ var IdleController = class extends Controller {
154
207
  }
155
208
  this.#idle = true;
156
209
  this.#prompted = false;
157
- this.element.setAttribute("data-idle", "true");
210
+ this.#reflect();
158
211
  this.dispatch("idle", { detail: {} });
159
212
  }
160
- /** Prompt-timer callback: warn at `promptBefore` before the idle deadline. */
213
+ /**
214
+ * Prompt-timer callback: warn at `promptBefore` before the idle deadline. A window
215
+ * narrowed while the cycle runs can push the warning onto the deadline or past it,
216
+ * and the phase it belongs to is the one before idle, so a lapsed cycle keeps the
217
+ * phase it reached. The next window opens with the cycle that activity arms.
218
+ *
219
+ * @stimeoRuntimeOnly `timeout` and `promptBefore` set the deadlines this one check compares
220
+ * against; the phase it shows follows the elapsed time.
221
+ */
161
222
  #checkPrompt() {
223
+ if (this.#idle) return;
162
224
  const remaining = this.timeoutValue - this.promptBeforeValue - (Date.now() - this.#lastActivity);
163
225
  if (remaining > 0) {
164
226
  this.#timeouts.set(() => this.#checkPrompt(), remaining);
165
227
  return;
166
228
  }
167
229
  this.#prompted = true;
230
+ this.#reflect();
168
231
  this.dispatch("prompt", { detail: { remaining: this.promptBeforeValue } });
169
232
  }
233
+ /** Applies the current phase to a warning region inserted or replaced at runtime. */
234
+ promptTargetConnected() {
235
+ this.#promptRegions.reflect(this.element, this.#prompted);
236
+ }
237
+ /** Applies the current phase to an idle region inserted or replaced at runtime. */
238
+ idleTargetConnected() {
239
+ this.#idleRegions.reflect(this.element, this.#idle);
240
+ }
241
+ /**
242
+ * Writes the phase to the element and to the declared regions. Both hooks and both
243
+ * regions are a pure function of the two flags, so every place that moves a flag ends
244
+ * here and the markup can never disagree with the phase the detector is in.
245
+ */
246
+ #reflect() {
247
+ const { element } = this;
248
+ if (this.#prompted) element.setAttribute("data-prompt", "true");
249
+ else element.removeAttribute("data-prompt");
250
+ if (this.#idle) element.setAttribute("data-idle", "true");
251
+ else element.removeAttribute("data-idle");
252
+ this.#promptRegions.reflect(element, this.#prompted);
253
+ this.#idleRegions.reflect(element, this.#idle);
254
+ }
170
255
  };
171
256
 
172
257
  export { IdleController };