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,141 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/idle_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/idle_controller.ts
|
|
59
|
+
var IdleController = class extends Controller {
|
|
60
|
+
static values = {
|
|
61
|
+
timeout: { type: Number, default: 9e5 },
|
|
62
|
+
promptBefore: { type: Number, default: 0 },
|
|
63
|
+
events: {
|
|
64
|
+
type: Array,
|
|
65
|
+
default: ["mousemove", "mousedown", "keydown", "wheel", "touchstart", "scroll"]
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
static events = ["prompt", "idle", "active"];
|
|
69
|
+
#timeouts = new SafeTimeout();
|
|
70
|
+
#idle = false;
|
|
71
|
+
#prompted = false;
|
|
72
|
+
/** Timestamp of the last activity; the timers self-reschedule against it. */
|
|
73
|
+
#lastActivity = 0;
|
|
74
|
+
#onActivity = () => {
|
|
75
|
+
this.#lastActivity = Date.now();
|
|
76
|
+
if (this.#idle || this.#prompted) {
|
|
77
|
+
this.#idle = false;
|
|
78
|
+
this.#prompted = false;
|
|
79
|
+
this.element.removeAttribute("data-idle");
|
|
80
|
+
this.dispatch("active", { detail: {} });
|
|
81
|
+
this.#arm();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
#onVisibility = () => {
|
|
85
|
+
if (document.visibilityState === "visible") this.#onActivity();
|
|
86
|
+
};
|
|
87
|
+
connect() {
|
|
88
|
+
for (const type of this.eventsValue) {
|
|
89
|
+
document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
|
|
90
|
+
}
|
|
91
|
+
document.addEventListener("visibilitychange", this.#onVisibility);
|
|
92
|
+
this.#arm();
|
|
93
|
+
}
|
|
94
|
+
disconnect() {
|
|
95
|
+
for (const type of this.eventsValue) {
|
|
96
|
+
document.removeEventListener(type, this.#onActivity, { capture: true });
|
|
97
|
+
}
|
|
98
|
+
document.removeEventListener("visibilitychange", this.#onVisibility);
|
|
99
|
+
this.#timeouts.clearAll();
|
|
100
|
+
}
|
|
101
|
+
/** Schedules the prompt and idle checks from the current activity baseline. */
|
|
102
|
+
#arm() {
|
|
103
|
+
this.#timeouts.clearAll();
|
|
104
|
+
this.#lastActivity = Date.now();
|
|
105
|
+
const { promptBeforeValue: prompt, timeoutValue: timeout } = this;
|
|
106
|
+
if (prompt > 0 && prompt < timeout) {
|
|
107
|
+
this.#timeouts.set(() => this.#checkPrompt(), timeout - prompt);
|
|
108
|
+
}
|
|
109
|
+
this.#timeouts.set(() => this.#checkIdle(), timeout);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Idle-timer callback: go idle only if there has genuinely been no activity for
|
|
113
|
+
* `timeout`; otherwise reschedule for the remaining time. This lets activity events
|
|
114
|
+
* stay O(1) (a timestamp write) while the deadline still tracks the last activity.
|
|
115
|
+
*/
|
|
116
|
+
#checkIdle() {
|
|
117
|
+
const remaining = this.timeoutValue - (Date.now() - this.#lastActivity);
|
|
118
|
+
if (remaining > 0) {
|
|
119
|
+
this.#timeouts.set(() => this.#checkIdle(), remaining);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.#idle = true;
|
|
123
|
+
this.#prompted = false;
|
|
124
|
+
this.element.setAttribute("data-idle", "true");
|
|
125
|
+
this.dispatch("idle", { detail: {} });
|
|
126
|
+
}
|
|
127
|
+
/** Prompt-timer callback: warn at `promptBefore` before the idle deadline. */
|
|
128
|
+
#checkPrompt() {
|
|
129
|
+
const remaining = this.timeoutValue - this.promptBeforeValue - (Date.now() - this.#lastActivity);
|
|
130
|
+
if (remaining > 0) {
|
|
131
|
+
this.#timeouts.set(() => this.#checkPrompt(), remaining);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.#prompted = true;
|
|
135
|
+
this.dispatch("prompt", { detail: { remaining: this.promptBeforeValue } });
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export { IdleController };
|
|
140
|
+
//# sourceMappingURL=idle_controller.js.map
|
|
141
|
+
//# sourceMappingURL=idle_controller.js.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/input_mask_controller.ts
|
|
4
|
+
var DEFAULT_TOKENS = {
|
|
5
|
+
"9": "\\d",
|
|
6
|
+
a: "[A-Za-z]",
|
|
7
|
+
"*": "[A-Za-z0-9]"
|
|
8
|
+
};
|
|
9
|
+
var UNMASK_ATTR = "data-stimeo--input-mask-unmask";
|
|
10
|
+
function applyMask(value, pattern, tokens) {
|
|
11
|
+
let masked = "";
|
|
12
|
+
let unmasked = "";
|
|
13
|
+
const tokenFlags = [];
|
|
14
|
+
let valueIndex = 0;
|
|
15
|
+
let totalTokens = 0;
|
|
16
|
+
for (const patternChar of pattern) {
|
|
17
|
+
const regex = tokens.get(patternChar);
|
|
18
|
+
if (regex) totalTokens += 1;
|
|
19
|
+
if (valueIndex >= value.length) continue;
|
|
20
|
+
if (regex) {
|
|
21
|
+
while (valueIndex < value.length && !regex.test(value[valueIndex] ?? "")) valueIndex += 1;
|
|
22
|
+
const char = value[valueIndex];
|
|
23
|
+
if (char === void 0) continue;
|
|
24
|
+
masked += char;
|
|
25
|
+
unmasked += char;
|
|
26
|
+
tokenFlags.push(true);
|
|
27
|
+
valueIndex += 1;
|
|
28
|
+
} else {
|
|
29
|
+
masked += patternChar;
|
|
30
|
+
tokenFlags.push(false);
|
|
31
|
+
if (value[valueIndex] === patternChar) valueIndex += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
masked,
|
|
36
|
+
unmasked,
|
|
37
|
+
complete: totalTokens > 0 && unmasked.length === totalTokens,
|
|
38
|
+
tokenFlags
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
var InputMaskController = class extends Controller {
|
|
42
|
+
static values = {
|
|
43
|
+
pattern: { type: String, default: "" },
|
|
44
|
+
tokens: { type: Object, default: {} },
|
|
45
|
+
unmaskToHidden: { type: Boolean, default: true }
|
|
46
|
+
};
|
|
47
|
+
static actions = ["format"];
|
|
48
|
+
static events = ["change"];
|
|
49
|
+
connect() {
|
|
50
|
+
this.#apply();
|
|
51
|
+
}
|
|
52
|
+
/** Formats the field on input, preserving the caret. Bound via `data-action`. */
|
|
53
|
+
format() {
|
|
54
|
+
this.#apply();
|
|
55
|
+
}
|
|
56
|
+
/** Core reformat: mask the current value, restore the caret, sync, and announce. */
|
|
57
|
+
#apply() {
|
|
58
|
+
if (!this.patternValue) return;
|
|
59
|
+
const input = this.element;
|
|
60
|
+
const previous = input.value;
|
|
61
|
+
const caret = input.selectionStart ?? previous.length;
|
|
62
|
+
const tokens = this.#tokenRegexes();
|
|
63
|
+
const significant = this.#countSignificant(previous.slice(0, caret), tokens);
|
|
64
|
+
const result = applyMask(previous, this.patternValue, tokens);
|
|
65
|
+
input.value = result.masked;
|
|
66
|
+
this.#restoreCaret(input, result.tokenFlags, significant);
|
|
67
|
+
if (this.unmaskToHiddenValue) {
|
|
68
|
+
const unmask = this.#unmaskField();
|
|
69
|
+
if (unmask) unmask.value = result.unmasked;
|
|
70
|
+
}
|
|
71
|
+
this.#flag("data-mask-complete", result.complete);
|
|
72
|
+
this.#flag("data-mask-empty", result.masked.length === 0);
|
|
73
|
+
if (result.masked !== previous) {
|
|
74
|
+
this.dispatch("change", {
|
|
75
|
+
detail: { masked: result.masked, unmasked: result.unmasked, complete: result.complete }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Places the caret after the `n`-th token char (skipping following literals). */
|
|
80
|
+
#restoreCaret(input, tokenFlags, n) {
|
|
81
|
+
let position;
|
|
82
|
+
if (n <= 0) {
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < tokenFlags.length && !tokenFlags[i]) i += 1;
|
|
85
|
+
position = i;
|
|
86
|
+
} else {
|
|
87
|
+
let seen = 0;
|
|
88
|
+
position = tokenFlags.length;
|
|
89
|
+
for (let i = 0; i < tokenFlags.length; i += 1) {
|
|
90
|
+
if (!tokenFlags[i]) continue;
|
|
91
|
+
seen += 1;
|
|
92
|
+
if (seen === n) {
|
|
93
|
+
let j = i + 1;
|
|
94
|
+
while (j < tokenFlags.length && !tokenFlags[j]) j += 1;
|
|
95
|
+
position = j;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
input.setSelectionRange(position, position);
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The hidden raw-value sink for this input, resolved so several masked inputs
|
|
107
|
+
* can coexist in one form:
|
|
108
|
+
*
|
|
109
|
+
* 1. **Explicit pairing** — a sink whose attribute value names this input's
|
|
110
|
+
* `id` (`data-stimeo--input-mask-unmask="zip"`), looked up across the form
|
|
111
|
+
* (or the document when the input is form-less).
|
|
112
|
+
* 2. **Nearest container** — otherwise, walking up from the input (stopping at
|
|
113
|
+
* the form boundary), the first *value-less* sink in the closest ancestor.
|
|
114
|
+
* Wrapped input+sink pairs each find their own sink, and the single
|
|
115
|
+
* form-level sink keeps working unchanged. A sink claimed by another
|
|
116
|
+
* input's id is never matched here.
|
|
117
|
+
*/
|
|
118
|
+
#unmaskField() {
|
|
119
|
+
const id = this.element.id;
|
|
120
|
+
if (id.length > 0) {
|
|
121
|
+
const scope = this.element.form ?? document;
|
|
122
|
+
const quoted = id.replace(/["\\]/g, "\\$&");
|
|
123
|
+
const paired = scope.querySelector(`input[${UNMASK_ATTR}="${quoted}"]`);
|
|
124
|
+
if (paired) return paired;
|
|
125
|
+
}
|
|
126
|
+
for (let node = this.element.parentElement; node !== null; node = node.parentElement) {
|
|
127
|
+
const sink = node.querySelector(`input[${UNMASK_ATTR}=""]`);
|
|
128
|
+
if (sink) return sink;
|
|
129
|
+
if (node === this.element.form) break;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/** Counts characters in `text` that fill any token (the caret-significant chars). */
|
|
134
|
+
#countSignificant(text, tokens) {
|
|
135
|
+
let count = 0;
|
|
136
|
+
for (const char of text) {
|
|
137
|
+
for (const regex of tokens.values()) {
|
|
138
|
+
if (regex.test(char)) {
|
|
139
|
+
count += 1;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return count;
|
|
145
|
+
}
|
|
146
|
+
/** Compiles the effective token map (defaults with the user `tokens` merged over). */
|
|
147
|
+
#tokenRegexes() {
|
|
148
|
+
const map = /* @__PURE__ */ new Map();
|
|
149
|
+
for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...this.tokensValue })) {
|
|
150
|
+
try {
|
|
151
|
+
map.set(key, new RegExp(`^(?:${source})$`));
|
|
152
|
+
} catch {
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return map;
|
|
156
|
+
}
|
|
157
|
+
/** Sets a boolean `data-*` flag to `"true"` when `on`, else removes it. */
|
|
158
|
+
#flag(name, on) {
|
|
159
|
+
if (on) this.element.setAttribute(name, "true");
|
|
160
|
+
else this.element.removeAttribute(name);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export { InputMaskController, applyMask };
|
|
165
|
+
//# sourceMappingURL=input_mask_controller.js.map
|
|
166
|
+
//# sourceMappingURL=input_mask_controller.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/lazy_frame_controller.ts
|
|
4
|
+
var LazyFrameController = class extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
url: { type: String, default: "" },
|
|
7
|
+
rootMargin: { type: String, default: "0px" },
|
|
8
|
+
once: { type: Boolean, default: true }
|
|
9
|
+
};
|
|
10
|
+
static events = ["load"];
|
|
11
|
+
#observer = null;
|
|
12
|
+
#loaded = false;
|
|
13
|
+
/** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */
|
|
14
|
+
#onFocus = () => this.#trigger();
|
|
15
|
+
connect() {
|
|
16
|
+
if (this.element.hasAttribute("data-lazy-loaded")) {
|
|
17
|
+
this.#loaded = true;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!this.urlValue) return;
|
|
21
|
+
this.element.addEventListener("focusin", this.#onFocus);
|
|
22
|
+
if (typeof IntersectionObserver !== "undefined") {
|
|
23
|
+
this.#observer = new IntersectionObserver((entries) => this.#onIntersect(entries), {
|
|
24
|
+
rootMargin: this.rootMarginValue
|
|
25
|
+
});
|
|
26
|
+
this.#observer.observe(this.element);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.#stopObserving();
|
|
31
|
+
}
|
|
32
|
+
#onIntersect(entries) {
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (entry.isIntersecting) {
|
|
35
|
+
this.#trigger();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
#trigger() {
|
|
41
|
+
if (!this.#loaded) this.#load();
|
|
42
|
+
else if (!this.onceValue) this.#reload();
|
|
43
|
+
}
|
|
44
|
+
/** Starts the load by writing the held URL to `src`. */
|
|
45
|
+
#load() {
|
|
46
|
+
this.#loaded = true;
|
|
47
|
+
this.element.setAttribute("src", this.urlValue);
|
|
48
|
+
this.element.setAttribute("data-lazy-loaded", "true");
|
|
49
|
+
this.dispatch("load", { detail: { url: this.urlValue } });
|
|
50
|
+
if (this.onceValue) this.#stopObserving();
|
|
51
|
+
}
|
|
52
|
+
/** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */
|
|
53
|
+
#reload() {
|
|
54
|
+
const frame = this.element;
|
|
55
|
+
if (typeof frame.reload !== "function") return;
|
|
56
|
+
frame.reload();
|
|
57
|
+
this.dispatch("load", { detail: { url: this.urlValue } });
|
|
58
|
+
}
|
|
59
|
+
#stopObserving() {
|
|
60
|
+
this.#observer?.disconnect();
|
|
61
|
+
this.#observer = null;
|
|
62
|
+
this.element.removeEventListener("focusin", this.#onFocus);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export { LazyFrameController };
|
|
67
|
+
//# sourceMappingURL=lazy_frame_controller.js.map
|
|
68
|
+
//# sourceMappingURL=lazy_frame_controller.js.map
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/listbox_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/listbox_controller.ts
|
|
59
|
+
var TYPEAHEAD_TIMEOUT = 500;
|
|
60
|
+
var ListboxController = class extends Controller {
|
|
61
|
+
static targets = ["trigger", "value", "list", "option", "field"];
|
|
62
|
+
static actions = ["close", "onTriggerKeydown", "open", "select", "toggle"];
|
|
63
|
+
static events = ["change"];
|
|
64
|
+
/** Index of the active option, or -1 when none is active. */
|
|
65
|
+
#activeIndex = -1;
|
|
66
|
+
/** Accumulated typeahead query, reset after {@link TYPEAHEAD_TIMEOUT} ms. */
|
|
67
|
+
#typeahead = "";
|
|
68
|
+
#typeaheadTimer = 0;
|
|
69
|
+
#timers = new SafeTimeout();
|
|
70
|
+
/** Starts closed and registers the outside-click listener. */
|
|
71
|
+
connect() {
|
|
72
|
+
this.close();
|
|
73
|
+
document.addEventListener("click", this.#onOutsideClick);
|
|
74
|
+
}
|
|
75
|
+
/** Removes the document listener and clears the typeahead timer. */
|
|
76
|
+
disconnect() {
|
|
77
|
+
document.removeEventListener("click", this.#onOutsideClick);
|
|
78
|
+
this.#timers.clearAll();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Toggles the list on a real mouse click. Keyboard activation of the
|
|
82
|
+
* `<button>` also fires a click (`detail === 0`); the keydown handler already
|
|
83
|
+
* drives that, so the synthetic click is ignored to avoid double-toggling.
|
|
84
|
+
*/
|
|
85
|
+
toggle(event) {
|
|
86
|
+
if (event.detail === 0) return;
|
|
87
|
+
if (this.#isClosed) {
|
|
88
|
+
this.open();
|
|
89
|
+
} else {
|
|
90
|
+
this.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Routes trigger keyboard interaction per the APG select-only model. */
|
|
94
|
+
onTriggerKeydown(event) {
|
|
95
|
+
const length = this.optionTargets.length;
|
|
96
|
+
if (this.#isClosed) {
|
|
97
|
+
switch (event.key) {
|
|
98
|
+
case "Enter":
|
|
99
|
+
case " ":
|
|
100
|
+
case "ArrowDown":
|
|
101
|
+
case "ArrowUp":
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
this.open();
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (length === 0 && event.key !== "Escape" && event.key !== "Tab") {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
switch (event.key) {
|
|
112
|
+
case "ArrowDown":
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
this.#setActive(this.#activeIndex < 0 ? 0 : (this.#activeIndex + 1) % length);
|
|
115
|
+
break;
|
|
116
|
+
case "ArrowUp":
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
this.#setActive(
|
|
119
|
+
this.#activeIndex < 0 ? length - 1 : (this.#activeIndex - 1 + length) % length
|
|
120
|
+
);
|
|
121
|
+
break;
|
|
122
|
+
case "Home":
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
this.#setActive(0);
|
|
125
|
+
break;
|
|
126
|
+
case "End":
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
this.#setActive(length - 1);
|
|
129
|
+
break;
|
|
130
|
+
case "Enter":
|
|
131
|
+
case " ":
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
this.#commitActive();
|
|
134
|
+
break;
|
|
135
|
+
case "Escape":
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
this.close();
|
|
138
|
+
this.triggerTarget.focus();
|
|
139
|
+
break;
|
|
140
|
+
case "Tab":
|
|
141
|
+
this.close();
|
|
142
|
+
break;
|
|
143
|
+
default:
|
|
144
|
+
if (this.#isPrintable(event)) {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
this.#typeaheadTo(event.key);
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Selects the clicked option and closes, returning focus to the trigger. */
|
|
152
|
+
select(event) {
|
|
153
|
+
const option = event.currentTarget.closest('[role="option"]');
|
|
154
|
+
if (!option) return;
|
|
155
|
+
this.#selectOption(option);
|
|
156
|
+
this.close();
|
|
157
|
+
this.triggerTarget.focus();
|
|
158
|
+
}
|
|
159
|
+
/** Opens the list and activates the selected option (else the first). */
|
|
160
|
+
open() {
|
|
161
|
+
if (!this.hasListTarget) return;
|
|
162
|
+
this.listTarget.hidden = false;
|
|
163
|
+
this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
164
|
+
if (this.optionTargets.length === 0) {
|
|
165
|
+
this.#setActive(-1);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const selected = this.optionTargets.findIndex(
|
|
169
|
+
(option) => option.getAttribute("aria-selected") === "true"
|
|
170
|
+
);
|
|
171
|
+
this.#setActive(selected === -1 ? 0 : selected);
|
|
172
|
+
}
|
|
173
|
+
/** Closes the list, clears the active option, and resets the typeahead buffer. */
|
|
174
|
+
close() {
|
|
175
|
+
if (!this.hasListTarget) return;
|
|
176
|
+
this.listTarget.hidden = true;
|
|
177
|
+
this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
178
|
+
this.#setActive(-1);
|
|
179
|
+
this.#resetTypeahead();
|
|
180
|
+
}
|
|
181
|
+
/** Commits the active option (keyboard) and closes, returning focus. */
|
|
182
|
+
#commitActive() {
|
|
183
|
+
const option = this.#activeIndex < 0 ? void 0 : this.optionTargets[this.#activeIndex];
|
|
184
|
+
if (option) this.#selectOption(option);
|
|
185
|
+
this.close();
|
|
186
|
+
this.triggerTarget.focus();
|
|
187
|
+
}
|
|
188
|
+
/** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */
|
|
189
|
+
#selectOption(option) {
|
|
190
|
+
for (const candidate of this.optionTargets) {
|
|
191
|
+
candidate.setAttribute("aria-selected", candidate === option ? "true" : "false");
|
|
192
|
+
}
|
|
193
|
+
const label = (option.textContent ?? "").trim();
|
|
194
|
+
const value = option.dataset.value ?? label;
|
|
195
|
+
if (this.hasValueTarget) this.valueTarget.textContent = label;
|
|
196
|
+
if (this.hasFieldTarget && this.fieldTarget.value !== value) {
|
|
197
|
+
this.fieldTarget.value = value;
|
|
198
|
+
this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
|
|
199
|
+
}
|
|
200
|
+
this.dispatch("change", { detail: { value, option } });
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Marks the option at `index` active via `data-active` and the trigger's
|
|
204
|
+
* `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not
|
|
205
|
+
* set to empty, per the APG).
|
|
206
|
+
*/
|
|
207
|
+
#setActive(index) {
|
|
208
|
+
this.#activeIndex = index;
|
|
209
|
+
const active = index < 0 ? null : this.optionTargets[index];
|
|
210
|
+
for (const option of this.optionTargets) {
|
|
211
|
+
if (option === active) {
|
|
212
|
+
option.setAttribute("data-active", "");
|
|
213
|
+
} else {
|
|
214
|
+
option.removeAttribute("data-active");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (active?.id) {
|
|
218
|
+
this.triggerTarget.setAttribute("aria-activedescendant", active.id);
|
|
219
|
+
} else {
|
|
220
|
+
this.triggerTarget.removeAttribute("aria-activedescendant");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Appends a character to the typeahead query and activates the first match. */
|
|
224
|
+
#typeaheadTo(char) {
|
|
225
|
+
this.#timers.clear(this.#typeaheadTimer);
|
|
226
|
+
this.#typeahead += char.toLowerCase();
|
|
227
|
+
this.#typeaheadTimer = this.#timers.set(() => {
|
|
228
|
+
this.#typeahead = "";
|
|
229
|
+
}, TYPEAHEAD_TIMEOUT);
|
|
230
|
+
const index = this.optionTargets.findIndex(
|
|
231
|
+
(option) => (option.textContent ?? "").trim().toLowerCase().startsWith(this.#typeahead)
|
|
232
|
+
);
|
|
233
|
+
if (index !== -1) this.#setActive(index);
|
|
234
|
+
}
|
|
235
|
+
/** Clears the typeahead query and its pending reset timer. */
|
|
236
|
+
#resetTypeahead() {
|
|
237
|
+
this.#timers.clear(this.#typeaheadTimer);
|
|
238
|
+
this.#typeahead = "";
|
|
239
|
+
}
|
|
240
|
+
/** Closes the list when a click lands outside the controller element. */
|
|
241
|
+
#onOutsideClick = (event) => {
|
|
242
|
+
if (!this.#isClosed && !this.element.contains(event.target)) this.close();
|
|
243
|
+
};
|
|
244
|
+
/** Whether `event.key` is a single printable character (no modifier chord). */
|
|
245
|
+
#isPrintable(event) {
|
|
246
|
+
return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
247
|
+
}
|
|
248
|
+
/** Whether the list is currently hidden. */
|
|
249
|
+
get #isClosed() {
|
|
250
|
+
return !this.hasListTarget || this.listTarget.hidden !== false;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export { ListboxController };
|
|
255
|
+
//# sourceMappingURL=listbox_controller.js.map
|
|
256
|
+
//# sourceMappingURL=listbox_controller.js.map
|