stimeo-ui 0.2.1 → 0.4.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 +160 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +92 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +226 -22
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +77 -3
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +102 -8
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +408 -57
- data/dist/controllers/pagination_controller.js +163 -32
- 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/progress_controller.js +116 -9
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +100 -9
- data/dist/controllers/rating_controller.js +69 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +49 -8
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +61 -10
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +4507 -907
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
|
@@ -1,5 +1,63 @@
|
|
|
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
|
+
|
|
28
|
+
// src/utils/microtask_coalescer.ts
|
|
29
|
+
var MicrotaskCoalescer = class {
|
|
30
|
+
#run;
|
|
31
|
+
#queued = false;
|
|
32
|
+
#active = false;
|
|
33
|
+
#generation = 0;
|
|
34
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
35
|
+
constructor(run) {
|
|
36
|
+
this.#run = run;
|
|
37
|
+
}
|
|
38
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
39
|
+
activate() {
|
|
40
|
+
this.#active = true;
|
|
41
|
+
}
|
|
42
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
43
|
+
cancel() {
|
|
44
|
+
this.#active = false;
|
|
45
|
+
this.#queued = false;
|
|
46
|
+
this.#generation += 1;
|
|
47
|
+
}
|
|
48
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
49
|
+
schedule() {
|
|
50
|
+
if (!this.#active || this.#queued) return;
|
|
51
|
+
this.#queued = true;
|
|
52
|
+
const generation = this.#generation;
|
|
53
|
+
queueMicrotask(() => {
|
|
54
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
55
|
+
this.#queued = false;
|
|
56
|
+
this.#run();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
3
61
|
// src/controllers/color_picker_controller.ts
|
|
4
62
|
var COLOR_PROPERTY = "--stimeo-color";
|
|
5
63
|
var CHANNEL_RANGE = {
|
|
@@ -12,34 +70,53 @@ var ColorPickerController = class extends Controller {
|
|
|
12
70
|
static targets = ["slider", "hex", "preview", "field"];
|
|
13
71
|
static values = {
|
|
14
72
|
value: { type: String, default: "#000000" },
|
|
15
|
-
alpha: { type: Boolean, default: false }
|
|
73
|
+
alpha: { type: Boolean, default: false },
|
|
74
|
+
logicalTrack: { type: Boolean, default: false }
|
|
16
75
|
};
|
|
17
76
|
static actions = ["onHexInput", "onKeydown", "onPointerDown"];
|
|
18
77
|
static events = ["change"];
|
|
78
|
+
/** Whether the consumer declared a mirroring track and the direction mirrors it. */
|
|
79
|
+
get #mirrored() {
|
|
80
|
+
return this.logicalTrackValue && isRtl(this.element);
|
|
81
|
+
}
|
|
19
82
|
/** The current color in the editing model. */
|
|
20
83
|
#color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
|
|
21
84
|
/** Aborts in-progress pointer-drag listeners on drag end / teardown. */
|
|
22
85
|
#dragAbort = null;
|
|
23
86
|
/** Seeds the model from the initial hex value and renders every surface. */
|
|
87
|
+
/**
|
|
88
|
+
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
89
|
+
* pass Stimulus delivers before `connect()`.
|
|
90
|
+
*/
|
|
91
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
92
|
+
this.#render();
|
|
93
|
+
});
|
|
24
94
|
connect() {
|
|
95
|
+
this.#repaint.activate();
|
|
25
96
|
const parsed = hexToHsla(this.valueValue);
|
|
26
97
|
if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
|
|
27
98
|
this.#render();
|
|
28
99
|
}
|
|
29
100
|
/** Cancels any active pointer drag so document listeners never leak. */
|
|
30
101
|
disconnect() {
|
|
102
|
+
this.#repaint.cancel();
|
|
31
103
|
this.#dragAbort?.abort();
|
|
32
104
|
this.#dragAbort = null;
|
|
33
105
|
}
|
|
106
|
+
/** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
|
|
107
|
+
alphaValueChanged() {
|
|
108
|
+
this.#repaint.schedule();
|
|
109
|
+
}
|
|
34
110
|
/** Keyboard stepping on the focused channel slider (APG Slider model). */
|
|
35
111
|
onKeydown(event) {
|
|
112
|
+
if (isReservedArrowChord(event)) return;
|
|
36
113
|
const slider = event.currentTarget;
|
|
37
114
|
const channel = this.#channelOf(slider);
|
|
38
115
|
if (!channel) return;
|
|
39
116
|
const [min, max] = this.#rangeOf(slider, channel);
|
|
40
117
|
const value = this.#color[channel];
|
|
41
118
|
let next = null;
|
|
42
|
-
switch (event.key) {
|
|
119
|
+
switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
|
|
43
120
|
case "ArrowRight":
|
|
44
121
|
case "ArrowUp":
|
|
45
122
|
next = value + 1;
|
|
@@ -74,10 +151,12 @@ var ColorPickerController = class extends Controller {
|
|
|
74
151
|
event.preventDefault();
|
|
75
152
|
slider.focus();
|
|
76
153
|
const [min, max] = this.#rangeOf(slider, channel);
|
|
154
|
+
const mirrored = this.#mirrored;
|
|
77
155
|
const update = (clientX) => {
|
|
78
156
|
const rect = slider.getBoundingClientRect();
|
|
79
157
|
if (rect.width === 0) return;
|
|
80
|
-
const
|
|
158
|
+
const offset = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
|
159
|
+
const fraction = mirrored ? 1 - offset : offset;
|
|
81
160
|
this.#setChannel(channel, min + fraction * (max - min), min, max);
|
|
82
161
|
};
|
|
83
162
|
update(event.clientX);
|
|
@@ -140,12 +219,18 @@ var ColorPickerController = class extends Controller {
|
|
|
140
219
|
const channel = slider.getAttribute("data-channel");
|
|
141
220
|
return channel && channel in CHANNEL_RANGE ? channel : null;
|
|
142
221
|
}
|
|
143
|
-
/**
|
|
222
|
+
/**
|
|
223
|
+
* A slider's `[min, max]` from aria-valuemin/max, falling back per channel.
|
|
224
|
+
* An absent or blank attribute means "not authored", not zero — coercing it to
|
|
225
|
+
* a number would pin the channel at `0` and make the per-channel default
|
|
226
|
+
* unreachable.
|
|
227
|
+
*/
|
|
144
228
|
#rangeOf(slider, channel) {
|
|
145
229
|
const [defMin, defMax] = CHANNEL_RANGE[channel];
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
230
|
+
return [
|
|
231
|
+
toFiniteNumber(slider.getAttribute("aria-valuemin")) ?? defMin,
|
|
232
|
+
toFiniteNumber(slider.getAttribute("aria-valuemax")) ?? defMax
|
|
233
|
+
];
|
|
149
234
|
}
|
|
150
235
|
};
|
|
151
236
|
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
|
-
/**
|
|
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);
|