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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -0
- data/dist/controllers/accordion_controller.js +10 -0
- 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 +46 -7
- 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/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +27 -3
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/form_validation_controller.js +1 -1
- 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 +2 -2
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- 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 +381 -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/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +32 -6
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +63 -19
- 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/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/stick_to_bottom_controller.js +2 -1
- 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 +3514 -689
- data/lib/stimeo/ui/version.rb +2 -3
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
|
280
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
/**
|
|
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);
|