stimeo-ui 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/controllers/accordion_controller.d.ts +59 -0
- package/dist/controllers/accordion_controller.js +76 -0
- package/dist/controllers/accordion_controller.js.map +1 -0
- package/dist/controllers/announcer_controller.d.ts +78 -0
- package/dist/controllers/announcer_controller.js +184 -0
- package/dist/controllers/announcer_controller.js.map +1 -0
- package/dist/controllers/aspect_ratio_controller.d.ts +39 -0
- package/dist/controllers/aspect_ratio_controller.js +36 -0
- package/dist/controllers/aspect_ratio_controller.js.map +1 -0
- package/dist/controllers/auto_submit_controller.d.ts +74 -0
- package/dist/controllers/auto_submit_controller.js +147 -0
- package/dist/controllers/auto_submit_controller.js.map +1 -0
- package/dist/controllers/avatar_controller.d.ts +50 -0
- package/dist/controllers/avatar_controller.js +66 -0
- package/dist/controllers/avatar_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +60 -0
- package/dist/controllers/breadcrumb_controller.js +123 -0
- package/dist/controllers/breadcrumb_controller.js.map +1 -0
- package/dist/controllers/bulk_select_controller.d.ts +67 -0
- package/dist/controllers/bulk_select_controller.js +104 -0
- package/dist/controllers/bulk_select_controller.js.map +1 -0
- package/dist/controllers/calendar_controller.d.ts +100 -0
- package/dist/controllers/calendar_controller.js +394 -0
- package/dist/controllers/calendar_controller.js.map +1 -0
- package/dist/controllers/character_counter_controller.d.ts +63 -0
- package/dist/controllers/character_counter_controller.js +179 -0
- package/dist/controllers/character_counter_controller.js.map +1 -0
- package/dist/controllers/checkbox_controller.d.ts +52 -0
- package/dist/controllers/checkbox_controller.js +73 -0
- package/dist/controllers/checkbox_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +76 -0
- package/dist/controllers/combobox_controller.js +186 -0
- package/dist/controllers/combobox_controller.js.map +1 -0
- package/dist/controllers/command_palette_controller.d.ts +103 -0
- package/dist/controllers/command_palette_controller.js +381 -0
- package/dist/controllers/command_palette_controller.js.map +1 -0
- package/dist/controllers/conditional_fields_controller.d.ts +56 -0
- package/dist/controllers/conditional_fields_controller.js +112 -0
- package/dist/controllers/conditional_fields_controller.js.map +1 -0
- package/dist/controllers/confirm_controller.d.ts +83 -0
- package/dist/controllers/confirm_controller.js +276 -0
- package/dist/controllers/confirm_controller.js.map +1 -0
- package/dist/controllers/context_menu_controller.d.ts +81 -0
- package/dist/controllers/context_menu_controller.js +112 -0
- package/dist/controllers/context_menu_controller.js.map +1 -0
- package/dist/controllers/countdown_controller.d.ts +93 -0
- package/dist/controllers/countdown_controller.js +202 -0
- package/dist/controllers/countdown_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.d.ts +57 -0
- package/dist/controllers/dialog_controller.js +207 -0
- package/dist/controllers/dialog_controller.js.map +1 -0
- package/dist/controllers/direct_upload_controller.d.ts +77 -0
- package/dist/controllers/direct_upload_controller.js +212 -0
- package/dist/controllers/direct_upload_controller.js.map +1 -0
- package/dist/controllers/dirty_form_controller.d.ts +52 -0
- package/dist/controllers/dirty_form_controller.js +128 -0
- package/dist/controllers/dirty_form_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +49 -0
- package/dist/controllers/dropdown_controller.js +66 -0
- package/dist/controllers/dropdown_controller.js.map +1 -0
- package/dist/controllers/empty_state_controller.d.ts +60 -0
- package/dist/controllers/empty_state_controller.js +67 -0
- package/dist/controllers/empty_state_controller.js.map +1 -0
- package/dist/controllers/flash_controller.d.ts +63 -0
- package/dist/controllers/flash_controller.js +221 -0
- package/dist/controllers/flash_controller.js.map +1 -0
- package/dist/controllers/focus_controller.d.ts +69 -0
- package/dist/controllers/focus_controller.js +216 -0
- package/dist/controllers/focus_controller.js.map +1 -0
- package/dist/controllers/form_field_controller.d.ts +79 -0
- package/dist/controllers/form_field_controller.js +154 -0
- package/dist/controllers/form_field_controller.js.map +1 -0
- package/dist/controllers/form_validation_controller.d.ts +114 -0
- package/dist/controllers/form_validation_controller.js +202 -0
- package/dist/controllers/form_validation_controller.js.map +1 -0
- package/dist/controllers/frame_loading_controller.d.ts +58 -0
- package/dist/controllers/frame_loading_controller.js +177 -0
- package/dist/controllers/frame_loading_controller.js.map +1 -0
- package/dist/controllers/highlight_controller.d.ts +50 -0
- package/dist/controllers/highlight_controller.js +107 -0
- package/dist/controllers/highlight_controller.js.map +1 -0
- package/dist/controllers/hover_card_controller.d.ts +79 -0
- package/dist/controllers/hover_card_controller.js +165 -0
- package/dist/controllers/hover_card_controller.js.map +1 -0
- package/dist/controllers/idle_controller.d.ts +51 -0
- package/dist/controllers/idle_controller.js +141 -0
- package/dist/controllers/idle_controller.js.map +1 -0
- package/dist/controllers/input_mask_controller.d.ts +74 -0
- package/dist/controllers/input_mask_controller.js +166 -0
- package/dist/controllers/input_mask_controller.js.map +1 -0
- package/dist/controllers/lazy_frame_controller.d.ts +53 -0
- package/dist/controllers/lazy_frame_controller.js +68 -0
- package/dist/controllers/lazy_frame_controller.js.map +1 -0
- package/dist/controllers/listbox_controller.d.ts +88 -0
- package/dist/controllers/listbox_controller.js +256 -0
- package/dist/controllers/listbox_controller.js.map +1 -0
- package/dist/controllers/local_time_controller.d.ts +60 -0
- package/dist/controllers/local_time_controller.js +81 -0
- package/dist/controllers/local_time_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +81 -0
- package/dist/controllers/menu_controller.js +134 -0
- package/dist/controllers/menu_controller.js.map +1 -0
- package/dist/controllers/meter_controller.d.ts +93 -0
- package/dist/controllers/meter_controller.js +96 -0
- package/dist/controllers/meter_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +85 -0
- package/dist/controllers/nested_form_controller.js +131 -0
- package/dist/controllers/nested_form_controller.js.map +1 -0
- package/dist/controllers/network_status_controller.d.ts +49 -0
- package/dist/controllers/network_status_controller.js +126 -0
- package/dist/controllers/network_status_controller.js.map +1 -0
- package/dist/controllers/number_input_controller.d.ts +93 -0
- package/dist/controllers/number_input_controller.js +306 -0
- package/dist/controllers/number_input_controller.js.map +1 -0
- package/dist/controllers/otp_controller.d.ts +68 -0
- package/dist/controllers/otp_controller.js +201 -0
- package/dist/controllers/otp_controller.js.map +1 -0
- package/dist/controllers/overflow_indicator_controller.d.ts +62 -0
- package/dist/controllers/overflow_indicator_controller.js +169 -0
- package/dist/controllers/overflow_indicator_controller.js.map +1 -0
- package/dist/controllers/overflow_menu_controller.d.ts +64 -0
- package/dist/controllers/overflow_menu_controller.js +274 -0
- package/dist/controllers/overflow_menu_controller.js.map +1 -0
- package/dist/controllers/pagination_controller.d.ts +69 -0
- package/dist/controllers/pagination_controller.js +89 -0
- package/dist/controllers/pagination_controller.js.map +1 -0
- package/dist/controllers/password_strength_controller.d.ts +67 -0
- package/dist/controllers/password_strength_controller.js +175 -0
- package/dist/controllers/password_strength_controller.js.map +1 -0
- package/dist/controllers/persist_controller.d.ts +63 -0
- package/dist/controllers/persist_controller.js +259 -0
- package/dist/controllers/persist_controller.js.map +1 -0
- package/dist/controllers/popover_controller.d.ts +63 -0
- package/dist/controllers/popover_controller.js +94 -0
- package/dist/controllers/popover_controller.js.map +1 -0
- package/dist/controllers/portal_controller.d.ts +58 -0
- package/dist/controllers/portal_controller.js +63 -0
- package/dist/controllers/portal_controller.js.map +1 -0
- package/dist/controllers/preview_guard_controller.d.ts +47 -0
- package/dist/controllers/preview_guard_controller.js +69 -0
- package/dist/controllers/preview_guard_controller.js.map +1 -0
- package/dist/controllers/progress_controller.d.ts +85 -0
- package/dist/controllers/progress_controller.js +93 -0
- package/dist/controllers/progress_controller.js.map +1 -0
- package/dist/controllers/radio_group_controller.d.ts +49 -0
- package/dist/controllers/radio_group_controller.js +128 -0
- package/dist/controllers/radio_group_controller.js.map +1 -0
- package/dist/controllers/rating_controller.d.ts +82 -0
- package/dist/controllers/rating_controller.js +179 -0
- package/dist/controllers/rating_controller.js.map +1 -0
- package/dist/controllers/relative_time_controller.d.ts +48 -0
- package/dist/controllers/relative_time_controller.js +129 -0
- package/dist/controllers/relative_time_controller.js.map +1 -0
- package/dist/controllers/reset_before_cache_controller.d.ts +54 -0
- package/dist/controllers/reset_before_cache_controller.js +62 -0
- package/dist/controllers/reset_before_cache_controller.js.map +1 -0
- package/dist/controllers/resizable_controller.d.ts +81 -0
- package/dist/controllers/resizable_controller.js +163 -0
- package/dist/controllers/resizable_controller.js.map +1 -0
- package/dist/controllers/roving_controller.d.ts +63 -0
- package/dist/controllers/roving_controller.js +116 -0
- package/dist/controllers/roving_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.d.ts +45 -0
- package/dist/controllers/scroll_area_controller.js +183 -0
- package/dist/controllers/scroll_area_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +72 -0
- package/dist/controllers/scroll_visibility_controller.js +103 -0
- package/dist/controllers/scroll_visibility_controller.js.map +1 -0
- package/dist/controllers/scrollspy_controller.d.ts +65 -0
- package/dist/controllers/scrollspy_controller.js +171 -0
- package/dist/controllers/scrollspy_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.d.ts +50 -0
- package/dist/controllers/skeleton_controller.js +125 -0
- package/dist/controllers/skeleton_controller.js.map +1 -0
- package/dist/controllers/slider_controller.d.ts +76 -0
- package/dist/controllers/slider_controller.js +109 -0
- package/dist/controllers/slider_controller.js.map +1 -0
- package/dist/controllers/spinner_controller.d.ts +62 -0
- package/dist/controllers/spinner_controller.js +164 -0
- package/dist/controllers/spinner_controller.js.map +1 -0
- package/dist/controllers/step_indicator_controller.d.ts +55 -0
- package/dist/controllers/step_indicator_controller.js +55 -0
- package/dist/controllers/step_indicator_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +66 -0
- package/dist/controllers/stepper_controller.js +78 -0
- package/dist/controllers/stepper_controller.js.map +1 -0
- package/dist/controllers/stick_to_bottom_controller.d.ts +61 -0
- package/dist/controllers/stick_to_bottom_controller.js +100 -0
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -0
- package/dist/controllers/sticky_observer_controller.d.ts +54 -0
- package/dist/controllers/sticky_observer_controller.js +53 -0
- package/dist/controllers/sticky_observer_controller.js.map +1 -0
- package/dist/controllers/submit_once_controller.d.ts +66 -0
- package/dist/controllers/submit_once_controller.js +206 -0
- package/dist/controllers/submit_once_controller.js.map +1 -0
- package/dist/controllers/switch_controller.d.ts +43 -0
- package/dist/controllers/switch_controller.js +50 -0
- package/dist/controllers/switch_controller.js.map +1 -0
- package/dist/controllers/tabs_controller.d.ts +48 -0
- package/dist/controllers/tabs_controller.js +63 -0
- package/dist/controllers/tabs_controller.js.map +1 -0
- package/dist/controllers/textarea_autosize_controller.d.ts +47 -0
- package/dist/controllers/textarea_autosize_controller.js +72 -0
- package/dist/controllers/textarea_autosize_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +70 -0
- package/dist/controllers/theme_controller.js +154 -0
- package/dist/controllers/theme_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +123 -0
- package/dist/controllers/toast_controller.js +310 -0
- package/dist/controllers/toast_controller.js.map +1 -0
- package/dist/controllers/toggle_group_controller.d.ts +56 -0
- package/dist/controllers/toggle_group_controller.js +130 -0
- package/dist/controllers/toggle_group_controller.js.map +1 -0
- package/dist/controllers/toolbar_controller.d.ts +59 -0
- package/dist/controllers/toolbar_controller.js +113 -0
- package/dist/controllers/toolbar_controller.js.map +1 -0
- package/dist/controllers/tooltip_controller.d.ts +74 -0
- package/dist/controllers/tooltip_controller.js +165 -0
- package/dist/controllers/tooltip_controller.js.map +1 -0
- package/dist/controllers/transition_controller.d.ts +86 -0
- package/dist/controllers/transition_controller.js +203 -0
- package/dist/controllers/transition_controller.js.map +1 -0
- package/dist/index.d.ts +1931 -0
- package/dist/index.js +12241 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector/cli.d.ts +149 -0
- package/dist/inspector/cli.js +663 -0
- package/dist/inspector/cli.js.map +1 -0
- package/dist/inspector/cli_bin.d.ts +2 -0
- package/dist/inspector/cli_bin.js +665 -0
- package/dist/inspector/cli_bin.js.map +1 -0
- package/dist/inspector/manifest.json +2235 -0
- package/dist/positioning/index.d.ts +226 -0
- package/dist/positioning/index.js +145 -0
- package/dist/positioning/index.js.map +1 -0
- package/package.json +93 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/toast_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/safe_timeout.ts
|
|
6
|
+
var TimerRegistry = class {
|
|
7
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
8
|
+
ids = /* @__PURE__ */ new Set();
|
|
9
|
+
/**
|
|
10
|
+
* Cancels a single tracked timer.
|
|
11
|
+
*
|
|
12
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
13
|
+
* registry), so callers can clear defensively without guarding.
|
|
14
|
+
*/
|
|
15
|
+
clear(id) {
|
|
16
|
+
if (this.ids.delete(id)) {
|
|
17
|
+
this.cancel(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
22
|
+
* to guarantee no timer outlives the element.
|
|
23
|
+
*/
|
|
24
|
+
clearAll() {
|
|
25
|
+
for (const id of this.ids) {
|
|
26
|
+
this.cancel(id);
|
|
27
|
+
}
|
|
28
|
+
this.ids.clear();
|
|
29
|
+
}
|
|
30
|
+
/** Number of timers currently tracked (pending). */
|
|
31
|
+
get size() {
|
|
32
|
+
return this.ids.size;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
36
|
+
/**
|
|
37
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
38
|
+
*
|
|
39
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
40
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
41
|
+
*/
|
|
42
|
+
set(callback, delay) {
|
|
43
|
+
const id = this.schedule(() => {
|
|
44
|
+
this.ids.delete(id);
|
|
45
|
+
callback();
|
|
46
|
+
}, delay);
|
|
47
|
+
this.ids.add(id);
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
schedule(callback, delay) {
|
|
51
|
+
return window.setTimeout(callback, delay);
|
|
52
|
+
}
|
|
53
|
+
cancel(id) {
|
|
54
|
+
window.clearTimeout(id);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/controllers/toast_controller.ts
|
|
59
|
+
var ToastController = class extends Controller {
|
|
60
|
+
static targets = ["list", "template", "item"];
|
|
61
|
+
static values = {
|
|
62
|
+
duration: { type: Number, default: 0 },
|
|
63
|
+
max: { type: Number, default: 3 }
|
|
64
|
+
};
|
|
65
|
+
static actions = ["dismiss", "onKeydown", "pause", "resume", "show"];
|
|
66
|
+
static events = ["dismiss", "show"];
|
|
67
|
+
/**
|
|
68
|
+
* Registry for every auto-dismiss and transition-finalize timer the controller
|
|
69
|
+
* schedules. {@link SafeTimeout} owns *registration and teardown only*; the
|
|
70
|
+
* pause/resume remaining-time accounting stays in `#activeTimeouts` so the
|
|
71
|
+
* per-widget WCAG 2.2.1 semantics are not flattened into the helper.
|
|
72
|
+
*/
|
|
73
|
+
#timers = new SafeTimeout();
|
|
74
|
+
/**
|
|
75
|
+
* Pending one-shot `requestAnimationFrame` handles (the entering→visible flip).
|
|
76
|
+
* Tracked so {@link disconnect} can cancel any that have not fired, preventing a
|
|
77
|
+
* detached element from being mutated after it leaves the DOM (Turbo).
|
|
78
|
+
*/
|
|
79
|
+
#rafHandles = /* @__PURE__ */ new Set();
|
|
80
|
+
/** Track active timeouts mapped by each toast element for safe cancellation. */
|
|
81
|
+
#activeTimeouts = /* @__PURE__ */ new Map();
|
|
82
|
+
/** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */
|
|
83
|
+
#pauseReasons = /* @__PURE__ */ new Map();
|
|
84
|
+
connect() {
|
|
85
|
+
this.enforceMaxLimit();
|
|
86
|
+
for (const item of this.itemTargets) {
|
|
87
|
+
if (!this.#activeTimeouts.has(item)) {
|
|
88
|
+
this.#startTimer(item);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
disconnect() {
|
|
93
|
+
this.#timers.clearAll();
|
|
94
|
+
for (const handle of this.#rafHandles) {
|
|
95
|
+
window.cancelAnimationFrame(handle);
|
|
96
|
+
}
|
|
97
|
+
this.#rafHandles.clear();
|
|
98
|
+
this.#activeTimeouts.clear();
|
|
99
|
+
this.#pauseReasons.clear();
|
|
100
|
+
}
|
|
101
|
+
durationValueChanged() {
|
|
102
|
+
if (this.durationValue > 0) {
|
|
103
|
+
for (const item of this.itemTargets) {
|
|
104
|
+
if (!this.#activeTimeouts.has(item)) {
|
|
105
|
+
this.#startTimer(item);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
maxValueChanged() {
|
|
111
|
+
this.enforceMaxLimit();
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Stimulus lifecycle callback triggered automatically when a new item target
|
|
115
|
+
* enters the DOM. Perfectly handles dynamic client-side injections and server-side
|
|
116
|
+
* Turbo Stream appends alike.
|
|
117
|
+
*/
|
|
118
|
+
itemTargetConnected(element) {
|
|
119
|
+
this.enforceMaxLimit();
|
|
120
|
+
this.#startTimer(element);
|
|
121
|
+
element.setAttribute("data-state", "entering");
|
|
122
|
+
const handle = window.requestAnimationFrame(() => {
|
|
123
|
+
this.#rafHandles.delete(handle);
|
|
124
|
+
element.setAttribute("data-state", "visible");
|
|
125
|
+
});
|
|
126
|
+
this.#rafHandles.add(handle);
|
|
127
|
+
}
|
|
128
|
+
/** Clears any active timer when a toast is removed from the DOM. */
|
|
129
|
+
itemTargetDisconnected(element) {
|
|
130
|
+
this.#clearTimer(element);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Shows a new toast. Accepts its content from either a Stimulus action param
|
|
134
|
+
* (attribute-only trigger) or a programmatic `show` CustomEvent `detail`
|
|
135
|
+
* (remote / Turbo trigger); the action param wins when both are present.
|
|
136
|
+
*
|
|
137
|
+
* <button data-action="click->stimeo--toast#show"
|
|
138
|
+
* data-stimeo--toast-body-param="Saved"
|
|
139
|
+
* data-stimeo--toast-type-param="status">Show</button>
|
|
140
|
+
*
|
|
141
|
+
* element.dispatchEvent(new CustomEvent("show", { detail: { body, type } }))
|
|
142
|
+
*
|
|
143
|
+
* Clones the template slot, interpolates the body text, and appends to the list.
|
|
144
|
+
*/
|
|
145
|
+
show(event) {
|
|
146
|
+
if (!this.hasTemplateTarget || !this.hasListTarget) return;
|
|
147
|
+
const body = this.#readField(event, "body");
|
|
148
|
+
if (!body) return;
|
|
149
|
+
const clone = this.templateTarget.content.cloneNode(true);
|
|
150
|
+
const item = clone.querySelector("[data-stimeo--toast-target='item']");
|
|
151
|
+
if (!item) return;
|
|
152
|
+
const bodySlot = item.querySelector("[data-toast-slot='body']");
|
|
153
|
+
if (bodySlot) {
|
|
154
|
+
bodySlot.textContent = body;
|
|
155
|
+
}
|
|
156
|
+
item.setAttribute("role", this.#readField(event, "type") === "alert" ? "alert" : "status");
|
|
157
|
+
this.listTarget.appendChild(item);
|
|
158
|
+
this.dispatch("show", { detail: { item } });
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Reads a string field from a Stimulus action param or a CustomEvent `detail`,
|
|
162
|
+
* preferring the action param. Returns null unless a non-empty string is found,
|
|
163
|
+
* so untrusted runtime payloads cannot inject non-string values.
|
|
164
|
+
*/
|
|
165
|
+
#readField(event, key) {
|
|
166
|
+
const params = event.params;
|
|
167
|
+
const fromParams = params?.[key];
|
|
168
|
+
if (typeof fromParams === "string" && fromParams.length > 0) return fromParams;
|
|
169
|
+
const detail = event.detail;
|
|
170
|
+
if (detail && typeof detail === "object" && key in detail) {
|
|
171
|
+
const value = detail[key];
|
|
172
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
/** Dismisses the toast that contained the trigger. */
|
|
177
|
+
dismiss(event) {
|
|
178
|
+
const target = event.currentTarget || event.target;
|
|
179
|
+
if (!target) return;
|
|
180
|
+
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
181
|
+
if (!item) return;
|
|
182
|
+
this.#removeWithTransition(item, "user");
|
|
183
|
+
}
|
|
184
|
+
/** Dismisses the focused toast when Escape is pressed. */
|
|
185
|
+
onKeydown(event) {
|
|
186
|
+
if (event.key === "Escape") {
|
|
187
|
+
const target = event.currentTarget || event.target;
|
|
188
|
+
if (!target) return;
|
|
189
|
+
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
190
|
+
if (!item) return;
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
this.#removeWithTransition(item, "user");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Pauses the auto-dismiss timer on mouse entry or keyboard focus.
|
|
197
|
+
*
|
|
198
|
+
* Hover and focus are tracked as independent reasons: the timer is only
|
|
199
|
+
* snapshotted on the first active reason, and only resumed once *every*
|
|
200
|
+
* reason has been released (see {@link resume}). This keeps a toast paused
|
|
201
|
+
* while it is still hovered *or* focused, per WCAG 2.2.1.
|
|
202
|
+
*/
|
|
203
|
+
pause(event) {
|
|
204
|
+
const item = this.#itemFromEvent(event);
|
|
205
|
+
if (!item || this.durationValue <= 0) return;
|
|
206
|
+
const timeout = this.#activeTimeouts.get(item);
|
|
207
|
+
if (!timeout) return;
|
|
208
|
+
const reasons = this.#pauseReasonsFor(item);
|
|
209
|
+
const wasActive = reasons.size > 0;
|
|
210
|
+
reasons.add(this.#pauseReason(event));
|
|
211
|
+
if (wasActive || timeout.id === 0) return;
|
|
212
|
+
this.#timers.clear(timeout.id);
|
|
213
|
+
const elapsed = Date.now() - timeout.startedAt;
|
|
214
|
+
const remaining = Math.max(0, timeout.remaining - elapsed);
|
|
215
|
+
this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });
|
|
216
|
+
item.setAttribute("data-paused", "true");
|
|
217
|
+
}
|
|
218
|
+
/** Resumes the auto-dismiss timer once both hover and focus have been released. */
|
|
219
|
+
resume(event) {
|
|
220
|
+
const item = this.#itemFromEvent(event);
|
|
221
|
+
if (!item || this.durationValue <= 0) return;
|
|
222
|
+
const reasons = this.#pauseReasonsFor(item);
|
|
223
|
+
reasons.delete(this.#pauseReason(event));
|
|
224
|
+
if (reasons.size > 0) return;
|
|
225
|
+
const timeout = this.#activeTimeouts.get(item);
|
|
226
|
+
if (!timeout || timeout.remaining <= 0) return;
|
|
227
|
+
item.removeAttribute("data-paused");
|
|
228
|
+
this.#startTimer(item, timeout.remaining);
|
|
229
|
+
}
|
|
230
|
+
/** Resolves the toast item element a pause/resume event targets. */
|
|
231
|
+
#itemFromEvent(event) {
|
|
232
|
+
const target = event.currentTarget || event.target;
|
|
233
|
+
return target?.closest("[data-stimeo--toast-target='item']") ?? null;
|
|
234
|
+
}
|
|
235
|
+
/** Classifies a pause/resume event as a hover or focus reason. */
|
|
236
|
+
#pauseReason(event) {
|
|
237
|
+
return event.type === "focusin" || event.type === "focusout" ? "focus" : "hover";
|
|
238
|
+
}
|
|
239
|
+
/** Lazily creates and returns the active pause-reason set for an item. */
|
|
240
|
+
#pauseReasonsFor(item) {
|
|
241
|
+
let reasons = this.#pauseReasons.get(item);
|
|
242
|
+
if (!reasons) {
|
|
243
|
+
reasons = /* @__PURE__ */ new Set();
|
|
244
|
+
this.#pauseReasons.set(item, reasons);
|
|
245
|
+
}
|
|
246
|
+
return reasons;
|
|
247
|
+
}
|
|
248
|
+
#startTimer(element, duration = this.durationValue) {
|
|
249
|
+
if (duration <= 0) return;
|
|
250
|
+
this.#clearTimer(element);
|
|
251
|
+
const id = this.#timers.set(() => {
|
|
252
|
+
this.#removeWithTransition(element, "timeout");
|
|
253
|
+
}, duration);
|
|
254
|
+
this.#activeTimeouts.set(element, { id, startedAt: Date.now(), remaining: duration });
|
|
255
|
+
}
|
|
256
|
+
#clearTimer(element) {
|
|
257
|
+
const timeout = this.#activeTimeouts.get(element);
|
|
258
|
+
if (timeout) {
|
|
259
|
+
if (timeout.id) this.#timers.clear(timeout.id);
|
|
260
|
+
this.#activeTimeouts.delete(element);
|
|
261
|
+
}
|
|
262
|
+
this.#pauseReasons.delete(element);
|
|
263
|
+
}
|
|
264
|
+
#removeWithTransition(element, reason) {
|
|
265
|
+
this.#clearTimer(element);
|
|
266
|
+
element.setAttribute("data-state", "leaving");
|
|
267
|
+
const finalize = () => {
|
|
268
|
+
if (element.parentNode === this.listTarget) {
|
|
269
|
+
this.listTarget.removeChild(element);
|
|
270
|
+
}
|
|
271
|
+
this.dispatch("dismiss", { detail: { item: element, reason } });
|
|
272
|
+
};
|
|
273
|
+
const transitions = window.getComputedStyle(element).transitionDuration;
|
|
274
|
+
const duration = cssTimeToMs(transitions);
|
|
275
|
+
if (duration > 0) {
|
|
276
|
+
this.#timers.set(finalize, duration);
|
|
277
|
+
} else {
|
|
278
|
+
finalize();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Removes the oldest toasts when the list exceeds `maxValue`.
|
|
283
|
+
*
|
|
284
|
+
* Public (not `#private`) as a deterministic test seam: enforcement normally
|
|
285
|
+
* runs from `itemTargetConnected`, a Stimulus callback that fires via a
|
|
286
|
+
* MutationObserver — which happy-dom does not reliably deliver — so the unit
|
|
287
|
+
* tests invoke it directly. It is therefore listed in the contract guard's
|
|
288
|
+
* NON_ACTION_ALLOWLIST (it is not a user-wired action).
|
|
289
|
+
*/
|
|
290
|
+
enforceMaxLimit() {
|
|
291
|
+
const currentItems = this.itemTargets;
|
|
292
|
+
if (currentItems.length > this.maxValue) {
|
|
293
|
+
const excessCount = currentItems.length - this.maxValue;
|
|
294
|
+
for (let i = 0; i < excessCount; i++) {
|
|
295
|
+
const oldest = currentItems[i];
|
|
296
|
+
if (oldest) this.#removeWithTransition(oldest, "timeout");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
function cssTimeToMs(value) {
|
|
302
|
+
const first = value.split(",")[0]?.trim() ?? "";
|
|
303
|
+
const amount = Number.parseFloat(first);
|
|
304
|
+
if (Number.isNaN(amount)) return 0;
|
|
305
|
+
return first.endsWith("ms") ? amount : amount * 1e3;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export { ToastController, cssTimeToMs };
|
|
309
|
+
//# sourceMappingURL=toast_controller.js.map
|
|
310
|
+
//# sourceMappingURL=toast_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/toast_controller.ts"],"names":[],"mappings":"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","file":"toast_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless, highly accessible toast notification behavior.\n *\n * Markup contract (identifier: `stimeo--toast`):\n * <div data-controller=\"stimeo--toast\"\n * data-stimeo--toast-duration-value=\"5000\"\n * data-stimeo--toast-max-value=\"3\">\n * <!-- Attribute-only trigger: no hand-written JS required. -->\n * <button data-action=\"click->stimeo--toast#show\"\n * data-stimeo--toast-body-param=\"Saved\"\n * data-stimeo--toast-type-param=\"status\">Show</button>\n * <!-- The live region is a descendant; the controller element only needs\n * to enclose both the trigger(s) and the list/template targets. -->\n * <div role=\"region\" aria-label=\"Notifications\">\n * <ol data-stimeo--toast-target=\"list\"></ol>\n * <template data-stimeo--toast-target=\"template\">\n * <li role=\"status\" data-stimeo--toast-target=\"item\"\n * data-action=\"mouseenter->stimeo--toast#pause\n * mouseleave->stimeo--toast#resume\n * focusin->stimeo--toast#pause\n * focusout->stimeo--toast#resume\n * keydown->stimeo--toast#onKeydown\"\n * tabindex=\"0\">\n * <span data-toast-slot=\"body\"></span>\n * <button type=\"button\" data-action=\"stimeo--toast#dismiss\">Dismiss</button>\n * </li>\n * </template>\n * </div>\n * </div>\n *\n * Implements WAI-ARIA live region status/alert announcements, limits simultaneous\n * elements, and pauses dismiss timeouts on hover or focus to comply with WCAG 2.2.1.\n *\n * @remarks\n * Behavior only. The controller handles state updates via `data-state` and lifecycle events\n * while leaving visual styling completely to the client's CSS transitions.\n */\nexport class ToastController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"template\", \"item\"];\n static override values = {\n duration: { type: Number, default: 0 },\n max: { type: Number, default: 3 },\n };\n static actions = [\"dismiss\", \"onKeydown\", \"pause\", \"resume\", \"show\"] as const;\n static events = [\"dismiss\", \"show\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly templateTarget: HTMLTemplateElement;\n declare readonly itemTargets: HTMLElement[];\n declare readonly hasListTarget: boolean;\n declare readonly hasTemplateTarget: boolean;\n\n declare durationValue: number;\n declare maxValue: number;\n\n /**\n * Registry for every auto-dismiss and transition-finalize timer the controller\n * schedules. {@link SafeTimeout} owns *registration and teardown only*; the\n * pause/resume remaining-time accounting stays in `#activeTimeouts` so the\n * per-widget WCAG 2.2.1 semantics are not flattened into the helper.\n */\n #timers = new SafeTimeout();\n\n /**\n * Pending one-shot `requestAnimationFrame` handles (the entering→visible flip).\n * Tracked so {@link disconnect} can cancel any that have not fired, preventing a\n * detached element from being mutated after it leaves the DOM (Turbo).\n */\n #rafHandles = new Set<number>();\n\n /** Track active timeouts mapped by each toast element for safe cancellation. */\n #activeTimeouts = new Map<HTMLElement, { id: number; startedAt: number; remaining: number }>();\n\n /** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */\n #pauseReasons = new Map<HTMLElement, Set<string>>();\n\n override connect(): void {\n this.enforceMaxLimit();\n for (const item of this.itemTargets) {\n if (!this.#activeTimeouts.has(item)) {\n this.#startTimer(item);\n }\n }\n }\n\n override disconnect(): void {\n // SafeTimeout owns every auto-dismiss + finalize timer; one call tears them\n // all down so none fires against the detached controller.\n this.#timers.clearAll();\n for (const handle of this.#rafHandles) {\n window.cancelAnimationFrame(handle);\n }\n this.#rafHandles.clear();\n this.#activeTimeouts.clear();\n this.#pauseReasons.clear();\n }\n\n durationValueChanged(): void {\n if (this.durationValue > 0) {\n for (const item of this.itemTargets) {\n if (!this.#activeTimeouts.has(item)) {\n this.#startTimer(item);\n }\n }\n }\n }\n\n maxValueChanged(): void {\n this.enforceMaxLimit();\n }\n\n /**\n * Stimulus lifecycle callback triggered automatically when a new item target\n * enters the DOM. Perfectly handles dynamic client-side injections and server-side\n * Turbo Stream appends alike.\n */\n itemTargetConnected(element: HTMLElement): void {\n this.enforceMaxLimit();\n this.#startTimer(element);\n element.setAttribute(\"data-state\", \"entering\");\n const handle = window.requestAnimationFrame(() => {\n this.#rafHandles.delete(handle);\n element.setAttribute(\"data-state\", \"visible\");\n });\n this.#rafHandles.add(handle);\n }\n\n /** Clears any active timer when a toast is removed from the DOM. */\n itemTargetDisconnected(element: HTMLElement): void {\n this.#clearTimer(element);\n }\n\n /**\n * Shows a new toast. Accepts its content from either a Stimulus action param\n * (attribute-only trigger) or a programmatic `show` CustomEvent `detail`\n * (remote / Turbo trigger); the action param wins when both are present.\n *\n * <button data-action=\"click->stimeo--toast#show\"\n * data-stimeo--toast-body-param=\"Saved\"\n * data-stimeo--toast-type-param=\"status\">Show</button>\n *\n * element.dispatchEvent(new CustomEvent(\"show\", { detail: { body, type } }))\n *\n * Clones the template slot, interpolates the body text, and appends to the list.\n */\n show(event: Event): void {\n if (!this.hasTemplateTarget || !this.hasListTarget) return;\n\n const body = this.#readField(event, \"body\");\n if (!body) return;\n\n const clone = this.templateTarget.content.cloneNode(true) as DocumentFragment;\n const item = clone.querySelector(\"[data-stimeo--toast-target='item']\") as HTMLElement | null;\n if (!item) return;\n\n const bodySlot = item.querySelector(\"[data-toast-slot='body']\");\n if (bodySlot) {\n bodySlot.textContent = body;\n }\n\n // Validate the live-region role at runtime; untrusted params/detail could\n // otherwise write an invalid ARIA role. Anything but \"alert\" stays polite.\n item.setAttribute(\"role\", this.#readField(event, \"type\") === \"alert\" ? \"alert\" : \"status\");\n\n this.listTarget.appendChild(item);\n this.dispatch(\"show\", { detail: { item } });\n }\n\n /**\n * Reads a string field from a Stimulus action param or a CustomEvent `detail`,\n * preferring the action param. Returns null unless a non-empty string is found,\n * so untrusted runtime payloads cannot inject non-string values.\n */\n #readField(event: Event, key: \"body\" | \"type\"): string | null {\n const params = (event as { params?: Record<string, unknown> }).params;\n const fromParams = params?.[key];\n if (typeof fromParams === \"string\" && fromParams.length > 0) return fromParams;\n\n const detail = (event as CustomEvent<unknown>).detail;\n if (detail && typeof detail === \"object\" && key in detail) {\n const value = (detail as Record<string, unknown>)[key];\n if (typeof value === \"string\" && value.length > 0) return value;\n }\n return null;\n }\n\n /** Dismisses the toast that contained the trigger. */\n dismiss(event: Event): void {\n const target = (event.currentTarget || event.target) as HTMLElement | null;\n if (!target) return;\n const item = target.closest(\"[data-stimeo--toast-target='item']\") as HTMLElement | null;\n if (!item) return;\n\n this.#removeWithTransition(item, \"user\");\n }\n\n /** Dismisses the focused toast when Escape is pressed. */\n onKeydown(event: KeyboardEvent): void {\n if (event.key === \"Escape\") {\n const target = (event.currentTarget || event.target) as HTMLElement | null;\n if (!target) return;\n const item = target.closest(\"[data-stimeo--toast-target='item']\") as HTMLElement | null;\n if (!item) return;\n\n event.preventDefault();\n this.#removeWithTransition(item, \"user\");\n }\n }\n\n /**\n * Pauses the auto-dismiss timer on mouse entry or keyboard focus.\n *\n * Hover and focus are tracked as independent reasons: the timer is only\n * snapshotted on the first active reason, and only resumed once *every*\n * reason has been released (see {@link resume}). This keeps a toast paused\n * while it is still hovered *or* focused, per WCAG 2.2.1.\n */\n pause(event: Event): void {\n const item = this.#itemFromEvent(event);\n if (!item || this.durationValue <= 0) return;\n\n const timeout = this.#activeTimeouts.get(item);\n if (!timeout) return;\n\n const reasons = this.#pauseReasonsFor(item);\n const wasActive = reasons.size > 0;\n reasons.add(this.#pauseReason(event));\n\n // Only snapshot the remaining time on the first reason; subsequent reasons\n // must not recompute elapsed against the already-cleared timer.\n if (wasActive || timeout.id === 0) return;\n\n this.#timers.clear(timeout.id);\n const elapsed = Date.now() - timeout.startedAt;\n const remaining = Math.max(0, timeout.remaining - elapsed);\n\n this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });\n item.setAttribute(\"data-paused\", \"true\");\n }\n\n /** Resumes the auto-dismiss timer once both hover and focus have been released. */\n resume(event: Event): void {\n const item = this.#itemFromEvent(event);\n if (!item || this.durationValue <= 0) return;\n\n const reasons = this.#pauseReasonsFor(item);\n reasons.delete(this.#pauseReason(event));\n // Still paused by the other reason (e.g. mouse left but focus remains).\n if (reasons.size > 0) return;\n\n const timeout = this.#activeTimeouts.get(item);\n if (!timeout || timeout.remaining <= 0) return;\n\n item.removeAttribute(\"data-paused\");\n this.#startTimer(item, timeout.remaining);\n }\n\n /** Resolves the toast item element a pause/resume event targets. */\n #itemFromEvent(event: Event): HTMLElement | null {\n const target = (event.currentTarget || event.target) as HTMLElement | null;\n return target?.closest(\"[data-stimeo--toast-target='item']\") ?? null;\n }\n\n /** Classifies a pause/resume event as a hover or focus reason. */\n #pauseReason(event: Event): \"focus\" | \"hover\" {\n return event.type === \"focusin\" || event.type === \"focusout\" ? \"focus\" : \"hover\";\n }\n\n /** Lazily creates and returns the active pause-reason set for an item. */\n #pauseReasonsFor(item: HTMLElement): Set<string> {\n let reasons = this.#pauseReasons.get(item);\n if (!reasons) {\n reasons = new Set<string>();\n this.#pauseReasons.set(item, reasons);\n }\n return reasons;\n }\n\n #startTimer(element: HTMLElement, duration = this.durationValue): void {\n if (duration <= 0) return;\n\n this.#clearTimer(element);\n const id = this.#timers.set(() => {\n this.#removeWithTransition(element, \"timeout\");\n }, duration);\n\n this.#activeTimeouts.set(element, { id, startedAt: Date.now(), remaining: duration });\n }\n\n #clearTimer(element: HTMLElement): void {\n const timeout = this.#activeTimeouts.get(element);\n if (timeout) {\n if (timeout.id) this.#timers.clear(timeout.id);\n this.#activeTimeouts.delete(element);\n }\n this.#pauseReasons.delete(element);\n }\n\n #removeWithTransition(element: HTMLElement, reason: \"timeout\" | \"user\"): void {\n this.#clearTimer(element);\n element.setAttribute(\"data-state\", \"leaving\");\n\n const finalize = () => {\n if (element.parentNode === this.listTarget) {\n this.listTarget.removeChild(element);\n }\n this.dispatch(\"dismiss\", { detail: { item: element, reason } });\n };\n\n const transitions = window.getComputedStyle(element).transitionDuration;\n const duration = cssTimeToMs(transitions);\n if (duration > 0) {\n this.#timers.set(finalize, duration);\n } else {\n finalize();\n }\n }\n\n /**\n * Removes the oldest toasts when the list exceeds `maxValue`.\n *\n * Public (not `#private`) as a deterministic test seam: enforcement normally\n * runs from `itemTargetConnected`, a Stimulus callback that fires via a\n * MutationObserver — which happy-dom does not reliably deliver — so the unit\n * tests invoke it directly. It is therefore listed in the contract guard's\n * NON_ACTION_ALLOWLIST (it is not a user-wired action).\n */\n enforceMaxLimit(): void {\n const currentItems = this.itemTargets;\n if (currentItems.length > this.maxValue) {\n const excessCount = currentItems.length - this.maxValue;\n for (let i = 0; i < excessCount; i++) {\n const oldest = currentItems[i];\n if (oldest) this.#removeWithTransition(oldest, \"timeout\");\n }\n }\n }\n}\n\n/**\n * Converts the first value of a CSS `transition-duration` string to milliseconds.\n * Browsers normalize computed `<time>` values to seconds (e.g. `0.2s`), but parse\n * `ms` defensively so a consumer's `ms` transition is not delayed 1000x.\n *\n * Pure (no `this`); exported so it can be unit-tested directly.\n */\nexport function cssTimeToMs(value: string): number {\n const first = value.split(\",\")[0]?.trim() ?? \"\";\n const amount = Number.parseFloat(first);\n if (Number.isNaN(amount)) return 0;\n return first.endsWith(\"ms\") ? amount : amount * 1000;\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible toggle-button group behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--toggle-group`):
|
|
7
|
+
* <div data-controller="stimeo--toggle-group"
|
|
8
|
+
* data-stimeo--toggle-group-mode-value="single"
|
|
9
|
+
* role="group" aria-label="Text style">
|
|
10
|
+
* <button type="button" aria-pressed="true" tabindex="0" data-value="bold"
|
|
11
|
+
* data-stimeo--toggle-group-target="item"
|
|
12
|
+
* data-action="click->stimeo--toggle-group#toggle
|
|
13
|
+
* keydown->stimeo--toggle-group#onKeydown">Bold</button>
|
|
14
|
+
* <!-- more items; exactly one tabindex=0 -->
|
|
15
|
+
* </div>
|
|
16
|
+
*
|
|
17
|
+
* Implements the WAI-ARIA APG **Button (toggle)** pattern with **Toolbar**-style
|
|
18
|
+
* roving navigation. Each item's pressed state is `aria-pressed` (the accessible
|
|
19
|
+
* name never changes); the group is a single Tab stop. For strict mutually
|
|
20
|
+
* exclusive selection with radio semantics, use
|
|
21
|
+
* {@link RadioGroupController | Radio Group} instead.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* Behavior only — the consumer styles off `[aria-pressed="true"]`. Per the
|
|
25
|
+
* Toolbar model the arrow keys move **focus only**; activation is Space/Enter
|
|
26
|
+
* (handled natively on `<button>` hosts, which synthesize a click).
|
|
27
|
+
*
|
|
28
|
+
* Behavior provided:
|
|
29
|
+
* - Click (or Space/Enter) toggles an item. In `single` mode pressing one item
|
|
30
|
+
* releases the others (0 or 1 pressed); `multiple` allows any number.
|
|
31
|
+
* - `ArrowRight`/`ArrowDown` move focus to the next item, `ArrowLeft`/`ArrowUp`
|
|
32
|
+
* to the previous (wrapping); `Home`/`End` to the first/last.
|
|
33
|
+
* - `stimeo--toggle-group:change` is dispatched on every toggle.
|
|
34
|
+
*/
|
|
35
|
+
declare class ToggleGroupController extends Controller<HTMLElement> {
|
|
36
|
+
#private;
|
|
37
|
+
static targets: string[];
|
|
38
|
+
static values: {
|
|
39
|
+
mode: {
|
|
40
|
+
type: StringConstructor;
|
|
41
|
+
default: string;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
static actions: readonly ["onKeydown", "toggle"];
|
|
45
|
+
static events: readonly ["change"];
|
|
46
|
+
readonly itemTargets: HTMLElement[];
|
|
47
|
+
modeValue: string;
|
|
48
|
+
/** Establishes the roving entry point (first pressed item, else the first). */
|
|
49
|
+
connect(): void;
|
|
50
|
+
/** Toggles the activated item. Bound via `data-action` (click). */
|
|
51
|
+
toggle(event: Event): void;
|
|
52
|
+
/** Arrow/Home/End move focus only; Space/Enter toggle non-button hosts. */
|
|
53
|
+
onKeydown(event: KeyboardEvent): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { ToggleGroupController };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/toggle_group_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/roving_tabindex.ts
|
|
6
|
+
var RovingTabindex = class {
|
|
7
|
+
/** Returns the current ordered item elements; called on every operation. */
|
|
8
|
+
#getItems;
|
|
9
|
+
/**
|
|
10
|
+
* @param getItems - Returns the current ordered item elements. Called on every
|
|
11
|
+
* operation so the live target list is always used.
|
|
12
|
+
*/
|
|
13
|
+
constructor(getItems) {
|
|
14
|
+
this.#getItems = getItems;
|
|
15
|
+
}
|
|
16
|
+
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
17
|
+
get activeIndex() {
|
|
18
|
+
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
22
|
+
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
23
|
+
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
24
|
+
* "nothing is currently tabbable".
|
|
25
|
+
*
|
|
26
|
+
* @param index - Position of the item to make tabbable.
|
|
27
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item.
|
|
28
|
+
*/
|
|
29
|
+
setActive(index, { focus = false } = {}) {
|
|
30
|
+
const items = this.#getItems();
|
|
31
|
+
items.forEach((item, i) => {
|
|
32
|
+
item.tabIndex = i === index ? 0 : -1;
|
|
33
|
+
});
|
|
34
|
+
if (focus) items[index]?.focus();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
function rovingMove(current, length, delta, wrap) {
|
|
38
|
+
if (length === 0) return -1;
|
|
39
|
+
const next = current + delta;
|
|
40
|
+
return (next + length) % length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/controllers/toggle_group_controller.ts
|
|
44
|
+
var ToggleGroupController = class extends Controller {
|
|
45
|
+
static targets = ["item"];
|
|
46
|
+
static values = {
|
|
47
|
+
mode: { type: String, default: "multiple" }
|
|
48
|
+
};
|
|
49
|
+
static actions = ["onKeydown", "toggle"];
|
|
50
|
+
static events = ["change"];
|
|
51
|
+
#roving = new RovingTabindex(() => this.itemTargets);
|
|
52
|
+
/** Establishes the roving entry point (first pressed item, else the first). */
|
|
53
|
+
connect() {
|
|
54
|
+
const firstPressed = this.itemTargets.findIndex((item) => this.#isPressed(item));
|
|
55
|
+
this.#roving.setActive(firstPressed === -1 ? 0 : firstPressed);
|
|
56
|
+
}
|
|
57
|
+
/** Toggles the activated item. Bound via `data-action` (click). */
|
|
58
|
+
toggle(event) {
|
|
59
|
+
this.#toggleIndex(this.itemTargets.indexOf(event.currentTarget));
|
|
60
|
+
}
|
|
61
|
+
/** Arrow/Home/End move focus only; Space/Enter toggle non-button hosts. */
|
|
62
|
+
onKeydown(event) {
|
|
63
|
+
const current = this.itemTargets.indexOf(event.currentTarget);
|
|
64
|
+
if (current === -1) return;
|
|
65
|
+
let next = null;
|
|
66
|
+
switch (event.key) {
|
|
67
|
+
case "ArrowRight":
|
|
68
|
+
case "ArrowDown":
|
|
69
|
+
next = rovingMove(current, this.itemTargets.length, 1);
|
|
70
|
+
break;
|
|
71
|
+
case "ArrowLeft":
|
|
72
|
+
case "ArrowUp":
|
|
73
|
+
next = rovingMove(current, this.itemTargets.length, -1);
|
|
74
|
+
break;
|
|
75
|
+
case "Home":
|
|
76
|
+
next = 0;
|
|
77
|
+
break;
|
|
78
|
+
case "End":
|
|
79
|
+
next = this.itemTargets.length - 1;
|
|
80
|
+
break;
|
|
81
|
+
case " ":
|
|
82
|
+
case "Enter":
|
|
83
|
+
if (event.currentTarget instanceof HTMLButtonElement) return;
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
this.#toggleIndex(current);
|
|
86
|
+
return;
|
|
87
|
+
default:
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
this.#roving.setActive(next, { focus: true });
|
|
92
|
+
}
|
|
93
|
+
/** Applies the toggle at `index` per the current mode and dispatches `change`. */
|
|
94
|
+
#toggleIndex(index) {
|
|
95
|
+
const item = this.itemTargets[index];
|
|
96
|
+
if (!item) return;
|
|
97
|
+
const willPress = !this.#isPressed(item);
|
|
98
|
+
if (this.modeValue === "single") {
|
|
99
|
+
this.itemTargets.forEach((other, i) => {
|
|
100
|
+
this.#setPressed(other, i === index && willPress);
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
this.#setPressed(item, willPress);
|
|
104
|
+
}
|
|
105
|
+
this.#roving.setActive(index);
|
|
106
|
+
this.dispatch("change", {
|
|
107
|
+
detail: { value: this.#itemValue(item), pressed: willPress, values: this.#pressedValues() }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Whether an item is currently pressed. */
|
|
111
|
+
#isPressed(item) {
|
|
112
|
+
return item.getAttribute("aria-pressed") === "true";
|
|
113
|
+
}
|
|
114
|
+
/** Reflects the pressed state on `aria-pressed`. */
|
|
115
|
+
#setPressed(item, pressed) {
|
|
116
|
+
item.setAttribute("aria-pressed", pressed ? "true" : "false");
|
|
117
|
+
}
|
|
118
|
+
/** The `data-value` of every currently pressed item. */
|
|
119
|
+
#pressedValues() {
|
|
120
|
+
return this.itemTargets.filter((item) => this.#isPressed(item)).map((item) => this.#itemValue(item));
|
|
121
|
+
}
|
|
122
|
+
/** An item's value (`data-value`, defaulting to empty). */
|
|
123
|
+
#itemValue(item) {
|
|
124
|
+
return item.getAttribute("data-value") ?? "";
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export { ToggleGroupController };
|
|
129
|
+
//# sourceMappingURL=toggle_group_controller.js.map
|
|
130
|
+
//# sourceMappingURL=toggle_group_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/roving_tabindex.ts","../../src/controllers/toggle_group_controller.ts"],"names":[],"mappings":";;;;;AAqBO,IAAM,iBAAN,MAAqB;AAAA;AAAA,EAEjB,SAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMT,YAAY,QAAA,EAA+B;AACzC,IAAA,IAAA,CAAK,SAAA,GAAY,QAAA;AAAA,EACnB;AAAA;AAAA,EAGA,IAAI,WAAA,GAAsB;AACxB,IAAA,OAAO,IAAA,CAAK,WAAU,CAAE,SAAA,CAAU,CAAC,IAAA,KAAS,IAAA,CAAK,aAAa,CAAC,CAAA;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,UAAU,KAAA,EAAe,EAAE,QAAQ,KAAA,EAAM,GAAyB,EAAC,EAAS;AAC1E,IAAA,MAAM,KAAA,GAAQ,KAAK,SAAA,EAAU;AAC7B,IAAA,KAAA,CAAM,OAAA,CAAQ,CAAC,IAAA,EAAM,CAAA,KAAM;AACzB,MAAA,IAAA,CAAK,QAAA,GAAW,CAAA,KAAM,KAAA,GAAQ,CAAA,GAAI,EAAA;AAAA,IACpC,CAAC,CAAA;AACD,IAAA,IAAI,KAAA,EAAO,KAAA,CAAM,KAAK,CAAA,EAAG,KAAA,EAAM;AAAA,EACjC;AACF,CAAA;AAkBO,SAAS,UAAA,CACd,OAAA,EACA,MAAA,EACA,KAAA,EACA,IAAA,EACQ;AACR,EAAA,IAAI,MAAA,KAAW,GAAG,OAAO,EAAA;AACzB,EAAA,MAAM,OAAO,OAAA,GAAU,KAAA;AACvB,EAAqB,OAAA,CAAQ,IAAA,GAAO,MAAA,IAAU,MAAA;AAEhD;;;AC/CO,IAAM,qBAAA,GAAN,cAAoC,UAAA,CAAwB;AAAA,EACjE,OAAgB,OAAA,GAAU,CAAC,MAAM,CAAA;AAAA,EACjC,OAAgB,MAAA,GAAS;AAAA,IACvB,IAAA,EAAM,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,UAAA;AAAW,GAC5C;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,QAAQ,CAAA;AAAA,EACvC,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA,EAKhB,OAAA,GAAU,IAAI,cAAA,CAAe,MAAM,KAAK,WAAW,CAAA;AAAA;AAAA,EAGnD,OAAA,GAAgB;AACvB,IAAA,MAAM,YAAA,GAAe,KAAK,WAAA,CAAY,SAAA,CAAU,CAAC,IAAA,KAAS,IAAA,CAAK,UAAA,CAAW,IAAI,CAAC,CAAA;AAC/E,IAAA,IAAA,CAAK,OAAA,CAAQ,SAAA,CAAU,YAAA,KAAiB,EAAA,GAAK,IAAI,YAAY,CAAA;AAAA,EAC/D;AAAA;AAAA,EAGA,OAAO,KAAA,EAAoB;AACzB,IAAA,IAAA,CAAK,aAAa,IAAA,CAAK,WAAA,CAAY,OAAA,CAAQ,KAAA,CAAM,aAA4B,CAAC,CAAA;AAAA,EAChF;AAAA;AAAA,EAGA,UAAU,KAAA,EAA4B;AACpC,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,WAAA,CAAY,OAAA,CAAQ,MAAM,aAA4B,CAAA;AAC3E,IAAA,IAAI,YAAY,EAAA,EAAI;AAEpB,IAAA,IAAI,IAAA,GAAsB,IAAA;AAC1B,IAAA,QAAQ,MAAM,GAAA;AAAK,MACjB,KAAK,YAAA;AAAA,MACL,KAAK,WAAA;AACH,QAAA,IAAA,GAAO,WAAW,OAAA,EAAS,IAAA,CAAK,WAAA,CAAY,MAAA,EAAQ,CAAS,CAAA;AAC7D,QAAA;AAAA,MACF,KAAK,WAAA;AAAA,MACL,KAAK,SAAA;AACH,QAAA,IAAA,GAAO,WAAW,OAAA,EAAS,IAAA,CAAK,WAAA,CAAY,MAAA,EAAQ,EAAU,CAAA;AAC9D,QAAA;AAAA,MACF,KAAK,MAAA;AACH,QAAA,IAAA,GAAO,CAAA;AACP,QAAA;AAAA,MACF,KAAK,KAAA;AACH,QAAA,IAAA,GAAO,IAAA,CAAK,YAAY,MAAA,GAAS,CAAA;AACjC,QAAA;AAAA,MACF,KAAK,GAAA;AAAA,MACL,KAAK,OAAA;AAGH,QAAA,IAAI,KAAA,CAAM,yBAAyB,iBAAA,EAAmB;AACtD,QAAA,KAAA,CAAM,cAAA,EAAe;AACrB,QAAA,IAAA,CAAK,aAAa,OAAO,CAAA;AACzB,QAAA;AAAA,MACF;AACE,QAAA;AAAA;AAGJ,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAA,CAAK,QAAQ,SAAA,CAAU,IAAA,EAAM,EAAE,KAAA,EAAO,MAAM,CAAA;AAAA,EAC9C;AAAA;AAAA,EAGA,aAAa,KAAA,EAAqB;AAChC,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,WAAA,CAAY,KAAK,CAAA;AACnC,IAAA,IAAI,CAAC,IAAA,EAAM;AACX,IAAA,MAAM,SAAA,GAAY,CAAC,IAAA,CAAK,UAAA,CAAW,IAAI,CAAA;AACvC,IAAA,IAAI,IAAA,CAAK,cAAc,QAAA,EAAU;AAC/B,MAAA,IAAA,CAAK,WAAA,CAAY,OAAA,CAAQ,CAAC,KAAA,EAAO,CAAA,KAAM;AACrC,QAAA,IAAA,CAAK,WAAA,CAAY,KAAA,EAAO,CAAA,KAAM,KAAA,IAAS,SAAS,CAAA;AAAA,MAClD,CAAC,CAAA;AAAA,IACH,CAAA,MAAO;AACL,MAAA,IAAA,CAAK,WAAA,CAAY,MAAM,SAAS,CAAA;AAAA,IAClC;AACA,IAAA,IAAA,CAAK,OAAA,CAAQ,UAAU,KAAK,CAAA;AAC5B,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU;AAAA,MACtB,MAAA,EAAQ,EAAE,KAAA,EAAO,IAAA,CAAK,UAAA,CAAW,IAAI,CAAA,EAAG,OAAA,EAAS,SAAA,EAAW,MAAA,EAAQ,IAAA,CAAK,cAAA,EAAe;AAAE,KAC3F,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,WAAW,IAAA,EAA4B;AACrC,IAAA,OAAO,IAAA,CAAK,YAAA,CAAa,cAAc,CAAA,KAAM,MAAA;AAAA,EAC/C;AAAA;AAAA,EAGA,WAAA,CAAY,MAAmB,OAAA,EAAwB;AACrD,IAAA,IAAA,CAAK,YAAA,CAAa,cAAA,EAAgB,OAAA,GAAU,MAAA,GAAS,OAAO,CAAA;AAAA,EAC9D;AAAA;AAAA,EAGA,cAAA,GAA2B;AACzB,IAAA,OAAO,KAAK,WAAA,CACT,MAAA,CAAO,CAAC,IAAA,KAAS,KAAK,UAAA,CAAW,IAAI,CAAC,CAAA,CACtC,IAAI,CAAC,IAAA,KAAS,IAAA,CAAK,UAAA,CAAW,IAAI,CAAC,CAAA;AAAA,EACxC;AAAA;AAAA,EAGA,WAAW,IAAA,EAA2B;AACpC,IAAA,OAAO,IAAA,CAAK,YAAA,CAAa,YAAY,CAAA,IAAK,EAAA;AAAA,EAC5C;AACF","file":"toggle_group_controller.js","sourcesContent":["/**\n * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { RovingTabindex, rovingMove } from \"../utils/roving_tabindex\";\n\n/**\n * Headless, accessible toggle-button group behavior.\n *\n * Markup contract (identifier: `stimeo--toggle-group`):\n * <div data-controller=\"stimeo--toggle-group\"\n * data-stimeo--toggle-group-mode-value=\"single\"\n * role=\"group\" aria-label=\"Text style\">\n * <button type=\"button\" aria-pressed=\"true\" tabindex=\"0\" data-value=\"bold\"\n * data-stimeo--toggle-group-target=\"item\"\n * data-action=\"click->stimeo--toggle-group#toggle\n * keydown->stimeo--toggle-group#onKeydown\">Bold</button>\n * <!-- more items; exactly one tabindex=0 -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Button (toggle)** pattern with **Toolbar**-style\n * roving navigation. Each item's pressed state is `aria-pressed` (the accessible\n * name never changes); the group is a single Tab stop. For strict mutually\n * exclusive selection with radio semantics, use\n * {@link RadioGroupController | Radio Group} instead.\n *\n * @remarks\n * Behavior only — the consumer styles off `[aria-pressed=\"true\"]`. Per the\n * Toolbar model the arrow keys move **focus only**; activation is Space/Enter\n * (handled natively on `<button>` hosts, which synthesize a click).\n *\n * Behavior provided:\n * - Click (or Space/Enter) toggles an item. In `single` mode pressing one item\n * releases the others (0 or 1 pressed); `multiple` allows any number.\n * - `ArrowRight`/`ArrowDown` move focus to the next item, `ArrowLeft`/`ArrowUp`\n * to the previous (wrapping); `Home`/`End` to the first/last.\n * - `stimeo--toggle-group:change` is dispatched on every toggle.\n */\nexport class ToggleGroupController extends Controller<HTMLElement> {\n static override targets = [\"item\"];\n static override values = {\n mode: { type: String, default: \"multiple\" },\n };\n static actions = [\"onKeydown\", \"toggle\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare modeValue: string;\n\n readonly #roving = new RovingTabindex(() => this.itemTargets);\n\n /** Establishes the roving entry point (first pressed item, else the first). */\n override connect(): void {\n const firstPressed = this.itemTargets.findIndex((item) => this.#isPressed(item));\n this.#roving.setActive(firstPressed === -1 ? 0 : firstPressed);\n }\n\n /** Toggles the activated item. Bound via `data-action` (click). */\n toggle(event: Event): void {\n this.#toggleIndex(this.itemTargets.indexOf(event.currentTarget as HTMLElement));\n }\n\n /** Arrow/Home/End move focus only; Space/Enter toggle non-button hosts. */\n onKeydown(event: KeyboardEvent): void {\n const current = this.itemTargets.indexOf(event.currentTarget as HTMLElement);\n if (current === -1) return;\n\n let next: number | null = null;\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowDown\":\n next = rovingMove(current, this.itemTargets.length, 1, \"wrap\");\n break;\n case \"ArrowLeft\":\n case \"ArrowUp\":\n next = rovingMove(current, this.itemTargets.length, -1, \"wrap\");\n break;\n case \"Home\":\n next = 0;\n break;\n case \"End\":\n next = this.itemTargets.length - 1;\n break;\n case \" \":\n case \"Enter\":\n // Native <button> hosts synthesize a click that drives #toggle; handling\n // the key here too would toggle twice. Only non-button hosts need it.\n if (event.currentTarget instanceof HTMLButtonElement) return;\n event.preventDefault();\n this.#toggleIndex(current);\n return;\n default:\n return;\n }\n\n event.preventDefault();\n this.#roving.setActive(next, { focus: true });\n }\n\n /** Applies the toggle at `index` per the current mode and dispatches `change`. */\n #toggleIndex(index: number): void {\n const item = this.itemTargets[index];\n if (!item) return;\n const willPress = !this.#isPressed(item);\n if (this.modeValue === \"single\") {\n this.itemTargets.forEach((other, i) => {\n this.#setPressed(other, i === index && willPress);\n });\n } else {\n this.#setPressed(item, willPress);\n }\n this.#roving.setActive(index);\n this.dispatch(\"change\", {\n detail: { value: this.#itemValue(item), pressed: willPress, values: this.#pressedValues() },\n });\n }\n\n /** Whether an item is currently pressed. */\n #isPressed(item: HTMLElement): boolean {\n return item.getAttribute(\"aria-pressed\") === \"true\";\n }\n\n /** Reflects the pressed state on `aria-pressed`. */\n #setPressed(item: HTMLElement, pressed: boolean): void {\n item.setAttribute(\"aria-pressed\", pressed ? \"true\" : \"false\");\n }\n\n /** The `data-value` of every currently pressed item. */\n #pressedValues(): string[] {\n return this.itemTargets\n .filter((item) => this.#isPressed(item))\n .map((item) => this.#itemValue(item));\n }\n\n /** An item's value (`data-value`, defaulting to empty). */\n #itemValue(item: HTMLElement): string {\n return item.getAttribute(\"data-value\") ?? \"\";\n }\n}\n"]}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible toolbar behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--toolbar`):
|
|
7
|
+
* <div data-controller="stimeo--toolbar" role="toolbar" aria-label="Text formatting"
|
|
8
|
+
* data-stimeo--toolbar-orientation-value="horizontal">
|
|
9
|
+
* <button type="button" data-stimeo--toolbar-target="control"
|
|
10
|
+
* data-action="keydown->stimeo--toolbar#onKeydown">Bold</button>
|
|
11
|
+
* <!-- more controls; exactly one tabindex=0 -->
|
|
12
|
+
* </div>
|
|
13
|
+
*
|
|
14
|
+
* Implements the WAI-ARIA APG **Toolbar** pattern: the group is a single Tab
|
|
15
|
+
* stop (roving tabindex) and the arrow keys move focus between controls. Each
|
|
16
|
+
* control's own function (press, toggle, open a menu) stays with that element or
|
|
17
|
+
* its own controller — the toolbar only owns navigation.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* Behavior only. The roving mechanics are delegated to {@link RovingTabindex};
|
|
21
|
+
* orientation and wrap policy stay here per the APG (they differ per widget).
|
|
22
|
+
*
|
|
23
|
+
* Behavior provided:
|
|
24
|
+
* - Exactly one control is tabbable (`tabindex="0"`); the rest are `-1`.
|
|
25
|
+
* - `ArrowRight`/`ArrowLeft` (horizontal) or `ArrowDown`/`ArrowUp` (vertical)
|
|
26
|
+
* move focus to the next/previous control; `Home`/`End` to the first/last.
|
|
27
|
+
* - With `wrap=true` movement cycles past the ends; with `wrap=false` it stops.
|
|
28
|
+
* - Returning focus from outside lands on the most recently active control,
|
|
29
|
+
* because that is the one left tabbable.
|
|
30
|
+
*/
|
|
31
|
+
declare class ToolbarController extends Controller<HTMLElement> {
|
|
32
|
+
#private;
|
|
33
|
+
static targets: string[];
|
|
34
|
+
static values: {
|
|
35
|
+
orientation: {
|
|
36
|
+
type: StringConstructor;
|
|
37
|
+
default: string;
|
|
38
|
+
};
|
|
39
|
+
wrap: {
|
|
40
|
+
type: BooleanConstructor;
|
|
41
|
+
default: boolean;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
static actions: readonly ["onKeydown"];
|
|
45
|
+
readonly controlTargets: HTMLElement[];
|
|
46
|
+
orientationValue: string;
|
|
47
|
+
wrapValue: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Establishes the single tab stop: keep an existing one if it is still
|
|
50
|
+
* navigable, else the first navigable control. Disabled / hidden controls are
|
|
51
|
+
* skipped so the toolbar's lone Tab stop never lands on an unfocusable element
|
|
52
|
+
* (which would make the whole group unreachable on Tab re-entry).
|
|
53
|
+
*/
|
|
54
|
+
connect(): void;
|
|
55
|
+
/** Arrow/Home/End move focus and the single tab stop. Bound via `data-action`. */
|
|
56
|
+
onKeydown(event: KeyboardEvent): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { ToolbarController };
|