stimeo-ui 0.2.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/carousel_controller.js +272 -0
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +213 -0
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +168 -0
- data/dist/controllers/date_range_picker_controller.js +417 -0
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/editable_controller.js +168 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menubar_controller.js +433 -0
- data/dist/controllers/multi_select_controller.js +472 -0
- data/dist/controllers/navigation_menu_controller.js +384 -0
- data/dist/controllers/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/range_slider_controller.js +166 -0
- data/dist/controllers/read_more_controller.js +194 -0
- data/dist/controllers/scroll_area_controller.js +15 -2
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +8 -4
- data/dist/controllers/scrollspy_controller.js +33 -11
- data/dist/controllers/separator_controller.js +87 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +8 -4
- data/dist/controllers/sticky_observer_controller.js +88 -20
- data/dist/controllers/tags_input_controller.js +275 -0
- data/dist/controllers/theme_controller.js +20 -10
- data/dist/controllers/time_picker_controller.js +212 -0
- data/dist/controllers/toast_controller.js +36 -9
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +811 -295
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +28 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4f40e999f04533e49b382f7a5165c17b2338e8d520c702c34523843c12eff4c
|
|
4
|
+
data.tar.gz: 2bd4c18004fd4f92998513dd6a0d51975c7f9fcbe452fcef89477eb3f87edd7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 06ed197e53937fa086971d86c22d66efa4aed0f2accf14000046711be6aa73c3f486671c3912264a4ce5ca7c6da97a9836d7fe79c1d3ce106e1b368e5013f0d1
|
|
7
|
+
data.tar.gz: e49c3e2ccbc075def36be9d4e926d167fa4714cb66710389ad983ad7725f86e91d525fddfbb809090b4af09bc0cdaed44541466cb57391db53566119613f8863
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
|
|
8
8
|
change between releases.
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-07-26
|
|
11
|
+
|
|
12
|
+
Patch release from a review of the disclosure and layout components, plus a
|
|
13
|
+
packaging fix. No breaking changes, but `stimeo check` now reports two markup
|
|
14
|
+
requirements that existing pages may need to satisfy.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- sidebar: `beforeCache` action for Turbo page caching — wire
|
|
19
|
+
`data-action="turbo:before-cache@document->stimeo--sidebar#beforeCache"` to close
|
|
20
|
+
an open overlay and release its modal side effects before the snapshot is taken.
|
|
21
|
+
- sidebar, stepper: the `breakpoint` and `index` values now take effect when
|
|
22
|
+
changed at runtime. An invalid `breakpoint` falls back to 768px.
|
|
23
|
+
- Inspector: `stimeo check` verifies that the tabs `role="tablist"` element has an
|
|
24
|
+
accessible name.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- tabs: the tablist container is now a required `list` target. Add
|
|
29
|
+
`data-stimeo--tabs-target="list"` to it, or `stimeo check` reports an error.
|
|
30
|
+
Runtime behavior is unchanged.
|
|
31
|
+
- Inspector: `stimeo check` reports an error when `stimeo--sidebar` markup has no
|
|
32
|
+
`panel` target.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- package exports: 26 controllers — including sidebar, drawer, collapsible,
|
|
37
|
+
read-more, alert-dialog, carousel and tree-view — had no build output behind the
|
|
38
|
+
`./controllers/*` subpath their `package.json` `exports` declares, so
|
|
39
|
+
`import … from "stimeo-ui/controllers/<name>_controller"` failed to resolve in
|
|
40
|
+
0.2.0. Every controller in the catalog now ships one.
|
|
41
|
+
- transition, flash, toast, sidebar, drawer, collapsible: animations now wait for
|
|
42
|
+
every declared transition property and its `transition-delay`, not just the
|
|
43
|
+
first duration. Multi-property and delayed animations are no longer cut short, a
|
|
44
|
+
cancelled transition settles immediately, and pseudo-element transitions no
|
|
45
|
+
longer end the wait early — so `hidden`, events, focus restore and scroll unlock
|
|
46
|
+
land at the right moment.
|
|
47
|
+
- sidebar, drawer, collapsible: an interrupted close animation no longer leaves the
|
|
48
|
+
panel stuck open with the focus trap, background `inert` or scroll lock in place.
|
|
49
|
+
- sidebar, drawer, collapsible, read-more, sticky-observer, overflow-indicator:
|
|
50
|
+
targets swapped in or added after connect (Turbo morph, lazily loaded Frames,
|
|
51
|
+
Streams) are now reconciled with the current state instead of keeping the value
|
|
52
|
+
they were rendered with.
|
|
53
|
+
- overflow-indicator, scroll-area: horizontal position is read logically, so the
|
|
54
|
+
overflow attributes, the `scrollByPage` action, `data-scroll` and
|
|
55
|
+
`--stimeo-scroll-progress` behave correctly inside `dir="rtl"` containers.
|
|
56
|
+
- overflow-indicator: reaching an edge while a page button has focus no longer
|
|
57
|
+
drops focus to the page body — the button stays focused and inert via
|
|
58
|
+
`aria-disabled`, becoming natively `disabled` only once blurred.
|
|
59
|
+
- read-more: the toggle is no longer hidden while it holds keyboard focus when the
|
|
60
|
+
text starts to fit; overflow is re-checked on blur, on content changes, and when
|
|
61
|
+
media inside it finishes loading.
|
|
62
|
+
- sticky-observer: `data-stuck="true"` is set only once the sentinel has actually
|
|
63
|
+
scrolled past the top edge — one that is merely out of view, or inside a hidden
|
|
64
|
+
container, no longer marks the element as stuck. Negative `offset` values are now
|
|
65
|
+
accepted.
|
|
66
|
+
- intersection: an element with no layout box (hidden ancestor, collapsed
|
|
67
|
+
`<details>`) is no longer reported as scrolled past.
|
|
68
|
+
|
|
10
69
|
## [0.2.0] - 2026-07-22
|
|
11
70
|
|
|
12
71
|
First release off the beta channel: `npm install stimeo-ui` now resolves
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/alert_dialog_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
var EscapeLayer = class _EscapeLayer {
|
|
7
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
#ownerDocument = null;
|
|
9
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
10
|
+
#onDismiss = null;
|
|
11
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
12
|
+
#claims = null;
|
|
13
|
+
/**
|
|
14
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
15
|
+
* the document's shared resolver listener if this is its first layer.
|
|
16
|
+
* Re-activating an already-active layer moves it to the top.
|
|
17
|
+
*/
|
|
18
|
+
activate(ownerDocument = document, options) {
|
|
19
|
+
this.deactivate();
|
|
20
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
21
|
+
if (!registry) {
|
|
22
|
+
registry = _EscapeLayer.#createRegistry();
|
|
23
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
24
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
25
|
+
}
|
|
26
|
+
registry.stack.push(this);
|
|
27
|
+
this.#ownerDocument = ownerDocument;
|
|
28
|
+
this.#onDismiss = options.onDismiss;
|
|
29
|
+
this.#claims = options.claims ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
33
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
34
|
+
*/
|
|
35
|
+
deactivate() {
|
|
36
|
+
const ownerDocument = this.#ownerDocument;
|
|
37
|
+
if (!ownerDocument) return;
|
|
38
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
39
|
+
if (registry) {
|
|
40
|
+
const index = registry.stack.lastIndexOf(this);
|
|
41
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
42
|
+
if (registry.stack.length === 0) {
|
|
43
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
44
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.#ownerDocument = null;
|
|
48
|
+
this.#onDismiss = null;
|
|
49
|
+
this.#claims = null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
53
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
54
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
55
|
+
*/
|
|
56
|
+
get ownsEscape() {
|
|
57
|
+
const ownerDocument = this.#ownerDocument;
|
|
58
|
+
if (!ownerDocument) return false;
|
|
59
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
60
|
+
if (!registry) return false;
|
|
61
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
62
|
+
}
|
|
63
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
64
|
+
static #createRegistry() {
|
|
65
|
+
const registry = {
|
|
66
|
+
stack: [],
|
|
67
|
+
onKeydown: (event) => {
|
|
68
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
69
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
70
|
+
if (!owner) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
owner.#onDismiss?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return registry;
|
|
76
|
+
}
|
|
77
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
78
|
+
static #resolveOwner(stack) {
|
|
79
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
80
|
+
const layer = stack[index];
|
|
81
|
+
if (!layer) continue;
|
|
82
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
83
|
+
return layer;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/utils/focus_trap.ts
|
|
90
|
+
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
91
|
+
var FocusTrap = class {
|
|
92
|
+
/** The element focused before activation, restored on deactivation. */
|
|
93
|
+
#previouslyFocused = null;
|
|
94
|
+
/** The body's inline `overflow` before locking, restored on deactivation. */
|
|
95
|
+
#previousBodyOverflow = "";
|
|
96
|
+
/** Whether scroll was locked this activation (so it is only restored if applied). */
|
|
97
|
+
#scrollLocked = false;
|
|
98
|
+
/** Background siblings made `inert` while active, restored on deactivation. */
|
|
99
|
+
#inertedSiblings = [];
|
|
100
|
+
/** Whether the modal side effects are currently applied. */
|
|
101
|
+
#activeState = false;
|
|
102
|
+
/** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
|
|
103
|
+
#escapeLayer = new EscapeLayer();
|
|
104
|
+
/** Returns the trapped element; called on every operation for the live target. */
|
|
105
|
+
#getContainer;
|
|
106
|
+
/** Closing/focus hooks; see {@link FocusTrapOptions}. */
|
|
107
|
+
#options;
|
|
108
|
+
/**
|
|
109
|
+
* @param getContainer - Returns the trapped element. Called on every operation
|
|
110
|
+
* so the live target is always used.
|
|
111
|
+
* @param options - Closing/focus hooks; see {@link FocusTrapOptions}.
|
|
112
|
+
*/
|
|
113
|
+
constructor(getContainer, options = {}) {
|
|
114
|
+
this.#getContainer = getContainer;
|
|
115
|
+
this.#options = options;
|
|
116
|
+
}
|
|
117
|
+
/** Whether the trap is currently active. */
|
|
118
|
+
get active() {
|
|
119
|
+
return this.#activeState;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Applies the trap: records the current focus, optionally locks background scroll
|
|
123
|
+
* and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless
|
|
124
|
+
* `autoFocus` is off) moves focus inside. No-ops if already active.
|
|
125
|
+
*/
|
|
126
|
+
activate() {
|
|
127
|
+
if (this.#activeState) return;
|
|
128
|
+
this.#activeState = true;
|
|
129
|
+
const active = document.activeElement;
|
|
130
|
+
this.#previouslyFocused = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
131
|
+
if (this.#flag(this.#options.lockScroll, true)) {
|
|
132
|
+
this.#previousBodyOverflow = document.body.style.overflow;
|
|
133
|
+
document.body.style.overflow = "hidden";
|
|
134
|
+
this.#scrollLocked = true;
|
|
135
|
+
}
|
|
136
|
+
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
|
+
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
+
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
139
|
+
const onEscape = this.#options.onEscape;
|
|
140
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
|
+
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Reverts every side effect applied by {@link activate}. No-ops if inactive, so
|
|
145
|
+
* a controller can call it defensively from both `close()` and `disconnect()`.
|
|
146
|
+
*
|
|
147
|
+
* @param restoreFocus - Move focus back to the opener (default `true`). Pass
|
|
148
|
+
* `false` on teardown (`disconnect`), where yanking focus is undesirable.
|
|
149
|
+
*/
|
|
150
|
+
deactivate({ restoreFocus = true } = {}) {
|
|
151
|
+
if (!this.#activeState) return;
|
|
152
|
+
this.#activeState = false;
|
|
153
|
+
this.#escapeLayer.deactivate();
|
|
154
|
+
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
+
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
156
|
+
if (this.#scrollLocked) {
|
|
157
|
+
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
|
+
this.#scrollLocked = false;
|
|
159
|
+
}
|
|
160
|
+
this.#releaseBackground();
|
|
161
|
+
if (restoreFocus) {
|
|
162
|
+
const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;
|
|
163
|
+
target?.focus();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Resolves a boolean-or-getter option, defaulting when it was not provided. */
|
|
167
|
+
#flag(option, fallback) {
|
|
168
|
+
if (option === void 0) return fallback;
|
|
169
|
+
return typeof option === "function" ? option() : option;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reverts the side effects just before Turbo caches the page snapshot, so an
|
|
173
|
+
* overlay left open does not bake the scroll lock into `body[style]` — a
|
|
174
|
+
* restored page would feed that locked value back into {@link activate} as the
|
|
175
|
+
* baseline, and closing would then never unlock the page. Markup state stays
|
|
176
|
+
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
|
+
* is left alone mid-navigation. The listener lives only while active.
|
|
178
|
+
*/
|
|
179
|
+
#onBeforeCache = () => {
|
|
180
|
+
this.deactivate({ restoreFocus: false });
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
|
+
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
185
|
+
* which layer currently owns Escape.
|
|
186
|
+
*/
|
|
187
|
+
#onKeydown = (event) => {
|
|
188
|
+
if (event.key === "Tab") this.#trapTab(event);
|
|
189
|
+
};
|
|
190
|
+
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
191
|
+
#trapTab(event) {
|
|
192
|
+
const focusable = this.#focusableElements();
|
|
193
|
+
if (focusable.length === 0) {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const first = focusable[0];
|
|
198
|
+
const last = focusable[focusable.length - 1];
|
|
199
|
+
const active = document.activeElement;
|
|
200
|
+
if (!(active instanceof Node) || !this.#getContainer().contains(active)) {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
first?.focus();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (event.shiftKey && active === first) {
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
last?.focus();
|
|
208
|
+
} else if (!event.shiftKey && active === last) {
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
first?.focus();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Marks every element outside the container's subtree as `inert` so background
|
|
215
|
+
* content cannot be focused or reached by assistive technology, honoring the
|
|
216
|
+
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
217
|
+
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
218
|
+
*/
|
|
219
|
+
#isolateBackground() {
|
|
220
|
+
const container = this.#getContainer();
|
|
221
|
+
this.#inertedSiblings = [];
|
|
222
|
+
for (const sibling of Array.from(document.body.children)) {
|
|
223
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
224
|
+
if (sibling.contains(container) || sibling.inert) continue;
|
|
225
|
+
sibling.inert = true;
|
|
226
|
+
this.#inertedSiblings.push(sibling);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
230
|
+
#releaseBackground() {
|
|
231
|
+
for (const sibling of this.#inertedSiblings) {
|
|
232
|
+
sibling.inert = false;
|
|
233
|
+
}
|
|
234
|
+
this.#inertedSiblings = [];
|
|
235
|
+
}
|
|
236
|
+
/** Moves focus to the initial target, the first focusable, or the container. */
|
|
237
|
+
#focusInitial() {
|
|
238
|
+
const preferred = this.#options.initialFocus?.();
|
|
239
|
+
if (preferred) {
|
|
240
|
+
preferred.focus();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const focusable = this.#focusableElements();
|
|
244
|
+
if (focusable[0]) {
|
|
245
|
+
focusable[0].focus();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const container = this.#getContainer();
|
|
249
|
+
container.tabIndex = -1;
|
|
250
|
+
container.focus();
|
|
251
|
+
}
|
|
252
|
+
/** Collects the container's currently focusable descendants in DOM order. */
|
|
253
|
+
#focusableElements() {
|
|
254
|
+
return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
|
|
255
|
+
(el) => !el.hidden
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// src/controllers/alert_dialog_controller.ts
|
|
261
|
+
var AlertDialogController = class extends Controller {
|
|
262
|
+
static targets = ["trigger", "dialog", "initialFocus"];
|
|
263
|
+
static actions = ["cancel", "confirm", "open"];
|
|
264
|
+
static events = ["cancel", "confirm"];
|
|
265
|
+
/**
|
|
266
|
+
* Owns the modal side effects. Escape is routed through {@link cancel} so it
|
|
267
|
+
* emits the same event as the cancel button (tagged `"escape"`); focus falls
|
|
268
|
+
* back to the trigger when nothing was focused before opening.
|
|
269
|
+
*/
|
|
270
|
+
#trap = new FocusTrap(() => this.dialogTarget, {
|
|
271
|
+
onEscape: () => this.#requestCancel("escape"),
|
|
272
|
+
initialFocus: () => this.hasInitialFocusTarget ? this.initialFocusTarget : null,
|
|
273
|
+
fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
|
|
274
|
+
});
|
|
275
|
+
/** Starts closed (idempotently reflects the closed state on the markup). */
|
|
276
|
+
connect() {
|
|
277
|
+
if (this.hasDialogTarget) this.dialogTarget.hidden = true;
|
|
278
|
+
}
|
|
279
|
+
/** Reverts the modal side effects if torn down while open (Turbo navigation). */
|
|
280
|
+
disconnect() {
|
|
281
|
+
this.#trap.deactivate({ restoreFocus: false });
|
|
282
|
+
}
|
|
283
|
+
/** Opens the dialog, traps focus, and locks background scroll. */
|
|
284
|
+
open() {
|
|
285
|
+
if (!this.hasDialogTarget || this.#isOpen) return;
|
|
286
|
+
this.dialogTarget.hidden = false;
|
|
287
|
+
this.#trap.activate();
|
|
288
|
+
}
|
|
289
|
+
/** Confirms: closes and dispatches `confirm`. Bound via `data-action`. */
|
|
290
|
+
confirm() {
|
|
291
|
+
if (!this.#isOpen) return;
|
|
292
|
+
this.dispatch("confirm");
|
|
293
|
+
this.#closeDialog();
|
|
294
|
+
}
|
|
295
|
+
/** Cancels (user action): closes and dispatches `cancel` with `reason: "user"`. */
|
|
296
|
+
cancel() {
|
|
297
|
+
this.#requestCancel("user");
|
|
298
|
+
}
|
|
299
|
+
/** Shared cancel path used by both the cancel action and the Escape key. */
|
|
300
|
+
#requestCancel(reason) {
|
|
301
|
+
if (!this.#isOpen) return;
|
|
302
|
+
this.dispatch("cancel", { detail: { reason } });
|
|
303
|
+
this.#closeDialog();
|
|
304
|
+
}
|
|
305
|
+
/** Hides the dialog and reverts the modal side effects (restoring focus). */
|
|
306
|
+
#closeDialog() {
|
|
307
|
+
this.dialogTarget.hidden = true;
|
|
308
|
+
this.#trap.deactivate();
|
|
309
|
+
}
|
|
310
|
+
/** Whether the dialog is currently visible. */
|
|
311
|
+
get #isOpen() {
|
|
312
|
+
return this.hasDialogTarget && !this.dialogTarget.hidden;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
export { AlertDialogController };
|
|
317
|
+
//# sourceMappingURL=alert_dialog_controller.js.map
|
|
318
|
+
//# sourceMappingURL=alert_dialog_controller.js.map
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/carousel_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/utils/safe_timeout.ts
|
|
44
|
+
var TimerRegistry = class {
|
|
45
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
46
|
+
ids = /* @__PURE__ */ new Set();
|
|
47
|
+
/**
|
|
48
|
+
* Cancels a single tracked timer.
|
|
49
|
+
*
|
|
50
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
51
|
+
* registry), so callers can clear defensively without guarding.
|
|
52
|
+
*/
|
|
53
|
+
clear(id) {
|
|
54
|
+
if (this.ids.delete(id)) {
|
|
55
|
+
this.cancel(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
60
|
+
* to guarantee no timer outlives the element.
|
|
61
|
+
*/
|
|
62
|
+
clearAll() {
|
|
63
|
+
for (const id of this.ids) {
|
|
64
|
+
this.cancel(id);
|
|
65
|
+
}
|
|
66
|
+
this.ids.clear();
|
|
67
|
+
}
|
|
68
|
+
/** Number of timers currently tracked (pending). */
|
|
69
|
+
get size() {
|
|
70
|
+
return this.ids.size;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var SafeInterval = class extends TimerRegistry {
|
|
74
|
+
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
75
|
+
set(callback, delay) {
|
|
76
|
+
const id = this.schedule(callback, delay);
|
|
77
|
+
this.ids.add(id);
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
schedule(callback, delay) {
|
|
81
|
+
return window.setInterval(callback, delay);
|
|
82
|
+
}
|
|
83
|
+
cancel(id) {
|
|
84
|
+
window.clearInterval(id);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// src/controllers/carousel_controller.ts
|
|
89
|
+
var CarouselController = class extends Controller {
|
|
90
|
+
static targets = ["slide", "viewport", "prev", "next", "picker", "playToggle"];
|
|
91
|
+
static values = {
|
|
92
|
+
autoplay: { type: Boolean, default: false },
|
|
93
|
+
interval: { type: Number, default: 5e3 },
|
|
94
|
+
loop: { type: Boolean, default: true }
|
|
95
|
+
};
|
|
96
|
+
static actions = [
|
|
97
|
+
"goto",
|
|
98
|
+
"next",
|
|
99
|
+
"onPickerKeydown",
|
|
100
|
+
"pause",
|
|
101
|
+
"prev",
|
|
102
|
+
"resume",
|
|
103
|
+
"togglePlay"
|
|
104
|
+
];
|
|
105
|
+
static events = ["change", "pause", "play"];
|
|
106
|
+
#roving = new RovingTabindex(() => this.pickerTargets);
|
|
107
|
+
#intervals = new SafeInterval();
|
|
108
|
+
/** Index of the visible slide. */
|
|
109
|
+
#index = 0;
|
|
110
|
+
/** User intent to autoplay (toggled by the play button / focus hard-stop). */
|
|
111
|
+
#playing = false;
|
|
112
|
+
/** Pointer is hovering the carousel: a temporary, auto-resuming suspension. */
|
|
113
|
+
#pointerPaused = false;
|
|
114
|
+
/** Id of the live autoplay interval, or null when stopped. */
|
|
115
|
+
#timerId = null;
|
|
116
|
+
/** Renders the initial slide and starts autoplay when requested. */
|
|
117
|
+
connect() {
|
|
118
|
+
const preselected = this.pickerTargets.findIndex(
|
|
119
|
+
(picker) => picker.getAttribute("aria-selected") === "true"
|
|
120
|
+
);
|
|
121
|
+
this.#index = preselected === -1 ? 0 : preselected;
|
|
122
|
+
this.#playing = this.#initialPlaying();
|
|
123
|
+
this.#render({ focus: false });
|
|
124
|
+
this.#syncTimer();
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
|
|
128
|
+
* source of truth **when present**, so a Turbo Drive cache restore / morph that
|
|
129
|
+
* re-runs `connect()` against existing DOM does not silently resume autoplay the
|
|
130
|
+
* user had stopped (e.g. by focusing into the carousel). Only when no toggle
|
|
131
|
+
* carries `aria-pressed` does it fall back to the declarative `autoplay` value.
|
|
132
|
+
*/
|
|
133
|
+
#initialPlaying() {
|
|
134
|
+
if (this.hasPlayToggleTarget && this.playToggleTarget.hasAttribute("aria-pressed")) {
|
|
135
|
+
return this.playToggleTarget.getAttribute("aria-pressed") === "true";
|
|
136
|
+
}
|
|
137
|
+
return this.autoplayValue;
|
|
138
|
+
}
|
|
139
|
+
/** Clears the autoplay interval so it never fires after teardown. */
|
|
140
|
+
disconnect() {
|
|
141
|
+
this.#intervals.clearAll();
|
|
142
|
+
this.#timerId = null;
|
|
143
|
+
}
|
|
144
|
+
/** Advances to the next slide. Bound via `data-action`. */
|
|
145
|
+
next() {
|
|
146
|
+
this.#select(this.#step(1), { focus: false });
|
|
147
|
+
}
|
|
148
|
+
/** Returns to the previous slide. Bound via `data-action`. */
|
|
149
|
+
prev() {
|
|
150
|
+
this.#select(this.#step(-1), { focus: false });
|
|
151
|
+
}
|
|
152
|
+
/** Jumps to the slide whose picker was activated (click / Enter / Space). */
|
|
153
|
+
goto(event) {
|
|
154
|
+
const target = event.currentTarget;
|
|
155
|
+
const index = this.pickerTargets.indexOf(target);
|
|
156
|
+
if (index !== -1) this.#select(index, { focus: false });
|
|
157
|
+
}
|
|
158
|
+
/** Toggles autoplay on the user's explicit request and syncs the timer. */
|
|
159
|
+
togglePlay() {
|
|
160
|
+
this.#playing = !this.#playing;
|
|
161
|
+
this.#syncTimer();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes
|
|
165
|
+
* on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so
|
|
166
|
+
* it cannot resume without an explicit play (WCAG 2.2.2).
|
|
167
|
+
*/
|
|
168
|
+
pause(event) {
|
|
169
|
+
if (event?.type.startsWith("focus")) {
|
|
170
|
+
this.#playing = false;
|
|
171
|
+
} else {
|
|
172
|
+
this.#pointerPaused = true;
|
|
173
|
+
}
|
|
174
|
+
this.#syncTimer();
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still
|
|
178
|
+
* on. A `focusout` does nothing here: the focus pause was a hard stop, so the
|
|
179
|
+
* user must press play to restart.
|
|
180
|
+
*/
|
|
181
|
+
resume(event) {
|
|
182
|
+
if (event?.type.startsWith("focus")) return;
|
|
183
|
+
this.#pointerPaused = false;
|
|
184
|
+
this.#syncTimer();
|
|
185
|
+
}
|
|
186
|
+
/** Picker roving: arrows move focus only; Home/End activate first/last slide. */
|
|
187
|
+
onPickerKeydown(event) {
|
|
188
|
+
const current = this.pickerTargets.indexOf(event.currentTarget);
|
|
189
|
+
if (current === -1) return;
|
|
190
|
+
const length = this.pickerTargets.length;
|
|
191
|
+
switch (event.key) {
|
|
192
|
+
case "ArrowRight":
|
|
193
|
+
case "ArrowDown":
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
this.#roving.setActive(rovingMove(current, length, 1), { focus: true });
|
|
196
|
+
return;
|
|
197
|
+
case "ArrowLeft":
|
|
198
|
+
case "ArrowUp":
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
this.#roving.setActive(rovingMove(current, length, -1), { focus: true });
|
|
201
|
+
return;
|
|
202
|
+
case "Home":
|
|
203
|
+
event.preventDefault();
|
|
204
|
+
this.#select(0, { focus: true });
|
|
205
|
+
return;
|
|
206
|
+
case "End":
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
this.#select(length - 1, { focus: true });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Resolves the index one step away from the current one, honoring `loop`. */
|
|
213
|
+
#step(delta) {
|
|
214
|
+
const total = this.slideTargets.length;
|
|
215
|
+
if (total === 0) return 0;
|
|
216
|
+
const next = this.#index + delta;
|
|
217
|
+
if (this.loopValue) return (next + total) % total;
|
|
218
|
+
return Math.min(total - 1, Math.max(0, next));
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Changes the active slide, updates state hooks, and emits `change` — but only
|
|
222
|
+
* when the index actually changes, so a `next`/`prev` clamped at the end (or an
|
|
223
|
+
* autoplay tick at a non-looping boundary) re-renders without a spurious event
|
|
224
|
+
* (matching the "emit on real change" policy of flash/masonry/bulk-select).
|
|
225
|
+
*/
|
|
226
|
+
#select(index, { focus }) {
|
|
227
|
+
const changed = index !== this.#index;
|
|
228
|
+
this.#index = index;
|
|
229
|
+
this.#render({ focus });
|
|
230
|
+
this.#syncTimer();
|
|
231
|
+
if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
|
|
232
|
+
}
|
|
233
|
+
/** Reflects `this.#index` onto slides and pickers (state hooks + roving). */
|
|
234
|
+
#render({ focus }) {
|
|
235
|
+
this.slideTargets.forEach((slide, i) => {
|
|
236
|
+
const active = i === this.#index;
|
|
237
|
+
slide.setAttribute("data-state", active ? "active" : "inactive");
|
|
238
|
+
slide.hidden = !active;
|
|
239
|
+
});
|
|
240
|
+
this.pickerTargets.forEach((picker, i) => {
|
|
241
|
+
picker.setAttribute("aria-selected", i === this.#index ? "true" : "false");
|
|
242
|
+
});
|
|
243
|
+
this.#roving.setActive(this.#index, { focus });
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Drives the autoplay interval toward the desired state. Autoplay should run
|
|
247
|
+
* only when the user wants it (`playing`), the pointer is not hovering, and more
|
|
248
|
+
* than one slide exists. Transitions emit `play`/`pause` and keep the toggle's
|
|
249
|
+
* `aria-pressed` in sync.
|
|
250
|
+
*/
|
|
251
|
+
#syncTimer() {
|
|
252
|
+
if (!this.loopValue && this.#index >= this.slideTargets.length - 1) {
|
|
253
|
+
this.#playing = false;
|
|
254
|
+
}
|
|
255
|
+
const shouldRun = this.#playing && !this.#pointerPaused && this.slideTargets.length > 1;
|
|
256
|
+
if (shouldRun && this.#timerId === null) {
|
|
257
|
+
this.#timerId = this.#intervals.set(() => this.next(), this.intervalValue);
|
|
258
|
+
this.dispatch("play");
|
|
259
|
+
} else if (!shouldRun && this.#timerId !== null) {
|
|
260
|
+
this.#intervals.clear(this.#timerId);
|
|
261
|
+
this.#timerId = null;
|
|
262
|
+
this.dispatch("pause");
|
|
263
|
+
}
|
|
264
|
+
if (this.hasPlayToggleTarget) {
|
|
265
|
+
this.playToggleTarget.setAttribute("aria-pressed", this.#playing ? "true" : "false");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export { CarouselController };
|
|
271
|
+
//# sourceMappingURL=carousel_controller.js.map
|
|
272
|
+
//# sourceMappingURL=carousel_controller.js.map
|