stimeo-ui 0.2.1 → 0.4.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 +160 -0
- package/dist/cable/index.d.ts +16 -16
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.js +10 -0
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.js +32 -5
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +32 -6
- package/dist/controllers/announcer_controller.js +255 -20
- package/dist/controllers/announcer_controller.js.map +1 -1
- 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 +16 -1
- package/dist/controllers/carousel_controller.js +47 -6
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.js +2 -2
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +14 -2
- package/dist/controllers/color_picker_controller.js +92 -7
- package/dist/controllers/color_picker_controller.js.map +1 -1
- 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 +226 -22
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +32 -5
- 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.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +19 -5
- package/dist/controllers/countdown_controller.js +112 -12
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +19 -1
- package/dist/controllers/data_grid_controller.js +82 -4
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +5 -1
- package/dist/controllers/date_range_picker_controller.js +77 -3
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +32 -5
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.js +32 -5
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +1 -2
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.js +1 -0
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/empty_state_controller.d.ts +9 -4
- package/dist/controllers/empty_state_controller.js +24 -10
- package/dist/controllers/empty_state_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +1 -1
- package/dist/controllers/focus_controller.js +32 -5
- 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/frame_loading_controller.d.ts +14 -3
- package/dist/controllers/frame_loading_controller.js +177 -15
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- 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 +36 -11
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +31 -10
- 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.d.ts +19 -3
- package/dist/controllers/local_time_controller.js +102 -8
- package/dist/controllers/local_time_controller.js.map +1 -1
- 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 +91 -11
- package/dist/controllers/menubar_controller.js +415 -63
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/meter_controller.d.ts +22 -2
- package/dist/controllers/meter_controller.js +145 -26
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +31 -5
- package/dist/controllers/multi_select_controller.js +312 -29
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +65 -18
- package/dist/controllers/navigation_menu_controller.js +154 -27
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.d.ts +4 -5
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.d.ts +13 -1
- package/dist/controllers/network_status_controller.js +28 -8
- package/dist/controllers/network_status_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 +3 -2
- package/dist/controllers/overflow_indicator_controller.js +81 -13
- 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 +408 -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/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.d.ts +16 -1
- package/dist/controllers/progress_controller.js +116 -9
- 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 +18 -5
- package/dist/controllers/range_slider_controller.js +100 -9
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +4 -1
- package/dist/controllers/rating_controller.js +69 -2
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.js +63 -19
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.d.ts +13 -0
- package/dist/controllers/relative_time_controller.js +133 -12
- package/dist/controllers/relative_time_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 +86 -12
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +1 -1
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
- package/dist/controllers/scroll_visibility_controller.js +33 -3
- 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 +346 -73
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +20 -5
- package/dist/controllers/separator_controller.js +9 -0
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +2 -12
- package/dist/controllers/sidebar_controller.js +37 -8
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +7 -2
- package/dist/controllers/skeleton_controller.js +73 -20
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +11 -1
- package/dist/controllers/slider_controller.js +49 -8
- 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.d.ts +37 -4
- package/dist/controllers/spinner_controller.js +228 -27
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +12 -1
- package/dist/controllers/step_indicator_controller.js +82 -5
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +31 -8
- package/dist/controllers/stick_to_bottom_controller.js +61 -10
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js +32 -11
- 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 +11 -3
- package/dist/controllers/tabs_controller.js +26 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.js +22 -2
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +1 -1
- package/dist/controllers/theme_controller.js +22 -3
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.js +20 -1
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +4 -5
- package/dist/controllers/toast_controller.js +4 -5
- 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.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +87 -6
- package/dist/controllers/tree_view_controller.js +467 -51
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +4507 -907
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +375 -39
- package/dist/inspector/cli.js +1009 -86
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +1200 -120
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +28 -28
- package/dist/inspector/manifest.json +1060 -132
- package/package.json +5 -6
|
@@ -9,7 +9,8 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
9
9
|
*
|
|
10
10
|
* Markup contract (identifier: `stimeo--stick-to-bottom`):
|
|
11
11
|
* <div data-controller="stimeo--stick-to-bottom"
|
|
12
|
-
* data-stimeo--stick-to-bottom-threshold-value="80"
|
|
12
|
+
* data-stimeo--stick-to-bottom-threshold-value="80"
|
|
13
|
+
* data-stimeo--stick-to-bottom-pin-on-connect-value="true" style="overflow:auto">
|
|
13
14
|
* <ul data-stimeo--stick-to-bottom-target="content"><!-- Turbo Stream appends --></ul>
|
|
14
15
|
* </div>
|
|
15
16
|
*
|
|
@@ -19,6 +20,14 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
19
20
|
* Scrolling recomputes pinned and reflects `data-pinned`, emitting `pin` on change; the
|
|
20
21
|
* `scrollToBottom` action jumps back down (a "new messages" button).
|
|
21
22
|
*
|
|
23
|
+
* A container renders at `scrollTop` 0 — unpinned — so out of the box it flags the first
|
|
24
|
+
* append rather than following it. `pinOnConnect` opts into the other starting point:
|
|
25
|
+
* `connect()` jumps to the bottom and reads the state back from where it landed. It runs
|
|
26
|
+
* on every connect, so a container inserted or re-rendered later (a Turbo Stream, a panel
|
|
27
|
+
* built on open) starts at the bottom too — which a once-per-document page event cannot
|
|
28
|
+
* do. It is off by default, so a restored reading position (a Turbo cache restore, a user
|
|
29
|
+
* who had scrolled up) is never yanked to the bottom unless the consumer asks for it.
|
|
30
|
+
*
|
|
22
31
|
* @remarks
|
|
23
32
|
* The `MutationObserver` watches `childList` only (not `subtree`), so it follows
|
|
24
33
|
* direct appends to `content` (or the element). Appends made deeper inside a nested
|
|
@@ -26,12 +35,11 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
26
35
|
* `scrollToBottom` action after such inserts.
|
|
27
36
|
*
|
|
28
37
|
* Behavior only — it does not add content (Turbo Stream / the consumer does) and is the
|
|
29
|
-
* minimal follow primitive, not a full chat UI (no virtualization / message input).
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* navigation included).
|
|
38
|
+
* minimal follow primitive, not a full chat UI (no virtualization / message input). State
|
|
39
|
+
* is derived from the scroll position each pass (no module-scope state), so `connect()`
|
|
40
|
+
* re-syncs after a Turbo Stream insert; reduced motion forces an instant jump
|
|
41
|
+
* independently of consumer CSS; auto-scroll never moves focus; the observer and the
|
|
42
|
+
* passive scroll listener are released on `disconnect()` (Turbo navigation included).
|
|
35
43
|
*/
|
|
36
44
|
declare class StickToBottomController extends Controller<HTMLElement> {
|
|
37
45
|
#private;
|
|
@@ -45,6 +53,10 @@ declare class StickToBottomController extends Controller<HTMLElement> {
|
|
|
45
53
|
type: StringConstructor;
|
|
46
54
|
default: string;
|
|
47
55
|
};
|
|
56
|
+
pinOnConnect: {
|
|
57
|
+
type: BooleanConstructor;
|
|
58
|
+
default: boolean;
|
|
59
|
+
};
|
|
48
60
|
};
|
|
49
61
|
static actions: readonly ["scrollToBottom"];
|
|
50
62
|
static events: readonly ["pin", "new"];
|
|
@@ -52,9 +64,20 @@ declare class StickToBottomController extends Controller<HTMLElement> {
|
|
|
52
64
|
readonly hasContentTarget: boolean;
|
|
53
65
|
thresholdValue: number;
|
|
54
66
|
behaviorValue: string;
|
|
67
|
+
pinOnConnectValue: boolean;
|
|
55
68
|
connect(): void;
|
|
56
69
|
disconnect(): void;
|
|
57
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Jumps to the bottom and re-pins (wired to a "new messages" button).
|
|
72
|
+
*
|
|
73
|
+
* The has-new flag clears on request — the user has acknowledged the arrival — while
|
|
74
|
+
* pinned is read back from where the scroll landed: a jump that arrives by the time
|
|
75
|
+
* this returns pins immediately, an animated one settles from its own scroll events,
|
|
76
|
+
* and a jump the engine cannot honor leaves the container unpinned, so the next append
|
|
77
|
+
* flags it again instead of being swallowed by a pinned state that does not hold.
|
|
78
|
+
*
|
|
79
|
+
* Which of those happens is not this method's to decide — see {@link behaviorValue}.
|
|
80
|
+
*/
|
|
58
81
|
scrollToBottom(): void;
|
|
59
82
|
}
|
|
60
83
|
|
|
@@ -17,14 +17,18 @@ var StickToBottomController = class extends Controller {
|
|
|
17
17
|
static targets = ["content"];
|
|
18
18
|
static values = {
|
|
19
19
|
threshold: { type: Number, default: 80 },
|
|
20
|
-
behavior: { type: String, default: "auto" }
|
|
20
|
+
behavior: { type: String, default: "auto" },
|
|
21
|
+
pinOnConnect: { type: Boolean, default: false }
|
|
21
22
|
};
|
|
22
23
|
static actions = ["scrollToBottom"];
|
|
23
24
|
static events = ["pin", "new"];
|
|
24
25
|
#observer = null;
|
|
26
|
+
/** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */
|
|
27
|
+
#layout = null;
|
|
25
28
|
#pinned = false;
|
|
26
29
|
#onScroll = () => this.#updatePinned();
|
|
27
30
|
connect() {
|
|
31
|
+
if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
|
|
28
32
|
this.#pinned = this.#isPinned();
|
|
29
33
|
this.#reflectPinned();
|
|
30
34
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
@@ -32,21 +36,29 @@ var StickToBottomController = class extends Controller {
|
|
|
32
36
|
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
33
37
|
this.#observer.observe(this.#watched(), { childList: true });
|
|
34
38
|
}
|
|
39
|
+
if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
|
|
35
40
|
}
|
|
36
41
|
disconnect() {
|
|
37
42
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
38
43
|
this.#observer?.disconnect();
|
|
39
44
|
this.#observer = null;
|
|
45
|
+
this.#stopWaitingForLayout();
|
|
40
46
|
}
|
|
41
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Jumps to the bottom and re-pins (wired to a "new messages" button).
|
|
49
|
+
*
|
|
50
|
+
* The has-new flag clears on request — the user has acknowledged the arrival — while
|
|
51
|
+
* pinned is read back from where the scroll landed: a jump that arrives by the time
|
|
52
|
+
* this returns pins immediately, an animated one settles from its own scroll events,
|
|
53
|
+
* and a jump the engine cannot honor leaves the container unpinned, so the next append
|
|
54
|
+
* flags it again instead of being swallowed by a pinned state that does not hold.
|
|
55
|
+
*
|
|
56
|
+
* Which of those happens is not this method's to decide — see {@link behaviorValue}.
|
|
57
|
+
*/
|
|
42
58
|
scrollToBottom() {
|
|
43
59
|
this.#scrollToBottom();
|
|
44
60
|
this.element.removeAttribute("data-has-new");
|
|
45
|
-
|
|
46
|
-
this.#pinned = true;
|
|
47
|
-
this.element.setAttribute("data-pinned", "true");
|
|
48
|
-
this.dispatch("pin", { detail: { pinned: true } });
|
|
49
|
-
}
|
|
61
|
+
this.#updatePinned();
|
|
50
62
|
}
|
|
51
63
|
/** Follows appended children while pinned; otherwise flags new content. */
|
|
52
64
|
#onMutations(mutations) {
|
|
@@ -81,10 +93,43 @@ var StickToBottomController = class extends Controller {
|
|
|
81
93
|
const el = this.element;
|
|
82
94
|
return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
|
|
83
95
|
}
|
|
84
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Whether the container has a box to scroll and to measure. One that is not rendered
|
|
98
|
+
* (inside a closed panel) reports every metric as 0, which reads as "already at the
|
|
99
|
+
* bottom" — a position describing no layout the user will ever see.
|
|
100
|
+
*/
|
|
101
|
+
#measurable() {
|
|
102
|
+
return this.element.clientHeight > 0;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Holds the `pinOnConnect` jump until the container is laid out, then runs it and
|
|
106
|
+
* re-reads the state — otherwise the panel opens at the top still claiming the bottom.
|
|
107
|
+
*/
|
|
108
|
+
#pinWhenLaidOut() {
|
|
109
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
110
|
+
this.#layout = new ResizeObserver(() => {
|
|
111
|
+
if (!this.#measurable()) return;
|
|
112
|
+
this.#stopWaitingForLayout();
|
|
113
|
+
this.#scrollToBottom("instant");
|
|
114
|
+
this.#updatePinned();
|
|
115
|
+
});
|
|
116
|
+
this.#layout.observe(this.element);
|
|
117
|
+
}
|
|
118
|
+
/** Releases the layout watch, whether or not the deferred jump ever ran. */
|
|
119
|
+
#stopWaitingForLayout() {
|
|
120
|
+
this.#layout?.disconnect();
|
|
121
|
+
this.#layout = null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
|
|
125
|
+
* 0 for a container tall enough to hold its whole content, so the jump moves nothing
|
|
126
|
+
* there. `behavior` defaults to the configured follow behavior; pass `"instant"` for a
|
|
127
|
+
* jump that must not animate.
|
|
128
|
+
*/
|
|
129
|
+
#scrollToBottom(behavior = this.#behavior()) {
|
|
85
130
|
const top = this.element.scrollHeight;
|
|
86
131
|
if (typeof this.element.scrollTo === "function") {
|
|
87
|
-
this.element.scrollTo({ top, behavior
|
|
132
|
+
this.element.scrollTo({ top, behavior });
|
|
88
133
|
} else {
|
|
89
134
|
this.element.scrollTop = top;
|
|
90
135
|
}
|
|
@@ -93,8 +138,14 @@ var StickToBottomController = class extends Controller {
|
|
|
93
138
|
#watched() {
|
|
94
139
|
return this.hasContentTarget ? this.contentTarget : this.element;
|
|
95
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
|
|
143
|
+
* once: it hands the decision to the element's computed `scroll-behavior`, so a
|
|
144
|
+
* consumer stylesheet saying `smooth` animates these scrolls too. Only `"instant"`
|
|
145
|
+
* overrides that CSS, which is why reduced motion and the `pinOnConnect` jump name it.
|
|
146
|
+
*/
|
|
96
147
|
#behavior() {
|
|
97
|
-
if (prefersReducedMotion()) return "
|
|
148
|
+
if (prefersReducedMotion()) return "instant";
|
|
98
149
|
return this.behaviorValue === "smooth" ? "smooth" : "auto";
|
|
99
150
|
}
|
|
100
151
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/reduced_motion.ts","../../src/controllers/stick_to_bottom_controller.ts"],"names":[],"mappings":"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","file":"stick_to_bottom_controller.js","sourcesContent":["/**\n * Shared `prefers-reduced-motion` lookup for the motion-aware controllers\n * (count-up, highlight, overflow-indicator, scroll-visibility, stick-to-bottom,\n * transition).\n *\n * Each of those controllers used to duplicate the same guarded `matchMedia`\n * read; this one-liner keeps the media query string and the environment guard\n * single-sourced. The preference is intentionally re-read on every call — the\n * controllers check it at each animation/scroll start (WCAG 2.2 **2.3.3**), so\n * flipping the OS setting takes effect immediately without any listener or\n * cache bookkeeping here.\n */\n\n/**\n * Whether the user currently requests reduced motion.\n *\n * @returns `true` when `(prefers-reduced-motion: reduce)` matches; `false`\n * otherwise, including environments without `window.matchMedia` (treated as\n * \"no preference\").\n */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { prefersReducedMotion } from \"../utils/reduced_motion\";\n\n/** Counts the element nodes in an added-node list (text nodes are ignored). */\nconst countElements = (nodes: NodeList): number => {\n let n = 0;\n for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;\n return n;\n};\n\n/**\n * Headless **stick-to-bottom**: auto-follows new content to the bottom of a scroll\n * container (a chat log, a live console) — but only while the user is already near the\n * bottom; if they have scrolled up to read, it holds position and flags that new content\n * arrived instead (no APG pattern; honors WCAG 2.3.3 via `prefers-reduced-motion` and\n * never steals focus).\n *\n * Markup contract (identifier: `stimeo--stick-to-bottom`):\n * <div data-controller=\"stimeo--stick-to-bottom\"\n * data-stimeo--stick-to-bottom-threshold-value=\"80\" style=\"overflow:auto\">\n * <ul data-stimeo--stick-to-bottom-target=\"content\"><!-- Turbo Stream appends --></ul>\n * </div>\n *\n * The container is \"pinned\" while its distance from the bottom is within `threshold`. A\n * `MutationObserver` on `content` (or the element) reacts to appended children: while\n * pinned it scrolls to the bottom; while unpinned it sets `data-has-new` and emits `new`.\n * Scrolling recomputes pinned and reflects `data-pinned`, emitting `pin` on change; the\n * `scrollToBottom` action jumps back down (a \"new messages\" button).\n *\n * @remarks\n * The `MutationObserver` watches `childList` only (not `subtree`), so it follows\n * direct appends to `content` (or the element). Appends made deeper inside a nested\n * wrapper are not detected — keep messages as direct children, or call the public\n * `scrollToBottom` action after such inserts.\n *\n * Behavior only — it does not add content (Turbo Stream / the consumer does) and is the\n * minimal follow primitive, not a full chat UI (no virtualization / message input). It is\n * the lightweight member of the scroll family. State is derived from the scroll position\n * each pass (no module-scope state), so `connect()` re-syncs after a Turbo Stream insert;\n * `behavior` falls back to `auto` under reduced motion; auto-scroll never moves focus; the\n * observer and the passive scroll listener are released on `disconnect()` (Turbo\n * navigation included).\n */\nexport class StickToBottomController extends Controller<HTMLElement> {\n static override targets = [\"content\"];\n static override values = {\n threshold: { type: Number, default: 80 },\n behavior: { type: String, default: \"auto\" },\n };\n static actions = [\"scrollToBottom\"] as const;\n static events = [\"pin\", \"new\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n\n declare thresholdValue: number;\n declare behaviorValue: string;\n\n #observer: MutationObserver | null = null;\n #pinned = false;\n\n readonly #onScroll = (): void => this.#updatePinned();\n\n override connect(): void {\n this.#pinned = this.#isPinned();\n // Re-sync the hooks from the current geometry — a Turbo cache restore may bring back a\n // stale data-pinned / data-has-new that no longer matches the scroll position.\n this.#reflectPinned();\n\n this.element.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));\n this.#observer.observe(this.#watched(), { childList: true });\n }\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"scroll\", this.#onScroll);\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /** Jumps to the bottom and re-pins (wired to a \"new messages\" button). */\n scrollToBottom(): void {\n this.#scrollToBottom();\n this.element.removeAttribute(\"data-has-new\");\n if (!this.#pinned) {\n this.#pinned = true;\n this.element.setAttribute(\"data-pinned\", \"true\");\n this.dispatch(\"pin\", { detail: { pinned: true } });\n }\n }\n\n /** Follows appended children while pinned; otherwise flags new content. */\n #onMutations(mutations: MutationRecord[]): void {\n let added = 0;\n for (const mutation of mutations) added += countElements(mutation.addedNodes);\n if (added === 0) return;\n\n if (this.#pinned) {\n this.#scrollToBottom();\n } else {\n this.element.setAttribute(\"data-has-new\", \"true\");\n this.dispatch(\"new\", { detail: { count: added } });\n }\n }\n\n /** Recomputes pinned from the scroll position and reflects it on a transition. */\n #updatePinned(): void {\n const pinned = this.#isPinned();\n if (pinned === this.#pinned) return;\n this.#pinned = pinned;\n this.#reflectPinned();\n this.dispatch(\"pin\", { detail: { pinned } });\n }\n\n /** Mirrors the current `#pinned` onto the state hooks (clearing has-new once pinned). */\n #reflectPinned(): void {\n if (this.#pinned) {\n this.element.setAttribute(\"data-pinned\", \"true\");\n this.element.removeAttribute(\"data-has-new\"); // caught up with the bottom\n } else {\n this.element.removeAttribute(\"data-pinned\");\n }\n }\n\n #isPinned(): boolean {\n const el = this.element;\n return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;\n }\n\n #scrollToBottom(): void {\n const top = this.element.scrollHeight;\n if (typeof this.element.scrollTo === \"function\") {\n this.element.scrollTo({ top, behavior: this.#behavior() });\n } else {\n this.element.scrollTop = top;\n }\n }\n\n /** The append-watched element: the `content` target, or the container itself. */\n #watched(): HTMLElement {\n return this.hasContentTarget ? this.contentTarget : this.element;\n }\n\n #behavior(): ScrollBehavior {\n if (prefersReducedMotion()) return \"auto\";\n return this.behaviorValue === \"smooth\" ? \"smooth\" : \"auto\";\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/reduced_motion.ts","../../src/controllers/stick_to_bottom_controller.ts"],"names":[],"mappings":"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","file":"stick_to_bottom_controller.js","sourcesContent":["/**\n * Shared `prefers-reduced-motion` lookup for the motion-aware controllers\n * (count-up, highlight, overflow-indicator, scroll-visibility, stick-to-bottom,\n * transition).\n *\n * This one-liner keeps the media query string and the environment guard\n * single-sourced across them. The preference is intentionally re-read on every\n * call — the controllers check it at each animation/scroll start (WCAG 2.2\n * **2.3.3**), so flipping the OS setting takes effect immediately without any\n * listener or cache bookkeeping here.\n */\n\n/**\n * Whether the user currently requests reduced motion.\n *\n * @returns `true` when `(prefers-reduced-motion: reduce)` matches; `false`\n * otherwise, including environments without `window.matchMedia` (treated as\n * \"no preference\").\n */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { prefersReducedMotion } from \"../utils/reduced_motion\";\n\n/** Counts the element nodes in an added-node list (text nodes are ignored). */\nconst countElements = (nodes: NodeList): number => {\n let n = 0;\n for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;\n return n;\n};\n\n/**\n * Headless **stick-to-bottom**: auto-follows new content to the bottom of a scroll\n * container (a chat log, a live console) — but only while the user is already near the\n * bottom; if they have scrolled up to read, it holds position and flags that new content\n * arrived instead (no APG pattern; honors WCAG 2.3.3 via `prefers-reduced-motion` and\n * never steals focus).\n *\n * Markup contract (identifier: `stimeo--stick-to-bottom`):\n * <div data-controller=\"stimeo--stick-to-bottom\"\n * data-stimeo--stick-to-bottom-threshold-value=\"80\"\n * data-stimeo--stick-to-bottom-pin-on-connect-value=\"true\" style=\"overflow:auto\">\n * <ul data-stimeo--stick-to-bottom-target=\"content\"><!-- Turbo Stream appends --></ul>\n * </div>\n *\n * The container is \"pinned\" while its distance from the bottom is within `threshold`. A\n * `MutationObserver` on `content` (or the element) reacts to appended children: while\n * pinned it scrolls to the bottom; while unpinned it sets `data-has-new` and emits `new`.\n * Scrolling recomputes pinned and reflects `data-pinned`, emitting `pin` on change; the\n * `scrollToBottom` action jumps back down (a \"new messages\" button).\n *\n * A container renders at `scrollTop` 0 — unpinned — so out of the box it flags the first\n * append rather than following it. `pinOnConnect` opts into the other starting point:\n * `connect()` jumps to the bottom and reads the state back from where it landed. It runs\n * on every connect, so a container inserted or re-rendered later (a Turbo Stream, a panel\n * built on open) starts at the bottom too — which a once-per-document page event cannot\n * do. It is off by default, so a restored reading position (a Turbo cache restore, a user\n * who had scrolled up) is never yanked to the bottom unless the consumer asks for it.\n *\n * @remarks\n * The `MutationObserver` watches `childList` only (not `subtree`), so it follows\n * direct appends to `content` (or the element). Appends made deeper inside a nested\n * wrapper are not detected — keep messages as direct children, or call the public\n * `scrollToBottom` action after such inserts.\n *\n * Behavior only — it does not add content (Turbo Stream / the consumer does) and is the\n * minimal follow primitive, not a full chat UI (no virtualization / message input). State\n * is derived from the scroll position each pass (no module-scope state), so `connect()`\n * re-syncs after a Turbo Stream insert; reduced motion forces an instant jump\n * independently of consumer CSS; auto-scroll never moves focus; the observer and the\n * passive scroll listener are released on `disconnect()` (Turbo navigation included).\n */\nexport class StickToBottomController extends Controller<HTMLElement> {\n static override targets = [\"content\"];\n static override values = {\n threshold: { type: Number, default: 80 },\n behavior: { type: String, default: \"auto\" },\n pinOnConnect: { type: Boolean, default: false },\n };\n static actions = [\"scrollToBottom\"] as const;\n static events = [\"pin\", \"new\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n\n declare thresholdValue: number;\n declare behaviorValue: string;\n declare pinOnConnectValue: boolean;\n\n #observer: MutationObserver | null = null;\n /** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */\n #layout: ResizeObserver | null = null;\n #pinned = false;\n\n readonly #onScroll = (): void => this.#updatePinned();\n\n override connect(): void {\n // Instant whatever `behavior` says, overriding a consumer's `scroll-behavior: smooth`:\n // an animated jump emits scroll events on the way down, each recomputing pinned from a\n // position still far from the bottom, so the container would unpin (and re-pin)\n // mid-flight before it ever settles.\n if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom(\"instant\");\n // Read the state back from where the container actually is, asked for a jump or not: a\n // request the engine does not honor must leave it unpinned, so the next append is\n // flagged rather than swallowed. This also drops a stale data-pinned / data-has-new a\n // Turbo cache restore brought back.\n this.#pinned = this.#isPinned();\n this.#reflectPinned();\n\n this.element.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));\n this.#observer.observe(this.#watched(), { childList: true });\n }\n if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"scroll\", this.#onScroll);\n this.#observer?.disconnect();\n this.#observer = null;\n this.#stopWaitingForLayout();\n }\n\n /**\n * Jumps to the bottom and re-pins (wired to a \"new messages\" button).\n *\n * The has-new flag clears on request — the user has acknowledged the arrival — while\n * pinned is read back from where the scroll landed: a jump that arrives by the time\n * this returns pins immediately, an animated one settles from its own scroll events,\n * and a jump the engine cannot honor leaves the container unpinned, so the next append\n * flags it again instead of being swallowed by a pinned state that does not hold.\n *\n * Which of those happens is not this method's to decide — see {@link behaviorValue}.\n */\n scrollToBottom(): void {\n this.#scrollToBottom();\n this.element.removeAttribute(\"data-has-new\");\n this.#updatePinned();\n }\n\n /** Follows appended children while pinned; otherwise flags new content. */\n #onMutations(mutations: MutationRecord[]): void {\n let added = 0;\n for (const mutation of mutations) added += countElements(mutation.addedNodes);\n if (added === 0) return;\n\n if (this.#pinned) {\n this.#scrollToBottom();\n } else {\n this.element.setAttribute(\"data-has-new\", \"true\");\n this.dispatch(\"new\", { detail: { count: added } });\n }\n }\n\n /** Recomputes pinned from the scroll position and reflects it on a transition. */\n #updatePinned(): void {\n const pinned = this.#isPinned();\n if (pinned === this.#pinned) return;\n this.#pinned = pinned;\n this.#reflectPinned();\n this.dispatch(\"pin\", { detail: { pinned } });\n }\n\n /** Mirrors the current `#pinned` onto the state hooks (clearing has-new once pinned). */\n #reflectPinned(): void {\n if (this.#pinned) {\n this.element.setAttribute(\"data-pinned\", \"true\");\n this.element.removeAttribute(\"data-has-new\"); // caught up with the bottom\n } else {\n this.element.removeAttribute(\"data-pinned\");\n }\n }\n\n #isPinned(): boolean {\n const el = this.element;\n return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;\n }\n\n /**\n * Whether the container has a box to scroll and to measure. One that is not rendered\n * (inside a closed panel) reports every metric as 0, which reads as \"already at the\n * bottom\" — a position describing no layout the user will ever see.\n */\n #measurable(): boolean {\n return this.element.clientHeight > 0;\n }\n\n /**\n * Holds the `pinOnConnect` jump until the container is laid out, then runs it and\n * re-reads the state — otherwise the panel opens at the top still claiming the bottom.\n */\n #pinWhenLaidOut(): void {\n if (typeof ResizeObserver === \"undefined\") return;\n this.#layout = new ResizeObserver(() => {\n if (!this.#measurable()) return;\n this.#stopWaitingForLayout();\n this.#scrollToBottom(\"instant\");\n this.#updatePinned();\n });\n this.#layout.observe(this.element);\n }\n\n /** Releases the layout watch, whether or not the deferred jump ever ran. */\n #stopWaitingForLayout(): void {\n this.#layout?.disconnect();\n this.#layout = null;\n }\n\n /**\n * Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is\n * 0 for a container tall enough to hold its whole content, so the jump moves nothing\n * there. `behavior` defaults to the configured follow behavior; pass `\"instant\"` for a\n * jump that must not animate.\n */\n #scrollToBottom(behavior: ScrollBehavior = this.#behavior()): void {\n const top = this.element.scrollHeight;\n if (typeof this.element.scrollTo === \"function\") {\n this.element.scrollTo({ top, behavior });\n } else {\n this.element.scrollTop = top;\n }\n }\n\n /** The append-watched element: the `content` target, or the container itself. */\n #watched(): HTMLElement {\n return this.hasContentTarget ? this.contentTarget : this.element;\n }\n\n /**\n * The behavior a follow-scroll runs with. `\"auto\"` is **not** a request to arrive at\n * once: it hands the decision to the element's computed `scroll-behavior`, so a\n * consumer stylesheet saying `smooth` animates these scrolls too. Only `\"instant\"`\n * overrides that CSS, which is why reduced motion and the `pinOnConnect` jump name it.\n */\n #behavior(): ScrollBehavior {\n if (prefersReducedMotion()) return \"instant\";\n return this.behaviorValue === \"smooth\" ? \"smooth\" : \"auto\";\n }\n}\n"]}
|
|
@@ -13,6 +13,7 @@ var IntersectionWatcher = class {
|
|
|
13
13
|
#onEntries;
|
|
14
14
|
#observer = null;
|
|
15
15
|
#active = false;
|
|
16
|
+
#usingPlatformDefaults = false;
|
|
16
17
|
constructor(onEntries) {
|
|
17
18
|
this.#onEntries = onEntries;
|
|
18
19
|
}
|
|
@@ -20,15 +21,22 @@ var IntersectionWatcher = class {
|
|
|
20
21
|
get active() {
|
|
21
22
|
return this.#active;
|
|
22
23
|
}
|
|
24
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
25
|
+
get usingPlatformDefaults() {
|
|
26
|
+
return this.#usingPlatformDefaults;
|
|
27
|
+
}
|
|
23
28
|
/**
|
|
24
29
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
25
30
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
26
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.
|
|
27
34
|
*
|
|
28
|
-
* @throws
|
|
29
|
-
* or
|
|
30
|
-
* the watcher rolls back first: every target observed
|
|
31
|
-
* `active` stays `false`, so a caller that retries
|
|
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.
|
|
32
40
|
*/
|
|
33
41
|
start(targets, options = {}) {
|
|
34
42
|
this.stop();
|
|
@@ -38,12 +46,23 @@ var IntersectionWatcher = class {
|
|
|
38
46
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
39
47
|
let observer = null;
|
|
40
48
|
try {
|
|
41
|
-
|
|
42
|
-
(entries)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|
|
47
66
|
for (const target of list) observer.observe(target);
|
|
48
67
|
this.#observer = observer;
|
|
49
68
|
this.#active = true;
|
|
@@ -52,6 +71,7 @@ var IntersectionWatcher = class {
|
|
|
52
71
|
observer?.disconnect();
|
|
53
72
|
this.#observer = null;
|
|
54
73
|
this.#active = false;
|
|
74
|
+
this.#usingPlatformDefaults = false;
|
|
55
75
|
throw error;
|
|
56
76
|
}
|
|
57
77
|
}
|
|
@@ -78,6 +98,7 @@ var IntersectionWatcher = class {
|
|
|
78
98
|
this.#active = false;
|
|
79
99
|
this.#observer?.disconnect();
|
|
80
100
|
this.#observer = null;
|
|
101
|
+
this.#usingPlatformDefaults = false;
|
|
81
102
|
}
|
|
82
103
|
};
|
|
83
104
|
|
|
@@ -93,7 +114,7 @@ var StickyObserverController = class extends Controller {
|
|
|
93
114
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
94
115
|
/** Last reported stuck state, so `change` fires only on transitions. */
|
|
95
116
|
#stuck = null;
|
|
96
|
-
/** Target currently owned by the watcher
|
|
117
|
+
/** Target currently owned by the watcher; comparing it avoids duplicate restarts. */
|
|
97
118
|
#observedSentinel = null;
|
|
98
119
|
#connected = false;
|
|
99
120
|
#onIntersect(entries) {
|
|
@@ -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 */\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\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 * @throws Whatever the platform throws for an invalid `rootMargin`/`threshold`\n * or a failing `observe()`. The exception is passed through unchanged, but\n * the watcher rolls back first: every target observed so far is released and\n * `active` stays `false`, so a caller that retries 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 observer = new IntersectionObserver(\n (entries) => {\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 { root, rootMargin: options.rootMargin, threshold: options.threshold },\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 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 }\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, used to avoid 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"]}
|
|
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"]}
|
|
@@ -25,8 +25,9 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
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
27
|
* the consumer owns all styling. The required `list` target marks the tablist
|
|
28
|
-
* container as part of the semantic contract —
|
|
29
|
-
* role and accessible name; the controller performs no runtime
|
|
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.
|
|
@@ -40,7 +41,14 @@ declare class TabsController extends Controller<HTMLElement> {
|
|
|
40
41
|
readonly listTarget: HTMLElement;
|
|
41
42
|
readonly tabTargets: HTMLButtonElement[];
|
|
42
43
|
readonly panelTargets: HTMLElement[];
|
|
43
|
-
/**
|
|
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
|
+
*/
|
|
44
52
|
connect(): void;
|
|
45
53
|
/** Selects the clicked tab. Bound via `data-action` (click). */
|
|
46
54
|
select(event: Event): void;
|