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,62 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless loading-indicator behavior built on the live-region + `aria-busy`
|
|
5
|
+
* practice (no dedicated APG pattern).
|
|
6
|
+
*
|
|
7
|
+
* Markup contract (identifier: `stimeo--spinner`):
|
|
8
|
+
* <div data-controller="stimeo--spinner"
|
|
9
|
+
* data-stimeo--spinner-delay-value="150"
|
|
10
|
+
* data-stimeo--spinner-min-duration-value="500"
|
|
11
|
+
* data-action="loading:start->stimeo--spinner#start
|
|
12
|
+
* loading:stop->stimeo--spinner#stop">
|
|
13
|
+
* <div role="status" aria-live="polite" hidden
|
|
14
|
+
* data-stimeo--spinner-target="indicator">
|
|
15
|
+
* <span data-stimeo--spinner-target="message">Loading…</span>
|
|
16
|
+
* </div>
|
|
17
|
+
* <div aria-busy="false" data-stimeo--spinner-target="region"></div>
|
|
18
|
+
* </div>
|
|
19
|
+
*
|
|
20
|
+
* The indicator is a `role="status"` live region carrying *text* (never an icon
|
|
21
|
+
* alone) so screen readers announce loading; the controlled `region` mirrors the
|
|
22
|
+
* busy state via `aria-busy`. Two timers tame flicker: `delay` suppresses the
|
|
23
|
+
* spinner for fast operations, and `minDuration` keeps it visible long enough to
|
|
24
|
+
* be perceived once shown.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* Behavior only — the visual spinner is the consumer's, alongside the text and
|
|
28
|
+
* `aria-hidden="true"`. Both timers are owned by {@link SafeTimeout} and torn
|
|
29
|
+
* down on `disconnect()` (Turbo navigation included).
|
|
30
|
+
*/
|
|
31
|
+
declare class SpinnerController extends Controller<HTMLElement> {
|
|
32
|
+
#private;
|
|
33
|
+
static targets: string[];
|
|
34
|
+
static values: {
|
|
35
|
+
delay: {
|
|
36
|
+
type: NumberConstructor;
|
|
37
|
+
default: number;
|
|
38
|
+
};
|
|
39
|
+
minDuration: {
|
|
40
|
+
type: NumberConstructor;
|
|
41
|
+
default: number;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
static actions: readonly ["start", "stop"];
|
|
45
|
+
static events: readonly ["hide", "show"];
|
|
46
|
+
readonly indicatorTarget: HTMLElement;
|
|
47
|
+
readonly regionTarget: HTMLElement;
|
|
48
|
+
readonly messageTarget: HTMLElement;
|
|
49
|
+
readonly hasIndicatorTarget: boolean;
|
|
50
|
+
readonly hasRegionTarget: boolean;
|
|
51
|
+
readonly hasMessageTarget: boolean;
|
|
52
|
+
delayValue: number;
|
|
53
|
+
minDurationValue: number;
|
|
54
|
+
connect(): void;
|
|
55
|
+
disconnect(): void;
|
|
56
|
+
/** Begins loading. Honors `delay` before the spinner actually appears. */
|
|
57
|
+
start(): void;
|
|
58
|
+
/** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
|
|
59
|
+
stop(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { SpinnerController };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/spinner_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/spinner_controller.ts
|
|
59
|
+
var SpinnerController = class extends Controller {
|
|
60
|
+
static targets = ["indicator", "region", "message"];
|
|
61
|
+
static values = {
|
|
62
|
+
delay: { type: Number, default: 0 },
|
|
63
|
+
minDuration: { type: Number, default: 0 }
|
|
64
|
+
};
|
|
65
|
+
static actions = ["start", "stop"];
|
|
66
|
+
static events = ["hide", "show"];
|
|
67
|
+
#timers = new SafeTimeout();
|
|
68
|
+
/** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
|
|
69
|
+
#delayTimerId = null;
|
|
70
|
+
/** Pending min-duration hide timer id, or `null` when none is scheduled. */
|
|
71
|
+
#hideTimerId = null;
|
|
72
|
+
/** Epoch ms when the spinner became visible, used to enforce `minDuration`. */
|
|
73
|
+
#shownAt = 0;
|
|
74
|
+
connect() {
|
|
75
|
+
if (!this.element.hasAttribute("data-state")) {
|
|
76
|
+
this.element.setAttribute("data-state", "idle");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
disconnect() {
|
|
80
|
+
this.#timers.clearAll();
|
|
81
|
+
this.#delayTimerId = null;
|
|
82
|
+
this.#hideTimerId = null;
|
|
83
|
+
}
|
|
84
|
+
/** Begins loading. Honors `delay` before the spinner actually appears. */
|
|
85
|
+
start() {
|
|
86
|
+
if (this.#state === "loading") {
|
|
87
|
+
this.#setBusy(true);
|
|
88
|
+
this.#cancelHide();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (this.#state !== "idle") return;
|
|
92
|
+
this.#setBusy(true);
|
|
93
|
+
this.#cancelHide();
|
|
94
|
+
if (this.delayValue > 0) {
|
|
95
|
+
this.element.setAttribute("data-state", "pending");
|
|
96
|
+
this.#delayTimerId = this.#timers.set(() => {
|
|
97
|
+
this.#delayTimerId = null;
|
|
98
|
+
this.#show();
|
|
99
|
+
}, this.delayValue);
|
|
100
|
+
} else {
|
|
101
|
+
this.#show();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
|
|
105
|
+
stop() {
|
|
106
|
+
const state = this.#state;
|
|
107
|
+
if (state === "pending") {
|
|
108
|
+
this.#cancelDelay();
|
|
109
|
+
this.#setBusy(false);
|
|
110
|
+
this.element.setAttribute("data-state", "idle");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (state !== "loading") return;
|
|
114
|
+
this.#setBusy(false);
|
|
115
|
+
const remaining = this.minDurationValue - (Date.now() - this.#shownAt);
|
|
116
|
+
if (remaining > 0) {
|
|
117
|
+
this.#hideTimerId = this.#timers.set(() => {
|
|
118
|
+
this.#hideTimerId = null;
|
|
119
|
+
this.#hide();
|
|
120
|
+
}, remaining);
|
|
121
|
+
} else {
|
|
122
|
+
this.#hide();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Reveals the indicator, marks the moment shown, and announces via the live region. */
|
|
126
|
+
#show() {
|
|
127
|
+
this.#shownAt = Date.now();
|
|
128
|
+
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
|
|
129
|
+
this.element.setAttribute("data-state", "loading");
|
|
130
|
+
this.dispatch("show", { detail: {} });
|
|
131
|
+
}
|
|
132
|
+
/** Hides the indicator and returns to the idle state. */
|
|
133
|
+
#hide() {
|
|
134
|
+
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
|
|
135
|
+
this.element.setAttribute("data-state", "idle");
|
|
136
|
+
this.dispatch("hide", { detail: {} });
|
|
137
|
+
}
|
|
138
|
+
/** Reflects busy state onto the controlled region (if present). */
|
|
139
|
+
#setBusy(busy) {
|
|
140
|
+
if (this.hasRegionTarget) {
|
|
141
|
+
this.regionTarget.setAttribute("aria-busy", String(busy));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
#cancelDelay() {
|
|
145
|
+
if (this.#delayTimerId !== null) {
|
|
146
|
+
this.#timers.clear(this.#delayTimerId);
|
|
147
|
+
this.#delayTimerId = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
#cancelHide() {
|
|
151
|
+
if (this.#hideTimerId !== null) {
|
|
152
|
+
this.#timers.clear(this.#hideTimerId);
|
|
153
|
+
this.#hideTimerId = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Current lifecycle phase as reflected on `data-state`. */
|
|
157
|
+
get #state() {
|
|
158
|
+
return this.element.getAttribute("data-state") ?? "idle";
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export { SpinnerController };
|
|
163
|
+
//# sourceMappingURL=spinner_controller.js.map
|
|
164
|
+
//# sourceMappingURL=spinner_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/spinner_controller.ts"],"names":[],"mappings":"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","file":"spinner_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 loading-indicator behavior built on the live-region + `aria-busy`\n * practice (no dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--spinner`):\n * <div data-controller=\"stimeo--spinner\"\n * data-stimeo--spinner-delay-value=\"150\"\n * data-stimeo--spinner-min-duration-value=\"500\"\n * data-action=\"loading:start->stimeo--spinner#start\n * loading:stop->stimeo--spinner#stop\">\n * <div role=\"status\" aria-live=\"polite\" hidden\n * data-stimeo--spinner-target=\"indicator\">\n * <span data-stimeo--spinner-target=\"message\">Loading…</span>\n * </div>\n * <div aria-busy=\"false\" data-stimeo--spinner-target=\"region\"></div>\n * </div>\n *\n * The indicator is a `role=\"status\"` live region carrying *text* (never an icon\n * alone) so screen readers announce loading; the controlled `region` mirrors the\n * busy state via `aria-busy`. Two timers tame flicker: `delay` suppresses the\n * spinner for fast operations, and `minDuration` keeps it visible long enough to\n * be perceived once shown.\n *\n * @remarks\n * Behavior only — the visual spinner is the consumer's, alongside the text and\n * `aria-hidden=\"true\"`. Both timers are owned by {@link SafeTimeout} and torn\n * down on `disconnect()` (Turbo navigation included).\n */\nexport class SpinnerController extends Controller<HTMLElement> {\n static override targets = [\"indicator\", \"region\", \"message\"];\n static override values = {\n delay: { type: Number, default: 0 },\n minDuration: { type: Number, default: 0 },\n };\n static actions = [\"start\", \"stop\"] as const;\n static events = [\"hide\", \"show\"] as const;\n\n declare readonly indicatorTarget: HTMLElement;\n declare readonly regionTarget: HTMLElement;\n declare readonly messageTarget: HTMLElement;\n declare readonly hasIndicatorTarget: boolean;\n declare readonly hasRegionTarget: boolean;\n declare readonly hasMessageTarget: boolean;\n\n declare delayValue: number;\n declare minDurationValue: number;\n\n readonly #timers = new SafeTimeout();\n\n /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */\n #delayTimerId: number | null = null;\n /** Pending min-duration hide timer id, or `null` when none is scheduled. */\n #hideTimerId: number | null = null;\n /** Epoch ms when the spinner became visible, used to enforce `minDuration`. */\n #shownAt = 0;\n\n override connect(): void {\n if (!this.element.hasAttribute(\"data-state\")) {\n this.element.setAttribute(\"data-state\", \"idle\");\n }\n }\n\n override disconnect(): void {\n this.#timers.clearAll();\n this.#delayTimerId = null;\n this.#hideTimerId = null;\n }\n\n /** Begins loading. Honors `delay` before the spinner actually appears. */\n start(): void {\n if (this.#state === \"loading\") {\n // Already shown (possibly waiting out `minDuration` after a stop): a quick\n // stop→start within that window must keep the spinner visible. Restore the\n // busy state and cancel the pending hide instead of returning a no-op, which\n // would let the stale hide fire and flicker the spinner away mid-load.\n this.#setBusy(true);\n this.#cancelHide();\n return;\n }\n if (this.#state !== \"idle\") return;\n this.#setBusy(true);\n // A pending hide from a previous cycle is now stale.\n this.#cancelHide();\n if (this.delayValue > 0) {\n this.element.setAttribute(\"data-state\", \"pending\");\n this.#delayTimerId = this.#timers.set(() => {\n this.#delayTimerId = null;\n this.#show();\n }, this.delayValue);\n } else {\n this.#show();\n }\n }\n\n /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */\n stop(): void {\n const state = this.#state;\n if (state === \"pending\") {\n // The delay never elapsed — the spinner never appeared, so just cancel.\n this.#cancelDelay();\n this.#setBusy(false);\n this.element.setAttribute(\"data-state\", \"idle\");\n return;\n }\n if (state !== \"loading\") return;\n\n this.#setBusy(false);\n const remaining = this.minDurationValue - (Date.now() - this.#shownAt);\n if (remaining > 0) {\n this.#hideTimerId = this.#timers.set(() => {\n this.#hideTimerId = null;\n this.#hide();\n }, remaining);\n } else {\n this.#hide();\n }\n }\n\n /** Reveals the indicator, marks the moment shown, and announces via the live region. */\n #show(): void {\n this.#shownAt = Date.now();\n if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;\n this.element.setAttribute(\"data-state\", \"loading\");\n this.dispatch(\"show\", { detail: {} });\n }\n\n /** Hides the indicator and returns to the idle state. */\n #hide(): void {\n if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;\n this.element.setAttribute(\"data-state\", \"idle\");\n this.dispatch(\"hide\", { detail: {} });\n }\n\n /** Reflects busy state onto the controlled region (if present). */\n #setBusy(busy: boolean): void {\n if (this.hasRegionTarget) {\n this.regionTarget.setAttribute(\"aria-busy\", String(busy));\n }\n }\n\n #cancelDelay(): void {\n if (this.#delayTimerId !== null) {\n this.#timers.clear(this.#delayTimerId);\n this.#delayTimerId = null;\n }\n }\n\n #cancelHide(): void {\n if (this.#hideTimerId !== null) {\n this.#timers.clear(this.#hideTimerId);\n this.#hideTimerId = null;\n }\n }\n\n /** Current lifecycle phase as reflected on `data-state`. */\n get #state(): string {\n return this.element.getAttribute(\"data-state\") ?? \"idle\";\n }\n}\n"]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible **read-only** step-progress indicator.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--step-indicator`):
|
|
7
|
+
* <ol data-controller="stimeo--step-indicator" aria-label="Checkout progress"
|
|
8
|
+
* data-stimeo--step-indicator-current-value="1"
|
|
9
|
+
* data-action="step:set->stimeo--step-indicator#setCurrent">
|
|
10
|
+
* <li data-stimeo--step-indicator-target="step">Cart</li>
|
|
11
|
+
* <li data-stimeo--step-indicator-target="step">Shipping</li>
|
|
12
|
+
* <li data-stimeo--step-indicator-target="step">Payment</li>
|
|
13
|
+
* </ol>
|
|
14
|
+
*
|
|
15
|
+
* There is no dedicated APG widget; the current position is expressed with
|
|
16
|
+
* `aria-current="step"`. This indicator is **read only** — it never moves focus
|
|
17
|
+
* and the steps are not operable. For an interactive wizard whose steps are
|
|
18
|
+
* `<button>`s, use {@link StepperController | Stepper}.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* Behavior only. Each step `<li>` gets a `data-state` (`complete`/`current`/
|
|
22
|
+
* `upcoming`) derived from the current index; the consumer draws the circles,
|
|
23
|
+
* lines, and numbers from those hooks. A `--stimeo-step-indicator-ratio`
|
|
24
|
+
* (0–1) custom property on the root expresses overall progress for CSS.
|
|
25
|
+
*
|
|
26
|
+
* Behavior provided:
|
|
27
|
+
* - Reflects `current` onto each step's `data-state` and `aria-current`.
|
|
28
|
+
* - `setCurrent` (bound to an event whose `detail.current` is the 0-based index)
|
|
29
|
+
* updates the current step and dispatches `stimeo--step-indicator:change`.
|
|
30
|
+
*/
|
|
31
|
+
declare class StepIndicatorController extends Controller<HTMLElement> {
|
|
32
|
+
#private;
|
|
33
|
+
static targets: string[];
|
|
34
|
+
static values: {
|
|
35
|
+
current: {
|
|
36
|
+
type: NumberConstructor;
|
|
37
|
+
default: number;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
static actions: readonly ["setCurrent"];
|
|
41
|
+
static events: readonly ["change"];
|
|
42
|
+
readonly stepTargets: HTMLElement[];
|
|
43
|
+
currentValue: number;
|
|
44
|
+
/** Renders the initial state from the `current` value. */
|
|
45
|
+
connect(): void;
|
|
46
|
+
/**
|
|
47
|
+
* Updates the current step from an external event (`detail.current`, 0-based)
|
|
48
|
+
* and dispatches `change`. Out-of-range indices are clamped to the step set.
|
|
49
|
+
*/
|
|
50
|
+
setCurrent(event: CustomEvent<{
|
|
51
|
+
current?: number;
|
|
52
|
+
}>): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { StepIndicatorController };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/step_indicator_controller.ts
|
|
4
|
+
var StepIndicatorController = class extends Controller {
|
|
5
|
+
static targets = ["step"];
|
|
6
|
+
static values = {
|
|
7
|
+
current: { type: Number, default: 0 }
|
|
8
|
+
};
|
|
9
|
+
static actions = ["setCurrent"];
|
|
10
|
+
static events = ["change"];
|
|
11
|
+
/** Renders the initial state from the `current` value. */
|
|
12
|
+
connect() {
|
|
13
|
+
this.#render();
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Updates the current step from an external event (`detail.current`, 0-based)
|
|
17
|
+
* and dispatches `change`. Out-of-range indices are clamped to the step set.
|
|
18
|
+
*/
|
|
19
|
+
setCurrent(event) {
|
|
20
|
+
const next = event.detail?.current;
|
|
21
|
+
if (typeof next !== "number" || !Number.isFinite(next)) return;
|
|
22
|
+
const clamped = this.#clamp(next);
|
|
23
|
+
if (clamped === this.currentValue) return;
|
|
24
|
+
this.currentValue = clamped;
|
|
25
|
+
this.#render();
|
|
26
|
+
this.dispatch("change", {
|
|
27
|
+
detail: { current: clamped, total: this.stepTargets.length }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Applies `data-state`, `aria-current`, and the progress ratio custom property. */
|
|
31
|
+
#render() {
|
|
32
|
+
const total = this.stepTargets.length;
|
|
33
|
+
const current = this.#clamp(this.currentValue);
|
|
34
|
+
this.stepTargets.forEach((step, index) => {
|
|
35
|
+
step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
|
|
36
|
+
if (index === current) {
|
|
37
|
+
step.setAttribute("aria-current", "step");
|
|
38
|
+
} else {
|
|
39
|
+
step.removeAttribute("aria-current");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const ratio = total > 1 ? current / (total - 1) : 0;
|
|
43
|
+
this.element.style.setProperty("--stimeo-step-indicator-ratio", String(ratio));
|
|
44
|
+
}
|
|
45
|
+
/** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
|
|
46
|
+
#clamp(index) {
|
|
47
|
+
const last = this.stepTargets.length - 1;
|
|
48
|
+
if (last < 0) return 0;
|
|
49
|
+
return Math.min(last, Math.max(0, Math.trunc(index)));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export { StepIndicatorController };
|
|
54
|
+
//# sourceMappingURL=step_indicator_controller.js.map
|
|
55
|
+
//# sourceMappingURL=step_indicator_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/step_indicator_controller.ts"],"names":[],"mappings":";;;AA8BO,IAAM,uBAAA,GAAN,cAAsC,UAAA,CAAwB;AAAA,EACnE,OAAgB,OAAA,GAAU,CAAC,MAAM,CAAA;AAAA,EACjC,OAAgB,MAAA,GAAS;AAAA,IACvB,OAAA,EAAS,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA;AAAE,GACtC;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,YAAY,CAAA;AAAA,EAC9B,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA;AAAA,EAMhB,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,WAAW,KAAA,EAAgD;AACzD,IAAA,MAAM,IAAA,GAAO,MAAM,MAAA,EAAQ,OAAA;AAC3B,IAAA,IAAI,OAAO,IAAA,KAAS,QAAA,IAAY,CAAC,MAAA,CAAO,QAAA,CAAS,IAAI,CAAA,EAAG;AACxD,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,MAAA,CAAO,IAAI,CAAA;AAChC,IAAA,IAAI,OAAA,KAAY,KAAK,YAAA,EAAc;AACnC,IAAA,IAAA,CAAK,YAAA,GAAe,OAAA;AACpB,IAAA,IAAA,CAAK,OAAA,EAAQ;AACb,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU;AAAA,MACtB,QAAQ,EAAE,OAAA,EAAS,SAAS,KAAA,EAAO,IAAA,CAAK,YAAY,MAAA;AAAO,KAC5D,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,MAAM,KAAA,GAAQ,KAAK,WAAA,CAAY,MAAA;AAC/B,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,MAAA,CAAO,IAAA,CAAK,YAAY,CAAA;AAC7C,IAAA,IAAA,CAAK,WAAA,CAAY,OAAA,CAAQ,CAAC,IAAA,EAAM,KAAA,KAAU;AACxC,MAAA,IAAA,CAAK,QAAQ,KAAA,GACX,KAAA,GAAQ,UAAU,UAAA,GAAa,KAAA,KAAU,UAAU,SAAA,GAAY,UAAA;AACjE,MAAA,IAAI,UAAU,OAAA,EAAS;AACrB,QAAA,IAAA,CAAK,YAAA,CAAa,gBAAgB,MAAM,CAAA;AAAA,MAC1C,CAAA,MAAO;AACL,QAAA,IAAA,CAAK,gBAAgB,cAAc,CAAA;AAAA,MACrC;AAAA,IACF,CAAC,CAAA;AACD,IAAA,MAAM,KAAA,GAAQ,KAAA,GAAQ,CAAA,GAAI,OAAA,IAAW,QAAQ,CAAA,CAAA,GAAK,CAAA;AAClD,IAAA,IAAA,CAAK,QAAQ,KAAA,CAAM,WAAA,CAAY,+BAAA,EAAiC,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,EAC/E;AAAA;AAAA,EAGA,OAAO,KAAA,EAAuB;AAC5B,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,WAAA,CAAY,MAAA,GAAS,CAAA;AACvC,IAAA,IAAI,IAAA,GAAO,GAAG,OAAO,CAAA;AACrB,IAAA,OAAO,IAAA,CAAK,GAAA,CAAI,IAAA,EAAM,IAAA,CAAK,GAAA,CAAI,GAAG,IAAA,CAAK,KAAA,CAAM,KAAK,CAAC,CAAC,CAAA;AAAA,EACtD;AACF","file":"step_indicator_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible **read-only** step-progress indicator.\n *\n * Markup contract (identifier: `stimeo--step-indicator`):\n * <ol data-controller=\"stimeo--step-indicator\" aria-label=\"Checkout progress\"\n * data-stimeo--step-indicator-current-value=\"1\"\n * data-action=\"step:set->stimeo--step-indicator#setCurrent\">\n * <li data-stimeo--step-indicator-target=\"step\">Cart</li>\n * <li data-stimeo--step-indicator-target=\"step\">Shipping</li>\n * <li data-stimeo--step-indicator-target=\"step\">Payment</li>\n * </ol>\n *\n * There is no dedicated APG widget; the current position is expressed with\n * `aria-current=\"step\"`. This indicator is **read only** — it never moves focus\n * and the steps are not operable. For an interactive wizard whose steps are\n * `<button>`s, use {@link StepperController | Stepper}.\n *\n * @remarks\n * Behavior only. Each step `<li>` gets a `data-state` (`complete`/`current`/\n * `upcoming`) derived from the current index; the consumer draws the circles,\n * lines, and numbers from those hooks. A `--stimeo-step-indicator-ratio`\n * (0–1) custom property on the root expresses overall progress for CSS.\n *\n * Behavior provided:\n * - Reflects `current` onto each step's `data-state` and `aria-current`.\n * - `setCurrent` (bound to an event whose `detail.current` is the 0-based index)\n * updates the current step and dispatches `stimeo--step-indicator:change`.\n */\nexport class StepIndicatorController extends Controller<HTMLElement> {\n static override targets = [\"step\"];\n static override values = {\n current: { type: Number, default: 0 },\n };\n static actions = [\"setCurrent\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly stepTargets: HTMLElement[];\n declare currentValue: number;\n\n /** Renders the initial state from the `current` value. */\n override connect(): void {\n this.#render();\n }\n\n /**\n * Updates the current step from an external event (`detail.current`, 0-based)\n * and dispatches `change`. Out-of-range indices are clamped to the step set.\n */\n setCurrent(event: CustomEvent<{ current?: number }>): void {\n const next = event.detail?.current;\n if (typeof next !== \"number\" || !Number.isFinite(next)) return;\n const clamped = this.#clamp(next);\n if (clamped === this.currentValue) return;\n this.currentValue = clamped;\n this.#render();\n this.dispatch(\"change\", {\n detail: { current: clamped, total: this.stepTargets.length },\n });\n }\n\n /** Applies `data-state`, `aria-current`, and the progress ratio custom property. */\n #render(): void {\n const total = this.stepTargets.length;\n const current = this.#clamp(this.currentValue);\n this.stepTargets.forEach((step, index) => {\n step.dataset.state =\n index < current ? \"complete\" : index === current ? \"current\" : \"upcoming\";\n if (index === current) {\n step.setAttribute(\"aria-current\", \"step\");\n } else {\n step.removeAttribute(\"aria-current\");\n }\n });\n const ratio = total > 1 ? current / (total - 1) : 0;\n this.element.style.setProperty(\"--stimeo-step-indicator-ratio\", String(ratio));\n }\n\n /** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */\n #clamp(index: number): number {\n const last = this.stepTargets.length - 1;\n if (last < 0) return 0;\n return Math.min(last, Math.max(0, Math.trunc(index)));\n }\n}\n"]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible stepper / wizard navigation behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--stepper`):
|
|
7
|
+
* <ol data-controller="stimeo--stepper" data-stimeo--stepper-index-value="0">
|
|
8
|
+
* <li data-stimeo--stepper-target="step">
|
|
9
|
+
* <button aria-current="step" data-stimeo--stepper-index-param="0"
|
|
10
|
+
* data-action="click->stimeo--stepper#goto">Account</button>
|
|
11
|
+
* </li>
|
|
12
|
+
* <!-- more steps -->
|
|
13
|
+
* </ol>
|
|
14
|
+
*
|
|
15
|
+
* There is no dedicated APG widget; the current step is expressed with
|
|
16
|
+
* `aria-current="step"` on the operable `<button>`. Each step `<li>` also gets a
|
|
17
|
+
* `data-state` (`complete`/`current`/`upcoming`) derived from the current index.
|
|
18
|
+
* For a read-only progress display use
|
|
19
|
+
* {@link StepIndicatorController | Step Indicator}; for panel switching use Tabs.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Behavior only. The controller never traps or restores focus — each step button
|
|
23
|
+
* is in the natural Tab order. `data-state` is purely derived from `index`
|
|
24
|
+
* (completion is not persisted).
|
|
25
|
+
*
|
|
26
|
+
* Behavior provided:
|
|
27
|
+
* - `next`/`prev` move one step, ignoring moves past either end.
|
|
28
|
+
* - `goto` jumps to the step in its `index` action param.
|
|
29
|
+
* - With `linear=true`, `goto` may not skip more than one step ahead of the
|
|
30
|
+
* current one (moving backward is always allowed).
|
|
31
|
+
* - Each move re-derives `data-state`/`aria-current` and dispatches
|
|
32
|
+
* `stimeo--stepper:change`.
|
|
33
|
+
*/
|
|
34
|
+
declare class StepperController extends Controller<HTMLElement> {
|
|
35
|
+
#private;
|
|
36
|
+
static targets: string[];
|
|
37
|
+
static values: {
|
|
38
|
+
index: {
|
|
39
|
+
type: NumberConstructor;
|
|
40
|
+
default: number;
|
|
41
|
+
};
|
|
42
|
+
linear: {
|
|
43
|
+
type: BooleanConstructor;
|
|
44
|
+
default: boolean;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
static actions: readonly ["goto", "next", "prev"];
|
|
48
|
+
static events: readonly ["change"];
|
|
49
|
+
readonly stepTargets: HTMLElement[];
|
|
50
|
+
indexValue: number;
|
|
51
|
+
linearValue: boolean;
|
|
52
|
+
/** Normalizes an out-of-range initial `index` and renders the initial state. */
|
|
53
|
+
connect(): void;
|
|
54
|
+
/** Advances to the next step (ignored at the last step). */
|
|
55
|
+
next(): void;
|
|
56
|
+
/** Returns to the previous step (ignored at the first step). */
|
|
57
|
+
prev(): void;
|
|
58
|
+
/** Jumps to the step carried in the action's `index` param. */
|
|
59
|
+
goto(event: {
|
|
60
|
+
params: {
|
|
61
|
+
index?: number;
|
|
62
|
+
};
|
|
63
|
+
}): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { StepperController };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/stepper_controller.ts
|
|
4
|
+
var StepperController = class extends Controller {
|
|
5
|
+
static targets = ["step"];
|
|
6
|
+
static values = {
|
|
7
|
+
index: { type: Number, default: 0 },
|
|
8
|
+
linear: { type: Boolean, default: false }
|
|
9
|
+
};
|
|
10
|
+
static actions = ["goto", "next", "prev"];
|
|
11
|
+
static events = ["change"];
|
|
12
|
+
/** Normalizes an out-of-range initial `index` and renders the initial state. */
|
|
13
|
+
connect() {
|
|
14
|
+
this.indexValue = this.#clampIndex(this.indexValue);
|
|
15
|
+
this.#render();
|
|
16
|
+
}
|
|
17
|
+
/** Advances to the next step (ignored at the last step). */
|
|
18
|
+
next() {
|
|
19
|
+
this.#moveTo(this.indexValue + 1);
|
|
20
|
+
}
|
|
21
|
+
/** Returns to the previous step (ignored at the first step). */
|
|
22
|
+
prev() {
|
|
23
|
+
this.#moveTo(this.indexValue - 1);
|
|
24
|
+
}
|
|
25
|
+
/** Jumps to the step carried in the action's `index` param. */
|
|
26
|
+
goto(event) {
|
|
27
|
+
const target = Number(event.params.index);
|
|
28
|
+
if (!Number.isFinite(target)) return;
|
|
29
|
+
this.#moveTo(target);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Moves the current step to `target` when allowed: in range, not a no-op, and
|
|
33
|
+
* — under `linear` — not skipping more than one step ahead. Re-renders and
|
|
34
|
+
* dispatches `change`.
|
|
35
|
+
*/
|
|
36
|
+
#moveTo(target) {
|
|
37
|
+
const total = this.stepTargets.length;
|
|
38
|
+
if (target < 0 || target >= total) return;
|
|
39
|
+
if (target === this.indexValue) return;
|
|
40
|
+
if (this.linearValue && target > this.indexValue + 1) return;
|
|
41
|
+
const previous = this.indexValue;
|
|
42
|
+
this.indexValue = target;
|
|
43
|
+
this.#render();
|
|
44
|
+
this.dispatch("change", {
|
|
45
|
+
detail: { index: target, previous, step: this.stepTargets[target] }
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Derives each step's `data-state` and the current button's `aria-current`.
|
|
50
|
+
*
|
|
51
|
+
* `aria-current="step"` is placed on the step's **first** `<button>`; the markup
|
|
52
|
+
* contract assumes one operable button per step. If a step needs multiple
|
|
53
|
+
* buttons, mark the navigational one first (or this would target the wrong one).
|
|
54
|
+
*/
|
|
55
|
+
#render() {
|
|
56
|
+
const current = this.indexValue;
|
|
57
|
+
this.stepTargets.forEach((step, index) => {
|
|
58
|
+
step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
|
|
59
|
+
const button = step.querySelector("button");
|
|
60
|
+
if (!button) return;
|
|
61
|
+
if (index === current) {
|
|
62
|
+
button.setAttribute("aria-current", "step");
|
|
63
|
+
} else {
|
|
64
|
+
button.removeAttribute("aria-current");
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
|
|
69
|
+
#clampIndex(index) {
|
|
70
|
+
const last = this.stepTargets.length - 1;
|
|
71
|
+
if (last < 0) return 0;
|
|
72
|
+
return Math.min(last, Math.max(0, Math.trunc(index)));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export { StepperController };
|
|
77
|
+
//# sourceMappingURL=stepper_controller.js.map
|
|
78
|
+
//# sourceMappingURL=stepper_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/stepper_controller.ts"],"names":[],"mappings":"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","file":"stepper_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible stepper / wizard navigation behavior.\n *\n * Markup contract (identifier: `stimeo--stepper`):\n * <ol data-controller=\"stimeo--stepper\" data-stimeo--stepper-index-value=\"0\">\n * <li data-stimeo--stepper-target=\"step\">\n * <button aria-current=\"step\" data-stimeo--stepper-index-param=\"0\"\n * data-action=\"click->stimeo--stepper#goto\">Account</button>\n * </li>\n * <!-- more steps -->\n * </ol>\n *\n * There is no dedicated APG widget; the current step is expressed with\n * `aria-current=\"step\"` on the operable `<button>`. Each step `<li>` also gets a\n * `data-state` (`complete`/`current`/`upcoming`) derived from the current index.\n * For a read-only progress display use\n * {@link StepIndicatorController | Step Indicator}; for panel switching use Tabs.\n *\n * @remarks\n * Behavior only. The controller never traps or restores focus — each step button\n * is in the natural Tab order. `data-state` is purely derived from `index`\n * (completion is not persisted).\n *\n * Behavior provided:\n * - `next`/`prev` move one step, ignoring moves past either end.\n * - `goto` jumps to the step in its `index` action param.\n * - With `linear=true`, `goto` may not skip more than one step ahead of the\n * current one (moving backward is always allowed).\n * - Each move re-derives `data-state`/`aria-current` and dispatches\n * `stimeo--stepper:change`.\n */\nexport class StepperController extends Controller<HTMLElement> {\n static override targets = [\"step\"];\n static override values = {\n index: { type: Number, default: 0 },\n linear: { type: Boolean, default: false },\n };\n static actions = [\"goto\", \"next\", \"prev\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly stepTargets: HTMLElement[];\n declare indexValue: number;\n declare linearValue: boolean;\n\n /** Normalizes an out-of-range initial `index` and renders the initial state. */\n override connect(): void {\n this.indexValue = this.#clampIndex(this.indexValue);\n this.#render();\n }\n\n /** Advances to the next step (ignored at the last step). */\n next(): void {\n this.#moveTo(this.indexValue + 1);\n }\n\n /** Returns to the previous step (ignored at the first step). */\n prev(): void {\n this.#moveTo(this.indexValue - 1);\n }\n\n /** Jumps to the step carried in the action's `index` param. */\n goto(event: { params: { index?: number } }): void {\n const target = Number(event.params.index);\n if (!Number.isFinite(target)) return;\n this.#moveTo(target);\n }\n\n /**\n * Moves the current step to `target` when allowed: in range, not a no-op, and\n * — under `linear` — not skipping more than one step ahead. Re-renders and\n * dispatches `change`.\n */\n #moveTo(target: number): void {\n const total = this.stepTargets.length;\n if (target < 0 || target >= total) return;\n if (target === this.indexValue) return;\n if (this.linearValue && target > this.indexValue + 1) return;\n\n const previous = this.indexValue;\n this.indexValue = target;\n this.#render();\n this.dispatch(\"change\", {\n detail: { index: target, previous, step: this.stepTargets[target] },\n });\n }\n\n /**\n * Derives each step's `data-state` and the current button's `aria-current`.\n *\n * `aria-current=\"step\"` is placed on the step's **first** `<button>`; the markup\n * contract assumes one operable button per step. If a step needs multiple\n * buttons, mark the navigational one first (or this would target the wrong one).\n */\n #render(): void {\n const current = this.indexValue;\n this.stepTargets.forEach((step, index) => {\n step.dataset.state =\n index < current ? \"complete\" : index === current ? \"current\" : \"upcoming\";\n const button = step.querySelector<HTMLElement>(\"button\");\n if (!button) return;\n if (index === current) {\n button.setAttribute(\"aria-current\", \"step\");\n } else {\n button.removeAttribute(\"aria-current\");\n }\n });\n }\n\n /** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */\n #clampIndex(index: number): number {\n const last = this.stepTargets.length - 1;\n if (last < 0) return 0;\n return Math.min(last, Math.max(0, Math.trunc(index)));\n }\n}\n"]}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless **stick-to-bottom**: auto-follows new content to the bottom of a scroll
|
|
5
|
+
* container (a chat log, a live console) — but only while the user is already near the
|
|
6
|
+
* bottom; if they have scrolled up to read, it holds position and flags that new content
|
|
7
|
+
* arrived instead (no APG pattern; honors WCAG 2.3.3 via `prefers-reduced-motion` and
|
|
8
|
+
* never steals focus).
|
|
9
|
+
*
|
|
10
|
+
* Markup contract (identifier: `stimeo--stick-to-bottom`):
|
|
11
|
+
* <div data-controller="stimeo--stick-to-bottom"
|
|
12
|
+
* data-stimeo--stick-to-bottom-threshold-value="80" style="overflow:auto">
|
|
13
|
+
* <ul data-stimeo--stick-to-bottom-target="content"><!-- Turbo Stream appends --></ul>
|
|
14
|
+
* </div>
|
|
15
|
+
*
|
|
16
|
+
* The container is "pinned" while its distance from the bottom is within `threshold`. A
|
|
17
|
+
* `MutationObserver` on `content` (or the element) reacts to appended children: while
|
|
18
|
+
* pinned it scrolls to the bottom; while unpinned it sets `data-has-new` and emits `new`.
|
|
19
|
+
* Scrolling recomputes pinned and reflects `data-pinned`, emitting `pin` on change; the
|
|
20
|
+
* `scrollToBottom` action jumps back down (a "new messages" button).
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* The `MutationObserver` watches `childList` only (not `subtree`), so it follows
|
|
24
|
+
* direct appends to `content` (or the element). Appends made deeper inside a nested
|
|
25
|
+
* wrapper are not detected — keep messages as direct children, or call the public
|
|
26
|
+
* `scrollToBottom` action after such inserts.
|
|
27
|
+
*
|
|
28
|
+
* Behavior only — it does not add content (Turbo Stream / the consumer does) and is the
|
|
29
|
+
* minimal follow primitive, not a full chat UI (no virtualization / message input). It is
|
|
30
|
+
* the lightweight member of the scroll family. State is derived from the scroll position
|
|
31
|
+
* each pass (no module-scope state), so `connect()` re-syncs after a Turbo Stream insert;
|
|
32
|
+
* `behavior` falls back to `auto` under reduced motion; auto-scroll never moves focus; the
|
|
33
|
+
* observer and the passive scroll listener are released on `disconnect()` (Turbo
|
|
34
|
+
* navigation included).
|
|
35
|
+
*/
|
|
36
|
+
declare class StickToBottomController extends Controller<HTMLElement> {
|
|
37
|
+
#private;
|
|
38
|
+
static targets: string[];
|
|
39
|
+
static values: {
|
|
40
|
+
threshold: {
|
|
41
|
+
type: NumberConstructor;
|
|
42
|
+
default: number;
|
|
43
|
+
};
|
|
44
|
+
behavior: {
|
|
45
|
+
type: StringConstructor;
|
|
46
|
+
default: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
static actions: readonly ["scrollToBottom"];
|
|
50
|
+
static events: readonly ["pin", "new"];
|
|
51
|
+
readonly contentTarget: HTMLElement;
|
|
52
|
+
readonly hasContentTarget: boolean;
|
|
53
|
+
thresholdValue: number;
|
|
54
|
+
behaviorValue: string;
|
|
55
|
+
connect(): void;
|
|
56
|
+
disconnect(): void;
|
|
57
|
+
/** Jumps to the bottom and re-pins (wired to a "new messages" button). */
|
|
58
|
+
scrollToBottom(): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { StickToBottomController };
|