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,93 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless countdown/timer behavior built on the live-region `role="timer"`
|
|
5
|
+
* practice (no dedicated APG pattern).
|
|
6
|
+
*
|
|
7
|
+
* Markup contract (identifier: `stimeo--countdown`):
|
|
8
|
+
* <div data-controller="stimeo--countdown" role="timer" aria-live="off"
|
|
9
|
+
* data-stimeo--countdown-deadline-value="2026-12-31T23:59:59+09:00"
|
|
10
|
+
* data-action="countdown:pause->stimeo--countdown#pause
|
|
11
|
+
* countdown:resume->stimeo--countdown#resume">
|
|
12
|
+
* <span data-stimeo--countdown-target="days">0</span>
|
|
13
|
+
* <span data-stimeo--countdown-target="hours">00</span>
|
|
14
|
+
* <span data-stimeo--countdown-target="minutes">00</span>
|
|
15
|
+
* <span data-stimeo--countdown-target="seconds">00</span>
|
|
16
|
+
* </div>
|
|
17
|
+
*
|
|
18
|
+
* Computes the time remaining to `deadline` (or elapsed since it, in
|
|
19
|
+
* `direction="up"`), formats it into the day/hour/minute/second slots, and ticks
|
|
20
|
+
* on `interval`. `aria-live="off"` is recommended so the timer is not announced
|
|
21
|
+
* every second; only the milestone (completion) is surfaced — via the optional
|
|
22
|
+
* `status` live region when a `completeLabel` is provided, or the `complete`
|
|
23
|
+
* event for the consumer to handle.
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* Behavior only — slot text is updated, not styled. Pause/resume shifts an
|
|
27
|
+
* internal time anchor so the displayed amount is preserved across a pause. The
|
|
28
|
+
* interval is owned by {@link SafeInterval} and torn down on `disconnect()`
|
|
29
|
+
* (Turbo navigation included).
|
|
30
|
+
*/
|
|
31
|
+
declare class CountdownController extends Controller<HTMLElement> {
|
|
32
|
+
#private;
|
|
33
|
+
static targets: string[];
|
|
34
|
+
static values: {
|
|
35
|
+
deadline: {
|
|
36
|
+
type: StringConstructor;
|
|
37
|
+
default: string;
|
|
38
|
+
};
|
|
39
|
+
interval: {
|
|
40
|
+
type: NumberConstructor;
|
|
41
|
+
default: number;
|
|
42
|
+
};
|
|
43
|
+
direction: {
|
|
44
|
+
type: StringConstructor;
|
|
45
|
+
default: string;
|
|
46
|
+
};
|
|
47
|
+
autostart: {
|
|
48
|
+
type: BooleanConstructor;
|
|
49
|
+
default: boolean;
|
|
50
|
+
};
|
|
51
|
+
completeLabel: {
|
|
52
|
+
type: StringConstructor;
|
|
53
|
+
default: string;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
static actions: readonly ["pause", "reset", "resume", "start"];
|
|
57
|
+
static events: readonly ["complete", "tick"];
|
|
58
|
+
readonly daysTarget: HTMLElement;
|
|
59
|
+
readonly hoursTarget: HTMLElement;
|
|
60
|
+
readonly minutesTarget: HTMLElement;
|
|
61
|
+
readonly secondsTarget: HTMLElement;
|
|
62
|
+
readonly statusTarget: HTMLElement;
|
|
63
|
+
readonly hasDaysTarget: boolean;
|
|
64
|
+
readonly hasHoursTarget: boolean;
|
|
65
|
+
readonly hasMinutesTarget: boolean;
|
|
66
|
+
readonly hasSecondsTarget: boolean;
|
|
67
|
+
readonly hasStatusTarget: boolean;
|
|
68
|
+
deadlineValue: string;
|
|
69
|
+
intervalValue: number;
|
|
70
|
+
directionValue: string;
|
|
71
|
+
autostartValue: boolean;
|
|
72
|
+
completeLabelValue: string;
|
|
73
|
+
connect(): void;
|
|
74
|
+
disconnect(): void;
|
|
75
|
+
/** Starts (or restarts after pause) ticking toward the deadline. */
|
|
76
|
+
start(): void;
|
|
77
|
+
/** Pauses ticking, preserving the currently displayed amount. */
|
|
78
|
+
pause(): void;
|
|
79
|
+
/** Resumes from a pause, continuing from the preserved amount. */
|
|
80
|
+
resume(): void;
|
|
81
|
+
/**
|
|
82
|
+
* Re-syncs to the deadline and clears any pause offset, **preserving the current
|
|
83
|
+
* run state**: a running timer keeps counting down from the reset amount, while a
|
|
84
|
+
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
85
|
+
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
86
|
+
* not re-derived from the declarative `autostart` Value (which only governs the
|
|
87
|
+
* initial state on connect); re-deriving it would override a user's pause, the same
|
|
88
|
+
* anti-pattern the Turbo-lifecycle guide warns about.
|
|
89
|
+
*/
|
|
90
|
+
reset(): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { CountdownController };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/countdown_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/safe_timeout.ts
|
|
6
|
+
var TimerRegistry = class {
|
|
7
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
8
|
+
ids = /* @__PURE__ */ new Set();
|
|
9
|
+
/**
|
|
10
|
+
* Cancels a single tracked timer.
|
|
11
|
+
*
|
|
12
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
13
|
+
* registry), so callers can clear defensively without guarding.
|
|
14
|
+
*/
|
|
15
|
+
clear(id) {
|
|
16
|
+
if (this.ids.delete(id)) {
|
|
17
|
+
this.cancel(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
22
|
+
* to guarantee no timer outlives the element.
|
|
23
|
+
*/
|
|
24
|
+
clearAll() {
|
|
25
|
+
for (const id of this.ids) {
|
|
26
|
+
this.cancel(id);
|
|
27
|
+
}
|
|
28
|
+
this.ids.clear();
|
|
29
|
+
}
|
|
30
|
+
/** Number of timers currently tracked (pending). */
|
|
31
|
+
get size() {
|
|
32
|
+
return this.ids.size;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var SafeInterval = class extends TimerRegistry {
|
|
36
|
+
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
37
|
+
set(callback, delay) {
|
|
38
|
+
const id = this.schedule(callback, delay);
|
|
39
|
+
this.ids.add(id);
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
schedule(callback, delay) {
|
|
43
|
+
return window.setInterval(callback, delay);
|
|
44
|
+
}
|
|
45
|
+
cancel(id) {
|
|
46
|
+
window.clearInterval(id);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// src/controllers/countdown_controller.ts
|
|
51
|
+
var CountdownController = class extends Controller {
|
|
52
|
+
static targets = ["days", "hours", "minutes", "seconds", "status"];
|
|
53
|
+
static values = {
|
|
54
|
+
deadline: { type: String, default: "" },
|
|
55
|
+
interval: { type: Number, default: 1e3 },
|
|
56
|
+
direction: { type: String, default: "down" },
|
|
57
|
+
autostart: { type: Boolean, default: true },
|
|
58
|
+
completeLabel: { type: String, default: "" }
|
|
59
|
+
};
|
|
60
|
+
static actions = ["pause", "reset", "resume", "start"];
|
|
61
|
+
static events = ["complete", "tick"];
|
|
62
|
+
#intervals = new SafeInterval();
|
|
63
|
+
#intervalId = null;
|
|
64
|
+
/** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
|
|
65
|
+
#reference = 0;
|
|
66
|
+
/** Amount (ms) captured at pause, so resume can restore the same display. */
|
|
67
|
+
#pausedAmount = 0;
|
|
68
|
+
connect() {
|
|
69
|
+
this.#initReference();
|
|
70
|
+
this.#render(this.#currentAmount());
|
|
71
|
+
if (this.autostartValue && this.#isValidDeadline) {
|
|
72
|
+
this.start();
|
|
73
|
+
} else {
|
|
74
|
+
this.element.setAttribute("data-state", "paused");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
disconnect() {
|
|
78
|
+
this.#intervals.clearAll();
|
|
79
|
+
this.#intervalId = null;
|
|
80
|
+
}
|
|
81
|
+
/** Starts (or restarts after pause) ticking toward the deadline. */
|
|
82
|
+
start() {
|
|
83
|
+
if (this.#state === "running" || !this.#isValidDeadline) return;
|
|
84
|
+
if (this.#isDown && this.#currentAmount() <= 0) {
|
|
85
|
+
this.#complete();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.#runInterval();
|
|
89
|
+
}
|
|
90
|
+
/** Pauses ticking, preserving the currently displayed amount. */
|
|
91
|
+
pause() {
|
|
92
|
+
if (this.#state !== "running") return;
|
|
93
|
+
this.#pausedAmount = this.#currentAmount();
|
|
94
|
+
this.#teardownInterval();
|
|
95
|
+
this.element.setAttribute("data-state", "paused");
|
|
96
|
+
}
|
|
97
|
+
/** Resumes from a pause, continuing from the preserved amount. */
|
|
98
|
+
resume() {
|
|
99
|
+
if (this.#state !== "paused" || !this.#isValidDeadline) return;
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
this.#reference = this.#isDown ? now + this.#pausedAmount : now - this.#pausedAmount;
|
|
102
|
+
this.start();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Re-syncs to the deadline and clears any pause offset, **preserving the current
|
|
106
|
+
* run state**: a running timer keeps counting down from the reset amount, while a
|
|
107
|
+
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
108
|
+
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
109
|
+
* not re-derived from the declarative `autostart` Value (which only governs the
|
|
110
|
+
* initial state on connect); re-deriving it would override a user's pause, the same
|
|
111
|
+
* anti-pattern the Turbo-lifecycle guide warns about.
|
|
112
|
+
*/
|
|
113
|
+
reset() {
|
|
114
|
+
const wasRunning = this.#state === "running";
|
|
115
|
+
this.#teardownInterval();
|
|
116
|
+
this.#initReference();
|
|
117
|
+
const amount = this.#currentAmount();
|
|
118
|
+
this.#render(amount);
|
|
119
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = "";
|
|
120
|
+
this.element.setAttribute("data-state", "paused");
|
|
121
|
+
if (wasRunning && this.#isValidDeadline) {
|
|
122
|
+
this.#pausedAmount = 0;
|
|
123
|
+
this.start();
|
|
124
|
+
} else {
|
|
125
|
+
this.#pausedAmount = amount;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Schedules the repeating tick and marks the timer running. */
|
|
129
|
+
#runInterval() {
|
|
130
|
+
this.element.setAttribute("data-state", "running");
|
|
131
|
+
this.#intervalId = this.#intervals.set(() => this.#tick(), this.intervalValue);
|
|
132
|
+
}
|
|
133
|
+
/** Cancels the repeating tick, if any. */
|
|
134
|
+
#teardownInterval() {
|
|
135
|
+
if (this.#intervalId !== null) {
|
|
136
|
+
this.#intervals.clear(this.#intervalId);
|
|
137
|
+
this.#intervalId = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Recomputes, renders, emits `tick`, and completes when a countdown hits 0. */
|
|
141
|
+
#tick() {
|
|
142
|
+
const amount = this.#currentAmount();
|
|
143
|
+
this.#render(amount);
|
|
144
|
+
this.dispatch("tick", {
|
|
145
|
+
detail: { remaining: amount, direction: this.#isDown ? "down" : "up" }
|
|
146
|
+
});
|
|
147
|
+
if (this.#isDown && this.#reference - Date.now() <= 0) {
|
|
148
|
+
this.#complete();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Stops at zero, marks completion, announces it, and emits `complete`. */
|
|
152
|
+
#complete() {
|
|
153
|
+
this.#teardownInterval();
|
|
154
|
+
this.#render(0);
|
|
155
|
+
this.element.setAttribute("data-state", "complete");
|
|
156
|
+
if (this.hasStatusTarget && this.completeLabelValue.length > 0) {
|
|
157
|
+
this.statusTarget.textContent = this.completeLabelValue;
|
|
158
|
+
}
|
|
159
|
+
this.dispatch("complete", { detail: {} });
|
|
160
|
+
}
|
|
161
|
+
/** Sets the time anchor from the `deadline` value. */
|
|
162
|
+
#initReference() {
|
|
163
|
+
this.#reference = Date.parse(this.deadlineValue);
|
|
164
|
+
}
|
|
165
|
+
/** Remaining (down) or elapsed (up) ms, never negative. */
|
|
166
|
+
#currentAmount() {
|
|
167
|
+
if (!this.#isValidDeadline) return 0;
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const raw = this.#isDown ? this.#reference - now : now - this.#reference;
|
|
170
|
+
return Math.max(0, raw);
|
|
171
|
+
}
|
|
172
|
+
/** Writes the amount into the day/hour/minute/second slots. */
|
|
173
|
+
#render(amount) {
|
|
174
|
+
const totalSeconds = Math.floor(amount / 1e3);
|
|
175
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
176
|
+
const hours = Math.floor(totalSeconds % 86400 / 3600);
|
|
177
|
+
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
178
|
+
const seconds = totalSeconds % 60;
|
|
179
|
+
if (this.hasDaysTarget) this.daysTarget.textContent = String(days);
|
|
180
|
+
if (this.hasHoursTarget) this.hoursTarget.textContent = this.#pad(hours);
|
|
181
|
+
if (this.hasMinutesTarget) this.minutesTarget.textContent = this.#pad(minutes);
|
|
182
|
+
if (this.hasSecondsTarget) this.secondsTarget.textContent = this.#pad(seconds);
|
|
183
|
+
}
|
|
184
|
+
/** Zero-pads a unit to two digits. */
|
|
185
|
+
#pad(unit) {
|
|
186
|
+
return String(unit).padStart(2, "0");
|
|
187
|
+
}
|
|
188
|
+
get #isDown() {
|
|
189
|
+
return this.directionValue !== "up";
|
|
190
|
+
}
|
|
191
|
+
get #isValidDeadline() {
|
|
192
|
+
return !Number.isNaN(this.#reference);
|
|
193
|
+
}
|
|
194
|
+
/** Current lifecycle phase as reflected on `data-state`. */
|
|
195
|
+
get #state() {
|
|
196
|
+
return this.element.getAttribute("data-state") ?? "paused";
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export { CountdownController };
|
|
201
|
+
//# sourceMappingURL=countdown_controller.js.map
|
|
202
|
+
//# sourceMappingURL=countdown_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/countdown_controller.ts"],"names":[],"mappings":"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","file":"countdown_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 } from \"../utils/safe_timeout\";\n\n/**\n * Headless countdown/timer behavior built on the live-region `role=\"timer\"`\n * practice (no dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--countdown`):\n * <div data-controller=\"stimeo--countdown\" role=\"timer\" aria-live=\"off\"\n * data-stimeo--countdown-deadline-value=\"2026-12-31T23:59:59+09:00\"\n * data-action=\"countdown:pause->stimeo--countdown#pause\n * countdown:resume->stimeo--countdown#resume\">\n * <span data-stimeo--countdown-target=\"days\">0</span>\n * <span data-stimeo--countdown-target=\"hours\">00</span>\n * <span data-stimeo--countdown-target=\"minutes\">00</span>\n * <span data-stimeo--countdown-target=\"seconds\">00</span>\n * </div>\n *\n * Computes the time remaining to `deadline` (or elapsed since it, in\n * `direction=\"up\"`), formats it into the day/hour/minute/second slots, and ticks\n * on `interval`. `aria-live=\"off\"` is recommended so the timer is not announced\n * every second; only the milestone (completion) is surfaced — via the optional\n * `status` live region when a `completeLabel` is provided, or the `complete`\n * event for the consumer to handle.\n *\n * @remarks\n * Behavior only — slot text is updated, not styled. Pause/resume shifts an\n * internal time anchor so the displayed amount is preserved across a pause. The\n * interval is owned by {@link SafeInterval} and torn down on `disconnect()`\n * (Turbo navigation included).\n */\nexport class CountdownController extends Controller<HTMLElement> {\n static override targets = [\"days\", \"hours\", \"minutes\", \"seconds\", \"status\"];\n static override values = {\n deadline: { type: String, default: \"\" },\n interval: { type: Number, default: 1000 },\n direction: { type: String, default: \"down\" },\n autostart: { type: Boolean, default: true },\n completeLabel: { type: String, default: \"\" },\n };\n static actions = [\"pause\", \"reset\", \"resume\", \"start\"] as const;\n static events = [\"complete\", \"tick\"] as const;\n\n declare readonly daysTarget: HTMLElement;\n declare readonly hoursTarget: HTMLElement;\n declare readonly minutesTarget: HTMLElement;\n declare readonly secondsTarget: HTMLElement;\n declare readonly statusTarget: HTMLElement;\n declare readonly hasDaysTarget: boolean;\n declare readonly hasHoursTarget: boolean;\n declare readonly hasMinutesTarget: boolean;\n declare readonly hasSecondsTarget: boolean;\n declare readonly hasStatusTarget: boolean;\n\n declare deadlineValue: string;\n declare intervalValue: number;\n declare directionValue: string;\n declare autostartValue: boolean;\n declare completeLabelValue: string;\n\n readonly #intervals = new SafeInterval();\n #intervalId: number | null = null;\n\n /** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */\n #reference = 0;\n /** Amount (ms) captured at pause, so resume can restore the same display. */\n #pausedAmount = 0;\n\n override connect(): void {\n this.#initReference();\n this.#render(this.#currentAmount());\n if (this.autostartValue && this.#isValidDeadline) {\n this.start();\n } else {\n this.element.setAttribute(\"data-state\", \"paused\");\n }\n }\n\n override disconnect(): void {\n this.#intervals.clearAll();\n this.#intervalId = null;\n }\n\n /** Starts (or restarts after pause) ticking toward the deadline. */\n start(): void {\n if (this.#state === \"running\" || !this.#isValidDeadline) return;\n if (this.#isDown && this.#currentAmount() <= 0) {\n this.#complete();\n return;\n }\n this.#runInterval();\n }\n\n /** Pauses ticking, preserving the currently displayed amount. */\n pause(): void {\n if (this.#state !== \"running\") return;\n this.#pausedAmount = this.#currentAmount();\n this.#teardownInterval();\n this.element.setAttribute(\"data-state\", \"paused\");\n }\n\n /** Resumes from a pause, continuing from the preserved amount. */\n resume(): void {\n if (this.#state !== \"paused\" || !this.#isValidDeadline) return;\n const now = Date.now();\n this.#reference = this.#isDown ? now + this.#pausedAmount : now - this.#pausedAmount;\n this.start();\n }\n\n /**\n * Re-syncs to the deadline and clears any pause offset, **preserving the current\n * run state**: a running timer keeps counting down from the reset amount, while a\n * paused (or completed) one resets the displayed amount but stays paused until the\n * user resumes — it never silently restarts. The run state is read from the DOM,\n * not re-derived from the declarative `autostart` Value (which only governs the\n * initial state on connect); re-deriving it would override a user's pause, the same\n * anti-pattern the Turbo-lifecycle guide warns about.\n */\n reset(): void {\n const wasRunning = this.#state === \"running\";\n this.#teardownInterval();\n this.#initReference();\n const amount = this.#currentAmount();\n this.#render(amount);\n // A prior complete() may have written a completion message into the status live\n // region; clear it so a reset timer does not keep showing (and announcing to a\n // screen reader) the stale \"finished\" text.\n if (this.hasStatusTarget) this.statusTarget.textContent = \"\";\n // teardownInterval() leaves data-state untouched; drop any lingering \"running\" to a\n // resting \"paused\" so start() (a no-op while \"running\") can re-arm when we resume.\n this.element.setAttribute(\"data-state\", \"paused\");\n if (wasRunning && this.#isValidDeadline) {\n // Was counting down: re-arm and keep running from the reset amount.\n this.#pausedAmount = 0;\n this.start();\n } else {\n // Was paused/complete: hold at the reset amount and wait for the user to resume.\n // Remember the amount so resume() restores it instead of snapping the anchor to 0.\n this.#pausedAmount = amount;\n }\n }\n\n /** Schedules the repeating tick and marks the timer running. */\n #runInterval(): void {\n this.element.setAttribute(\"data-state\", \"running\");\n this.#intervalId = this.#intervals.set(() => this.#tick(), this.intervalValue);\n }\n\n /** Cancels the repeating tick, if any. */\n #teardownInterval(): void {\n if (this.#intervalId !== null) {\n this.#intervals.clear(this.#intervalId);\n this.#intervalId = null;\n }\n }\n\n /** Recomputes, renders, emits `tick`, and completes when a countdown hits 0. */\n #tick(): void {\n const amount = this.#currentAmount();\n this.#render(amount);\n // `remaining` is the displayed amount (time left when counting down, elapsed\n // when counting up); `direction` lets consumers disambiguate the two.\n this.dispatch(\"tick\", {\n detail: { remaining: amount, direction: this.#isDown ? \"down\" : \"up\" },\n });\n if (this.#isDown && this.#reference - Date.now() <= 0) {\n this.#complete();\n }\n }\n\n /** Stops at zero, marks completion, announces it, and emits `complete`. */\n #complete(): void {\n this.#teardownInterval();\n this.#render(0);\n this.element.setAttribute(\"data-state\", \"complete\");\n if (this.hasStatusTarget && this.completeLabelValue.length > 0) {\n this.statusTarget.textContent = this.completeLabelValue;\n }\n this.dispatch(\"complete\", { detail: {} });\n }\n\n /** Sets the time anchor from the `deadline` value. */\n #initReference(): void {\n this.#reference = Date.parse(this.deadlineValue);\n }\n\n /** Remaining (down) or elapsed (up) ms, never negative. */\n #currentAmount(): number {\n if (!this.#isValidDeadline) return 0;\n const now = Date.now();\n const raw = this.#isDown ? this.#reference - now : now - this.#reference;\n return Math.max(0, raw);\n }\n\n /** Writes the amount into the day/hour/minute/second slots. */\n #render(amount: number): void {\n const totalSeconds = Math.floor(amount / 1000);\n const days = Math.floor(totalSeconds / 86400);\n const hours = Math.floor((totalSeconds % 86400) / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const seconds = totalSeconds % 60;\n\n if (this.hasDaysTarget) this.daysTarget.textContent = String(days);\n if (this.hasHoursTarget) this.hoursTarget.textContent = this.#pad(hours);\n if (this.hasMinutesTarget) this.minutesTarget.textContent = this.#pad(minutes);\n if (this.hasSecondsTarget) this.secondsTarget.textContent = this.#pad(seconds);\n }\n\n /** Zero-pads a unit to two digits. */\n #pad(unit: number): string {\n return String(unit).padStart(2, \"0\");\n }\n\n get #isDown(): boolean {\n return this.directionValue !== \"up\";\n }\n\n get #isValidDeadline(): boolean {\n return !Number.isNaN(this.#reference);\n }\n\n /** Current lifecycle phase as reflected on `data-state`. */\n get #state(): string {\n return this.element.getAttribute(\"data-state\") ?? \"paused\";\n }\n}\n"]}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible modal dialog behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--dialog`):
|
|
7
|
+
* <div data-controller="stimeo--dialog">
|
|
8
|
+
* <button data-stimeo--dialog-target="trigger"
|
|
9
|
+
* data-action="stimeo--dialog#open">Open</button>
|
|
10
|
+
* <div data-stimeo--dialog-target="dialog" role="dialog" aria-modal="true"
|
|
11
|
+
* aria-labelledby="title" hidden>
|
|
12
|
+
* <h2 id="title">…</h2>
|
|
13
|
+
* <button data-action="stimeo--dialog#close">Close</button>
|
|
14
|
+
* </div>
|
|
15
|
+
* </div>
|
|
16
|
+
*
|
|
17
|
+
* Implements the WAI-ARIA APG **Dialog (Modal)** pattern: focus moves into the
|
|
18
|
+
* dialog on open and is trapped within it, `Escape` closes it, background scroll
|
|
19
|
+
* is locked, and focus returns to the trigger on close.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Behavior only. The sole visual side effect is locking `document.body`'s scroll
|
|
23
|
+
* while open (the minimum required by the pattern); all other styling is the
|
|
24
|
+
* consumer's. Clicking the dialog backdrop (the dialog target itself, outside
|
|
25
|
+
* its content) also closes it.
|
|
26
|
+
*
|
|
27
|
+
* The modal lifecycle — focus trap, scroll lock, background `inert`, focus
|
|
28
|
+
* restore, and teardown reversal — is delegated to the shared {@link FocusTrap}
|
|
29
|
+
* primitive (also used by alert-dialog and drawer). This controller only owns
|
|
30
|
+
* *when* to open/close and the dialog-specific backdrop click.
|
|
31
|
+
*/
|
|
32
|
+
declare class DialogController extends Controller<HTMLElement> {
|
|
33
|
+
#private;
|
|
34
|
+
static targets: string[];
|
|
35
|
+
static actions: readonly ["close", "closeOnBackdrop", "open"];
|
|
36
|
+
readonly triggerTarget: HTMLElement;
|
|
37
|
+
readonly dialogTarget: HTMLElement;
|
|
38
|
+
readonly hasTriggerTarget: boolean;
|
|
39
|
+
readonly hasDialogTarget: boolean;
|
|
40
|
+
/** Starts closed (idempotently reflects the closed state on the markup). */
|
|
41
|
+
connect(): void;
|
|
42
|
+
/**
|
|
43
|
+
* Reverts the modal side effects (scroll lock, background `inert`, keydown
|
|
44
|
+
* listener) if the controller is torn down while open (e.g. a Turbo navigation
|
|
45
|
+
* replaces the page while the dialog is showing). Focus is not restored on
|
|
46
|
+
* teardown.
|
|
47
|
+
*/
|
|
48
|
+
disconnect(): void;
|
|
49
|
+
/** Opens the dialog, traps focus, and locks background scroll. */
|
|
50
|
+
open(): void;
|
|
51
|
+
/** Closes the dialog, restores scroll, and returns focus to the opener. */
|
|
52
|
+
close(): void;
|
|
53
|
+
/** Closes when the backdrop (the dialog target itself) is clicked. */
|
|
54
|
+
closeOnBackdrop(event: MouseEvent): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { DialogController };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/dialog_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/focus_trap.ts
|
|
6
|
+
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
|
+
var FocusTrap = class {
|
|
8
|
+
/** The element focused before activation, restored on deactivation. */
|
|
9
|
+
#previouslyFocused = null;
|
|
10
|
+
/** The body's inline `overflow` before locking, restored on deactivation. */
|
|
11
|
+
#previousBodyOverflow = "";
|
|
12
|
+
/** Whether scroll was locked this activation (so it is only restored if applied). */
|
|
13
|
+
#scrollLocked = false;
|
|
14
|
+
/** Background siblings made `inert` while active, restored on deactivation. */
|
|
15
|
+
#inertedSiblings = [];
|
|
16
|
+
/** Whether the modal side effects are currently applied. */
|
|
17
|
+
#activeState = false;
|
|
18
|
+
/** Returns the trapped element; called on every operation for the live target. */
|
|
19
|
+
#getContainer;
|
|
20
|
+
/** Closing/focus hooks; see {@link FocusTrapOptions}. */
|
|
21
|
+
#options;
|
|
22
|
+
/**
|
|
23
|
+
* @param getContainer - Returns the trapped element. Called on every operation
|
|
24
|
+
* so the live target is always used.
|
|
25
|
+
* @param options - Closing/focus hooks; see {@link FocusTrapOptions}.
|
|
26
|
+
*/
|
|
27
|
+
constructor(getContainer, options = {}) {
|
|
28
|
+
this.#getContainer = getContainer;
|
|
29
|
+
this.#options = options;
|
|
30
|
+
}
|
|
31
|
+
/** Whether the trap is currently active. */
|
|
32
|
+
get active() {
|
|
33
|
+
return this.#activeState;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Applies the trap: records the current focus, optionally locks background scroll
|
|
37
|
+
* and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless
|
|
38
|
+
* `autoFocus` is off) moves focus inside. No-ops if already active.
|
|
39
|
+
*/
|
|
40
|
+
activate() {
|
|
41
|
+
if (this.#activeState) return;
|
|
42
|
+
this.#activeState = true;
|
|
43
|
+
const active = document.activeElement;
|
|
44
|
+
this.#previouslyFocused = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
45
|
+
if (this.#flag(this.#options.lockScroll, true)) {
|
|
46
|
+
this.#previousBodyOverflow = document.body.style.overflow;
|
|
47
|
+
document.body.style.overflow = "hidden";
|
|
48
|
+
this.#scrollLocked = true;
|
|
49
|
+
}
|
|
50
|
+
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
51
|
+
document.addEventListener("keydown", this.#onKeydown);
|
|
52
|
+
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reverts every side effect applied by {@link activate}. No-ops if inactive, so
|
|
56
|
+
* a controller can call it defensively from both `close()` and `disconnect()`.
|
|
57
|
+
*
|
|
58
|
+
* @param restoreFocus - Move focus back to the opener (default `true`). Pass
|
|
59
|
+
* `false` on teardown (`disconnect`), where yanking focus is undesirable.
|
|
60
|
+
*/
|
|
61
|
+
deactivate({ restoreFocus = true } = {}) {
|
|
62
|
+
if (!this.#activeState) return;
|
|
63
|
+
this.#activeState = false;
|
|
64
|
+
document.removeEventListener("keydown", this.#onKeydown);
|
|
65
|
+
if (this.#scrollLocked) {
|
|
66
|
+
document.body.style.overflow = this.#previousBodyOverflow;
|
|
67
|
+
this.#scrollLocked = false;
|
|
68
|
+
}
|
|
69
|
+
this.#releaseBackground();
|
|
70
|
+
if (restoreFocus) {
|
|
71
|
+
const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;
|
|
72
|
+
target?.focus();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Resolves a boolean-or-getter option, defaulting when it was not provided. */
|
|
76
|
+
#flag(option, fallback) {
|
|
77
|
+
if (option === void 0) return fallback;
|
|
78
|
+
return typeof option === "function" ? option() : option;
|
|
79
|
+
}
|
|
80
|
+
/** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
|
|
81
|
+
#onKeydown = (event) => {
|
|
82
|
+
if (event.key === "Escape") {
|
|
83
|
+
if (this.#options.onEscape) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
this.#options.onEscape();
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (event.key === "Tab") this.#trapTab(event);
|
|
90
|
+
};
|
|
91
|
+
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
92
|
+
#trapTab(event) {
|
|
93
|
+
const focusable = this.#focusableElements();
|
|
94
|
+
if (focusable.length === 0) {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const first = focusable[0];
|
|
99
|
+
const last = focusable[focusable.length - 1];
|
|
100
|
+
const active = document.activeElement;
|
|
101
|
+
if (!(active instanceof Node) || !this.#getContainer().contains(active)) {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
first?.focus();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (event.shiftKey && active === first) {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
last?.focus();
|
|
109
|
+
} else if (!event.shiftKey && active === last) {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
first?.focus();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Marks every element outside the container's subtree as `inert` so background
|
|
116
|
+
* content cannot be focused or reached by assistive technology, honoring the
|
|
117
|
+
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
118
|
+
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
119
|
+
*/
|
|
120
|
+
#isolateBackground() {
|
|
121
|
+
const container = this.#getContainer();
|
|
122
|
+
this.#inertedSiblings = [];
|
|
123
|
+
for (const sibling of Array.from(document.body.children)) {
|
|
124
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
125
|
+
if (sibling.contains(container) || sibling.inert) continue;
|
|
126
|
+
sibling.inert = true;
|
|
127
|
+
this.#inertedSiblings.push(sibling);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
131
|
+
#releaseBackground() {
|
|
132
|
+
for (const sibling of this.#inertedSiblings) {
|
|
133
|
+
sibling.inert = false;
|
|
134
|
+
}
|
|
135
|
+
this.#inertedSiblings = [];
|
|
136
|
+
}
|
|
137
|
+
/** Moves focus to the initial target, the first focusable, or the container. */
|
|
138
|
+
#focusInitial() {
|
|
139
|
+
const preferred = this.#options.initialFocus?.();
|
|
140
|
+
if (preferred) {
|
|
141
|
+
preferred.focus();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const focusable = this.#focusableElements();
|
|
145
|
+
if (focusable[0]) {
|
|
146
|
+
focusable[0].focus();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const container = this.#getContainer();
|
|
150
|
+
container.tabIndex = -1;
|
|
151
|
+
container.focus();
|
|
152
|
+
}
|
|
153
|
+
/** Collects the container's currently focusable descendants in DOM order. */
|
|
154
|
+
#focusableElements() {
|
|
155
|
+
return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
|
|
156
|
+
(el) => !el.hidden
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// src/controllers/dialog_controller.ts
|
|
162
|
+
var DialogController = class extends Controller {
|
|
163
|
+
static targets = ["trigger", "dialog"];
|
|
164
|
+
static actions = ["close", "closeOnBackdrop", "open"];
|
|
165
|
+
/** Owns the modal side effects; Escape closes, focus falls back to the trigger. */
|
|
166
|
+
#trap = new FocusTrap(() => this.dialogTarget, {
|
|
167
|
+
onEscape: () => this.close(),
|
|
168
|
+
fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
|
|
169
|
+
});
|
|
170
|
+
/** Starts closed (idempotently reflects the closed state on the markup). */
|
|
171
|
+
connect() {
|
|
172
|
+
if (this.hasDialogTarget) this.dialogTarget.hidden = true;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Reverts the modal side effects (scroll lock, background `inert`, keydown
|
|
176
|
+
* listener) if the controller is torn down while open (e.g. a Turbo navigation
|
|
177
|
+
* replaces the page while the dialog is showing). Focus is not restored on
|
|
178
|
+
* teardown.
|
|
179
|
+
*/
|
|
180
|
+
disconnect() {
|
|
181
|
+
this.#trap.deactivate({ restoreFocus: false });
|
|
182
|
+
}
|
|
183
|
+
/** Opens the dialog, traps focus, and locks background scroll. */
|
|
184
|
+
open() {
|
|
185
|
+
if (!this.hasDialogTarget || this.#isOpen) return;
|
|
186
|
+
this.dialogTarget.hidden = false;
|
|
187
|
+
this.#trap.activate();
|
|
188
|
+
}
|
|
189
|
+
/** Closes the dialog, restores scroll, and returns focus to the opener. */
|
|
190
|
+
close() {
|
|
191
|
+
if (!this.hasDialogTarget || !this.#isOpen) return;
|
|
192
|
+
this.dialogTarget.hidden = true;
|
|
193
|
+
this.#trap.deactivate();
|
|
194
|
+
}
|
|
195
|
+
/** Closes when the backdrop (the dialog target itself) is clicked. */
|
|
196
|
+
closeOnBackdrop(event) {
|
|
197
|
+
if (event.target === this.dialogTarget) this.close();
|
|
198
|
+
}
|
|
199
|
+
/** Whether the dialog is currently visible. */
|
|
200
|
+
get #isOpen() {
|
|
201
|
+
return this.hasDialogTarget && !this.dialogTarget.hidden;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export { DialogController };
|
|
206
|
+
//# sourceMappingURL=dialog_controller.js.map
|
|
207
|
+
//# sourceMappingURL=dialog_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/focus_trap.ts","../../src/controllers/dialog_controller.ts"],"names":[],"mappings":";;;;;AA6BO,IAAM,SAAA,GACX,2IAAA;AA6CK,IAAM,YAAN,MAAgB;AAAA;AAAA,EAErB,kBAAA,GAAyC,IAAA;AAAA;AAAA,EAEzC,qBAAA,GAAwB,EAAA;AAAA;AAAA,EAExB,aAAA,GAAgB,KAAA;AAAA;AAAA,EAEhB,mBAAkC,EAAC;AAAA;AAAA,EAEnC,YAAA,GAAe,KAAA;AAAA;AAAA,EAGN,aAAA;AAAA;AAAA,EAEA,QAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOT,WAAA,CAAY,YAAA,EAAiC,OAAA,GAA4B,EAAC,EAAG;AAC3E,IAAA,IAAA,CAAK,aAAA,GAAgB,YAAA;AACrB,IAAA,IAAA,CAAK,QAAA,GAAW,OAAA;AAAA,EAClB;AAAA;AAAA,EAGA,IAAI,MAAA,GAAkB;AACpB,IAAA,OAAO,IAAA,CAAK,YAAA;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,QAAA,GAAiB;AACf,IAAA,IAAI,KAAK,YAAA,EAAc;AACvB,IAAA,IAAA,CAAK,YAAA,GAAe,IAAA;AAIpB,IAAA,MAAM,SAAS,QAAA,CAAS,aAAA;AACxB,IAAA,IAAA,CAAK,qBACH,MAAA,YAAkB,WAAA,IAAe,MAAA,KAAW,QAAA,CAAS,OAAO,MAAA,GAAS,IAAA;AACvE,IAAA,IAAI,KAAK,KAAA,CAAM,IAAA,CAAK,QAAA,CAAS,UAAA,EAAY,IAAI,CAAA,EAAG;AAC9C,MAAA,IAAA,CAAK,qBAAA,GAAwB,QAAA,CAAS,IAAA,CAAK,KAAA,CAAM,QAAA;AACjD,MAAA,QAAA,CAAS,IAAA,CAAK,MAAM,QAAA,GAAW,QAAA;AAC/B,MAAA,IAAA,CAAK,aAAA,GAAgB,IAAA;AAAA,IACvB;AACA,IAAA,IAAI,IAAA,CAAK,MAAM,IAAA,CAAK,QAAA,CAAS,SAAS,IAAI,CAAA,OAAQ,kBAAA,EAAmB;AACrE,IAAA,QAAA,CAAS,gBAAA,CAAiB,SAAA,EAAW,IAAA,CAAK,UAAU,CAAA;AACpD,IAAA,IAAI,IAAA,CAAK,MAAM,IAAA,CAAK,QAAA,CAAS,WAAW,IAAI,CAAA,OAAQ,aAAA,EAAc;AAAA,EACpE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,WAAW,EAAE,YAAA,GAAe,IAAA,EAAK,GAAgC,EAAC,EAAS;AACzE,IAAA,IAAI,CAAC,KAAK,YAAA,EAAc;AACxB,IAAA,IAAA,CAAK,YAAA,GAAe,KAAA;AACpB,IAAA,QAAA,CAAS,mBAAA,CAAoB,SAAA,EAAW,IAAA,CAAK,UAAU,CAAA;AACvD,IAAA,IAAI,KAAK,aAAA,EAAe;AACtB,MAAA,QAAA,CAAS,IAAA,CAAK,KAAA,CAAM,QAAA,GAAW,IAAA,CAAK,qBAAA;AACpC,MAAA,IAAA,CAAK,aAAA,GAAgB,KAAA;AAAA,IACvB;AACA,IAAA,IAAA,CAAK,kBAAA,EAAmB;AACxB,IAAA,IAAI,YAAA,EAAc;AAChB,MAAA,MAAM,SAAS,IAAA,CAAK,kBAAA,IAAsB,IAAA,CAAK,QAAA,CAAS,iBAAgB,IAAK,IAAA;AAC7E,MAAA,MAAA,EAAQ,KAAA,EAAM;AAAA,IAChB;AAAA,EACF;AAAA;AAAA,EAGA,KAAA,CAAM,QAA+C,QAAA,EAA4B;AAC/E,IAAA,IAAI,MAAA,KAAW,QAAW,OAAO,QAAA;AACjC,IAAA,OAAO,OAAO,MAAA,KAAW,UAAA,GAAa,MAAA,EAAO,GAAI,MAAA;AAAA,EACnD;AAAA;AAAA,EAGS,UAAA,GAAa,CAAC,KAAA,KAA+B;AACpD,IAAA,IAAI,KAAA,CAAM,QAAQ,QAAA,EAAU;AAC1B,MAAA,IAAI,IAAA,CAAK,SAAS,QAAA,EAAU;AAC1B,QAAA,KAAA,CAAM,cAAA,EAAe;AACrB,QAAA,IAAA,CAAK,SAAS,QAAA,EAAS;AAAA,MACzB;AACA,MAAA;AAAA,IACF;AACA,IAAA,IAAI,KAAA,CAAM,GAAA,KAAQ,KAAA,EAAO,IAAA,CAAK,SAAS,KAAK,CAAA;AAAA,EAC9C,CAAA;AAAA;AAAA,EAGA,SAAS,KAAA,EAA4B;AACnC,IAAA,MAAM,SAAA,GAAY,KAAK,kBAAA,EAAmB;AAC1C,IAAA,IAAI,SAAA,CAAU,WAAW,CAAA,EAAG;AAC1B,MAAA,KAAA,CAAM,cAAA,EAAe;AACrB,MAAA;AAAA,IACF;AACA,IAAA,MAAM,KAAA,GAAQ,UAAU,CAAC,CAAA;AACzB,IAAA,MAAM,IAAA,GAAO,SAAA,CAAU,SAAA,CAAU,MAAA,GAAS,CAAC,CAAA;AAC3C,IAAA,MAAM,SAAS,QAAA,CAAS,aAAA;AAGxB,IAAA,IAAI,EAAE,kBAAkB,IAAA,CAAA,IAAS,CAAC,KAAK,aAAA,EAAc,CAAE,QAAA,CAAS,MAAM,CAAA,EAAG;AACvE,MAAA,KAAA,CAAM,cAAA,EAAe;AACrB,MAAA,KAAA,EAAO,KAAA,EAAM;AACb,MAAA;AAAA,IACF;AAEA,IAAA,IAAI,KAAA,CAAM,QAAA,IAAY,MAAA,KAAW,KAAA,EAAO;AACtC,MAAA,KAAA,CAAM,cAAA,EAAe;AACrB,MAAA,IAAA,EAAM,KAAA,EAAM;AAAA,IACd,CAAA,MAAA,IAAW,CAAC,KAAA,CAAM,QAAA,IAAY,WAAW,IAAA,EAAM;AAC7C,MAAA,KAAA,CAAM,cAAA,EAAe;AACrB,MAAA,KAAA,EAAO,KAAA,EAAM;AAAA,IACf;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,kBAAA,GAA2B;AACzB,IAAA,MAAM,SAAA,GAAY,KAAK,aAAA,EAAc;AACrC,IAAA,IAAA,CAAK,mBAAmB,EAAC;AACzB,IAAA,KAAA,MAAW,WAAW,KAAA,CAAM,IAAA,CAAK,QAAA,CAAS,IAAA,CAAK,QAAQ,CAAA,EAAG;AACxD,MAAA,IAAI,EAAE,mBAAmB,WAAA,CAAA,EAAc;AACvC,MAAA,IAAI,OAAA,CAAQ,QAAA,CAAS,SAAS,CAAA,IAAK,QAAQ,KAAA,EAAO;AAClD,MAAA,OAAA,CAAQ,KAAA,GAAQ,IAAA;AAChB,MAAA,IAAA,CAAK,gBAAA,CAAiB,KAAK,OAAO,CAAA;AAAA,IACpC;AAAA,EACF;AAAA;AAAA,EAGA,kBAAA,GAA2B;AACzB,IAAA,KAAA,MAAW,OAAA,IAAW,KAAK,gBAAA,EAAkB;AAC3C,MAAA,OAAA,CAAQ,KAAA,GAAQ,KAAA;AAAA,IAClB;AACA,IAAA,IAAA,CAAK,mBAAmB,EAAC;AAAA,EAC3B;AAAA;AAAA,EAGA,aAAA,GAAsB;AACpB,IAAA,MAAM,SAAA,GAAY,IAAA,CAAK,QAAA,CAAS,YAAA,IAAe;AAC/C,IAAA,IAAI,SAAA,EAAW;AACb,MAAA,SAAA,CAAU,KAAA,EAAM;AAChB,MAAA;AAAA,IACF;AACA,IAAA,MAAM,SAAA,GAAY,KAAK,kBAAA,EAAmB;AAC1C,IAAA,IAAI,SAAA,CAAU,CAAC,CAAA,EAAG;AAChB,MAAA,SAAA,CAAU,CAAC,EAAE,KAAA,EAAM;AACnB,MAAA;AAAA,IACF;AACA,IAAA,MAAM,SAAA,GAAY,KAAK,aAAA,EAAc;AACrC,IAAA,SAAA,CAAU,QAAA,GAAW,EAAA;AACrB,IAAA,SAAA,CAAU,KAAA,EAAM;AAAA,EAClB;AAAA;AAAA,EAGA,kBAAA,GAAoC;AAClC,IAAA,OAAO,KAAA,CAAM,KAAK,IAAA,CAAK,aAAA,GAAgB,gBAAA,CAA8B,SAAS,CAAC,CAAA,CAAE,MAAA;AAAA,MAC/E,CAAC,EAAA,KAAO,CAAC,EAAA,CAAG;AAAA,KACd;AAAA,EACF;AACF,CAAA;;;ACtNO,IAAM,gBAAA,GAAN,cAA+B,UAAA,CAAwB;AAAA,EAC5D,OAAgB,OAAA,GAAU,CAAC,SAAA,EAAW,QAAQ,CAAA;AAAA,EAC9C,OAAO,OAAA,GAAU,CAAC,OAAA,EAAS,mBAAmB,MAAM,CAAA;AAAA;AAAA,EAQ3C,KAAA,GAAQ,IAAI,SAAA,CAAU,MAAM,KAAK,YAAA,EAAc;AAAA,IACtD,QAAA,EAAU,MAAM,IAAA,CAAK,KAAA,EAAM;AAAA,IAC3B,aAAA,EAAe,MAAO,IAAA,CAAK,gBAAA,GAAmB,KAAK,aAAA,GAAgB;AAAA,GACpE,CAAA;AAAA;AAAA,EAGQ,OAAA,GAAgB;AACvB,IAAA,IAAI,IAAA,CAAK,eAAA,EAAiB,IAAA,CAAK,YAAA,CAAa,MAAA,GAAS,IAAA;AAAA,EACvD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQS,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,KAAA,CAAM,UAAA,CAAW,EAAE,YAAA,EAAc,OAAO,CAAA;AAAA,EAC/C;AAAA;AAAA,EAGA,IAAA,GAAa;AACX,IAAA,IAAI,CAAC,IAAA,CAAK,eAAA,IAAmB,IAAA,CAAK,OAAA,EAAS;AAC3C,IAAA,IAAA,CAAK,aAAa,MAAA,GAAS,KAAA;AAC3B,IAAA,IAAA,CAAK,MAAM,QAAA,EAAS;AAAA,EACtB;AAAA;AAAA,EAGA,KAAA,GAAc;AACZ,IAAA,IAAI,CAAC,IAAA,CAAK,eAAA,IAAmB,CAAC,KAAK,OAAA,EAAS;AAC5C,IAAA,IAAA,CAAK,aAAa,MAAA,GAAS,IAAA;AAC3B,IAAA,IAAA,CAAK,MAAM,UAAA,EAAW;AAAA,EACxB;AAAA;AAAA,EAGA,gBAAgB,KAAA,EAAyB;AACvC,IAAA,IAAI,KAAA,CAAM,MAAA,KAAW,IAAA,CAAK,YAAA,OAAmB,KAAA,EAAM;AAAA,EACrD;AAAA;AAAA,EAGA,IAAI,OAAA,GAAmB;AACrB,IAAA,OAAO,IAAA,CAAK,eAAA,IAAmB,CAAC,IAAA,CAAK,YAAA,CAAa,MAAA;AAAA,EACpD;AACF","file":"dialog_controller.js","sourcesContent":["/**\n * Modal focus-trap primitive shared by the modal-overlay controllers\n * (dialog / alert-dialog / drawer).\n *\n * The WAI-ARIA APG modal pattern is more than \"cycle Tab inside a box\": a modal\n * also locks background scroll, makes the rest of the page `inert` (so assistive\n * technology and pointer/Tab cannot reach it, honoring `aria-modal=\"true\"`),\n * sends focus inside on open, and restores it to the opener on close — and every\n * one of those side effects must be reverted if the element is torn down while\n * open (a Turbo navigation mid-dialog). {@link FocusTrap} owns that whole modal\n * lifecycle so each controller only decides *when* to open/close and *what*\n * \"close\" means.\n *\n * It is intentionally **policy-free about closing**. Escape semantics differ per\n * widget (a plain dialog just closes; an alert-dialog closes *as a cancel* with a\n * reason; a drawer runs an exit transition), so the trap merely forwards Escape\n * to an {@link FocusTrapOptions.onEscape | onEscape} callback and never decides on\n * its own what closing entails.\n *\n * @remarks\n * The container is read through a getter so a controller can hand over a Stimulus\n * target without worrying about when the trap instance is constructed relative to\n * `connect()`.\n */\n\n/**\n * Selector matching the elements considered focusable. Shared by the trap's Tab\n * cycling and by form-validation's invalid-focus delegation.\n */\nexport const FOCUSABLE =\n 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n/** Behavior hooks a controller supplies when constructing a {@link FocusTrap}. */\nexport interface FocusTrapOptions {\n /**\n * Called when `Escape` is pressed while the trap is active. When omitted,\n * `Escape` is left alone (the trap never closes itself). The trap calls\n * `preventDefault()` before invoking it.\n */\n onEscape?: () => void;\n /**\n * Returns the element to focus when the trap activates. When it returns `null`\n * (or is omitted), the first focusable descendant is used, falling back to the\n * container itself (made programmatically focusable with `tabindex=-1`).\n */\n initialFocus?: () => HTMLElement | null;\n /**\n * Returns the element to focus on deactivation when nothing was focused before\n * the trap opened (e.g. the trigger). The element focused *before* opening\n * always takes precedence.\n */\n fallbackFocus?: () => HTMLElement | null;\n /**\n * Lock background scroll (`body` overflow) while active. Defaults to `true` for\n * the modal overlays; a lighter focus scope passes `false`. Read on `activate`.\n */\n lockScroll?: boolean | (() => boolean);\n /**\n * Make background siblings `inert` while active (the `aria-modal` isolation).\n * Defaults to `true` for the modal overlays; a soft focus scope can opt out so\n * the background stays reachable while `Tab` still cycles inside. Read on `activate`.\n */\n isolate?: boolean | (() => boolean);\n /**\n * Move focus inside on `activate`. Defaults to `true`; a focus scope that only\n * wants the `Tab` boundary (no focus move) passes `false`. Read on `activate`.\n */\n autoFocus?: boolean | (() => boolean);\n}\n\n/**\n * Owns the modal side effects (scroll lock, background `inert`, focus trap, focus\n * restore) for a single container, applied on {@link activate} and reverted on\n * {@link deactivate}.\n */\nexport class FocusTrap {\n /** The element focused before activation, restored on deactivation. */\n #previouslyFocused: HTMLElement | null = null;\n /** The body's inline `overflow` before locking, restored on deactivation. */\n #previousBodyOverflow = \"\";\n /** Whether scroll was locked this activation (so it is only restored if applied). */\n #scrollLocked = false;\n /** Background siblings made `inert` while active, restored on deactivation. */\n #inertedSiblings: HTMLElement[] = [];\n /** Whether the modal side effects are currently applied. */\n #activeState = false;\n\n /** Returns the trapped element; called on every operation for the live target. */\n readonly #getContainer: () => HTMLElement;\n /** Closing/focus hooks; see {@link FocusTrapOptions}. */\n readonly #options: FocusTrapOptions;\n\n /**\n * @param getContainer - Returns the trapped element. Called on every operation\n * so the live target is always used.\n * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.\n */\n constructor(getContainer: () => HTMLElement, options: FocusTrapOptions = {}) {\n this.#getContainer = getContainer;\n this.#options = options;\n }\n\n /** Whether the trap is currently active. */\n get active(): boolean {\n return this.#activeState;\n }\n\n /**\n * Applies the trap: records the current focus, optionally locks background scroll\n * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless\n * `autoFocus` is off) moves focus inside. No-ops if already active.\n */\n activate(): void {\n if (this.#activeState) return;\n this.#activeState = true;\n // Record the opener so it can be refocused on close. `<body>` (the default\n // active element when nothing is focused) is treated as \"nothing\", so the\n // fallback target — typically the trigger — wins in that case.\n const active = document.activeElement;\n this.#previouslyFocused =\n active instanceof HTMLElement && active !== document.body ? active : null;\n if (this.#flag(this.#options.lockScroll, true)) {\n this.#previousBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n this.#scrollLocked = true;\n }\n if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();\n document.addEventListener(\"keydown\", this.#onKeydown);\n if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();\n }\n\n /**\n * Reverts every side effect applied by {@link activate}. No-ops if inactive, so\n * a controller can call it defensively from both `close()` and `disconnect()`.\n *\n * @param restoreFocus - Move focus back to the opener (default `true`). Pass\n * `false` on teardown (`disconnect`), where yanking focus is undesirable.\n */\n deactivate({ restoreFocus = true }: { restoreFocus?: boolean } = {}): void {\n if (!this.#activeState) return;\n this.#activeState = false;\n document.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.#scrollLocked) {\n document.body.style.overflow = this.#previousBodyOverflow;\n this.#scrollLocked = false;\n }\n this.#releaseBackground();\n if (restoreFocus) {\n const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;\n target?.focus();\n }\n }\n\n /** Resolves a boolean-or-getter option, defaulting when it was not provided. */\n #flag(option: boolean | (() => boolean) | undefined, fallback: boolean): boolean {\n if (option === undefined) return fallback;\n return typeof option === \"function\" ? option() : option;\n }\n\n /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") {\n if (this.#options.onEscape) {\n event.preventDefault();\n this.#options.onEscape();\n }\n return;\n }\n if (event.key === \"Tab\") this.#trapTab(event);\n };\n\n /** Keeps `Tab` focus cycling within the container's focusable elements. */\n #trapTab(event: KeyboardEvent): void {\n const focusable = this.#focusableElements();\n if (focusable.length === 0) {\n event.preventDefault();\n return;\n }\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n const active = document.activeElement;\n\n // If focus has somehow escaped the container, pull it back to the first item.\n if (!(active instanceof Node) || !this.#getContainer().contains(active)) {\n event.preventDefault();\n first?.focus();\n return;\n }\n\n if (event.shiftKey && active === first) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first?.focus();\n }\n }\n\n /**\n * Marks every element outside the container's subtree as `inert` so background\n * content cannot be focused or reached by assistive technology, honoring the\n * `aria-modal=\"true\"` contract. An element that was *already* `inert` is left\n * untracked so `#releaseBackground` does not wrongly clear it.\n */\n #isolateBackground(): void {\n const container = this.#getContainer();\n this.#inertedSiblings = [];\n for (const sibling of Array.from(document.body.children)) {\n if (!(sibling instanceof HTMLElement)) continue;\n if (sibling.contains(container) || sibling.inert) continue;\n sibling.inert = true;\n this.#inertedSiblings.push(sibling);\n }\n }\n\n /** Reverts the `inert` flags applied by `#isolateBackground`. */\n #releaseBackground(): void {\n for (const sibling of this.#inertedSiblings) {\n sibling.inert = false;\n }\n this.#inertedSiblings = [];\n }\n\n /** Moves focus to the initial target, the first focusable, or the container. */\n #focusInitial(): void {\n const preferred = this.#options.initialFocus?.();\n if (preferred) {\n preferred.focus();\n return;\n }\n const focusable = this.#focusableElements();\n if (focusable[0]) {\n focusable[0].focus();\n return;\n }\n const container = this.#getContainer();\n container.tabIndex = -1;\n container.focus();\n }\n\n /** Collects the container's currently focusable descendants in DOM order. */\n #focusableElements(): HTMLElement[] {\n return Array.from(this.#getContainer().querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n (el) => !el.hidden,\n );\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { FocusTrap } from \"../utils/focus_trap\";\n\n/**\n * Headless, accessible modal dialog behavior.\n *\n * Markup contract (identifier: `stimeo--dialog`):\n * <div data-controller=\"stimeo--dialog\">\n * <button data-stimeo--dialog-target=\"trigger\"\n * data-action=\"stimeo--dialog#open\">Open</button>\n * <div data-stimeo--dialog-target=\"dialog\" role=\"dialog\" aria-modal=\"true\"\n * aria-labelledby=\"title\" hidden>\n * <h2 id=\"title\">…</h2>\n * <button data-action=\"stimeo--dialog#close\">Close</button>\n * </div>\n * </div>\n *\n * Implements the WAI-ARIA APG **Dialog (Modal)** pattern: focus moves into the\n * dialog on open and is trapped within it, `Escape` closes it, background scroll\n * is locked, and focus returns to the trigger on close.\n *\n * @remarks\n * Behavior only. The sole visual side effect is locking `document.body`'s scroll\n * while open (the minimum required by the pattern); all other styling is the\n * consumer's. Clicking the dialog backdrop (the dialog target itself, outside\n * its content) also closes it.\n *\n * The modal lifecycle — focus trap, scroll lock, background `inert`, focus\n * restore, and teardown reversal — is delegated to the shared {@link FocusTrap}\n * primitive (also used by alert-dialog and drawer). This controller only owns\n * *when* to open/close and the dialog-specific backdrop click.\n */\nexport class DialogController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"dialog\"];\n static actions = [\"close\", \"closeOnBackdrop\", \"open\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly dialogTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasDialogTarget: boolean;\n\n /** Owns the modal side effects; Escape closes, focus falls back to the trigger. */\n readonly #trap = new FocusTrap(() => this.dialogTarget, {\n onEscape: () => this.close(),\n fallbackFocus: () => (this.hasTriggerTarget ? this.triggerTarget : null),\n });\n\n /** Starts closed (idempotently reflects the closed state on the markup). */\n override connect(): void {\n if (this.hasDialogTarget) this.dialogTarget.hidden = true;\n }\n\n /**\n * Reverts the modal side effects (scroll lock, background `inert`, keydown\n * listener) if the controller is torn down while open (e.g. a Turbo navigation\n * replaces the page while the dialog is showing). Focus is not restored on\n * teardown.\n */\n override disconnect(): void {\n this.#trap.deactivate({ restoreFocus: false });\n }\n\n /** Opens the dialog, traps focus, and locks background scroll. */\n open(): void {\n if (!this.hasDialogTarget || this.#isOpen) return;\n this.dialogTarget.hidden = false;\n this.#trap.activate();\n }\n\n /** Closes the dialog, restores scroll, and returns focus to the opener. */\n close(): void {\n if (!this.hasDialogTarget || !this.#isOpen) return;\n this.dialogTarget.hidden = true;\n this.#trap.deactivate();\n }\n\n /** Closes when the backdrop (the dialog target itself) is clicked. */\n closeOnBackdrop(event: MouseEvent): void {\n if (event.target === this.dialogTarget) this.close();\n }\n\n /** Whether the dialog is currently visible. */\n get #isOpen(): boolean {\n return this.hasDialogTarget && !this.dialogTarget.hidden;\n }\n}\n"]}
|