stimeo-ui 0.1.0-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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/controllers/accordion_controller.d.ts +59 -0
- package/dist/controllers/accordion_controller.js +76 -0
- package/dist/controllers/accordion_controller.js.map +1 -0
- package/dist/controllers/announcer_controller.d.ts +78 -0
- package/dist/controllers/announcer_controller.js +184 -0
- package/dist/controllers/announcer_controller.js.map +1 -0
- package/dist/controllers/aspect_ratio_controller.d.ts +39 -0
- package/dist/controllers/aspect_ratio_controller.js +36 -0
- package/dist/controllers/aspect_ratio_controller.js.map +1 -0
- package/dist/controllers/auto_submit_controller.d.ts +74 -0
- package/dist/controllers/auto_submit_controller.js +147 -0
- package/dist/controllers/auto_submit_controller.js.map +1 -0
- package/dist/controllers/avatar_controller.d.ts +50 -0
- package/dist/controllers/avatar_controller.js +66 -0
- package/dist/controllers/avatar_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +60 -0
- package/dist/controllers/breadcrumb_controller.js +123 -0
- package/dist/controllers/breadcrumb_controller.js.map +1 -0
- package/dist/controllers/bulk_select_controller.d.ts +67 -0
- package/dist/controllers/bulk_select_controller.js +104 -0
- package/dist/controllers/bulk_select_controller.js.map +1 -0
- package/dist/controllers/calendar_controller.d.ts +100 -0
- package/dist/controllers/calendar_controller.js +394 -0
- package/dist/controllers/calendar_controller.js.map +1 -0
- package/dist/controllers/character_counter_controller.d.ts +63 -0
- package/dist/controllers/character_counter_controller.js +179 -0
- package/dist/controllers/character_counter_controller.js.map +1 -0
- package/dist/controllers/checkbox_controller.d.ts +52 -0
- package/dist/controllers/checkbox_controller.js +73 -0
- package/dist/controllers/checkbox_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +76 -0
- package/dist/controllers/combobox_controller.js +186 -0
- package/dist/controllers/combobox_controller.js.map +1 -0
- package/dist/controllers/command_palette_controller.d.ts +103 -0
- package/dist/controllers/command_palette_controller.js +381 -0
- package/dist/controllers/command_palette_controller.js.map +1 -0
- package/dist/controllers/conditional_fields_controller.d.ts +56 -0
- package/dist/controllers/conditional_fields_controller.js +112 -0
- package/dist/controllers/conditional_fields_controller.js.map +1 -0
- package/dist/controllers/confirm_controller.d.ts +83 -0
- package/dist/controllers/confirm_controller.js +276 -0
- package/dist/controllers/confirm_controller.js.map +1 -0
- package/dist/controllers/context_menu_controller.d.ts +81 -0
- package/dist/controllers/context_menu_controller.js +112 -0
- package/dist/controllers/context_menu_controller.js.map +1 -0
- package/dist/controllers/countdown_controller.d.ts +93 -0
- package/dist/controllers/countdown_controller.js +202 -0
- package/dist/controllers/countdown_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.d.ts +57 -0
- package/dist/controllers/dialog_controller.js +207 -0
- package/dist/controllers/dialog_controller.js.map +1 -0
- package/dist/controllers/direct_upload_controller.d.ts +77 -0
- package/dist/controllers/direct_upload_controller.js +212 -0
- package/dist/controllers/direct_upload_controller.js.map +1 -0
- package/dist/controllers/dirty_form_controller.d.ts +52 -0
- package/dist/controllers/dirty_form_controller.js +128 -0
- package/dist/controllers/dirty_form_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +49 -0
- package/dist/controllers/dropdown_controller.js +66 -0
- package/dist/controllers/dropdown_controller.js.map +1 -0
- package/dist/controllers/empty_state_controller.d.ts +60 -0
- package/dist/controllers/empty_state_controller.js +67 -0
- package/dist/controllers/empty_state_controller.js.map +1 -0
- package/dist/controllers/flash_controller.d.ts +63 -0
- package/dist/controllers/flash_controller.js +221 -0
- package/dist/controllers/flash_controller.js.map +1 -0
- package/dist/controllers/focus_controller.d.ts +69 -0
- package/dist/controllers/focus_controller.js +216 -0
- package/dist/controllers/focus_controller.js.map +1 -0
- package/dist/controllers/form_field_controller.d.ts +79 -0
- package/dist/controllers/form_field_controller.js +154 -0
- package/dist/controllers/form_field_controller.js.map +1 -0
- package/dist/controllers/form_validation_controller.d.ts +114 -0
- package/dist/controllers/form_validation_controller.js +202 -0
- package/dist/controllers/form_validation_controller.js.map +1 -0
- package/dist/controllers/frame_loading_controller.d.ts +58 -0
- package/dist/controllers/frame_loading_controller.js +177 -0
- package/dist/controllers/frame_loading_controller.js.map +1 -0
- package/dist/controllers/highlight_controller.d.ts +50 -0
- package/dist/controllers/highlight_controller.js +107 -0
- package/dist/controllers/highlight_controller.js.map +1 -0
- package/dist/controllers/hover_card_controller.d.ts +79 -0
- package/dist/controllers/hover_card_controller.js +165 -0
- package/dist/controllers/hover_card_controller.js.map +1 -0
- package/dist/controllers/idle_controller.d.ts +51 -0
- package/dist/controllers/idle_controller.js +141 -0
- package/dist/controllers/idle_controller.js.map +1 -0
- package/dist/controllers/input_mask_controller.d.ts +74 -0
- package/dist/controllers/input_mask_controller.js +166 -0
- package/dist/controllers/input_mask_controller.js.map +1 -0
- package/dist/controllers/lazy_frame_controller.d.ts +53 -0
- package/dist/controllers/lazy_frame_controller.js +68 -0
- package/dist/controllers/lazy_frame_controller.js.map +1 -0
- package/dist/controllers/listbox_controller.d.ts +88 -0
- package/dist/controllers/listbox_controller.js +256 -0
- package/dist/controllers/listbox_controller.js.map +1 -0
- package/dist/controllers/local_time_controller.d.ts +60 -0
- package/dist/controllers/local_time_controller.js +81 -0
- package/dist/controllers/local_time_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +81 -0
- package/dist/controllers/menu_controller.js +134 -0
- package/dist/controllers/menu_controller.js.map +1 -0
- package/dist/controllers/meter_controller.d.ts +93 -0
- package/dist/controllers/meter_controller.js +96 -0
- package/dist/controllers/meter_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +85 -0
- package/dist/controllers/nested_form_controller.js +131 -0
- package/dist/controllers/nested_form_controller.js.map +1 -0
- package/dist/controllers/network_status_controller.d.ts +49 -0
- package/dist/controllers/network_status_controller.js +126 -0
- package/dist/controllers/network_status_controller.js.map +1 -0
- package/dist/controllers/number_input_controller.d.ts +93 -0
- package/dist/controllers/number_input_controller.js +306 -0
- package/dist/controllers/number_input_controller.js.map +1 -0
- package/dist/controllers/otp_controller.d.ts +68 -0
- package/dist/controllers/otp_controller.js +201 -0
- package/dist/controllers/otp_controller.js.map +1 -0
- package/dist/controllers/overflow_indicator_controller.d.ts +62 -0
- package/dist/controllers/overflow_indicator_controller.js +169 -0
- package/dist/controllers/overflow_indicator_controller.js.map +1 -0
- package/dist/controllers/overflow_menu_controller.d.ts +64 -0
- package/dist/controllers/overflow_menu_controller.js +274 -0
- package/dist/controllers/overflow_menu_controller.js.map +1 -0
- package/dist/controllers/pagination_controller.d.ts +69 -0
- package/dist/controllers/pagination_controller.js +89 -0
- package/dist/controllers/pagination_controller.js.map +1 -0
- package/dist/controllers/password_strength_controller.d.ts +67 -0
- package/dist/controllers/password_strength_controller.js +175 -0
- package/dist/controllers/password_strength_controller.js.map +1 -0
- package/dist/controllers/persist_controller.d.ts +63 -0
- package/dist/controllers/persist_controller.js +259 -0
- package/dist/controllers/persist_controller.js.map +1 -0
- package/dist/controllers/popover_controller.d.ts +63 -0
- package/dist/controllers/popover_controller.js +94 -0
- package/dist/controllers/popover_controller.js.map +1 -0
- package/dist/controllers/portal_controller.d.ts +58 -0
- package/dist/controllers/portal_controller.js +63 -0
- package/dist/controllers/portal_controller.js.map +1 -0
- package/dist/controllers/preview_guard_controller.d.ts +47 -0
- package/dist/controllers/preview_guard_controller.js +69 -0
- package/dist/controllers/preview_guard_controller.js.map +1 -0
- package/dist/controllers/progress_controller.d.ts +85 -0
- package/dist/controllers/progress_controller.js +93 -0
- package/dist/controllers/progress_controller.js.map +1 -0
- package/dist/controllers/radio_group_controller.d.ts +49 -0
- package/dist/controllers/radio_group_controller.js +128 -0
- package/dist/controllers/radio_group_controller.js.map +1 -0
- package/dist/controllers/rating_controller.d.ts +82 -0
- package/dist/controllers/rating_controller.js +179 -0
- package/dist/controllers/rating_controller.js.map +1 -0
- package/dist/controllers/relative_time_controller.d.ts +48 -0
- package/dist/controllers/relative_time_controller.js +129 -0
- package/dist/controllers/relative_time_controller.js.map +1 -0
- package/dist/controllers/reset_before_cache_controller.d.ts +54 -0
- package/dist/controllers/reset_before_cache_controller.js +62 -0
- package/dist/controllers/reset_before_cache_controller.js.map +1 -0
- package/dist/controllers/resizable_controller.d.ts +81 -0
- package/dist/controllers/resizable_controller.js +163 -0
- package/dist/controllers/resizable_controller.js.map +1 -0
- package/dist/controllers/roving_controller.d.ts +63 -0
- package/dist/controllers/roving_controller.js +116 -0
- package/dist/controllers/roving_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.d.ts +45 -0
- package/dist/controllers/scroll_area_controller.js +183 -0
- package/dist/controllers/scroll_area_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +72 -0
- package/dist/controllers/scroll_visibility_controller.js +103 -0
- package/dist/controllers/scroll_visibility_controller.js.map +1 -0
- package/dist/controllers/scrollspy_controller.d.ts +65 -0
- package/dist/controllers/scrollspy_controller.js +171 -0
- package/dist/controllers/scrollspy_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.d.ts +50 -0
- package/dist/controllers/skeleton_controller.js +125 -0
- package/dist/controllers/skeleton_controller.js.map +1 -0
- package/dist/controllers/slider_controller.d.ts +76 -0
- package/dist/controllers/slider_controller.js +109 -0
- package/dist/controllers/slider_controller.js.map +1 -0
- package/dist/controllers/spinner_controller.d.ts +62 -0
- package/dist/controllers/spinner_controller.js +164 -0
- package/dist/controllers/spinner_controller.js.map +1 -0
- package/dist/controllers/step_indicator_controller.d.ts +55 -0
- package/dist/controllers/step_indicator_controller.js +55 -0
- package/dist/controllers/step_indicator_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +66 -0
- package/dist/controllers/stepper_controller.js +78 -0
- package/dist/controllers/stepper_controller.js.map +1 -0
- package/dist/controllers/stick_to_bottom_controller.d.ts +61 -0
- package/dist/controllers/stick_to_bottom_controller.js +100 -0
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -0
- package/dist/controllers/sticky_observer_controller.d.ts +54 -0
- package/dist/controllers/sticky_observer_controller.js +53 -0
- package/dist/controllers/sticky_observer_controller.js.map +1 -0
- package/dist/controllers/submit_once_controller.d.ts +66 -0
- package/dist/controllers/submit_once_controller.js +206 -0
- package/dist/controllers/submit_once_controller.js.map +1 -0
- package/dist/controllers/switch_controller.d.ts +43 -0
- package/dist/controllers/switch_controller.js +50 -0
- package/dist/controllers/switch_controller.js.map +1 -0
- package/dist/controllers/tabs_controller.d.ts +48 -0
- package/dist/controllers/tabs_controller.js +63 -0
- package/dist/controllers/tabs_controller.js.map +1 -0
- package/dist/controllers/textarea_autosize_controller.d.ts +47 -0
- package/dist/controllers/textarea_autosize_controller.js +72 -0
- package/dist/controllers/textarea_autosize_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +70 -0
- package/dist/controllers/theme_controller.js +154 -0
- package/dist/controllers/theme_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +123 -0
- package/dist/controllers/toast_controller.js +310 -0
- package/dist/controllers/toast_controller.js.map +1 -0
- package/dist/controllers/toggle_group_controller.d.ts +56 -0
- package/dist/controllers/toggle_group_controller.js +130 -0
- package/dist/controllers/toggle_group_controller.js.map +1 -0
- package/dist/controllers/toolbar_controller.d.ts +59 -0
- package/dist/controllers/toolbar_controller.js +113 -0
- package/dist/controllers/toolbar_controller.js.map +1 -0
- package/dist/controllers/tooltip_controller.d.ts +74 -0
- package/dist/controllers/tooltip_controller.js +165 -0
- package/dist/controllers/tooltip_controller.js.map +1 -0
- package/dist/controllers/transition_controller.d.ts +86 -0
- package/dist/controllers/transition_controller.js +203 -0
- package/dist/controllers/transition_controller.js.map +1 -0
- package/dist/index.d.ts +1931 -0
- package/dist/index.js +12241 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector/cli.d.ts +149 -0
- package/dist/inspector/cli.js +663 -0
- package/dist/inspector/cli.js.map +1 -0
- package/dist/inspector/cli_bin.d.ts +2 -0
- package/dist/inspector/cli_bin.js +665 -0
- package/dist/inspector/cli_bin.js.map +1 -0
- package/dist/inspector/manifest.json +2235 -0
- package/dist/positioning/index.d.ts +226 -0
- package/dist/positioning/index.js +145 -0
- package/dist/positioning/index.js.map +1 -0
- package/package.json +93 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless online/offline banner behavior built on the live-region practice (no
|
|
5
|
+
* dedicated APG pattern).
|
|
6
|
+
*
|
|
7
|
+
* Markup contract (identifier: `stimeo--network-status`):
|
|
8
|
+
* <div data-controller="stimeo--network-status">
|
|
9
|
+
* <div role="alert" hidden data-stimeo--network-status-target="offline">
|
|
10
|
+
* You are offline.
|
|
11
|
+
* </div>
|
|
12
|
+
* <div role="status" hidden data-stimeo--network-status-target="online">
|
|
13
|
+
* Back online.
|
|
14
|
+
* </div>
|
|
15
|
+
* </div>
|
|
16
|
+
*
|
|
17
|
+
* Reads `navigator.onLine` on connect and subscribes to the `window`
|
|
18
|
+
* `online`/`offline` events, toggling the matching banner. The offline banner is
|
|
19
|
+
* `role="alert"` (assertive) because losing connectivity is urgent; the recovery
|
|
20
|
+
* banner is `role="status"` (polite).
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* Behavior only. `navigator.onLine` is the browser's *guess* — it does not
|
|
24
|
+
* guarantee server reachability, which stays the consumer's job. To make the
|
|
25
|
+
* announcement reliable across assistive tech (merely un-hiding a static banner
|
|
26
|
+
* is flaky), the controller re-writes the banner's text on each transition,
|
|
27
|
+
* guarded so an unchanged state never re-announces. The event listeners and the
|
|
28
|
+
* auto-hide timer are removed/cleared on `disconnect()` (Turbo included).
|
|
29
|
+
*/
|
|
30
|
+
declare class NetworkStatusController extends Controller<HTMLElement> {
|
|
31
|
+
#private;
|
|
32
|
+
static targets: string[];
|
|
33
|
+
static values: {
|
|
34
|
+
onlineAutoHide: {
|
|
35
|
+
type: NumberConstructor;
|
|
36
|
+
default: number;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
static events: readonly ["change"];
|
|
40
|
+
readonly offlineTarget: HTMLElement;
|
|
41
|
+
readonly onlineTarget: HTMLElement;
|
|
42
|
+
readonly hasOfflineTarget: boolean;
|
|
43
|
+
readonly hasOnlineTarget: boolean;
|
|
44
|
+
onlineAutoHideValue: number;
|
|
45
|
+
connect(): void;
|
|
46
|
+
disconnect(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { NetworkStatusController };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/network_status_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/network_status_controller.ts
|
|
59
|
+
var NetworkStatusController = class extends Controller {
|
|
60
|
+
static targets = ["offline", "online"];
|
|
61
|
+
static values = {
|
|
62
|
+
onlineAutoHide: { type: Number, default: 0 }
|
|
63
|
+
};
|
|
64
|
+
static events = ["change"];
|
|
65
|
+
#timers = new SafeTimeout();
|
|
66
|
+
/** Last known connectivity; guards against duplicate-state re-announcements. */
|
|
67
|
+
#online = true;
|
|
68
|
+
/** Banner text captured from the markup so transitions can re-write it. */
|
|
69
|
+
#offlineMessage = "";
|
|
70
|
+
#onlineMessage = "";
|
|
71
|
+
#handleOnline = () => this.#update(true);
|
|
72
|
+
#handleOffline = () => this.#update(false);
|
|
73
|
+
connect() {
|
|
74
|
+
this.#offlineMessage = this.hasOfflineTarget ? (this.offlineTarget.textContent ?? "").trim() : "";
|
|
75
|
+
this.#onlineMessage = this.hasOnlineTarget ? (this.onlineTarget.textContent ?? "").trim() : "";
|
|
76
|
+
if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
|
|
77
|
+
if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
|
|
78
|
+
this.#online = navigator.onLine;
|
|
79
|
+
this.element.setAttribute("data-state", this.#online ? "online" : "offline");
|
|
80
|
+
if (!this.#online) this.#showOffline();
|
|
81
|
+
window.addEventListener("online", this.#handleOnline);
|
|
82
|
+
window.addEventListener("offline", this.#handleOffline);
|
|
83
|
+
}
|
|
84
|
+
disconnect() {
|
|
85
|
+
window.removeEventListener("online", this.#handleOnline);
|
|
86
|
+
window.removeEventListener("offline", this.#handleOffline);
|
|
87
|
+
this.#timers.clearAll();
|
|
88
|
+
}
|
|
89
|
+
/** Applies a connectivity transition, guarded against duplicate states. */
|
|
90
|
+
#update(online) {
|
|
91
|
+
if (online === this.#online) return;
|
|
92
|
+
this.#online = online;
|
|
93
|
+
this.element.setAttribute("data-state", online ? "online" : "offline");
|
|
94
|
+
if (online) {
|
|
95
|
+
this.#showOnline();
|
|
96
|
+
} else {
|
|
97
|
+
this.#showOffline();
|
|
98
|
+
}
|
|
99
|
+
this.dispatch("change", { detail: { online } });
|
|
100
|
+
}
|
|
101
|
+
/** Shows the offline banner and hides the recovery banner. */
|
|
102
|
+
#showOffline() {
|
|
103
|
+
this.#timers.clearAll();
|
|
104
|
+
if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
|
|
105
|
+
if (this.hasOfflineTarget) {
|
|
106
|
+
this.offlineTarget.textContent = this.#offlineMessage;
|
|
107
|
+
this.offlineTarget.hidden = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */
|
|
111
|
+
#showOnline() {
|
|
112
|
+
if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
|
|
113
|
+
if (!this.hasOnlineTarget) return;
|
|
114
|
+
this.onlineTarget.textContent = this.#onlineMessage;
|
|
115
|
+
this.onlineTarget.hidden = false;
|
|
116
|
+
if (this.onlineAutoHideValue > 0) {
|
|
117
|
+
this.#timers.set(() => {
|
|
118
|
+
this.onlineTarget.hidden = true;
|
|
119
|
+
}, this.onlineAutoHideValue);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export { NetworkStatusController };
|
|
125
|
+
//# sourceMappingURL=network_status_controller.js.map
|
|
126
|
+
//# sourceMappingURL=network_status_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/network_status_controller.ts"],"names":[],"mappings":"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","file":"network_status_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless online/offline banner behavior built on the live-region practice (no\n * dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--network-status`):\n * <div data-controller=\"stimeo--network-status\">\n * <div role=\"alert\" hidden data-stimeo--network-status-target=\"offline\">\n * You are offline.\n * </div>\n * <div role=\"status\" hidden data-stimeo--network-status-target=\"online\">\n * Back online.\n * </div>\n * </div>\n *\n * Reads `navigator.onLine` on connect and subscribes to the `window`\n * `online`/`offline` events, toggling the matching banner. The offline banner is\n * `role=\"alert\"` (assertive) because losing connectivity is urgent; the recovery\n * banner is `role=\"status\"` (polite).\n *\n * @remarks\n * Behavior only. `navigator.onLine` is the browser's *guess* — it does not\n * guarantee server reachability, which stays the consumer's job. To make the\n * announcement reliable across assistive tech (merely un-hiding a static banner\n * is flaky), the controller re-writes the banner's text on each transition,\n * guarded so an unchanged state never re-announces. The event listeners and the\n * auto-hide timer are removed/cleared on `disconnect()` (Turbo included).\n */\nexport class NetworkStatusController extends Controller<HTMLElement> {\n static override targets = [\"offline\", \"online\"];\n static override values = {\n onlineAutoHide: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly offlineTarget: HTMLElement;\n declare readonly onlineTarget: HTMLElement;\n declare readonly hasOfflineTarget: boolean;\n declare readonly hasOnlineTarget: boolean;\n\n declare onlineAutoHideValue: number;\n\n readonly #timers = new SafeTimeout();\n\n /** Last known connectivity; guards against duplicate-state re-announcements. */\n #online = true;\n /** Banner text captured from the markup so transitions can re-write it. */\n #offlineMessage = \"\";\n #onlineMessage = \"\";\n\n readonly #handleOnline = (): void => this.#update(true);\n readonly #handleOffline = (): void => this.#update(false);\n\n override connect(): void {\n this.#offlineMessage = this.hasOfflineTarget\n ? (this.offlineTarget.textContent ?? \"\").trim()\n : \"\";\n this.#onlineMessage = this.hasOnlineTarget ? (this.onlineTarget.textContent ?? \"\").trim() : \"\";\n\n // Normalize initial visibility so a missing `hidden` in the markup cannot\n // strand a stale banner (e.g. an offline notice showing while online).\n if (this.hasOfflineTarget) this.offlineTarget.hidden = true;\n if (this.hasOnlineTarget) this.onlineTarget.hidden = true;\n\n this.#online = navigator.onLine;\n // On connect, surface only the offline state; do not flash a \"back online\"\n // banner just because the page loaded while connected.\n this.element.setAttribute(\"data-state\", this.#online ? \"online\" : \"offline\");\n if (!this.#online) this.#showOffline();\n\n window.addEventListener(\"online\", this.#handleOnline);\n window.addEventListener(\"offline\", this.#handleOffline);\n }\n\n override disconnect(): void {\n window.removeEventListener(\"online\", this.#handleOnline);\n window.removeEventListener(\"offline\", this.#handleOffline);\n this.#timers.clearAll();\n }\n\n /** Applies a connectivity transition, guarded against duplicate states. */\n #update(online: boolean): void {\n if (online === this.#online) return;\n this.#online = online;\n this.element.setAttribute(\"data-state\", online ? \"online\" : \"offline\");\n if (online) {\n this.#showOnline();\n } else {\n this.#showOffline();\n }\n this.dispatch(\"change\", { detail: { online } });\n }\n\n /** Shows the offline banner and hides the recovery banner. */\n #showOffline(): void {\n this.#timers.clearAll();\n if (this.hasOnlineTarget) this.onlineTarget.hidden = true;\n if (this.hasOfflineTarget) {\n this.offlineTarget.textContent = this.#offlineMessage;\n this.offlineTarget.hidden = false;\n }\n }\n\n /** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */\n #showOnline(): void {\n if (this.hasOfflineTarget) this.offlineTarget.hidden = true;\n if (!this.hasOnlineTarget) return;\n this.onlineTarget.textContent = this.#onlineMessage;\n this.onlineTarget.hidden = false;\n if (this.onlineAutoHideValue > 0) {\n this.#timers.set(() => {\n this.onlineTarget.hidden = true;\n }, this.onlineAutoHideValue);\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible number / spin-button behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--number-input`):
|
|
7
|
+
* <div data-controller="stimeo--number-input"
|
|
8
|
+
* data-stimeo--number-input-min-value="0"
|
|
9
|
+
* data-stimeo--number-input-max-value="100"
|
|
10
|
+
* data-stimeo--number-input-step-value="1">
|
|
11
|
+
* <button type="button" aria-label="Decrease" tabindex="-1"
|
|
12
|
+
* data-stimeo--number-input-target="decrement"
|
|
13
|
+
* data-action="click->stimeo--number-input#decrement">−</button>
|
|
14
|
+
* <input type="number" min="0" max="100" step="1" value="0"
|
|
15
|
+
* data-stimeo--number-input-target="input"
|
|
16
|
+
* data-action="change->stimeo--number-input#onInput
|
|
17
|
+
* keydown->stimeo--number-input#onKeydown" />
|
|
18
|
+
* <button type="button" aria-label="Increase" tabindex="-1"
|
|
19
|
+
* data-stimeo--number-input-target="increment"
|
|
20
|
+
* data-action="click->stimeo--number-input#increment">+</button>
|
|
21
|
+
* </div>
|
|
22
|
+
*
|
|
23
|
+
* Implements the WAI-ARIA APG **Spinbutton** pattern. The arrow/step logic is
|
|
24
|
+
* fully owned by the controller (not delegated to the browser's native number
|
|
25
|
+
* stepping) so the behavior is identical for a native `<input type="number">` and
|
|
26
|
+
* a custom `role="spinbutton"` host, and deterministic under happy-dom.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Behavior only — the consumer styles the field and buttons. The input is the
|
|
30
|
+
* sole Tab stop; the buttons are `tabindex="-1"` and keep focus on the input
|
|
31
|
+
* (pointerdown is suppressed), so stepping never moves focus away.
|
|
32
|
+
*
|
|
33
|
+
* Behavior provided:
|
|
34
|
+
* - `ArrowUp`/`ArrowDown` step by `step`; `PageUp`/`PageDown` by `pageStep`
|
|
35
|
+
* (default `step × 10`); `Home`/`End` jump to a finite `min`/`max`.
|
|
36
|
+
* - Increment/decrement buttons step too, and are `disabled` at the bounds (focus
|
|
37
|
+
* is returned to the input before a focused button is disabled).
|
|
38
|
+
* - **Press-and-hold auto-repeat**: holding a step button steps once, then after a
|
|
39
|
+
* short delay repeats until release, the bound is reached, or the element
|
|
40
|
+
* disconnects. The `click` binding stays the single-step path (a quick click, a
|
|
41
|
+
* synthesized/programmatic click, or assistive activation), so a normal click
|
|
42
|
+
* never double-steps — the trailing click after a hold is swallowed.
|
|
43
|
+
* - Typed input is clamped and snapped to the step grid on `change`;
|
|
44
|
+
* `stimeo--number-input:change` is dispatched on every committed change.
|
|
45
|
+
*/
|
|
46
|
+
declare class NumberInputController extends Controller<HTMLElement> {
|
|
47
|
+
#private;
|
|
48
|
+
static targets: string[];
|
|
49
|
+
static values: {
|
|
50
|
+
min: {
|
|
51
|
+
type: NumberConstructor;
|
|
52
|
+
default: number;
|
|
53
|
+
};
|
|
54
|
+
max: {
|
|
55
|
+
type: NumberConstructor;
|
|
56
|
+
default: number;
|
|
57
|
+
};
|
|
58
|
+
step: {
|
|
59
|
+
type: NumberConstructor;
|
|
60
|
+
default: number;
|
|
61
|
+
};
|
|
62
|
+
pageStep: {
|
|
63
|
+
type: NumberConstructor;
|
|
64
|
+
default: number;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
static actions: readonly ["decrement", "increment", "onInput", "onKeydown"];
|
|
68
|
+
static events: readonly ["change"];
|
|
69
|
+
readonly inputTarget: HTMLInputElement;
|
|
70
|
+
readonly hasInputTarget: boolean;
|
|
71
|
+
readonly incrementTarget: HTMLButtonElement;
|
|
72
|
+
readonly hasIncrementTarget: boolean;
|
|
73
|
+
readonly decrementTarget: HTMLButtonElement;
|
|
74
|
+
readonly hasDecrementTarget: boolean;
|
|
75
|
+
minValue: number;
|
|
76
|
+
maxValue: number;
|
|
77
|
+
stepValue: number;
|
|
78
|
+
pageStepValue: number;
|
|
79
|
+
/** Normalizes any initial value and wires the focus/hold pointer guards. */
|
|
80
|
+
connect(): void;
|
|
81
|
+
/** Releases the pointer guards and tears down every pending timer and hold state. */
|
|
82
|
+
disconnect(): void;
|
|
83
|
+
/** Increases by one step. Bound via `data-action` (click). */
|
|
84
|
+
increment(): void;
|
|
85
|
+
/** Decreases by one step. Bound via `data-action` (click). */
|
|
86
|
+
decrement(): void;
|
|
87
|
+
/** Clamps and snaps a typed value. Bound via `data-action` (change). */
|
|
88
|
+
onInput(): void;
|
|
89
|
+
/** Keyboard stepping per the APG spinbutton model. */
|
|
90
|
+
onKeydown(event: KeyboardEvent): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { NumberInputController };
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/number_input_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
|
+
var SafeInterval = class extends TimerRegistry {
|
|
58
|
+
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
59
|
+
set(callback, delay) {
|
|
60
|
+
const id = this.schedule(callback, delay);
|
|
61
|
+
this.ids.add(id);
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
schedule(callback, delay) {
|
|
65
|
+
return window.setInterval(callback, delay);
|
|
66
|
+
}
|
|
67
|
+
cancel(id) {
|
|
68
|
+
window.clearInterval(id);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// src/controllers/number_input_controller.ts
|
|
73
|
+
var NumberInputController = class _NumberInputController extends Controller {
|
|
74
|
+
static targets = ["input", "increment", "decrement"];
|
|
75
|
+
static values = {
|
|
76
|
+
min: { type: Number, default: Number.NEGATIVE_INFINITY },
|
|
77
|
+
max: { type: Number, default: Number.POSITIVE_INFINITY },
|
|
78
|
+
step: { type: Number, default: 1 },
|
|
79
|
+
pageStep: { type: Number, default: 0 }
|
|
80
|
+
};
|
|
81
|
+
static actions = ["decrement", "increment", "onInput", "onKeydown"];
|
|
82
|
+
static events = ["change"];
|
|
83
|
+
/** Delay (ms) a button must be held before auto-repeat starts. */
|
|
84
|
+
static #HOLD_DELAY_MS = 400;
|
|
85
|
+
/** Interval (ms) between auto-repeat steps once a hold has started. */
|
|
86
|
+
static #HOLD_REPEAT_MS = 80;
|
|
87
|
+
/**
|
|
88
|
+
* Window (ms) after a hold ends during which the trailing synthetic `click` is
|
|
89
|
+
* swallowed. A safety net for the (rare) case where that click never arrives
|
|
90
|
+
* (e.g. the pointer was released off the button): the flag self-clears instead
|
|
91
|
+
* of poisoning the next legitimate click.
|
|
92
|
+
*/
|
|
93
|
+
static #SUPPRESS_RESET_MS = 250;
|
|
94
|
+
/** Aborts the pointer listeners on disconnect so none outlive the element. */
|
|
95
|
+
#guards = null;
|
|
96
|
+
/** Timers for the hold delay and the suppress-reset safety net. */
|
|
97
|
+
#holdTimeouts = new SafeTimeout();
|
|
98
|
+
/** The running auto-repeat interval (one at a time). */
|
|
99
|
+
#holdIntervals = new SafeInterval();
|
|
100
|
+
/** True while a hold is armed/running, making `#stopHold` idempotent. */
|
|
101
|
+
#holdActive = false;
|
|
102
|
+
/** True once a hold actually produced a repeated step (vs. a quick click). */
|
|
103
|
+
#repeatedDuringHold = false;
|
|
104
|
+
/** True when the next `click` is the trailing one after a hold and must be ignored. */
|
|
105
|
+
#suppressNextClick = false;
|
|
106
|
+
/** Normalizes any initial value and wires the focus/hold pointer guards. */
|
|
107
|
+
connect() {
|
|
108
|
+
if (!this.hasInputTarget) return;
|
|
109
|
+
if (this.inputTarget.value.trim() !== "") {
|
|
110
|
+
this.#write(this.#normalize(this.#currentValue()));
|
|
111
|
+
} else {
|
|
112
|
+
this.#updateButtons(this.#currentValue());
|
|
113
|
+
}
|
|
114
|
+
this.#guards = new AbortController();
|
|
115
|
+
const { signal } = this.#guards;
|
|
116
|
+
if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, this.stepValue, signal);
|
|
117
|
+
if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -this.stepValue, signal);
|
|
118
|
+
window.addEventListener("pointerup", this.#stopHold, { signal });
|
|
119
|
+
window.addEventListener("pointercancel", this.#stopHold, { signal });
|
|
120
|
+
window.addEventListener("blur", this.#stopHold, { signal });
|
|
121
|
+
}
|
|
122
|
+
/** Releases the pointer guards and tears down every pending timer and hold state. */
|
|
123
|
+
disconnect() {
|
|
124
|
+
this.#guards?.abort();
|
|
125
|
+
this.#guards = null;
|
|
126
|
+
this.#holdActive = false;
|
|
127
|
+
this.#repeatedDuringHold = false;
|
|
128
|
+
this.#suppressNextClick = false;
|
|
129
|
+
this.#holdTimeouts.clearAll();
|
|
130
|
+
this.#holdIntervals.clearAll();
|
|
131
|
+
}
|
|
132
|
+
/** Increases by one step. Bound via `data-action` (click). */
|
|
133
|
+
increment() {
|
|
134
|
+
if (this.#consumeSuppressedClick()) return;
|
|
135
|
+
this.#commit(this.#currentValue() + this.stepValue);
|
|
136
|
+
this.inputTarget.focus();
|
|
137
|
+
}
|
|
138
|
+
/** Decreases by one step. Bound via `data-action` (click). */
|
|
139
|
+
decrement() {
|
|
140
|
+
if (this.#consumeSuppressedClick()) return;
|
|
141
|
+
this.#commit(this.#currentValue() - this.stepValue);
|
|
142
|
+
this.inputTarget.focus();
|
|
143
|
+
}
|
|
144
|
+
/** Clamps and snaps a typed value. Bound via `data-action` (change). */
|
|
145
|
+
onInput() {
|
|
146
|
+
if (this.inputTarget.value.trim() === "") return;
|
|
147
|
+
this.#commit(this.#currentValue());
|
|
148
|
+
}
|
|
149
|
+
/** Keyboard stepping per the APG spinbutton model. */
|
|
150
|
+
onKeydown(event) {
|
|
151
|
+
const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
|
|
152
|
+
let next = null;
|
|
153
|
+
switch (event.key) {
|
|
154
|
+
case "ArrowUp":
|
|
155
|
+
next = this.#currentValue() + this.stepValue;
|
|
156
|
+
break;
|
|
157
|
+
case "ArrowDown":
|
|
158
|
+
next = this.#currentValue() - this.stepValue;
|
|
159
|
+
break;
|
|
160
|
+
case "PageUp":
|
|
161
|
+
next = this.#currentValue() + page;
|
|
162
|
+
break;
|
|
163
|
+
case "PageDown":
|
|
164
|
+
next = this.#currentValue() - page;
|
|
165
|
+
break;
|
|
166
|
+
case "Home":
|
|
167
|
+
if (!Number.isFinite(this.minValue)) return;
|
|
168
|
+
next = this.minValue;
|
|
169
|
+
break;
|
|
170
|
+
case "End":
|
|
171
|
+
if (!Number.isFinite(this.maxValue)) return;
|
|
172
|
+
next = this.maxValue;
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
this.#commit(next);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Wires a step button: `pointerdown` keeps focus on the input and arms the
|
|
182
|
+
* hold; leaving the button while held stops it (the global listeners cover
|
|
183
|
+
* release/cancel/blur).
|
|
184
|
+
*/
|
|
185
|
+
#wireButton(button, delta, signal) {
|
|
186
|
+
button.addEventListener("pointerdown", (event) => this.#armHold(event, button, delta), {
|
|
187
|
+
signal
|
|
188
|
+
});
|
|
189
|
+
button.addEventListener("pointerleave", this.#stopHold, { signal });
|
|
190
|
+
}
|
|
191
|
+
/** Starts a hold: focus the input, then schedule the first repeat after a delay. */
|
|
192
|
+
#armHold(event, button, delta) {
|
|
193
|
+
const pointerButton = event.button;
|
|
194
|
+
if (typeof pointerButton === "number" && pointerButton !== 0) return;
|
|
195
|
+
if (button.disabled) return;
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
this.inputTarget.focus();
|
|
198
|
+
this.#stopHold();
|
|
199
|
+
this.#holdActive = true;
|
|
200
|
+
this.#repeatedDuringHold = false;
|
|
201
|
+
this.#suppressNextClick = false;
|
|
202
|
+
this.#holdTimeouts.set(() => {
|
|
203
|
+
if (!this.#commit(this.#currentValue() + delta)) {
|
|
204
|
+
this.#stopHold();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.#repeatedDuringHold = true;
|
|
208
|
+
this.#holdIntervals.set(() => {
|
|
209
|
+
if (!this.#commit(this.#currentValue() + delta)) this.#stopHold();
|
|
210
|
+
}, _NumberInputController.#HOLD_REPEAT_MS);
|
|
211
|
+
}, _NumberInputController.#HOLD_DELAY_MS);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Stops the current hold (idempotent). When the hold actually repeated, the
|
|
215
|
+
* trailing synthetic `click` must be ignored, so it is suppressed until the
|
|
216
|
+
* click consumes it or a short safety-net timeout clears it.
|
|
217
|
+
*/
|
|
218
|
+
#stopHold = () => {
|
|
219
|
+
if (!this.#holdActive) return;
|
|
220
|
+
this.#holdActive = false;
|
|
221
|
+
this.#holdTimeouts.clearAll();
|
|
222
|
+
this.#holdIntervals.clearAll();
|
|
223
|
+
if (this.#repeatedDuringHold) {
|
|
224
|
+
this.#suppressNextClick = true;
|
|
225
|
+
this.#holdTimeouts.set(() => {
|
|
226
|
+
this.#suppressNextClick = false;
|
|
227
|
+
}, _NumberInputController.#SUPPRESS_RESET_MS);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/** Consumes a pending trailing-click suppression; returns true if the click was swallowed. */
|
|
231
|
+
#consumeSuppressedClick() {
|
|
232
|
+
if (!this.#suppressNextClick) return false;
|
|
233
|
+
this.#suppressNextClick = false;
|
|
234
|
+
this.#holdTimeouts.clearAll();
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Normalizes `raw`, reflects it, and dispatches `change` only when the
|
|
239
|
+
* displayed value actually changes.
|
|
240
|
+
*
|
|
241
|
+
* @returns Whether the value changed (drives the auto-repeat's bound stop).
|
|
242
|
+
*/
|
|
243
|
+
#commit(raw) {
|
|
244
|
+
const value = this.#normalize(raw);
|
|
245
|
+
const changed = this.inputTarget.value !== String(value);
|
|
246
|
+
this.#write(value);
|
|
247
|
+
if (changed) this.dispatch("change", { detail: { value } });
|
|
248
|
+
return changed;
|
|
249
|
+
}
|
|
250
|
+
/** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
|
|
251
|
+
#write(value) {
|
|
252
|
+
this.inputTarget.value = String(value);
|
|
253
|
+
if (this.inputTarget.getAttribute("role") === "spinbutton") {
|
|
254
|
+
this.inputTarget.setAttribute("aria-valuenow", String(value));
|
|
255
|
+
if (Number.isFinite(this.minValue)) {
|
|
256
|
+
this.inputTarget.setAttribute("aria-valuemin", String(this.minValue));
|
|
257
|
+
}
|
|
258
|
+
if (Number.isFinite(this.maxValue)) {
|
|
259
|
+
this.inputTarget.setAttribute("aria-valuemax", String(this.maxValue));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
this.#updateButtons(value);
|
|
263
|
+
}
|
|
264
|
+
/** Disables a step button at its bound, returning focus to the input first. */
|
|
265
|
+
#updateButtons(value) {
|
|
266
|
+
if (this.hasIncrementTarget) this.#toggleButton(this.incrementTarget, value < this.maxValue);
|
|
267
|
+
if (this.hasDecrementTarget) this.#toggleButton(this.decrementTarget, value > this.minValue);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Enables or disables a step button at its bound, never disabling one while it
|
|
271
|
+
* holds focus. Owns only the `disabled` it sets itself via a marker
|
|
272
|
+
* (`data-number-input-disabled`, like `conditional-fields`/`submit-once`), so an
|
|
273
|
+
* author-disabled button (e.g. the whole control disabled) is never re-enabled.
|
|
274
|
+
*/
|
|
275
|
+
#toggleButton(button, enabled) {
|
|
276
|
+
if (enabled) {
|
|
277
|
+
if (button.hasAttribute("data-number-input-disabled")) {
|
|
278
|
+
button.disabled = false;
|
|
279
|
+
button.removeAttribute("data-number-input-disabled");
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (button.disabled) return;
|
|
284
|
+
if (document.activeElement === button) this.inputTarget.focus();
|
|
285
|
+
button.disabled = true;
|
|
286
|
+
button.setAttribute("data-number-input-disabled", "");
|
|
287
|
+
}
|
|
288
|
+
/** The current numeric value, falling back to a finite min (else 0) when blank. */
|
|
289
|
+
#currentValue() {
|
|
290
|
+
const parsed = Number(this.inputTarget.value);
|
|
291
|
+
if (Number.isFinite(parsed) && this.inputTarget.value.trim() !== "") return parsed;
|
|
292
|
+
return Number.isFinite(this.minValue) ? this.minValue : 0;
|
|
293
|
+
}
|
|
294
|
+
/** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */
|
|
295
|
+
#normalize(raw) {
|
|
296
|
+
const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
|
|
297
|
+
if (this.stepValue <= 0) return clamped;
|
|
298
|
+
const base = Number.isFinite(this.minValue) ? this.minValue : 0;
|
|
299
|
+
const stepped = Math.round((clamped - base) / this.stepValue) * this.stepValue + base;
|
|
300
|
+
return Math.min(this.maxValue, Math.max(this.minValue, stepped));
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
export { NumberInputController };
|
|
305
|
+
//# sourceMappingURL=number_input_controller.js.map
|
|
306
|
+
//# sourceMappingURL=number_input_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/number_input_controller.ts"],"names":[],"mappings":"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","file":"number_input_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeInterval, SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless, accessible number / spin-button behavior.\n *\n * Markup contract (identifier: `stimeo--number-input`):\n * <div data-controller=\"stimeo--number-input\"\n * data-stimeo--number-input-min-value=\"0\"\n * data-stimeo--number-input-max-value=\"100\"\n * data-stimeo--number-input-step-value=\"1\">\n * <button type=\"button\" aria-label=\"Decrease\" tabindex=\"-1\"\n * data-stimeo--number-input-target=\"decrement\"\n * data-action=\"click->stimeo--number-input#decrement\">−</button>\n * <input type=\"number\" min=\"0\" max=\"100\" step=\"1\" value=\"0\"\n * data-stimeo--number-input-target=\"input\"\n * data-action=\"change->stimeo--number-input#onInput\n * keydown->stimeo--number-input#onKeydown\" />\n * <button type=\"button\" aria-label=\"Increase\" tabindex=\"-1\"\n * data-stimeo--number-input-target=\"increment\"\n * data-action=\"click->stimeo--number-input#increment\">+</button>\n * </div>\n *\n * Implements the WAI-ARIA APG **Spinbutton** pattern. The arrow/step logic is\n * fully owned by the controller (not delegated to the browser's native number\n * stepping) so the behavior is identical for a native `<input type=\"number\">` and\n * a custom `role=\"spinbutton\"` host, and deterministic under happy-dom.\n *\n * @remarks\n * Behavior only — the consumer styles the field and buttons. The input is the\n * sole Tab stop; the buttons are `tabindex=\"-1\"` and keep focus on the input\n * (pointerdown is suppressed), so stepping never moves focus away.\n *\n * Behavior provided:\n * - `ArrowUp`/`ArrowDown` step by `step`; `PageUp`/`PageDown` by `pageStep`\n * (default `step × 10`); `Home`/`End` jump to a finite `min`/`max`.\n * - Increment/decrement buttons step too, and are `disabled` at the bounds (focus\n * is returned to the input before a focused button is disabled).\n * - **Press-and-hold auto-repeat**: holding a step button steps once, then after a\n * short delay repeats until release, the bound is reached, or the element\n * disconnects. The `click` binding stays the single-step path (a quick click, a\n * synthesized/programmatic click, or assistive activation), so a normal click\n * never double-steps — the trailing click after a hold is swallowed.\n * - Typed input is clamped and snapped to the step grid on `change`;\n * `stimeo--number-input:change` is dispatched on every committed change.\n */\nexport class NumberInputController extends Controller<HTMLElement> {\n static override targets = [\"input\", \"increment\", \"decrement\"];\n static override values = {\n min: { type: Number, default: Number.NEGATIVE_INFINITY },\n max: { type: Number, default: Number.POSITIVE_INFINITY },\n step: { type: Number, default: 1 },\n pageStep: { type: Number, default: 0 },\n };\n static actions = [\"decrement\", \"increment\", \"onInput\", \"onKeydown\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly inputTarget: HTMLInputElement;\n declare readonly hasInputTarget: boolean;\n declare readonly incrementTarget: HTMLButtonElement;\n declare readonly hasIncrementTarget: boolean;\n declare readonly decrementTarget: HTMLButtonElement;\n declare readonly hasDecrementTarget: boolean;\n declare minValue: number;\n declare maxValue: number;\n declare stepValue: number;\n declare pageStepValue: number;\n\n /** Delay (ms) a button must be held before auto-repeat starts. */\n static readonly #HOLD_DELAY_MS = 400;\n /** Interval (ms) between auto-repeat steps once a hold has started. */\n static readonly #HOLD_REPEAT_MS = 80;\n /**\n * Window (ms) after a hold ends during which the trailing synthetic `click` is\n * swallowed. A safety net for the (rare) case where that click never arrives\n * (e.g. the pointer was released off the button): the flag self-clears instead\n * of poisoning the next legitimate click.\n */\n static readonly #SUPPRESS_RESET_MS = 250;\n\n /** Aborts the pointer listeners on disconnect so none outlive the element. */\n #guards: AbortController | null = null;\n /** Timers for the hold delay and the suppress-reset safety net. */\n readonly #holdTimeouts = new SafeTimeout();\n /** The running auto-repeat interval (one at a time). */\n readonly #holdIntervals = new SafeInterval();\n /** True while a hold is armed/running, making `#stopHold` idempotent. */\n #holdActive = false;\n /** True once a hold actually produced a repeated step (vs. a quick click). */\n #repeatedDuringHold = false;\n /** True when the next `click` is the trailing one after a hold and must be ignored. */\n #suppressNextClick = false;\n\n /** Normalizes any initial value and wires the focus/hold pointer guards. */\n override connect(): void {\n if (!this.hasInputTarget) return;\n if (this.inputTarget.value.trim() !== \"\") {\n this.#write(this.#normalize(this.#currentValue()));\n } else {\n this.#updateButtons(this.#currentValue());\n }\n this.#guards = new AbortController();\n const { signal } = this.#guards;\n if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, this.stepValue, signal);\n if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -this.stepValue, signal);\n // A pointer released or focus lost anywhere stops a running hold, even when\n // the button stopped receiving its own pointerup (e.g. it became disabled at\n // the bound, or the pointer was released off it).\n window.addEventListener(\"pointerup\", this.#stopHold, { signal });\n window.addEventListener(\"pointercancel\", this.#stopHold, { signal });\n window.addEventListener(\"blur\", this.#stopHold, { signal });\n }\n\n /** Releases the pointer guards and tears down every pending timer and hold state. */\n override disconnect(): void {\n this.#guards?.abort();\n this.#guards = null;\n this.#holdActive = false;\n this.#repeatedDuringHold = false;\n this.#suppressNextClick = false;\n this.#holdTimeouts.clearAll();\n this.#holdIntervals.clearAll();\n }\n\n /** Increases by one step. Bound via `data-action` (click). */\n increment(): void {\n if (this.#consumeSuppressedClick()) return;\n this.#commit(this.#currentValue() + this.stepValue);\n this.inputTarget.focus();\n }\n\n /** Decreases by one step. Bound via `data-action` (click). */\n decrement(): void {\n if (this.#consumeSuppressedClick()) return;\n this.#commit(this.#currentValue() - this.stepValue);\n this.inputTarget.focus();\n }\n\n /** Clamps and snaps a typed value. Bound via `data-action` (change). */\n onInput(): void {\n if (this.inputTarget.value.trim() === \"\") return;\n this.#commit(this.#currentValue());\n }\n\n /** Keyboard stepping per the APG spinbutton model. */\n onKeydown(event: KeyboardEvent): void {\n const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;\n let next: number | null = null;\n switch (event.key) {\n case \"ArrowUp\":\n next = this.#currentValue() + this.stepValue;\n break;\n case \"ArrowDown\":\n next = this.#currentValue() - this.stepValue;\n break;\n case \"PageUp\":\n next = this.#currentValue() + page;\n break;\n case \"PageDown\":\n next = this.#currentValue() - page;\n break;\n case \"Home\":\n if (!Number.isFinite(this.minValue)) return;\n next = this.minValue;\n break;\n case \"End\":\n if (!Number.isFinite(this.maxValue)) return;\n next = this.maxValue;\n break;\n default:\n return;\n }\n event.preventDefault();\n this.#commit(next);\n }\n\n /**\n * Wires a step button: `pointerdown` keeps focus on the input and arms the\n * hold; leaving the button while held stops it (the global listeners cover\n * release/cancel/blur).\n */\n #wireButton(button: HTMLButtonElement, delta: number, signal: AbortSignal): void {\n button.addEventListener(\"pointerdown\", (event) => this.#armHold(event, button, delta), {\n signal,\n });\n button.addEventListener(\"pointerleave\", this.#stopHold, { signal });\n }\n\n /** Starts a hold: focus the input, then schedule the first repeat after a delay. */\n #armHold(event: Event, button: HTMLButtonElement, delta: number): void {\n // Ignore secondary buttons (right/middle) when the event exposes one.\n const pointerButton = (event as PointerEvent).button;\n if (typeof pointerButton === \"number\" && pointerButton !== 0) return;\n if (button.disabled) return;\n event.preventDefault(); // keep focus on the input rather than the button\n this.inputTarget.focus();\n\n this.#stopHold();\n this.#holdActive = true;\n this.#repeatedDuringHold = false;\n this.#suppressNextClick = false;\n this.#holdTimeouts.set(() => {\n if (!this.#commit(this.#currentValue() + delta)) {\n this.#stopHold();\n return;\n }\n this.#repeatedDuringHold = true;\n this.#holdIntervals.set(() => {\n if (!this.#commit(this.#currentValue() + delta)) this.#stopHold();\n }, NumberInputController.#HOLD_REPEAT_MS);\n }, NumberInputController.#HOLD_DELAY_MS);\n }\n\n /**\n * Stops the current hold (idempotent). When the hold actually repeated, the\n * trailing synthetic `click` must be ignored, so it is suppressed until the\n * click consumes it or a short safety-net timeout clears it.\n */\n readonly #stopHold = (): void => {\n if (!this.#holdActive) return;\n this.#holdActive = false;\n this.#holdTimeouts.clearAll();\n this.#holdIntervals.clearAll();\n if (this.#repeatedDuringHold) {\n this.#suppressNextClick = true;\n this.#holdTimeouts.set(() => {\n this.#suppressNextClick = false;\n }, NumberInputController.#SUPPRESS_RESET_MS);\n }\n };\n\n /** Consumes a pending trailing-click suppression; returns true if the click was swallowed. */\n #consumeSuppressedClick(): boolean {\n if (!this.#suppressNextClick) return false;\n this.#suppressNextClick = false;\n this.#holdTimeouts.clearAll();\n return true;\n }\n\n /**\n * Normalizes `raw`, reflects it, and dispatches `change` only when the\n * displayed value actually changes.\n *\n * @returns Whether the value changed (drives the auto-repeat's bound stop).\n */\n #commit(raw: number): boolean {\n const value = this.#normalize(raw);\n const changed = this.inputTarget.value !== String(value);\n this.#write(value);\n if (changed) this.dispatch(\"change\", { detail: { value } });\n return changed;\n }\n\n /** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */\n #write(value: number): void {\n this.inputTarget.value = String(value);\n if (this.inputTarget.getAttribute(\"role\") === \"spinbutton\") {\n this.inputTarget.setAttribute(\"aria-valuenow\", String(value));\n if (Number.isFinite(this.minValue)) {\n this.inputTarget.setAttribute(\"aria-valuemin\", String(this.minValue));\n }\n if (Number.isFinite(this.maxValue)) {\n this.inputTarget.setAttribute(\"aria-valuemax\", String(this.maxValue));\n }\n }\n this.#updateButtons(value);\n }\n\n /** Disables a step button at its bound, returning focus to the input first. */\n #updateButtons(value: number): void {\n if (this.hasIncrementTarget) this.#toggleButton(this.incrementTarget, value < this.maxValue);\n if (this.hasDecrementTarget) this.#toggleButton(this.decrementTarget, value > this.minValue);\n }\n\n /**\n * Enables or disables a step button at its bound, never disabling one while it\n * holds focus. Owns only the `disabled` it sets itself via a marker\n * (`data-number-input-disabled`, like `conditional-fields`/`submit-once`), so an\n * author-disabled button (e.g. the whole control disabled) is never re-enabled.\n */\n #toggleButton(button: HTMLButtonElement, enabled: boolean): void {\n if (enabled) {\n if (button.hasAttribute(\"data-number-input-disabled\")) {\n button.disabled = false;\n button.removeAttribute(\"data-number-input-disabled\");\n }\n return;\n }\n if (button.disabled) return; // already disabled (possibly by the author) — leave it\n if (document.activeElement === button) this.inputTarget.focus();\n button.disabled = true;\n button.setAttribute(\"data-number-input-disabled\", \"\");\n }\n\n /** The current numeric value, falling back to a finite min (else 0) when blank. */\n #currentValue(): number {\n const parsed = Number(this.inputTarget.value);\n if (Number.isFinite(parsed) && this.inputTarget.value.trim() !== \"\") return parsed;\n return Number.isFinite(this.minValue) ? this.minValue : 0;\n }\n\n /** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */\n #normalize(raw: number): number {\n const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));\n if (this.stepValue <= 0) return clamped;\n const base = Number.isFinite(this.minValue) ? this.minValue : 0;\n const stepped = Math.round((clamped - base) / this.stepValue) * this.stepValue + base;\n return Math.min(this.maxValue, Math.max(this.minValue, stepped));\n }\n}\n"]}
|