stimeo-ui 0.2.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +163 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +313 -0
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +252 -0
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +194 -17
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +246 -0
- data/dist/controllers/date_range_picker_controller.js +441 -0
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/editable_controller.js +169 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +67 -19
- data/dist/controllers/lazy_frame_controller.js +54 -11
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +2 -2
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +785 -0
- data/dist/controllers/multi_select_controller.js +755 -0
- data/dist/controllers/navigation_menu_controller.js +511 -0
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +246 -27
- data/dist/controllers/overflow_menu_controller.js +381 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +192 -0
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +238 -0
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +101 -14
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +40 -6
- data/dist/controllers/scrollspy_controller.js +369 -74
- data/dist/controllers/separator_controller.js +96 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/skeleton_controller.js +1 -1
- data/dist/controllers/slider_controller.js +32 -6
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +1 -1
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +9 -4
- data/dist/controllers/sticky_observer_controller.js +109 -20
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +295 -0
- data/dist/controllers/theme_controller.js +42 -13
- data/dist/controllers/time_picker_controller.js +231 -0
- data/dist/controllers/toast_controller.js +40 -14
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +691 -0
- data/dist/index.js +4256 -915
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +28 -2
|
@@ -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
|
-
/**
|
|
70
|
-
#
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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);
|
|
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/command_palette_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();
|
|
@@ -304,6 +322,39 @@ var FocusTrap = class {
|
|
|
304
322
|
}
|
|
305
323
|
};
|
|
306
324
|
|
|
325
|
+
// src/utils/microtask_coalescer.ts
|
|
326
|
+
var MicrotaskCoalescer = class {
|
|
327
|
+
#run;
|
|
328
|
+
#queued = false;
|
|
329
|
+
#active = false;
|
|
330
|
+
#generation = 0;
|
|
331
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
332
|
+
constructor(run) {
|
|
333
|
+
this.#run = run;
|
|
334
|
+
}
|
|
335
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
336
|
+
activate() {
|
|
337
|
+
this.#active = true;
|
|
338
|
+
}
|
|
339
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
340
|
+
cancel() {
|
|
341
|
+
this.#active = false;
|
|
342
|
+
this.#queued = false;
|
|
343
|
+
this.#generation += 1;
|
|
344
|
+
}
|
|
345
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
346
|
+
schedule() {
|
|
347
|
+
if (!this.#active || this.#queued) return;
|
|
348
|
+
this.#queued = true;
|
|
349
|
+
const generation = this.#generation;
|
|
350
|
+
queueMicrotask(() => {
|
|
351
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
352
|
+
this.#queued = false;
|
|
353
|
+
this.#run();
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
307
358
|
// src/controllers/command_palette_controller.ts
|
|
308
359
|
var CommandPaletteController = class _CommandPaletteController extends Controller {
|
|
309
360
|
static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
|
|
@@ -323,8 +374,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
323
374
|
"toggle"
|
|
324
375
|
];
|
|
325
376
|
static events = ["select"];
|
|
326
|
-
/**
|
|
327
|
-
#
|
|
377
|
+
/** Stable ID of the active option; the live target is resolved before every use. */
|
|
378
|
+
#activeId = null;
|
|
379
|
+
/** Visible/selectable target ID order captured for removal fallback. */
|
|
380
|
+
#activeOrder = [];
|
|
381
|
+
#connected = false;
|
|
382
|
+
/** Collapses one mutation batch of target callbacks into a single pass. */
|
|
383
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());
|
|
328
384
|
/** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
|
|
329
385
|
#composition = new CompositionTracker({
|
|
330
386
|
onEnd: () => {
|
|
@@ -358,9 +414,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
358
414
|
const shouldOpen = this.#isOpen || this.openValue;
|
|
359
415
|
this.#resetToClosedState();
|
|
360
416
|
if (shouldOpen) this.open();
|
|
417
|
+
this.#connected = true;
|
|
418
|
+
this.#reconcile.activate();
|
|
361
419
|
}
|
|
362
420
|
/** Tears down the global hotkey listener and reverts the modal side effects. */
|
|
363
421
|
disconnect() {
|
|
422
|
+
this.#connected = false;
|
|
423
|
+
this.#reconcile.cancel();
|
|
364
424
|
document.removeEventListener("keydown", this.#onGlobalKeydown);
|
|
365
425
|
this.#composition.disconnect();
|
|
366
426
|
this.#trap.deactivate({ restoreFocus: false });
|
|
@@ -369,10 +429,20 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
369
429
|
/** Initializes semantics for an option added before or after the controller connects. */
|
|
370
430
|
optionTargetConnected(option) {
|
|
371
431
|
this.#syncOptionSemanticsFor(option);
|
|
432
|
+
option.setAttribute("aria-selected", "false");
|
|
433
|
+
option.removeAttribute("data-active");
|
|
434
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
435
|
+
}
|
|
436
|
+
/** Clears active residue on the old node and reconciles the surviving targets. */
|
|
437
|
+
optionTargetDisconnected(option) {
|
|
438
|
+
option.setAttribute("aria-selected", "false");
|
|
439
|
+
option.removeAttribute("data-active");
|
|
440
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
372
441
|
}
|
|
373
442
|
/** Tracks an input added initially or after connect without extra consumer actions. */
|
|
374
443
|
inputTargetConnected(input) {
|
|
375
444
|
this.#composition.observe(input);
|
|
445
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
376
446
|
}
|
|
377
447
|
/** Removes controller-owned listeners when the input target is replaced or removed. */
|
|
378
448
|
inputTargetDisconnected(input) {
|
|
@@ -412,7 +482,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
412
482
|
const matches = searchText.includes(query);
|
|
413
483
|
if (matches) {
|
|
414
484
|
option.removeAttribute("hidden");
|
|
415
|
-
if (
|
|
485
|
+
if (this.#isNavigable(option)) hasSelectableMatch = true;
|
|
416
486
|
} else {
|
|
417
487
|
option.setAttribute("hidden", "true");
|
|
418
488
|
}
|
|
@@ -427,8 +497,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
427
497
|
const target = event.currentTarget;
|
|
428
498
|
if (!target) return;
|
|
429
499
|
const option = target.closest("[role='option']");
|
|
430
|
-
if (option
|
|
431
|
-
|
|
500
|
+
if (!option || !this.optionTargets.includes(option)) return;
|
|
501
|
+
this.#syncOptionSemanticsFor(option);
|
|
502
|
+
if (!option.hasAttribute("hidden") && !this.#isDisabled(option)) {
|
|
432
503
|
this.#confirmSelection(option);
|
|
433
504
|
}
|
|
434
505
|
}
|
|
@@ -443,7 +514,10 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
443
514
|
* focus — not only the input.
|
|
444
515
|
*/
|
|
445
516
|
onKeydown(event) {
|
|
517
|
+
if (event.defaultPrevented) return;
|
|
518
|
+
if (isReservedArrowChord(event)) return;
|
|
446
519
|
if (this.#composition.isComposing(event) || !this.#isOpen) return;
|
|
520
|
+
this.#reconcileActive();
|
|
447
521
|
switch (event.key) {
|
|
448
522
|
case "ArrowDown":
|
|
449
523
|
event.preventDefault();
|
|
@@ -463,7 +537,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
463
537
|
break;
|
|
464
538
|
case "Enter": {
|
|
465
539
|
event.preventDefault();
|
|
466
|
-
const
|
|
540
|
+
const visible = this.#visibleOptions;
|
|
541
|
+
const activeIndex = this.#findActiveIndex(visible);
|
|
542
|
+
const activeOption = activeIndex < 0 ? void 0 : visible[activeIndex];
|
|
467
543
|
if (activeOption) this.#confirmSelection(activeOption);
|
|
468
544
|
break;
|
|
469
545
|
}
|
|
@@ -472,7 +548,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
472
548
|
#navigate(direction) {
|
|
473
549
|
const visible = this.#visibleOptions;
|
|
474
550
|
if (visible.length === 0) return;
|
|
475
|
-
let newIndex = this.#
|
|
551
|
+
let newIndex = this.#findActiveIndex(visible) + direction;
|
|
476
552
|
if (newIndex >= visible.length) newIndex = 0;
|
|
477
553
|
if (newIndex < 0) newIndex = visible.length - 1;
|
|
478
554
|
this.#setActiveIndex(newIndex);
|
|
@@ -481,16 +557,16 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
481
557
|
this.#syncOptionSemantics();
|
|
482
558
|
const visible = this.#visibleOptions;
|
|
483
559
|
const activeOption = visible[index] ?? null;
|
|
484
|
-
this
|
|
560
|
+
syncActiveOption(this.optionTargets, activeOption);
|
|
485
561
|
for (const option of this.optionTargets) {
|
|
486
|
-
option.setAttribute("
|
|
487
|
-
option.removeAttribute("data-active");
|
|
562
|
+
if (option === activeOption) option.setAttribute("data-active", "true");
|
|
563
|
+
else option.removeAttribute("data-active");
|
|
488
564
|
}
|
|
489
565
|
if (activeOption) {
|
|
490
|
-
activeOption.setAttribute("aria-selected", "true");
|
|
491
|
-
activeOption.setAttribute("data-active", "true");
|
|
492
566
|
activeOption.scrollIntoView({ block: "nearest" });
|
|
493
567
|
}
|
|
568
|
+
this.#activeId = activeOption?.id || null;
|
|
569
|
+
this.#activeOrder = activeOption ? visible.map((option) => option.id).filter(Boolean) : [];
|
|
494
570
|
if (this.hasInputTarget) {
|
|
495
571
|
if (activeOption?.id) {
|
|
496
572
|
this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
|
|
@@ -499,7 +575,75 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
499
575
|
}
|
|
500
576
|
}
|
|
501
577
|
}
|
|
578
|
+
/** Re-resolves active identity and component-specific fallback against live targets. */
|
|
579
|
+
#reconcileActive() {
|
|
580
|
+
if (!this.#isOpen) {
|
|
581
|
+
this.#setActiveIndex(-1);
|
|
582
|
+
this.#reflectEmptyState();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const visible = this.#visibleOptions;
|
|
586
|
+
const currentIndex = this.#findActiveIndex(visible);
|
|
587
|
+
if (currentIndex >= 0) {
|
|
588
|
+
const active = visible[currentIndex] ?? null;
|
|
589
|
+
const options = this.optionTargets;
|
|
590
|
+
const marked = options.filter((option) => option.hasAttribute("data-active"));
|
|
591
|
+
const selected = options.filter((option) => option.getAttribute("aria-selected") === "true");
|
|
592
|
+
const stateMatches = this.#activeId === (active?.id || null) && marked.length === 1 && marked[0] === active && selected.length === 1 && selected[0] === active && (!this.hasInputTarget || this.inputTarget.getAttribute("aria-activedescendant") === (active?.id || null));
|
|
593
|
+
if (stateMatches) {
|
|
594
|
+
this.#activeOrder = visible.map((option) => option.id).filter(Boolean);
|
|
595
|
+
} else {
|
|
596
|
+
this.#setActiveIndex(currentIndex);
|
|
597
|
+
}
|
|
598
|
+
this.#reflectEmptyState();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
|
|
602
|
+
this.#setActiveIndex(activeId ? this.#findFallbackIndex(visible, activeId) : -1);
|
|
603
|
+
this.#reflectEmptyState();
|
|
604
|
+
}
|
|
605
|
+
/** Resolves stable ID first, then active markers for the existing ID-less case. */
|
|
606
|
+
#findActiveIndex(options) {
|
|
607
|
+
const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
|
|
608
|
+
if (activeId) return options.findIndex((option) => option.id === activeId);
|
|
609
|
+
return options.findIndex(
|
|
610
|
+
(option) => option.hasAttribute("data-active") || option.getAttribute("aria-selected") === "true"
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
/** Chooses a surviving former successor, then predecessor, from selectable targets only. */
|
|
614
|
+
#findFallbackIndex(options, activeId) {
|
|
615
|
+
const oldIndex = this.#activeOrder.indexOf(activeId);
|
|
616
|
+
if (oldIndex < 0) return -1;
|
|
617
|
+
const indexesById = new Map(options.map((option, index) => [option.id, index]));
|
|
618
|
+
for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {
|
|
619
|
+
const fallback = indexesById.get(this.#activeOrder[index] ?? "");
|
|
620
|
+
if (fallback !== void 0) return fallback;
|
|
621
|
+
}
|
|
622
|
+
for (let index = oldIndex - 1; index >= 0; index -= 1) {
|
|
623
|
+
const fallback = indexesById.get(this.#activeOrder[index] ?? "");
|
|
624
|
+
if (fallback !== void 0) return fallback;
|
|
625
|
+
}
|
|
626
|
+
return -1;
|
|
627
|
+
}
|
|
628
|
+
/** Reflects whether any visible, navigable command remains. */
|
|
629
|
+
#reflectEmptyState() {
|
|
630
|
+
if (this.hasEmptyTarget) this.emptyTarget.hidden = this.#visibleOptions.length > 0;
|
|
631
|
+
}
|
|
632
|
+
/** Coalesces all target callbacks from one MutationObserver batch. */
|
|
633
|
+
#queueOptionReconciliation() {
|
|
634
|
+
this.#reconcile.schedule();
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Runs `option`: dispatches `select` and closes.
|
|
638
|
+
*
|
|
639
|
+
* **Activation is refused here, not at the call sites.** Every path that runs a
|
|
640
|
+
* command funnels through this method, so one check covers keyboard, pointer,
|
|
641
|
+
* and anything added later. Per-call-site guards would leave `aria-disabled`
|
|
642
|
+
* options — which stay within the keyboard's reach — runnable from whichever
|
|
643
|
+
* path forgets to consult the predicate.
|
|
644
|
+
*/
|
|
502
645
|
#confirmSelection(option) {
|
|
646
|
+
if (this.#isDisabled(option)) return;
|
|
503
647
|
const value = option.dataset.value || option.textContent || "";
|
|
504
648
|
this.dispatch("select", { detail: { value, option } });
|
|
505
649
|
this.close();
|
|
@@ -510,14 +654,33 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
510
654
|
option.removeAttribute("hidden");
|
|
511
655
|
}
|
|
512
656
|
const hasSelectableOption = this.#visibleOptions.length > 0;
|
|
513
|
-
|
|
657
|
+
this.#reflectEmptyState();
|
|
514
658
|
this.#setActiveIndex(hasSelectableOption ? 0 : -1);
|
|
515
659
|
}
|
|
516
660
|
get #visibleOptions() {
|
|
517
661
|
return this.optionTargets.filter(
|
|
518
|
-
(option) => !option.hasAttribute("hidden") &&
|
|
662
|
+
(option) => !option.hasAttribute("hidden") && this.#isNavigable(option)
|
|
519
663
|
);
|
|
520
664
|
}
|
|
665
|
+
/**
|
|
666
|
+
* Whether virtual focus may land on `option`.
|
|
667
|
+
*
|
|
668
|
+
* Only `data-disabled` disqualifies. That attribute is this controller's own —
|
|
669
|
+
* authors reach for it to mark a row that is not a destination at all, such as
|
|
670
|
+
* a group heading — so it can mean "skip entirely" without contradicting ARIA.
|
|
671
|
+
*
|
|
672
|
+
* **`aria-disabled` does not disqualify.** It marks a command that must stay
|
|
673
|
+
* *discoverable*: `aria-activedescendant` names it, so a reader announces it as
|
|
674
|
+
* unavailable rather than hiding its existence. Virtual focus does
|
|
675
|
+
* not change that — pointing `aria-activedescendant` at a disabled option is
|
|
676
|
+
* ordinary ARIA. Keeping the two attributes distinct is what leaves both
|
|
677
|
+
* meanings expressible; collapsing them would take "show it, say it is
|
|
678
|
+
* unavailable, do not run it" away from consumers.
|
|
679
|
+
*/
|
|
680
|
+
#isNavigable(option) {
|
|
681
|
+
return option.dataset.disabled !== "true";
|
|
682
|
+
}
|
|
683
|
+
/** Whether activating `option` may run. Suppressed for either disabled marker. */
|
|
521
684
|
#isDisabled(option) {
|
|
522
685
|
return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
|
|
523
686
|
}
|
|
@@ -525,9 +688,18 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
525
688
|
#syncOptionSemantics() {
|
|
526
689
|
for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
|
|
527
690
|
}
|
|
528
|
-
/**
|
|
691
|
+
/**
|
|
692
|
+
* Reflects `data-disabled` to ARIA without losing a pre-existing authored value,
|
|
693
|
+
* and puts `aria-selected` back to its baseline.
|
|
694
|
+
*
|
|
695
|
+
* Here `aria-selected` marks the *active* option, not a committed choice — the
|
|
696
|
+
* palette runs a command and keeps nothing selected — so an authored value is
|
|
697
|
+
* meaningless and is overwritten rather than preserved. Every caller either
|
|
698
|
+
* establishes the active option straight after (`filter`, `#setActiveIndex`) or
|
|
699
|
+
* is handling an option that cannot be the active one yet
|
|
700
|
+
* (`optionTargetConnected`).
|
|
701
|
+
*/
|
|
529
702
|
#syncOptionSemanticsFor(option) {
|
|
530
|
-
if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
|
|
531
703
|
const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
|
|
532
704
|
const originalValue = option.getAttribute(originalAttribute);
|
|
533
705
|
if (option.dataset.disabled === "true") {
|
|
@@ -581,8 +753,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
581
753
|
}
|
|
582
754
|
/** Resets transient open state so reconnect starts from a predictable closed snapshot. */
|
|
583
755
|
#resetToClosedState() {
|
|
584
|
-
this.#
|
|
756
|
+
this.#activeId = null;
|
|
757
|
+
this.#activeOrder = [];
|
|
585
758
|
this.openValue = false;
|
|
759
|
+
for (const option of this.optionTargets) {
|
|
760
|
+
option.setAttribute("aria-selected", "false");
|
|
761
|
+
option.removeAttribute("data-active");
|
|
762
|
+
}
|
|
586
763
|
if (this.hasDialogTarget) {
|
|
587
764
|
this.dialogTarget.hidden = true;
|
|
588
765
|
}
|