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