stimeo-ui 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/breadcrumb_controller.js +225 -13
  5. data/dist/controllers/calendar_controller.js +89 -22
  6. data/dist/controllers/carousel_controller.js +47 -6
  7. data/dist/controllers/collapsible_controller.js +2 -2
  8. data/dist/controllers/color_picker_controller.js +46 -7
  9. data/dist/controllers/combobox_controller.js +162 -23
  10. data/dist/controllers/command_palette_controller.js +194 -17
  11. data/dist/controllers/context_menu_controller.js +32 -10
  12. data/dist/controllers/data_grid_controller.js +82 -4
  13. data/dist/controllers/date_range_picker_controller.js +27 -3
  14. data/dist/controllers/editable_controller.js +1 -0
  15. data/dist/controllers/form_validation_controller.js +1 -1
  16. data/dist/controllers/intersection_controller.js +36 -11
  17. data/dist/controllers/lazy_frame_controller.js +31 -10
  18. data/dist/controllers/listbox_controller.js +257 -53
  19. data/dist/controllers/local_time_controller.js +2 -2
  20. data/dist/controllers/menu_controller.js +104 -17
  21. data/dist/controllers/menubar_controller.js +415 -63
  22. data/dist/controllers/multi_select_controller.js +312 -29
  23. data/dist/controllers/navigation_menu_controller.js +154 -27
  24. data/dist/controllers/number_input_controller.js +7 -0
  25. data/dist/controllers/otp_controller.js +18 -1
  26. data/dist/controllers/overflow_indicator_controller.js +81 -13
  27. data/dist/controllers/overflow_menu_controller.js +381 -57
  28. data/dist/controllers/pagination_controller.js +163 -32
  29. data/dist/controllers/persist_controller.js +6 -6
  30. data/dist/controllers/pointer_drag_controller.js +9 -1
  31. data/dist/controllers/popover_controller.js +2 -2
  32. data/dist/controllers/radio_group_controller.js +22 -3
  33. data/dist/controllers/range_slider_controller.js +32 -6
  34. data/dist/controllers/rating_controller.js +16 -2
  35. data/dist/controllers/read_more_controller.js +63 -19
  36. data/dist/controllers/resizable_controller.js +65 -1
  37. data/dist/controllers/roving_controller.js +17 -2
  38. data/dist/controllers/scroll_area_controller.js +86 -12
  39. data/dist/controllers/scroll_restore_controller.js +1 -1
  40. data/dist/controllers/scroll_visibility_controller.js +33 -3
  41. data/dist/controllers/scrollspy_controller.js +346 -73
  42. data/dist/controllers/separator_controller.js +9 -0
  43. data/dist/controllers/skeleton_controller.js +1 -1
  44. data/dist/controllers/slider_controller.js +32 -6
  45. data/dist/controllers/sortable_controller.js +34 -3
  46. data/dist/controllers/spinner_controller.js +1 -1
  47. data/dist/controllers/stick_to_bottom_controller.js +2 -1
  48. data/dist/controllers/sticky_observer_controller.js +32 -11
  49. data/dist/controllers/switch_controller.js +1 -0
  50. data/dist/controllers/tabs_controller.js +26 -3
  51. data/dist/controllers/tags_input_controller.js +22 -2
  52. data/dist/controllers/theme_controller.js +22 -3
  53. data/dist/controllers/time_picker_controller.js +20 -1
  54. data/dist/controllers/toast_controller.js +4 -5
  55. data/dist/controllers/toggle_group_controller.js +23 -2
  56. data/dist/controllers/toolbar_controller.js +230 -31
  57. data/dist/controllers/tree_view_controller.js +467 -51
  58. data/dist/index.js +3514 -689
  59. data/lib/stimeo/ui/version.rb +2 -3
  60. metadata +2 -2
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/carousel_controller.ts
4
4
 
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function logicalArrowStep(key, element) {
12
+ if (key === "ArrowDown") return 1;
13
+ if (key === "ArrowUp") return -1;
14
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return 0;
15
+ const forward = isRtl(element) ? "ArrowLeft" : "ArrowRight";
16
+ return key === forward ? 1 : -1;
17
+ }
18
+ function isReservedArrowChord(event, allow = []) {
19
+ if (!event.key.startsWith("Arrow")) return false;
20
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
+ }
22
+
5
23
  // src/utils/roving_tabindex.ts
