stimeo-ui 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +209 -0
- data/README.md +128 -1
- data/dist/cable/index.js +392 -417
- data/dist/controllers/accordion_controller.js +228 -28
- data/dist/controllers/alert_dialog_controller.js +862 -221
- data/dist/controllers/announcer_controller.js +135 -191
- data/dist/controllers/aspect_ratio_controller.js +0 -10
- data/dist/controllers/auto_submit_controller.js +295 -110
- data/dist/controllers/avatar_controller.js +172 -106
- data/dist/controllers/breadcrumb_controller.js +0 -129
- data/dist/controllers/bulk_select_controller.js +264 -53
- data/dist/controllers/calendar_controller.js +346 -180
- data/dist/controllers/carousel_controller.js +587 -300
- data/dist/controllers/character_counter_controller.js +271 -143
- data/dist/controllers/checkbox_controller.js +77 -75
- data/dist/controllers/clipboard_controller.js +187 -108
- data/dist/controllers/collapsible_controller.js +374 -108
- data/dist/controllers/color_picker_controller.js +183 -127
- data/dist/controllers/combobox_controller.js +426 -109
- data/dist/controllers/command_palette_controller.js +994 -350
- data/dist/controllers/conditional_fields_controller.js +212 -125
- data/dist/controllers/confirm_controller.js +939 -245
- data/dist/controllers/context_menu_controller.js +275 -127
- data/dist/controllers/count_up_controller.js +73 -21
- data/dist/controllers/countdown_controller.js +235 -97
- data/dist/controllers/currency_input_controller.js +241 -118
- data/dist/controllers/data_grid_controller.js +229 -135
- data/dist/controllers/date_range_picker_controller.js +359 -165
- data/dist/controllers/dialog_controller.js +887 -225
- data/dist/controllers/direct_upload_controller.js +80 -142
- data/dist/controllers/dirty_form_controller.js +26 -47
- data/dist/controllers/dismissible_controller.js +175 -20
- data/dist/controllers/drawer_controller.js +963 -336
- data/dist/controllers/dropdown_controller.js +342 -85
- data/dist/controllers/editable_controller.js +182 -58
- data/dist/controllers/empty_state_controller.js +67 -53
- data/dist/controllers/file_dropzone_controller.js +426 -229
- data/dist/controllers/filter_controller.js +78 -36
- data/dist/controllers/flash_controller.js +328 -211
- data/dist/controllers/focus_controller.js +654 -239
- data/dist/controllers/form_field_controller.js +153 -136
- data/dist/controllers/form_validation_controller.js +29 -96
- data/dist/controllers/frame_loading_controller.js +263 -198
- data/dist/controllers/highlight_controller.js +119 -70
- data/dist/controllers/hover_card_controller.js +335 -118
- data/dist/controllers/idle_controller.js +316 -65
- data/dist/controllers/input_mask_controller.js +116 -61
- data/dist/controllers/intersection_controller.js +147 -103
- data/dist/controllers/lazy_frame_controller.js +9 -56
- data/dist/controllers/listbox_controller.js +413 -153
- data/dist/controllers/local_time_controller.js +54 -64
- data/dist/controllers/masonry_controller.js +70 -95
- data/dist/controllers/menu_controller.js +254 -167
- data/dist/controllers/menubar_controller.js +206 -331
- data/dist/controllers/meter_controller.js +159 -71
- data/dist/controllers/multi_select_controller.js +797 -388
- data/dist/controllers/navigation_menu_controller.js +156 -231
- data/dist/controllers/nested_form_controller.js +373 -174
- data/dist/controllers/network_status_controller.js +241 -45
- data/dist/controllers/number_input_controller.js +387 -227
- data/dist/controllers/optimistic_controller.js +28 -75
- data/dist/controllers/otp_controller.js +344 -196
- data/dist/controllers/overflow_indicator_controller.js +167 -93
- data/dist/controllers/overflow_menu_controller.js +408 -277
- data/dist/controllers/pagination_controller.js +342 -120
- data/dist/controllers/password_reveal_controller.js +424 -99
- data/dist/controllers/password_strength_controller.js +99 -153
- data/dist/controllers/persist_controller.js +252 -96
- data/dist/controllers/pointer_drag_controller.js +263 -164
- data/dist/controllers/popover_controller.js +267 -94
- data/dist/controllers/portal_controller.js +209 -72
- data/dist/controllers/preview_guard_controller.js +243 -112
- data/dist/controllers/progress_controller.js +132 -59
- data/dist/controllers/radio_group_controller.js +138 -133
- data/dist/controllers/range_slider_controller.js +245 -112
- data/dist/controllers/rating_controller.js +429 -194
- data/dist/controllers/read_more_controller.js +185 -47
- data/dist/controllers/reading_progress_controller.js +147 -135
- data/dist/controllers/relative_time_controller.js +145 -110
- data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
- data/dist/controllers/resizable_controller.js +294 -118
- data/dist/controllers/roving_controller.js +39 -84
- data/dist/controllers/scroll_area_controller.js +428 -221
- data/dist/controllers/scroll_restore_controller.js +0 -53
- data/dist/controllers/scroll_visibility_controller.js +395 -146
- data/dist/controllers/scrollspy_controller.js +181 -236
- data/dist/controllers/separator_controller.js +288 -132
- data/dist/controllers/sidebar_controller.js +1071 -375
- data/dist/controllers/skeleton_controller.js +248 -92
- data/dist/controllers/slider_controller.js +211 -96
- data/dist/controllers/smart_sticky_header_controller.js +163 -54
- data/dist/controllers/sortable_controller.js +13 -110
- data/dist/controllers/spinner_controller.js +277 -161
- data/dist/controllers/step_indicator_controller.js +107 -53
- data/dist/controllers/stepper_controller.js +171 -48
- data/dist/controllers/stick_to_bottom_controller.js +310 -116
- data/dist/controllers/sticky_observer_controller.js +214 -49
- data/dist/controllers/submit_once_controller.js +294 -192
- data/dist/controllers/switch_controller.js +107 -43
- data/dist/controllers/tabs_controller.js +50 -25
- data/dist/controllers/tags_input_controller.js +290 -149
- data/dist/controllers/textarea_autosize_controller.js +71 -58
- data/dist/controllers/theme_controller.js +148 -80
- data/dist/controllers/time_picker_controller.js +162 -81
- data/dist/controllers/toast_controller.js +365 -193
- data/dist/controllers/toggle_group_controller.js +194 -99
- data/dist/controllers/toolbar_controller.js +92 -90
- data/dist/controllers/tooltip_controller.js +185 -125
- data/dist/controllers/transition_controller.js +123 -115
- data/dist/controllers/tree_view_controller.js +248 -290
- data/dist/index.js +8649 -8572
- data/dist/positioning/index.js +148 -45
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +3 -3
|
@@ -1,19 +1,45 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
|
|
4
|
+
var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
|
|
5
|
+
function stateReasonFor(event) {
|
|
6
|
+
if (!event) return "api";
|
|
7
|
+
if (FOCUS_EVENTS.has(event.type)) return "focus";
|
|
8
|
+
if (POINTER_EVENTS.has(event.type)) return "pointer";
|
|
9
|
+
return "user";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function actionSource(source) {
|
|
13
|
+
if (source instanceof HTMLElement) {
|
|
14
|
+
return { event: null, host: source, origin: source, reason: "api" };
|
|
15
|
+
}
|
|
16
|
+
const event = source ?? null;
|
|
17
|
+
return {
|
|
18
|
+
event,
|
|
19
|
+
host: event?.currentTarget instanceof Element ? event.currentTarget : null,
|
|
20
|
+
origin: event?.target instanceof Element ? event.target : null,
|
|
21
|
+
reason: stateReasonFor(event)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
4
24
|
|
|
5
|
-
// src/utils/logical_scroll.ts
|
|
6
25
|
function isRtl(element) {
|
|
7
26
|
return window.getComputedStyle(element).direction === "rtl";
|
|
8
27
|
}
|
|
9
28
|
|
|
10
|
-
// src/utils/arrow_step.ts
|
|
11
29
|
function isReservedArrowChord(event, allow = []) {
|
|
12
30
|
if (!event.key.startsWith("Arrow")) return false;
|
|
13
31
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
32
|
}
|
|
15
33
|
|
|
16
|
-
|
|
34
|
+
function writeField(field, value) {
|
|
35
|
+
if (field.value === value) return false;
|
|
36
|
+
field.value = value;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
function commitField(target) {
|
|
40
|
+
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
function inheritsFieldsetDisabled(control) {
|
|
18
44
|
let fieldset = control.closest("fieldset[disabled]");
|
|
19
45
|
while (fieldset) {
|
|
@@ -23,15 +49,25 @@ function inheritsFieldsetDisabled(control) {
|
|
|
23
49
|
}
|
|
24
50
|
return false;
|
|
25
51
|
}
|
|
52
|
+
function unslotted(element) {
|
|
53
|
+
return element.assignedSlot === null && element.parentElement?.shadowRoot != null;
|
|
54
|
+
}
|
|
55
|
+
function flatTreeParent(element) {
|
|
56
|
+
if (unslotted(element)) return null;
|
|
57
|
+
const parent = element.assignedSlot ?? element.parentNode;
|
|
58
|
+
if (parent instanceof ShadowRoot) return parent.host;
|
|
59
|
+
return parent instanceof Element ? parent : null;
|
|
60
|
+
}
|
|
26
61
|
function canTakeFocus(element) {
|
|
27
|
-
|
|
62
|
+
for (let node = element; node; node = flatTreeParent(node)) {
|
|
63
|
+
if (unslotted(node) || node.matches("[hidden], [inert]")) return false;
|
|
64
|
+
}
|
|
28
65
|
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
29
66
|
if (!("disabled" in element)) return true;
|
|
30
67
|
if (element.disabled) return false;
|
|
31
68
|
return !inheritsFieldsetDisabled(element);
|
|
32
69
|
}
|
|
33
70
|
|
|
34
|
-
// src/utils/interactive_host.ts
|
|
35
71
|
var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
|
|
36
72
|
function isInteractiveHost(element) {
|
|
37
73
|
if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
|
|
@@ -48,31 +84,89 @@ function isInteractiveHost(element) {
|
|
|
48
84
|
return false;
|
|
49
85
|
}
|
|
50
86
|
|
|
51
|
-
|
|
87
|
+
var ListenerSet = class {
|
|
88
|
+
#abort = new AbortController();
|
|
89
|
+
add(target, type, handler, options) {
|
|
90
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
91
|
+
}
|
|
92
|
+
dispose() {
|
|
93
|
+
this.#abort.abort();
|
|
94
|
+
this.#abort = new AbortController();
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
var MicrotaskCoalescer = class {
|
|
99
|
+
#run;
|
|
100
|
+
#queued = false;
|
|
101
|
+
#active = false;
|
|
102
|
+
#generation = 0;
|
|
103
|
+
constructor(run) {
|
|
104
|
+
this.#run = run;
|
|
105
|
+
}
|
|
106
|
+
activate() {
|
|
107
|
+
this.#active = true;
|
|
108
|
+
}
|
|
109
|
+
cancel() {
|
|
110
|
+
this.#active = false;
|
|
111
|
+
this.#queued = false;
|
|
112
|
+
this.#generation += 1;
|
|
113
|
+
}
|
|
114
|
+
schedule() {
|
|
115
|
+
if (!this.#active || this.#queued) return;
|
|
116
|
+
this.#queued = true;
|
|
117
|
+
const generation = this.#generation;
|
|
118
|
+
queueMicrotask(() => {
|
|
119
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
120
|
+
this.#queued = false;
|
|
121
|
+
this.#run();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
var MorphRenderWatcher = class {
|
|
127
|
+
#listeners = new ListenerSet();
|
|
128
|
+
#pass;
|
|
129
|
+
#element = null;
|
|
130
|
+
#onMorph = () => this.#pass.schedule();
|
|
131
|
+
constructor(run) {
|
|
132
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
133
|
+
}
|
|
134
|
+
observe(element) {
|
|
135
|
+
if (this.#element === element) return;
|
|
136
|
+
this.disconnect();
|
|
137
|
+
this.#element = element;
|
|
138
|
+
this.#pass.activate();
|
|
139
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
140
|
+
}
|
|
141
|
+
schedule() {
|
|
142
|
+
this.#pass.schedule();
|
|
143
|
+
}
|
|
144
|
+
disconnect() {
|
|
145
|
+
this.#listeners.dispose();
|
|
146
|
+
this.#pass.cancel();
|
|
147
|
+
this.#element = null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
var MoveCounter = class {
|
|
152
|
+
#sequence = 0;
|
|
153
|
+
record() {
|
|
154
|
+
this.#sequence += 1;
|
|
155
|
+
return this.#sequence;
|
|
156
|
+
}
|
|
157
|
+
isLatest(token) {
|
|
158
|
+
return token === this.#sequence;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
52
162
|
var RovingTabindex = class {
|
|
53
|
-
/** Returns the current ordered item elements; called on every operation. */
|
|
54
163
|
#getItems;
|
|
55
|
-
/**
|
|
56
|
-
* @param getItems - Returns the current ordered item elements. Called on every
|
|
57
|
-
* operation so the live target list is always used.
|
|
58
|
-
*/
|
|
59
164
|
constructor(getItems) {
|
|
60
165
|
this.#getItems = getItems;
|
|
61
166
|
}
|
|
62
|
-
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
63
167
|
get activeIndex() {
|
|
64
168
|
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
65
169
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
68
|
-
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
69
|
-
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
70
|
-
* "nothing is currently tabbable".
|
|
71
|
-
*
|
|
72
|
-
* @param index - Position of the item to make tabbable.
|
|
73
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
74
|
-
* and `items` to reuse an event-scoped collection snapshot.
|
|
75
|
-
*/
|
|
76
170
|
setActive(index, options = {}) {
|
|
77
171
|
const { focus = false } = options;
|
|
78
172
|
const items = options.items ?? this.#getItems();
|
|
@@ -83,43 +177,28 @@ var RovingTabindex = class {
|
|
|
83
177
|
}
|
|
84
178
|
};
|
|
85
179
|
|
|
86
|
-
|
|
180
|
+
function targetSelector(identifier, name) {
|
|
181
|
+
return `[data-${identifier}-target~="${name}"]`;
|
|
182
|
+
}
|
|
183
|
+
|
|
87
184
|
var TimerRegistry = class {
|
|
88
|
-
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
89
185
|
ids = /* @__PURE__ */ new Set();
|
|
90
|
-
/**
|
|
91
|
-
* Cancels a single tracked timer.
|
|
92
|
-
*
|
|
93
|
-
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
94
|
-
* registry), so callers can clear defensively without guarding.
|
|
95
|
-
*/
|
|
96
186
|
clear(id) {
|
|
97
187
|
if (this.ids.delete(id)) {
|
|
98
188
|
this.cancel(id);
|
|
99
189
|
}
|
|
100
190
|
}
|
|
101
|
-
/**
|
|
102
|
-
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
103
|
-
* to guarantee no timer outlives the element.
|
|
104
|
-
*/
|
|
105
191
|
clearAll() {
|
|
106
192
|
for (const id of this.ids) {
|
|
107
193
|
this.cancel(id);
|
|
108
194
|
}
|
|
109
195
|
this.ids.clear();
|
|
110
196
|
}
|
|
111
|
-
/** Number of timers currently tracked (pending). */
|
|
112
197
|
get size() {
|
|
113
198
|
return this.ids.size;
|
|
114
199
|
}
|
|
115
200
|
};
|
|
116
201
|
var SafeTimeout = class extends TimerRegistry {
|
|
117
|
-
/**
|
|
118
|
-
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
119
|
-
*
|
|
120
|
-
* The id is removed from the registry automatically when the timeout fires,
|
|
121
|
-
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
122
|
-
*/
|
|
123
202
|
set(callback, delay) {
|
|
124
203
|
const id = this.schedule(() => {
|
|
125
204
|
this.ids.delete(id);
|
|
@@ -136,29 +215,18 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
136
215
|
}
|
|
137
216
|
};
|
|
138
217
|
|
|
139
|
-
// src/utils/typeahead.ts
|
|
140
218
|
var TYPEAHEAD_RESET_MS = 500;
|
|
141
219
|
var Typeahead = class {
|
|
142
|
-
/** Timer registry for the pending idle reset; private so `reset()` is the only exit. */
|
|
143
220
|
#timers = new SafeTimeout();
|
|
144
|
-
/** Idle window before the query resets, in milliseconds. */
|
|
145
221
|
#resetMs;
|
|
146
|
-
/** The accumulated lowercase query, empty when idle. */
|
|
147
222
|
#query = "";
|
|
148
|
-
/** Id of the pending reset timer, `0` when none is scheduled. */
|
|
149
223
|
#timerId = 0;
|
|
150
|
-
/** @param options - Overrides for the idle window. */
|
|
151
224
|
constructor({ resetMs = TYPEAHEAD_RESET_MS } = {}) {
|
|
152
225
|
this.#resetMs = resetMs;
|
|
153
226
|
}
|
|
154
|
-
/** The query a search would currently run with; empty while idle. */
|
|
155
227
|
get query() {
|
|
156
228
|
return this.#query;
|
|
157
229
|
}
|
|
158
|
-
/**
|
|
159
|
-
* Folds `key` into the query, restarts the idle window, and returns the query to
|
|
160
|
-
* search with. A repeated character collapses the query to that one character.
|
|
161
|
-
*/
|
|
162
230
|
push(key) {
|
|
163
231
|
const char = key.toLowerCase();
|
|
164
232
|
const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);
|
|
@@ -167,7 +235,6 @@ var Typeahead = class {
|
|
|
167
235
|
this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);
|
|
168
236
|
return this.#query;
|
|
169
237
|
}
|
|
170
|
-
/** Clears the query and cancels the pending idle reset. */
|
|
171
238
|
reset() {
|
|
172
239
|
this.#query = "";
|
|
173
240
|
this.#timers.clear(this.#timerId);
|
|
@@ -194,48 +261,28 @@ function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel
|
|
|
194
261
|
return -1;
|
|
195
262
|
}
|
|
196
263
|
|
|
197
|
-
|
|
264
|
+
var OBSERVED_ATTRIBUTES = ["aria-selected", "data-value"];
|
|
265
|
+
function setAttributeIfChanged(element, name, value) {
|
|
266
|
+
if (element.getAttribute(name) !== value) element.setAttribute(name, value);
|
|
267
|
+
}
|
|
198
268
|
var NESTED_INTERACTIVE = INTERACTIVE_HOST_SELECTOR;
|
|
199
269
|
var TreeViewController = class extends Controller {
|
|
200
|
-
|
|
201
|
-
static
|
|
202
|
-
static
|
|
270
|
+
#moves = new MoveCounter();
|
|
271
|
+
static targets = ["item", "group", "field"];
|
|
272
|
+
static actions = ["onKeydown", "select", "toggle"];
|
|
273
|
+
static events = ["select", "toggle", "reconcile"];
|
|
203
274
|
#roving = new RovingTabindex(() => this.itemTargets);
|
|
204
275
|
#typeahead = new Typeahead();
|
|
205
|
-
#
|
|
276
|
+
#reconcile = new MorphRenderWatcher(() => this.#reconcileSelection());
|
|
277
|
+
#observer = null;
|
|
278
|
+
#settled = { item: null, value: "" };
|
|
206
279
|
#connected = false;
|
|
207
|
-
/**
|
|
208
|
-
* Item targets in DOM order as of the last connect / target change. A removed
|
|
209
|
-
* item is gone from `itemTargets` by the time Stimulus reports it, so this is
|
|
210
|
-
* the only record of where in the order the gap opened.
|
|
211
|
-
*/
|
|
212
280
|
#order = [];
|
|
213
|
-
/** The `treeitem` that last took DOM focus inside this tree, if any. */
|
|
214
281
|
#focused = null;
|
|
215
|
-
/**
|
|
216
|
-
* Records which row owns DOM focus. `focusin` bubbles, so one listener covers
|
|
217
|
-
* rows and anything nested in them; the tree needs this because a removal is
|
|
218
|
-
* only reported *after* the fact, when the browser has already reset
|
|
219
|
-
* `document.activeElement`.
|
|
220
|
-
*/
|
|
221
282
|
#onFocusIn = (event) => {
|
|
222
283
|
const target = event.target;
|
|
223
284
|
this.#focused = target?.closest('[role="treeitem"]') ?? null;
|
|
224
285
|
};
|
|
225
|
-
/**
|
|
226
|
-
* Forgets the tracked row when focus genuinely leaves the tree, so a later
|
|
227
|
-
* removal of that row cannot be mistaken for "focus died with it" and pull
|
|
228
|
-
* focus back in.
|
|
229
|
-
*
|
|
230
|
-
* A *removed* focused row must not clear the record — that is exactly the case
|
|
231
|
-
* {@link TreeViewController.itemTargetDisconnected} needs it for. The two are
|
|
232
|
-
* indistinguishable while the event is dispatching: Chromium fires `focusout`
|
|
233
|
-
* for a removed row *before* the detach lands — `isConnected` still reads
|
|
234
|
-
* `true` — with `document.activeElement` already on `<body>`, exactly like a
|
|
235
|
-
* plain `blur()`. Deciding on a microtask separates them — a row that is gone
|
|
236
|
-
* by then was removed. Focus moving between two rows needs no handling:
|
|
237
|
-
* `focusin` overwrites the record right after.
|
|
238
|
-
*/
|
|
239
286
|
#onFocusOut = (event) => {
|
|
240
287
|
const target = event.target;
|
|
241
288
|
const row = target?.closest('[role="treeitem"]') ?? null;
|
|
@@ -246,11 +293,6 @@ var TreeViewController = class extends Controller {
|
|
|
246
293
|
if (this.#focused === row && row.isConnected) this.#focused = null;
|
|
247
294
|
});
|
|
248
295
|
};
|
|
249
|
-
/**
|
|
250
|
-
* Reconciles authored expansion state and establishes the single tab stop.
|
|
251
|
-
* Idempotent, so a Turbo cache restore / morph re-runs it safely (re-adding
|
|
252
|
-
* the same listener reference is a no-op per the DOM spec).
|
|
253
|
-
*/
|
|
254
296
|
connect() {
|
|
255
297
|
this.element.addEventListener("focusin", this.#onFocusIn);
|
|
256
298
|
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
@@ -258,76 +300,87 @@ var TreeViewController = class extends Controller {
|
|
|
258
300
|
this.#reconcileExpansion();
|
|
259
301
|
this.#normalizeSelection();
|
|
260
302
|
this.#normalizeTabStop();
|
|
303
|
+
this.#mirrorField(false);
|
|
261
304
|
this.#order = [...this.itemTargets];
|
|
305
|
+
this.#settled = this.#selection();
|
|
262
306
|
this.#connected = true;
|
|
307
|
+
this.#reconcile.observe(this.element);
|
|
308
|
+
this.#observeItems();
|
|
309
|
+
}
|
|
310
|
+
fieldTargetConnected() {
|
|
311
|
+
this.#reconcile.schedule();
|
|
312
|
+
}
|
|
313
|
+
fieldTargetDisconnected() {
|
|
314
|
+
this.#reconcile.schedule();
|
|
263
315
|
}
|
|
264
|
-
/** Detaches the focus trackers, drops the typeahead buffer and its timer. */
|
|
265
316
|
disconnect() {
|
|
266
317
|
this.element.removeEventListener("focusin", this.#onFocusIn);
|
|
267
318
|
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
268
319
|
this.#connected = false;
|
|
320
|
+
this.#reconcile.disconnect();
|
|
321
|
+
this.#observer?.disconnect();
|
|
322
|
+
this.#observer = null;
|
|
269
323
|
this.#typeahead.reset();
|
|
270
|
-
this.#timers.clearAll();
|
|
271
324
|
this.#order = [];
|
|
272
325
|
this.#focused = null;
|
|
273
326
|
}
|
|
274
|
-
/**
|
|
275
|
-
* Seeds the focus record from the live document. A row can already hold focus
|
|
276
|
-
* when the controller connects (a Turbo restore, or `data-controller` added to
|
|
277
|
-
* a tree the user was already in), and the `focusin` listener alone would
|
|
278
|
-
* never learn about it.
|
|
279
|
-
*/
|
|
280
327
|
#seedFocused() {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Re-normalizes the tab stop when a `treeitem` is added at runtime, so an
|
|
286
|
-
* appended item carrying `tabindex="0"` cannot turn the tree into two Tab
|
|
287
|
-
* stops. Skipped before `connect()`: Stimulus registers the initial targets
|
|
288
|
-
* first, and `connect()` owns the initial tab-stop policy.
|
|
289
|
-
*/
|
|
328
|
+
this.#focused = this.element.ownerDocument.activeElement?.closest('[role="treeitem"]') ?? null;
|
|
329
|
+
}
|
|
290
330
|
itemTargetConnected() {
|
|
291
331
|
if (!this.#connected) return;
|
|
292
|
-
this.#normalizeSelection();
|
|
293
332
|
this.#normalizeTabStop();
|
|
294
333
|
this.#trackOrder();
|
|
334
|
+
this.#reconcile.schedule();
|
|
295
335
|
}
|
|
296
|
-
/**
|
|
297
|
-
* Brings authored `aria-selected` to the shape the APG requires, without
|
|
298
|
-
* changing which item the author chose.
|
|
299
|
-
*
|
|
300
|
-
* Every item gets an explicit value — an absent `aria-selected` means "not
|
|
301
|
-
* selectable" in ARIA, so a forgotten attribute hides a selectable row — and a
|
|
302
|
-
* single-select tree keeps at most one `true`, first in DOM order. `connect()`
|
|
303
|
-
* reconciles authored expansion the same way. The scan is the `item` target
|
|
304
|
-
* set: a `role="treeitem"` without the target is outside the contract and is
|
|
305
|
-
* neither counted nor written.
|
|
306
|
-
*/
|
|
307
336
|
#normalizeSelection() {
|
|
308
337
|
const items = this.itemTargets;
|
|
309
338
|
const selected = items.find((item) => item.getAttribute("aria-selected") === "true");
|
|
310
339
|
for (const item of items) {
|
|
311
|
-
item
|
|
340
|
+
setAttributeIfChanged(item, "aria-selected", item === selected ? "true" : "false");
|
|
312
341
|
}
|
|
313
342
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
343
|
+
#mirrorField(notify) {
|
|
344
|
+
if (!this.hasFieldTarget) return;
|
|
345
|
+
const moved = writeField(this.fieldTarget, this.#selection().value);
|
|
346
|
+
if (moved && notify) commitField(this.fieldTarget);
|
|
347
|
+
}
|
|
348
|
+
#selection() {
|
|
349
|
+
const item = this.itemTargets.find((candidate) => candidate.getAttribute("aria-selected") === "true") ?? null;
|
|
350
|
+
return { item, value: item?.dataset.value ?? "" };
|
|
351
|
+
}
|
|
352
|
+
#reconcileSelection() {
|
|
353
|
+
this.#reconcileExpansion();
|
|
354
|
+
this.#normalizeSelection();
|
|
355
|
+
this.#normalizeTabStop();
|
|
356
|
+
this.#dropOwnRecords();
|
|
357
|
+
this.#mirrorField(false);
|
|
358
|
+
this.#reportMove();
|
|
359
|
+
}
|
|
360
|
+
#reportMove() {
|
|
361
|
+
const selection = this.#selection();
|
|
362
|
+
if (selection.item === this.#settled.item && selection.value === this.#settled.value) return;
|
|
363
|
+
this.#settled = selection;
|
|
364
|
+
this.dispatch("reconcile", { detail: { item: selection.item } });
|
|
365
|
+
}
|
|
366
|
+
#observeItems() {
|
|
367
|
+
const observer = new MutationObserver((records) => {
|
|
368
|
+
if (this.#concernsItems(records)) this.#reconcile.schedule();
|
|
369
|
+
});
|
|
370
|
+
observer.observe(this.element, {
|
|
371
|
+
subtree: true,
|
|
372
|
+
attributes: true,
|
|
373
|
+
attributeFilter: OBSERVED_ATTRIBUTES
|
|
374
|
+
});
|
|
375
|
+
this.#observer = observer;
|
|
376
|
+
}
|
|
377
|
+
#concernsItems(records) {
|
|
378
|
+
const items = new Set(this.itemTargets);
|
|
379
|
+
return records.some((record) => items.has(record.target));
|
|
380
|
+
}
|
|
381
|
+
#dropOwnRecords() {
|
|
382
|
+
this.#observer?.takeRecords();
|
|
383
|
+
}
|
|
331
384
|
itemTargetDisconnected(item) {
|
|
332
385
|
if (!this.#connected || !this.element.isConnected) return;
|
|
333
386
|
const stranded = this.#focusDiedWith(item);
|
|
@@ -336,14 +389,9 @@ var TreeViewController = class extends Controller {
|
|
|
336
389
|
const next = this.#neighborOf(item);
|
|
337
390
|
if (next) this.#roving.setActive(this.itemTargets.indexOf(next), { focus: stranded });
|
|
338
391
|
}
|
|
392
|
+
this.#reconcile.schedule();
|
|
339
393
|
this.#order = this.#order.filter((tracked) => tracked !== item);
|
|
340
394
|
}
|
|
341
|
-
/**
|
|
342
|
-
* Folds newly connected targets into the DOM-order snapshot, keeping the slot
|
|
343
|
-
* of any item whose disconnect callback has not arrived yet — a morph that
|
|
344
|
-
* both adds and removes rows reports one batch as several callbacks, and
|
|
345
|
-
* {@link TreeViewController.#neighborOf} needs those slots.
|
|
346
|
-
*/
|
|
347
395
|
#trackOrder() {
|
|
348
396
|
const live = this.itemTargets;
|
|
349
397
|
const pending = this.#order.filter((tracked) => !live.includes(tracked));
|
|
@@ -357,12 +405,6 @@ var TreeViewController = class extends Controller {
|
|
|
357
405
|
}
|
|
358
406
|
this.#order = merged;
|
|
359
407
|
}
|
|
360
|
-
/**
|
|
361
|
-
* Routes tree keyboard interaction. Because `treeitem`s nest, only the handler
|
|
362
|
-
* on the nearest item to the event target acts; the same keydown bubbling to an
|
|
363
|
-
* ancestor item's handler is ignored to avoid double moves / selections. Keys
|
|
364
|
-
* from a nested interactive control (or an IME composition) are left untouched.
|
|
365
|
-
*/
|
|
366
408
|
onKeydown(event) {
|
|
367
409
|
if (event.defaultPrevented) return;
|
|
368
410
|
if (isReservedArrowChord(event)) return;
|
|
@@ -378,15 +420,6 @@ var TreeViewController = class extends Controller {
|
|
|
378
420
|
event.preventDefault();
|
|
379
421
|
this.#moveBy(item, -1);
|
|
380
422
|
break;
|
|
381
|
-
// Logical, not physical: APG describes these as "to the child level" and
|
|
382
|
-
// "to the parent level" — a spatial move — so they follow the writing
|
|
383
|
-
// direction, exactly as horizontal roving does. `isRtl()` is the shared
|
|
384
|
-
// detector; never introduce a second one.
|
|
385
|
-
//
|
|
386
|
-
// Direction comes from the tree, not from `item`: the tree is what lays
|
|
387
|
-
// the rows out, and a row may carry its own `dir` (an LTR path inside an
|
|
388
|
-
// RTL browser is ordinary bidi authoring). Probing the focused row instead
|
|
389
|
-
// would let one row's arrows mean the opposite of its sibling's.
|
|
390
423
|
case "ArrowRight":
|
|
391
424
|
event.preventDefault();
|
|
392
425
|
if (isRtl(this.element)) this.#collapseOrLeave(item);
|
|
@@ -418,53 +451,43 @@ var TreeViewController = class extends Controller {
|
|
|
418
451
|
break;
|
|
419
452
|
}
|
|
420
453
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const item = this
|
|
424
|
-
if (!item) return;
|
|
454
|
+
select(source) {
|
|
455
|
+
const { event, host, origin, reason } = actionSource(source);
|
|
456
|
+
const item = host?.closest(targetSelector(this.identifier, "item"));
|
|
457
|
+
if (!item || !this.itemTargets.includes(item)) return;
|
|
458
|
+
if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
|
|
459
|
+
return;
|
|
460
|
+
if (event && this.#ownerItem(event) !== item) return;
|
|
461
|
+
const group = this.#childGroup(item);
|
|
462
|
+
if (event && origin && group?.contains(origin)) return;
|
|
463
|
+
const moveFocus = event !== null || this.element.contains(document.activeElement);
|
|
464
|
+
this.#focusItem(item, moveFocus);
|
|
465
|
+
this.#select(item, reason);
|
|
466
|
+
}
|
|
467
|
+
toggle(source) {
|
|
468
|
+
const { event, origin, reason } = actionSource(source);
|
|
469
|
+
const item = origin?.closest(targetSelector(this.identifier, "item"));
|
|
470
|
+
if (!item || !this.itemTargets.includes(item)) return;
|
|
471
|
+
if (origin?.closest(`[data-controller~="${this.identifier}"]`) !== this.element) return;
|
|
425
472
|
const group = this.#childGroup(item);
|
|
426
|
-
if (group
|
|
427
|
-
this
|
|
428
|
-
this.#
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
*
|
|
435
|
-
* The toggled row then takes focus and the tab stop, matching what the keyboard
|
|
436
|
-
* path guarantees. Without it, a browser that focuses buttons on mousedown leaves
|
|
437
|
-
* focus on the clicked chevron: arrows would stop working until focus returns to a
|
|
438
|
-
* row, and a decorative `aria-hidden` chevron would hold focus. The synchronous
|
|
439
|
-
* `stimeo--tree-view:toggle` event is observed before this final focus hand-off.
|
|
440
|
-
*/
|
|
441
|
-
toggle(event) {
|
|
442
|
-
const item = event.target.closest('[role="treeitem"]');
|
|
443
|
-
if (!item || !this.element.contains(item)) return;
|
|
444
|
-
if (!this.#childGroup(item)) return;
|
|
445
|
-
this.#setExpanded(item, !this.#isExpanded(item));
|
|
446
|
-
this.#focusItem(item);
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* The item a raw event belongs to, or `null` when the tree must not act: the
|
|
450
|
-
* event came from a nested `treeitem` (it bubbled to an ancestor's handler) or
|
|
451
|
-
* from an interactive control inside the item.
|
|
452
|
-
*/
|
|
473
|
+
if (!group) return;
|
|
474
|
+
const moveFocus = event !== null || this.element.contains(document.activeElement);
|
|
475
|
+
this.#setExpanded(item, group, !this.#isExpanded(item), reason);
|
|
476
|
+
this.#focusItem(
|
|
477
|
+
item,
|
|
478
|
+
moveFocus && (event !== null || this.element.contains(document.activeElement))
|
|
479
|
+
);
|
|
480
|
+
}
|
|
453
481
|
#ownerItem(event) {
|
|
454
|
-
const
|
|
455
|
-
const
|
|
482
|
+
const { host, origin: target } = actionSource(event);
|
|
483
|
+
const item = host?.closest('[role="treeitem"]');
|
|
484
|
+
if (!item || !target) return null;
|
|
456
485
|
if (target.closest('[role="treeitem"]') !== item) return null;
|
|
457
486
|
const control = target.closest(NESTED_INTERACTIVE);
|
|
458
487
|
if (control && item.contains(control)) return null;
|
|
459
|
-
if (isInteractiveHost(target)) return null;
|
|
488
|
+
if (target instanceof HTMLElement && isInteractiveHost(target)) return null;
|
|
460
489
|
return item;
|
|
461
490
|
}
|
|
462
|
-
/**
|
|
463
|
-
* The surviving visible item that inherits `removed`'s position: the next one
|
|
464
|
-
* in the pre-removal order, else the previous one. Falls back to the first
|
|
465
|
-
* visible item when `removed` is not in that order — a target that arrived and
|
|
466
|
-
* left between two callbacks — and to `undefined` when nothing visible is left.
|
|
467
|
-
*/
|
|
468
491
|
#neighborOf(removed) {
|
|
469
492
|
const visible = this.#visibleItems;
|
|
470
493
|
const index = this.#order.indexOf(removed);
|
|
@@ -478,14 +501,6 @@ var TreeViewController = class extends Controller {
|
|
|
478
501
|
}
|
|
479
502
|
return visible[0];
|
|
480
503
|
}
|
|
481
|
-
/**
|
|
482
|
-
* Whether DOM focus went down with `removed`: it sat on that row (or inside
|
|
483
|
-
* it) and the document now has nowhere to put focus. A browser falls back to
|
|
484
|
-
* `<body>`; a still-referenced but detached `activeElement` is the same
|
|
485
|
-
* condition seen from the other side. Focus that landed on a real element —
|
|
486
|
-
* inside this tree or anywhere else on the page — is left alone, so a removal
|
|
487
|
-
* can never steal it.
|
|
488
|
-
*/
|
|
489
504
|
#focusDiedWith(removed) {
|
|
490
505
|
const focused = this.#focused;
|
|
491
506
|
if (!focused || focused !== removed && !removed.contains(focused)) return false;
|
|
@@ -494,7 +509,6 @@ var TreeViewController = class extends Controller {
|
|
|
494
509
|
if (active === null || active === doc.body || active === doc.documentElement) return true;
|
|
495
510
|
return !active.isConnected;
|
|
496
511
|
}
|
|
497
|
-
/** Moves focus to the next (`delta=1`) or previous visible item, if any. */
|
|
498
512
|
#moveBy(item, delta) {
|
|
499
513
|
const visible = this.#visibleItems;
|
|
500
514
|
const current = visible.indexOf(item);
|
|
@@ -502,17 +516,11 @@ var TreeViewController = class extends Controller {
|
|
|
502
516
|
const next = visible[current + delta];
|
|
503
517
|
if (next) this.#focusItem(next);
|
|
504
518
|
}
|
|
505
|
-
/**
|
|
506
|
-
* Expands a collapsed parent, else steps into the first child.
|
|
507
|
-
*
|
|
508
|
-
* Bound to `ArrowRight` under LTR and `ArrowLeft` under RTL — the caller picks;
|
|
509
|
-
* this method only knows "toward the child level".
|
|
510
|
-
*/
|
|
511
519
|
#expandOrEnter(item) {
|
|
512
520
|
const group = this.#childGroup(item);
|
|
513
521
|
if (!group) return;
|
|
514
522
|
if (!this.#isExpanded(item)) {
|
|
515
|
-
this.#setExpanded(item, true);
|
|
523
|
+
this.#setExpanded(item, group, true);
|
|
516
524
|
return;
|
|
517
525
|
}
|
|
518
526
|
if (group.hidden) return;
|
|
@@ -521,34 +529,20 @@ var TreeViewController = class extends Controller {
|
|
|
521
529
|
).find((child) => !child.hidden);
|
|
522
530
|
if (firstChild) this.#focusItem(firstChild);
|
|
523
531
|
}
|
|
524
|
-
/** `ArrowLeft`: collapse an expanded parent, else step out to the parent item. */
|
|
525
532
|
#collapseOrLeave(item) {
|
|
526
|
-
|
|
527
|
-
|
|
533
|
+
const group = this.#childGroup(item);
|
|
534
|
+
if (group && this.#isExpanded(item)) {
|
|
535
|
+
this.#setExpanded(item, group, false);
|
|
528
536
|
return;
|
|
529
537
|
}
|
|
530
538
|
const parent = this.#parentItem(item);
|
|
531
539
|
if (parent) this.#focusItem(parent);
|
|
532
540
|
}
|
|
533
|
-
/** Focuses the first (`0`) or last (`-1`) visible item. */
|
|
534
541
|
#focusEdge(index) {
|
|
535
542
|
const visible = this.#visibleItems;
|
|
536
543
|
const target = index < 0 ? visible[visible.length - 1] : visible[index];
|
|
537
544
|
if (target) this.#focusItem(target);
|
|
538
545
|
}
|
|
539
|
-
/**
|
|
540
|
-
* Whether the item is disabled — **its own attribute or an ancestor's**.
|
|
541
|
-
*
|
|
542
|
-
* ARIA is explicit that the state carries down: "The state of being disabled
|
|
543
|
-
* applies to the current element *and all focusable descendant elements* of the
|
|
544
|
-
* element on which the `aria-disabled` attribute is applied." A tree is the only
|
|
545
|
-
* pattern here whose items nest, so it is the only place the inheritance is
|
|
546
|
-
* observable — but the rule is the shared one.
|
|
547
|
-
*
|
|
548
|
-
* The walk stops at the controller element: an `aria-disabled` on the tree root
|
|
549
|
-
* would otherwise disable every row, and a disabled *tree* is the consumer's
|
|
550
|
-
* call to make with `inert`, not something this controller infers.
|
|
551
|
-
*/
|
|
552
546
|
#isDisabled(item) {
|
|
553
547
|
let current = item;
|
|
554
548
|
while (current && current !== this.element) {
|
|
@@ -557,35 +551,30 @@ var TreeViewController = class extends Controller {
|
|
|
557
551
|
}
|
|
558
552
|
return false;
|
|
559
553
|
}
|
|
560
|
-
|
|
561
|
-
* Applies single selection and dispatches `select`. An `aria-disabled="true"`
|
|
562
|
-
* item stays focusable (APG keeps disabled nodes reachable) but is never
|
|
563
|
-
* activated, so consumers see no `select` for it.
|
|
564
|
-
*/
|
|
565
|
-
#select(item) {
|
|
554
|
+
#select(item, reason = "user") {
|
|
566
555
|
if (this.#isDisabled(item)) return;
|
|
567
556
|
for (const candidate of this.itemTargets) {
|
|
568
|
-
candidate
|
|
557
|
+
setAttributeIfChanged(candidate, "aria-selected", candidate === item ? "true" : "false");
|
|
558
|
+
}
|
|
559
|
+
this.#dropOwnRecords();
|
|
560
|
+
const selection = this.#selection();
|
|
561
|
+
const changed = selection.item !== this.#settled.item || selection.value !== this.#settled.value;
|
|
562
|
+
this.#settled = selection;
|
|
563
|
+
if (!changed) {
|
|
564
|
+
this.#mirrorField(false);
|
|
565
|
+
return;
|
|
569
566
|
}
|
|
570
|
-
this.
|
|
567
|
+
const token = this.#moves.record();
|
|
568
|
+
this.#mirrorField(true);
|
|
569
|
+
if (!this.#moves.isLatest(token)) return;
|
|
570
|
+
this.dispatch("select", { detail: { item, reason } });
|
|
571
571
|
}
|
|
572
|
-
|
|
573
|
-
#setExpanded(item, expanded) {
|
|
574
|
-
const group = this.#childGroup(item);
|
|
575
|
-
if (!group) return;
|
|
572
|
+
#setExpanded(item, group, expanded, reason = "user") {
|
|
576
573
|
item.setAttribute("aria-expanded", String(expanded));
|
|
577
574
|
group.hidden = !expanded;
|
|
578
575
|
if (!expanded) this.#escapeCollapsedSubtree(item, group);
|
|
579
|
-
this.dispatch("toggle", { detail: { item, expanded } });
|
|
580
|
-
}
|
|
581
|
-
/**
|
|
582
|
-
* Reconciles focus and the roving tab stop before a synchronous `toggle` event
|
|
583
|
-
* observes a newly collapsed group. If DOM focus remains inside the group,
|
|
584
|
-
* both move to the parent. Otherwise only a stranded tab stop moves, preserving
|
|
585
|
-
* DOM focus. A native chevron click takes this path when mousedown has already
|
|
586
|
-
* focused the button. {@link TreeViewController.toggle} completes its focus
|
|
587
|
-
* hand-off after this reconciliation and event dispatch.
|
|
588
|
-
*/
|
|
576
|
+
this.dispatch("toggle", { detail: { item, expanded, reason } });
|
|
577
|
+
}
|
|
589
578
|
#escapeCollapsedSubtree(item, group) {
|
|
590
579
|
const active = document.activeElement;
|
|
591
580
|
if (active instanceof HTMLElement && group.contains(active)) {
|
|
@@ -593,23 +582,21 @@ var TreeViewController = class extends Controller {
|
|
|
593
582
|
return;
|
|
594
583
|
}
|
|
595
584
|
const activeIndex = this.#roving.activeIndex;
|
|
596
|
-
const tabbable =
|
|
585
|
+
const tabbable = this.itemTargets[activeIndex];
|
|
597
586
|
if (!tabbable || !group.contains(tabbable)) return;
|
|
598
587
|
const index = this.itemTargets.indexOf(item);
|
|
599
588
|
if (index !== -1) this.#roving.setActive(index);
|
|
600
589
|
}
|
|
601
|
-
|
|
602
|
-
#focusItem(item) {
|
|
590
|
+
#focusItem(item, focus = true) {
|
|
603
591
|
const index = this.itemTargets.indexOf(item);
|
|
604
592
|
if (index !== -1) {
|
|
605
|
-
this.#roving.setActive(index, { focus
|
|
593
|
+
this.#roving.setActive(index, { focus });
|
|
606
594
|
return;
|
|
607
595
|
}
|
|
608
596
|
if (!canTakeFocus(item)) return;
|
|
609
597
|
item.tabIndex = -1;
|
|
610
598
|
item.focus();
|
|
611
599
|
}
|
|
612
|
-
/** Advances the typeahead query and focuses the next matching visible item. */
|
|
613
600
|
#typeaheadTo(item, char) {
|
|
614
601
|
const query = this.#typeahead.push(char);
|
|
615
602
|
const visible = this.#visibleItems;
|
|
@@ -622,13 +609,6 @@ var TreeViewController = class extends Controller {
|
|
|
622
609
|
const match = visible[index];
|
|
623
610
|
if (match) this.#focusItem(match);
|
|
624
611
|
}
|
|
625
|
-
/**
|
|
626
|
-
* Aligns each parent's `aria-expanded` with its child `group`'s `hidden`.
|
|
627
|
-
* `aria-expanded` wins when authored (it is what assistive tech reads); a
|
|
628
|
-
* parent without it derives one from `hidden` (establish-only-when-absent, so
|
|
629
|
-
* a restored snapshot is never clobbered). Disagreeing markup would otherwise
|
|
630
|
-
* strand the only tab stop on an invisible item.
|
|
631
|
-
*/
|
|
632
612
|
#reconcileExpansion() {
|
|
633
613
|
for (const item of this.itemTargets) {
|
|
634
614
|
const group = this.#childGroup(item);
|
|
@@ -640,32 +620,19 @@ var TreeViewController = class extends Controller {
|
|
|
640
620
|
}
|
|
641
621
|
}
|
|
642
622
|
}
|
|
643
|
-
/**
|
|
644
|
-
* Leaves exactly one item tabbable. An existing, still-visible tab stop wins
|
|
645
|
-
* (the DOM is the source of truth after a Turbo restore, so the user's roving
|
|
646
|
-
* position survives); otherwise the selected item takes it (APG: a single-select
|
|
647
|
-
* tree puts initial focus on its selected node), else the first visible item.
|
|
648
|
-
*/
|
|
649
623
|
#normalizeTabStop() {
|
|
650
624
|
const items = this.itemTargets;
|
|
651
625
|
const visible = this.#visibleItems;
|
|
652
626
|
const activeIndex = this.#roving.activeIndex;
|
|
653
|
-
const active =
|
|
627
|
+
const active = items[activeIndex];
|
|
654
628
|
if (active && visible.includes(active)) {
|
|
655
629
|
this.#roving.setActive(activeIndex);
|
|
656
630
|
return;
|
|
657
631
|
}
|
|
658
632
|
const selected = visible.find((item) => item.getAttribute("aria-selected") === "true");
|
|
659
633
|
const next = selected ?? visible[0];
|
|
660
|
-
this.#roving.setActive(
|
|
661
|
-
}
|
|
662
|
-
/**
|
|
663
|
-
* The visible items: those with no `hidden` ancestor up to the tree, and not
|
|
664
|
-
* `hidden` themselves. A hidden row is out of the move set — it can hold
|
|
665
|
-
* neither DOM focus nor the only Tab stop — and neither can anything nested
|
|
666
|
-
* under one, which is why the walk tests every ancestor rather than only the
|
|
667
|
-
* collapsed `role="group"` containers.
|
|
668
|
-
*/
|
|
634
|
+
this.#roving.setActive(items.findIndex((item) => item === next));
|
|
635
|
+
}
|
|
669
636
|
get #visibleItems() {
|
|
670
637
|
return this.itemTargets.filter((item) => {
|
|
671
638
|
let node = item;
|
|
@@ -676,23 +643,15 @@ var TreeViewController = class extends Controller {
|
|
|
676
643
|
return true;
|
|
677
644
|
});
|
|
678
645
|
}
|
|
679
|
-
/** The child `group` owned directly by `item`, or `null` for a leaf. */
|
|
680
646
|
#childGroup(item) {
|
|
681
647
|
return item.querySelector(':scope > [role="group"]');
|
|
682
648
|
}
|
|
683
|
-
/** The nearest ancestor `treeitem`, or `null` at the root level. */
|
|
684
649
|
#parentItem(item) {
|
|
685
650
|
return item.parentElement?.closest('[role="treeitem"]') ?? null;
|
|
686
651
|
}
|
|
687
|
-
/** Whether a parent item is currently expanded. */
|
|
688
652
|
#isExpanded(item) {
|
|
689
653
|
return item.getAttribute("aria-expanded") === "true";
|
|
690
654
|
}
|
|
691
|
-
/**
|
|
692
|
-
* `item`'s name for typeahead. The fallback is the item's *own* text: a parent
|
|
693
|
-
* row's nested child group is rendered inside it, and folding a whole subtree
|
|
694
|
-
* into one name would let "readme" match the folder containing it.
|
|
695
|
-
*/
|
|
696
655
|
#label(item) {
|
|
697
656
|
return typeaheadLabel(item, () => {
|
|
698
657
|
let text = "";
|
|
@@ -708,5 +667,4 @@ var TreeViewController = class extends Controller {
|
|
|
708
667
|
};
|
|
709
668
|
|
|
710
669
|
export { TreeViewController };
|
|
711
|
-
//# sourceMappingURL=tree_view_controller.js.map
|
|
712
670
|
//# sourceMappingURL=tree_view_controller.js.map
|