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,276 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/confirm_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/confirm_controller.ts
|
|
162
|
+
var ConfirmController = class extends Controller {
|
|
163
|
+
static targets = ["dialog", "title", "message", "confirm", "cancel"];
|
|
164
|
+
static values = {
|
|
165
|
+
confirmLabel: { type: String, default: "OK" },
|
|
166
|
+
cancelLabel: { type: String, default: "Cancel" },
|
|
167
|
+
initialFocus: { type: String, default: "cancel" }
|
|
168
|
+
};
|
|
169
|
+
static actions = ["confirm", "cancel", "request"];
|
|
170
|
+
static events = ["open", "resolve"];
|
|
171
|
+
/** Resolver for the in-flight confirmation Promise (one dialog at a time). */
|
|
172
|
+
#pending = null;
|
|
173
|
+
/** Turbo's forms config and its original confirm method, for restore. */
|
|
174
|
+
#turboForms = null;
|
|
175
|
+
#previousConfirm = void 0;
|
|
176
|
+
#trap = new FocusTrap(() => this.dialogTarget, {
|
|
177
|
+
onEscape: () => this.#resolve(false),
|
|
178
|
+
initialFocus: () => this.#initialFocusElement()
|
|
179
|
+
});
|
|
180
|
+
connect() {
|
|
181
|
+
if (this.hasDialogTarget) this.dialogTarget.hidden = true;
|
|
182
|
+
this.#installTurboHook();
|
|
183
|
+
}
|
|
184
|
+
disconnect() {
|
|
185
|
+
if (this.#turboForms) {
|
|
186
|
+
this.#turboForms.confirm = this.#previousConfirm;
|
|
187
|
+
this.#turboForms = null;
|
|
188
|
+
}
|
|
189
|
+
this.#resolve(false, false);
|
|
190
|
+
this.#trap.deactivate({ restoreFocus: false });
|
|
191
|
+
}
|
|
192
|
+
/** Confirms the pending request (resolves `true`). Bound via `data-action`. */
|
|
193
|
+
confirm() {
|
|
194
|
+
this.#resolve(true);
|
|
195
|
+
}
|
|
196
|
+
/** Cancels the pending request (resolves `false`). Bound via `data-action`. */
|
|
197
|
+
cancel() {
|
|
198
|
+
this.#resolve(false);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Intercepts a link/button click, shows the confirm dialog, and continues the
|
|
202
|
+
* original action (form submit or navigation) only when confirmed. The message
|
|
203
|
+
* comes from the `message` action param or the element's `data-turbo-confirm`.
|
|
204
|
+
*/
|
|
205
|
+
request(event) {
|
|
206
|
+
const element = event.currentTarget ?? event.target;
|
|
207
|
+
if (!element) return;
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
const params = event.params;
|
|
210
|
+
const fromParam = typeof params?.message === "string" ? params.message : null;
|
|
211
|
+
const message = fromParam ?? element.getAttribute("data-turbo-confirm") ?? "";
|
|
212
|
+
void this.#prompt(message).then((confirmed) => {
|
|
213
|
+
if (confirmed) this.#continue(element);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Opens the dialog for `message` and resolves once the user confirms or cancels.
|
|
218
|
+
* Degrades to native `window.confirm` when no dialog target is present.
|
|
219
|
+
*/
|
|
220
|
+
#prompt(message) {
|
|
221
|
+
if (!this.hasDialogTarget) return Promise.resolve(window.confirm(message));
|
|
222
|
+
this.#resolve(false);
|
|
223
|
+
if (this.hasMessageTarget) this.messageTarget.textContent = message;
|
|
224
|
+
if (this.hasConfirmTarget) this.confirmTarget.textContent = this.confirmLabelValue;
|
|
225
|
+
if (this.hasCancelTarget) this.cancelTarget.textContent = this.cancelLabelValue;
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
this.#pending = resolve;
|
|
228
|
+
this.dialogTarget.hidden = false;
|
|
229
|
+
this.dispatch("open", { detail: { message } });
|
|
230
|
+
this.#trap.activate();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Settles the pending Promise, closes the dialog, and emits `resolve`.
|
|
235
|
+
* `restoreFocus` is forwarded to the trap so teardown (disconnect) can settle a
|
|
236
|
+
* pending confirmation without moving focus.
|
|
237
|
+
*/
|
|
238
|
+
#resolve(confirmed, restoreFocus = true) {
|
|
239
|
+
const pending = this.#pending;
|
|
240
|
+
if (!pending) return;
|
|
241
|
+
this.#pending = null;
|
|
242
|
+
if (this.hasDialogTarget) this.dialogTarget.hidden = true;
|
|
243
|
+
this.#trap.deactivate({ restoreFocus });
|
|
244
|
+
this.dispatch("resolve", { detail: { confirmed } });
|
|
245
|
+
pending(confirmed);
|
|
246
|
+
}
|
|
247
|
+
/** Continues the intercepted action when confirmed: submit a form, else navigate. */
|
|
248
|
+
#continue(element) {
|
|
249
|
+
if (element instanceof HTMLAnchorElement && element.href) {
|
|
250
|
+
window.location.href = element.href;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const form = element instanceof HTMLFormElement ? element : element.closest("form");
|
|
254
|
+
if (form) {
|
|
255
|
+
form.requestSubmit(element instanceof HTMLButtonElement ? element : void 0);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Swaps Turbo's confirm method for a Promise-returning bridge to this dialog. */
|
|
259
|
+
#installTurboHook() {
|
|
260
|
+
const turbo = window.Turbo;
|
|
261
|
+
const forms = turbo?.config?.forms;
|
|
262
|
+
if (!forms) return;
|
|
263
|
+
this.#turboForms = forms;
|
|
264
|
+
this.#previousConfirm = forms.confirm;
|
|
265
|
+
forms.confirm = (message) => this.#prompt(message);
|
|
266
|
+
}
|
|
267
|
+
/** The button to focus on open — the least-destructive one (cancel) by default. */
|
|
268
|
+
#initialFocusElement() {
|
|
269
|
+
if (this.initialFocusValue === "confirm" && this.hasConfirmTarget) return this.confirmTarget;
|
|
270
|
+
return this.hasCancelTarget ? this.cancelTarget : null;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export { ConfirmController };
|
|
275
|
+
//# sourceMappingURL=confirm_controller.js.map
|
|
276
|
+
//# sourceMappingURL=confirm_controller.js.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/context_menu_controller.ts
|
|
4
|
+
var ContextMenuController = class extends Controller {
|
|
5
|
+
static targets = ["region", "menu", "item"];
|
|
6
|
+
static actions = ["activate", "onItemKeydown", "onRegionKeydown", "open"];
|
|
7
|
+
/** Starts closed and registers the outside-click listener. */
|
|
8
|
+
connect() {
|
|
9
|
+
this.#closeMenu();
|
|
10
|
+
document.addEventListener("click", this.#onOutsideClick);
|
|
11
|
+
}
|
|
12
|
+
/** Removes the document-level listener registered in {@link connect}. */
|
|
13
|
+
disconnect() {
|
|
14
|
+
document.removeEventListener("click", this.#onOutsideClick);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Opens the menu from a `contextmenu` event: suppresses the native menu and
|
|
18
|
+
* places this one at the pointer coordinate.
|
|
19
|
+
*/
|
|
20
|
+
open(event) {
|
|
21
|
+
event.preventDefault();
|
|
22
|
+
this.#openAt(event.clientX, event.clientY);
|
|
23
|
+
}
|
|
24
|
+
/** Keyboard entry on the region: `Shift+F10` / `ContextMenu` open at center. */
|
|
25
|
+
onRegionKeydown(event) {
|
|
26
|
+
const isContextKey = event.key === "ContextMenu" || event.key === "F10" && event.shiftKey;
|
|
27
|
+
if (!isContextKey) return;
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
const rect = this.hasRegionTarget ? this.regionTarget.getBoundingClientRect() : { left: 0, top: 0, width: 0, height: 0 };
|
|
30
|
+
this.#openAt(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
31
|
+
}
|
|
32
|
+
/** Roving focus and closing keys inside the menu. */
|
|
33
|
+
onItemKeydown(event) {
|
|
34
|
+
const items = this.#navigableItems;
|
|
35
|
+
const currentIndex = items.indexOf(event.currentTarget);
|
|
36
|
+
switch (event.key) {
|
|
37
|
+
case "ArrowDown":
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();
|
|
40
|
+
break;
|
|
41
|
+
case "ArrowUp":
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();
|
|
44
|
+
break;
|
|
45
|
+
case "Home":
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
items[0]?.focus();
|
|
48
|
+
break;
|
|
49
|
+
case "End":
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
items[items.length - 1]?.focus();
|
|
52
|
+
break;
|
|
53
|
+
case "Escape":
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
this.#closeAndRestore();
|
|
56
|
+
break;
|
|
57
|
+
case "Tab":
|
|
58
|
+
this.#closeMenu();
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Closes after an item is activated and restores focus to the region. */
|
|
63
|
+
activate() {
|
|
64
|
+
this.#closeAndRestore();
|
|
65
|
+
}
|
|
66
|
+
/** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */
|
|
67
|
+
#openAt(x, y) {
|
|
68
|
+
if (!this.hasMenuTarget) return;
|
|
69
|
+
this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
|
|
70
|
+
this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
|
|
71
|
+
this.menuTarget.hidden = false;
|
|
72
|
+
if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
|
|
73
|
+
this.#navigableItems[0]?.focus();
|
|
74
|
+
}
|
|
75
|
+
/** Hides the menu and reflects the collapsed state on the region. */
|
|
76
|
+
#closeMenu() {
|
|
77
|
+
if (!this.hasMenuTarget) return;
|
|
78
|
+
this.menuTarget.hidden = true;
|
|
79
|
+
if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "closed");
|
|
80
|
+
}
|
|
81
|
+
/** Closes the menu and returns focus to the region (Escape / activation). */
|
|
82
|
+
#closeAndRestore() {
|
|
83
|
+
this.#closeMenu();
|
|
84
|
+
if (this.hasRegionTarget) this.regionTarget.focus();
|
|
85
|
+
}
|
|
86
|
+
/** Closes the menu when a click lands outside the controller's element. */
|
|
87
|
+
#onOutsideClick = (event) => {
|
|
88
|
+
if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
|
|
89
|
+
};
|
|
90
|
+
/** Menu items eligible for roving focus (excludes disabled / hidden). */
|
|
91
|
+
get #navigableItems() {
|
|
92
|
+
return this.itemTargets.filter((item) => this.#isNavigable(item));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* An item can take roving focus unless it is `hidden`, `aria-disabled="true"`,
|
|
96
|
+
* or a natively `disabled` form control. CSS-only visibility is not detectable
|
|
97
|
+
* here and is the consumer's responsibility.
|
|
98
|
+
*/
|
|
99
|
+
#isNavigable(item) {
|
|
100
|
+
if (item.hasAttribute("hidden")) return false;
|
|
101
|
+
if (item.getAttribute("aria-disabled") === "true") return false;
|
|
102
|
+
return !item.disabled;
|
|
103
|
+
}
|
|
104
|
+
/** Whether the menu is currently visible. */
|
|
105
|
+
get #isOpen() {
|
|
106
|
+
return this.hasMenuTarget && !this.menuTarget.hidden;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export { ContextMenuController };
|
|
111
|
+
//# sourceMappingURL=context_menu_controller.js.map
|
|
112
|
+
//# sourceMappingURL=context_menu_controller.js.map
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/countdown_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 SafeInterval = class extends TimerRegistry {
|
|
36
|
+
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
37
|
+
set(callback, delay) {
|
|
38
|
+
const id = this.schedule(callback, delay);
|
|
39
|
+
this.ids.add(id);
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
schedule(callback, delay) {
|
|
43
|
+
return window.setInterval(callback, delay);
|
|
44
|
+
}
|
|
45
|
+
cancel(id) {
|
|
46
|
+
window.clearInterval(id);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// src/controllers/countdown_controller.ts
|
|
51
|
+
var CountdownController = class extends Controller {
|
|
52
|
+
static targets = ["days", "hours", "minutes", "seconds", "status"];
|
|
53
|
+
static values = {
|
|
54
|
+
deadline: { type: String, default: "" },
|
|
55
|
+
interval: { type: Number, default: 1e3 },
|
|
56
|
+
direction: { type: String, default: "down" },
|
|
57
|
+
autostart: { type: Boolean, default: true },
|
|
58
|
+
completeLabel: { type: String, default: "" }
|
|
59
|
+
};
|
|
60
|
+
static actions = ["pause", "reset", "resume", "start"];
|
|
61
|
+
static events = ["complete", "tick"];
|
|
62
|
+
#intervals = new SafeInterval();
|
|
63
|
+
#intervalId = null;
|
|
64
|
+
/** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
|
|
65
|
+
#reference = 0;
|
|
66
|
+
/** Amount (ms) captured at pause, so resume can restore the same display. */
|
|
67
|
+
#pausedAmount = 0;
|
|
68
|
+
connect() {
|
|
69
|
+
this.#initReference();
|
|
70
|
+
this.#render(this.#currentAmount());
|
|
71
|
+
if (this.autostartValue && this.#isValidDeadline) {
|
|
72
|
+
this.start();
|
|
73
|
+
} else {
|
|
74
|
+
this.element.setAttribute("data-state", "paused");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
disconnect() {
|
|
78
|
+
this.#intervals.clearAll();
|
|
79
|
+
this.#intervalId = null;
|
|
80
|
+
}
|
|
81
|
+
/** Starts (or restarts after pause) ticking toward the deadline. */
|
|
82
|
+
start() {
|
|
83
|
+
if (this.#state === "running" || !this.#isValidDeadline) return;
|
|
84
|
+
if (this.#isDown && this.#currentAmount() <= 0) {
|
|
85
|
+
this.#complete();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.#runInterval();
|
|
89
|
+
}
|
|
90
|
+
/** Pauses ticking, preserving the currently displayed amount. */
|
|
91
|
+
pause() {
|
|
92
|
+
if (this.#state !== "running") return;
|
|
93
|
+
this.#pausedAmount = this.#currentAmount();
|
|
94
|
+
this.#teardownInterval();
|
|
95
|
+
this.element.setAttribute("data-state", "paused");
|
|
96
|
+
}
|
|
97
|
+
/** Resumes from a pause, continuing from the preserved amount. */
|
|
98
|
+
resume() {
|
|
99
|
+
if (this.#state !== "paused" || !this.#isValidDeadline) return;
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
this.#reference = this.#isDown ? now + this.#pausedAmount : now - this.#pausedAmount;
|
|
102
|
+
this.start();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Re-syncs to the deadline and clears any pause offset, **preserving the current
|
|
106
|
+
* run state**: a running timer keeps counting down from the reset amount, while a
|
|
107
|
+
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
108
|
+
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
109
|
+
* not re-derived from the declarative `autostart` Value (which only governs the
|
|
110
|
+
* initial state on connect); re-deriving it would override a user's pause, the same
|
|
111
|
+
* anti-pattern the Turbo-lifecycle guide warns about.
|
|
112
|
+
*/
|
|
113
|
+
reset() {
|
|
114
|
+
const wasRunning = this.#state === "running";
|
|
115
|
+
this.#teardownInterval();
|
|
116
|
+
this.#initReference();
|
|
117
|
+
const amount = this.#currentAmount();
|
|
118
|
+
this.#render(amount);
|
|
119
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = "";
|
|
120
|
+
this.element.setAttribute("data-state", "paused");
|
|
121
|
+
if (wasRunning && this.#isValidDeadline) {
|
|
122
|
+
this.#pausedAmount = 0;
|
|
123
|
+
this.start();
|
|
124
|
+
} else {
|
|
125
|
+
this.#pausedAmount = amount;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Schedules the repeating tick and marks the timer running. */
|
|
129
|
+
#runInterval() {
|
|
130
|
+
this.element.setAttribute("data-state", "running");
|
|
131
|
+
this.#intervalId = this.#intervals.set(() => this.#tick(), this.intervalValue);
|
|
132
|
+
}
|
|
133
|
+
/** Cancels the repeating tick, if any. */
|
|
134
|
+
#teardownInterval() {
|
|
135
|
+
if (this.#intervalId !== null) {
|
|
136
|
+
this.#intervals.clear(this.#intervalId);
|
|
137
|
+
this.#intervalId = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Recomputes, renders, emits `tick`, and completes when a countdown hits 0. */
|
|
141
|
+
#tick() {
|
|
142
|
+
const amount = this.#currentAmount();
|
|
143
|
+
this.#render(amount);
|
|
144
|
+
this.dispatch("tick", {
|
|
145
|
+
detail: { remaining: amount, direction: this.#isDown ? "down" : "up" }
|
|
146
|
+
});
|
|
147
|
+
if (this.#isDown && this.#reference - Date.now() <= 0) {
|
|
148
|
+
this.#complete();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Stops at zero, marks completion, announces it, and emits `complete`. */
|
|
152
|
+
#complete() {
|
|
153
|
+
this.#teardownInterval();
|
|
154
|
+
this.#render(0);
|
|
155
|
+
this.element.setAttribute("data-state", "complete");
|
|
156
|
+
if (this.hasStatusTarget && this.completeLabelValue.length > 0) {
|
|
157
|
+
this.statusTarget.textContent = this.completeLabelValue;
|
|
158
|
+
}
|
|
159
|
+
this.dispatch("complete", { detail: {} });
|
|
160
|
+
}
|
|
161
|
+
/** Sets the time anchor from the `deadline` value. */
|
|
162
|
+
#initReference() {
|
|
163
|
+
this.#reference = Date.parse(this.deadlineValue);
|
|
164
|
+
}
|
|
165
|
+
/** Remaining (down) or elapsed (up) ms, never negative. */
|
|
166
|
+
#currentAmount() {
|
|
167
|
+
if (!this.#isValidDeadline) return 0;
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const raw = this.#isDown ? this.#reference - now : now - this.#reference;
|
|
170
|
+
return Math.max(0, raw);
|
|
171
|
+
}
|
|
172
|
+
/** Writes the amount into the day/hour/minute/second slots. */
|
|
173
|
+
#render(amount) {
|
|
174
|
+
const totalSeconds = Math.floor(amount / 1e3);
|
|
175
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
176
|
+
const hours = Math.floor(totalSeconds % 86400 / 3600);
|
|
177
|
+
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
178
|
+
const seconds = totalSeconds % 60;
|
|
179
|
+
if (this.hasDaysTarget) this.daysTarget.textContent = String(days);
|
|
180
|
+
if (this.hasHoursTarget) this.hoursTarget.textContent = this.#pad(hours);
|
|
181
|
+
if (this.hasMinutesTarget) this.minutesTarget.textContent = this.#pad(minutes);
|
|
182
|
+
if (this.hasSecondsTarget) this.secondsTarget.textContent = this.#pad(seconds);
|
|
183
|
+
}
|
|
184
|
+
/** Zero-pads a unit to two digits. */
|
|
185
|
+
#pad(unit) {
|
|
186
|
+
return String(unit).padStart(2, "0");
|
|
187
|
+
}
|
|
188
|
+
get #isDown() {
|
|
189
|
+
return this.directionValue !== "up";
|
|
190
|
+
}
|
|
191
|
+
get #isValidDeadline() {
|
|
192
|
+
return !Number.isNaN(this.#reference);
|
|
193
|
+
}
|
|
194
|
+
/** Current lifecycle phase as reflected on `data-state`. */
|
|
195
|
+
get #state() {
|
|
196
|
+
return this.element.getAttribute("data-state") ?? "paused";
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export { CountdownController };
|
|
201
|
+
//# sourceMappingURL=countdown_controller.js.map
|
|
202
|
+
//# sourceMappingURL=countdown_controller.js.map
|