stimeo-ui 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/controllers/accordion_controller.d.ts +59 -0
- package/dist/controllers/accordion_controller.js +76 -0
- package/dist/controllers/accordion_controller.js.map +1 -0
- package/dist/controllers/announcer_controller.d.ts +78 -0
- package/dist/controllers/announcer_controller.js +184 -0
- package/dist/controllers/announcer_controller.js.map +1 -0
- package/dist/controllers/aspect_ratio_controller.d.ts +39 -0
- package/dist/controllers/aspect_ratio_controller.js +36 -0
- package/dist/controllers/aspect_ratio_controller.js.map +1 -0
- package/dist/controllers/auto_submit_controller.d.ts +74 -0
- package/dist/controllers/auto_submit_controller.js +147 -0
- package/dist/controllers/auto_submit_controller.js.map +1 -0
- package/dist/controllers/avatar_controller.d.ts +50 -0
- package/dist/controllers/avatar_controller.js +66 -0
- package/dist/controllers/avatar_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +60 -0
- package/dist/controllers/breadcrumb_controller.js +123 -0
- package/dist/controllers/breadcrumb_controller.js.map +1 -0
- package/dist/controllers/bulk_select_controller.d.ts +67 -0
- package/dist/controllers/bulk_select_controller.js +104 -0
- package/dist/controllers/bulk_select_controller.js.map +1 -0
- package/dist/controllers/calendar_controller.d.ts +100 -0
- package/dist/controllers/calendar_controller.js +394 -0
- package/dist/controllers/calendar_controller.js.map +1 -0
- package/dist/controllers/character_counter_controller.d.ts +63 -0
- package/dist/controllers/character_counter_controller.js +179 -0
- package/dist/controllers/character_counter_controller.js.map +1 -0
- package/dist/controllers/checkbox_controller.d.ts +52 -0
- package/dist/controllers/checkbox_controller.js +73 -0
- package/dist/controllers/checkbox_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +76 -0
- package/dist/controllers/combobox_controller.js +186 -0
- package/dist/controllers/combobox_controller.js.map +1 -0
- package/dist/controllers/command_palette_controller.d.ts +103 -0
- package/dist/controllers/command_palette_controller.js +381 -0
- package/dist/controllers/command_palette_controller.js.map +1 -0
- package/dist/controllers/conditional_fields_controller.d.ts +56 -0
- package/dist/controllers/conditional_fields_controller.js +112 -0
- package/dist/controllers/conditional_fields_controller.js.map +1 -0
- package/dist/controllers/confirm_controller.d.ts +83 -0
- package/dist/controllers/confirm_controller.js +276 -0
- package/dist/controllers/confirm_controller.js.map +1 -0
- package/dist/controllers/context_menu_controller.d.ts +81 -0
- package/dist/controllers/context_menu_controller.js +112 -0
- package/dist/controllers/context_menu_controller.js.map +1 -0
- package/dist/controllers/countdown_controller.d.ts +93 -0
- package/dist/controllers/countdown_controller.js +202 -0
- package/dist/controllers/countdown_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.d.ts +57 -0
- package/dist/controllers/dialog_controller.js +207 -0
- package/dist/controllers/dialog_controller.js.map +1 -0
- package/dist/controllers/direct_upload_controller.d.ts +77 -0
- package/dist/controllers/direct_upload_controller.js +212 -0
- package/dist/controllers/direct_upload_controller.js.map +1 -0
- package/dist/controllers/dirty_form_controller.d.ts +52 -0
- package/dist/controllers/dirty_form_controller.js +128 -0
- package/dist/controllers/dirty_form_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +49 -0
- package/dist/controllers/dropdown_controller.js +66 -0
- package/dist/controllers/dropdown_controller.js.map +1 -0
- package/dist/controllers/empty_state_controller.d.ts +60 -0
- package/dist/controllers/empty_state_controller.js +67 -0
- package/dist/controllers/empty_state_controller.js.map +1 -0
- package/dist/controllers/flash_controller.d.ts +63 -0
- package/dist/controllers/flash_controller.js +221 -0
- package/dist/controllers/flash_controller.js.map +1 -0
- package/dist/controllers/focus_controller.d.ts +69 -0
- package/dist/controllers/focus_controller.js +216 -0
- package/dist/controllers/focus_controller.js.map +1 -0
- package/dist/controllers/form_field_controller.d.ts +79 -0
- package/dist/controllers/form_field_controller.js +154 -0
- package/dist/controllers/form_field_controller.js.map +1 -0
- package/dist/controllers/form_validation_controller.d.ts +114 -0
- package/dist/controllers/form_validation_controller.js +202 -0
- package/dist/controllers/form_validation_controller.js.map +1 -0
- package/dist/controllers/frame_loading_controller.d.ts +58 -0
- package/dist/controllers/frame_loading_controller.js +177 -0
- package/dist/controllers/frame_loading_controller.js.map +1 -0
- package/dist/controllers/highlight_controller.d.ts +50 -0
- package/dist/controllers/highlight_controller.js +107 -0
- package/dist/controllers/highlight_controller.js.map +1 -0
- package/dist/controllers/hover_card_controller.d.ts +79 -0
- package/dist/controllers/hover_card_controller.js +165 -0
- package/dist/controllers/hover_card_controller.js.map +1 -0
- package/dist/controllers/idle_controller.d.ts +51 -0
- package/dist/controllers/idle_controller.js +141 -0
- package/dist/controllers/idle_controller.js.map +1 -0
- package/dist/controllers/input_mask_controller.d.ts +74 -0
- package/dist/controllers/input_mask_controller.js +166 -0
- package/dist/controllers/input_mask_controller.js.map +1 -0
- package/dist/controllers/lazy_frame_controller.d.ts +53 -0
- package/dist/controllers/lazy_frame_controller.js +68 -0
- package/dist/controllers/lazy_frame_controller.js.map +1 -0
- package/dist/controllers/listbox_controller.d.ts +88 -0
- package/dist/controllers/listbox_controller.js +256 -0
- package/dist/controllers/listbox_controller.js.map +1 -0
- package/dist/controllers/local_time_controller.d.ts +60 -0
- package/dist/controllers/local_time_controller.js +81 -0
- package/dist/controllers/local_time_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +81 -0
- package/dist/controllers/menu_controller.js +134 -0
- package/dist/controllers/menu_controller.js.map +1 -0
- package/dist/controllers/meter_controller.d.ts +93 -0
- package/dist/controllers/meter_controller.js +96 -0
- package/dist/controllers/meter_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +85 -0
- package/dist/controllers/nested_form_controller.js +131 -0
- package/dist/controllers/nested_form_controller.js.map +1 -0
- package/dist/controllers/network_status_controller.d.ts +49 -0
- package/dist/controllers/network_status_controller.js +126 -0
- package/dist/controllers/network_status_controller.js.map +1 -0
- package/dist/controllers/number_input_controller.d.ts +93 -0
- package/dist/controllers/number_input_controller.js +306 -0
- package/dist/controllers/number_input_controller.js.map +1 -0
- package/dist/controllers/otp_controller.d.ts +68 -0
- package/dist/controllers/otp_controller.js +201 -0
- package/dist/controllers/otp_controller.js.map +1 -0
- package/dist/controllers/overflow_indicator_controller.d.ts +62 -0
- package/dist/controllers/overflow_indicator_controller.js +169 -0
- package/dist/controllers/overflow_indicator_controller.js.map +1 -0
- package/dist/controllers/overflow_menu_controller.d.ts +64 -0
- package/dist/controllers/overflow_menu_controller.js +274 -0
- package/dist/controllers/overflow_menu_controller.js.map +1 -0
- package/dist/controllers/pagination_controller.d.ts +69 -0
- package/dist/controllers/pagination_controller.js +89 -0
- package/dist/controllers/pagination_controller.js.map +1 -0
- package/dist/controllers/password_strength_controller.d.ts +67 -0
- package/dist/controllers/password_strength_controller.js +175 -0
- package/dist/controllers/password_strength_controller.js.map +1 -0
- package/dist/controllers/persist_controller.d.ts +63 -0
- package/dist/controllers/persist_controller.js +259 -0
- package/dist/controllers/persist_controller.js.map +1 -0
- package/dist/controllers/popover_controller.d.ts +63 -0
- package/dist/controllers/popover_controller.js +94 -0
- package/dist/controllers/popover_controller.js.map +1 -0
- package/dist/controllers/portal_controller.d.ts +58 -0
- package/dist/controllers/portal_controller.js +63 -0
- package/dist/controllers/portal_controller.js.map +1 -0
- package/dist/controllers/preview_guard_controller.d.ts +47 -0
- package/dist/controllers/preview_guard_controller.js +69 -0
- package/dist/controllers/preview_guard_controller.js.map +1 -0
- package/dist/controllers/progress_controller.d.ts +85 -0
- package/dist/controllers/progress_controller.js +93 -0
- package/dist/controllers/progress_controller.js.map +1 -0
- package/dist/controllers/radio_group_controller.d.ts +49 -0
- package/dist/controllers/radio_group_controller.js +128 -0
- package/dist/controllers/radio_group_controller.js.map +1 -0
- package/dist/controllers/rating_controller.d.ts +82 -0
- package/dist/controllers/rating_controller.js +179 -0
- package/dist/controllers/rating_controller.js.map +1 -0
- package/dist/controllers/relative_time_controller.d.ts +48 -0
- package/dist/controllers/relative_time_controller.js +129 -0
- package/dist/controllers/relative_time_controller.js.map +1 -0
- package/dist/controllers/reset_before_cache_controller.d.ts +54 -0
- package/dist/controllers/reset_before_cache_controller.js +62 -0
- package/dist/controllers/reset_before_cache_controller.js.map +1 -0
- package/dist/controllers/resizable_controller.d.ts +81 -0
- package/dist/controllers/resizable_controller.js +163 -0
- package/dist/controllers/resizable_controller.js.map +1 -0
- package/dist/controllers/roving_controller.d.ts +63 -0
- package/dist/controllers/roving_controller.js +116 -0
- package/dist/controllers/roving_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.d.ts +45 -0
- package/dist/controllers/scroll_area_controller.js +183 -0
- package/dist/controllers/scroll_area_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +72 -0
- package/dist/controllers/scroll_visibility_controller.js +103 -0
- package/dist/controllers/scroll_visibility_controller.js.map +1 -0
- package/dist/controllers/scrollspy_controller.d.ts +65 -0
- package/dist/controllers/scrollspy_controller.js +171 -0
- package/dist/controllers/scrollspy_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.d.ts +50 -0
- package/dist/controllers/skeleton_controller.js +125 -0
- package/dist/controllers/skeleton_controller.js.map +1 -0
- package/dist/controllers/slider_controller.d.ts +76 -0
- package/dist/controllers/slider_controller.js +109 -0
- package/dist/controllers/slider_controller.js.map +1 -0
- package/dist/controllers/spinner_controller.d.ts +62 -0
- package/dist/controllers/spinner_controller.js +164 -0
- package/dist/controllers/spinner_controller.js.map +1 -0
- package/dist/controllers/step_indicator_controller.d.ts +55 -0
- package/dist/controllers/step_indicator_controller.js +55 -0
- package/dist/controllers/step_indicator_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +66 -0
- package/dist/controllers/stepper_controller.js +78 -0
- package/dist/controllers/stepper_controller.js.map +1 -0
- package/dist/controllers/stick_to_bottom_controller.d.ts +61 -0
- package/dist/controllers/stick_to_bottom_controller.js +100 -0
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -0
- package/dist/controllers/sticky_observer_controller.d.ts +54 -0
- package/dist/controllers/sticky_observer_controller.js +53 -0
- package/dist/controllers/sticky_observer_controller.js.map +1 -0
- package/dist/controllers/submit_once_controller.d.ts +66 -0
- package/dist/controllers/submit_once_controller.js +206 -0
- package/dist/controllers/submit_once_controller.js.map +1 -0
- package/dist/controllers/switch_controller.d.ts +43 -0
- package/dist/controllers/switch_controller.js +50 -0
- package/dist/controllers/switch_controller.js.map +1 -0
- package/dist/controllers/tabs_controller.d.ts +48 -0
- package/dist/controllers/tabs_controller.js +63 -0
- package/dist/controllers/tabs_controller.js.map +1 -0
- package/dist/controllers/textarea_autosize_controller.d.ts +47 -0
- package/dist/controllers/textarea_autosize_controller.js +72 -0
- package/dist/controllers/textarea_autosize_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +70 -0
- package/dist/controllers/theme_controller.js +154 -0
- package/dist/controllers/theme_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +123 -0
- package/dist/controllers/toast_controller.js +310 -0
- package/dist/controllers/toast_controller.js.map +1 -0
- package/dist/controllers/toggle_group_controller.d.ts +56 -0
- package/dist/controllers/toggle_group_controller.js +130 -0
- package/dist/controllers/toggle_group_controller.js.map +1 -0
- package/dist/controllers/toolbar_controller.d.ts +59 -0
- package/dist/controllers/toolbar_controller.js +113 -0
- package/dist/controllers/toolbar_controller.js.map +1 -0
- package/dist/controllers/tooltip_controller.d.ts +74 -0
- package/dist/controllers/tooltip_controller.js +165 -0
- package/dist/controllers/tooltip_controller.js.map +1 -0
- package/dist/controllers/transition_controller.d.ts +86 -0
- package/dist/controllers/transition_controller.js +203 -0
- package/dist/controllers/transition_controller.js.map +1 -0
- package/dist/index.d.ts +1931 -0
- package/dist/index.js +12241 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector/cli.d.ts +149 -0
- package/dist/inspector/cli.js +663 -0
- package/dist/inspector/cli.js.map +1 -0
- package/dist/inspector/cli_bin.d.ts +2 -0
- package/dist/inspector/cli_bin.js +665 -0
- package/dist/inspector/cli_bin.js.map +1 -0
- package/dist/inspector/manifest.json +2235 -0
- package/dist/positioning/index.d.ts +226 -0
- package/dist/positioning/index.js +145 -0
- package/dist/positioning/index.js.map +1 -0
- package/package.json +93 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/command_palette_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/focus_trap.ts
|
|
6
|
+
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
|
+
var FocusTrap = class {
|
|
8
|
+
/** The element focused before activation, restored on deactivation. */
|
|
9
|
+
#previouslyFocused = null;
|
|
10
|
+
/** The body's inline `overflow` before locking, restored on deactivation. */
|
|
11
|
+
#previousBodyOverflow = "";
|
|
12
|
+
/** Whether scroll was locked this activation (so it is only restored if applied). */
|
|
13
|
+
#scrollLocked = false;
|
|
14
|
+
/** Background siblings made `inert` while active, restored on deactivation. */
|
|
15
|
+
#inertedSiblings = [];
|
|
16
|
+
/** Whether the modal side effects are currently applied. */
|
|
17
|
+
#activeState = false;
|
|
18
|
+
/** Returns the trapped element; called on every operation for the live target. */
|
|
19
|
+
#getContainer;
|
|
20
|
+
/** Closing/focus hooks; see {@link FocusTrapOptions}. */
|
|
21
|
+
#options;
|
|
22
|
+
/**
|
|
23
|
+
* @param getContainer - Returns the trapped element. Called on every operation
|
|
24
|
+
* so the live target is always used.
|
|
25
|
+
* @param options - Closing/focus hooks; see {@link FocusTrapOptions}.
|
|
26
|
+
*/
|
|
27
|
+
constructor(getContainer, options = {}) {
|
|
28
|
+
this.#getContainer = getContainer;
|
|
29
|
+
this.#options = options;
|
|
30
|
+
}
|
|
31
|
+
/** Whether the trap is currently active. */
|
|
32
|
+
get active() {
|
|
33
|
+
return this.#activeState;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Applies the trap: records the current focus, optionally locks background scroll
|
|
37
|
+
* and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless
|
|
38
|
+
* `autoFocus` is off) moves focus inside. No-ops if already active.
|
|
39
|
+
*/
|
|
40
|
+
activate() {
|
|
41
|
+
if (this.#activeState) return;
|
|
42
|
+
this.#activeState = true;
|
|
43
|
+
const active = document.activeElement;
|
|
44
|
+
this.#previouslyFocused = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
45
|
+
if (this.#flag(this.#options.lockScroll, true)) {
|
|
46
|
+
this.#previousBodyOverflow = document.body.style.overflow;
|
|
47
|
+
document.body.style.overflow = "hidden";
|
|
48
|
+
this.#scrollLocked = true;
|
|
49
|
+
}
|
|
50
|
+
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
51
|
+
document.addEventListener("keydown", this.#onKeydown);
|
|
52
|
+
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reverts every side effect applied by {@link activate}. No-ops if inactive, so
|
|
56
|
+
* a controller can call it defensively from both `close()` and `disconnect()`.
|
|
57
|
+
*
|
|
58
|
+
* @param restoreFocus - Move focus back to the opener (default `true`). Pass
|
|
59
|
+
* `false` on teardown (`disconnect`), where yanking focus is undesirable.
|
|
60
|
+
*/
|
|
61
|
+
deactivate({ restoreFocus = true } = {}) {
|
|
62
|
+
if (!this.#activeState) return;
|
|
63
|
+
this.#activeState = false;
|
|
64
|
+
document.removeEventListener("keydown", this.#onKeydown);
|
|
65
|
+
if (this.#scrollLocked) {
|
|
66
|
+
document.body.style.overflow = this.#previousBodyOverflow;
|
|
67
|
+
this.#scrollLocked = false;
|
|
68
|
+
}
|
|
69
|
+
this.#releaseBackground();
|
|
70
|
+
if (restoreFocus) {
|
|
71
|
+
const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;
|
|
72
|
+
target?.focus();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Resolves a boolean-or-getter option, defaulting when it was not provided. */
|
|
76
|
+
#flag(option, fallback) {
|
|
77
|
+
if (option === void 0) return fallback;
|
|
78
|
+
return typeof option === "function" ? option() : option;
|
|
79
|
+
}
|
|
80
|
+
/** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
|
|
81
|
+
#onKeydown = (event) => {
|
|
82
|
+
if (event.key === "Escape") {
|
|
83
|
+
if (this.#options.onEscape) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
this.#options.onEscape();
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (event.key === "Tab") this.#trapTab(event);
|
|
90
|
+
};
|
|
91
|
+
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
92
|
+
#trapTab(event) {
|
|
93
|
+
const focusable = this.#focusableElements();
|
|
94
|
+
if (focusable.length === 0) {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const first = focusable[0];
|
|
99
|
+
const last = focusable[focusable.length - 1];
|
|
100
|
+
const active = document.activeElement;
|
|
101
|
+
if (!(active instanceof Node) || !this.#getContainer().contains(active)) {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
first?.focus();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (event.shiftKey && active === first) {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
last?.focus();
|
|
109
|
+
} else if (!event.shiftKey && active === last) {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
first?.focus();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Marks every element outside the container's subtree as `inert` so background
|
|
116
|
+
* content cannot be focused or reached by assistive technology, honoring the
|
|
117
|
+
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
118
|
+
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
119
|
+
*/
|
|
120
|
+
#isolateBackground() {
|
|
121
|
+
const container = this.#getContainer();
|
|
122
|
+
this.#inertedSiblings = [];
|
|
123
|
+
for (const sibling of Array.from(document.body.children)) {
|
|
124
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
125
|
+
if (sibling.contains(container) || sibling.inert) continue;
|
|
126
|
+
sibling.inert = true;
|
|
127
|
+
this.#inertedSiblings.push(sibling);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
131
|
+
#releaseBackground() {
|
|
132
|
+
for (const sibling of this.#inertedSiblings) {
|
|
133
|
+
sibling.inert = false;
|
|
134
|
+
}
|
|
135
|
+
this.#inertedSiblings = [];
|
|
136
|
+
}
|
|
137
|
+
/** Moves focus to the initial target, the first focusable, or the container. */
|
|
138
|
+
#focusInitial() {
|
|
139
|
+
const preferred = this.#options.initialFocus?.();
|
|
140
|
+
if (preferred) {
|
|
141
|
+
preferred.focus();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const focusable = this.#focusableElements();
|
|
145
|
+
if (focusable[0]) {
|
|
146
|
+
focusable[0].focus();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const container = this.#getContainer();
|
|
150
|
+
container.tabIndex = -1;
|
|
151
|
+
container.focus();
|
|
152
|
+
}
|
|
153
|
+
/** Collects the container's currently focusable descendants in DOM order. */
|
|
154
|
+
#focusableElements() {
|
|
155
|
+
return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
|
|
156
|
+
(el) => !el.hidden
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// src/controllers/command_palette_controller.ts
|
|
162
|
+
var CommandPaletteController = class extends Controller {
|
|
163
|
+
static targets = ["dialog", "input", "list", "option", "empty"];
|
|
164
|
+
static values = {
|
|
165
|
+
hotkey: { type: String, default: "mod+k" },
|
|
166
|
+
open: { type: Boolean, default: false }
|
|
167
|
+
};
|
|
168
|
+
static actions = [
|
|
169
|
+
"close",
|
|
170
|
+
"closeOnBackdrop",
|
|
171
|
+
"filter",
|
|
172
|
+
"onKeydown",
|
|
173
|
+
"open",
|
|
174
|
+
"selectByClick",
|
|
175
|
+
"toggle"
|
|
176
|
+
];
|
|
177
|
+
static events = ["select"];
|
|
178
|
+
/** The index of the currently active option within the visible subset. */
|
|
179
|
+
#activeIndex = -1;
|
|
180
|
+
/**
|
|
181
|
+
* Owns the modal side effects (focus trap, scroll lock, background `inert`, focus
|
|
182
|
+
* restore). Escape closes; focus on open goes to the input, and is restored to
|
|
183
|
+
* whatever opened the palette on close.
|
|
184
|
+
*/
|
|
185
|
+
#trap = new FocusTrap(() => this.dialogTarget, {
|
|
186
|
+
onEscape: () => this.close(),
|
|
187
|
+
initialFocus: () => this.hasInputTarget ? this.inputTarget : null
|
|
188
|
+
});
|
|
189
|
+
/**
|
|
190
|
+
* Initializes the global hotkey handler and establishes the initial open state.
|
|
191
|
+
*
|
|
192
|
+
* The DOM is the source of truth on reconnect (Turbo cache restore / morph): if
|
|
193
|
+
* the restored snapshot already shows the dialog open, honor that rather than
|
|
194
|
+
* re-deriving from the declarative `open` Value (which would slam a user-opened
|
|
195
|
+
* palette shut). The `open` Value only seeds the initial state of a genuinely
|
|
196
|
+
* fresh render. We normalize to a clean closed baseline first so {@link open}
|
|
197
|
+
* runs its full setup — the {@link FocusTrap} is a fresh instance after a
|
|
198
|
+
* reconnect and must be re-activated.
|
|
199
|
+
*/
|
|
200
|
+
connect() {
|
|
201
|
+
document.addEventListener("keydown", this.#onGlobalKeydown);
|
|
202
|
+
const shouldOpen = this.#isOpen || this.openValue;
|
|
203
|
+
this.#resetToClosedState();
|
|
204
|
+
if (shouldOpen) this.open();
|
|
205
|
+
}
|
|
206
|
+
/** Tears down the global hotkey listener and reverts the modal side effects. */
|
|
207
|
+
disconnect() {
|
|
208
|
+
document.removeEventListener("keydown", this.#onGlobalKeydown);
|
|
209
|
+
this.#trap.deactivate({ restoreFocus: false });
|
|
210
|
+
this.#resetToClosedState();
|
|
211
|
+
}
|
|
212
|
+
/** Toggles the open state of the command palette. */
|
|
213
|
+
toggle() {
|
|
214
|
+
if (this.#isOpen) {
|
|
215
|
+
this.close();
|
|
216
|
+
} else {
|
|
217
|
+
this.open();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Opens the palette, traps focus, and shifts focus to the input. */
|
|
221
|
+
open() {
|
|
222
|
+
if (!this.hasDialogTarget || this.#isOpen) return;
|
|
223
|
+
this.dialogTarget.hidden = false;
|
|
224
|
+
this.openValue = true;
|
|
225
|
+
if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "true");
|
|
226
|
+
this.#resetFilter();
|
|
227
|
+
this.#trap.activate();
|
|
228
|
+
}
|
|
229
|
+
/** Closes the palette and restores focus back to the opener. */
|
|
230
|
+
close() {
|
|
231
|
+
if (!this.hasDialogTarget || !this.#isOpen) return;
|
|
232
|
+
this.#resetToClosedState();
|
|
233
|
+
this.#trap.deactivate();
|
|
234
|
+
}
|
|
235
|
+
/** Filters option elements in-memory matching the input value. Bound to input target. */
|
|
236
|
+
filter() {
|
|
237
|
+
if (!this.hasInputTarget) return;
|
|
238
|
+
const query = this.inputTarget.value.trim().toLowerCase();
|
|
239
|
+
let hasSelectableMatch = false;
|
|
240
|
+
for (const option of this.optionTargets) {
|
|
241
|
+
const searchText = (option.dataset.searchValue || option.textContent || "").trim().toLowerCase();
|
|
242
|
+
const matches = searchText.includes(query);
|
|
243
|
+
if (matches) {
|
|
244
|
+
option.removeAttribute("hidden");
|
|
245
|
+
if (!this.#isDisabled(option)) hasSelectableMatch = true;
|
|
246
|
+
} else {
|
|
247
|
+
option.setAttribute("hidden", "true");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (this.hasEmptyTarget) {
|
|
251
|
+
this.emptyTarget.hidden = hasSelectableMatch;
|
|
252
|
+
}
|
|
253
|
+
this.#setActiveIndex(hasSelectableMatch ? 0 : -1);
|
|
254
|
+
}
|
|
255
|
+
/** Selects the clicked option. Bound to option targets. */
|
|
256
|
+
selectByClick(event) {
|
|
257
|
+
const target = event.currentTarget;
|
|
258
|
+
if (!target) return;
|
|
259
|
+
const option = target.closest("[role='option']");
|
|
260
|
+
if (option && !this.#isDisabled(option)) {
|
|
261
|
+
this.#confirmSelection(option);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Closes when the backdrop (the dialog target itself) is clicked, ignoring inner clicks. */
|
|
265
|
+
closeOnBackdrop(event) {
|
|
266
|
+
if (event.target === this.dialogTarget) this.close();
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Combobox navigation keys (arrows / Home / End / Enter), bound to the input.
|
|
270
|
+
* `Tab` (focus trap) and `Escape` (close) are owned by the {@link FocusTrap} at
|
|
271
|
+
* the document level, so they work no matter which element inside the dialog has
|
|
272
|
+
* focus — not only the input.
|
|
273
|
+
*/
|
|
274
|
+
onKeydown(event) {
|
|
275
|
+
if (!this.#isOpen) return;
|
|
276
|
+
switch (event.key) {
|
|
277
|
+
case "ArrowDown":
|
|
278
|
+
event.preventDefault();
|
|
279
|
+
this.#navigate(1);
|
|
280
|
+
break;
|
|
281
|
+
case "ArrowUp":
|
|
282
|
+
event.preventDefault();
|
|
283
|
+
this.#navigate(-1);
|
|
284
|
+
break;
|
|
285
|
+
case "Home":
|
|
286
|
+
event.preventDefault();
|
|
287
|
+
this.#setActiveIndex(0);
|
|
288
|
+
break;
|
|
289
|
+
case "End":
|
|
290
|
+
event.preventDefault();
|
|
291
|
+
this.#setActiveIndex(this.#visibleOptions.length - 1);
|
|
292
|
+
break;
|
|
293
|
+
case "Enter": {
|
|
294
|
+
event.preventDefault();
|
|
295
|
+
const activeOption = this.#visibleOptions[this.#activeIndex];
|
|
296
|
+
if (activeOption) this.#confirmSelection(activeOption);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
#navigate(direction) {
|
|
302
|
+
const visible = this.#visibleOptions;
|
|
303
|
+
if (visible.length === 0) return;
|
|
304
|
+
let newIndex = this.#activeIndex + direction;
|
|
305
|
+
if (newIndex >= visible.length) newIndex = 0;
|
|
306
|
+
if (newIndex < 0) newIndex = visible.length - 1;
|
|
307
|
+
this.#setActiveIndex(newIndex);
|
|
308
|
+
}
|
|
309
|
+
#setActiveIndex(index) {
|
|
310
|
+
const visible = this.#visibleOptions;
|
|
311
|
+
this.#activeIndex = index;
|
|
312
|
+
visible.forEach((option, i) => {
|
|
313
|
+
if (i === index) {
|
|
314
|
+
option.setAttribute("aria-selected", "true");
|
|
315
|
+
option.setAttribute("data-active", "true");
|
|
316
|
+
if (this.hasInputTarget) {
|
|
317
|
+
this.inputTarget.setAttribute("aria-activedescendant", option.id || "");
|
|
318
|
+
}
|
|
319
|
+
option.scrollIntoView({ block: "nearest" });
|
|
320
|
+
} else {
|
|
321
|
+
option.setAttribute("aria-selected", "false");
|
|
322
|
+
option.removeAttribute("data-active");
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
if (index === -1 && this.hasInputTarget) {
|
|
326
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
#confirmSelection(option) {
|
|
330
|
+
const value = option.dataset.value || option.textContent || "";
|
|
331
|
+
this.dispatch("select", { detail: { value, option } });
|
|
332
|
+
this.close();
|
|
333
|
+
}
|
|
334
|
+
#resetFilter() {
|
|
335
|
+
if (this.hasInputTarget) this.inputTarget.value = "";
|
|
336
|
+
for (const option of this.optionTargets) {
|
|
337
|
+
option.removeAttribute("hidden");
|
|
338
|
+
}
|
|
339
|
+
if (this.hasEmptyTarget) this.emptyTarget.hidden = true;
|
|
340
|
+
this.#setActiveIndex(0);
|
|
341
|
+
}
|
|
342
|
+
get #visibleOptions() {
|
|
343
|
+
return this.optionTargets.filter(
|
|
344
|
+
(option) => !option.hasAttribute("hidden") && !this.#isDisabled(option)
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
#isDisabled(option) {
|
|
348
|
+
return option.dataset.disabled === "true";
|
|
349
|
+
}
|
|
350
|
+
get #isOpen() {
|
|
351
|
+
return this.hasDialogTarget && !this.dialogTarget.hidden;
|
|
352
|
+
}
|
|
353
|
+
#onGlobalKeydown = (event) => {
|
|
354
|
+
const hotkey = this.hotkeyValue.toLowerCase();
|
|
355
|
+
const isMod = hotkey.includes("mod+");
|
|
356
|
+
const key = hotkey.split("+").pop();
|
|
357
|
+
if (!key) return;
|
|
358
|
+
const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
|
|
359
|
+
const keyMatch = event.key.toLowerCase() === key;
|
|
360
|
+
if (modPressed && keyMatch) {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
this.toggle();
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
/** Resets transient open state so reconnect starts from a predictable closed snapshot. */
|
|
366
|
+
#resetToClosedState() {
|
|
367
|
+
this.#activeIndex = -1;
|
|
368
|
+
this.openValue = false;
|
|
369
|
+
if (this.hasDialogTarget) {
|
|
370
|
+
this.dialogTarget.hidden = true;
|
|
371
|
+
}
|
|
372
|
+
if (this.hasInputTarget) {
|
|
373
|
+
this.inputTarget.setAttribute("aria-expanded", "false");
|
|
374
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
export { CommandPaletteController };
|
|
380
|
+
//# sourceMappingURL=command_palette_controller.js.map
|
|
381
|
+
//# sourceMappingURL=command_palette_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/focus_trap.ts","../../src/controllers/command_palette_controller.ts"],"names":[],"mappings":"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","file":"command_palette_controller.js","sourcesContent":["/**\n * Modal focus-trap primitive shared by the modal-overlay controllers\n * (dialog / alert-dialog / drawer).\n *\n * The WAI-ARIA APG modal pattern is more than \"cycle Tab inside a box\": a modal\n * also locks background scroll, makes the rest of the page `inert` (so assistive\n * technology and pointer/Tab cannot reach it, honoring `aria-modal=\"true\"`),\n * sends focus inside on open, and restores it to the opener on close — and every\n * one of those side effects must be reverted if the element is torn down while\n * open (a Turbo navigation mid-dialog). {@link FocusTrap} owns that whole modal\n * lifecycle so each controller only decides *when* to open/close and *what*\n * \"close\" means.\n *\n * It is intentionally **policy-free about closing**. Escape semantics differ per\n * widget (a plain dialog just closes; an alert-dialog closes *as a cancel* with a\n * reason; a drawer runs an exit transition), so the trap merely forwards Escape\n * to an {@link FocusTrapOptions.onEscape | onEscape} callback and never decides on\n * its own what closing entails.\n *\n * @remarks\n * The container is read through a getter so a controller can hand over a Stimulus\n * target without worrying about when the trap instance is constructed relative to\n * `connect()`.\n */\n\n/**\n * Selector matching the elements considered focusable. Shared by the trap's Tab\n * cycling and by form-validation's invalid-focus delegation.\n */\nexport const FOCUSABLE =\n 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n/** Behavior hooks a controller supplies when constructing a {@link FocusTrap}. */\nexport interface FocusTrapOptions {\n /**\n * Called when `Escape` is pressed while the trap is active. When omitted,\n * `Escape` is left alone (the trap never closes itself). The trap calls\n * `preventDefault()` before invoking it.\n */\n onEscape?: () => void;\n /**\n * Returns the element to focus when the trap activates. When it returns `null`\n * (or is omitted), the first focusable descendant is used, falling back to the\n * container itself (made programmatically focusable with `tabindex=-1`).\n */\n initialFocus?: () => HTMLElement | null;\n /**\n * Returns the element to focus on deactivation when nothing was focused before\n * the trap opened (e.g. the trigger). The element focused *before* opening\n * always takes precedence.\n */\n fallbackFocus?: () => HTMLElement | null;\n /**\n * Lock background scroll (`body` overflow) while active. Defaults to `true` for\n * the modal overlays; a lighter focus scope passes `false`. Read on `activate`.\n */\n lockScroll?: boolean | (() => boolean);\n /**\n * Make background siblings `inert` while active (the `aria-modal` isolation).\n * Defaults to `true` for the modal overlays; a soft focus scope can opt out so\n * the background stays reachable while `Tab` still cycles inside. Read on `activate`.\n */\n isolate?: boolean | (() => boolean);\n /**\n * Move focus inside on `activate`. Defaults to `true`; a focus scope that only\n * wants the `Tab` boundary (no focus move) passes `false`. Read on `activate`.\n */\n autoFocus?: boolean | (() => boolean);\n}\n\n/**\n * Owns the modal side effects (scroll lock, background `inert`, focus trap, focus\n * restore) for a single container, applied on {@link activate} and reverted on\n * {@link deactivate}.\n */\nexport class FocusTrap {\n /** The element focused before activation, restored on deactivation. */\n #previouslyFocused: HTMLElement | null = null;\n /** The body's inline `overflow` before locking, restored on deactivation. */\n #previousBodyOverflow = \"\";\n /** Whether scroll was locked this activation (so it is only restored if applied). */\n #scrollLocked = false;\n /** Background siblings made `inert` while active, restored on deactivation. */\n #inertedSiblings: HTMLElement[] = [];\n /** Whether the modal side effects are currently applied. */\n #activeState = false;\n\n /** Returns the trapped element; called on every operation for the live target. */\n readonly #getContainer: () => HTMLElement;\n /** Closing/focus hooks; see {@link FocusTrapOptions}. */\n readonly #options: FocusTrapOptions;\n\n /**\n * @param getContainer - Returns the trapped element. Called on every operation\n * so the live target is always used.\n * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.\n */\n constructor(getContainer: () => HTMLElement, options: FocusTrapOptions = {}) {\n this.#getContainer = getContainer;\n this.#options = options;\n }\n\n /** Whether the trap is currently active. */\n get active(): boolean {\n return this.#activeState;\n }\n\n /**\n * Applies the trap: records the current focus, optionally locks background scroll\n * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless\n * `autoFocus` is off) moves focus inside. No-ops if already active.\n */\n activate(): void {\n if (this.#activeState) return;\n this.#activeState = true;\n // Record the opener so it can be refocused on close. `<body>` (the default\n // active element when nothing is focused) is treated as \"nothing\", so the\n // fallback target — typically the trigger — wins in that case.\n const active = document.activeElement;\n this.#previouslyFocused =\n active instanceof HTMLElement && active !== document.body ? active : null;\n if (this.#flag(this.#options.lockScroll, true)) {\n this.#previousBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n this.#scrollLocked = true;\n }\n if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();\n document.addEventListener(\"keydown\", this.#onKeydown);\n if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();\n }\n\n /**\n * Reverts every side effect applied by {@link activate}. No-ops if inactive, so\n * a controller can call it defensively from both `close()` and `disconnect()`.\n *\n * @param restoreFocus - Move focus back to the opener (default `true`). Pass\n * `false` on teardown (`disconnect`), where yanking focus is undesirable.\n */\n deactivate({ restoreFocus = true }: { restoreFocus?: boolean } = {}): void {\n if (!this.#activeState) return;\n this.#activeState = false;\n document.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.#scrollLocked) {\n document.body.style.overflow = this.#previousBodyOverflow;\n this.#scrollLocked = false;\n }\n this.#releaseBackground();\n if (restoreFocus) {\n const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;\n target?.focus();\n }\n }\n\n /** Resolves a boolean-or-getter option, defaulting when it was not provided. */\n #flag(option: boolean | (() => boolean) | undefined, fallback: boolean): boolean {\n if (option === undefined) return fallback;\n return typeof option === \"function\" ? option() : option;\n }\n\n /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") {\n if (this.#options.onEscape) {\n event.preventDefault();\n this.#options.onEscape();\n }\n return;\n }\n if (event.key === \"Tab\") this.#trapTab(event);\n };\n\n /** Keeps `Tab` focus cycling within the container's focusable elements. */\n #trapTab(event: KeyboardEvent): void {\n const focusable = this.#focusableElements();\n if (focusable.length === 0) {\n event.preventDefault();\n return;\n }\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n const active = document.activeElement;\n\n // If focus has somehow escaped the container, pull it back to the first item.\n if (!(active instanceof Node) || !this.#getContainer().contains(active)) {\n event.preventDefault();\n first?.focus();\n return;\n }\n\n if (event.shiftKey && active === first) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first?.focus();\n }\n }\n\n /**\n * Marks every element outside the container's subtree as `inert` so background\n * content cannot be focused or reached by assistive technology, honoring the\n * `aria-modal=\"true\"` contract. An element that was *already* `inert` is left\n * untracked so `#releaseBackground` does not wrongly clear it.\n */\n #isolateBackground(): void {\n const container = this.#getContainer();\n this.#inertedSiblings = [];\n for (const sibling of Array.from(document.body.children)) {\n if (!(sibling instanceof HTMLElement)) continue;\n if (sibling.contains(container) || sibling.inert) continue;\n sibling.inert = true;\n this.#inertedSiblings.push(sibling);\n }\n }\n\n /** Reverts the `inert` flags applied by `#isolateBackground`. */\n #releaseBackground(): void {\n for (const sibling of this.#inertedSiblings) {\n sibling.inert = false;\n }\n this.#inertedSiblings = [];\n }\n\n /** Moves focus to the initial target, the first focusable, or the container. */\n #focusInitial(): void {\n const preferred = this.#options.initialFocus?.();\n if (preferred) {\n preferred.focus();\n return;\n }\n const focusable = this.#focusableElements();\n if (focusable[0]) {\n focusable[0].focus();\n return;\n }\n const container = this.#getContainer();\n container.tabIndex = -1;\n container.focus();\n }\n\n /** Collects the container's currently focusable descendants in DOM order. */\n #focusableElements(): HTMLElement[] {\n return Array.from(this.#getContainer().querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n (el) => !el.hidden,\n );\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { FocusTrap } from \"../utils/focus_trap\";\n\n/**\n * Headless, highly accessible Command Palette behavior.\n *\n * Markup contract (identifier: `stimeo--command-palette`):\n * <div data-controller=\"stimeo--command-palette\"\n * data-stimeo--command-palette-hotkey-value=\"mod+k\">\n * <div data-stimeo--command-palette-target=\"dialog\" role=\"dialog\"\n * aria-modal=\"true\" aria-label=\"Command palette\"\n * data-action=\"click->stimeo--command-palette#closeOnBackdrop\" hidden>\n * <input data-stimeo--command-palette-target=\"input\" role=\"combobox\"\n * aria-expanded=\"false\" aria-controls=\"cmdk-list\"\n * aria-autocomplete=\"list\" aria-label=\"Search commands\"\n * data-action=\"input->stimeo--command-palette#filter\n * keydown->stimeo--command-palette#onKeydown\" />\n * <ul id=\"cmdk-list\" data-stimeo--command-palette-target=\"list\" role=\"listbox\">\n * <li id=\"cmd-new\" role=\"option\"\n * data-stimeo--command-palette-target=\"option\"\n * data-action=\"click->stimeo--command-palette#selectByClick\">New…</li>\n * <li role=\"option\" data-stimeo--command-palette-target=\"option\"\n * data-disabled=\"true\">Section heading (shown but not selectable)</li>\n * </ul>\n * <p data-stimeo--command-palette-target=\"empty\" hidden>No commands</p>\n * </div>\n * </div>\n *\n * Implements a composite **Dialog (Modal)** + **Combobox** (Listbox options) pattern.\n * A customizable global hotkey (Command/Ctrl + K) toggles it, focus is trapped\n * within the dialog, virtual focus roves via `aria-activedescendant`, and filtering\n * is performed in memory.\n *\n * @remarks\n * Behavior only. The combobox concerns (filtering, virtual focus, selection) live\n * here; the modal lifecycle — focus trap, scroll lock, background `inert`, focus\n * restore, and teardown reversal — is delegated to the shared {@link FocusTrap}\n * primitive (also used by dialog / alert-dialog / drawer). `Escape` closes and\n * `Tab`/`Shift+Tab` cycle focus regardless of which element inside the dialog holds\n * focus (input, close button, …), because the trap listens at the document level.\n * Styling, transitions, and the actual command handlers remain the consumer's.\n */\nexport class CommandPaletteController extends Controller<HTMLElement> {\n static override targets = [\"dialog\", \"input\", \"list\", \"option\", \"empty\"];\n static override values = {\n hotkey: { type: String, default: \"mod+k\" },\n open: { type: Boolean, default: false },\n };\n static actions = [\n \"close\",\n \"closeOnBackdrop\",\n \"filter\",\n \"onKeydown\",\n \"open\",\n \"selectByClick\",\n \"toggle\",\n ] as const;\n static events = [\"select\"] as const;\n\n declare readonly dialogTarget: HTMLElement;\n declare readonly inputTarget: HTMLInputElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly emptyTarget: HTMLElement;\n\n declare readonly hasDialogTarget: boolean;\n declare readonly hasInputTarget: boolean;\n declare readonly hasListTarget: boolean;\n declare readonly hasEmptyTarget: boolean;\n\n declare hotkeyValue: string;\n declare openValue: boolean;\n\n /** The index of the currently active option within the visible subset. */\n #activeIndex = -1;\n\n /**\n * Owns the modal side effects (focus trap, scroll lock, background `inert`, focus\n * restore). Escape closes; focus on open goes to the input, and is restored to\n * whatever opened the palette on close.\n */\n readonly #trap = new FocusTrap(() => this.dialogTarget, {\n onEscape: () => this.close(),\n initialFocus: () => (this.hasInputTarget ? this.inputTarget : null),\n });\n\n /**\n * Initializes the global hotkey handler and establishes the initial open state.\n *\n * The DOM is the source of truth on reconnect (Turbo cache restore / morph): if\n * the restored snapshot already shows the dialog open, honor that rather than\n * re-deriving from the declarative `open` Value (which would slam a user-opened\n * palette shut). The `open` Value only seeds the initial state of a genuinely\n * fresh render. We normalize to a clean closed baseline first so {@link open}\n * runs its full setup — the {@link FocusTrap} is a fresh instance after a\n * reconnect and must be re-activated.\n */\n override connect(): void {\n document.addEventListener(\"keydown\", this.#onGlobalKeydown);\n const shouldOpen = this.#isOpen || this.openValue;\n this.#resetToClosedState();\n if (shouldOpen) this.open();\n }\n\n /** Tears down the global hotkey listener and reverts the modal side effects. */\n override disconnect(): void {\n document.removeEventListener(\"keydown\", this.#onGlobalKeydown);\n this.#trap.deactivate({ restoreFocus: false });\n this.#resetToClosedState();\n }\n\n /** Toggles the open state of the command palette. */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n }\n }\n\n /** Opens the palette, traps focus, and shifts focus to the input. */\n open(): void {\n if (!this.hasDialogTarget || this.#isOpen) return;\n this.dialogTarget.hidden = false;\n this.openValue = true;\n if (this.hasInputTarget) this.inputTarget.setAttribute(\"aria-expanded\", \"true\");\n this.#resetFilter();\n this.#trap.activate();\n }\n\n /** Closes the palette and restores focus back to the opener. */\n close(): void {\n if (!this.hasDialogTarget || !this.#isOpen) return;\n this.#resetToClosedState();\n this.#trap.deactivate();\n }\n\n /** Filters option elements in-memory matching the input value. Bound to input target. */\n filter(): void {\n if (!this.hasInputTarget) return;\n const query = this.inputTarget.value.trim().toLowerCase();\n // Disabled options (e.g. group headings) may still be shown, but they do not\n // count toward the empty state and are never navigable/selectable.\n let hasSelectableMatch = false;\n\n for (const option of this.optionTargets) {\n const searchText = (option.dataset.searchValue || option.textContent || \"\")\n .trim()\n .toLowerCase();\n const matches = searchText.includes(query);\n\n if (matches) {\n option.removeAttribute(\"hidden\");\n if (!this.#isDisabled(option)) hasSelectableMatch = true;\n } else {\n option.setAttribute(\"hidden\", \"true\");\n }\n }\n\n if (this.hasEmptyTarget) {\n this.emptyTarget.hidden = hasSelectableMatch;\n }\n\n this.#setActiveIndex(hasSelectableMatch ? 0 : -1);\n }\n\n /** Selects the clicked option. Bound to option targets. */\n selectByClick(event: MouseEvent): void {\n const target = event.currentTarget as HTMLElement | null;\n if (!target) return;\n const option = target.closest(\"[role='option']\") as HTMLElement | null;\n // Ignore clicks on disabled options (e.g. group headings) so they never fire select.\n if (option && !this.#isDisabled(option)) {\n this.#confirmSelection(option);\n }\n }\n\n /** Closes when the backdrop (the dialog target itself) is clicked, ignoring inner clicks. */\n closeOnBackdrop(event: MouseEvent): void {\n if (event.target === this.dialogTarget) this.close();\n }\n\n /**\n * Combobox navigation keys (arrows / Home / End / Enter), bound to the input.\n * `Tab` (focus trap) and `Escape` (close) are owned by the {@link FocusTrap} at\n * the document level, so they work no matter which element inside the dialog has\n * focus — not only the input.\n */\n onKeydown(event: KeyboardEvent): void {\n if (!this.#isOpen) return;\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#navigate(1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#navigate(-1);\n break;\n case \"Home\":\n event.preventDefault();\n this.#setActiveIndex(0);\n break;\n case \"End\":\n event.preventDefault();\n this.#setActiveIndex(this.#visibleOptions.length - 1);\n break;\n case \"Enter\": {\n event.preventDefault();\n const activeOption = this.#visibleOptions[this.#activeIndex];\n if (activeOption) this.#confirmSelection(activeOption);\n break;\n }\n }\n }\n\n #navigate(direction: number): void {\n const visible = this.#visibleOptions;\n if (visible.length === 0) return;\n\n let newIndex = this.#activeIndex + direction;\n if (newIndex >= visible.length) newIndex = 0;\n if (newIndex < 0) newIndex = visible.length - 1;\n\n this.#setActiveIndex(newIndex);\n }\n\n #setActiveIndex(index: number): void {\n const visible = this.#visibleOptions;\n this.#activeIndex = index;\n\n visible.forEach((option, i) => {\n if (i === index) {\n option.setAttribute(\"aria-selected\", \"true\");\n option.setAttribute(\"data-active\", \"true\");\n if (this.hasInputTarget) {\n this.inputTarget.setAttribute(\"aria-activedescendant\", option.id || \"\");\n }\n option.scrollIntoView({ block: \"nearest\" });\n } else {\n option.setAttribute(\"aria-selected\", \"false\");\n option.removeAttribute(\"data-active\");\n }\n });\n\n if (index === -1 && this.hasInputTarget) {\n this.inputTarget.removeAttribute(\"aria-activedescendant\");\n }\n }\n\n #confirmSelection(option: HTMLElement): void {\n const value = option.dataset.value || option.textContent || \"\";\n this.dispatch(\"select\", { detail: { value, option } });\n this.close();\n }\n\n #resetFilter(): void {\n if (this.hasInputTarget) this.inputTarget.value = \"\";\n for (const option of this.optionTargets) {\n option.removeAttribute(\"hidden\");\n }\n if (this.hasEmptyTarget) this.emptyTarget.hidden = true;\n this.#setActiveIndex(0);\n }\n\n get #visibleOptions(): HTMLElement[] {\n // Only options that are both shown and not disabled are navigable/selectable.\n return this.optionTargets.filter(\n (option) => !option.hasAttribute(\"hidden\") && !this.#isDisabled(option),\n );\n }\n\n #isDisabled(option: HTMLElement): boolean {\n return option.dataset.disabled === \"true\";\n }\n\n get #isOpen(): boolean {\n return this.hasDialogTarget && !this.dialogTarget.hidden;\n }\n\n readonly #onGlobalKeydown = (event: KeyboardEvent): void => {\n const hotkey = this.hotkeyValue.toLowerCase();\n const isMod = hotkey.includes(\"mod+\");\n const key = hotkey.split(\"+\").pop();\n\n if (!key) return;\n\n // \"mod\" accepts *either* Cmd or Ctrl so the documented \"Cmd+K / Ctrl+K\" works\n // on every platform — including Ctrl+K on macOS, not just Cmd+K. Without \"mod\",\n // the hotkey is a bare key that must be pressed with no Cmd/Ctrl held.\n const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;\n const keyMatch = event.key.toLowerCase() === key;\n\n if (modPressed && keyMatch) {\n event.preventDefault();\n this.toggle();\n }\n };\n\n /** Resets transient open state so reconnect starts from a predictable closed snapshot. */\n #resetToClosedState(): void {\n this.#activeIndex = -1;\n this.openValue = false;\n\n if (this.hasDialogTarget) {\n this.dialogTarget.hidden = true;\n }\n\n if (this.hasInputTarget) {\n this.inputTarget.setAttribute(\"aria-expanded\", \"false\");\n this.inputTarget.removeAttribute(\"aria-activedescendant\");\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/** Trigger controls whose state drives region visibility. */
|
|
4
|
+
type Trigger = HTMLInputElement | HTMLSelectElement;
|
|
5
|
+
/**
|
|
6
|
+
* Headless dependent-fields behavior: shows/hides (and enables/disables) regions
|
|
7
|
+
* based on trigger control values, keeping `hidden` / `aria-hidden` / `disabled` in
|
|
8
|
+
* sync (no dedicated APG pattern).
|
|
9
|
+
*
|
|
10
|
+
* Markup contract (identifier: `stimeo--conditional-fields`):
|
|
11
|
+
* <form data-controller="stimeo--conditional-fields">
|
|
12
|
+
* <input type="checkbox" data-stimeo--conditional-fields-target="trigger">
|
|
13
|
+
* <fieldset data-stimeo--conditional-fields-target="region" data-when-checked>
|
|
14
|
+
* …address…
|
|
15
|
+
* </fieldset>
|
|
16
|
+
* </form>
|
|
17
|
+
*
|
|
18
|
+
* Each region declares its condition with a data attribute: `data-when-checked`,
|
|
19
|
+
* `data-when-unchecked`, or `data-when-value="x"`. When the condition (combined
|
|
20
|
+
* across triggers per `match`) holds the region is shown and its inputs enabled;
|
|
21
|
+
* otherwise it is hidden, `aria-hidden`, and — with `disableHidden` — its inputs are
|
|
22
|
+
* disabled so they drop out of the submission. Hiding a region that holds focus
|
|
23
|
+
* first retreats focus to a trigger.
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* Behavior only. Visibility/enabled state lives entirely on the elements' `hidden` /
|
|
27
|
+
* `disabled` / `aria-hidden`, re-derived from triggers on `connect()` (Morph-safe).
|
|
28
|
+
* The change/input listener is removed on `disconnect()` (Turbo navigation included).
|
|
29
|
+
*/
|
|
30
|
+
declare class ConditionalFieldsController extends Controller<HTMLElement> {
|
|
31
|
+
#private;
|
|
32
|
+
static targets: string[];
|
|
33
|
+
static values: {
|
|
34
|
+
disableHidden: {
|
|
35
|
+
type: BooleanConstructor;
|
|
36
|
+
default: boolean;
|
|
37
|
+
};
|
|
38
|
+
match: {
|
|
39
|
+
type: StringConstructor;
|
|
40
|
+
default: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
static actions: readonly ["evaluate"];
|
|
44
|
+
static events: readonly ["change"];
|
|
45
|
+
readonly triggerTargets: Trigger[];
|
|
46
|
+
readonly regionTargets: HTMLElement[];
|
|
47
|
+
readonly hasTriggerTarget: boolean;
|
|
48
|
+
disableHiddenValue: boolean;
|
|
49
|
+
matchValue: string;
|
|
50
|
+
connect(): void;
|
|
51
|
+
disconnect(): void;
|
|
52
|
+
/** Re-evaluates every region against the current trigger state. */
|
|
53
|
+
evaluate(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { ConditionalFieldsController };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/conditional_fields_controller.ts
|
|
4
|
+
var DISABLED_MARKER = "data-conditional-disabled";
|
|
5
|
+
var ConditionalFieldsController = class extends Controller {
|
|
6
|
+
static targets = ["trigger", "region"];
|
|
7
|
+
static values = {
|
|
8
|
+
disableHidden: { type: Boolean, default: true },
|
|
9
|
+
match: { type: String, default: "any" }
|
|
10
|
+
};
|
|
11
|
+
static actions = ["evaluate"];
|
|
12
|
+
static events = ["change"];
|
|
13
|
+
#lastVisible = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
#onChange = () => {
|
|
15
|
+
this.evaluate();
|
|
16
|
+
};
|
|
17
|
+
connect() {
|
|
18
|
+
this.evaluate();
|
|
19
|
+
this.element.addEventListener("change", this.#onChange);
|
|
20
|
+
this.element.addEventListener("input", this.#onChange);
|
|
21
|
+
}
|
|
22
|
+
disconnect() {
|
|
23
|
+
this.element.removeEventListener("change", this.#onChange);
|
|
24
|
+
this.element.removeEventListener("input", this.#onChange);
|
|
25
|
+
}
|
|
26
|
+
/** Re-evaluates every region against the current trigger state. */
|
|
27
|
+
evaluate() {
|
|
28
|
+
const triggers = this.triggerTargets;
|
|
29
|
+
for (const region of this.regionTargets) {
|
|
30
|
+
this.#applyRegion(region, this.#isVisible(region, triggers));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Applies a region's visibility, syncing hidden/aria/disabled and emitting change. */
|
|
34
|
+
#applyRegion(region, visible) {
|
|
35
|
+
const previous = this.#lastVisible.get(region) ?? !region.hidden;
|
|
36
|
+
if (visible === previous && this.#lastVisible.has(region)) return;
|
|
37
|
+
this.#lastVisible.set(region, visible);
|
|
38
|
+
if (visible) {
|
|
39
|
+
region.hidden = false;
|
|
40
|
+
region.removeAttribute("aria-hidden");
|
|
41
|
+
region.setAttribute("data-visible", "true");
|
|
42
|
+
this.#setRegionDisabled(region, false);
|
|
43
|
+
} else {
|
|
44
|
+
this.#retreatFocus(region);
|
|
45
|
+
region.hidden = true;
|
|
46
|
+
region.setAttribute("aria-hidden", "true");
|
|
47
|
+
region.removeAttribute("data-visible");
|
|
48
|
+
this.#setRegionDisabled(region, true);
|
|
49
|
+
}
|
|
50
|
+
if (visible !== previous) {
|
|
51
|
+
this.dispatch("change", { detail: { region, visible } });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Whether a region's declared condition holds across `triggers` per `match`. */
|
|
55
|
+
#isVisible(region, triggers) {
|
|
56
|
+
const predicate = this.#predicateFor(region);
|
|
57
|
+
if (predicate === null) return !region.hidden;
|
|
58
|
+
if (triggers.length === 0) return false;
|
|
59
|
+
return this.matchValue === "all" ? triggers.every(predicate) : triggers.some(predicate);
|
|
60
|
+
}
|
|
61
|
+
/** Builds the per-trigger predicate from the region's `data-when-*` attribute. */
|
|
62
|
+
#predicateFor(region) {
|
|
63
|
+
if (region.hasAttribute("data-when-checked")) {
|
|
64
|
+
return (trigger) => this.#isChecked(trigger);
|
|
65
|
+
}
|
|
66
|
+
if (region.hasAttribute("data-when-unchecked")) {
|
|
67
|
+
return (trigger) => !this.#isChecked(trigger);
|
|
68
|
+
}
|
|
69
|
+
const wanted = region.dataset.whenValue;
|
|
70
|
+
if (wanted !== void 0) {
|
|
71
|
+
return (trigger) => this.#matchesValue(trigger, wanted);
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
#isChecked(trigger) {
|
|
76
|
+
return trigger instanceof HTMLInputElement && trigger.checked;
|
|
77
|
+
}
|
|
78
|
+
/** A trigger "has" a value: selected radio/checkbox value, or the control value. */
|
|
79
|
+
#matchesValue(trigger, wanted) {
|
|
80
|
+
if (trigger instanceof HTMLInputElement && (trigger.type === "checkbox" || trigger.type === "radio")) {
|
|
81
|
+
return trigger.checked && trigger.value === wanted;
|
|
82
|
+
}
|
|
83
|
+
return trigger.value === wanted;
|
|
84
|
+
}
|
|
85
|
+
/** Enables/disables a region's inputs, tracking only the ones we disabled. */
|
|
86
|
+
#setRegionDisabled(region, disabled) {
|
|
87
|
+
if (!this.disableHiddenValue) return;
|
|
88
|
+
const controls = region.querySelectorAll("input, textarea, select, button");
|
|
89
|
+
for (const control of Array.from(controls)) {
|
|
90
|
+
if (disabled) {
|
|
91
|
+
if (!control.disabled) {
|
|
92
|
+
control.disabled = true;
|
|
93
|
+
control.setAttribute(DISABLED_MARKER, "true");
|
|
94
|
+
}
|
|
95
|
+
} else if (control.hasAttribute(DISABLED_MARKER)) {
|
|
96
|
+
control.disabled = false;
|
|
97
|
+
control.removeAttribute(DISABLED_MARKER);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Moves focus out of a region about to be hidden, to a trigger when possible. */
|
|
102
|
+
#retreatFocus(region) {
|
|
103
|
+
const active = document.activeElement;
|
|
104
|
+
if (!(active instanceof HTMLElement) || !region.contains(active)) return;
|
|
105
|
+
active.blur();
|
|
106
|
+
if (this.hasTriggerTarget) this.triggerTargets[0]?.focus();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export { ConditionalFieldsController };
|
|
111
|
+
//# sourceMappingURL=conditional_fields_controller.js.map
|
|
112
|
+
//# sourceMappingURL=conditional_fields_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/conditional_fields_controller.ts"],"names":[],"mappings":"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","file":"conditional_fields_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** Trigger controls whose state drives region visibility. */\ntype Trigger = HTMLInputElement | HTMLSelectElement;\n/** Form controls inside a region that get disabled while hidden. */\ntype Disableable = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement;\n\n/** Marker on inputs this controller disabled (so we never re-enable authored-disabled ones). */\nconst DISABLED_MARKER = \"data-conditional-disabled\";\n\n/**\n * Headless dependent-fields behavior: shows/hides (and enables/disables) regions\n * based on trigger control values, keeping `hidden` / `aria-hidden` / `disabled` in\n * sync (no dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--conditional-fields`):\n * <form data-controller=\"stimeo--conditional-fields\">\n * <input type=\"checkbox\" data-stimeo--conditional-fields-target=\"trigger\">\n * <fieldset data-stimeo--conditional-fields-target=\"region\" data-when-checked>\n * …address…\n * </fieldset>\n * </form>\n *\n * Each region declares its condition with a data attribute: `data-when-checked`,\n * `data-when-unchecked`, or `data-when-value=\"x\"`. When the condition (combined\n * across triggers per `match`) holds the region is shown and its inputs enabled;\n * otherwise it is hidden, `aria-hidden`, and — with `disableHidden` — its inputs are\n * disabled so they drop out of the submission. Hiding a region that holds focus\n * first retreats focus to a trigger.\n *\n * @remarks\n * Behavior only. Visibility/enabled state lives entirely on the elements' `hidden` /\n * `disabled` / `aria-hidden`, re-derived from triggers on `connect()` (Morph-safe).\n * The change/input listener is removed on `disconnect()` (Turbo navigation included).\n */\nexport class ConditionalFieldsController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"region\"];\n static override values = {\n disableHidden: { type: Boolean, default: true },\n match: { type: String, default: \"any\" },\n };\n static actions = [\"evaluate\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly triggerTargets: Trigger[];\n declare readonly regionTargets: HTMLElement[];\n declare readonly hasTriggerTarget: boolean;\n\n declare disableHiddenValue: boolean;\n declare matchValue: string;\n\n readonly #lastVisible = new WeakMap<HTMLElement, boolean>();\n\n readonly #onChange = (): void => {\n this.evaluate();\n };\n\n override connect(): void {\n this.evaluate();\n this.element.addEventListener(\"change\", this.#onChange);\n this.element.addEventListener(\"input\", this.#onChange);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"change\", this.#onChange);\n this.element.removeEventListener(\"input\", this.#onChange);\n }\n\n /** Re-evaluates every region against the current trigger state. */\n evaluate(): void {\n // Resolved once per pass: this runs per keystroke (input/change bubble up from\n // the whole form) and every Stimulus target access re-queries the scope.\n const triggers = this.triggerTargets;\n for (const region of this.regionTargets) {\n this.#applyRegion(region, this.#isVisible(region, triggers));\n }\n }\n\n /** Applies a region's visibility, syncing hidden/aria/disabled and emitting change. */\n #applyRegion(region: HTMLElement, visible: boolean): void {\n const previous = this.#lastVisible.get(region) ?? !region.hidden;\n if (visible === previous && this.#lastVisible.has(region)) return;\n this.#lastVisible.set(region, visible);\n\n if (visible) {\n region.hidden = false;\n region.removeAttribute(\"aria-hidden\");\n region.setAttribute(\"data-visible\", \"true\");\n this.#setRegionDisabled(region, false);\n } else {\n this.#retreatFocus(region);\n region.hidden = true;\n region.setAttribute(\"aria-hidden\", \"true\");\n region.removeAttribute(\"data-visible\");\n this.#setRegionDisabled(region, true);\n }\n\n if (visible !== previous) {\n this.dispatch(\"change\", { detail: { region, visible } });\n }\n }\n\n /** Whether a region's declared condition holds across `triggers` per `match`. */\n #isVisible(region: HTMLElement, triggers: Trigger[]): boolean {\n const predicate = this.#predicateFor(region);\n if (predicate === null) return !region.hidden; // no condition declared → leave as-is\n if (triggers.length === 0) return false;\n return this.matchValue === \"all\" ? triggers.every(predicate) : triggers.some(predicate);\n }\n\n /** Builds the per-trigger predicate from the region's `data-when-*` attribute. */\n #predicateFor(region: HTMLElement): ((trigger: Trigger) => boolean) | null {\n if (region.hasAttribute(\"data-when-checked\")) {\n return (trigger) => this.#isChecked(trigger);\n }\n if (region.hasAttribute(\"data-when-unchecked\")) {\n return (trigger) => !this.#isChecked(trigger);\n }\n const wanted = region.dataset.whenValue;\n if (wanted !== undefined) {\n return (trigger) => this.#matchesValue(trigger, wanted);\n }\n return null;\n }\n\n #isChecked(trigger: Trigger): boolean {\n return trigger instanceof HTMLInputElement && trigger.checked;\n }\n\n /** A trigger \"has\" a value: selected radio/checkbox value, or the control value. */\n #matchesValue(trigger: Trigger, wanted: string): boolean {\n if (\n trigger instanceof HTMLInputElement &&\n (trigger.type === \"checkbox\" || trigger.type === \"radio\")\n ) {\n return trigger.checked && trigger.value === wanted;\n }\n return trigger.value === wanted;\n }\n\n /** Enables/disables a region's inputs, tracking only the ones we disabled. */\n #setRegionDisabled(region: HTMLElement, disabled: boolean): void {\n if (!this.disableHiddenValue) return;\n const controls = region.querySelectorAll<Disableable>(\"input, textarea, select, button\");\n for (const control of Array.from(controls)) {\n if (disabled) {\n if (!control.disabled) {\n control.disabled = true;\n control.setAttribute(DISABLED_MARKER, \"true\");\n }\n } else if (control.hasAttribute(DISABLED_MARKER)) {\n control.disabled = false;\n control.removeAttribute(DISABLED_MARKER);\n }\n }\n }\n\n /** Moves focus out of a region about to be hidden, to a trigger when possible. */\n #retreatFocus(region: HTMLElement): void {\n const active = document.activeElement;\n if (!(active instanceof HTMLElement) || !region.contains(active)) return;\n active.blur();\n if (this.hasTriggerTarget) this.triggerTargets[0]?.focus();\n }\n}\n"]}
|