stimeo-ui 0.2.0 → 0.3.0
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/CHANGELOG.md +163 -0
- package/dist/cable/index.d.ts +16 -16
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js +10 -0
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +97 -10
- package/dist/controllers/breadcrumb_controller.js +225 -13
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +11 -1
- package/dist/controllers/calendar_controller.js +89 -22
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +115 -0
- package/dist/controllers/carousel_controller.js +313 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +87 -0
- package/dist/controllers/color_picker_controller.js +252 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +14 -1
- package/dist/controllers/combobox_controller.js +162 -23
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +2 -0
- package/dist/controllers/command_palette_controller.js +194 -17
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +7 -6
- package/dist/controllers/context_menu_controller.js +32 -10
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js +8 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +89 -0
- package/dist/controllers/data_grid_controller.js +246 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +88 -0
- package/dist/controllers/date_range_picker_controller.js +441 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +1 -2
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +169 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +1 -1
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +1 -1
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +6 -4
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +1 -1
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +67 -19
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +54 -11
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +9 -2
- package/dist/controllers/listbox_controller.js +257 -53
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.js +2 -2
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +29 -11
- package/dist/controllers/menu_controller.js +104 -17
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +142 -0
- package/dist/controllers/menubar_controller.js +785 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +141 -0
- package/dist/controllers/multi_select_controller.js +755 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +146 -0
- package/dist/controllers/navigation_menu_controller.js +511 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +4 -5
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +1 -1
- package/dist/controllers/number_input_controller.js +7 -0
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +18 -1
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +15 -6
- package/dist/controllers/overflow_indicator_controller.js +246 -27
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +78 -19
- package/dist/controllers/overflow_menu_controller.js +381 -57
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +36 -5
- package/dist/controllers/pagination_controller.js +163 -32
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- package/dist/controllers/persist_controller.js +6 -6
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +2 -3
- package/dist/controllers/pointer_drag_controller.js +9 -1
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +1 -2
- package/dist/controllers/popover_controller.js +2 -2
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +4 -4
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.js +22 -3
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +97 -0
- package/dist/controllers/range_slider_controller.js +192 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/rating_controller.js +16 -2
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +238 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +3 -1
- package/dist/controllers/resizable_controller.js +65 -1
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +17 -2
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +101 -14
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
- package/dist/controllers/scroll_visibility_controller.js +40 -6
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +64 -15
- package/dist/controllers/scrollspy_controller.js +369 -74
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +69 -0
- package/dist/controllers/separator_controller.js +96 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.js +1 -1
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +11 -1
- package/dist/controllers/slider_controller.js +32 -6
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +3 -1
- package/dist/controllers/sortable_controller.js +34 -3
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.js +1 -1
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +5 -6
- package/dist/controllers/stick_to_bottom_controller.js +9 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +109 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +2 -2
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.js +1 -0
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +13 -4
- package/dist/controllers/tabs_controller.js +26 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +295 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +1 -1
- package/dist/controllers/theme_controller.js +42 -13
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +231 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +5 -14
- package/dist/controllers/toast_controller.js +40 -14
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.js +23 -2
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.d.ts +53 -13
- package/dist/controllers/toolbar_controller.js +230 -31
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +1 -1
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +135 -0
- package/dist/controllers/tree_view_controller.js +691 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1818
- package/dist/index.js +4256 -915
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +375 -39
- package/dist/inspector/cli.js +228 -14
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +419 -48
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +26 -26
- package/dist/inspector/manifest.json +1054 -127
- package/package.json +5 -6
|
@@ -3,10 +3,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
3
3
|
// src/controllers/sticky_observer_controller.ts
|
|
4
4
|
|
|
5
5
|
// src/utils/intersection_watcher.ts
|
|
6
|
+
function isBeforeRootStart(entry) {
|
|
7
|
+
const rect = entry.boundingClientRect;
|
|
8
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
9
|
+
const rootTop = entry.rootBounds?.top ?? 0;
|
|
10
|
+
return rect.bottom <= rootTop;
|
|
11
|
+
}
|
|
6
12
|
var IntersectionWatcher = class {
|
|
7
13
|
#onEntries;
|
|
8
14
|
#observer = null;
|
|
9
15
|
#active = false;
|
|
16
|
+
#usingPlatformDefaults = false;
|
|
10
17
|
constructor(onEntries) {
|
|
11
18
|
this.#onEntries = onEntries;
|
|
12
19
|
}
|
|
@@ -14,10 +21,22 @@ var IntersectionWatcher = class {
|
|
|
14
21
|
get active() {
|
|
15
22
|
return this.#active;
|
|
16
23
|
}
|
|
24
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
25
|
+
get usingPlatformDefaults() {
|
|
26
|
+
return this.#usingPlatformDefaults;
|
|
27
|
+
}
|
|
17
28
|
/**
|
|
18
29
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
30
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
31
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
32
|
+
* If initial construction with the configured options fails, the watcher
|
|
33
|
+
* warns and retries once with the same root and platform defaults.
|
|
34
|
+
*
|
|
35
|
+
* @throws The fallback constructor error if both construction attempts fail,
|
|
36
|
+
* or whatever the platform throws from `observe()`. The exception is passed
|
|
37
|
+
* through unchanged, but the watcher rolls back first: every target observed
|
|
38
|
+
* so far is released and `active` stays `false`, so a caller that retries
|
|
39
|
+
* starts from a clean slate.
|
|
21
40
|
*/
|
|
22
41
|
start(targets, options = {}) {
|
|
23
42
|
this.stop();
|
|
@@ -25,31 +44,61 @@ var IntersectionWatcher = class {
|
|
|
25
44
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
45
|
if (list.length === 0) return false;
|
|
27
46
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(entries) => {
|
|
31
|
-
if (this.#active) this.#onEntries(entries);
|
|
32
|
-
}
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
let observer = null;
|
|
48
|
+
try {
|
|
49
|
+
const onEntries = (entries) => {
|
|
50
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
observer = new IntersectionObserver(onEntries, {
|
|
54
|
+
root,
|
|
55
|
+
rootMargin: options.rootMargin,
|
|
56
|
+
threshold: options.threshold
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn(
|
|
60
|
+
"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
|
|
61
|
+
error
|
|
62
|
+
);
|
|
63
|
+
observer = new IntersectionObserver(onEntries, { root });
|
|
64
|
+
this.#usingPlatformDefaults = true;
|
|
65
|
+
}
|
|
66
|
+
for (const target of list) observer.observe(target);
|
|
67
|
+
this.#observer = observer;
|
|
68
|
+
this.#active = true;
|
|
69
|
+
return true;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
observer?.disconnect();
|
|
72
|
+
this.#observer = null;
|
|
73
|
+
this.#active = false;
|
|
74
|
+
this.#usingPlatformDefaults = false;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
37
77
|
}
|
|
38
78
|
/**
|
|
39
79
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
80
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
81
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
82
|
+
*
|
|
83
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
84
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
85
|
*/
|
|
43
86
|
rearm(target) {
|
|
44
87
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
88
|
+
try {
|
|
89
|
+
this.#observer.unobserve(target);
|
|
90
|
+
this.#observer.observe(target);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.stop();
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
47
95
|
}
|
|
48
96
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
97
|
stop() {
|
|
50
98
|
this.#active = false;
|
|
51
99
|
this.#observer?.disconnect();
|
|
52
100
|
this.#observer = null;
|
|
101
|
+
this.#usingPlatformDefaults = false;
|
|
53
102
|
}
|
|
54
103
|
};
|
|
55
104
|
|
|
@@ -65,23 +114,63 @@ var StickyObserverController = class extends Controller {
|
|
|
65
114
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
66
115
|
/** Last reported stuck state, so `change` fires only on transitions. */
|
|
67
116
|
#stuck = null;
|
|
117
|
+
/** Target currently owned by the watcher; comparing it avoids duplicate restarts. */
|
|
118
|
+
#observedSentinel = null;
|
|
119
|
+
#connected = false;
|
|
68
120
|
#onIntersect(entries) {
|
|
69
|
-
const entry
|
|
70
|
-
|
|
71
|
-
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (!this.#connected || !this.#watcher.active) return;
|
|
123
|
+
this.#setStuck(!entry.isIntersecting && isBeforeRootStart(entry));
|
|
124
|
+
}
|
|
72
125
|
}
|
|
73
126
|
connect() {
|
|
74
|
-
|
|
75
|
-
this.#
|
|
76
|
-
|
|
77
|
-
rootMargin: `-${this.offsetValue}px 0px 0px 0px`,
|
|
78
|
-
threshold: [0]
|
|
79
|
-
});
|
|
127
|
+
this.#connected = true;
|
|
128
|
+
this.#stuck = null;
|
|
129
|
+
this.#syncObserver();
|
|
80
130
|
}
|
|
81
131
|
disconnect() {
|
|
132
|
+
this.#connected = false;
|
|
82
133
|
this.#watcher.stop();
|
|
134
|
+
this.#observedSentinel = null;
|
|
83
135
|
this.#stuck = null;
|
|
84
136
|
}
|
|
137
|
+
/** Starts observation when a sentinel is inserted after connection. */
|
|
138
|
+
sentinelTargetConnected() {
|
|
139
|
+
if (this.#connected) this.#syncObserver();
|
|
140
|
+
}
|
|
141
|
+
/** Stops or transfers observation when the current sentinel is removed. */
|
|
142
|
+
sentinelTargetDisconnected() {
|
|
143
|
+
if (this.#connected) this.#syncObserver();
|
|
144
|
+
}
|
|
145
|
+
/** Reflects the last snapshot onto an element inserted after that snapshot. */
|
|
146
|
+
elementTargetConnected(element) {
|
|
147
|
+
if (this.#stuck !== null) {
|
|
148
|
+
element.setAttribute("data-stuck", this.#stuck ? "true" : "false");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Rebuilds the observer when Turbo morphs the configured root. */
|
|
152
|
+
rootSelectorValueChanged() {
|
|
153
|
+
if (this.#connected) this.#syncObserver(true);
|
|
154
|
+
}
|
|
155
|
+
/** Rebuilds the observer when Turbo morphs the configured top offset. */
|
|
156
|
+
offsetValueChanged() {
|
|
157
|
+
if (this.#connected) this.#syncObserver(true);
|
|
158
|
+
}
|
|
159
|
+
#syncObserver(force = false) {
|
|
160
|
+
const sentinel = this.hasSentinelTarget ? this.sentinelTarget : null;
|
|
161
|
+
if (!force && sentinel === this.#observedSentinel && this.#watcher.active) return;
|
|
162
|
+
this.#watcher.stop();
|
|
163
|
+
this.#observedSentinel = null;
|
|
164
|
+
if (!sentinel) return;
|
|
165
|
+
const configuredOffset = this.offsetValue;
|
|
166
|
+
const offset = Number.isFinite(configuredOffset) ? configuredOffset : 0;
|
|
167
|
+
const started = this.#watcher.start(sentinel, {
|
|
168
|
+
rootSelector: this.rootSelectorValue,
|
|
169
|
+
rootMargin: `${-offset}px 0px 0px 0px`,
|
|
170
|
+
threshold: [0]
|
|
171
|
+
});
|
|
172
|
+
if (started) this.#observedSentinel = sentinel;
|
|
173
|
+
}
|
|
85
174
|
/** Reflects the stuck state onto the sticky element and emits `change`. */
|
|
86
175
|
#setStuck(next) {
|
|
87
176
|
if (next === this.#stuck) return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/sticky_observer_controller.ts"],"names":[],"mappings":"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","file":"sticky_observer_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n this.#active = true;\n this.#observer = new IntersectionObserver(\n (entries) => {\n if (this.#active) this.#onEntries(entries);\n },\n { root, rootMargin: options.rootMargin, threshold: options.threshold },\n );\n for (const target of list) this.#observer.observe(target);\n return true;\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **Sticky State Observer**: detects whether a `position: sticky`\n * element is currently stuck and publishes it as `data-stuck`. No APG widget — a\n * pure state-detection utility. Detection uses an {@link IntersectionObserver}\n * and a sentinel placed just before the sticky element, avoiding per-frame scroll\n * math.\n *\n * Markup contract (identifier: `stimeo--sticky-observer`):\n * <div data-controller=\"stimeo--sticky-observer\">\n * <div data-stimeo--sticky-observer-target=\"sentinel\"\n * aria-hidden=\"true\" style=\"height: 1px;\"></div>\n * <header data-stimeo--sticky-observer-target=\"element\"\n * style=\"position: sticky; top: 0;\">Site heading</header>\n * <main>…</main>\n * </div>\n *\n * When the sentinel scrolls out past the top of the viewport (or `rootSelector`\n * container), the sticky element is considered stuck and `data-stuck=\"true\"` is\n * set; otherwise `false`. The `change` event fires on each transition.\n *\n * @remarks\n * Behavior only — `position: sticky`, shadows, and shrink effects are the\n * consumer's CSS (`[data-stuck=\"true\"] { … }`). `data-stuck` is a visual hook\n * only: it carries no ARIA role/state. `offset` feeds a negative top `rootMargin`\n * and must match the sticky element's CSS `top`. The observer is disconnected on\n * `disconnect()` (Turbo navigation included).\n */\nexport class StickyObserverController extends Controller<HTMLElement> {\n static override targets = [\"sentinel\", \"element\"];\n static override values = {\n rootSelector: { type: String, default: \"\" },\n offset: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly sentinelTarget: HTMLElement;\n declare readonly elementTarget: HTMLElement;\n declare readonly hasSentinelTarget: boolean;\n declare readonly hasElementTarget: boolean;\n\n declare rootSelectorValue: string;\n declare offsetValue: number;\n\n /** Shared IO plumbing (support guard, root resolution, active guard). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Last reported stuck state, so `change` fires only on transitions. */\n #stuck: boolean | null = null;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n const entry = entries[entries.length - 1];\n if (!entry) return;\n // The sentinel sits above the sticky element; once it is no longer\n // intersecting the (top-inset) root, the sticky element has stuck.\n this.#setStuck(!entry.isIntersecting);\n }\n\n override connect(): void {\n if (!this.hasSentinelTarget) return;\n this.#watcher.start(this.sentinelTarget, {\n rootSelector: this.rootSelectorValue,\n rootMargin: `-${this.offsetValue}px 0px 0px 0px`,\n threshold: [0],\n });\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n this.#stuck = null;\n }\n\n /** Reflects the stuck state onto the sticky element and emits `change`. */\n #setStuck(next: boolean): void {\n if (next === this.#stuck) return;\n this.#stuck = next;\n if (this.hasElementTarget) {\n this.elementTarget.setAttribute(\"data-stuck\", next ? \"true\" : \"false\");\n }\n this.dispatch(\"change\", { detail: { stuck: next } });\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/sticky_observer_controller.ts"],"names":[],"mappings":"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","file":"sticky_observer_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **Sticky State Observer**: detects whether a `position: sticky`\n * element is currently stuck and publishes it as `data-stuck`. No APG widget — a\n * pure state-detection utility. Detection uses an {@link IntersectionObserver}\n * and a sentinel placed just before the sticky element, avoiding per-frame scroll\n * math.\n *\n * Markup contract (identifier: `stimeo--sticky-observer`):\n * <div data-controller=\"stimeo--sticky-observer\">\n * <div data-stimeo--sticky-observer-target=\"sentinel\"\n * aria-hidden=\"true\" style=\"height: 1px;\"></div>\n * <header data-stimeo--sticky-observer-target=\"element\"\n * style=\"position: sticky; top: 0;\">Site heading</header>\n * <main>…</main>\n * </div>\n *\n * When the sentinel scrolls out past the top of the viewport (or `rootSelector`\n * container), the sticky element is considered stuck and `data-stuck=\"true\"` is\n * set; otherwise `false`. The observer's initial snapshot dispatches `change`\n * once with the current state, including after a Turbo reconnect; subsequent\n * notifications dispatch only when that state changes.\n *\n * @remarks\n * Behavior only — `position: sticky`, shadows, and shrink effects are the\n * consumer's CSS (`[data-stuck=\"true\"] { … }`). `data-stuck` is a visual hook\n * only: it carries no ARIA role/state. `offset` is negated numerically for the\n * top `rootMargin` (so negative offsets remain valid) and must match the sticky\n * element's CSS `top`. The observer follows dynamic sentinel targets and value\n * changes, and is disconnected on `disconnect()` (Turbo navigation included).\n */\nexport class StickyObserverController extends Controller<HTMLElement> {\n static override targets = [\"sentinel\", \"element\"];\n static override values = {\n rootSelector: { type: String, default: \"\" },\n offset: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly sentinelTarget: HTMLElement;\n declare readonly elementTarget: HTMLElement;\n declare readonly hasSentinelTarget: boolean;\n declare readonly hasElementTarget: boolean;\n\n declare rootSelectorValue: string;\n declare offsetValue: number;\n\n /** Shared IO plumbing (support guard, root resolution, active guard). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Last reported stuck state, so `change` fires only on transitions. */\n #stuck: boolean | null = null;\n /** Target currently owned by the watcher; comparing it avoids duplicate restarts. */\n #observedSentinel: HTMLElement | null = null;\n #connected = false;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // Delivery can batch multiple transitions after a fast scroll. Process\n // every snapshot in order so an above→visible pair is not collapsed.\n for (const entry of entries) {\n if (!this.#connected || !this.#watcher.active) return;\n // A sentinel with no layout box (hidden tab panel, collapsed section, an\n // undisplayed Turbo Frame) reports an empty rect that the shared edge test\n // deliberately refuses, so an unrendered sticky element is never published\n // as stuck; the state stays put until the sentinel is actually laid out.\n this.#setStuck(!entry.isIntersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n this.#connected = true;\n this.#stuck = null;\n this.#syncObserver();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#watcher.stop();\n this.#observedSentinel = null;\n this.#stuck = null;\n }\n\n /** Starts observation when a sentinel is inserted after connection. */\n sentinelTargetConnected(): void {\n if (this.#connected) this.#syncObserver();\n }\n\n /** Stops or transfers observation when the current sentinel is removed. */\n sentinelTargetDisconnected(): void {\n if (this.#connected) this.#syncObserver();\n }\n\n /** Reflects the last snapshot onto an element inserted after that snapshot. */\n elementTargetConnected(element: HTMLElement): void {\n if (this.#stuck !== null) {\n element.setAttribute(\"data-stuck\", this.#stuck ? \"true\" : \"false\");\n }\n }\n\n /** Rebuilds the observer when Turbo morphs the configured root. */\n rootSelectorValueChanged(): void {\n if (this.#connected) this.#syncObserver(true);\n }\n\n /** Rebuilds the observer when Turbo morphs the configured top offset. */\n offsetValueChanged(): void {\n if (this.#connected) this.#syncObserver(true);\n }\n\n #syncObserver(force = false): void {\n const sentinel = this.hasSentinelTarget ? this.sentinelTarget : null;\n if (!force && sentinel === this.#observedSentinel && this.#watcher.active) return;\n\n this.#watcher.stop();\n this.#observedSentinel = null;\n if (!sentinel) return;\n\n const configuredOffset = this.offsetValue;\n const offset = Number.isFinite(configuredOffset) ? configuredOffset : 0;\n const started = this.#watcher.start(sentinel, {\n rootSelector: this.rootSelectorValue,\n rootMargin: `${-offset}px 0px 0px 0px`,\n threshold: [0],\n });\n if (started) this.#observedSentinel = sentinel;\n }\n\n /** Reflects the stuck state onto the sticky element and emits `change`. */\n #setStuck(next: boolean): void {\n if (next === this.#stuck) return;\n this.#stuck = next;\n if (this.hasElementTarget) {\n this.elementTarget.setAttribute(\"data-stuck\", next ? \"true\" : \"false\");\n }\n this.dispatch(\"change\", { detail: { stuck: next } });\n }\n}\n"]}
|
|
@@ -16,8 +16,8 @@ type SubmitButton = HTMLButtonElement | HTMLInputElement;
|
|
|
16
16
|
*
|
|
17
17
|
* Zero wiring for Turbo forms: `connect()` subscribes to `turbo:submit-start`
|
|
18
18
|
* itself (symmetric with the `turbo:submit-end` it already listens for), so no
|
|
19
|
-
* `data-action` is required. The public {@link start} action
|
|
20
|
-
*
|
|
19
|
+
* `data-action` is required. The public {@link start} action stays available for
|
|
20
|
+
* **non-Turbo** forms, where you bind it to the native event yourself:
|
|
21
21
|
* `data-action="submit->stimeo--submit-once#start"`. Re-entrancy is guarded — once
|
|
22
22
|
* busy, a second `start` (auto + manual, or a duplicate event) no-ops.
|
|
23
23
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/submit_once_controller.ts"],"names":[],"mappings":"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","file":"submit_once_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/** Submit-capable controls this controller can disable. */\ntype SubmitButton = HTMLButtonElement | HTMLInputElement;\n\n/** Attribute the original label is parked in while busy (survives a cache restore). */\nconst ORIGINAL_LABEL = \"data-submit-once-original-label\";\n/** Marker on buttons this controller disabled (so we never re-enable authored-disabled ones). */\nconst DISABLED_MARKER = \"data-submit-once-disabled\";\n/** Per-button override for the busy label (a plain attribute, not a Stimulus Value). */\nconst BUTTON_BUSY_LABEL = \"data-submit-once-busy-label\";\n\n/**\n * Headless \"submit once\" behavior: disables the form's submit button(s) on submit\n * to prevent a double submission, optionally swaps in a busy label, and restores\n * on completion. The Headless superset of Rails' `disable_with` (no dedicated APG\n * pattern; follows the WCAG \"status messages\" practice via `aria-busy`).\n *\n * Markup contract (identifier: `stimeo--submit-once`):\n * <form data-controller=\"stimeo--submit-once\"\n * data-stimeo--submit-once-busy-label-value=\"Submitting…\">\n * <button type=\"submit\" data-stimeo--submit-once-target=\"submit\">Send</button>\n * </form>\n *\n * Zero wiring for Turbo forms: `connect()` subscribes to `turbo:submit-start`\n * itself (symmetric with the `turbo:submit-end` it already listens for), so no\n * `data-action` is required. The public {@link start} action is kept for back-compat\n * and for **non-Turbo** forms, where you bind it to the native event yourself:\n * `data-action=\"submit->stimeo--submit-once#start\"`. Re-entrancy is guarded — once\n * busy, a second `start` (auto + manual, or a duplicate event) no-ops.\n *\n * On submit it disables every `submit` target (or, with none, the form's native\n * `button[type=submit]` / `input[type=submit]`), sets `aria-busy` on them and\n * `data-submitting` on the form, and swaps the **triggering** button's visible\n * label for the busy label (per-button `data-submit-once-busy-label` overrides the\n * form's `busyLabel` Value). It restores on Turbo's `turbo:submit-end`, or after\n * `timeout` ms, re-enabling the buttons and putting the labels back.\n *\n * @remarks\n * Behavior only — no spinner is drawn (pair with {@link \"spinner\"}). The submit-end\n * listener and the timeout are torn down on `disconnect()` (Turbo navigation\n * included), and `connect()` clears any stale busy state parked in a restored cache\n * snapshot so a button is never left disabled. Non-Turbo caveat: the buttons are\n * disabled synchronously inside the `submit` handler, and a plain HTML submission\n * builds its entry list *after* that event — so a disabled submitter's `name`/`value`\n * is excluded from the payload. Turbo is unaffected (it appends the submitter's\n * name/value from attributes when building `FormData`); mirror the value into a\n * hidden field when a non-Turbo form depends on it.\n */\nexport class SubmitOnceController extends Controller<HTMLElement> {\n static override targets = [\"submit\"];\n static override values = {\n busyLabel: { type: String, default: \"\" },\n timeout: { type: Number, default: 0 },\n restoreFocus: { type: Boolean, default: false },\n };\n static actions = [\"start\"] as const;\n static events = [\"start\", \"end\"] as const;\n\n declare readonly submitTargets: SubmitButton[];\n declare readonly hasSubmitTarget: boolean;\n\n declare busyLabelValue: string;\n declare timeoutValue: number;\n declare restoreFocusValue: boolean;\n\n readonly #timeouts = new SafeTimeout();\n #timeoutId: number | null = null;\n #busy = false;\n #submitter: SubmitButton | null = null;\n\n readonly #onSubmitStart = (event: Event): void => {\n this.start(event);\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success;\n this.#restore(success);\n };\n\n override connect(): void {\n // Auto-subscribe to both ends of a Turbo submission so a drop-in form needs\n // no `data-action`. `turbo:submit-start` bubbles from the <form>, so this\n // works whether the controller is mounted on the form or an ancestor. The\n // re-entrancy guard in `start` keeps a manual `submit->#start` (non-Turbo)\n // from double-firing.\n this.element.addEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n // Idempotent: drop any busy state carried over in a restored snapshot so a\n // cached, disabled button does not stay stuck.\n this.#clearStaleBusy();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#clearTimeout();\n }\n\n /**\n * Enters the busy state for the submission started by `event`. Accepts both a\n * native `SubmitEvent` (manual `submit->#start` on non-Turbo forms) and Turbo's\n * `turbo:submit-start` CustomEvent (auto-subscribed in `connect`).\n */\n start(event: Event): void {\n if (this.#busy) return;\n this.#busy = true;\n const buttons = this.#buttons;\n const submitter = this.#resolveSubmitter(event, buttons);\n this.#submitter = submitter;\n for (const button of buttons) {\n this.#enterBusy(button, button === submitter);\n }\n this.element.setAttribute(\"data-submitting\", \"true\");\n this.element.setAttribute(\"aria-busy\", \"true\");\n this.dispatch(\"start\", { detail: {} });\n if (this.timeoutValue > 0) {\n this.#timeoutId = this.#timeouts.set(() => this.#restore(false), this.timeoutValue);\n }\n }\n\n /** Restores the non-busy state, re-enabling buttons and putting labels back. */\n #restore(success?: boolean): void {\n if (!this.#busy) return;\n this.#busy = false;\n this.#clearTimeout();\n for (const button of this.#buttons) {\n this.#exitBusy(button);\n }\n this.element.removeAttribute(\"data-submitting\");\n this.element.removeAttribute(\"aria-busy\");\n this.dispatch(\"end\", { detail: { success } });\n if (this.restoreFocusValue && this.#submitter) {\n this.#submitter.focus();\n }\n this.#submitter = null;\n }\n\n /** Disables a button, marks it busy, and swaps the trigger's label. */\n #enterBusy(button: SubmitButton, isTrigger: boolean): void {\n // Only disable (and mark) buttons that were enabled, so an authored-disabled\n // submit button — e.g. \"disabled until valid\" — is left untouched and is never\n // re-enabled by us on restore.\n if (!button.disabled) {\n button.disabled = true;\n button.setAttribute(DISABLED_MARKER, \"true\");\n button.setAttribute(\"aria-busy\", \"true\");\n }\n if (!isTrigger) return;\n const label = button.getAttribute(BUTTON_BUSY_LABEL) ?? this.busyLabelValue;\n if (label.length === 0) return;\n if (!button.hasAttribute(ORIGINAL_LABEL)) {\n button.setAttribute(ORIGINAL_LABEL, this.#getLabel(button));\n }\n this.#setLabel(button, label);\n }\n\n /** Re-enables a button we disabled and restores its parked label, if any. */\n #exitBusy(button: SubmitButton): void {\n if (button.hasAttribute(DISABLED_MARKER)) {\n button.disabled = false;\n button.removeAttribute(DISABLED_MARKER);\n button.removeAttribute(\"aria-busy\");\n }\n const original = button.getAttribute(ORIGINAL_LABEL);\n if (original !== null) {\n this.#setLabel(button, original);\n button.removeAttribute(ORIGINAL_LABEL);\n }\n }\n\n /** Resets busy state left in a restored cache snapshot without firing events. */\n #clearStaleBusy(): void {\n this.#busy = false;\n if (this.element.hasAttribute(\"data-submitting\")) {\n this.element.removeAttribute(\"data-submitting\");\n this.element.removeAttribute(\"aria-busy\");\n }\n for (const button of this.#buttons) {\n this.#exitBusy(button);\n }\n }\n\n /** The triggering button: the event's submitter when ours, else the first button. */\n #resolveSubmitter(event: Event, buttons: SubmitButton[]): SubmitButton | null {\n const submitter = this.#eventSubmitter(event);\n if (submitter && this.#isSubmitButton(submitter) && buttons.includes(submitter)) {\n return submitter;\n }\n return buttons[0] ?? null;\n }\n\n /**\n * Reads the submitter from a native `SubmitEvent` (`event.submitter`) or from\n * Turbo's `turbo:submit-start` detail (`detail.formSubmission.submitter`), so\n * the busy label swaps onto the right button under either path.\n */\n #eventSubmitter(event: Event): HTMLElement | null {\n // Guard the `instanceof` with a typeof check: `SubmitEvent` is absent in some\n // non-browser/older runtimes, where a bare reference would throw ReferenceError.\n if (typeof SubmitEvent !== \"undefined\" && event instanceof SubmitEvent) {\n return event.submitter;\n }\n const detail = (event as CustomEvent<{ formSubmission?: { submitter?: HTMLElement } }>).detail;\n return detail?.formSubmission?.submitter ?? null;\n }\n\n /** The controlled buttons: `submit` targets, or the form's native submit controls. */\n get #buttons(): SubmitButton[] {\n if (this.hasSubmitTarget) return this.submitTargets;\n return Array.from(\n this.element.querySelectorAll<SubmitButton>('button[type=\"submit\"], input[type=\"submit\"]'),\n );\n }\n\n #isSubmitButton(el: HTMLElement): el is SubmitButton {\n return el instanceof HTMLButtonElement || el instanceof HTMLInputElement;\n }\n\n /** Reads a button's visible label (input value, aria-label, or text). */\n #getLabel(button: SubmitButton): string {\n if (button instanceof HTMLInputElement) return button.value;\n const aria = button.getAttribute(\"aria-label\");\n if (aria !== null) return aria;\n return button.textContent ?? \"\";\n }\n\n /** Writes a button's visible label through the same channel it was read from. */\n #setLabel(button: SubmitButton, text: string): void {\n if (button instanceof HTMLInputElement) {\n button.value = text;\n return;\n }\n if (button.hasAttribute(\"aria-label\")) {\n button.setAttribute(\"aria-label\", text);\n return;\n }\n button.textContent = text;\n }\n\n #clearTimeout(): void {\n if (this.#timeoutId !== null) {\n this.#timeouts.clear(this.#timeoutId);\n this.#timeoutId = null;\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/submit_once_controller.ts"],"names":[],"mappings":"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","file":"submit_once_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/** Submit-capable controls this controller can disable. */\ntype SubmitButton = HTMLButtonElement | HTMLInputElement;\n\n/** Attribute the original label is parked in while busy (survives a cache restore). */\nconst ORIGINAL_LABEL = \"data-submit-once-original-label\";\n/** Marker on buttons this controller disabled (so we never re-enable authored-disabled ones). */\nconst DISABLED_MARKER = \"data-submit-once-disabled\";\n/** Per-button override for the busy label (a plain attribute, not a Stimulus Value). */\nconst BUTTON_BUSY_LABEL = \"data-submit-once-busy-label\";\n\n/**\n * Headless \"submit once\" behavior: disables the form's submit button(s) on submit\n * to prevent a double submission, optionally swaps in a busy label, and restores\n * on completion. The Headless superset of Rails' `disable_with` (no dedicated APG\n * pattern; follows the WCAG \"status messages\" practice via `aria-busy`).\n *\n * Markup contract (identifier: `stimeo--submit-once`):\n * <form data-controller=\"stimeo--submit-once\"\n * data-stimeo--submit-once-busy-label-value=\"Submitting…\">\n * <button type=\"submit\" data-stimeo--submit-once-target=\"submit\">Send</button>\n * </form>\n *\n * Zero wiring for Turbo forms: `connect()` subscribes to `turbo:submit-start`\n * itself (symmetric with the `turbo:submit-end` it already listens for), so no\n * `data-action` is required. The public {@link start} action stays available for\n * **non-Turbo** forms, where you bind it to the native event yourself:\n * `data-action=\"submit->stimeo--submit-once#start\"`. Re-entrancy is guarded — once\n * busy, a second `start` (auto + manual, or a duplicate event) no-ops.\n *\n * On submit it disables every `submit` target (or, with none, the form's native\n * `button[type=submit]` / `input[type=submit]`), sets `aria-busy` on them and\n * `data-submitting` on the form, and swaps the **triggering** button's visible\n * label for the busy label (per-button `data-submit-once-busy-label` overrides the\n * form's `busyLabel` Value). It restores on Turbo's `turbo:submit-end`, or after\n * `timeout` ms, re-enabling the buttons and putting the labels back.\n *\n * @remarks\n * Behavior only — no spinner is drawn (pair with {@link \"spinner\"}). The submit-end\n * listener and the timeout are torn down on `disconnect()` (Turbo navigation\n * included), and `connect()` clears any stale busy state parked in a restored cache\n * snapshot so a button is never left disabled. Non-Turbo caveat: the buttons are\n * disabled synchronously inside the `submit` handler, and a plain HTML submission\n * builds its entry list *after* that event — so a disabled submitter's `name`/`value`\n * is excluded from the payload. Turbo is unaffected (it appends the submitter's\n * name/value from attributes when building `FormData`); mirror the value into a\n * hidden field when a non-Turbo form depends on it.\n */\nexport class SubmitOnceController extends Controller<HTMLElement> {\n static override targets = [\"submit\"];\n static override values = {\n busyLabel: { type: String, default: \"\" },\n timeout: { type: Number, default: 0 },\n restoreFocus: { type: Boolean, default: false },\n };\n static actions = [\"start\"] as const;\n static events = [\"start\", \"end\"] as const;\n\n declare readonly submitTargets: SubmitButton[];\n declare readonly hasSubmitTarget: boolean;\n\n declare busyLabelValue: string;\n declare timeoutValue: number;\n declare restoreFocusValue: boolean;\n\n readonly #timeouts = new SafeTimeout();\n #timeoutId: number | null = null;\n #busy = false;\n #submitter: SubmitButton | null = null;\n\n readonly #onSubmitStart = (event: Event): void => {\n this.start(event);\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success;\n this.#restore(success);\n };\n\n override connect(): void {\n // Auto-subscribe to both ends of a Turbo submission so a drop-in form needs\n // no `data-action`. `turbo:submit-start` bubbles from the <form>, so this\n // works whether the controller is mounted on the form or an ancestor. The\n // re-entrancy guard in `start` keeps a manual `submit->#start` (non-Turbo)\n // from double-firing.\n this.element.addEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n // Idempotent: drop any busy state carried over in a restored snapshot so a\n // cached, disabled button does not stay stuck.\n this.#clearStaleBusy();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#clearTimeout();\n }\n\n /**\n * Enters the busy state for the submission started by `event`. Accepts both a\n * native `SubmitEvent` (manual `submit->#start` on non-Turbo forms) and Turbo's\n * `turbo:submit-start` CustomEvent (auto-subscribed in `connect`).\n */\n start(event: Event): void {\n if (this.#busy) return;\n this.#busy = true;\n const buttons = this.#buttons;\n const submitter = this.#resolveSubmitter(event, buttons);\n this.#submitter = submitter;\n for (const button of buttons) {\n this.#enterBusy(button, button === submitter);\n }\n this.element.setAttribute(\"data-submitting\", \"true\");\n this.element.setAttribute(\"aria-busy\", \"true\");\n this.dispatch(\"start\", { detail: {} });\n if (this.timeoutValue > 0) {\n this.#timeoutId = this.#timeouts.set(() => this.#restore(false), this.timeoutValue);\n }\n }\n\n /** Restores the non-busy state, re-enabling buttons and putting labels back. */\n #restore(success?: boolean): void {\n if (!this.#busy) return;\n this.#busy = false;\n this.#clearTimeout();\n for (const button of this.#buttons) {\n this.#exitBusy(button);\n }\n this.element.removeAttribute(\"data-submitting\");\n this.element.removeAttribute(\"aria-busy\");\n this.dispatch(\"end\", { detail: { success } });\n if (this.restoreFocusValue && this.#submitter) {\n this.#submitter.focus();\n }\n this.#submitter = null;\n }\n\n /** Disables a button, marks it busy, and swaps the trigger's label. */\n #enterBusy(button: SubmitButton, isTrigger: boolean): void {\n // Only disable (and mark) buttons that were enabled, so an authored-disabled\n // submit button — e.g. \"disabled until valid\" — is left untouched and is never\n // re-enabled by us on restore.\n if (!button.disabled) {\n button.disabled = true;\n button.setAttribute(DISABLED_MARKER, \"true\");\n button.setAttribute(\"aria-busy\", \"true\");\n }\n if (!isTrigger) return;\n const label = button.getAttribute(BUTTON_BUSY_LABEL) ?? this.busyLabelValue;\n if (label.length === 0) return;\n if (!button.hasAttribute(ORIGINAL_LABEL)) {\n button.setAttribute(ORIGINAL_LABEL, this.#getLabel(button));\n }\n this.#setLabel(button, label);\n }\n\n /** Re-enables a button we disabled and restores its parked label, if any. */\n #exitBusy(button: SubmitButton): void {\n if (button.hasAttribute(DISABLED_MARKER)) {\n button.disabled = false;\n button.removeAttribute(DISABLED_MARKER);\n button.removeAttribute(\"aria-busy\");\n }\n const original = button.getAttribute(ORIGINAL_LABEL);\n if (original !== null) {\n this.#setLabel(button, original);\n button.removeAttribute(ORIGINAL_LABEL);\n }\n }\n\n /** Resets busy state left in a restored cache snapshot without firing events. */\n #clearStaleBusy(): void {\n this.#busy = false;\n if (this.element.hasAttribute(\"data-submitting\")) {\n this.element.removeAttribute(\"data-submitting\");\n this.element.removeAttribute(\"aria-busy\");\n }\n for (const button of this.#buttons) {\n this.#exitBusy(button);\n }\n }\n\n /** The triggering button: the event's submitter when ours, else the first button. */\n #resolveSubmitter(event: Event, buttons: SubmitButton[]): SubmitButton | null {\n const submitter = this.#eventSubmitter(event);\n if (submitter && this.#isSubmitButton(submitter) && buttons.includes(submitter)) {\n return submitter;\n }\n return buttons[0] ?? null;\n }\n\n /**\n * Reads the submitter from a native `SubmitEvent` (`event.submitter`) or from\n * Turbo's `turbo:submit-start` detail (`detail.formSubmission.submitter`), so\n * the busy label swaps onto the right button under either path.\n */\n #eventSubmitter(event: Event): HTMLElement | null {\n // Guard the `instanceof` with a typeof check: `SubmitEvent` is absent in some\n // non-browser/older runtimes, where a bare reference would throw ReferenceError.\n if (typeof SubmitEvent !== \"undefined\" && event instanceof SubmitEvent) {\n return event.submitter;\n }\n const detail = (event as CustomEvent<{ formSubmission?: { submitter?: HTMLElement } }>).detail;\n return detail?.formSubmission?.submitter ?? null;\n }\n\n /** The controlled buttons: `submit` targets, or the form's native submit controls. */\n get #buttons(): SubmitButton[] {\n if (this.hasSubmitTarget) return this.submitTargets;\n return Array.from(\n this.element.querySelectorAll<SubmitButton>('button[type=\"submit\"], input[type=\"submit\"]'),\n );\n }\n\n #isSubmitButton(el: HTMLElement): el is SubmitButton {\n return el instanceof HTMLButtonElement || el instanceof HTMLInputElement;\n }\n\n /** Reads a button's visible label (input value, aria-label, or text). */\n #getLabel(button: SubmitButton): string {\n if (button instanceof HTMLInputElement) return button.value;\n const aria = button.getAttribute(\"aria-label\");\n if (aria !== null) return aria;\n return button.textContent ?? \"\";\n }\n\n /** Writes a button's visible label through the same channel it was read from. */\n #setLabel(button: SubmitButton, text: string): void {\n if (button instanceof HTMLInputElement) {\n button.value = text;\n return;\n }\n if (button.hasAttribute(\"aria-label\")) {\n button.setAttribute(\"aria-label\", text);\n return;\n }\n button.textContent = text;\n }\n\n #clearTimeout(): void {\n if (this.#timeoutId !== null) {\n this.#timeouts.clear(this.#timeoutId);\n this.#timeoutId = null;\n }\n }\n}\n"]}
|
|
@@ -27,6 +27,7 @@ var SwitchController = class extends Controller {
|
|
|
27
27
|
* which would otherwise toggle the switch twice.
|
|
28
28
|
*/
|
|
29
29
|
onKeydown(event) {
|
|
30
|
+
if (event.defaultPrevented) return;
|
|
30
31
|
if (this.element instanceof HTMLButtonElement) return;
|
|
31
32
|
if (event.repeat) return;
|
|
32
33
|
if (event.key === " " || event.key === "Enter") {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/switch_controller.ts"],"names":[],"mappings":";;;AAyBO,IAAM,gBAAA,GAAN,cAA+B,UAAA,CAAwB;AAAA,EAC5D,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,QAAQ,CAAA;AAAA,EACvC,OAAO,MAAA,GAAS,CAAC,SAAS,CAAA;AAAA;AAAA,EAGjB,OAAA,GAAgB;AACvB,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,MAAM,CAAA,EAAG;AACtC,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,MAAA,EAAQ,QAAQ,CAAA;AAAA,IAC5C;AACA,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAc,CAAA,EAAG;AAC9C,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAA,EAAgB,OAAO,CAAA;AAAA,IACnD;AAIA,IAAA,IAAI,EAAE,KAAK,OAAA,YAAmB,iBAAA,CAAA,IAAsB,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,UAAU,CAAA,EAAG;AAC1F,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,UAAA,EAAY,GAAG,CAAA;AAAA,IAC3C;AAAA,EACF;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAA,CAAK,QAAA,GAAW,CAAC,IAAA,CAAK,QAAA;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,UAAU,KAAA,EAA4B;
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/switch_controller.ts"],"names":[],"mappings":";;;AAyBO,IAAM,gBAAA,GAAN,cAA+B,UAAA,CAAwB;AAAA,EAC5D,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,QAAQ,CAAA;AAAA,EACvC,OAAO,MAAA,GAAS,CAAC,SAAS,CAAA;AAAA;AAAA,EAGjB,OAAA,GAAgB;AACvB,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,MAAM,CAAA,EAAG;AACtC,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,MAAA,EAAQ,QAAQ,CAAA;AAAA,IAC5C;AACA,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAc,CAAA,EAAG;AAC9C,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAA,EAAgB,OAAO,CAAA;AAAA,IACnD;AAIA,IAAA,IAAI,EAAE,KAAK,OAAA,YAAmB,iBAAA,CAAA,IAAsB,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,UAAU,CAAA,EAAG;AAC1F,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,UAAA,EAAY,GAAG,CAAA;AAAA,IAC3C;AAAA,EACF;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAA,CAAK,QAAA,GAAW,CAAC,IAAA,CAAK,QAAA;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,UAAU,KAAA,EAA4B;AAGpC,IAAA,IAAI,MAAM,gBAAA,EAAkB;AAC5B,IAAA,IAAI,IAAA,CAAK,mBAAmB,iBAAA,EAAmB;AAC/C,IAAA,IAAI,MAAM,MAAA,EAAQ;AAClB,IAAA,IAAI,KAAA,CAAM,GAAA,KAAQ,GAAA,IAAO,KAAA,CAAM,QAAQ,OAAA,EAAS;AAC9C,MAAA,KAAA,CAAM,cAAA,EAAe;AACrB,MAAA,IAAA,CAAK,MAAA,EAAO;AAAA,IACd;AAAA,EACF;AAAA;AAAA,EAGA,IAAI,QAAA,GAAoB;AACtB,IAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAc,CAAA,KAAM,MAAA;AAAA,EACvD;AAAA;AAAA,EAGA,IAAI,SAAS,KAAA,EAAgB;AAC3B,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,cAAA,EAAgB,KAAA,GAAQ,SAAS,OAAO,CAAA;AAClE,IAAA,IAAA,CAAK,QAAA,CAAS,WAAW,EAAE,MAAA,EAAQ,EAAE,OAAA,EAAS,KAAA,IAAS,CAAA;AAAA,EACzD;AACF","file":"switch_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible switch (toggle) behavior.\n *\n * Markup contract (identifier: `stimeo--switch`):\n * <button data-controller=\"stimeo--switch\"\n * data-action=\"stimeo--switch#toggle keydown->stimeo--switch#onKeydown\"\n * role=\"switch\" aria-checked=\"false\">…</button>\n *\n * Implements the WAI-ARIA APG **Switch** pattern. The controller element is the\n * switch itself; its on/off state is reflected solely through `aria-checked`.\n *\n * @remarks\n * Behavior only — the consumer owns all styling (typically keyed off the\n * `[aria-checked=\"true\"]` attribute). On a native `<button>` host, the browser\n * already synthesizes a click for Space/Enter, so {@link onKeydown} deliberately\n * does nothing there (to avoid a double toggle) and only drives non-button hosts\n * such as `<div role=\"switch\" tabindex=\"0\">`.\n *\n * Behavior provided:\n * - Click (or Space/Enter) toggles `aria-checked` between `\"true\"` and `\"false\"`.\n * - A `stimeo--switch:changed` event is dispatched on every toggle so the\n * consumer can react (its `detail.checked` carries the new boolean state).\n */\nexport class SwitchController extends Controller<HTMLElement> {\n static actions = [\"onKeydown\", \"toggle\"] as const;\n static events = [\"changed\"] as const;\n\n /** Ensures the switch exposes a role and is keyboard-reachable. */\n override connect(): void {\n if (!this.element.hasAttribute(\"role\")) {\n this.element.setAttribute(\"role\", \"switch\");\n }\n if (!this.element.hasAttribute(\"aria-checked\")) {\n this.element.setAttribute(\"aria-checked\", \"false\");\n }\n // Native <button> hosts are focusable already; a non-button host (e.g.\n // <div role=\"switch\">) needs an explicit tabindex to be keyboard-reachable,\n // otherwise the keyboard support below would be unreachable.\n if (!(this.element instanceof HTMLButtonElement) && !this.element.hasAttribute(\"tabindex\")) {\n this.element.setAttribute(\"tabindex\", \"0\");\n }\n }\n\n /** Toggles the checked state. Bound via `data-action` (click). */\n toggle(): void {\n this.#checked = !this.#checked;\n }\n\n /**\n * Activates the switch on Space/Enter for non-native hosts and prevents the\n * default Space scroll. Bound via `data-action` (keydown). Native `<button>`\n * hosts are skipped because the browser already turns Space/Enter into a click,\n * which would otherwise toggle the switch twice.\n */\n onKeydown(event: KeyboardEvent): void {\n // A descendant widget that already claimed the key must not ALSO toggle the switch —\n // composition depends on this yield.\n if (event.defaultPrevented) return;\n if (this.element instanceof HTMLButtonElement) return;\n if (event.repeat) return;\n if (event.key === \" \" || event.key === \"Enter\") {\n event.preventDefault();\n this.toggle();\n }\n }\n\n /** Whether the switch is currently on. */\n get #checked(): boolean {\n return this.element.getAttribute(\"aria-checked\") === \"true\";\n }\n\n /** Reflects the new state on `aria-checked` and notifies listeners. */\n set #checked(value: boolean) {\n this.element.setAttribute(\"aria-checked\", value ? \"true\" : \"false\");\n this.dispatch(\"changed\", { detail: { checked: value } });\n }\n}\n"]}
|
|
@@ -24,9 +24,10 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
24
24
|
* @remarks
|
|
25
25
|
* Behavior only. State is exposed through `aria-selected`, roving `tabindex`
|
|
26
26
|
* (`0` for the active tab, `-1` for the rest), and the panel `hidden` attribute;
|
|
27
|
-
* the consumer owns all styling. The
|
|
28
|
-
* container as part of the semantic contract —
|
|
29
|
-
* `role="tablist"
|
|
27
|
+
* the consumer owns all styling. The required `list` target marks the tablist
|
|
28
|
+
* container as part of the semantic contract — that container carries
|
|
29
|
+
* `role="tablist"` and an accessible name; the controller performs no runtime
|
|
30
|
+
* work on it.
|
|
30
31
|
*
|
|
31
32
|
* Behavior provided:
|
|
32
33
|
* - Click a tab to select it.
|
|
@@ -37,9 +38,17 @@ declare class TabsController extends Controller<HTMLElement> {
|
|
|
37
38
|
#private;
|
|
38
39
|
static targets: string[];
|
|
39
40
|
static actions: readonly ["onKeydown", "select"];
|
|
41
|
+
readonly listTarget: HTMLElement;
|
|
40
42
|
readonly tabTargets: HTMLButtonElement[];
|
|
41
43
|
readonly panelTargets: HTMLElement[];
|
|
42
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Selects the initially active tab: the pre-selected one, else the first.
|
|
46
|
+
*
|
|
47
|
+
* `findIndex` makes this first-wins when the author marked several — the first
|
|
48
|
+
* in DOM order is the only deterministic reading of "which one did they mean" —
|
|
49
|
+
* and `#selectIndex` then writes an explicit value onto every tab, so a
|
|
50
|
+
* forgotten `aria-selected` cannot leave a tab looking unselectable.
|
|
51
|
+
*/
|
|
43
52
|
connect(): void;
|
|
44
53
|
/** Selects the clicked tab. Bound via `data-action` (click). */
|
|
45
54
|
select(event: Event): void;
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/tabs_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function isReservedArrowChord(event, allow = []) {
|
|
12
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
13
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
// src/controllers/tabs_controller.ts
|
|
4
17
|
var TabsController = class extends Controller {
|
|
5
18
|
static targets = ["tab", "panel", "list"];
|
|
6
19
|
static actions = ["onKeydown", "select"];
|
|
7
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Selects the initially active tab: the pre-selected one, else the first.
|
|
22
|
+
*
|
|
23
|
+
* `findIndex` makes this first-wins when the author marked several — the first
|
|
24
|
+
* in DOM order is the only deterministic reading of "which one did they mean" —
|
|
25
|
+
* and `#selectIndex` then writes an explicit value onto every tab, so a
|
|
26
|
+
* forgotten `aria-selected` cannot leave a tab looking unselectable.
|
|
27
|
+
*/
|
|
8
28
|
connect() {
|
|
9
29
|
const preselected = this.tabTargets.findIndex(
|
|
10
30
|
(tab) => tab.getAttribute("aria-selected") === "true"
|
|
@@ -18,16 +38,19 @@ var TabsController = class extends Controller {
|
|
|
18
38
|
}
|
|
19
39
|
/** Implements arrow/Home/End navigation with automatic activation. */
|
|
20
40
|
onKeydown(event) {
|
|
41
|
+
if (event.defaultPrevented) return;
|
|
42
|
+
if (isReservedArrowChord(event)) return;
|
|
21
43
|
const tabs = this.tabTargets;
|
|
22
44
|
const currentIndex = tabs.indexOf(event.currentTarget);
|
|
23
45
|
if (currentIndex === -1) return;
|
|
24
46
|
let nextIndex = null;
|
|
47
|
+
const step = isRtl(this.element) ? -1 : 1;
|
|
25
48
|
switch (event.key) {
|
|
26
49
|
case "ArrowRight":
|
|
27
|
-
nextIndex = (currentIndex +
|
|
50
|
+
nextIndex = (currentIndex + step + tabs.length) % tabs.length;
|
|
28
51
|
break;
|
|
29
52
|
case "ArrowLeft":
|
|
30
|
-
nextIndex = (currentIndex -
|
|
53
|
+
nextIndex = (currentIndex - step + tabs.length) % tabs.length;
|
|
31
54
|
break;
|
|
32
55
|
case "Home":
|
|
33
56
|
nextIndex = 0;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/tabs_controller.ts"],"names":[],"mappings":";;;AAmCO,IAAM,cAAA,GAAN,cAA6B,UAAA,CAAwB;AAAA,EAC1D,OAAgB,OAAA,GAAU,CAAC,KAAA,EAAO,SAAS,MAAM,CAAA;AAAA,EACjD,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,QAAQ,CAAA;AAAA;AAAA,EAM9B,OAAA,GAAgB;AACvB,IAAA,MAAM,WAAA,GAAc,KAAK,UAAA,CAAW,SAAA;AAAA,MAClC,CAAC,GAAA,KAAQ,GAAA,CAAI,YAAA,CAAa,eAAe,CAAA,KAAM;AAAA,KACjD;AACA,IAAA,IAAA,CAAK,YAAA,CAAa,gBAAgB,EAAA,GAAK,CAAA,GAAI,aAAa,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC1E;AAAA;AAAA,EAGA,OAAO,KAAA,EAAoB;AACzB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,UAAA,CAAW,OAAA,CAAQ,MAAM,aAAkC,CAAA;AAC9E,IAAA,IAAI,KAAA,KAAU,IAAI,IAAA,CAAK,YAAA,CAAa,OAAO,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC7D;AAAA;AAAA,EAGA,UAAU,KAAA,EAA4B;AACpC,IAAA,MAAM,OAAO,IAAA,CAAK,UAAA;AAClB,IAAA,MAAM,YAAA,GAAe,IAAA,CAAK,OAAA,CAAQ,KAAA,CAAM,aAAkC,CAAA;AAC1E,IAAA,IAAI,iBAAiB,EAAA,EAAI;AAEzB,IAAA,IAAI,SAAA,GAA2B,IAAA;AAC/B,IAAA,QAAQ,MAAM,GAAA;AAAK,MACjB,KAAK,YAAA;AACH,QAAA,SAAA,GAAA,CAAa,YAAA,GAAe,KAAK,IAAA,CAAK,MAAA;AACtC,QAAA;AAAA,MACF,KAAK,WAAA;AACH,QAAA,SAAA,GAAA,CAAa,YAAA,GAAe,CAAA,GAAI,IAAA,CAAK,MAAA,IAAU,IAAA,CAAK,MAAA;AACpD,QAAA;AAAA,MACF,KAAK,MAAA;AACH,QAAA,SAAA,GAAY,CAAA;AACZ,QAAA;AAAA,MACF,KAAK,KAAA;AACH,QAAA,SAAA,GAAY,KAAK,MAAA,GAAS,CAAA;AAC1B,QAAA;AAAA,MACF;AACE,QAAA;AAAA;AAGJ,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAA,CAAK,YAAA,CAAa,SAAA,EAAW,EAAE,KAAA,EAAO,MAAM,CAAA;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAA,CAAa,KAAA,EAAe,EAAE,KAAA,EAAM,EAA6B;AAC/D,IAAA,IAAA,CAAK,UAAA,CAAW,OAAA,CAAQ,CAAC,GAAA,EAAK,CAAA,KAAM;AAClC,MAAA,MAAM,WAAW,CAAA,KAAM,KAAA;AACvB,MAAA,GAAA,CAAI,YAAA,CAAa,eAAA,EAAiB,QAAA,GAAW,MAAA,GAAS,OAAO,CAAA;AAC7D,MAAA,GAAA,CAAI,QAAA,GAAW,WAAW,CAAA,GAAI,EAAA;AAAA,IAChC,CAAC,CAAA;AACD,IAAA,IAAA,CAAK,YAAA,CAAa,OAAA,CAAQ,CAAC,KAAA,EAAO,CAAA,KAAM;AACtC,MAAA,KAAA,CAAM,SAAS,CAAA,KAAM,KAAA;AAAA,IACvB,CAAC,CAAA;AACD,IAAA,IAAI,KAAA,EAAO,IAAA,CAAK,UAAA,CAAW,KAAK,GAAG,KAAA,EAAM;AAAA,EAC3C;AACF","file":"tabs_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible tabs behavior.\n *\n * Markup contract (identifier: `stimeo--tabs`):\n * <div data-controller=\"stimeo--tabs\">\n * <div role=\"tablist\" aria-label=\"…\" data-stimeo--tabs-target=\"list\">\n * <button role=\"tab\" id=\"tab-1\" aria-controls=\"panel-1\"\n * data-stimeo--tabs-target=\"tab\"\n * data-action=\"stimeo--tabs#select\n * keydown->stimeo--tabs#onKeydown\">Tab 1</button>\n * <!-- more tabs -->\n * </div>\n * <div role=\"tabpanel\" id=\"panel-1\" aria-labelledby=\"tab-1\"\n * data-stimeo--tabs-target=\"panel\">…</div>\n * <!-- more panels -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Tabs** pattern with **automatic activation**:\n * moving focus with the arrow keys immediately selects the focused tab. Tabs are\n * paired to panels by index (the Nth tab controls the Nth panel).\n *\n * @remarks\n * Behavior only. State is exposed through `aria-selected`, roving `tabindex`\n * (`0` for the active tab, `-1` for the rest), and the panel `hidden` attribute;\n * the consumer owns all styling. The optional `list` target marks the tablist\n * container as part of the semantic contract — the Inspector requires its\n * `role=\"tablist\"`; the controller performs no runtime work on it.\n *\n * Behavior provided:\n * - Click a tab to select it.\n * - `ArrowRight`/`ArrowLeft` move to and select the next/previous tab (wrapping);\n * `Home`/`End` select the first/last tab.\n */\nexport class TabsController extends Controller<HTMLElement> {\n static override targets = [\"tab\", \"panel\", \"list\"];\n static actions = [\"onKeydown\", \"select\"] as const;\n\n declare readonly tabTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Selects the initially active tab (the pre-selected one, else the first). */\n override connect(): void {\n const preselected = this.tabTargets.findIndex(\n (tab) => tab.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });\n }\n\n /** Selects the clicked tab. Bound via `data-action` (click). */\n select(event: Event): void {\n const index = this.tabTargets.indexOf(event.currentTarget as HTMLButtonElement);\n if (index !== -1) this.#selectIndex(index, { focus: false });\n }\n\n /** Implements arrow/Home/End navigation with automatic activation. */\n onKeydown(event: KeyboardEvent): void {\n const tabs = this.tabTargets;\n const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement);\n if (currentIndex === -1) return;\n\n let nextIndex: number | null = null;\n switch (event.key) {\n case \"ArrowRight\":\n nextIndex = (currentIndex + 1) % tabs.length;\n break;\n case \"ArrowLeft\":\n nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;\n break;\n case \"Home\":\n nextIndex = 0;\n break;\n case \"End\":\n nextIndex = tabs.length - 1;\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.#selectIndex(nextIndex, { focus: true });\n }\n\n /**\n * Activates the tab/panel pair at `index`: updates `aria-selected`, the roving\n * `tabindex`, and panel visibility. Optionally moves focus to the new tab.\n */\n #selectIndex(index: number, { focus }: { focus: boolean }): void {\n this.tabTargets.forEach((tab, i) => {\n const selected = i === index;\n tab.setAttribute(\"aria-selected\", selected ? \"true\" : \"false\");\n tab.tabIndex = selected ? 0 : -1;\n });\n this.panelTargets.forEach((panel, i) => {\n panel.hidden = i !== index;\n });\n if (focus) this.tabTargets[index]?.focus();\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/logical_scroll.ts","../../src/utils/arrow_step.ts","../../src/controllers/tabs_controller.ts"],"names":[],"mappings":"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","file":"tabs_controller.js","sourcesContent":["/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { isRtl } from \"../utils/logical_scroll\";\n\n/**\n * Headless, accessible tabs behavior.\n *\n * Markup contract (identifier: `stimeo--tabs`):\n * <div data-controller=\"stimeo--tabs\">\n * <div role=\"tablist\" aria-label=\"…\" data-stimeo--tabs-target=\"list\">\n * <button role=\"tab\" id=\"tab-1\" aria-controls=\"panel-1\"\n * data-stimeo--tabs-target=\"tab\"\n * data-action=\"stimeo--tabs#select\n * keydown->stimeo--tabs#onKeydown\">Tab 1</button>\n * <!-- more tabs -->\n * </div>\n * <div role=\"tabpanel\" id=\"panel-1\" aria-labelledby=\"tab-1\"\n * data-stimeo--tabs-target=\"panel\">…</div>\n * <!-- more panels -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Tabs** pattern with **automatic activation**:\n * moving focus with the arrow keys immediately selects the focused tab. Tabs are\n * paired to panels by index (the Nth tab controls the Nth panel).\n *\n * @remarks\n * Behavior only. State is exposed through `aria-selected`, roving `tabindex`\n * (`0` for the active tab, `-1` for the rest), and the panel `hidden` attribute;\n * the consumer owns all styling. The required `list` target marks the tablist\n * container as part of the semantic contract — that container carries\n * `role=\"tablist\"` and an accessible name; the controller performs no runtime\n * work on it.\n *\n * Behavior provided:\n * - Click a tab to select it.\n * - `ArrowRight`/`ArrowLeft` move to and select the next/previous tab (wrapping);\n * `Home`/`End` select the first/last tab.\n */\nexport class TabsController extends Controller<HTMLElement> {\n static override targets = [\"tab\", \"panel\", \"list\"];\n static actions = [\"onKeydown\", \"select\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly tabTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /**\n * Selects the initially active tab: the pre-selected one, else the first.\n *\n * `findIndex` makes this first-wins when the author marked several — the first\n * in DOM order is the only deterministic reading of \"which one did they mean\" —\n * and `#selectIndex` then writes an explicit value onto every tab, so a\n * forgotten `aria-selected` cannot leave a tab looking unselectable.\n */\n override connect(): void {\n const preselected = this.tabTargets.findIndex(\n (tab) => tab.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });\n }\n\n /** Selects the clicked tab. Bound via `data-action` (click). */\n select(event: Event): void {\n const index = this.tabTargets.indexOf(event.currentTarget as HTMLButtonElement);\n if (index !== -1) this.#selectIndex(index, { focus: false });\n }\n\n /** Implements arrow/Home/End navigation with automatic activation. */\n onKeydown(event: KeyboardEvent): void {\n // A descendant widget that already claimed the key (a grabbed drag handle, a\n // nested menu) must not ALSO act on it — composition depends on this yield.\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n const tabs = this.tabTargets;\n const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement);\n if (currentIndex === -1) return;\n\n let nextIndex: number | null = null;\n // Logical, not physical. APG defines these as \"next / previous control\", and\n // says a vertical arrangement swaps in Down/Up for the same meaning — so the\n // pair is one axis's spelling of an order, and the order reverses with the\n // writing direction. Read from the controller element: the container is what\n // lays the items out, and a child may carry its own `dir` (an LTR input\n // inside an RTL form is ordinary authoring).\n const step = isRtl(this.element) ? -1 : 1;\n switch (event.key) {\n case \"ArrowRight\":\n nextIndex = (currentIndex + step + tabs.length) % tabs.length;\n break;\n case \"ArrowLeft\":\n nextIndex = (currentIndex - step + tabs.length) % tabs.length;\n break;\n case \"Home\":\n nextIndex = 0;\n break;\n case \"End\":\n nextIndex = tabs.length - 1;\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.#selectIndex(nextIndex, { focus: true });\n }\n\n /**\n * Activates the tab/panel pair at `index`: updates `aria-selected`, the roving\n * `tabindex`, and panel visibility. Optionally moves focus to the new tab.\n */\n #selectIndex(index: number, { focus }: { focus: boolean }): void {\n this.tabTargets.forEach((tab, i) => {\n const selected = i === index;\n tab.setAttribute(\"aria-selected\", selected ? \"true\" : \"false\");\n tab.tabIndex = selected ? 0 : -1;\n });\n this.panelTargets.forEach((panel, i) => {\n panel.hidden = i !== index;\n });\n if (focus) this.tabTargets[index]?.focus();\n }\n}\n"]}
|