stimeo-ui 0.1.0.pre.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +72 -0
- data/dist/controllers/accordion_controller.js +76 -0
- data/dist/controllers/announcer_controller.js +184 -0
- data/dist/controllers/aspect_ratio_controller.js +36 -0
- data/dist/controllers/auto_submit_controller.js +147 -0
- data/dist/controllers/avatar_controller.js +66 -0
- data/dist/controllers/breadcrumb_controller.js +123 -0
- data/dist/controllers/bulk_select_controller.js +104 -0
- data/dist/controllers/calendar_controller.js +394 -0
- data/dist/controllers/character_counter_controller.js +179 -0
- data/dist/controllers/checkbox_controller.js +73 -0
- data/dist/controllers/combobox_controller.js +186 -0
- data/dist/controllers/command_palette_controller.js +381 -0
- data/dist/controllers/conditional_fields_controller.js +112 -0
- data/dist/controllers/confirm_controller.js +276 -0
- data/dist/controllers/context_menu_controller.js +112 -0
- data/dist/controllers/countdown_controller.js +202 -0
- data/dist/controllers/dialog_controller.js +207 -0
- data/dist/controllers/direct_upload_controller.js +212 -0
- data/dist/controllers/dirty_form_controller.js +128 -0
- data/dist/controllers/dropdown_controller.js +66 -0
- data/dist/controllers/empty_state_controller.js +67 -0
- data/dist/controllers/flash_controller.js +221 -0
- data/dist/controllers/focus_controller.js +216 -0
- data/dist/controllers/form_field_controller.js +154 -0
- data/dist/controllers/form_validation_controller.js +202 -0
- data/dist/controllers/frame_loading_controller.js +177 -0
- data/dist/controllers/highlight_controller.js +107 -0
- data/dist/controllers/hover_card_controller.js +165 -0
- data/dist/controllers/idle_controller.js +141 -0
- data/dist/controllers/input_mask_controller.js +166 -0
- data/dist/controllers/lazy_frame_controller.js +68 -0
- data/dist/controllers/listbox_controller.js +256 -0
- data/dist/controllers/local_time_controller.js +81 -0
- data/dist/controllers/menu_controller.js +134 -0
- data/dist/controllers/meter_controller.js +96 -0
- data/dist/controllers/nested_form_controller.js +131 -0
- data/dist/controllers/network_status_controller.js +126 -0
- data/dist/controllers/number_input_controller.js +306 -0
- data/dist/controllers/otp_controller.js +201 -0
- data/dist/controllers/overflow_indicator_controller.js +169 -0
- data/dist/controllers/overflow_menu_controller.js +274 -0
- data/dist/controllers/pagination_controller.js +89 -0
- data/dist/controllers/password_strength_controller.js +175 -0
- data/dist/controllers/persist_controller.js +259 -0
- data/dist/controllers/popover_controller.js +94 -0
- data/dist/controllers/portal_controller.js +63 -0
- data/dist/controllers/preview_guard_controller.js +69 -0
- data/dist/controllers/progress_controller.js +93 -0
- data/dist/controllers/radio_group_controller.js +128 -0
- data/dist/controllers/rating_controller.js +179 -0
- data/dist/controllers/relative_time_controller.js +129 -0
- data/dist/controllers/reset_before_cache_controller.js +62 -0
- data/dist/controllers/resizable_controller.js +163 -0
- data/dist/controllers/roving_controller.js +116 -0
- data/dist/controllers/scroll_area_controller.js +183 -0
- data/dist/controllers/scroll_visibility_controller.js +103 -0
- data/dist/controllers/scrollspy_controller.js +171 -0
- data/dist/controllers/skeleton_controller.js +125 -0
- data/dist/controllers/slider_controller.js +109 -0
- data/dist/controllers/spinner_controller.js +164 -0
- data/dist/controllers/step_indicator_controller.js +55 -0
- data/dist/controllers/stepper_controller.js +78 -0
- data/dist/controllers/stick_to_bottom_controller.js +100 -0
- data/dist/controllers/sticky_observer_controller.js +53 -0
- data/dist/controllers/submit_once_controller.js +206 -0
- data/dist/controllers/switch_controller.js +50 -0
- data/dist/controllers/tabs_controller.js +63 -0
- data/dist/controllers/textarea_autosize_controller.js +72 -0
- data/dist/controllers/theme_controller.js +154 -0
- data/dist/controllers/toast_controller.js +310 -0
- data/dist/controllers/toggle_group_controller.js +130 -0
- data/dist/controllers/toolbar_controller.js +113 -0
- data/dist/controllers/tooltip_controller.js +165 -0
- data/dist/controllers/transition_controller.js +203 -0
- data/dist/index.js +12241 -0
- data/dist/positioning/index.js +145 -0
- data/lib/generators/stimeo/install/install_generator.rb +114 -0
- data/lib/generators/stimeo/install/templates/stimeo.js +12 -0
- data/lib/stimeo/ui/version.rb +10 -0
- data/lib/stimeo/ui.rb +19 -0
- data/lib/stimeo-ui.rb +4 -0
- metadata +152 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/flash_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/safe_timeout.ts
|
|
6
|
+
var TimerRegistry = class {
|
|
7
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
8
|
+
ids = /* @__PURE__ */ new Set();
|
|
9
|
+
/**
|
|
10
|
+
* Cancels a single tracked timer.
|
|
11
|
+
*
|
|
12
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
13
|
+
* registry), so callers can clear defensively without guarding.
|
|
14
|
+
*/
|
|
15
|
+
clear(id) {
|
|
16
|
+
if (this.ids.delete(id)) {
|
|
17
|
+
this.cancel(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
22
|
+
* to guarantee no timer outlives the element.
|
|
23
|
+
*/
|
|
24
|
+
clearAll() {
|
|
25
|
+
for (const id of this.ids) {
|
|
26
|
+
this.cancel(id);
|
|
27
|
+
}
|
|
28
|
+
this.ids.clear();
|
|
29
|
+
}
|
|
30
|
+
/** Number of timers currently tracked (pending). */
|
|
31
|
+
get size() {
|
|
32
|
+
return this.ids.size;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
36
|
+
/**
|
|
37
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
38
|
+
*
|
|
39
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
40
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
41
|
+
*/
|
|
42
|
+
set(callback, delay) {
|
|
43
|
+
const id = this.schedule(() => {
|
|
44
|
+
this.ids.delete(id);
|
|
45
|
+
callback();
|
|
46
|
+
}, delay);
|
|
47
|
+
this.ids.add(id);
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
schedule(callback, delay) {
|
|
51
|
+
return window.setTimeout(callback, delay);
|
|
52
|
+
}
|
|
53
|
+
cancel(id) {
|
|
54
|
+
window.clearTimeout(id);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/controllers/flash_controller.ts
|
|
59
|
+
var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
|
|
60
|
+
var MESSAGE_SELECTOR = '[data-stimeo--flash-target="message"]';
|
|
61
|
+
var FlashController = class extends Controller {
|
|
62
|
+
static targets = ["region", "message"];
|
|
63
|
+
static values = {
|
|
64
|
+
duration: { type: Number, default: 5e3 },
|
|
65
|
+
pauseOnHover: { type: Boolean, default: true },
|
|
66
|
+
max: { type: Number, default: 0 }
|
|
67
|
+
};
|
|
68
|
+
static actions = ["dismiss"];
|
|
69
|
+
static events = ["show", "dismiss"];
|
|
70
|
+
#timers = new SafeTimeout();
|
|
71
|
+
#observer = null;
|
|
72
|
+
/** Auto-dismiss timer state keyed by message element. */
|
|
73
|
+
#state = /* @__PURE__ */ new Map();
|
|
74
|
+
/** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
|
|
75
|
+
#order = [];
|
|
76
|
+
#onEnter = (event) => this.#pause(event.currentTarget);
|
|
77
|
+
#onLeave = (event) => this.#resume(event.currentTarget);
|
|
78
|
+
connect() {
|
|
79
|
+
if (!this.hasRegionTarget) return;
|
|
80
|
+
for (const message of this.messageTargets) {
|
|
81
|
+
this.#process(message, true);
|
|
82
|
+
}
|
|
83
|
+
if (typeof MutationObserver !== "undefined") {
|
|
84
|
+
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
85
|
+
this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
disconnect() {
|
|
89
|
+
this.#observer?.disconnect();
|
|
90
|
+
this.#observer = null;
|
|
91
|
+
this.#timers.clearAll();
|
|
92
|
+
for (const message of this.#order) this.#unbindPause(message);
|
|
93
|
+
this.#state.clear();
|
|
94
|
+
this.#order.length = 0;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
|
|
98
|
+
* stay in sync. Binding is gated by `pauseOnHover`; unbinding is unconditional
|
|
99
|
+
* and idempotent (a no-op when nothing was bound), which keeps teardown correct
|
|
100
|
+
* even if `pauseOnHover` were ever toggled during a message's life.
|
|
101
|
+
*/
|
|
102
|
+
#bindPause(message) {
|
|
103
|
+
if (!this.pauseOnHoverValue) return;
|
|
104
|
+
message.addEventListener("mouseenter", this.#onEnter);
|
|
105
|
+
message.addEventListener("mouseleave", this.#onLeave);
|
|
106
|
+
message.addEventListener("focusin", this.#onEnter);
|
|
107
|
+
message.addEventListener("focusout", this.#onLeave);
|
|
108
|
+
}
|
|
109
|
+
#unbindPause(message) {
|
|
110
|
+
message.removeEventListener("mouseenter", this.#onEnter);
|
|
111
|
+
message.removeEventListener("mouseleave", this.#onLeave);
|
|
112
|
+
message.removeEventListener("focusin", this.#onEnter);
|
|
113
|
+
message.removeEventListener("focusout", this.#onLeave);
|
|
114
|
+
}
|
|
115
|
+
/** Dismisses the flash whose close control fired the event. */
|
|
116
|
+
dismiss(event) {
|
|
117
|
+
const target = event.currentTarget || event.target;
|
|
118
|
+
const message = target?.closest(MESSAGE_SELECTOR);
|
|
119
|
+
if (message) this.#beginDismiss(message, "user");
|
|
120
|
+
}
|
|
121
|
+
/** Processes messages added after connect (Turbo Stream); their own role announces them. */
|
|
122
|
+
#onMutations(mutations) {
|
|
123
|
+
for (const mutation of mutations) {
|
|
124
|
+
for (const node of mutation.addedNodes) {
|
|
125
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
126
|
+
if (node.matches(MESSAGE_SELECTOR)) this.#process(node, false);
|
|
127
|
+
for (const message of node.querySelectorAll(MESSAGE_SELECTOR)) {
|
|
128
|
+
this.#process(message, false);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Applies role/state, wires pause listeners, schedules auto-dismiss, and either
|
|
135
|
+
* bridges to the Announcer (`bridge`, for initial flashes) or leaves the message's
|
|
136
|
+
* own role to do the announcing (dynamic inserts). Idempotent per message.
|
|
137
|
+
*/
|
|
138
|
+
#process(message, bridge) {
|
|
139
|
+
if (this.#state.has(message) || this.#order.includes(message)) return;
|
|
140
|
+
const type = message.getAttribute("data-flash-type") ?? "";
|
|
141
|
+
const assertive = ASSERTIVE_TYPES.has(type);
|
|
142
|
+
if (!message.hasAttribute("role")) {
|
|
143
|
+
message.setAttribute("role", assertive ? "alert" : "status");
|
|
144
|
+
}
|
|
145
|
+
message.setAttribute("data-flash-state", "visible");
|
|
146
|
+
this.#order.push(message);
|
|
147
|
+
this.#bindPause(message);
|
|
148
|
+
const text = message.textContent?.trim() ?? "";
|
|
149
|
+
this.dispatch("show", { target: message, detail: { type, message: text } });
|
|
150
|
+
if (bridge && text) {
|
|
151
|
+
window.dispatchEvent(
|
|
152
|
+
new CustomEvent("stimeo--announcer:announce", { detail: { message: text, assertive } })
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
this.#startTimer(message);
|
|
156
|
+
this.#enforceMax();
|
|
157
|
+
}
|
|
158
|
+
/** Removes the oldest visible flashes once the count exceeds `max` (0 = unlimited). */
|
|
159
|
+
#enforceMax() {
|
|
160
|
+
if (this.maxValue <= 0) return;
|
|
161
|
+
while (this.#order.length > this.maxValue) {
|
|
162
|
+
const oldest = this.#order[0];
|
|
163
|
+
if (!oldest) break;
|
|
164
|
+
this.#beginDismiss(oldest, "limit");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
#startTimer(message, duration = this.durationValue) {
|
|
168
|
+
if (duration <= 0) return;
|
|
169
|
+
const existing = this.#state.get(message);
|
|
170
|
+
if (existing?.id) this.#timers.clear(existing.id);
|
|
171
|
+
const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
|
|
172
|
+
this.#state.set(message, { id, startedAt: Date.now(), remaining: duration });
|
|
173
|
+
}
|
|
174
|
+
/** Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1). */
|
|
175
|
+
#pause(message) {
|
|
176
|
+
const timer = this.#state.get(message);
|
|
177
|
+
if (!timer || timer.id === 0) return;
|
|
178
|
+
this.#timers.clear(timer.id);
|
|
179
|
+
const remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));
|
|
180
|
+
this.#state.set(message, { id: 0, startedAt: 0, remaining });
|
|
181
|
+
}
|
|
182
|
+
/** Resumes a paused message's auto-dismiss with the banked time. */
|
|
183
|
+
#resume(message) {
|
|
184
|
+
const timer = this.#state.get(message);
|
|
185
|
+
if (!timer) return;
|
|
186
|
+
if (timer.id !== 0 || timer.remaining <= 0) return;
|
|
187
|
+
this.#startTimer(message, timer.remaining);
|
|
188
|
+
}
|
|
189
|
+
/** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
|
|
190
|
+
#beginDismiss(message, reason) {
|
|
191
|
+
const timer = this.#state.get(message);
|
|
192
|
+
if (timer?.id) this.#timers.clear(timer.id);
|
|
193
|
+
this.#state.delete(message);
|
|
194
|
+
const index = this.#order.indexOf(message);
|
|
195
|
+
if (index !== -1) this.#order.splice(index, 1);
|
|
196
|
+
message.setAttribute("data-flash-state", "leaving");
|
|
197
|
+
const finalize = () => {
|
|
198
|
+
this.#unbindPause(message);
|
|
199
|
+
message.remove();
|
|
200
|
+
this.dispatch("dismiss", { detail: { element: message, reason } });
|
|
201
|
+
};
|
|
202
|
+
const transition = this.#transitionMs(message);
|
|
203
|
+
if (transition > 0) {
|
|
204
|
+
this.#timers.set(finalize, transition);
|
|
205
|
+
} else {
|
|
206
|
+
finalize();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** First `transition-duration` of `el` in ms (0 when none / unsupported). */
|
|
210
|
+
#transitionMs(el) {
|
|
211
|
+
if (typeof window.getComputedStyle !== "function") return 0;
|
|
212
|
+
const first = window.getComputedStyle(el).transitionDuration.split(",")[0]?.trim() ?? "";
|
|
213
|
+
const amount = Number.parseFloat(first);
|
|
214
|
+
if (Number.isNaN(amount)) return 0;
|
|
215
|
+
return first.endsWith("ms") ? amount : amount * 1e3;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export { FlashController };
|
|
220
|
+
//# sourceMappingURL=flash_controller.js.map
|
|
221
|
+
//# sourceMappingURL=flash_controller.js.map
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/focus_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/focus_controller.ts
|
|
162
|
+
var FocusController = class extends Controller {
|
|
163
|
+
static targets = ["initial"];
|
|
164
|
+
static values = {
|
|
165
|
+
trap: { type: Boolean, default: false },
|
|
166
|
+
auto: { type: Boolean, default: true },
|
|
167
|
+
restore: { type: Boolean, default: true },
|
|
168
|
+
inert: { type: Boolean, default: false }
|
|
169
|
+
};
|
|
170
|
+
static actions = ["activate", "deactivate"];
|
|
171
|
+
static events = ["activate", "deactivate"];
|
|
172
|
+
#trap = new FocusTrap(() => this.element, {
|
|
173
|
+
// A focus scope is not a modal: never lock scroll, and only isolate the background
|
|
174
|
+
// when `inert` is requested. `auto` gates the initial focus move; Escape releases.
|
|
175
|
+
lockScroll: false,
|
|
176
|
+
isolate: () => this.inertValue,
|
|
177
|
+
autoFocus: () => this.autoValue,
|
|
178
|
+
initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
|
|
179
|
+
onEscape: () => this.deactivate()
|
|
180
|
+
});
|
|
181
|
+
/** Stimulus drives activation from the `trap` value (also fires on connect). */
|
|
182
|
+
trapValueChanged() {
|
|
183
|
+
if (this.trapValue) this.#activate();
|
|
184
|
+
else this.#deactivate();
|
|
185
|
+
}
|
|
186
|
+
disconnect() {
|
|
187
|
+
this.#trap.deactivate({ restoreFocus: false });
|
|
188
|
+
this.element.removeAttribute("data-focus-trapped");
|
|
189
|
+
}
|
|
190
|
+
/** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
|
|
191
|
+
activate() {
|
|
192
|
+
this.trapValue = true;
|
|
193
|
+
this.#activate();
|
|
194
|
+
}
|
|
195
|
+
/** Turns the trap off (also wired to Escape). */
|
|
196
|
+
deactivate() {
|
|
197
|
+
this.trapValue = false;
|
|
198
|
+
this.#deactivate();
|
|
199
|
+
}
|
|
200
|
+
#activate() {
|
|
201
|
+
if (this.#trap.active) return;
|
|
202
|
+
this.#trap.activate();
|
|
203
|
+
this.element.setAttribute("data-focus-trapped", "true");
|
|
204
|
+
this.dispatch("activate", { detail: {} });
|
|
205
|
+
}
|
|
206
|
+
#deactivate() {
|
|
207
|
+
if (!this.#trap.active) return;
|
|
208
|
+
this.#trap.deactivate({ restoreFocus: this.restoreValue });
|
|
209
|
+
this.element.removeAttribute("data-focus-trapped");
|
|
210
|
+
this.dispatch("deactivate", { detail: {} });
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export { FocusController };
|
|
215
|
+
//# sourceMappingURL=focus_controller.js.map
|
|
216
|
+
//# sourceMappingURL=focus_controller.js.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/form_field_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/controllers/form_field_controller.ts
|
|
23
|
+
var FormFieldController = class _FormFieldController extends Controller {
|
|
24
|
+
static targets = ["control", "description", "error"];
|
|
25
|
+
static values = {
|
|
26
|
+
focusOnError: { type: Boolean, default: false }
|
|
27
|
+
};
|
|
28
|
+
static actions = ["clearError", "setError"];
|
|
29
|
+
static events = ["validate"];
|
|
30
|
+
/** Root attribute (CSS hook) reflecting the invalid state. */
|
|
31
|
+
static #INVALID_ATTR = "data-stimeo--form-field-invalid";
|
|
32
|
+
/**
|
|
33
|
+
* `aria-describedby` tokens the consumer set on the control that the
|
|
34
|
+
* controller does not own. Captured once so composition never clobbers them.
|
|
35
|
+
*/
|
|
36
|
+
#baseDescribedBy = [];
|
|
37
|
+
/** Wires ids, captures consumer tokens, and reflects any initial error state. */
|
|
38
|
+
connect() {
|
|
39
|
+
for (const description of this.descriptionTargets) {
|
|
40
|
+
ensureId(description, "stimeo--form-field-desc");
|
|
41
|
+
}
|
|
42
|
+
for (const error of this.errorTargets) {
|
|
43
|
+
ensureId(error, "stimeo--form-field-error");
|
|
44
|
+
}
|
|
45
|
+
this.#baseDescribedBy = this.#externalDescribedByTokens();
|
|
46
|
+
this.#reflect();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Marks the field invalid and shows the error message. Bound via `data-action`
|
|
50
|
+
* (`#setError`) or callable directly.
|
|
51
|
+
*
|
|
52
|
+
* @param arg - Either the message string, or the action event whose
|
|
53
|
+
* `data-stimeo--form-field-message-param` supplies it. When no message is
|
|
54
|
+
* resolvable, any already-populated error targets are simply (re)shown.
|
|
55
|
+
*/
|
|
56
|
+
setError(arg) {
|
|
57
|
+
const message = this.#resolveMessage(arg);
|
|
58
|
+
if (message !== null && this.hasErrorTarget) {
|
|
59
|
+
this.errorTargets[0]?.replaceChildren(document.createTextNode(message));
|
|
60
|
+
}
|
|
61
|
+
for (const error of this.errorTargets) {
|
|
62
|
+
error.hidden = (error.textContent ?? "").trim() === "";
|
|
63
|
+
}
|
|
64
|
+
this.#reflect(true);
|
|
65
|
+
this.dispatch("validate", { detail: { valid: false, message: this.#shownMessage() } });
|
|
66
|
+
if (this.focusOnErrorValue && this.hasControlTarget) {
|
|
67
|
+
this.controlTarget.focus();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Clears the error: empties and hides every error target and marks the field
|
|
72
|
+
* valid. Bound via `data-action` (`#clearError`) or callable directly.
|
|
73
|
+
*/
|
|
74
|
+
clearError() {
|
|
75
|
+
for (const error of this.errorTargets) {
|
|
76
|
+
error.replaceChildren();
|
|
77
|
+
error.hidden = true;
|
|
78
|
+
}
|
|
79
|
+
this.#reflect();
|
|
80
|
+
this.dispatch("validate", { detail: { valid: true, message: "" } });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Synchronizes the control's ARIA wiring and the root CSS hook from the current
|
|
84
|
+
* error targets. Idempotent, so it is safe to call on connect and after any
|
|
85
|
+
* change (and survives Turbo morphing).
|
|
86
|
+
*
|
|
87
|
+
* @param force - When `true`, the field is marked invalid regardless of whether
|
|
88
|
+
* a (visible, non-empty) error region exists. {@link setError} passes this so
|
|
89
|
+
* the invalid state holds even with no error target; derivation from the DOM
|
|
90
|
+
* (connect / {@link clearError}) leaves it `false`.
|
|
91
|
+
*/
|
|
92
|
+
#reflect(force = false) {
|
|
93
|
+
const shown = this.#shownErrors();
|
|
94
|
+
const invalid = force || shown.length > 0;
|
|
95
|
+
if (invalid) {
|
|
96
|
+
this.element.setAttribute(_FormFieldController.#INVALID_ATTR, "");
|
|
97
|
+
} else {
|
|
98
|
+
this.element.removeAttribute(_FormFieldController.#INVALID_ATTR);
|
|
99
|
+
}
|
|
100
|
+
if (!this.hasControlTarget) return;
|
|
101
|
+
const control = this.controlTarget;
|
|
102
|
+
control.setAttribute("aria-invalid", invalid ? "true" : "false");
|
|
103
|
+
const errorIds = shown.map((error) => error.id);
|
|
104
|
+
const primaryErrorId = errorIds[0];
|
|
105
|
+
if (primaryErrorId) {
|
|
106
|
+
control.setAttribute("aria-errormessage", primaryErrorId);
|
|
107
|
+
} else {
|
|
108
|
+
control.removeAttribute("aria-errormessage");
|
|
109
|
+
}
|
|
110
|
+
const describedBy = [
|
|
111
|
+
...this.#baseDescribedBy,
|
|
112
|
+
...this.descriptionTargets.map((description) => description.id),
|
|
113
|
+
...errorIds
|
|
114
|
+
];
|
|
115
|
+
if (describedBy.length > 0) {
|
|
116
|
+
control.setAttribute("aria-describedby", describedBy.join(" "));
|
|
117
|
+
} else {
|
|
118
|
+
control.removeAttribute("aria-describedby");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Error targets currently visible and non-empty. */
|
|
122
|
+
#shownErrors() {
|
|
123
|
+
return this.errorTargets.filter(
|
|
124
|
+
(error) => !error.hidden && (error.textContent ?? "").trim() !== ""
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
/** Text of the first shown error, for the `validate` event detail. */
|
|
128
|
+
#shownMessage() {
|
|
129
|
+
return (this.#shownErrors()[0]?.textContent ?? "").trim();
|
|
130
|
+
}
|
|
131
|
+
/** Resolves a message from a string argument or an action event's params. */
|
|
132
|
+
#resolveMessage(arg) {
|
|
133
|
+
if (typeof arg === "string") return arg;
|
|
134
|
+
const message = arg?.params?.message;
|
|
135
|
+
return typeof message === "string" ? message : null;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Tokens already in the control's `aria-describedby` that are not ids of this
|
|
139
|
+
* controller's own description/error targets.
|
|
140
|
+
*/
|
|
141
|
+
#externalDescribedByTokens() {
|
|
142
|
+
if (!this.hasControlTarget) return [];
|
|
143
|
+
const owned = /* @__PURE__ */ new Set([
|
|
144
|
+
...this.descriptionTargets.map((description) => description.id),
|
|
145
|
+
...this.errorTargets.map((error) => error.id)
|
|
146
|
+
]);
|
|
147
|
+
const existing = this.controlTarget.getAttribute("aria-describedby") ?? "";
|
|
148
|
+
return existing.split(/\s+/).filter((token) => token.length > 0 && !owned.has(token));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export { FormFieldController };
|
|
153
|
+
//# sourceMappingURL=form_field_controller.js.map
|
|
154
|
+
//# sourceMappingURL=form_field_controller.js.map
|