stimeo-ui 0.1.0-beta.2 → 0.2.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 +107 -0
- package/README.md +6 -6
- package/dist/controllers/auto_submit_controller.js +56 -21
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.js +52 -15
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +6 -2
- package/dist/controllers/combobox_controller.js +64 -3
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +13 -6
- package/dist/controllers/command_palette_controller.js +256 -39
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +107 -8
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +11 -6
- package/dist/controllers/context_menu_controller.js +191 -13
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +2 -2
- package/dist/controllers/countdown_controller.js +2 -2
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +107 -8
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +9 -6
- package/dist/controllers/dropdown_controller.js +139 -16
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +107 -8
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +2 -2
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +15 -12
- package/dist/controllers/hover_card_controller.js +103 -27
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.js +1 -0
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +18 -11
- package/dist/controllers/menu_controller.js +181 -8
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +62 -22
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +3 -2
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +3 -3
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +18 -10
- package/dist/controllers/popover_controller.js +118 -27
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +2 -2
- package/dist/controllers/sortable_controller.js +1 -1
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +1 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +7 -9
- package/dist/controllers/toast_controller.js +108 -29
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +15 -18
- package/dist/controllers/tooltip_controller.js +148 -42
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/index.d.ts +44 -14
- package/dist/index.js +847 -349
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +6 -6
- package/dist/inspector/manifest.json +16 -3
- package/package.json +7 -9
|
@@ -1,35 +1,136 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/dropdown_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/aria_ids.ts
|
|
6
|
+
var counter = 0;
|
|
7
|
+
function uniqueId(prefix = "stimeo") {
|
|
8
|
+
let candidate;
|
|
9
|
+
do {
|
|
10
|
+
counter += 1;
|
|
11
|
+
candidate = `${prefix}-${counter}`;
|
|
12
|
+
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
function ensureId(element, prefix = "stimeo") {
|
|
16
|
+
if (element.id) return element.id;
|
|
17
|
+
const id = uniqueId(prefix);
|
|
18
|
+
element.id = id;
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/escape_layer.ts
|
|
23
|
+
function claimsWhileFocusWithin(element) {
|
|
24
|
+
return () => {
|
|
25
|
+
const active = element.ownerDocument.activeElement;
|
|
26
|
+
return active === null || active === element.ownerDocument.body || element.contains(active);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
var EscapeLayer = class _EscapeLayer {
|
|
30
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
31
|
+
#ownerDocument = null;
|
|
32
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
33
|
+
#onDismiss = null;
|
|
34
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
35
|
+
#claims = null;
|
|
36
|
+
/**
|
|
37
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
38
|
+
* the document's shared resolver listener if this is its first layer.
|
|
39
|
+
* Re-activating an already-active layer moves it to the top.
|
|
40
|
+
*/
|
|
41
|
+
activate(ownerDocument = document, options) {
|
|
42
|
+
this.deactivate();
|
|
43
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
44
|
+
if (!registry) {
|
|
45
|
+
registry = _EscapeLayer.#createRegistry();
|
|
46
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
47
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
48
|
+
}
|
|
49
|
+
registry.stack.push(this);
|
|
50
|
+
this.#ownerDocument = ownerDocument;
|
|
51
|
+
this.#onDismiss = options.onDismiss;
|
|
52
|
+
this.#claims = options.claims ?? null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
56
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
57
|
+
*/
|
|
58
|
+
deactivate() {
|
|
59
|
+
const ownerDocument = this.#ownerDocument;
|
|
60
|
+
if (!ownerDocument) return;
|
|
61
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
62
|
+
if (registry) {
|
|
63
|
+
const index = registry.stack.lastIndexOf(this);
|
|
64
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
65
|
+
if (registry.stack.length === 0) {
|
|
66
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
67
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.#ownerDocument = null;
|
|
71
|
+
this.#onDismiss = null;
|
|
72
|
+
this.#claims = null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
76
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
77
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
78
|
+
*/
|
|
79
|
+
get ownsEscape() {
|
|
80
|
+
const ownerDocument = this.#ownerDocument;
|
|
81
|
+
if (!ownerDocument) return false;
|
|
82
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
83
|
+
if (!registry) return false;
|
|
84
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
85
|
+
}
|
|
86
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
87
|
+
static #createRegistry() {
|
|
88
|
+
const registry = {
|
|
89
|
+
stack: [],
|
|
90
|
+
onKeydown: (event) => {
|
|
91
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
92
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
93
|
+
if (!owner) return;
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
owner.#onDismiss?.();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
return registry;
|
|
99
|
+
}
|
|
100
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
101
|
+
static #resolveOwner(stack) {
|
|
102
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
103
|
+
const layer = stack[index];
|
|
104
|
+
if (!layer) continue;
|
|
105
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
106
|
+
return layer;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
3
112
|
// src/controllers/dropdown_controller.ts
|
|
4
113
|
var DropdownController = class extends Controller {
|
|
5
114
|
static targets = ["trigger", "menu"];
|
|
6
115
|
static actions = ["close", "open", "toggle"];
|
|
116
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
117
|
+
#escapeLayer = new EscapeLayer();
|
|
7
118
|
/** Closes the menu when a click lands outside the controller's element. */
|
|
8
119
|
#onOutsideClick = (event) => {
|
|
9
120
|
if (!this.element.contains(event.target)) {
|
|
10
121
|
this.close();
|
|
11
122
|
}
|
|
12
123
|
};
|
|
13
|
-
/**
|
|
14
|
-
#onKeydown = (event) => {
|
|
15
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
16
|
-
this.close();
|
|
17
|
-
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
/**
|
|
21
|
-
* Starts in the closed state and registers the document-level listeners that
|
|
22
|
-
* power outside-click and `Escape` handling.
|
|
23
|
-
*/
|
|
124
|
+
/** Starts in the closed state and registers outside-click handling. */
|
|
24
125
|
connect() {
|
|
126
|
+
this.#associateTriggerWithMenu();
|
|
25
127
|
this.close();
|
|
26
|
-
document.addEventListener("click", this.#onOutsideClick);
|
|
27
|
-
document.addEventListener("keydown", this.#onKeydown);
|
|
128
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
28
129
|
}
|
|
29
|
-
/** Removes the
|
|
130
|
+
/** Removes the listeners registered in {@link connect}. */
|
|
30
131
|
disconnect() {
|
|
31
|
-
|
|
32
|
-
document.removeEventListener("
|
|
132
|
+
this.#escapeLayer.deactivate();
|
|
133
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
33
134
|
}
|
|
34
135
|
/** Toggles the menu between open and closed. Bound via `data-action`. */
|
|
35
136
|
toggle() {
|
|
@@ -42,6 +143,10 @@ var DropdownController = class extends Controller {
|
|
|
42
143
|
/** Reveals the menu and reflects the open state on the trigger. */
|
|
43
144
|
open() {
|
|
44
145
|
if (!this.hasMenuTarget) return;
|
|
146
|
+
this.#escapeLayer.activate(document, {
|
|
147
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
148
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
149
|
+
});
|
|
45
150
|
this.menuTarget.hidden = false;
|
|
46
151
|
if (this.hasTriggerTarget) {
|
|
47
152
|
this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
@@ -49,16 +154,34 @@ var DropdownController = class extends Controller {
|
|
|
49
154
|
}
|
|
50
155
|
/** Hides the menu and reflects the closed state on the trigger. */
|
|
51
156
|
close() {
|
|
157
|
+
this.#escapeLayer.deactivate();
|
|
52
158
|
if (!this.hasMenuTarget) return;
|
|
53
159
|
this.menuTarget.hidden = true;
|
|
54
160
|
if (this.hasTriggerTarget) {
|
|
55
161
|
this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
56
162
|
}
|
|
57
163
|
}
|
|
164
|
+
/** Closes and restores focus to the trigger (the keyboard-dismissal path). */
|
|
165
|
+
#closeAndRestore() {
|
|
166
|
+
this.close();
|
|
167
|
+
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
168
|
+
}
|
|
58
169
|
/** Whether the menu is currently visible. */
|
|
59
170
|
get #isOpen() {
|
|
60
171
|
return this.hasMenuTarget && !this.menuTarget.hidden;
|
|
61
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Associates the disclosure trigger and controlled region without clobbering
|
|
175
|
+
* authored markup.
|
|
176
|
+
*/
|
|
177
|
+
#associateTriggerWithMenu() {
|
|
178
|
+
if (!this.hasTriggerTarget || !this.hasMenuTarget) return;
|
|
179
|
+
if (this.triggerTarget.hasAttribute("aria-controls")) return;
|
|
180
|
+
this.triggerTarget.setAttribute(
|
|
181
|
+
"aria-controls",
|
|
182
|
+
ensureId(this.menuTarget, "stimeo--dropdown-menu")
|
|
183
|
+
);
|
|
184
|
+
}
|
|
62
185
|
};
|
|
63
186
|
|
|
64
187
|
export { DropdownController };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/dropdown_controller.ts"],"names":[],"mappings":";;;AAyBO,IAAM,kBAAA,GAAN,cAAiC,UAAA,CAAwB;AAAA,EAC9D,OAAgB,OAAA,GAAU,CAAC,SAAA,EAAW,MAAM,CAAA;AAAA,EAC5C,OAAO,OAAA,GAAU,CAAC,OAAA,EAAS,QAAQ,QAAQ,CAAA;AAAA;AAAA,EAQlC,eAAA,GAAkB,CAAC,KAAA,KAA4B;AACtD,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,CAAQ,QAAA,CAAS,KAAA,CAAM,MAAc,CAAA,EAAG;AAChD,MAAA,IAAA,CAAK,KAAA,EAAM;AAAA,IACb;AAAA,EACF,CAAA;AAAA;AAAA,EAGS,UAAA,GAAa,CAAC,KAAA,KAA+B;AACpD,IAAA,IAAI,KAAA,CAAM,GAAA,KAAQ,QAAA,IAAY,IAAA,CAAK,OAAA,EAAS;AAC1C,MAAA,IAAA,CAAK,KAAA,EAAM;AACX,MAAA,IAAI,IAAA,CAAK,gBAAA,EAAkB,IAAA,CAAK,aAAA,CAAc,KAAA,EAAM;AAAA,IACtD;AAAA,EACF,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMS,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,KAAA,EAAM;AACX,IAAA,QAAA,CAAS,gBAAA,CAAiB,OAAA,EAAS,IAAA,CAAK,eAAe,CAAA;AACvD,IAAA,QAAA,CAAS,gBAAA,CAAiB,SAAA,EAAW,IAAA,CAAK,UAAU,CAAA;AAAA,EACtD;AAAA;AAAA,EAGS,UAAA,GAAmB;AAC1B,IAAA,QAAA,CAAS,mBAAA,CAAoB,OAAA,EAAS,IAAA,CAAK,eAAe,CAAA;AAC1D,IAAA,QAAA,CAAS,mBAAA,CAAoB,SAAA,EAAW,IAAA,CAAK,UAAU,CAAA;AAAA,EACzD;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAI,KAAK,OAAA,EAAS;AAChB,MAAA,IAAA,CAAK,KAAA,EAAM;AAAA,IACb,CAAA,MAAO;AACL,MAAA,IAAA,CAAK,IAAA,EAAK;AAAA,IACZ;AAAA,EACF;AAAA;AAAA,EAGA,IAAA,GAAa;AACX,IAAA,IAAI,CAAC,KAAK,aAAA,EAAe;AACzB,IAAA,IAAA,CAAK,WAAW,MAAA,GAAS,KAAA;AACzB,IAAA,IAAI,KAAK,gBAAA,EAAkB;AACzB,MAAA,IAAA,CAAK,aAAA,CAAc,YAAA,CAAa,eAAA,EAAiB,MAAM,CAAA;AAAA,IACzD;AAAA,EACF;AAAA;AAAA,EAGA,KAAA,GAAc;AACZ,IAAA,IAAI,CAAC,KAAK,aAAA,EAAe;AACzB,IAAA,IAAA,CAAK,WAAW,MAAA,GAAS,IAAA;AACzB,IAAA,IAAI,KAAK,gBAAA,EAAkB;AACzB,MAAA,IAAA,CAAK,aAAA,CAAc,YAAA,CAAa,eAAA,EAAiB,OAAO,CAAA;AAAA,IAC1D;AAAA,EACF;AAAA;AAAA,EAGA,IAAI,OAAA,GAAmB;AACrB,IAAA,OAAO,IAAA,CAAK,aAAA,IAAiB,CAAC,IAAA,CAAK,UAAA,CAAW,MAAA;AAAA,EAChD;AACF","file":"dropdown_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible dropdown menu behavior.\n *\n * Markup contract (identifier: `stimeo--dropdown`):\n * <div data-controller=\"stimeo--dropdown\">\n * <button data-stimeo--dropdown-target=\"trigger\"\n * data-action=\"stimeo--dropdown#toggle\">Menu</button>\n * <div data-stimeo--dropdown-target=\"menu\">...</div>\n * </div>\n *\n * This is a **disclosure** pattern (WAI-ARIA APG): a button toggles the\n * visibility of an adjacent region. It is intentionally *not* a full APG\n * \"menu\" widget — there is no roving-tabindex arrow-key navigation.\n *\n * @remarks\n * The library owns behavior only (ARIA state, keyboard, focus, outside-click).\n * Visual styling is left entirely to the consumer's CSS.\n *\n * Behavior provided:\n * - Click the trigger to toggle the menu (`aria-expanded` + `hidden` reflect state).\n * - `Escape` closes the menu and returns focus to the trigger.\n * - A click outside the controller element closes the menu.\n */\nexport class DropdownController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"menu\"];\n static actions = [\"close\", \"open\", \"toggle\"] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly menuTarget: HTMLElement;\n declare readonly hasMenuTarget: boolean;\n declare readonly hasTriggerTarget: boolean;\n\n /** Closes the menu when a click lands outside the controller's element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.element.contains(event.target as Node)) {\n this.close();\n }\n };\n\n /** Closes the menu on `Escape` and restores focus to the trigger. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\" && this.#isOpen) {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n }\n };\n\n /**\n * Starts in the closed state and registers the document-level listeners that\n * power outside-click and `Escape` handling.\n */\n override connect(): void {\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick);\n document.addEventListener(\"keydown\", this.#onKeydown);\n }\n\n /** Removes the document-level listeners registered in {@link connect}. */\n override disconnect(): void {\n document.removeEventListener(\"click\", this.#onOutsideClick);\n document.removeEventListener(\"keydown\", this.#onKeydown);\n }\n\n /** Toggles the menu between open and closed. Bound via `data-action`. */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n }\n }\n\n /** Reveals the menu and reflects the open state on the trigger. */\n open(): void {\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = false;\n if (this.hasTriggerTarget) {\n this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n }\n }\n\n /** Hides the menu and reflects the closed state on the trigger. */\n close(): void {\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = true;\n if (this.hasTriggerTarget) {\n this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n }\n\n /** Whether the menu is currently visible. */\n get #isOpen(): boolean {\n return this.hasMenuTarget && !this.menuTarget.hidden;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/aria_ids.ts","../../src/utils/escape_layer.ts","../../src/controllers/dropdown_controller.ts"],"names":[],"mappings":"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","file":"dropdown_controller.js","sourcesContent":["/**\n * Minimal id primitives for wiring ARIA relationships.\n *\n * Accessible relationships such as `aria-describedby`, `aria-errormessage`,\n * `aria-labelledby`, and `aria-activedescendant` reference their targets by DOM\n * `id`. When a controller must establish such a relationship but the consumer's\n * markup left the target without an `id`, it needs to mint one that is stable for\n * the page's lifetime and guaranteed not to collide with another generated id.\n *\n * These helpers own *only* id generation/assignment. The linking policy (which\n * attribute, what order, when to add or remove it) stays in each controller —\n * this deliberately avoids a premature generic \"ARIA linker\" abstraction.\n */\n\n/**\n * Monotonic counter backing {@link uniqueId}. Module-scoped so every generated\n * id is unique across all controller instances sharing this module in a document.\n */\nlet counter = 0;\n\n/**\n * Returns a unique, DOM-id-safe string of the form `` `${prefix}-${n}` `` where\n * `n` increases on each call.\n *\n * The counter alone guarantees uniqueness against other *generated* ids, but a\n * consumer-authored element could already own `` `${prefix}-${n}` ``. When a\n * `document` is available the candidate is therefore advanced until no element\n * with that id exists, so a generated id never collides with author markup\n * either — the minimal \"id registry\" guarantee these helpers exist to provide.\n *\n * @param prefix - Leading segment of the id (e.g. a controller's identifier).\n * Defaults to `\"stimeo\"`.\n */\nexport function uniqueId(prefix = \"stimeo\"): string {\n let candidate: string;\n do {\n counter += 1;\n candidate = `${prefix}-${counter}`;\n } while (typeof document !== \"undefined\" && document.getElementById(candidate) !== null);\n return candidate;\n}\n\n/**\n * Returns `element`'s existing `id`, or assigns it a freshly generated\n * {@link uniqueId} (with the given `prefix`) and returns that. Idempotent: an\n * element that already has an id keeps it, so consumer-authored ids are never\n * overwritten.\n *\n * @param element - The element to read or stamp an `id` onto.\n * @param prefix - Prefix forwarded to {@link uniqueId} when one must be generated.\n * @returns The element's id (existing or newly assigned).\n */\nexport function ensureId(element: Element, prefix = \"stimeo\"): string {\n if (element.id) return element.id;\n const id = uniqueId(prefix);\n element.id = id;\n return id;\n}\n","/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest-first half of the\n * shared layered-Escape contract. A keydown that is part of an IME composition\n * (`event.isComposing`) cancels the composition, never a layer, and is ignored\n * here for every layer at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { ensureId } from \"../utils/aria_ids\";\nimport { claimsWhileFocusWithin, EscapeLayer } from \"../utils/escape_layer\";\n\n/**\n * Headless, accessible dropdown menu behavior.\n *\n * Markup contract (identifier: `stimeo--dropdown`):\n * <div data-controller=\"stimeo--dropdown\">\n * <button data-stimeo--dropdown-target=\"trigger\"\n * data-action=\"stimeo--dropdown#toggle\">Menu</button>\n * <div data-stimeo--dropdown-target=\"menu\">...</div>\n * </div>\n *\n * This is a **disclosure** pattern (WAI-ARIA APG): a button toggles the\n * visibility of an adjacent region. It is intentionally *not* a full APG\n * \"menu\" widget — there is no roving-tabindex arrow-key navigation.\n *\n * @remarks\n * The library owns behavior only (ARIA state, keyboard, focus, outside-click).\n * Visual styling is left entirely to the consumer's CSS.\n *\n * Behavior provided:\n * - The trigger is associated with the menu through `aria-controls`.\n * - Click the trigger to toggle the menu (`aria-expanded` + `hidden` reflect state).\n * - `Escape` closes the menu and returns focus to the trigger. While open the\n * menu is a layer on the shared {@link EscapeLayer} stack; it claims a press\n * only while focus is inside the controller or fell to the body (a click on\n * non-focusable menu content), so a press aimed at another layer never closes\n * the menu or steals focus, and one keypress closes exactly one layer (the\n * shared layered-Escape contract).\n * - A click outside the controller element closes the menu.\n */\nexport class DropdownController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"menu\"];\n static actions = [\"close\", \"open\", \"toggle\"] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly menuTarget: HTMLElement;\n declare readonly hasMenuTarget: boolean;\n declare readonly hasTriggerTarget: boolean;\n\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n\n /** Closes the menu when a click lands outside the controller's element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.element.contains(event.target as Node)) {\n this.close();\n }\n };\n\n /** Starts in the closed state and registers outside-click handling. */\n override connect(): void {\n this.#associateTriggerWithMenu();\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n }\n\n /** Removes the listeners registered in {@link connect}. */\n override disconnect(): void {\n this.#escapeLayer.deactivate();\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n }\n\n /** Toggles the menu between open and closed. Bound via `data-action`. */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n }\n }\n\n /** Reveals the menu and reflects the open state on the trigger. */\n open(): void {\n if (!this.hasMenuTarget) return;\n this.#escapeLayer.activate(document, {\n onDismiss: () => this.#closeAndRestore(),\n claims: claimsWhileFocusWithin(this.element),\n });\n this.menuTarget.hidden = false;\n if (this.hasTriggerTarget) {\n this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n }\n }\n\n /** Hides the menu and reflects the closed state on the trigger. */\n close(): void {\n this.#escapeLayer.deactivate();\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = true;\n if (this.hasTriggerTarget) {\n this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n }\n\n /** Closes and restores focus to the trigger (the keyboard-dismissal path). */\n #closeAndRestore(): void {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n }\n\n /** Whether the menu is currently visible. */\n get #isOpen(): boolean {\n return this.hasMenuTarget && !this.menuTarget.hidden;\n }\n\n /**\n * Associates the disclosure trigger and controlled region without clobbering\n * authored markup.\n */\n #associateTriggerWithMenu(): void {\n if (!this.hasTriggerTarget || !this.hasMenuTarget) return;\n if (this.triggerTarget.hasAttribute(\"aria-controls\")) return;\n\n this.triggerTarget.setAttribute(\n \"aria-controls\",\n ensureId(this.menuTarget, \"stimeo--dropdown-menu\"),\n );\n }\n}\n"]}
|
|
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/focus_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
var EscapeLayer = class _EscapeLayer {
|
|
7
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
#ownerDocument = null;
|
|
9
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
10
|
+
#onDismiss = null;
|
|
11
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
12
|
+
#claims = null;
|
|
13
|
+
/**
|
|
14
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
15
|
+
* the document's shared resolver listener if this is its first layer.
|
|
16
|
+
* Re-activating an already-active layer moves it to the top.
|
|
17
|
+
*/
|
|
18
|
+
activate(ownerDocument = document, options) {
|
|
19
|
+
this.deactivate();
|
|
20
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
21
|
+
if (!registry) {
|
|
22
|
+
registry = _EscapeLayer.#createRegistry();
|
|
23
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
24
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
25
|
+
}
|
|
26
|
+
registry.stack.push(this);
|
|
27
|
+
this.#ownerDocument = ownerDocument;
|
|
28
|
+
this.#onDismiss = options.onDismiss;
|
|
29
|
+
this.#claims = options.claims ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
33
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
34
|
+
*/
|
|
35
|
+
deactivate() {
|
|
36
|
+
const ownerDocument = this.#ownerDocument;
|
|
37
|
+
if (!ownerDocument) return;
|
|
38
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
39
|
+
if (registry) {
|
|
40
|
+
const index = registry.stack.lastIndexOf(this);
|
|
41
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
42
|
+
if (registry.stack.length === 0) {
|
|
43
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
44
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.#ownerDocument = null;
|
|
48
|
+
this.#onDismiss = null;
|
|
49
|
+
this.#claims = null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
53
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
54
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
55
|
+
*/
|
|
56
|
+
get ownsEscape() {
|
|
57
|
+
const ownerDocument = this.#ownerDocument;
|
|
58
|
+
if (!ownerDocument) return false;
|
|
59
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
60
|
+
if (!registry) return false;
|
|
61
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
62
|
+
}
|
|
63
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
64
|
+
static #createRegistry() {
|
|
65
|
+
const registry = {
|
|
66
|
+
stack: [],
|
|
67
|
+
onKeydown: (event) => {
|
|
68
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
69
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
70
|
+
if (!owner) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
owner.#onDismiss?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return registry;
|
|
76
|
+
}
|
|
77
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
78
|
+
static #resolveOwner(stack) {
|
|
79
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
80
|
+
const layer = stack[index];
|
|
81
|
+
if (!layer) continue;
|
|
82
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
83
|
+
return layer;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
5
89
|
// src/utils/focus_trap.ts
|
|
6
90
|
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
91
|
var FocusTrap = class {
|
|
@@ -15,6 +99,8 @@ var FocusTrap = class {
|
|
|
15
99
|
#inertedSiblings = [];
|
|
16
100
|
/** Whether the modal side effects are currently applied. */
|
|
17
101
|
#activeState = false;
|
|
102
|
+
/** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
|
|
103
|
+
#escapeLayer = new EscapeLayer();
|
|
18
104
|
/** Returns the trapped element; called on every operation for the live target. */
|
|
19
105
|
#getContainer;
|
|
20
106
|
/** Closing/focus hooks; see {@link FocusTrapOptions}. */
|
|
@@ -49,6 +135,9 @@ var FocusTrap = class {
|
|
|
49
135
|
}
|
|
50
136
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
51
137
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
+
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
139
|
+
const onEscape = this.#options.onEscape;
|
|
140
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
52
141
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
53
142
|
}
|
|
54
143
|
/**
|
|
@@ -61,7 +150,9 @@ var FocusTrap = class {
|
|
|
61
150
|
deactivate({ restoreFocus = true } = {}) {
|
|
62
151
|
if (!this.#activeState) return;
|
|
63
152
|
this.#activeState = false;
|
|
153
|
+
this.#escapeLayer.deactivate();
|
|
64
154
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
+
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
65
156
|
if (this.#scrollLocked) {
|
|
66
157
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
67
158
|
this.#scrollLocked = false;
|
|
@@ -77,15 +168,23 @@ var FocusTrap = class {
|
|
|
77
168
|
if (option === void 0) return fallback;
|
|
78
169
|
return typeof option === "function" ? option() : option;
|
|
79
170
|
}
|
|
80
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Reverts the side effects just before Turbo caches the page snapshot, so an
|
|
173
|
+
* overlay left open does not bake the scroll lock into `body[style]` — a
|
|
174
|
+
* restored page would feed that locked value back into {@link activate} as the
|
|
175
|
+
* baseline, and closing would then never unlock the page. Markup state stays
|
|
176
|
+
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
|
+
* is left alone mid-navigation. The listener lives only while active.
|
|
178
|
+
*/
|
|
179
|
+
#onBeforeCache = () => {
|
|
180
|
+
this.deactivate({ restoreFocus: false });
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
|
+
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
185
|
+
* which layer currently owns Escape.
|
|
186
|
+
*/
|
|
81
187
|
#onKeydown = (event) => {
|
|
82
|
-
if (event.key === "Escape") {
|
|
83
|
-
if (this.#options.onEscape) {
|
|
84
|
-
event.preventDefault();
|
|
85
|
-
this.#options.onEscape();
|
|
86
|
-
}
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
188
|
if (event.key === "Tab") this.#trapTab(event);
|
|
90
189
|
};
|
|
91
190
|
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/focus_trap.ts","../../src/controllers/focus_controller.ts"],"names":[],"mappings":"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","file":"focus_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 **focus scope**: exposes the shared {@link FocusTrap} as a standalone,\n * declarative focus boundary — `Tab` cycling, initial focus, and restore-on-release —\n * for any region, without building a full modal (counterpart to Alpine `focus` /\n * Headless UI's trap). No dedicated APG pattern; it is the primitive the overlay\n * patterns use, surfaced for direct use.\n *\n * Markup contract (identifier: `stimeo--focus`):\n * <div data-controller=\"stimeo--focus\" data-stimeo--focus-trap-value=\"true\">\n * <input data-stimeo--focus-target=\"initial\" />\n * …\n * </div>\n *\n * While `trap` is on, `Tab` / `Shift+Tab` cycle within the element, focus moves to the\n * `initial` target (or the first focusable) when `auto`, `Escape` releases it, and on\n * release focus returns to the opener when `restore`. With `inert` the rest of the page\n * is made `inert` (a hard, modal-style isolation); left off it is a soft boundary —\n * `Tab` still cycles but the background stays reachable. The element carries\n * `data-focus-trapped` while active and emits `activate` / `deactivate`.\n *\n * @remarks\n * Behavior only — it does not open/close or render an overlay (pair with Dialog) and\n * does not move DOM (pair with Portal). It reuses `src/utils/focus_trap.ts`, so it never\n * scroll-locks the page (unlike the modal overlays) and tracks live focusable children\n * (dynamic additions are picked up on the next `Tab`). The opener is recorded on\n * activate and refocused on release if still present. Everything is torn down on\n * `disconnect()` (Turbo navigation included) without yanking focus.\n */\nexport class FocusController extends Controller<HTMLElement> {\n static override targets = [\"initial\"];\n static override values = {\n trap: { type: Boolean, default: false },\n auto: { type: Boolean, default: true },\n restore: { type: Boolean, default: true },\n inert: { type: Boolean, default: false },\n };\n static actions = [\"activate\", \"deactivate\"] as const;\n static events = [\"activate\", \"deactivate\"] as const;\n\n declare readonly initialTarget: HTMLElement;\n declare readonly hasInitialTarget: boolean;\n\n declare trapValue: boolean;\n declare autoValue: boolean;\n declare restoreValue: boolean;\n declare inertValue: boolean;\n\n readonly #trap = new FocusTrap(() => this.element, {\n // A focus scope is not a modal: never lock scroll, and only isolate the background\n // when `inert` is requested. `auto` gates the initial focus move; Escape releases.\n lockScroll: false,\n isolate: () => this.inertValue,\n autoFocus: () => this.autoValue,\n initialFocus: () => (this.hasInitialTarget ? this.initialTarget : null),\n onEscape: () => this.deactivate(),\n });\n\n /** Stimulus drives activation from the `trap` value (also fires on connect). */\n trapValueChanged(): void {\n if (this.trapValue) this.#activate();\n else this.#deactivate();\n }\n\n override disconnect(): void {\n // Release without restoring focus — the element is leaving the DOM. This is a\n // teardown, not a user-driven close, so it intentionally does NOT emit\n // `deactivate` or reset `trapValue` (which would resurrect on a Turbo cache\n // restore); only the public `deactivate()` action fires the event.\n this.#trap.deactivate({ restoreFocus: false });\n this.element.removeAttribute(\"data-focus-trapped\");\n }\n\n /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */\n activate(): void {\n this.trapValue = true;\n this.#activate();\n }\n\n /** Turns the trap off (also wired to Escape). */\n deactivate(): void {\n this.trapValue = false;\n this.#deactivate();\n }\n\n #activate(): void {\n if (this.#trap.active) return;\n this.#trap.activate();\n this.element.setAttribute(\"data-focus-trapped\", \"true\");\n this.dispatch(\"activate\", { detail: {} });\n }\n\n #deactivate(): void {\n if (!this.#trap.active) return;\n this.#trap.deactivate({ restoreFocus: this.restoreValue });\n this.element.removeAttribute(\"data-focus-trapped\");\n this.dispatch(\"deactivate\", { detail: {} });\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/focus_trap.ts","../../src/controllers/focus_controller.ts"],"names":[],"mappings":"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","file":"focus_controller.js","sourcesContent":["/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest-first half of the\n * shared layered-Escape contract. A keydown that is part of an IME composition\n * (`event.isComposing`) cancels the composition, never a layer, and is ignored\n * here for every layer at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","import { EscapeLayer } from \"./escape_layer\";\n\n/**\n * Modal focus-trap primitive shared by the modal-overlay controllers\n * (dialog / alert-dialog / confirm / drawer / command-palette / sidebar); the\n * non-modal focus scope (focus) reuses it with the modal side effects opted out.\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) and kept out of the snapshot Turbo\n * caches (`turbo:before-cache`). {@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 joins the Escape stack). Dismissal\n * is resolved by the shared {@link EscapeLayer}: an Escape already consumed\n * by an inner handler is ignored, and among active layers the most recently\n * activated claiming one owns the press. The resolver consumes the event\n * before invoking the callback (the shared layered-Escape contract).\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 /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */\n readonly #escapeLayer = new EscapeLayer();\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 document.addEventListener(\"turbo:before-cache\", this.#onBeforeCache);\n const onEscape = this.#options.onEscape;\n if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });\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 this.#escapeLayer.deactivate();\n document.removeEventListener(\"keydown\", this.#onKeydown);\n document.removeEventListener(\"turbo:before-cache\", this.#onBeforeCache);\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 /**\n * Reverts the side effects just before Turbo caches the page snapshot, so an\n * overlay left open does not bake the scroll lock into `body[style]` — a\n * restored page would feed that locked value back into {@link activate} as the\n * baseline, and closing would then never unlock the page. Markup state stays\n * untouched (restore-open designs reopen against a clean baseline), and focus\n * is left alone mid-navigation. The listener lives only while active.\n */\n readonly #onBeforeCache = (): void => {\n this.deactivate({ restoreFocus: false });\n };\n\n /**\n * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the\n * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of\n * which layer currently owns Escape.\n */\n readonly #onKeydown = (event: KeyboardEvent): void => {\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 **focus scope**: exposes the shared {@link FocusTrap} as a standalone,\n * declarative focus boundary — `Tab` cycling, initial focus, and restore-on-release —\n * for any region, without building a full modal (counterpart to Alpine `focus` /\n * Headless UI's trap). No dedicated APG pattern; it is the primitive the overlay\n * patterns use, surfaced for direct use.\n *\n * Markup contract (identifier: `stimeo--focus`):\n * <div data-controller=\"stimeo--focus\" data-stimeo--focus-trap-value=\"true\">\n * <input data-stimeo--focus-target=\"initial\" />\n * …\n * </div>\n *\n * While `trap` is on, `Tab` / `Shift+Tab` cycle within the element, focus moves to the\n * `initial` target (or the first focusable) when `auto`, `Escape` releases it, and on\n * release focus returns to the opener when `restore`. With `inert` the rest of the page\n * is made `inert` (a hard, modal-style isolation); left off it is a soft boundary —\n * `Tab` still cycles but the background stays reachable. The element carries\n * `data-focus-trapped` while active and emits `activate` / `deactivate`.\n *\n * @remarks\n * Behavior only — it does not open/close or render an overlay (pair with Dialog) and\n * does not move DOM (pair with Portal). It reuses `src/utils/focus_trap.ts`, so it never\n * scroll-locks the page (unlike the modal overlays) and tracks live focusable children\n * (dynamic additions are picked up on the next `Tab`). The opener is recorded on\n * activate and refocused on release if still present. Everything is torn down on\n * `disconnect()` (Turbo navigation included) without yanking focus.\n */\nexport class FocusController extends Controller<HTMLElement> {\n static override targets = [\"initial\"];\n static override values = {\n trap: { type: Boolean, default: false },\n auto: { type: Boolean, default: true },\n restore: { type: Boolean, default: true },\n inert: { type: Boolean, default: false },\n };\n static actions = [\"activate\", \"deactivate\"] as const;\n static events = [\"activate\", \"deactivate\"] as const;\n\n declare readonly initialTarget: HTMLElement;\n declare readonly hasInitialTarget: boolean;\n\n declare trapValue: boolean;\n declare autoValue: boolean;\n declare restoreValue: boolean;\n declare inertValue: boolean;\n\n readonly #trap = new FocusTrap(() => this.element, {\n // A focus scope is not a modal: never lock scroll, and only isolate the background\n // when `inert` is requested. `auto` gates the initial focus move; Escape releases.\n lockScroll: false,\n isolate: () => this.inertValue,\n autoFocus: () => this.autoValue,\n initialFocus: () => (this.hasInitialTarget ? this.initialTarget : null),\n onEscape: () => this.deactivate(),\n });\n\n /** Stimulus drives activation from the `trap` value (also fires on connect). */\n trapValueChanged(): void {\n if (this.trapValue) this.#activate();\n else this.#deactivate();\n }\n\n override disconnect(): void {\n // Release without restoring focus — the element is leaving the DOM. This is a\n // teardown, not a user-driven close, so it intentionally does NOT emit\n // `deactivate` or reset `trapValue` (which would resurrect on a Turbo cache\n // restore); only the public `deactivate()` action fires the event.\n this.#trap.deactivate({ restoreFocus: false });\n this.element.removeAttribute(\"data-focus-trapped\");\n }\n\n /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */\n activate(): void {\n this.trapValue = true;\n this.#activate();\n }\n\n /** Turns the trap off (also wired to Escape). */\n deactivate(): void {\n this.trapValue = false;\n this.#deactivate();\n }\n\n #activate(): void {\n if (this.#trap.active) return;\n this.#trap.activate();\n this.element.setAttribute(\"data-focus-trapped\", \"true\");\n this.dispatch(\"activate\", { detail: {} });\n }\n\n #deactivate(): void {\n if (!this.#trap.active) return;\n this.#trap.deactivate({ restoreFocus: this.restoreValue });\n this.element.removeAttribute(\"data-focus-trapped\");\n this.dispatch(\"deactivate\", { detail: {} });\n }\n}\n"]}
|
|
@@ -181,8 +181,8 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
181
181
|
}
|
|
182
182
|
/**
|
|
183
183
|
* Applies (or clears) a declarative custom constraint via `setCustomValidity`,
|
|
184
|
-
* for controls that opt in with `data-stimeo--form-field-disallow`. The
|
|
185
|
-
*
|
|
184
|
+
* for controls that opt in with `data-stimeo--form-field-disallow`. The supported
|
|
185
|
+
* rule is `"whitespace"` — a value that is non-empty but blank
|
|
186
186
|
* after trimming (which slips past `required` / `minlength`); its message follows
|
|
187
187
|
* the per-constraint (`value-missing`) → generic → default chain.
|
|
188
188
|
*
|