stimeo-ui 0.16.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,14 +1,179 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/listbox_controller.ts
3
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
4
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
5
+ function stateReasonFor(event) {
6
+ if (!event) return "api";
7
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
8
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
9
+ return "user";
10
+ }
11
+
12
+ function actionSource(source) {
13
+ if (source instanceof HTMLElement) {
14
+ return { event: null, host: source, origin: source, reason: "api" };
15
+ }
16
+ const event = source ?? null;
17
+ return {
18
+ event,
19
+ host: event?.currentTarget instanceof Element ? event.currentTarget : null,
20
+ origin: event?.target instanceof Element ? event.target : null,
21
+ reason: stateReasonFor(event)
22
+ };
23
+ }
4
24
 
5
- // src/utils/arrow_step.ts
6
25
  function isReservedArrowChord(event, allow = []) {
7
26
  if (!event.key.startsWith("Arrow")) return false;
8
27
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
28
  }
10
29
 
11
- // src/utils/field_mirror.ts
30
+ function sharedRegistry(key, create) {
31
+ const symbol = Symbol.for(key);
32
+ const scope = globalThis;
33
+ const existing = scope[symbol];
34
+ if (existing !== void 0) return existing;
35
+ const registry = create();
36
+ Object.defineProperty(globalThis, symbol, { value: registry });
37
+ return registry;
38
+ }
39
+
40
+ function isAttributeValue(value) {
41
+ return value === null || typeof value === "string";
42
+ }
43
+ function readLeaseRecord(element, record) {
44
+ try {
45
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
46
+ if (!Array.isArray(parsed)) return void 0;
47
+ const [original, written, ...rest] = parsed;
48
+ const linked = rest[0] !== false;
49
+ const beneath = linked ? rest : rest.slice(1);
50
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
51
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
52
+ } catch {
53
+ return void 0;
54
+ }
55
+ }
56
+ var AttributeLease = class _AttributeLease {
57
+ static #registry = sharedRegistry(
58
+ "stimeo-ui.attribute-lease.registry.v1",
59
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
60
+ );
61
+ #attribute;
62
+ #record;
63
+ #base;
64
+ #held = /* @__PURE__ */ new Set();
65
+ constructor(attribute, owner, options = {}) {
66
+ this.#attribute = attribute;
67
+ this.#record = `data-${owner}-${attribute}-lease`;
68
+ this.#base = options.base === true;
69
+ const names = _AttributeLease.#registry.names;
70
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
71
+ }
72
+ write(element, value) {
73
+ const stack = this.#stack(element);
74
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
75
+ this.#held.add(element);
76
+ const shows = !this.#covered(element, stack, hold);
77
+ hold.written = value;
78
+ this.#mark(element, stack);
79
+ if (shows) this.#reflect(element, value);
80
+ }
81
+ return(element) {
82
+ const held = this.#held.delete(element);
83
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
84
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
85
+ const hold = this.#take(stack);
86
+ if (!hold) return false;
87
+ const carries = element.getAttribute(this.#attribute) === hold.written;
88
+ const owns = carries && !this.#covered(element, stack, hold);
89
+ const index = stack.indexOf(hold);
90
+ stack.splice(index, 1);
91
+ const above = stack[index];
92
+ if (above) above.linked &&= hold.linked;
93
+ this.#mark(element, stack);
94
+ if (!owns) return true;
95
+ const below = hold.linked ? stack[index - 1] : void 0;
96
+ this.#reflect(element, below ? below.written : hold.original);
97
+ return true;
98
+ }
99
+ returnAll() {
100
+ for (const element of Array.from(this.#held)) this.return(element);
101
+ }
102
+ #stack(element) {
103
+ const stacks = _AttributeLease.#registry.stacks;
104
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
105
+ stacks.set(element, byAttribute);
106
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
107
+ byAttribute.set(this.#attribute, stack);
108
+ return stack;
109
+ }
110
+ #seed(element) {
111
+ const found = [];
112
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
113
+ const read = readLeaseRecord(element, record);
114
+ if (read) found.push({ ...read, record });
115
+ }
116
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
117
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
118
+ lease: null,
119
+ record,
120
+ written,
121
+ original,
122
+ linked: linked && index > 0
123
+ }));
124
+ }
125
+ #take(stack) {
126
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
127
+ if (hold) hold.lease = this;
128
+ return hold;
129
+ }
130
+ #acquire(element, stack) {
131
+ const current = element.getAttribute(this.#attribute);
132
+ const top = stack.at(-1);
133
+ const bottom = stack[0];
134
+ const over = top?.written === current ? top : void 0;
135
+ const from = this.#base ? bottom : over;
136
+ const hold = {
137
+ lease: this,
138
+ record: this.#record,
139
+ written: current,
140
+ original: from ? from.original : current,
141
+ linked: !this.#base && over !== void 0
142
+ };
143
+ if (this.#base && bottom) {
144
+ bottom.linked = true;
145
+ stack.unshift(hold);
146
+ } else stack.push(hold);
147
+ return hold;
148
+ }
149
+ #covered(element, stack, hold) {
150
+ const current = element.getAttribute(this.#attribute);
151
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
152
+ }
153
+ #mark(element, stack) {
154
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
155
+ stack.forEach((hold, index) => {
156
+ const names = stack.slice(0, index).map((under) => under.record);
157
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
158
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
159
+ records.set(
160
+ hold.record,
161
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
162
+ );
163
+ });
164
+ for (const [name, recorded] of records) {
165
+ if (element.getAttribute(name) === recorded) continue;
166
+ if (recorded === null) element.removeAttribute(name);
167
+ else element.setAttribute(name, recorded);
168
+ }
169
+ }
170
+ #reflect(element, value) {
171
+ if (element.getAttribute(this.#attribute) === value) return;
172
+ if (value === null) element.removeAttribute(this.#attribute);
173
+ else element.setAttribute(this.#attribute, value);
174
+ }
175
+ };
176
+
12
177
  function writeField(field, value) {
13
178
  if (field.value === value) return false;
14
179
  field.value = value;
@@ -18,27 +183,33 @@ function commitField(target) {
18
183
  target.dispatchEvent(new Event("change", { bubbles: true }));
19
184
  }
20
185
 
21
- // src/utils/microtask_coalescer.ts
186
+ var ListenerSet = class {
187
+ #abort = new AbortController();
188
+ add(target, type, handler, options) {
189
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
190
+ }
191
+ dispose() {
192
+ this.#abort.abort();
193
+ this.#abort = new AbortController();
194
+ }
195
+ };
196
+
22
197
  var MicrotaskCoalescer = class {
23
198
  #run;
24
199
  #queued = false;
25
200
  #active = false;
26
201
  #generation = 0;
27
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
28
202
  constructor(run) {
29
203
  this.#run = run;
30
204
  }
31
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
32
205
  activate() {
33
206
  this.#active = true;
34
207
  }
35
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
36
208
  cancel() {
37
209
  this.#active = false;
38
210
  this.#queued = false;
39
211
  this.#generation += 1;
40
212
  }
41
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
42
213
  schedule() {
43
214
  if (!this.#active || this.#queued) return;
44
215
  this.#queued = true;
@@ -51,7 +222,42 @@ var MicrotaskCoalescer = class {
51
222
  }
52
223
  };
53
224
 
54
- // src/utils/option_scroll.ts
225
+ var MorphRenderWatcher = class {
226
+ #listeners = new ListenerSet();
227
+ #pass;
228
+ #element = null;
229
+ #onMorph = () => this.#pass.schedule();
230
+ constructor(run) {
231
+ this.#pass = new MicrotaskCoalescer(run);
232
+ }
233
+ observe(element) {
234
+ if (this.#element === element) return;
235
+ this.disconnect();
236
+ this.#element = element;
237
+ this.#pass.activate();
238
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
239
+ }
240
+ schedule() {
241
+ this.#pass.schedule();
242
+ }
243
+ disconnect() {
244
+ this.#listeners.dispose();
245
+ this.#pass.cancel();
246
+ this.#element = null;
247
+ }
248
+ };
249
+
250
+ var MoveCounter = class {
251
+ #sequence = 0;
252
+ record() {
253
+ this.#sequence += 1;
254
+ return this.#sequence;
255
+ }
256
+ isLatest(token) {
257
+ return token === this.#sequence;
258
+ }
259
+ };
260
+
55
261
  function scrollOptionIntoView(list, option) {
56
262
  if (list.scrollHeight <= list.clientHeight) return;
57
263
  const listRect = list.getBoundingClientRect();
@@ -63,43 +269,28 @@ function scrollOptionIntoView(list, option) {
63
269
  }
64
270
  }
65
271
 
66
- // src/utils/safe_timeout.ts
272
+ function targetSelector(identifier, name) {
273
+ return `[data-${identifier}-target~="${name}"]`;
274
+ }
275
+
67
276
  var TimerRegistry = class {
68
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
69
277
  ids = /* @__PURE__ */ new Set();
70
- /**
71
- * Cancels a single tracked timer.
72
- *
73
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
74
- * registry), so callers can clear defensively without guarding.
75
- */
76
278
  clear(id) {
77
279
  if (this.ids.delete(id)) {
78
280
  this.cancel(id);
79
281
  }
80
282
  }
81
- /**
82
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
83
- * to guarantee no timer outlives the element.
84
- */
85
283
  clearAll() {
86
284
  for (const id of this.ids) {
87
285
  this.cancel(id);
88
286
  }
89
287
  this.ids.clear();
90
288
  }
91
- /** Number of timers currently tracked (pending). */
92
289
  get size() {
93
290
  return this.ids.size;
94
291
  }
95
292
  };
96
293
  var SafeTimeout = class extends TimerRegistry {
97
- /**
98
- * Schedules `callback` after `delay` ms and returns the timer id.
99
- *
100
- * The id is removed from the registry automatically when the timeout fires,
101
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
102
- */
103
294
  set(callback, delay) {
104
295
  const id = this.schedule(() => {
105
296
  this.ids.delete(id);
@@ -116,29 +307,18 @@ var SafeTimeout = class extends TimerRegistry {
116
307
  }
117
308
  };
118
309
 
119
- // src/utils/typeahead.ts
120
310
  var TYPEAHEAD_RESET_MS = 500;
121
311
  var Typeahead = class {
122
- /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */
123
312
  #timers = new SafeTimeout();
124
- /** Idle window before the query resets, in milliseconds. */
125
313
  #resetMs;
126
- /** The accumulated lowercase query, empty when idle. */
127
314
  #query = "";
128
- /** Id of the pending reset timer, `0` when none is scheduled. */
129
315
  #timerId = 0;
130
- /** @param options - Overrides for the idle window. */
131
316
  constructor({ resetMs = TYPEAHEAD_RESET_MS } = {}) {
132
317
  this.#resetMs = resetMs;
133
318
  }
134
- /** The query a search would currently run with; empty while idle. */
135
319
  get query() {
136
320
  return this.#query;
137
321
  }
138
- /**
139
- * Folds `key` into the query, restarts the idle window, and returns the query to
140
- * search with. A repeated character collapses the query to that one character.
141
- */
142
322
  push(key) {
143
323
  const char = key.toLowerCase();
144
324
  const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);
@@ -147,7 +327,6 @@ var Typeahead = class {
147
327
  this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);
148
328
  return this.#query;
149
329
  }
150
- /** Clears the query and cancels the pending idle reset. */
151
330
  reset() {
152
331
  this.#query = "";
153
332
  this.#timers.clear(this.#timerId);
@@ -174,149 +353,129 @@ function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel
174
353
  return -1;
175
354
  }
176
355
 
177
- // src/controllers/listbox_controller.ts
178
356
  var OBSERVED_ATTRIBUTES = ["aria-selected", "data-value"];
179
357
  function setAttributeIfChanged(element, name, value) {
180
358
  if (element.getAttribute(name) !== value) element.setAttribute(name, value);
181
359
  }
182
360
  var ListboxController = class extends Controller {
361
+ #moves = new MoveCounter();
183
362
  static targets = ["trigger", "value", "list", "option", "field"];
184
363
  static values = {
185
364
  placeholder: { type: String, default: "" }
186
365
  };
187
366
  static actions = ["close", "onTriggerKeydown", "open", "select", "toggle"];
188
367
  static events = ["change", "reconcile"];
189
- /** Stable ID of the active option; DOM targets are resolved afresh before use. */
190
368
  #activeId = null;
191
- /** Target ID order captured while an option is active, used only for removal fallback. */
192
369
  #activeOrder = [];
193
- /**
194
- * Collapses the target callbacks, Value changes and observed option writes of
195
- * one DOM mutation into a single pass; refused before `connect()` and after
196
- * `disconnect()`.
197
- */
198
- #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
199
- /** Watches the option attributes a page can rewrite in place; set while connected. */
370
+ #reconcile = new MorphRenderWatcher(() => this.#reconcileOptions());
200
371
  #observer = null;
201
- /** The selection last settled: on connect, by the user, or by a reported pass. */
202
372
  #settled = { option: null, value: "" };
203
- /** Accumulated typeahead query and its idle-reset timer. */
373
+ #shownList = null;
374
+ #listHidden = new AttributeLease("hidden", this.identifier);
375
+ #expanded = new AttributeLease("aria-expanded", this.identifier);
376
+ #activeDescendant = new AttributeLease(
377
+ "aria-activedescendant",
378
+ this.identifier
379
+ );
204
380
  #typeahead = new Typeahead();
205
- /**
206
- * Establishes the ARIA baseline, settles the selection without reporting it,
207
- * starts closed, and listens for outside clicks.
208
- */
209
381
  connect() {
210
382
  this.#normalizeSelection();
211
383
  this.close();
212
384
  document.addEventListener("click", this.#onOutsideClick, true);
213
385
  this.#settled = this.#selection();
214
- this.#reconcile.activate();
386
+ this.#reconcile.observe(this.element);
215
387
  this.#observeOptions();
216
388
  }
217
- /**
218
- * Establishes an inactive baseline for a late option, then re-applies the
219
- * selection baseline and re-resolves active identity once the batch is in.
220
- */
221
389
  optionTargetConnected(option) {
222
390
  option.removeAttribute("data-active");
223
391
  this.#reconcile.schedule();
224
392
  }
225
- /**
226
- * Removes controller-owned active state, then settles the selection and the
227
- * active option among the surviving targets once the batch is in.
228
- */
229
393
  optionTargetDisconnected(option) {
230
394
  option.removeAttribute("data-active");
231
395
  this.#reconcile.schedule();
232
396
  }
233
- /** Fills a form field inserted or replaced at runtime with the current selection. */
397
+ triggerTargetConnected() {
398
+ this.#reconcile.schedule();
399
+ }
400
+ triggerTargetDisconnected(trigger) {
401
+ if (!this.triggerTargets.includes(trigger)) {
402
+ this.#expanded.return(trigger);
403
+ this.#activeDescendant.return(trigger);
404
+ }
405
+ this.#reconcile.schedule();
406
+ }
407
+ listTargetConnected() {
408
+ this.#reconcile.schedule();
409
+ }
410
+ listTargetDisconnected(list) {
411
+ if (!this.listTargets.includes(list)) {
412
+ if (list === this.#shownList) this.#adoptPageClose();
413
+ this.#listHidden.return(list);
414
+ }
415
+ this.#reconcile.schedule();
416
+ }
234
417
  fieldTargetConnected() {
235
418
  this.#reconcile.schedule();
236
419
  }
237
- /** Writes the current selection into a trigger value inserted or replaced at runtime. */
420
+ fieldTargetDisconnected() {
421
+ this.#reconcile.schedule();
422
+ }
238
423
  valueTargetConnected() {
239
424
  this.#reconcile.schedule();
240
425
  }
241
- /**
242
- * Re-renders the empty-selection label when application code (or a Turbo morph)
243
- * changes `placeholder` at runtime.
244
- */
426
+ valueTargetDisconnected() {
427
+ this.#reconcile.schedule();
428
+ }
245
429
  placeholderValueChanged() {
246
430
  this.#reconcile.schedule();
247
431
  }
248
- /**
249
- * Brings the authored DOM to the shape the APG requires, and derives the state
250
- * that follows from the selection.
251
- *
252
- * Three things happen, and only these three — which option is chosen is the
253
- * author's, and is never changed:
254
- *
255
- * 1. Every option gets an explicit value. An absent `aria-selected` means "not
256
- * selectable" in ARIA, so a forgotten attribute hides a selectable option
257
- * from assistive technology.
258
- * 2. At most one stays `true`. The first in DOM order wins, since that is the
259
- * only deterministic reading of "which one did the author mean"; an option
260
- * the page inserts ahead of the selection, already selected, takes it over.
261
- * 3. The trigger label and the hidden field are derived from that selection,
262
- * an empty one included. Without this the widget announces a choice it does
263
- * not submit: the popup says "Banana", the trigger still says "Choose…", and
264
- * the form posts "".
265
- *
266
- * Nothing is reported here: `connect()` describes the initial state, and a
267
- * page-driven pass reports a moved selection itself. The scan is the `option`
268
- * target set: a `role="option"` without the target is outside the contract and
269
- * is neither counted nor written.
270
- */
271
432
  #normalizeSelection() {
272
433
  const selected = this.optionTargets.find(
273
434
  (option) => option.getAttribute("aria-selected") === "true"
274
435
  );
275
436
  this.#applySelection(selected);
276
437
  }
277
- /**
278
- * The pass the page's changes run: the selection is normalized and mirrored,
279
- * the active option re-resolved, then a selection that moved is reported. The
280
- * report comes last, so options a subscriber rewrites are the next pass's to
281
- * settle.
282
- */
283
438
  #reconcileOptions() {
284
439
  this.#normalizeSelection();
440
+ this.#reconcileOpenState();
285
441
  this.#reconcileActive();
286
442
  this.#reportMove();
287
443
  }
288
- /**
289
- * Reports the selection as `reconcile` when its option or value differs from the
290
- * selection last settled. The settled selection is replaced before dispatching,
291
- * so a move a subscriber makes is measured against what it was told.
292
- */
444
+ #reconcileOpenState() {
445
+ if (!this.hasListTarget) {
446
+ this.#shownList = null;
447
+ return;
448
+ }
449
+ if (this.#shownList && this.listTargets.includes(this.#shownList)) this.#adoptPageClose();
450
+ const list = this.listTarget;
451
+ if (this.#shownList !== null && list !== this.#shownList) {
452
+ this.#listHidden.write(list, null);
453
+ this.#shownList = list;
454
+ }
455
+ if (this.hasTriggerTarget) {
456
+ this.#expanded.write(this.triggerTarget, list.hidden ? "false" : "true");
457
+ }
458
+ }
459
+ #adoptPageClose() {
460
+ if (this.#shownList?.hidden) this.#shownList = null;
461
+ }
293
462
  #reportMove() {
294
463
  const { option, value } = this.#selection();
295
464
  if (option === this.#settled.option && value === this.#settled.value) return;
296
465
  this.#settled = { option, value };
297
466
  this.dispatch("reconcile", { detail: { value, option } });
298
467
  }
299
- /** The selected option target, if any, and the value its field submits. */
300
468
  #selection() {
301
469
  const option = this.optionTargets.find((candidate) => candidate.getAttribute("aria-selected") === "true") ?? null;
302
470
  return { option, value: option ? this.#optionValue(option) : "" };
303
471
  }
304
- /**
305
- * Releases the outside-click listener and the option observer, drops a queued
306
- * pass, and clears the typeahead timer.
307
- */
308
472
  disconnect() {
309
- this.#reconcile.cancel();
473
+ this.#reconcile.disconnect();
310
474
  this.#observer?.disconnect();
311
475
  this.#observer = null;
312
476
  document.removeEventListener("click", this.#onOutsideClick, true);
313
477
  this.#typeahead.reset();
314
478
  }
315
- /**
316
- * Toggles the list on a real mouse click. Keyboard activation of the
317
- * `<button>` also fires a click (`detail === 0`); the keydown handler already
318
- * drives that, so the synthetic click is ignored to avoid double-toggling.
319
- */
320
479
  toggle(event) {
321
480
  if (event.detail === 0) return;
322
481
  if (this.#isClosed) {
@@ -325,7 +484,6 @@ var ListboxController = class extends Controller {
325
484
  this.close();
326
485
  }
327
486
  }
328
- /** Yields claimed keys; otherwise routes the APG select-only keyboard model. */
329
487
  onTriggerKeydown(event) {
330
488
  if (event.defaultPrevented) return;
331
489
  if (isReservedArrowChord(event)) return;
@@ -351,7 +509,7 @@ var ListboxController = class extends Controller {
351
509
  switch (event.key) {
352
510
  case "ArrowDown":
353
511
  event.preventDefault();
354
- this.#setActive(activeIndex < 0 ? 0 : (activeIndex + 1) % length);
512
+ this.#setActive((activeIndex + 1) % length);
355
513
  break;
356
514
  case "ArrowUp":
357
515
  event.preventDefault();
@@ -387,19 +545,24 @@ var ListboxController = class extends Controller {
387
545
  break;
388
546
  }
389
547
  }
390
- /** Selects the clicked option and closes, returning focus to the trigger. */
391
- select(event) {
392
- const option = event.currentTarget.closest('[role="option"]');
548
+ select(source) {
549
+ const { event, host, origin, reason } = actionSource(source);
550
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
551
+ return;
552
+ const option = host?.closest(targetSelector(this.identifier, "option"));
393
553
  if (!option || !this.optionTargets.includes(option)) return;
394
- this.#selectOption(option);
554
+ const focus = event !== null || this.element.contains(document.activeElement);
555
+ if (!this.#selectOption(option, reason)) return;
395
556
  this.close();
396
- this.triggerTarget.focus();
557
+ if (focus && (event !== null || this.element.contains(document.activeElement))) {
558
+ this.triggerTarget.focus();
559
+ }
397
560
  }
398
- /** Opens the list and activates the selected option (else the first). */
399
561
  open() {
400
562
  if (!this.hasListTarget) return;
401
- this.listTarget.hidden = false;
402
- this.triggerTarget.setAttribute("aria-expanded", "true");
563
+ this.#listHidden.write(this.listTarget, null);
564
+ this.#shownList = this.listTarget;
565
+ this.#expanded.write(this.triggerTarget, "true");
403
566
  if (this.optionTargets.length === 0) {
404
567
  this.#setActive(-1);
405
568
  return;
@@ -409,37 +572,33 @@ var ListboxController = class extends Controller {
409
572
  );
410
573
  this.#setActive(selected === -1 ? 0 : selected);
411
574
  }
412
- /** Closes the list, clears the active option, and resets the typeahead buffer. */
413
575
  close() {
414
576
  if (!this.hasListTarget) return;
415
- this.listTarget.hidden = true;
416
- this.triggerTarget.setAttribute("aria-expanded", "false");
577
+ this.#listHidden.write(this.listTarget, "");
578
+ this.#shownList = null;
579
+ this.#expanded.write(this.triggerTarget, "false");
417
580
  this.#setActive(-1);
418
581
  this.#typeahead.reset();
419
582
  }
420
- /** Commits the active option (keyboard) and closes, returning focus. */
421
583
  #commitActive() {
422
- this.#reconcileActive();
423
584
  const options = this.optionTargets;
424
585
  const activeIndex = this.#findActiveIndex(options);
425
- const option = activeIndex < 0 ? void 0 : options[activeIndex];
586
+ const option = options[activeIndex];
426
587
  if (option) this.#selectOption(option);
427
588
  this.close();
428
589
  this.triggerTarget.focus();
429
590
  }
430
- /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */
431
- #selectOption(option) {
591
+ #selectOption(option, reason = "user") {
432
592
  const { value, fieldChanged } = this.#applySelection(option);
593
+ const changed = option !== this.#settled.option || value !== this.#settled.value;
433
594
  this.#settled = { option, value };
595
+ if (!changed) return true;
596
+ const token = this.#moves.record();
434
597
  if (fieldChanged) commitField(this.fieldTarget);
435
- this.dispatch("change", { detail: { value, option } });
436
- }
437
- /**
438
- * Writes `option` — or, when it is `undefined`, no selection — into
439
- * `aria-selected`, the trigger label and the hidden field. Emits nothing: the
440
- * user's commit and a page-driven pass each report it their own way, and
441
- * `connect()` only describes the state it finds.
442
- */
598
+ if (!this.#moves.isLatest(token)) return false;
599
+ this.dispatch("change", { detail: { value, option, reason } });
600
+ return this.#moves.isLatest(token);
601
+ }
443
602
  #applySelection(option) {
444
603
  for (const candidate of this.optionTargets) {
445
604
  setAttributeIfChanged(candidate, "aria-selected", candidate === option ? "true" : "false");
@@ -450,25 +609,6 @@ var ListboxController = class extends Controller {
450
609
  const fieldChanged = this.hasFieldTarget && writeField(this.fieldTarget, value);
451
610
  return { value, fieldChanged };
452
611
  }
453
- /**
454
- * Keeps the trigger value on the selection: the selected option's text, or, with
455
- * nothing selected, `placeholder` when it is set, else the text the value held
456
- * before this controller first wrote into it.
457
- *
458
- * The value is this controller's only while it holds the text last written into
459
- * it and nothing else — the `owns-label` marker carries that text — and the text
460
- * it held before is kept in the `original-label` marker. A value the page
461
- * rewrote or added an element to after that — the marker is still there but no
462
- * longer matches — is the page's: with nothing selected it is left exactly as it
463
- * is, `placeholder` or not, and the next selection remembers its text before
464
- * replacing it. A value with no marker at all — the author's, or one a morph put
465
- * back without the markers — is taken over the same way by `placeholder` or by a
466
- * selection. A value that only ever showed a selection has no text of its own to
467
- * return to, so it is emptied rather than left naming an option that is not
468
- * selected.
469
- *
470
- * @stimeoRenderRoot
471
- */
472
612
  #renderLabel(option) {
473
613
  if (!this.hasValueTarget) return;
474
614
  const label = this.valueTarget;
@@ -493,20 +633,12 @@ var ListboxController = class extends Controller {
493
633
  if (label.textContent !== text) label.textContent = text;
494
634
  setAttributeIfChanged(label, ownsLabel, text);
495
635
  }
496
- /** An option's visible label: its own text, trimmed. */
497
636
  #optionLabel(option) {
498
637
  return (option.textContent ?? "").trim();
499
638
  }
500
- /** The value an option submits: its `data-value`, else its label. */
501
639
  #optionValue(option) {
502
640
  return option.dataset.value ?? this.#optionLabel(option);
503
641
  }
504
- /**
505
- * Watches the options' `aria-selected` and `data-value`. The listbox's own writes
506
- * to them are dropped from the queue as they happen, so each record the callback
507
- * receives is the page's, and only a record on one of the listbox's own options
508
- * schedules a pass.
509
- */
510
642
  #observeOptions() {
511
643
  const observer = new MutationObserver((records) => {
512
644
  if (this.#concernsOptions(records)) this.#reconcile.schedule();
@@ -518,33 +650,16 @@ var ListboxController = class extends Controller {
518
650
  });
519
651
  this.#observer = observer;
520
652
  }
521
- /**
522
- * Whether any record is about one of this listbox's own options. A widget nested
523
- * inside it writes the same attributes on its own elements, and those are no
524
- * reason to reconcile the listbox.
525
- */
526
653
  #concernsOptions(records) {
527
654
  const options = new Set(this.optionTargets);
528
655
  return records.some((record) => options.has(record.target));
529
656
  }
530
- /**
531
- * Drops the records the listbox's own writes just queued, so the observer never
532
- * takes them for the page's. A page write queued just before goes with them and
533
- * owes nothing: every such write leaves every option's `aria-selected` at the
534
- * value the selection gives it, and the selection is then read back from the DOM,
535
- * so it already holds what the page wrote.
536
- */
537
657
  #dropOwnRecords() {
538
658
  this.#observer?.takeRecords();
539
659
  }
540
- /**
541
- * Marks the option at `index` active via `data-active` and the trigger's
542
- * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not
543
- * set to empty, per the APG).
544
- */
545
660
  #setActive(index) {
546
661
  const options = this.optionTargets;
547
- const active = index < 0 ? null : options[index] ?? null;
662
+ const active = options[index] ?? null;
548
663
  for (const option of options) {
549
664
  const marked = option.hasAttribute("data-active");
550
665
  if (option === active) {
@@ -555,15 +670,14 @@ var ListboxController = class extends Controller {
555
670
  }
556
671
  if (active?.id) {
557
672
  this.#activeId = active.id;
558
- this.triggerTarget.setAttribute("aria-activedescendant", active.id);
673
+ this.#activeDescendant.write(this.triggerTarget, active.id);
559
674
  } else {
560
675
  this.#activeId = null;
561
- this.triggerTarget.removeAttribute("aria-activedescendant");
676
+ this.#activeDescendant.write(this.triggerTarget, null);
562
677
  }
563
678
  this.#activeOrder = active ? options.map((option) => option.id).filter(Boolean) : [];
564
679
  if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);
565
680
  }
566
- /** Resolves active state against the current target collection. */
567
681
  #reconcileActive() {
568
682
  if (!this.hasTriggerTarget || this.#isClosed) {
569
683
  if (this.hasTriggerTarget) this.#setActive(-1);
@@ -583,17 +697,15 @@ var ListboxController = class extends Controller {
583
697
  return;
584
698
  }
585
699
  const activeId = this.triggerTarget.getAttribute("aria-activedescendant") ?? this.#activeId;
586
- this.#setActive(activeId ? this.#findFallbackIndex(options, activeId) : -1);
700
+ this.#setActive(this.#findFallbackIndex(options, activeId));
587
701
  }
588
- /** Finds the live target carrying the stable ID, or the active marker for an ID-less option. */
589
702
  #findActiveIndex(options) {
590
703
  const activeId = (this.hasTriggerTarget ? this.triggerTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
591
704
  if (activeId) return options.findIndex((option) => option.id === activeId);
592
705
  return options.findIndex((option) => option.hasAttribute("data-active"));
593
706
  }
594
- /** Chooses a surviving former successor, then a former predecessor. */
595
707
  #findFallbackIndex(options, activeId) {
596
- const oldIndex = this.#activeOrder.indexOf(activeId);
708
+ const oldIndex = this.#activeOrder.indexOf(activeId ?? "");
597
709
  if (oldIndex < 0) return -1;
598
710
  const indexesById = new Map(options.map((option, index) => [option.id, index]));
599
711
  for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {
@@ -606,27 +718,17 @@ var ListboxController = class extends Controller {
606
718
  }
607
719
  return -1;
608
720
  }
609
- /**
610
- * Advances the typeahead query and activates the next matching option.
611
- *
612
- * The search resumes just after the active option so repeating a character
613
- * cycles through the options starting with it, rather than re-activating the
614
- * same first match on every press.
615
- */
616
721
  #typeaheadTo(options, activeIndex, char) {
617
722
  const index = findTypeaheadMatch(options, activeIndex, this.#typeahead.push(char));
618
723
  if (index !== -1) this.#setActive(index);
619
724
  }
620
- /** Closes on an outside click before an inside handler can detach its target. */
621
725
  #onOutsideClick = (event) => {
622
726
  if (!this.#isClosed && !this.element.contains(event.target)) this.close();
623
727
  };
624
- /** Whether the list is currently hidden. */
625
728
  get #isClosed() {
626
729
  return !this.hasListTarget || this.listTarget.hidden !== false;
627
730
  }
628
731
  };
629
732
 
630
733
  export { ListboxController };
631
- //# sourceMappingURL=listbox_controller.js.map
632
734
  //# sourceMappingURL=listbox_controller.js.map