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,8 +1,27 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/multi_select_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/announce.ts
6
25
  function announce(message, options = {}) {
7
26
  const text = message.trim();
8
27
  if (text.length === 0) return;
@@ -19,13 +38,22 @@ function fillTemplate(template, values) {
19
38
  });
20
39
  }
21
40
 
22
- // src/utils/aria_ids.ts
23
- var counter = 0;
41
+ function sharedRegistry(key, create) {
42
+ const symbol = Symbol.for(key);
43
+ const scope = globalThis;
44
+ const existing = scope[symbol];
45
+ if (existing !== void 0) return existing;
46
+ const registry = create();
47
+ Object.defineProperty(globalThis, symbol, { value: registry });
48
+ return registry;
49
+ }
50
+
51
+ var ids = sharedRegistry("stimeo-ui.aria-ids.registry.v1", () => ({ counter: 0 }));
24
52
  function uniqueId(prefix = "stimeo") {
25
53
  let candidate;
26
54
  do {
27
- counter += 1;
28
- candidate = `${prefix}-${counter}`;
55
+ ids.counter += 1;
56
+ candidate = `${prefix}-${ids.counter}`;
29
57
  } while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
30
58
  return candidate;
31
59
  }
@@ -36,12 +64,10 @@ function ensureId(element, prefix = "stimeo") {
36
64
  return id;
37
65
  }
38
66
 
39
- // src/utils/logical_scroll.ts
40
67
  function isRtl(element) {
41
68
  return window.getComputedStyle(element).direction === "rtl";
42
69
  }
43
70
 
