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,202 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/form_validation_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
|
+
|
|
8
|
+
// src/controllers/form_validation_controller.ts
|
|
9
|
+
var FormValidationController = class _FormValidationController extends Controller {
|
|
10
|
+
static outlets = ["stimeo--form-field"];
|
|
11
|
+
static values = {
|
|
12
|
+
validateOnBlur: { type: Boolean, default: true },
|
|
13
|
+
validateOnChange: { type: Boolean, default: true },
|
|
14
|
+
revalidateOnInput: { type: Boolean, default: true },
|
|
15
|
+
focusInvalid: { type: Boolean, default: true }
|
|
16
|
+
};
|
|
17
|
+
static actions = ["validate"];
|
|
18
|
+
static events = ["valid", "invalid"];
|
|
19
|
+
/** Marker recording that we added `novalidate`, so we only remove our own. */
|
|
20
|
+
static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
|
|
21
|
+
/** Controls already interacted with — the gate for blur / input (re)validation. */
|
|
22
|
+
#touched = /* @__PURE__ */ new WeakSet();
|
|
23
|
+
#onSubmit = (event) => {
|
|
24
|
+
if (event.target !== this.element) return;
|
|
25
|
+
const invalid = this.#validateAll();
|
|
26
|
+
if (invalid.length === 0) {
|
|
27
|
+
this.dispatch("valid", { detail: {} });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
event.stopImmediatePropagation();
|
|
32
|
+
const first = invalid[0];
|
|
33
|
+
if (this.focusInvalidValue && first) this.#focusTargetFor(first)?.focus();
|
|
34
|
+
this.dispatch("invalid", { detail: { invalid } });
|
|
35
|
+
};
|
|
36
|
+
#onFocusOut = (event) => {
|
|
37
|
+
if (!this.validateOnBlurValue) return;
|
|
38
|
+
const control = this.#controlFrom(event.target);
|
|
39
|
+
if (!control) return;
|
|
40
|
+
const field = this.#fieldFor(control);
|
|
41
|
+
const related = event.relatedTarget;
|
|
42
|
+
if (field && related instanceof Node && field.element.contains(related)) return;
|
|
43
|
+
this.#touched.add(control);
|
|
44
|
+
this.#validateControl(control);
|
|
45
|
+
};
|
|
46
|
+
#onInput = (event) => {
|
|
47
|
+
if (!this.revalidateOnInputValue) return;
|
|
48
|
+
const control = this.#controlFrom(event.target);
|
|
49
|
+
if (!control || !this.#touched.has(control)) return;
|
|
50
|
+
this.#validateControl(control);
|
|
51
|
+
};
|
|
52
|
+
#onChange = (event) => {
|
|
53
|
+
if (!this.validateOnChangeValue) return;
|
|
54
|
+
const control = this.#controlFrom(event.target);
|
|
55
|
+
if (!control) return;
|
|
56
|
+
this.#touched.add(control);
|
|
57
|
+
this.#validateControl(control);
|
|
58
|
+
};
|
|
59
|
+
/** Suppresses native bubbles and binds the submit / blur / input listeners. */
|
|
60
|
+
connect() {
|
|
61
|
+
if (!this.element.hasAttribute("novalidate")) {
|
|
62
|
+
this.element.setAttribute("novalidate", "");
|
|
63
|
+
this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
|
|
64
|
+
}
|
|
65
|
+
document.addEventListener("submit", this.#onSubmit, true);
|
|
66
|
+
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
67
|
+
this.element.addEventListener("input", this.#onInput);
|
|
68
|
+
this.element.addEventListener("change", this.#onChange);
|
|
69
|
+
}
|
|
70
|
+
/** Tears down listeners and restores `novalidate` if we added it. */
|
|
71
|
+
disconnect() {
|
|
72
|
+
document.removeEventListener("submit", this.#onSubmit, true);
|
|
73
|
+
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
74
|
+
this.element.removeEventListener("input", this.#onInput);
|
|
75
|
+
this.element.removeEventListener("change", this.#onChange);
|
|
76
|
+
if (this.element.hasAttribute(_FormValidationController.#NOVALIDATE_MARKER)) {
|
|
77
|
+
this.element.removeAttribute("novalidate");
|
|
78
|
+
this.element.removeAttribute(_FormValidationController.#NOVALIDATE_MARKER);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Validates every control now, rendering or clearing each field's message, and
|
|
83
|
+
* returns whether the whole form is valid. Marks every control touched so a
|
|
84
|
+
* later input re-validates it. Bound via `data-action`
|
|
85
|
+
* (`#validate`) or callable directly (e.g. before a programmatic submit).
|
|
86
|
+
*/
|
|
87
|
+
validate() {
|
|
88
|
+
return this.#validateAll().length === 0;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Validates every control and returns one invalid control per field. Controls
|
|
92
|
+
* are grouped by field first (see {@link #keyFor}) so a field with several
|
|
93
|
+
* controls — a radio group, or a mirror plus its visible control — reflects
|
|
94
|
+
* *all* of them: a valid sibling must never clear an invalid one's message.
|
|
95
|
+
* Each group's first invalid control supplies the message and the focus target.
|
|
96
|
+
*/
|
|
97
|
+
#validateAll() {
|
|
98
|
+
const groups = /* @__PURE__ */ new Map();
|
|
99
|
+
for (const control of this.#controls) {
|
|
100
|
+
this.#touched.add(control);
|
|
101
|
+
const field = this.#fieldFor(control);
|
|
102
|
+
const key = this.#keyFor(control, field);
|
|
103
|
+
const group = groups.get(key);
|
|
104
|
+
if (group) {
|
|
105
|
+
group.controls.push(control);
|
|
106
|
+
} else {
|
|
107
|
+
groups.set(key, { field, controls: [control] });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const invalid = [];
|
|
111
|
+
for (const group of groups.values()) {
|
|
112
|
+
const firstInvalid = this.#applyGroup(group);
|
|
113
|
+
if (firstInvalid) invalid.push(firstInvalid);
|
|
114
|
+
}
|
|
115
|
+
return invalid;
|
|
116
|
+
}
|
|
117
|
+
/** Re-validates the whole field a single control belongs to (or that control). */
|
|
118
|
+
#validateControl(control) {
|
|
119
|
+
const field = this.#fieldFor(control);
|
|
120
|
+
const key = this.#keyFor(control, field);
|
|
121
|
+
const controls = this.#controls.filter(
|
|
122
|
+
(other) => this.#keyFor(other, this.#fieldFor(other)) === key
|
|
123
|
+
);
|
|
124
|
+
this.#applyGroup({ field, controls });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Runs native constraint validation across a field's controls and routes the
|
|
128
|
+
* result to its `stimeo--form-field` outlet: the first invalid control's
|
|
129
|
+
* `validationMessage` is shown, an all-valid field is cleared. Returns the
|
|
130
|
+
* first invalid control (for the invalid list / focus), or `null` when valid.
|
|
131
|
+
* Routing goes through the outlet, so the ARIA wiring is never duplicated here.
|
|
132
|
+
*/
|
|
133
|
+
#applyGroup(group) {
|
|
134
|
+
const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;
|
|
135
|
+
if (group.field) {
|
|
136
|
+
if (firstInvalid) {
|
|
137
|
+
group.field.setError(firstInvalid.validationMessage);
|
|
138
|
+
} else {
|
|
139
|
+
group.field.clearError();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return firstInvalid;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A grouping key that collects controls belonging to the same field: the owning
|
|
146
|
+
* `stimeo--form-field` when present, else a radio group's shared `name`, else
|
|
147
|
+
* the control itself (always distinct).
|
|
148
|
+
*/
|
|
149
|
+
#keyFor(control, field) {
|
|
150
|
+
if (field) return field;
|
|
151
|
+
if (control instanceof HTMLInputElement && control.type === "radio" && control.name) {
|
|
152
|
+
return `radio:${control.name}`;
|
|
153
|
+
}
|
|
154
|
+
return control;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Where focus should land for an invalid control. A visible control is focused
|
|
158
|
+
* directly (status quo for native fields and radios). A validatable mirror
|
|
159
|
+
* (the `hidden` attribute) cannot receive focus, so focus is delegated to the
|
|
160
|
+
* visible widget: the owning field's `control` target when it is itself
|
|
161
|
+
* focusable, else its first focusable descendant (e.g. a roving-tabindex
|
|
162
|
+
* member). Resolved structurally — never by probing `focus()` — so behavior
|
|
163
|
+
* is deterministic and CSS-independent.
|
|
164
|
+
*/
|
|
165
|
+
#focusTargetFor(control) {
|
|
166
|
+
if (!control.hidden) return control;
|
|
167
|
+
const field = this.#fieldFor(control);
|
|
168
|
+
if (!field?.hasControlTarget) return null;
|
|
169
|
+
const root = field.controlTarget;
|
|
170
|
+
if (root.matches(FOCUSABLE)) return root;
|
|
171
|
+
return root.querySelector(FOCUSABLE);
|
|
172
|
+
}
|
|
173
|
+
/** The `stimeo--form-field` outlet whose element contains `control`, if any. */
|
|
174
|
+
#fieldFor(control) {
|
|
175
|
+
const elements = this.stimeoFormFieldOutletElements;
|
|
176
|
+
for (let index = 0; index < elements.length; index++) {
|
|
177
|
+
if (elements[index]?.contains(control)) return this.stimeoFormFieldOutlets[index];
|
|
178
|
+
}
|
|
179
|
+
return void 0;
|
|
180
|
+
}
|
|
181
|
+
/** This form's native controls that participate in constraint validation. */
|
|
182
|
+
get #controls() {
|
|
183
|
+
const controls = [];
|
|
184
|
+
for (const element of Array.from(this.element.elements)) {
|
|
185
|
+
if (this.#isValidatable(element)) controls.push(element);
|
|
186
|
+
}
|
|
187
|
+
return controls;
|
|
188
|
+
}
|
|
189
|
+
/** Narrows an event target to a validatable control. */
|
|
190
|
+
#controlFrom(target) {
|
|
191
|
+
return target instanceof Element && this.#isValidatable(target) ? target : null;
|
|
192
|
+
}
|
|
193
|
+
#isValidatable(element) {
|
|
194
|
+
return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, hidden, and button
|
|
195
|
+
// controls — the exact set barred from constraint validation.
|
|
196
|
+
element.willValidate;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export { FormValidationController };
|
|
201
|
+
//# sourceMappingURL=form_validation_controller.js.map
|
|
202
|
+
//# sourceMappingURL=form_validation_controller.js.map
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/frame_loading_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/frame_loading_controller.ts
|
|
59
|
+
var FrameLoadingController = class extends Controller {
|
|
60
|
+
static targets = ["content", "skeleton", "overlay"];
|
|
61
|
+
static values = {
|
|
62
|
+
minDuration: { type: Number, default: 0 },
|
|
63
|
+
restoreFocus: { type: Boolean, default: true }
|
|
64
|
+
};
|
|
65
|
+
static events = ["start", "end"];
|
|
66
|
+
#timeouts = new SafeTimeout();
|
|
67
|
+
#loading = false;
|
|
68
|
+
#startedAt = 0;
|
|
69
|
+
#inertApplied = false;
|
|
70
|
+
#previousFocus = null;
|
|
71
|
+
/** The id of the retreated element, used to re-find it if the load replaced it. */
|
|
72
|
+
#previousFocusId = "";
|
|
73
|
+
#onStart = () => {
|
|
74
|
+
this.#timeouts.clearAll();
|
|
75
|
+
if (!this.#loading) this.#begin();
|
|
76
|
+
};
|
|
77
|
+
#onEnd = () => {
|
|
78
|
+
if (!this.#loading) return;
|
|
79
|
+
const remaining = this.minDurationValue - (Date.now() - this.#startedAt);
|
|
80
|
+
if (remaining > 0) {
|
|
81
|
+
this.#timeouts.clearAll();
|
|
82
|
+
this.#timeouts.set(() => this.#finish(), remaining);
|
|
83
|
+
} else {
|
|
84
|
+
this.#finish();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
connect() {
|
|
88
|
+
this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
|
|
89
|
+
this.element.addEventListener("turbo:frame-load", this.#onEnd);
|
|
90
|
+
this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
|
|
91
|
+
}
|
|
92
|
+
disconnect() {
|
|
93
|
+
this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
|
|
94
|
+
this.element.removeEventListener("turbo:frame-load", this.#onEnd);
|
|
95
|
+
this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
|
|
96
|
+
this.#timeouts.clearAll();
|
|
97
|
+
if (this.#loading) {
|
|
98
|
+
this.element.removeAttribute("aria-busy");
|
|
99
|
+
this.element.removeAttribute("data-frame-loading");
|
|
100
|
+
this.#clearInert();
|
|
101
|
+
}
|
|
102
|
+
this.#loading = false;
|
|
103
|
+
this.#previousFocus = null;
|
|
104
|
+
}
|
|
105
|
+
/** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
|
|
106
|
+
#begin() {
|
|
107
|
+
this.#loading = true;
|
|
108
|
+
this.#startedAt = Date.now();
|
|
109
|
+
this.element.setAttribute("aria-busy", "true");
|
|
110
|
+
this.element.setAttribute("data-frame-loading", "true");
|
|
111
|
+
if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
|
|
112
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
|
|
113
|
+
this.#applyInert();
|
|
114
|
+
this.#retreatFocus();
|
|
115
|
+
this.dispatch("start", { detail: {} });
|
|
116
|
+
}
|
|
117
|
+
/** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
|
|
118
|
+
#finish() {
|
|
119
|
+
this.#loading = false;
|
|
120
|
+
this.element.removeAttribute("aria-busy");
|
|
121
|
+
this.element.removeAttribute("data-frame-loading");
|
|
122
|
+
if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
|
|
123
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
|
|
124
|
+
this.#clearInert();
|
|
125
|
+
this.#restoreFocus();
|
|
126
|
+
this.dispatch("end", { detail: {} });
|
|
127
|
+
}
|
|
128
|
+
/** Marks the content inert to block double-submits while stale (if we own it). */
|
|
129
|
+
#applyInert() {
|
|
130
|
+
if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
|
|
131
|
+
this.contentTarget.setAttribute("inert", "");
|
|
132
|
+
this.#inertApplied = true;
|
|
133
|
+
}
|
|
134
|
+
#clearInert() {
|
|
135
|
+
if (!this.#inertApplied) return;
|
|
136
|
+
this.#inertApplied = false;
|
|
137
|
+
if (this.hasContentTarget) this.contentTarget.removeAttribute("inert");
|
|
138
|
+
}
|
|
139
|
+
/** Saves and blurs focus if it sits inside the frame about to go stale. */
|
|
140
|
+
#retreatFocus() {
|
|
141
|
+
this.#previousFocus = null;
|
|
142
|
+
this.#previousFocusId = "";
|
|
143
|
+
if (!this.restoreFocusValue) return;
|
|
144
|
+
const active = document.activeElement;
|
|
145
|
+
if (active instanceof HTMLElement && active !== this.element && this.element.contains(active)) {
|
|
146
|
+
this.#previousFocus = active;
|
|
147
|
+
this.#previousFocusId = active.id;
|
|
148
|
+
active.blur();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Restores focus after the load. The same node when it survived (e.g. a
|
|
153
|
+
* non-replacing update), else the element re-rendered with the same id inside the
|
|
154
|
+
* frame — Turbo frames typically re-emit the same controls. When neither is present
|
|
155
|
+
* (an anonymous control was replaced) focus is left where the browser put it, to
|
|
156
|
+
* avoid an unexpected jump (WCAG 3.2.x).
|
|
157
|
+
*/
|
|
158
|
+
#restoreFocus() {
|
|
159
|
+
const target = this.#previousFocus;
|
|
160
|
+
const id = this.#previousFocusId;
|
|
161
|
+
this.#previousFocus = null;
|
|
162
|
+
this.#previousFocusId = "";
|
|
163
|
+
if (!this.restoreFocusValue) return;
|
|
164
|
+
if (target?.isConnected) {
|
|
165
|
+
target.focus();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (id) {
|
|
169
|
+
const replacement = document.getElementById(id);
|
|
170
|
+
if (replacement && this.element.contains(replacement)) replacement.focus();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export { FrameLoadingController };
|
|
176
|
+
//# sourceMappingURL=frame_loading_controller.js.map
|
|
177
|
+
//# sourceMappingURL=frame_loading_controller.js.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/highlight_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/highlight_controller.ts
|
|
59
|
+
var HighlightController = class extends Controller {
|
|
60
|
+
static values = {
|
|
61
|
+
duration: { type: Number, default: 1500 },
|
|
62
|
+
observe: { type: Boolean, default: false }
|
|
63
|
+
};
|
|
64
|
+
static events = ["start", "end"];
|
|
65
|
+
#timeouts = new SafeTimeout();
|
|
66
|
+
#observer = null;
|
|
67
|
+
connect() {
|
|
68
|
+
if (this.observeValue) {
|
|
69
|
+
if (typeof MutationObserver !== "undefined") {
|
|
70
|
+
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
71
|
+
this.#observer.observe(this.element, { childList: true });
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.#highlight(this.element);
|
|
76
|
+
}
|
|
77
|
+
disconnect() {
|
|
78
|
+
this.#observer?.disconnect();
|
|
79
|
+
this.#observer = null;
|
|
80
|
+
this.#timeouts.clearAll();
|
|
81
|
+
}
|
|
82
|
+
/** Highlights every element child added by a childList mutation. */
|
|
83
|
+
#onMutations(mutations) {
|
|
84
|
+
for (const mutation of mutations) {
|
|
85
|
+
for (const node of mutation.addedNodes) {
|
|
86
|
+
if (node instanceof HTMLElement) this.#highlight(node);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
|
|
91
|
+
#highlight(el) {
|
|
92
|
+
if (this.#prefersReducedMotion()) return;
|
|
93
|
+
el.setAttribute("data-highlight", "true");
|
|
94
|
+
this.dispatch("start", { target: el, detail: { element: el } });
|
|
95
|
+
this.#timeouts.set(() => {
|
|
96
|
+
el.removeAttribute("data-highlight");
|
|
97
|
+
this.dispatch("end", { target: el, detail: { element: el } });
|
|
98
|
+
}, this.durationValue);
|
|
99
|
+
}
|
|
100
|
+
#prefersReducedMotion() {
|
|
101
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export { HighlightController };
|
|
106
|
+
//# sourceMappingURL=highlight_controller.js.map
|
|
107
|
+
//# sourceMappingURL=highlight_controller.js.map
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/hover_card_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/hover_card_controller.ts
|
|
59
|
+
var HoverCardController = class extends Controller {
|
|
60
|
+
static targets = ["trigger", "card"];
|
|
61
|
+
static values = {
|
|
62
|
+
openDelay: { type: Number, default: 300 },
|
|
63
|
+
closeDelay: { type: Number, default: 200 }
|
|
64
|
+
};
|
|
65
|
+
static actions = ["close", "onKeydown", "open"];
|
|
66
|
+
/** Pending open/close timers, torn down together on disconnect. */
|
|
67
|
+
#timers = new SafeTimeout();
|
|
68
|
+
#pendingOpen = null;
|
|
69
|
+
#pendingClose = null;
|
|
70
|
+
/** Starts closed. */
|
|
71
|
+
connect() {
|
|
72
|
+
this.#conceal();
|
|
73
|
+
}
|
|
74
|
+
/** Clears timers and the document `Escape` listener so nothing outlives the element. */
|
|
75
|
+
disconnect() {
|
|
76
|
+
this.#timers.clearAll();
|
|
77
|
+
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
78
|
+
}
|
|
79
|
+
/** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */
|
|
80
|
+
open() {
|
|
81
|
+
this.#cancelClose();
|
|
82
|
+
if (this.#isOpen || this.#pendingOpen !== null) return;
|
|
83
|
+
if (this.openDelayValue <= 0) {
|
|
84
|
+
this.#reveal();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.#pendingOpen = this.#timers.set(() => {
|
|
88
|
+
this.#pendingOpen = null;
|
|
89
|
+
this.#reveal();
|
|
90
|
+
}, this.openDelayValue);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Schedules the card to close after `closeDelay`. Cancels a pending open. The
|
|
94
|
+
* delayed callback re-checks whether focus has landed inside the controller
|
|
95
|
+
* (e.g. a link in the card) and, if so, aborts the close — covering keyboard
|
|
96
|
+
* traversal that the pointer-only hoverable bridge cannot.
|
|
97
|
+
*/
|
|
98
|
+
close() {
|
|
99
|
+
this.#cancelOpen();
|
|
100
|
+
if (!this.#isOpen || this.#pendingClose !== null) return;
|
|
101
|
+
this.#pendingClose = this.#timers.set(() => {
|
|
102
|
+
this.#pendingClose = null;
|
|
103
|
+
if (this.element.contains(document.activeElement)) return;
|
|
104
|
+
this.#conceal();
|
|
105
|
+
}, this.closeDelayValue);
|
|
106
|
+
}
|
|
107
|
+
/** Closes immediately on `Escape` while open (keyboard dismissal from the trigger). */
|
|
108
|
+
onKeydown(event) {
|
|
109
|
+
if (event.key === "Escape" && this.#isOpen) {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
this.#dismiss();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Reveals the card, reflects state, and starts watching for a dismissing `Escape`. */
|
|
115
|
+
#reveal() {
|
|
116
|
+
if (!this.hasCardTarget) return;
|
|
117
|
+
this.cardTarget.hidden = false;
|
|
118
|
+
this.cardTarget.setAttribute("data-state", "open");
|
|
119
|
+
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
120
|
+
document.addEventListener("keydown", this.#onDocumentKeydown);
|
|
121
|
+
}
|
|
122
|
+
/** Hides the card, reflects state, and stops watching for `Escape`. */
|
|
123
|
+
#conceal() {
|
|
124
|
+
if (!this.hasCardTarget) return;
|
|
125
|
+
this.cardTarget.hidden = true;
|
|
126
|
+
this.cardTarget.setAttribute("data-state", "closed");
|
|
127
|
+
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
128
|
+
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
129
|
+
}
|
|
130
|
+
/** Cancels pending timers and conceals immediately (shared Escape path). */
|
|
131
|
+
#dismiss() {
|
|
132
|
+
this.#cancelOpen();
|
|
133
|
+
this.#cancelClose();
|
|
134
|
+
this.#conceal();
|
|
135
|
+
}
|
|
136
|
+
/** Document-level `Escape` watcher (active only while open). */
|
|
137
|
+
#onDocumentKeydown = (event) => {
|
|
138
|
+
if (event.key === "Escape") {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
this.#dismiss();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
/** Cancels any pending open timer. */
|
|
144
|
+
#cancelOpen() {
|
|
145
|
+
if (this.#pendingOpen !== null) {
|
|
146
|
+
this.#timers.clear(this.#pendingOpen);
|
|
147
|
+
this.#pendingOpen = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Cancels any pending close timer. */
|
|
151
|
+
#cancelClose() {
|
|
152
|
+
if (this.#pendingClose !== null) {
|
|
153
|
+
this.#timers.clear(this.#pendingClose);
|
|
154
|
+
this.#pendingClose = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Whether the card is currently visible. */
|
|
158
|
+
get #isOpen() {
|
|
159
|
+
return this.hasCardTarget && !this.cardTarget.hidden;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export { HoverCardController };
|
|
164
|
+
//# sourceMappingURL=hover_card_controller.js.map
|
|
165
|
+
//# sourceMappingURL=hover_card_controller.js.map
|