6
24
  var RovingTabindex = class {
7
25
  /** Returns the current ordered item elements; called on every operation. */
@@ -104,6 +122,8 @@ var CarouselController = class extends Controller {
104
122
  ];
105
123
  static events = ["change", "pause", "play"];
106
124
  #roving = new RovingTabindex(() => this.pickerTargets);
125
+ /** Gates the target callback so it does not repaint once per authored picker on mount. */
126
+ #connected = false;
107
127
  #intervals = new SafeInterval();
108
128
  /** Index of the visible slide. */
109
129
  #index = 0;
@@ -113,7 +133,12 @@ var CarouselController = class extends Controller {
113
133
  #pointerPaused = false;
114
134
  /** Id of the live autoplay interval, or null when stopped. */
115
135
  #timerId = null;
116
- /** Renders the initial slide and starts autoplay when requested. */
136
+ /**
137
+ * Renders the initial slide and starts autoplay when requested.
138
+ *
139
+ * `findIndex` makes the authored pre-selection first-wins when several pickers
140
+ * are marked; `#render` then writes an explicit value onto every picker.
141
+ */
117
142
  connect() {
118
143
  const preselected = this.pickerTargets.findIndex(
119
144
  (picker) => picker.getAttribute("aria-selected") === "true"
@@ -122,6 +147,20 @@ var CarouselController = class extends Controller {
122
147
  this.#playing = this.#initialPlaying();
123
148
  this.#render({ focus: false });
124
149
  this.#syncTimer();
150
+ this.#connected = true;
151
+ }
152
+ /**
153
+ * Re-establishes the single selected picker when one is added after connect.
154
+ *
155
+ * Without this an appended picker that arrives `aria-selected="true"` leaves two
156
+ * marked at once — the authored pre-selection is only read on connect, so
157
+ * nothing else ever resolves the conflict. The current slide is kept: the
158
+ * repaint re-derives every picker from `#index`, so a late arrival never steals
159
+ * the selection.
160
+ */
161
+ pickerTargetConnected() {
162
+ if (!this.#connected) return;
163
+ this.#render({ focus: false });
125
164
  }
126
165
  /**
127
166
  * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
@@ -138,6 +177,7 @@ var CarouselController = class extends Controller {
138
177
  }
139
178
  /** Clears the autoplay interval so it never fires after teardown. */
140
179
  disconnect() {
180
+ this.#connected = false;
141
181
  this.#intervals.clearAll();
142
182
  this.#timerId = null;
143
183
  }
@@ -185,20 +225,21 @@ var CarouselController = class extends Controller {
185
225
  }
186
226
  /** Picker roving: arrows move focus only; Home/End activate first/last slide. */
187
227
  onPickerKeydown(event) {
228
+ if (event.defaultPrevented) return;
229
+ if (isReservedArrowChord(event)) return;
188
230
  const current = this.pickerTargets.indexOf(event.currentTarget);
189
231
  if (current === -1) return;
190
232
  const length = this.pickerTargets.length;
191
233
  switch (event.key) {
192
234
  case "ArrowRight":
193
235
  case "ArrowDown":
194
- event.preventDefault();
195
- this.#roving.setActive(rovingMove(current, length, 1), { focus: true });
196
- return;
197
236
  case "ArrowLeft":
198
- case "ArrowUp":
237
+ case "ArrowUp": {
199
238
  event.preventDefault();
200
- this.#roving.setActive(rovingMove(current, length, -1), { focus: true });
239
+ const step = logicalArrowStep(event.key, this.element);
240
+ this.#roving.setActive(rovingMove(current, length, step), { focus: true });
201
241
  return;
242
+ }
202
243
  case "Home":
203
244
  event.preventDefault();
204
245
  this.#select(0, { focus: true });
@@ -276,8 +276,8 @@ var CollapsibleController = class extends Controller {
276
276
  }
277
277
  /**
278
278
  * Drives the disclosure to `open`, syncing the trigger's `aria-expanded` and
279
- * the content's `hidden` / `data-state` / height variable in the order the
280
- * spec requires.
279
+ * the content's `hidden` / `data-state` / height variable in an order where
280
+ * `hidden` never blocks measurement or the transition.
281
281
  *
282
282
  * @param open - Target state.
283
283
  * @param waitForCloseTransition - When `false` (initial `connect`) the close
@@ -1,5 +1,30 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/color_picker_controller.ts
4
+
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function logicalArrowKey(key, element) {
12
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
13
+ if (!isRtl(element)) return key;
14
+ return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
15
+ }
16
+ function isReservedArrowChord(event, allow = []) {
17
+ if (!event.key.startsWith("Arrow")) return false;
18
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
+ }
20
+
21
+ // src/utils/coerce.ts
22
+ function toFiniteNumber(raw) {
23
+ if (raw === null || raw === void 0 || raw === "") return null;
24
+ const value = typeof raw === "number" ? raw : Number(raw);
25
+ return Number.isFinite(value) ? value : null;
26
+ }
27
+
3
28
  // src/controllers/color_picker_controller.ts
4
29
  var COLOR_PROPERTY = "--stimeo-color";
5
30
  var CHANNEL_RANGE = {
@@ -12,10 +37,15 @@ var ColorPickerController = class extends Controller {
12
37
  static targets = ["slider", "hex", "preview", "field"];
13
38
  static values = {
14
39
  value: { type: String, default: "#000000" },
15
- alpha: { type: Boolean, default: false }
40
+ alpha: { type: Boolean, default: false },
41
+ logicalTrack: { type: Boolean, default: false }
16
42
  };
17
43
  static actions = ["onHexInput", "onKeydown", "onPointerDown"];
18
44
  static events = ["change"];
45
+ /** Whether the consumer declared a mirroring track and the direction mirrors it. */
46
+ get #mirrored() {
47
+ return this.logicalTrackValue && isRtl(this.element);
48
+ }
19
49
  /** The current color in the editing model. */
20
50
  #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
21
51
  /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
@@ -33,13 +63,14 @@ var ColorPickerController = class extends Controller {
33
63
  }
34
64
  /** Keyboard stepping on the focused channel slider (APG Slider model). */
35
65
  onKeydown(event) {
66
+ if (isReservedArrowChord(event)) return;
36
67
  const slider = event.currentTarget;
37
68
  const channel = this.#channelOf(slider);
38
69
  if (!channel) return;
39
70
  const [min, max] = this.#rangeOf(slider, channel);
40
71
  const value = this.#color[channel];
41
72
  let next = null;
42
- switch (event.key) {
73
+ switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
43
74
  case "ArrowRight":
44
75
  case "ArrowUp":
45
76
  next = value + 1;
@@ -74,10 +105,12 @@ var ColorPickerController = class extends Controller {
74
105
  event.preventDefault();
75
106
  slider.focus();
76
107
  const [min, max] = this.#rangeOf(slider, channel);
108
+ const mirrored = this.#mirrored;
77
109
  const update = (clientX) => {
78
110
  const rect = slider.getBoundingClientRect();
79
111
  if (rect.width === 0) return;
80
- const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
112
+ const offset = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
113
+ const fraction = mirrored ? 1 - offset : offset;
81
114
  this.#setChannel(channel, min + fraction * (max - min), min, max);
82
115
  };
83
116
  update(event.clientX);
@@ -140,12 +173,18 @@ var ColorPickerController = class extends Controller {
140
173
  const channel = slider.getAttribute("data-channel");
141
174
  return channel && channel in CHANNEL_RANGE ? channel : null;
142
175
  }
143
- /** A slider's `[min, max]` from aria-valuemin/max, falling back per channel. */
176
+ /**
177
+ * A slider's `[min, max]` from aria-valuemin/max, falling back per channel.
178
+ * An absent or blank attribute means "not authored", not zero — coercing it to
179
+ * a number would pin the channel at `0` and make the per-channel default
180
+ * unreachable.
181
+ */
144
182
  #rangeOf(slider, channel) {
145
183
  const [defMin, defMax] = CHANNEL_RANGE[channel];
146
- const min = Number(slider.getAttribute("aria-valuemin"));
147
- const max = Number(slider.getAttribute("aria-valuemax"));
148
- return [Number.isFinite(min) ? min : defMin, Number.isFinite(max) ? max : defMax];
184
+ return [
185
+ toFiniteNumber(slider.getAttribute("aria-valuemin")) ?? defMin,
186
+ toFiniteNumber(slider.getAttribute("aria-valuemax")) ?? defMax
187
+ ];
149
188
  }
150
189
  };
151
190
  function valueText(channel, value) {
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/combobox_controller.ts
4
4
 
5
+ // src/utils/active_option.ts
6
+ function syncActiveOption(options, active) {
7
+ let written = 0;
8
+ for (const option of options) {
9
+ const next = option === active ? "true" : "false";
10
+ if (option.getAttribute("aria-selected") === next) continue;
11
+ option.setAttribute("aria-selected", next);
12
+ written += 1;
13
+ }
14
+ return written;
15
+ }
16
+
17
+ // src/utils/arrow_step.ts
18
+ function isReservedArrowChord(event, allow = []) {
19
+ if (!event.key.startsWith("Arrow")) return false;
20
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
+ }
22
+
5
23
  // src/utils/composition_tracker.ts
6
24
  var CompositionTracker = class {
7
25
  #observedTargets = /* @__PURE__ */ new Set();
@@ -49,6 +67,39 @@ var CompositionTracker = class {
49
67
  };
50
68
  };
51
69
 
70
+ // src/utils/microtask_coalescer.ts
71
+ var MicrotaskCoalescer = class {
72
+ #run;
73
+ #queued = false;
74
+ #active = false;
75
+ #generation = 0;
76
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
77
+ constructor(run) {
78
+ this.#run = run;
79
+ }
80
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
81
+ activate() {
82
+ this.#active = true;
83
+ }
84
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
85
+ cancel() {
86
+ this.#active = false;
87
+ this.#queued = false;
88
+ this.#generation += 1;
89
+ }
90
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
91
+ schedule() {
92
+ if (!this.#active || this.#queued) return;
93
+ this.#queued = true;
94
+ const generation = this.#generation;
95
+ queueMicrotask(() => {
96
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
97
+ this.#queued = false;
98
+ this.#run();
99
+ });
100
+ }
101
+ };
102
+
52
103
  // src/utils/option_scroll.ts
53
104
  function scrollOptionIntoView(list, option) {
54
105
  if (list.scrollHeight <= list.clientHeight) return;
@@ -66,8 +117,11 @@ var ComboboxController = class extends Controller {
66
117
  static targets = ["input", "list", "option"];
67
118
  static actions = ["close", "filter", "onKeydown", "open", "selectByClick"];
68
119
  static events = ["selected"];
69
- /** Index into the *visible* options of the active option, or -1 if none. */
70
- #activeIndex = -1;
120
+ /** Stable ID of the active option; the live element is resolved before every use. */
121
+ #activeId = null;
122
+ #connected = false;
123
+ /** Collapses one mutation batch of target callbacks into a single pass. */
124
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
71
125
  /**
72
126
  * Suppresses {@link open} for the duration of the programmatic re-focus in
73
127
  * `#select`, so committing a value (which returns focus to the input)
@@ -80,21 +134,36 @@ var ComboboxController = class extends Controller {
80
134
  connect() {
81
135
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
82
136
  this.close();
83
- document.addEventListener("click", this.#onOutsideClick);
137
+ document.addEventListener("click", this.#onOutsideClick, true);
138
+ this.#connected = true;
139
+ this.#reconcile.activate();
84
140
  }
85
141
  /** Removes the document-level listener registered in {@link connect}. */
86
142
  disconnect() {
143
+ this.#connected = false;
144
+ this.#reconcile.cancel();
87
145
  this.#composition.disconnect();
88
- document.removeEventListener("click", this.#onOutsideClick);
146
+ document.removeEventListener("click", this.#onOutsideClick, true);
89
147
  }
90
148
  /** Tracks an input added initially or after connect. */
91
149
  inputTargetConnected(input) {
92
150
  this.#composition.observe(input);
151
+ if (this.#connected) this.#queueOptionReconciliation();
93
152
  }
94
153
  /** Removes composition listeners when the active input is replaced or removed. */
95
154
  inputTargetDisconnected(input) {
96
155
  this.#composition.unobserve(input);
97
156
  }
157
+ /** Establishes the active-state baseline, then reconciles a runtime addition. */
158
+ optionTargetConnected(option) {
159
+ option.setAttribute("aria-selected", "false");
160
+ if (this.#connected) this.#queueOptionReconciliation();
161
+ }
162
+ /** Clears the disconnected node's active state, then reconciles the survivors. */
163
+ optionTargetDisconnected(option) {
164
+ option.setAttribute("aria-selected", "false");
165
+ if (this.#connected) this.#queueOptionReconciliation();
166
+ }
98
167
  /** Filters confirmed input text and opens the listbox. */
99
168
  filter(event) {
100
169
  if (this.#composition.isComposing(event)) return;
@@ -106,7 +175,7 @@ var ComboboxController = class extends Controller {
106
175
  * re-opening with a stale non-matching value still surfaces the empty state).
107
176
  */
108
177
  open() {
109
- if (!this.hasListTarget || this.#suppressOpen) return;
178
+ if (!this.hasListTarget || !this.hasInputTarget || this.#suppressOpen) return;
110
179
  this.#applyFilter();
111
180
  this.listTarget.hidden = false;
112
181
  this.inputTarget.setAttribute("aria-expanded", "true");
@@ -124,24 +193,47 @@ var ComboboxController = class extends Controller {
124
193
  option.hidden = query.length > 0 && !text.includes(query);
125
194
  }
126
195
  }
127
- /** Closes the listbox, clears the active option, and updates ARIA state. */
196
+ /**
197
+ * Closes the listbox, clears the active option, and updates ARIA state.
198
+ *
199
+ * Survives a missing input in both directions. {@link inputTargetConnected}
200
+ * accepts an input added after connect, so dereferencing it here would abort
201
+ * `connect()` before it registers the outside-click listener, and nothing
202
+ * re-registers it later. Symmetrically, an input removed while the popup is
203
+ * open must still let the popup come down — so hiding the list is
204
+ * unconditional and only the ARIA half is guarded.
205
+ */
128
206
  close() {
129
207
  if (!this.hasListTarget) return;
130
208
  this.listTarget.hidden = true;
131
- this.inputTarget.setAttribute("aria-expanded", "false");
132
- this.#setActive(-1);
133
209
  this.element.removeAttribute("data-stimeo--combobox-empty");
210
+ if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "false");
211
+ this.#setActive(-1);
134
212
  }
135
213
  /** Routes keyboard interaction per the APG combobox model. */
136
214
  onKeydown(event) {
137
215
  if (this.#composition.isComposing(event)) return;
216
+ if (event.defaultPrevented) return;
217
+ if (isReservedArrowChord(event, ["alt"])) return;
218
+ if (event.altKey && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
219
+ if (event.key === "ArrowDown" ? !this.#isClosed : this.#isClosed) return;
220
+ event.preventDefault();
221
+ if (event.key === "ArrowDown") {
222
+ this.open();
223
+ } else {
224
+ this.close();
225
+ }
226
+ return;
227
+ }
228
+ if (!this.#isClosed) this.#reconcileActive();
138
229
  switch (event.key) {
139
230
  case "ArrowDown": {
140
231
  event.preventDefault();
141
232
  if (this.#isClosed) this.open();
142
233
  const visible = this.#visibleOptions();
143
234
  if (visible.length > 0) {
144
- const next = this.#activeIndex === -1 ? 0 : (this.#activeIndex + 1) % visible.length;
235
+ const activeIndex = this.#findActiveIndex(visible);
236
+ const next = activeIndex === -1 ? 0 : (activeIndex + 1) % visible.length;
145
237
  this.#setActive(next);
146
238
  }
147
239
  break;
@@ -151,7 +243,8 @@ var ComboboxController = class extends Controller {
151
243
  if (this.#isClosed) this.open();
152
244
  const visible = this.#visibleOptions();
153
245
  if (visible.length > 0) {
154
- const next = this.#activeIndex === -1 ? visible.length - 1 : (this.#activeIndex - 1 + visible.length) % visible.length;
246
+ const activeIndex = this.#findActiveIndex(visible);
247
+ const next = activeIndex === -1 ? visible.length - 1 : (activeIndex - 1 + visible.length) % visible.length;
155
248
  this.#setActive(next);
156
249
  }
157
250
  break;
@@ -172,7 +265,8 @@ var ComboboxController = class extends Controller {
172
265
  }
173
266
  case "Enter": {
174
267
  const visible = this.#visibleOptions();
175
- const active = this.#activeIndex === -1 ? void 0 : visible[this.#activeIndex];
268
+ const activeIndex = this.#findActiveIndex(visible);
269
+ const active = activeIndex === -1 ? void 0 : visible[activeIndex];
176
270
  if (active) {
177
271
  event.preventDefault();
178
272
  this.#select(active);
@@ -180,7 +274,7 @@ var ComboboxController = class extends Controller {
180
274
  break;
181
275
  }
182
276
  case "Escape":
183
- if (event.defaultPrevented || this.#isClosed) break;
277
+ if (this.#isClosed) break;
184
278
  event.preventDefault();
185
279
  this.close();
186
280
  break;
@@ -200,11 +294,24 @@ var ComboboxController = class extends Controller {
200
294
  /** Selects the clicked option. Bound via `data-action` (click). */
201
295
  selectByClick(event) {
202
296
  const option = event.currentTarget.closest('[role="option"]');
203
- if (option) this.#select(option);
297
+ if (option && this.optionTargets.includes(option)) this.#select(option);
204
298
  }
205
- /** Commits an option: fills the input, closes the listbox, notifies listeners. */
299
+ /**
300
+ * Commits an option: fills the input, closes the listbox, notifies listeners.
301
+ *
302
+ * An input removed while the popup is open leaves the options clickable, and
303
+ * {@link close} already survives that state. Selection does too: the popup
304
+ * comes down and listeners still hear the choice, with only the field-bound
305
+ * half — the value write, the focus return, and the native `change` — skipped,
306
+ * because there is no field to carry them.
307
+ */
206
308
  #select(option) {
207
309
  const value = option.dataset.value ?? (option.textContent ?? "").trim();
310
+ if (!this.hasInputTarget) {
311
+ this.close();
312
+ this.dispatch("selected", { detail: { value } });
313
+ return;
314
+ }
208
315
  const changed = this.inputTarget.value !== value;
209
316
  this.inputTarget.value = value;
210
317
  this.close();
@@ -235,19 +342,51 @@ var ComboboxController = class extends Controller {
235
342
  * input's `aria-activedescendant`. Pass `-1` to clear the active option.
236
343
  */
237
344
  #setActive(index) {
238
- this.#activeIndex = index;
239
345
  const visible = this.#visibleOptions();
240
- const active = index === -1 ? null : visible[index];
241
- for (const option of this.optionTargets) {
242
- option.setAttribute("aria-selected", option === active ? "true" : "false");
243
- }
244
- if (active?.id) {
245
- this.inputTarget.setAttribute("aria-activedescendant", active.id);
246
- } else {
247
- this.inputTarget.removeAttribute("aria-activedescendant");
346
+ const active = index === -1 ? null : visible[index] ?? null;
347
+ syncActiveOption(this.optionTargets, active);
348
+ this.#activeId = active?.id || null;
349
+ if (this.hasInputTarget) {
350
+ if (active?.id) {
351
+ this.inputTarget.setAttribute("aria-activedescendant", active.id);
352
+ } else {
353
+ this.inputTarget.removeAttribute("aria-activedescendant");
354
+ }
248
355
  }
249
356
  if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);
250
357
  }
358
+ /** Re-applies filtering and active state after a target collection change. */
359
+ #reconcileOptions() {
360
+ if (this.hasInputTarget && !this.#isClosed) this.#applyFilter();
361
+ this.#reconcileActive();
362
+ this.#reflectEmptyState();
363
+ }
364
+ /** Preserves a surviving active ID; disappearance deliberately clears active state. */
365
+ #reconcileActive() {
366
+ if (!this.hasInputTarget || this.#isClosed) {
367
+ this.#setActive(-1);
368
+ return;
369
+ }
370
+ const visible = this.#visibleOptions();
371
+ const index = this.#findActiveIndex(visible);
372
+ const active = index === -1 ? null : visible[index] ?? null;
373
+ const selected = this.optionTargets.filter(
374
+ (option) => option.getAttribute("aria-selected") === "true"
375
+ );
376
+ const idref = this.inputTarget.getAttribute("aria-activedescendant");
377
+ const stateMatches = active ? this.#activeId === (active.id || null) && selected.length === 1 && selected[0] === active && idref === (active.id || null) : this.#activeId === null && selected.length === 0 && idref === null;
378
+ if (!stateMatches) this.#setActive(index);
379
+ }
380
+ /** Resolves the stable ID, with `aria-selected` as the ID-less compatibility marker. */
381
+ #findActiveIndex(options) {
382
+ const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
383
+ if (activeId) return options.findIndex((option) => option.id === activeId);
384
+ return options.findIndex((option) => option.getAttribute("aria-selected") === "true");
385
+ }
386
+ /** Coalesces all target callbacks from one MutationObserver batch. */
387
+ #queueOptionReconciliation() {
388
+ this.#reconcile.schedule();
389
+ }
251
390
  /** The options currently shown (not filtered out). */
252
391
  #visibleOptions() {
253
392
  return this.optionTargets.filter((option) => !option.hidden);