44
- // src/utils/arrow_step.ts
45
71
  function logicalArrowKey(key, element) {
46
72
  if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
47
73
  if (!isRtl(element)) return key;
@@ -52,7 +78,143 @@ function isReservedArrowChord(event, allow = []) {
52
78
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
53
79
  }
54
80
 
55
- // src/utils/element_part.ts
81
+ function isAttributeValue(value) {
82
+ return value === null || typeof value === "string";
83
+ }
84
+ function readLeaseRecord(element, record) {
85
+ try {
86
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
87
+ if (!Array.isArray(parsed)) return void 0;
88
+ const [original, written, ...rest] = parsed;
89
+ const linked = rest[0] !== false;
90
+ const beneath = linked ? rest : rest.slice(1);
91
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
92
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
93
+ } catch {
94
+ return void 0;
95
+ }
96
+ }
97
+ var AttributeLease = class _AttributeLease {
98
+ static #registry = sharedRegistry(
99
+ "stimeo-ui.attribute-lease.registry.v1",
100
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
101
+ );
102
+ #attribute;
103
+ #record;
104
+ #base;
105
+ #held = /* @__PURE__ */ new Set();
106
+ constructor(attribute, owner, options = {}) {
107
+ this.#attribute = attribute;
108
+ this.#record = `data-${owner}-${attribute}-lease`;
109
+ this.#base = options.base === true;
110
+ const names = _AttributeLease.#registry.names;
111
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
112
+ }
113
+ write(element, value) {
114
+ const stack = this.#stack(element);
115
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
116
+ this.#held.add(element);
117
+ const shows = !this.#covered(element, stack, hold);
118
+ hold.written = value;
119
+ this.#mark(element, stack);
120
+ if (shows) this.#reflect(element, value);
121
+ }
122
+ return(element) {
123
+ const held = this.#held.delete(element);
124
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
125
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
126
+ const hold = this.#take(stack);
127
+ if (!hold) return false;
128
+ const carries = element.getAttribute(this.#attribute) === hold.written;
129
+ const owns = carries && !this.#covered(element, stack, hold);
130
+ const index = stack.indexOf(hold);
131
+ stack.splice(index, 1);
132
+ const above = stack[index];
133
+ if (above) above.linked &&= hold.linked;
134
+ this.#mark(element, stack);
135
+ if (!owns) return true;
136
+ const below = hold.linked ? stack[index - 1] : void 0;
137
+ this.#reflect(element, below ? below.written : hold.original);
138
+ return true;
139
+ }
140
+ returnAll() {
141
+ for (const element of Array.from(this.#held)) this.return(element);
142
+ }
143
+ #stack(element) {
144
+ const stacks = _AttributeLease.#registry.stacks;
145
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
146
+ stacks.set(element, byAttribute);
147
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
148
+ byAttribute.set(this.#attribute, stack);
149
+ return stack;
150
+ }
151
+ #seed(element) {
152
+ const found = [];
153
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
154
+ const read = readLeaseRecord(element, record);
155
+ if (read) found.push({ ...read, record });
156
+ }
157
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
158
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
159
+ lease: null,
160
+ record,
161
+ written,
162
+ original,
163
+ linked: linked && index > 0
164
+ }));
165
+ }
166
+ #take(stack) {
167
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
168
+ if (hold) hold.lease = this;
169
+ return hold;
170
+ }
171
+ #acquire(element, stack) {
172
+ const current = element.getAttribute(this.#attribute);
173
+ const top = stack.at(-1);
174
+ const bottom = stack[0];
175
+ const over = top?.written === current ? top : void 0;
176
+ const from = this.#base ? bottom : over;
177
+ const hold = {
178
+ lease: this,
179
+ record: this.#record,
180
+ written: current,
181
+ original: from ? from.original : current,
182
+ linked: !this.#base && over !== void 0
183
+ };
184
+ if (this.#base && bottom) {
185
+ bottom.linked = true;
186
+ stack.unshift(hold);
187
+ } else stack.push(hold);
188
+ return hold;
189
+ }
190
+ #covered(element, stack, hold) {
191
+ const current = element.getAttribute(this.#attribute);
192
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
193
+ }
194
+ #mark(element, stack) {
195
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
196
+ stack.forEach((hold, index) => {
197
+ const names = stack.slice(0, index).map((under) => under.record);
198
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
199
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
200
+ records.set(
201
+ hold.record,
202
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
203
+ );
204
+ });
205
+ for (const [name, recorded] of records) {
206
+ if (element.getAttribute(name) === recorded) continue;
207
+ if (recorded === null) element.removeAttribute(name);
208
+ else element.setAttribute(name, recorded);
209
+ }
210
+ }
211
+ #reflect(element, value) {
212
+ if (element.getAttribute(this.#attribute) === value) return;
213
+ if (value === null) element.removeAttribute(this.#attribute);
214
+ else element.setAttribute(this.#attribute, value);
215
+ }
216
+ };
217
+
56
218
  function matchingPart(root, selector) {
57
219
  return root.matches(selector) ? root : root.querySelector(selector);
58
220
  }
@@ -79,31 +241,14 @@ function readLabel(slot) {
79
241
  return text.trim();
80
242
  }
81
243
 
82
- // src/utils/roving_tabindex.ts
83
244
  var RovingTabindex = class {
84
- /** Returns the current ordered item elements; called on every operation. */
85
245
  #getItems;
86
- /**
87
- * @param getItems - Returns the current ordered item elements. Called on every
88
- * operation so the live target list is always used.
89
- */
90
246
  constructor(getItems) {
91
247
  this.#getItems = getItems;
92
248
  }
93
- /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
94
249
  get activeIndex() {
95
250
  return this.#getItems().findIndex((item) => item.tabIndex === 0);
96
251
  }
97
- /**
98
- * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
99
- * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
100
- * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
101
- * "nothing is currently tabbable".
102
- *
103
- * @param index - Position of the item to make tabbable.
104
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
105
- * and `items` to reuse an event-scoped collection snapshot.
106
- */
107
252
  setActive(index, options = {}) {
108
253
  const { focus = false } = options;
109
254
  const items = options.items ?? this.#getItems();
@@ -114,7 +259,6 @@ var RovingTabindex = class {
114
259
  }
115
260
  };
116
261
 
117
- // src/utils/chip_row.ts
118
262
  var ChipRow = class {
119
263
  #directionElement;
120
264
  #getItems;
@@ -130,10 +274,6 @@ var ChipRow = class {
130
274
  this.#onRemove = options.onRemove;
131
275
  this.#focusAfterEnd = options.focusAfterEnd;
132
276
  }
133
- /**
134
- * Binds delegation to `container`; any row currently bound is released first.
135
- * Idempotent for the same node, so target callbacks may call it freely.
136
- */
137
277
  connect(container) {
138
278
  if (this.#container === container) return;
139
279
  this.disconnect();
@@ -141,10 +281,6 @@ var ChipRow = class {
141
281
  container.addEventListener("click", this.#onClick);
142
282
  container.addEventListener("keydown", this.#onKeydown);
143
283
  }
144
- /**
145
- * Releases the current row. When `container` is supplied, a stale disconnect
146
- * callback cannot tear listeners off a newer replacement target.
147
- */
148
284
  disconnect(container) {
149
285
  const current = this.#container;
150
286
  if (!current || container !== void 0 && current !== container) return;
@@ -152,19 +288,15 @@ var ChipRow = class {
152
288
  current.removeEventListener("keydown", this.#onKeydown);
153
289
  this.#container = null;
154
290
  }
155
- /** Current remove buttons, at most one consumer-resolved button per chip item. */
156
291
  get buttons() {
157
292
  return this.#entries.map(({ button }) => button);
158
293
  }
159
- /** Number of currently navigable remove buttons. */
160
294
  get length() {
161
295
  return this.buttons.length;
162
296
  }
163
- /** Declared-item index of the last navigable chip, or `-1` for an empty row. */
164
297
  get lastIndex() {
165
298
  return this.#entries.at(-1)?.itemIndex ?? -1;
166
299
  }
167
- /** Keeps exactly one button tabbable, preferring the first authored Tab stop. */
168
300
  ensureTabStop() {
169
301
  const buttons = this.#entries.map(({ button }) => button);
170
302
  const active = buttons.findIndex((button) => button.tabIndex === 0);
@@ -173,7 +305,6 @@ var ChipRow = class {
173
305
  this.#roving.setActive(active === -1 ? 0 : active, { items: buttons });
174
306
  }
175
307
  }
176
- /** Focuses the last chip, returning false for an empty row. */
177
308
  focusLast() {
178
309
  const buttons = this.#entries.map(({ button }) => button);
179
310
  const last = buttons.length - 1;
@@ -181,10 +312,6 @@ var ChipRow = class {
181
312
  this.#roving.setActive(last, { focus: true, items: buttons });
182
313
  return true;
183
314
  }
184
- /**
185
- * Focuses the chip that followed a removed index, or the new last chip.
186
- * Returns false when removal emptied the row so the consumer can rescue focus.
187
- */
188
315
  focusAfterRemoval(index) {
189
316
  const entries = this.#entries;
190
317
  if (entries.length === 0) return false;
@@ -195,14 +322,12 @@ var ChipRow = class {
195
322
  });
196
323
  return true;
197
324
  }
198
- /** Current remove-button entries, retaining each button's declared item index. */
199
325
  get #entries() {
200
326
  return this.#getItems().flatMap((item, itemIndex) => {
201
327
  const button = this.#getButton(item);
202
328
  return button ? [{ button, itemIndex }] : [];
203
329
  });
204
330
  }
205
- /** Resolves a delegated event to the remove button owned by a declared chip. */
206
331
  #entry(event) {
207
332
  const target = event.target;
208
333
  if (!(target instanceof Element)) return null;
@@ -213,13 +338,11 @@ var ChipRow = class {
213
338
  const entry = entries[buttonIndex];
214
339
  return { ...entry, buttonIndex, buttons: entries.map(({ button: button2 }) => button2) };
215
340
  }
216
- /** Delegates removal clicks without waiting for Stimulus to wire a new chip. */
217
341
  #onClick = (event) => {
218
342
  const entry = this.#entry(event);
219
343
  if (!entry) return;
220
344
  this.#onRemove(entry.itemIndex);
221
345
  };
222
- /** Applies the shared logical-arrow and removal policy within the chip row. */
223
346
  #onKeydown = (event) => {
224
347
  if (event.defaultPrevented || isReservedArrowChord(event)) return;
225
348
  const entry = this.#entry(event);
@@ -250,7 +373,6 @@ var ChipRow = class {
250
373
  };
251
374
  };
252
375
 
253
- // src/utils/composition_tracker.ts
254
376
  var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
255
377
  "insertCompositionText",
256
378
  "insertFromComposition",
@@ -262,13 +384,11 @@ var CompositionTracker = class {
262
384
  #activeTargets = /* @__PURE__ */ new Set();
263
385
  #onStart;
264
386
  #onEnd;
265
- /** The field whose confirming `input` is still owed, while the window is open. */
266
387
  #confirmedTarget = null;
267
388
  constructor(options = {}) {
268
389
  this.#onStart = options.onStart;
269
390
  this.#onEnd = options.onEnd;
270
391
  }
271
- /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
272
392
  observe(target) {
273
393
  if (this.#observedTargets.has(target)) return;
274
394
  target.addEventListener("compositionstart", this.#handleStart);
@@ -276,7 +396,6 @@ var CompositionTracker = class {
276
396
  target.addEventListener("keydown", this.#handleKeydown);
277
397
  this.#observedTargets.add(target);
278
398
  }
279
- /** Stops tracking one target and clears any active composition it owned. */
280
399
  unobserve(target) {
281
400
  if (!this.#observedTargets.delete(target)) return;
282
401
  target.removeEventListener("compositionstart", this.#handleStart);
@@ -285,7 +404,6 @@ var CompositionTracker = class {
285
404
  this.#activeTargets.delete(target);
286
405
  if (this.#confirmedTarget === target) this.#confirmedTarget = null;
287
406
  }
288
- /** Releases every listener and clears state so reconnect starts cleanly. */
289
407
  disconnect() {
290
408
  for (const target of this.#observedTargets) {
291
409
  target.removeEventListener("compositionstart", this.#handleStart);
@@ -296,16 +414,9 @@ var CompositionTracker = class {
296
414
  this.#activeTargets.clear();
297
415
  this.#confirmedTarget = null;
298
416
  }
299
- /** True when lifecycle tracking or the current event reports composition. */
300
417
  isComposing(event) {
301
418
  return this.#activeTargets.size > 0 || event?.isComposing === true;
302
419
  }
303
- /**
304
- * Whether `event` is the `input` echoing the composition just confirmed.
305
- *
306
- * Asking closes the window either way, so one confirmation is folded at most
307
- * once and a consumer asks once per `input`.
308
- */
309
420
  consumesConfirmedInput(event) {
310
421
  const confirmed = this.#confirmedTarget;
311
422
  this.#confirmedTarget = null;
@@ -323,13 +434,11 @@ var CompositionTracker = class {
323
434
  this.#confirmedTarget = event.target;
324
435
  this.#onEnd?.(event);
325
436
  };
326
- /** A key on an observed field opens an edit of its own, so no echo is owed. */
327
437
  #handleKeydown = () => {
328
438
  this.#confirmedTarget = null;
329
439
  };
330
440
  };
331
441
 
332
- // src/utils/field_mirror.ts
333
442
  function writeFields(container, values, { name, form = "" }) {
334
443
  const current = [...container.children];
335
444
  const submits = current.length === values.length && current.every(
@@ -352,27 +461,33 @@ function commitField(target) {
352
461
  target.dispatchEvent(new Event("change", { bubbles: true }));
353
462
  }
354
463
 
355
- // src/utils/microtask_coalescer.ts
464
+ var ListenerSet = class {
465
+ #abort = new AbortController();
466
+ add(target, type, handler, options) {
467
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
468
+ }
469
+ dispose() {
470
+ this.#abort.abort();
471
+ this.#abort = new AbortController();
472
+ }
473
+ };
474
+
356
475
  var MicrotaskCoalescer = class {
357
476
  #run;
358
477
  #queued = false;
359
478
  #active = false;
360
479
  #generation = 0;
361
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
362
480
  constructor(run) {
363
481
  this.#run = run;
364
482
  }
365
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
366
483
  activate() {
367
484
  this.#active = true;
368
485
  }
369
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
370
486
  cancel() {
371
487
  this.#active = false;
372
488
  this.#queued = false;
373
489
  this.#generation += 1;
374
490
  }
375
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
376
491
  schedule() {
377
492
  if (!this.#active || this.#queued) return;
378
493
  this.#queued = true;
@@ -385,7 +500,86 @@ var MicrotaskCoalescer = class {
385
500
  }
386
501
  };
387
502
 
388
- // src/utils/option_scroll.ts
503
+ var MorphRenderWatcher = class {
504
+ #listeners = new ListenerSet();
505
+ #pass;
506
+ #element = null;
507
+ #onMorph = () => this.#pass.schedule();
508
+ constructor(run) {
509
+ this.#pass = new MicrotaskCoalescer(run);
510
+ }
511
+ observe(element) {
512
+ if (this.#element === element) return;
513
+ this.disconnect();
514
+ this.#element = element;
515
+ this.#pass.activate();
516
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
517
+ }
518
+ schedule() {
519
+ this.#pass.schedule();
520
+ }
521
+ disconnect() {
522
+ this.#listeners.dispose();
523
+ this.#pass.cancel();
524
+ this.#element = null;
525
+ }
526
+ };
527
+
528
+ var MoveCounter = class {
529
+ #sequence = 0;
530
+ record() {
531
+ this.#sequence += 1;
532
+ return this.#sequence;
533
+ }
534
+ isLatest(token) {
535
+ return token === this.#sequence;
536
+ }
537
+ };
538
+
539
+ var NUMBER_BOUNDS = {
540
+ finite: { finite: true }};
541
+ function matchesNumberBounds(value, bounds) {
542
+ if (!Number.isFinite(value)) {
543
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
544
+ if (direction === null) return false;
545
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
546
+ }
547
+ if (bounds.min !== void 0 && value < bounds.min) return false;
548
+ if (bounds.max !== void 0 && value > bounds.max) return false;
549
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
550
+ if (bounds.integer && !Number.isInteger(value)) return false;
551
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
552
+ return true;
553
+ }
554
+
555
+ function readNumber(raw, fallback, bounds) {
556
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
557
+ }
558
+
559
+ var NumberValueReader = class {
560
+ #lastRejected = /* @__PURE__ */ new Map();
561
+ read(owner, name, raw, fallback, bounds) {
562
+ const resolved = readNumber(raw, fallback, bounds);
563
+ if (matchesNumberBounds(raw, bounds)) {
564
+ this.#lastRejected.delete(name);
565
+ return resolved;
566
+ }
567
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
568
+ const literal = owner.element.getAttribute(attribute);
569
+ if (literal === null) {
570
+ this.#lastRejected.delete(name);
571
+ return resolved;
572
+ }
573
+ if (this.#lastRejected.get(name) !== literal) {
574
+ this.#lastRejected.set(name, literal);
575
+ console.warn(
576
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
577
+ );
578
+ }
579
+ return resolved;
580
+ }
581
+ };
582
+
389
583
  function scrollOptionIntoView(list, option) {
390
584
  if (list.scrollHeight <= list.clientHeight) return;
391
585
  const listRect = list.getBoundingClientRect();
@@ -397,16 +591,20 @@ function scrollOptionIntoView(list, option) {
397
591
  }
398
592
  }
399
593
 
400
- // src/utils/state_regions.ts
401
- var StateRegions = class {
594
+ var StateRegions = class _StateRegions {
595
+ static #registry = sharedRegistry(
596
+ "stimeo-ui.state-regions.registry.v1",
597
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
598
+ );
402
599
  #whenTrue;
403
600
  #whenFalse;
404
- #taken = /* @__PURE__ */ new WeakSet();
405
- constructor(options) {
601
+ #record;
602
+ constructor(options, owner) {
406
603
  this.#whenTrue = options.whenTrue;
407
604
  this.#whenFalse = options.whenFalse ?? null;
605
+ this.#record = `data-${owner}-hidden-region`;
606
+ _StateRegions.#registry.names.add(this.#record);
408
607
  }
409
- /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
410
608
  reflect(host, isTrue) {
411
609
  const shown = this.#inside(host, this.#whenTrue());
412
610
  if (!this.#whenFalse) {
@@ -422,88 +620,231 @@ var StateRegions = class {
422
620
  for (const region of shown) this.#write(region, !isTrue);
423
621
  for (const region of hiddenSide) this.#write(region, isTrue);
424
622
  }
425
- /** Writes `hidden` where it moves, noting which regions it takes out of view. */
623
+ release(host) {
624
+ for (const region of [host, ...host.querySelectorAll("*")]) {
625
+ if (!(region instanceof HTMLElement)) continue;
626
+ const stack = this.#held(region);
627
+ const hold = stack && this.#take(stack);
628
+ if (!stack || !hold) continue;
629
+ const owns = region.hidden === hold.written && !this.#covered(region, stack, hold);
630
+ const index = stack.indexOf(hold);
631
+ stack.splice(index, 1);
632
+ const above = stack[index];
633
+ if (above) above.linked &&= hold.linked;
634
+ if (owns) {
635
+ const below = hold.linked ? stack[index - 1] : void 0;
636
+ if (below) {
637
+ if (region.hidden !== below.written) region.hidden = below.written;
638
+ } else if (region.getAttribute("hidden") !== hold.authored) {
639
+ if (hold.authored === null) region.removeAttribute("hidden");
640
+ else region.setAttribute("hidden", hold.authored);
641
+ }
642
+ }
643
+ this.#mark(region, stack);
644
+ }
645
+ }
426
646
  #write(region, hidden) {
427
- if (hidden) this.#taken.add(region);
428
- else this.#taken.delete(region);
429
- if (region.hidden !== hidden) region.hidden = hidden;
647
+ const stack = this.#stack(region);
648
+ const hold = this.#take(stack) ?? this.#acquire(region, stack);
649
+ const shows = !this.#covered(region, stack, hold);
650
+ hold.written = hidden;
651
+ if (shows && region.hidden !== hidden) region.hidden = hidden;
652
+ this.#mark(region, stack);
430
653
  }
431
- /** Returns a region this instance hid; one it never hid keeps what it carries. */
432
654
  #give(region) {
433
- if (this.#taken.delete(region)) region.hidden = false;
655
+ const stack = this.#held(region);
656
+ if (stack && this.#take(stack)?.written) this.#write(region, false);
657
+ }
658
+ #held(region) {
659
+ return _StateRegions.#registry.stacks.has(region) || region.hasAttribute(this.#record) ? this.#stack(region) : void 0;
660
+ }
661
+ #stack(region) {
662
+ const stacks = _StateRegions.#registry.stacks;
663
+ const stack = stacks.get(region) ?? this.#seed(region);
664
+ stacks.set(region, stack);
665
+ return stack;
666
+ }
667
+ #seed(region) {
668
+ const found = [];
669
+ for (const record of _StateRegions.#registry.names) {
670
+ const read = this.#recorded(region, record);
671
+ if (read) found.push({ ...read, record });
672
+ }
673
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
674
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, authored, written, linked }, index) => ({
675
+ instance: null,
676
+ record,
677
+ authored,
678
+ written,
679
+ linked: linked && index > 0
680
+ }));
681
+ }
682
+ #take(stack) {
683
+ const hold = stack.find((held) => held.instance === this) ?? stack.find((held) => held.instance === null && held.record === this.#record);
684
+ if (hold) hold.instance = this;
685
+ return hold;
686
+ }
687
+ #covered(region, stack, hold) {
688
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === region.hidden);
689
+ }
690
+ #acquire(region, stack) {
691
+ const top = stack.at(-1);
692
+ const over = top && region.hidden === top.written ? top : void 0;
693
+ const hold = {
694
+ instance: this,
695
+ record: this.#record,
696
+ authored: over ? over.authored : region.getAttribute("hidden"),
697
+ written: false,
698
+ linked: over !== void 0
699
+ };
700
+ stack.push(hold);
701
+ return hold;
702
+ }
703
+ #mark(region, stack) {
704
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
705
+ stack.forEach((hold, index) => {
706
+ const names = stack.slice(0, index).map((under) => under.record);
707
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
708
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
709
+ records.set(
710
+ hold.record,
711
+ region.getAttribute("hidden") === hold.authored && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.authored, hold.written, ...marker, ...beneath])
712
+ );
713
+ });
714
+ for (const [name, recorded] of records) {
715
+ if (region.getAttribute(name) === recorded) continue;
716
+ if (recorded === null) region.removeAttribute(name);
717
+ else region.setAttribute(name, recorded);
718
+ }
719
+ }
720
+ #recorded(region, name) {
721
+ try {
722
+ const parsed = JSON.parse(region.getAttribute(name) ?? "null");
723
+ if (!Array.isArray(parsed)) return null;
724
+ const [authored, written, ...rest] = parsed;
725
+ if (authored !== null && typeof authored !== "string") return null;
726
+ const linked = rest[0] !== false;
727
+ const beneath = linked ? rest : rest.slice(1);
728
+ if (!beneath.every((under) => typeof under === "string")) return null;
729
+ return typeof written === "boolean" ? { authored, written, linked, beneath } : null;
730
+ } catch {
731
+ return null;
732
+ }
434
733
  }
435
- /** The declared regions that sit within `host`, which may be the host itself. */
436
734
  #inside(host, regions) {
437
735
  return regions.filter((region) => host.contains(region));
438
736
  }
439
737
  };
440
738
 
441
- // src/utils/before_cache_reset.ts
442
- var BeforeCacheReset = class _BeforeCacheReset {
443
- /** Every subscribed instance, iterated by the one shared document listener. */
444
- static #subscribers = /* @__PURE__ */ new Set();
445
- /** The shared listener; installed while at least one instance is subscribed. */
446
- static #onBeforeCache = () => {
447
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
448
- };
449
- #rewind;
450
- /** @param rewind - the pass that returns this controller's state to its initial form. */
451
- constructor(rewind) {
452
- this.#rewind = rewind;
453
- }
454
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
455
- activate() {
456
- const first = _BeforeCacheReset.#subscribers.size === 0;
457
- _BeforeCacheReset.#subscribers.add(this);
458
- if (first) {
459
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
739
+ function queryOpenDescendants(root, selector) {
740
+ const matches = [];
741
+ const roots = [root];
742
+ for (const scope of roots) {
743
+ matches.push(...scope.querySelectorAll(selector));
744
+ if (scope.nodeType === Node.ELEMENT_NODE) {
745
+ const shadow = scope.shadowRoot;
746
+ if (shadow) roots.push(shadow);
747
+ }
748
+ for (const element of scope.querySelectorAll("*")) {
749
+ if (element.shadowRoot) roots.push(element.shadowRoot);
460
750
  }
461
751
  }
462
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
463
- deactivate() {
464
- _BeforeCacheReset.#subscribers.delete(this);
465
- if (_BeforeCacheReset.#subscribers.size > 0) return;
466
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
467
- }
468
- };
752
+ return matches;
753
+ }
469
754
 
470
- // src/utils/tabindex_loan.ts
471
- var TabindexLoan = class {
755
+ var TabindexLoan = class _TabindexLoan {
756
+ static #registry = sharedRegistry(
757
+ "stimeo-ui.tabindex-loan.registry.v1",
758
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
759
+ );
472
760
  #value;
761
+ #record;
473
762
  #lent = /* @__PURE__ */ new Set();
474
- /** Returns live loans before Turbo can copy them into its page snapshot. */
475
- #beforeCache = new BeforeCacheReset(() => this.returnAll());
476
- /**
477
- * @param value - the `tabindex` to lend. `"-1"` (the default) is
478
- * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
479
- * which a scroll region with no focusable content of its own needs.
480
- */
481
- constructor(value = "-1") {
763
+ constructor(value, owner) {
482
764
  this.#value = value;
765
+ this.#record = `data-${owner}-tabindex-loan`;
766
+ _TabindexLoan.#registry.names.add(this.#record);
483
767
  }
484
- /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
485
768
  lend(element) {
486
- if (element.hasAttribute("tabindex")) return;
487
- element.setAttribute("tabindex", this.#value);
769
+ const stack = this.#stack(element);
770
+ const current = element.getAttribute("tabindex");
771
+ const held = stack.find((hold) => hold.loan === this) ?? stack.find(
772
+ (hold) => hold.loan === null && hold.record === this.#record && hold.value === this.#value
773
+ );
774
+ const linked = stack.at(-1)?.value === current;
775
+ if (!held && current !== null && !linked) return;
488
776
  this.#lent.add(element);
489
- this.#beforeCache.activate();
777
+ if (held) held.loan = this;
778
+ else stack.push({ loan: this, record: this.#record, value: this.#value, linked });
779
+ this.#mark(element, stack);
780
+ if (current === null || !held && current !== this.#value) {
781
+ element.setAttribute("tabindex", this.#value);
782
+ }
490
783
  }
491
- /** Takes back every loan whose value is still the one that was lent. */
492
784
  returnAll() {
493
785
  for (const element of this.#lent) {
494
- if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
786
+ const stack = this.#stack(element);
787
+ const hold = stack.find((held) => held.loan === this);
788
+ if (hold) this.#release(element, stack, hold);
495
789
  }
496
790
  this.#lent.clear();
497
- this.#beforeCache.deactivate();
791
+ }
792
+ reclaim(element) {
793
+ if (element.getAttribute(this.#record) !== this.#value) return;
794
+ const stack = this.#stack(element);
795
+ const hold = stack.find((held) => held.loan === null && held.record === this.#record);
796
+ if (hold && !element.matches(":focus")) this.#release(element, stack, hold);
797
+ }
798
+ reclaimWithin(root) {
799
+ for (const element of queryOpenDescendants(root, `[${this.#record}]`)) {
800
+ this.reclaim(element);
801
+ }
802
+ }
803
+ #stack(element) {
804
+ const stacks = _TabindexLoan.#registry.stacks;
805
+ const stack = stacks.get(element) ?? this.#seed(element);
806
+ stacks.set(element, stack);
807
+ return stack;
808
+ }
809
+ #seed(element) {
810
+ const current = element.getAttribute("tabindex");
811
+ const holds = [];
812
+ for (const record of _TabindexLoan.#registry.names) {
813
+ const value = element.getAttribute(record);
814
+ if (value !== null) holds.push({ loan: null, record, value, linked: true });
815
+ }
816
+ holds.sort((one, other) => Number(one.value === current) - Number(other.value === current));
817
+ for (const [index, hold] of holds.entries()) hold.linked = index > 0;
818
+ return holds;
819
+ }
820
+ #release(element, stack, hold) {
821
+ const current = element.getAttribute("tabindex");
822
+ const index = stack.indexOf(hold);
823
+ const owns = current === hold.value && !stack.slice(index + 1).some((above2) => above2.value === current);
824
+ stack.splice(index, 1);
825
+ const above = stack[index];
826
+ if (above) above.linked &&= hold.linked;
827
+ this.#mark(element, stack);
828
+ if (!owns) return;
829
+ const below = hold.linked ? stack[index - 1] : void 0;
830
+ if (!below) element.removeAttribute("tabindex");
831
+ else if (below.value !== current) element.setAttribute("tabindex", below.value);
832
+ }
833
+ #mark(element, stack) {
834
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
835
+ for (const hold of stack) records.set(hold.record, hold.value);
836
+ for (const [name, recorded] of records) {
837
+ if (element.getAttribute(name) === recorded) continue;
838
+ if (recorded === null) element.removeAttribute(name);
839
+ else element.setAttribute(name, recorded);
840
+ }
498
841
  }
499
842
  };
500
843
 
501
- // src/utils/target_selector.ts
502
844
  function targetSelector(identifier, name) {
503
845
  return `[data-${identifier}-target~="${name}"]`;
504
846
  }
505
847
 
506
- // src/utils/template_row.ts
507
848
  function cloneTemplateRoot(template) {
508
849
  const root = template.content.firstElementChild;
509
850
  return root instanceof HTMLElement ? root.cloneNode(true) : null;
@@ -514,22 +855,15 @@ var TemplateRow = class {
514
855
  constructor(options) {
515
856
  this.#options = options;
516
857
  }
517
- /** The attribute selector for one declared part, in this controller's namespace. */
518
858
  selector(name) {
519
859
  return targetSelector(this.#options.identifier, name);
520
860
  }
521
- /** Re-arms the once-per-connection diagnostic. */
522
861
  connect() {
523
862
  this.#warned = false;
524
863
  }
525
- /**
526
- * Names one missing part on the console, at most once per connection, and
527
- * returns `null` so a caller can hand it straight back.
528
- */
529
864
  report(missing) {
530
865
  return this.#say(`lacks ${missing}`);
531
866
  }
532
- /** Writes one diagnostic per connection, saying what the template got wrong. */
533
867
  #say(problem) {
534
868
  if (this.#warned) return null;
535
869
  this.#warned = true;
@@ -537,10 +871,6 @@ var TemplateRow = class {
537
871
  console.warn(`Stimeo UI: "${identifier}" ${outcome} because its ${noun} ${problem}.`);
538
872
  return null;
539
873
  }
540
- /**
541
- * Clones the row and resolves its parts, or names what the template got wrong
542
- * and returns `null`. `values` fills the button's authored `aria-label`.
543
- */
544
874
  instantiate(template, values) {
545
875
  const { root: rootName, required, optional, button: buttonName } = this.#options;
546
876
  const root = cloneTemplateRoot(template);
@@ -568,7 +898,6 @@ var TemplateRow = class {
568
898
  button.setAttribute("aria-label", fillTemplate(name, values));
569
899
  return { root, slots, button };
570
900
  }
571
- /** The row may be its own part; otherwise the first descendant carrying the target. */
572
901
  #resolve(root, name) {
573
902
  return matchingPart(root, this.selector(name));
574
903
  }
@@ -577,8 +906,9 @@ var TemplateRow = class {
577
906
  }
578
907
  };
579
908
 
580
- // src/controllers/multi_select_controller.ts
581
- var MultiSelectController = class extends Controller {
909
+ var MultiSelectController = class _MultiSelectController extends Controller {
910
+ #numbers = new NumberValueReader();
911
+ #moves = new MoveCounter();
582
912
  static targets = [
583
913
  "input",
584
914
  "list",
@@ -598,28 +928,28 @@ var MultiSelectController = class extends Controller {
598
928
  announceText: { type: String, default: "" },
599
929
  announceRemovedText: { type: String, default: "" }
600
930
  };
931
+ static valueConstraints = {
932
+ max: NUMBER_BOUNDS.finite
933
+ };
601
934
  static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
602
935
  static events = ["change", "filter", "reconcile"];
603
- /** Stable id of the active option; the current target is resolved from the DOM. */
604
936
  #activeOptionId = null;
605
- /** Whether the root borrowed a tab stop to catch focus, so teardown can undo it. */
606
- #tabindex = new TabindexLoan();
607
- /** Last reconciled selection, used to distinguish state changes from derived-DOM repair. */
937
+ #tabindex = new TabindexLoan("-1", this.identifier);
608
938
  #selectionValues = [];
609
- /** Whether initial normalization finished, so a fields callback cannot mirror stale state. */
610
939
  #connected = false;
611
- /** Owns `hidden` on the regions declared for the empty-result state. */
612
- #emptyRegion = new StateRegions({ whenTrue: () => this.emptyTargets });
613
- /** Collapses one batch of target callbacks into a single final-DOM reconciliation. */
614
- #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
615
- /** Owns IME lifecycle state; confirmed text emits one filter result. */
940
+ #emptyRegion = new StateRegions({ whenTrue: () => this.emptyTargets }, this.identifier);
941
+ #listHidden = new AttributeLease("hidden", this.identifier);
942
+ #expanded = new AttributeLease("aria-expanded", this.identifier);
943
+ #activeDescendant = new AttributeLease(
944
+ "aria-activedescendant",
945
+ this.identifier
946
+ );
947
+ #open = false;
948
+ #openList = null;
949
+ #reconcile = new MorphRenderWatcher(() => this.#reconcileOptions());
616
950
  #composition = new CompositionTracker({
617
- // Apply the confirmed query even in browsers that omit a final input event;
618
- // where one follows, the tracker folds it so consumers receive one filter
619
- // event for the confirmed text, not two.
620
951
  onEnd: () => this.filter()
621
952
  });
622
- /** Builds one chip from the authored template and owns its diagnostic. */
623
953
  #rows = new TemplateRow({
624
954
  identifier: this.identifier,
625
955
  root: "tag",
@@ -628,7 +958,6 @@ var MultiSelectController = class extends Controller {
628
958
  outcome: "changed no selection",
629
959
  noun: "chip template"
630
960
  });
631
- /** Shared delegated interaction for the replaceable row of removable chips. */
632
961
  #chipRow = new ChipRow({
633
962
  directionElement: this.element,
634
963
  getItems: () => this.tagTargets,
@@ -636,8 +965,8 @@ var MultiSelectController = class extends Controller {
636
965
  onRemove: (index) => this.#removeTagAt(index),
637
966
  focusAfterEnd: () => this.#focusInput()
638
967
  });
639
- /** Starts closed, syncs chips for any pre-selected options, and listens out. */
640
968
  connect() {
969
+ this.#tabindex.reclaim(this.element);
641
970
  this.#rows.connect();
642
971
  this.#normalizeSelection([]);
643
972
  this.close();
@@ -649,13 +978,8 @@ var MultiSelectController = class extends Controller {
649
978
  this.#syncFields();
650
979
  document.addEventListener("click", this.#onOutsideClick, true);
651
980
  this.#connected = true;
652
- this.#reconcile.activate();
981
+ this.#reconcile.observe(this.element);
653
982
  }
654
- /**
655
- * Derives the chips from the selected options, idempotently: a Turbo Drive cache
656
- * restore or morph can re-connect with chips already in the DOM, so they are
657
- * cleared before deriving afresh to avoid duplicates.
658
- */
659
983
  #rebuildTags() {
660
984
  if (!this.hasTagsTarget || !this.hasTagTemplateTarget) return;
661
985
  const chips = [];
@@ -668,72 +992,67 @@ var MultiSelectController = class extends Controller {
668
992
  this.tagsTarget.append(...chips);
669
993
  this.#chipRow.ensureTabStop();
670
994
  }
671
- /** Tears down document and chip listeners on disconnect (Turbo included). */
672
995
  disconnect() {
673
996
  this.#connected = false;
674
- this.#reconcile.cancel();
997
+ this.#reconcile.disconnect();
675
998
  this.#composition.disconnect();
676
999
  this.#chipRow.disconnect();
677
1000
  document.removeEventListener("click", this.#onOutsideClick, true);
678
1001
  this.#releaseTabindex();
679
1002
  }
680
- /** Reconciles active state after an option target is added at runtime. */
681
1003
  optionTargetConnected() {
682
1004
  this.#scheduleOptionReconcile();
683
1005
  }
684
- /** Cleans a removed target and reconciles active state against the surviving DOM. */
685
1006
  optionTargetDisconnected(option) {
686
1007
  option.removeAttribute("data-active");
687
1008
  this.#scheduleOptionReconcile();
688
1009
  }
689
- /** Settles a region inserted after connect on the side of the current state. */
690
1010
  emptyTargetConnected() {
691
1011
  if (this.#connected) this.#reflectEmpty();
692
1012
  }
693
- /** Rebinds delegated chip interaction when Turbo replaces the tags container. */
694
1013
  tagsTargetConnected(tags) {
695
1014
  this.#chipRow.connect(tags);
696
1015
  this.#scheduleOptionReconcile();
697
1016
  }
698
- /** Releases only the row that actually disconnected, never a newer replacement. */
699
1017
  tagsTargetDisconnected(tags) {
700
1018
  this.#chipRow.disconnect(tags);
701
1019
  this.#scheduleOptionReconcile();
702
1020
  }
703
- /** Seeds a fields target inserted after connect from the current selection. */
704
1021
  fieldsTargetConnected() {
705
1022
  if (this.#connected) this.#syncFields();
706
1023
  }
707
- /** Reconciles a runtime max change, including dropping any newly invalid overflow. */
1024
+ fieldsTargetDisconnected() {
1025
+ this.#scheduleOptionReconcile();
1026
+ }
1027
+ listTargetConnected(_list) {
1028
+ if (!this.#connected) return;
1029
+ this.#reconcileOpenState();
1030
+ this.#scheduleOptionReconcile();
1031
+ }
1032
+ listTargetDisconnected(list) {
1033
+ if (!this.listTargets.includes(list)) {
1034
+ if (list === this.#openList) {
1035
+ this.#adoptPageClose();
1036
+ this.#openList = null;
1037
+ }
1038
+ this.#listHidden.return(list);
1039
+ }
1040
+ if (!this.#connected) return;
1041
+ this.#reconcileOpenState();
1042
+ this.#scheduleOptionReconcile();
1043
+ }
708
1044
  maxValueChanged() {
709
1045
  this.#scheduleOptionReconcile();
710
1046
  }
711
- /** Rebuilds submitted fields when their public name changes at runtime. */
712
1047
  nameValueChanged() {
713
1048
  this.#scheduleOptionReconcile();
714
1049
  }
715
- /** Rebuilds submitted fields when their associated form changes at runtime. */
716
1050
  formValueChanged() {
717
1051
  this.#scheduleOptionReconcile();
718
1052
  }
719
- /** Schedules one reconciliation after all callbacks in the mutation batch. */
720
1053
  #scheduleOptionReconcile() {
721
1054
  this.#reconcile.schedule();
722
1055
  }
723
- /**
724
- * Keeps a surviving/same-id active target, otherwise falls back to the first
725
- * visible one — and brings the derived state back in line with the new option set.
726
- *
727
- * The baseline pass fills in any missing `aria-selected`, and the chips and
728
- * hidden fields are re-derived from it, because the options are the truth source
729
- * for the selection. The chips are rebuilt **only when the selected value set
730
- * actually moved**: the rebuild removes and recreates every chip, so running it
731
- * for an unrelated option would drop focus from a chip's remove button to
732
- * `<body>`, losing the keyboard user's place for something that did not concern
733
- * them.
734
- *
735
- * @stimeoRenderRoot
736
- */
737
1056
  #reconcileOptions() {
738
1057
  const visible = this.#visibleOptions;
739
1058
  const active = this.#activeOption;
@@ -762,24 +1081,23 @@ var MultiSelectController = class extends Controller {
762
1081
  const changed = !this.#sameSelection(previous, values);
763
1082
  this.#selectionValues = values;
764
1083
  if (!changed) return;
1084
+ const token = this.#moves.record();
765
1085
  const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
766
1086
  for (const value of previous) {
1087
+ if (!this.#moves.isLatest(token)) return;
767
1088
  if (!values.includes(value)) {
768
1089
  this.#announceTransition(false, previousLabels.get(value) || value, value, values.length);
769
1090
  }
770
1091
  }
771
1092
  for (const value of values) {
1093
+ if (!this.#moves.isLatest(token)) return;
772
1094
  if (previous.includes(value)) continue;
773
1095
  const option = options.get(value);
774
1096
  this.#announceTransition(true, this.#optionLabel(option), value, values.length);
775
1097
  }
1098
+ if (!this.#moves.isLatest(token)) return;
776
1099
  this.dispatch("reconcile", { detail: { values } });
777
1100
  }
778
- /**
779
- * Gives every option an explicit `aria-selected` and enforces the current cap.
780
- * Priority preserves existing chip order at runtime; a fresh connection passes
781
- * no priority, so deterministic option DOM order chooses the initial survivors.
782
- */
783
1101
  #normalizeSelection(priority) {
784
1102
  const selected = this.optionTargets.filter(
785
1103
  (option) => option.getAttribute("aria-selected") === "true"
@@ -805,25 +1123,36 @@ var MultiSelectController = class extends Controller {
805
1123
  option.setAttribute("aria-selected", String(kept.has(option)));
806
1124
  }
807
1125
  }
808
- /**
809
- * Tracks an input added initially or after connect, and makes it describe the
810
- * widget that is actually on screen: a swapped-in input arrives with the
811
- * authored ARIA of a fresh node while this controller still holds the popup
812
- * state, and the open path cannot repair that (it seeds an active option only
813
- * when there is none), so a live list would go unannounced.
814
- */
815
1126
  inputTargetConnected(input) {
816
1127
  this.#composition.observe(input);
817
- input.setAttribute("aria-expanded", String(!this.#isClosed));
818
- const active = this.#activeOption;
819
- if (active) input.setAttribute("aria-activedescendant", ensureId(active, "stimeo-ms-opt"));
820
- else input.removeAttribute("aria-activedescendant");
1128
+ this.#describeInput(input);
821
1129
  }
822
- /** Removes composition listeners when the active input is replaced or removed. */
823
1130
  inputTargetDisconnected(input) {
824
1131
  this.#composition.unobserve(input);
1132
+ if (!this.inputTargets.includes(input)) {
1133
+ this.#expanded.return(input);
1134
+ this.#activeDescendant.return(input);
1135
+ }
1136
+ if (this.#connected && this.hasInputTarget) this.#describeInput(this.inputTarget);
1137
+ }
1138
+ #describeInput(input) {
1139
+ this.#expanded.write(input, String(!this.#isClosed));
1140
+ const active = this.#activeOption;
1141
+ this.#activeDescendant.write(input, active ? ensureId(active, "stimeo-ms-opt") : null);
1142
+ }
1143
+ #reconcileOpenState() {
1144
+ if (!this.hasListTarget) return;
1145
+ this.#adoptPageClose();
1146
+ const list = this.listTarget;
1147
+ if (list !== this.#openList) {
1148
+ this.#showList(list, this.#open);
1149
+ this.#openList = list;
1150
+ }
1151
+ if (this.hasInputTarget) this.#expanded.write(this.inputTarget, String(!list.hidden));
1152
+ }
1153
+ #adoptPageClose() {
1154
+ if (this.#openList?.hidden) this.#open = false;
825
1155
  }
826
- /** Filters confirmed input text, opens, and re-seeds the active option. */
827
1156
  filter(event) {
828
1157
  if (!this.hasInputTarget) return;
829
1158
  if (event && this.#composition.consumesConfirmedInput(event)) return;
@@ -835,46 +1164,31 @@ var MultiSelectController = class extends Controller {
835
1164
  }
836
1165
  this.open();
837
1166
  const visible = this.#visibleOptions;
838
- this.#reflectEmpty();
839
1167
  this.#setActive(visible[0] ?? null);
840
1168
  this.dispatch("filter", { detail: { query } });
841
1169
  }
842
- /**
843
- * Opens the list and activates the first visible option when none is active.
844
- *
845
- * Needs the input, which owns `aria-expanded` and `aria-activedescendant`: a
846
- * list shown without one is a popup no assistive technology is told about. So
847
- * opening is skipped entirely, where {@link MultiSelectController.close | close}
848
- * still closes.
849
- */
850
1170
  open() {
851
1171
  if (!this.hasListTarget || !this.hasInputTarget) {
852
1172
  this.#reflectEmpty();
853
1173
  return;
854
1174
  }
855
- this.listTarget.hidden = false;
856
- this.inputTarget.setAttribute("aria-expanded", "true");
1175
+ this.#showList(this.listTarget, true);
1176
+ this.#open = true;
1177
+ this.#openList = this.listTarget;
1178
+ this.#expanded.write(this.inputTarget, "true");
857
1179
  if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
858
1180
  this.#reflectEmpty();
859
1181
  }
860
- /**
861
- * Closes the list and clears the active option.
862
- *
863
- * Survives a missing input in both directions. `connect()` calls this second,
864
- * so dereferencing the input here would throw before the chips, the roving
865
- * seed, the chip listeners, the hidden fields and the outside-click listener —
866
- * and Stimulus keeps the controller alive after that throw, so none of them
867
- * would ever run and the selection would silently stop submitting. An input
868
- * removed while the list is open must still let it come down *and* forget its
869
- * active option, so only the `aria-expanded` write is guarded.
870
- */
871
1182
  close() {
872
- if (this.hasListTarget) this.listTarget.hidden = true;
1183
+ this.#open = false;
1184
+ if (this.hasListTarget) {
1185
+ this.#showList(this.listTarget, false);
1186
+ this.#openList = this.listTarget;
1187
+ }
873
1188
  this.#setActive(null);
874
- if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "false");
1189
+ if (this.hasInputTarget) this.#expanded.write(this.inputTarget, "false");
875
1190
  this.#reflectEmpty();
876
1191
  }
877
- /** Routes input keyboard interaction per the multi-select combobox model. */
878
1192
  onKeydown(event) {
879
1193
  if (event.defaultPrevented) return;
880
1194
  if (isReservedArrowChord(event)) return;
@@ -939,39 +1253,20 @@ var MultiSelectController = class extends Controller {
939
1253
  break;
940
1254
  }
941
1255
  }
942
- /**
943
- * Toggles the clicked option's selection. Bound via `data-action`. Focus is
944
- * re-homed to the input afterwards: options are non-focusable, so the click blurs
945
- * the input to `body` — and with the list deliberately staying open, every
946
- * keyboard affordance (Escape, arrows, typing) is bound to the input and would
947
- * otherwise go dead until the user clicks back in ("focus stays on the input").
948
- */
949
- toggleOption(event) {
950
- const option = event.currentTarget.closest('[role="option"]');
1256
+ toggleOption(source) {
1257
+ const { event, host, origin, reason } = actionSource(source);
1258
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
1259
+ return;
1260
+ const option = host?.closest(targetSelector(this.identifier, "option"));
951
1261
  if (!option || !this.optionTargets.includes(option)) return;
952
- this.#toggleSelection(option);
953
- this.#focusInput();
954
- }
955
- /**
956
- * Re-homes focus to the input, or leaves it where it is when there is none.
957
- *
958
- * All three callers run *after* an option or a chip already took focus, and all
959
- * three are reachable without an input — options and chips carry their own
960
- * `data-action`. Throwing here would leave the chip removed but focus stranded
961
- * on a detached button.
962
- */
1262
+ const focus = event !== null || this.element.contains(document.activeElement);
1263
+ if (!this.#toggleSelection(option, reason)) return;
1264
+ if (focus && (event !== null || this.element.contains(document.activeElement)))
1265
+ this.#focusInput();
1266
+ }
963
1267
  #focusInput() {
964
1268
  if (this.hasInputTarget) this.inputTarget.focus();
965
1269
  }
966
- /**
967
- * Re-homes focus after the last chip was removed: to the input, else the root.
968
- *
969
- * Unlike the other {@link #focusInput} callers, the element that held focus has
970
- * just left the DOM, so "leave it alone" is not an option — the browser already
971
- * dropped it to `<body>`. The root borrows a `tabindex="-1"` just-in-time (not a
972
- * Tab stop, handed back before Turbo caches or on teardown). Focus that landed
973
- * on a real element is left alone, so a chip removed out of band never steals it.
974
- */
975
1270
  #focusAfterLastTag() {
976
1271
  if (this.hasInputTarget) {
977
1272
  this.inputTarget.focus();
@@ -985,15 +1280,9 @@ var MultiSelectController = class extends Controller {
985
1280
  this.#tabindex.lend(this.element);
986
1281
  this.element.focus();
987
1282
  }
988
- /**
989
- * Returns the borrowed tab stop. Owning the borrow is not enough — the value
990
- * has to still be the one this instance wrote, since a consumer that changed it
991
- * afterwards owns it now.
992
- */
993
1283
  #releaseTabindex() {
994
1284
  this.#tabindex.returnAll();
995
1285
  }
996
- /** Moves the active option by `delta` among visible options, wrapping. */
997
1286
  #moveActive(delta) {
998
1287
  const visible = this.#visibleOptions;
999
1288
  const active = this.#activeOption;
@@ -1002,41 +1291,27 @@ var MultiSelectController = class extends Controller {
1002
1291
  const next = (candidate + visible.length) % visible.length;
1003
1292
  this.#setActive(visible[next] ?? null);
1004
1293
  }
1005
- /** Selects/deselects `option`, honoring `max`, and syncs chip + announcement. */
1006
- #toggleSelection(option) {
1294
+ #toggleSelection(option, reason = "user") {
1007
1295
  const selected = option.getAttribute("aria-selected") === "true";
1008
1296
  const limit = this.#selectionLimit;
1009
1297
  if (!selected && limit > 0 && this.#selectedOptions.length >= limit) {
1010
- return;
1298
+ return true;
1011
1299
  }
1012
1300
  if (selected) {
1013
1301
  option.setAttribute("aria-selected", "false");
1014
1302
  this.#removeTagFor(option);
1015
1303
  } else {
1016
- if (this.hasTagTemplateTarget && !this.#appendTag(option)) return;
1304
+ if (this.hasTagTemplateTarget && !this.#appendTag(option)) return true;
1017
1305
  option.setAttribute("aria-selected", "true");
1018
1306
  }
1019
1307
  this.#refreshRoving();
1020
- this.#syncFields(true);
1021
- const values = this.#values;
1022
- this.#selectionValues = values;
1023
- this.#announceTransition(
1308
+ return this.#publishSelection(
1024
1309
  !selected,
1025
1310
  this.#optionLabel(option),
1026
1311
  this.#optionValue(option),
1027
- values.length
1312
+ reason
1028
1313
  );
1029
- this.dispatch("change", { detail: { values } });
1030
- }
1031
- /**
1032
- * Re-reads each chip's label from its option, in place.
1033
- *
1034
- * The value order alone does not say the chips are still correct: a server can
1035
- * re-render the same candidate with a new label ("Apple" → "Green Apple"), and
1036
- * the chip text and its `Remove {label}` name are both derived from the option.
1037
- * Updating them here keeps the rebuild — which would drop focus — for the case
1038
- * that actually needs it, a changed selection.
1039
- */
1314
+ }
1040
1315
  #refreshTagLabels(selected) {
1041
1316
  const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
1042
1317
  for (const tag of this.tagTargets) {
@@ -1050,7 +1325,6 @@ var MultiSelectController = class extends Controller {
1050
1325
  if (button.getAttribute("aria-label") !== name) button.setAttribute("aria-label", name);
1051
1326
  }
1052
1327
  }
1053
- /** Builds one chip from the template for `option`. */
1054
1328
  #appendTag(option) {
1055
1329
  if (!this.hasTagsTarget) {
1056
1330
  this.#rows.report('a "tags" target to append the chip to');
@@ -1061,14 +1335,6 @@ var MultiSelectController = class extends Controller {
1061
1335
  this.tagsTarget.appendChild(chip);
1062
1336
  return true;
1063
1337
  }
1064
- /**
1065
- * Builds one fully named chip without mutating the live tag row, or `null`
1066
- * when the authored template cannot produce one — in which case the selection
1067
- * stays untouched and the row names the missing part once per connection.
1068
- * Both callers establish the template first: a field authored without
1069
- * `tagTemplate` renders no chips at all — a supported configuration — and
1070
- * never reaches here.
1071
- */
1072
1338
  #buildTag(option) {
1073
1339
  const text = this.#optionLabel(option);
1074
1340
  const value = this.#optionValue(option);
@@ -1079,19 +1345,16 @@ var MultiSelectController = class extends Controller {
1079
1345
  row.button.tabIndex = -1;
1080
1346
  return row.root;
1081
1347
  }
1082
- /** Expands the current template's localized remove-button name. */
1083
1348
  #removeName(label, value) {
1084
1349
  if (!this.hasTagTemplateTarget) return null;
1085
1350
  const template = this.tagTemplateTarget.content.querySelector(`button${this.#rows.selector("remove")}`)?.getAttribute("aria-label")?.trim();
1086
1351
  return template ? fillTemplate(template, { label, value }) : null;
1087
1352
  }
1088
- /** Removes the chip mirroring `option`, if present. */
1089
1353
  #removeTagFor(option) {
1090
1354
  const value = this.#optionValue(option);
1091
1355
  const tag = this.tagTargets.find((candidate) => candidate.dataset.value === value);
1092
1356
  tag?.remove();
1093
1357
  }
1094
- /** Removes chip `index` and deselects its option, re-homing focus. */
1095
1358
  #removeTagAt(index, focus = "neighbor") {
1096
1359
  const tag = this.tagTargets[index];
1097
1360
  if (!tag) return;
@@ -1100,31 +1363,13 @@ var MultiSelectController = class extends Controller {
1100
1363
  if (option) option.setAttribute("aria-selected", "false");
1101
1364
  tag.remove();
1102
1365
  this.#refreshRoving();
1103
- this.#syncFields(true);
1104
- const values = this.#values;
1105
- this.#selectionValues = values;
1106
- this.#announceTransition(
1107
- false,
1108
- option ? this.#optionLabel(option) : value,
1109
- value,
1110
- values.length
1111
- );
1112
- this.dispatch("change", { detail: { values } });
1366
+ if (!this.#publishSelection(false, option ? this.#optionLabel(option) : value, value)) return;
1113
1367
  if (focus === "input") {
1114
1368
  this.#focusInput();
1115
1369
  return;
1116
1370
  }
1117
1371
  if (!this.#chipRow.focusAfterRemoval(index)) this.#focusAfterLastTag();
1118
1372
  }
1119
- /**
1120
- * Marks `option` active via `data-active` and the input's
1121
- * `aria-activedescendant` (the attribute is removed, not emptied, when null).
1122
- *
1123
- * The state half runs even with no input, so a `close()` that cannot touch ARIA
1124
- * still clears it: {@link MultiSelectController.open | open} seeds an active
1125
- * option only when there is none, so a stale one makes the next open skip the
1126
- * seeding and a replacement input gets no `aria-activedescendant` at all.
1127
- */
1128
1373
  #setActive(option) {
1129
1374
  const activeId = option ? ensureId(option, "stimeo-ms-opt") : null;
1130
1375
  this.#activeOptionId = activeId;
@@ -1132,14 +1377,8 @@ var MultiSelectController = class extends Controller {
1132
1377
  candidate.toggleAttribute("data-active", candidate === option);
1133
1378
  }
1134
1379
  if (option && this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
1135
- if (!this.hasInputTarget) return;
1136
- if (activeId !== null) {
1137
- this.inputTarget.setAttribute("aria-activedescendant", activeId);
1138
- } else {
1139
- this.inputTarget.removeAttribute("aria-activedescendant");
1140
- }
1380
+ if (this.hasInputTarget) this.#activeDescendant.write(this.inputTarget, activeId);
1141
1381
  }
1142
- /** Repairs only active identity before a key; the fallback waits for the target callback. */
1143
1382
  #reconcileActiveForInteraction() {
1144
1383
  const activeId = this.#activeOptionId;
1145
1384
  if (activeId === null) return;
@@ -1151,88 +1390,84 @@ var MultiSelectController = class extends Controller {
1151
1390
  this.#setActive(active);
1152
1391
  }
1153
1392
  }
1154
- /**
1155
- * Reflects whether the open list currently has no visible option targets, on
1156
- * the root's state attribute and on the regions declared for that state.
1157
- */
1158
1393
  #reflectEmpty() {
1159
1394
  const empty = !this.#isClosed && this.#visibleOptions.length === 0;
1160
1395
  this.element.toggleAttribute(`data-${this.identifier}-empty`, empty);
1161
1396
  this.#emptyRegion.reflect(this.element, empty);
1162
1397
  }
1163
- /**
1164
- * Mirrors the selected values into named hidden inputs under the `fields`
1165
- * target so the selection submits with a normal form (parity with tags-input).
1166
- * No-ops without a `fields` target. When the
1167
- * `form` value is set, each input gets a matching `form` attribute so the picker
1168
- * can submit with a `<form>` it lives outside of.
1169
- */
1170
- #syncFields(notify = false) {
1171
- if (!this.hasFieldsTarget) return;
1398
+ #syncFields() {
1399
+ if (!this.hasFieldsTarget) return false;
1172
1400
  const options = { name: this.nameValue, form: this.formValue };
1173
- if (writeFields(this.fieldsTarget, this.#values, options) && notify) {
1174
- commitField(this.fieldsTarget);
1175
- }
1401
+ return writeFields(this.fieldsTarget, this.#values, options);
1402
+ }
1403
+ #publishSelection(selected, label, value, reason = "user") {
1404
+ const values = this.#values;
1405
+ const changed = !this.#sameSelection(this.#selectionValues, values);
1406
+ const fieldChanged = this.#syncFields();
1407
+ this.#selectionValues = values;
1408
+ if (!changed) return true;
1409
+ const token = this.#moves.record();
1410
+ if (fieldChanged) commitField(this.fieldsTarget);
1411
+ if (!this.#moves.isLatest(token)) return false;
1412
+ this.#announceTransition(selected, label, value, values.length);
1413
+ if (!this.#moves.isLatest(token)) return false;
1414
+ this.dispatch("change", { detail: { values, reason } });
1415
+ return this.#moves.isLatest(token);
1176
1416
  }
1177
- /** Keeps exactly one chip remove button tabbable after the set changes. */
1178
1417
  #refreshRoving() {
1179
1418
  this.#chipRow.ensureTabStop();
1180
1419
  }
1181
- /**
1182
- * Sends one localized selection transition through the page's shared announcer.
1183
- *
1184
- * @stimeoRuntimeOnly The texts word the one announcement of this change.
1185
- */
1186
1420
  #announceTransition(selected, label, value, count) {
1187
1421
  const template = selected ? this.announceTextValue : this.announceRemovedTextValue;
1188
1422
  announce(fillTemplate(template, { label, value, count }));
1189
1423
  }
1190
- /** Whether two arrays represent the same selection set, independent of DOM order. */
1191
1424
  #sameSelection(left, right) {
1192
1425
  if (left.length !== right.length) return false;
1193
1426
  const a = [...left].sort();
1194
1427
  const b = [...right].sort();
1195
1428
  return a.every((value, index) => value === b[index]);
1196
1429
  }
1197
- /** Closes the list on a click outside the controller element. */
1198
1430
  #onOutsideClick = (event) => {
1199
1431
  if (!this.#isClosed && !this.element.contains(event.target)) this.close();
1200
1432
  };
1201
- /** Trimmed visible label of an option. */
1202
1433
  #optionLabel(option) {
1203
1434
  return (option.textContent ?? "").trim();
1204
1435
  }
1205
- /** An option's stable value: its `data-value`, else its display label. */
1206
1436
  #optionValue(option) {
1207
1437
  return option.dataset.value ?? this.#optionLabel(option);
1208
1438
  }
1209
- /** Options not hidden by the current filter. */
1210
1439
  get #visibleOptions() {
1211
1440
  return this.optionTargets.filter((option) => !option.hidden);
1212
1441
  }
1213
- /** Current active target resolved by stable id, never a detached node reference. */
1214
1442
  get #activeOption() {
1215
1443
  return this.optionTargets.find((option) => option.id === this.#activeOptionId) ?? null;
1216
1444
  }
1217
- /** Options currently selected. */
1218
1445
  get #selectedOptions() {
1219
1446
  return this.optionTargets.filter((option) => option.getAttribute("aria-selected") === "true");
1220
1447
  }
1221
- /** Selected values in option order. */
1222
1448
  get #values() {
1223
1449
  return this.#selectedOptions.map((option) => this.#optionValue(option));
1224
1450
  }
1225
- /** Normalized cardinality cap: zero and below are unlimited; a positive value floors, never below 1. */
1226
1451
  get #selectionLimit() {
1227
- if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
1228
- return Math.max(1, Math.floor(this.maxValue));
1452
+ if (this.#safeMax <= 0) return 0;
1453
+ return Math.max(1, Math.floor(this.#safeMax));
1454
+ }
1455
+ #showList(list, open) {
1456
+ this.#listHidden.write(list, open ? null : "");
1229
1457
  }
1230
- /** Whether the list is currently hidden. */
1231
1458
  get #isClosed() {
1232
1459
  return !this.hasListTarget || this.listTarget.hidden !== false;
1233
1460
  }
1461
+ get #safeMax() {
1462
+ return this.#numbers.read(
1463
+ this,
1464
+ "max",
1465
+ this.maxValue,
1466
+ _MultiSelectController.values.max.default,
1467
+ _MultiSelectController.valueConstraints.max
1468
+ );
1469
+ }
1234
1470
  };
1235
1471
 
1236
1472
  export { MultiSelectController };
1237
- //# sourceMappingURL=multi_select_controller.js.map
1238
1473
  //# sourceMappingURL=multi_select_controller.js.map