stimeo-ui 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/dist/controllers/auto_submit_controller.d.ts +14 -3
- package/dist/controllers/auto_submit_controller.js +94 -16
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +81 -47
- package/dist/controllers/carousel_controller.js +451 -100
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +39 -5
- package/dist/controllers/currency_input_controller.js +305 -74
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.d.ts +3 -1
- package/dist/controllers/direct_upload_controller.js +12 -2
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.d.ts +123 -29
- package/dist/controllers/file_dropzone_controller.js +386 -63
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +3 -1
- package/dist/controllers/flash_controller.js +3 -1
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +4 -2
- package/dist/controllers/frame_loading_controller.js +2 -1
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.d.ts +42 -16
- package/dist/controllers/input_mask_controller.js +251 -76
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.d.ts +51 -14
- package/dist/controllers/nested_form_controller.js +450 -42
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +61 -25
- package/dist/controllers/otp_controller.js +472 -114
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +3 -2
- package/dist/controllers/spinner_controller.js +7 -5
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +4 -1
- package/dist/controllers/submit_once_controller.js +3 -1
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.d.ts +30 -10
- package/dist/controllers/textarea_autosize_controller.js +131 -3
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/index.js +1927 -607
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +8 -1
- package/dist/inspector/cli.js +3 -1
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +3 -1
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +4 -4
- package/dist/inspector/manifest.json +182 -23
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,67 @@ 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.8.0] - 2026-08-22
|
|
11
|
+
|
|
12
|
+
Minor release with no new components. Eight existing ones are reworked —
|
|
13
|
+
auto-submit, carousel, currency-input, file-dropzone, input-mask, nested-form,
|
|
14
|
+
otp, and textarea-autosize — and of those, carousel, otp, and file-dropzone also
|
|
15
|
+
changed their markup contracts, so read Removed and Changed before upgrading. The
|
|
16
|
+
Inspector manifest stays on schema v12.
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
|
|
20
|
+
- otp: `length` — the number of `field` targets is the passcode length.
|
|
21
|
+
- file-dropzone: the `status` target, `dragLabel`, and the
|
|
22
|
+
`data-file-dropzone-slot` attributes.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `reconcile` on auto-submit, direct-upload, file-dropzone, flash, frame-loading,
|
|
27
|
+
input-mask, nested-form, otp, spinner, and submit-once — it also reports the
|
|
28
|
+
state a Turbo rewind discarded, carrying what that component lost
|
|
29
|
+
(`{ files }`, `{ ids }`, `{ forms }`, `{ removed }`, or `{}`).
|
|
30
|
+
- carousel: `data-state`, `inert` on inactive slides, `prefers-reduced-motion`,
|
|
31
|
+
and optional pickers. otp: `clear` / `onPointerDown`, and text carrying several
|
|
32
|
+
characters spread across the following fields. file-dropzone:
|
|
33
|
+
`allowDuplicates`, a `thumb` target, and the `announce*Text` wording.
|
|
34
|
+
- Characters an IME commits full-width are read as their half-width form in
|
|
35
|
+
input-mask, otp, and currency-input.
|
|
36
|
+
- Inspector: rules for the reworked markup — carousel's controller-owned ARIA,
|
|
37
|
+
the `playToggle` an `autoplay` carousel needs, the `prev` / `next` pair, a
|
|
38
|
+
finite positive `interval`, otp's group and field names, and the parts
|
|
39
|
+
file-dropzone's item template must contain.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- carousel: drop the `data-action` wiring and any authored `aria-pressed` —
|
|
44
|
+
clicks, picker keys, hover, and focus are delegated, and `aria-pressed` /
|
|
45
|
+
`aria-disabled` / `aria-live` / `aria-atomic` are controller-owned. Name the
|
|
46
|
+
toggle with `aria-label`, pair `prev` with `next`, and give an `autoplay`
|
|
47
|
+
carousel a `playToggle` (WCAG 2.2.2).
|
|
48
|
+
- otp: the root needs `role="group"` and a name, and each `field` its own.
|
|
49
|
+
`invalid` reports only input that filled no field.
|
|
50
|
+
- file-dropzone: put a `stimeo--announcer` on the page, set the `announce*Text`
|
|
51
|
+
wording, and use `name` / `thumb` / `remove` targets in the item template with
|
|
52
|
+
an authored `aria-label`. Validation runs `type` → `size` → `duplicate` →
|
|
53
|
+
`count`, and `change` precedes `reject`.
|
|
54
|
+
- input-mask: `change` fires only for a user edit; normalization and runtime
|
|
55
|
+
Value changes report `reconcile`. The manifest reports `tokens` as `String`
|
|
56
|
+
where it reported `Object`.
|
|
57
|
+
- textarea-autosize: `resize` no longer fires when a connect leaves the height
|
|
58
|
+
unchanged; width, font, and runtime `minRows` / `maxRows` changes are followed.
|
|
59
|
+
- currency-input keeps what was typed while typing and rounds on blur;
|
|
60
|
+
nested-form absorbs rows added or removed outside it; auto-submit follows a
|
|
61
|
+
swapped `form` target.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- carousel: the play toggle stops rotation — the `focusin` before the click no
|
|
66
|
+
longer makes the press resume instead. otp: a full-width commit into one field
|
|
67
|
+
fills the following ones. currency-input: a locale with non-Latin digits
|
|
68
|
+
reparses its own output. nested-form: a discarded-but-visible row's remove
|
|
69
|
+
button no longer swallows the click.
|
|
70
|
+
|
|
10
71
|
## [0.7.0] - 2026-08-16
|
|
11
72
|
|
|
12
73
|
Minor release reworking the form stack, separator, and scroll-area, with
|
|
@@ -634,6 +695,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
634
695
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
635
696
|
RubyGems.
|
|
636
697
|
|
|
698
|
+
[0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
|
|
637
699
|
[0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
|
|
638
700
|
[0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.0
|
|
639
701
|
[0.5.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.5.0
|
|
@@ -19,6 +19,8 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
19
19
|
* </div>
|
|
20
20
|
*
|
|
21
21
|
* `submit` dispatches `{ trigger }`; `done` dispatches `{ message? }`.
|
|
22
|
+
* `reconcile` dispatches `{}` when the Turbo cache rewind drops a submission
|
|
23
|
+
* that was pending or in flight — `done` would claim a response arrived.
|
|
22
24
|
*
|
|
23
25
|
* @remarks
|
|
24
26
|
* Behavior only — it owns *triggering* the submit (debounce + `requestSubmit`),
|
|
@@ -28,8 +30,13 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
28
30
|
* setting `announce` bridges the completion to the shared `stimeo--announcer`
|
|
29
31
|
* as a safety net; apps can also listen for `stimeo--auto-submit:done`
|
|
30
32
|
* and announce richer text themselves. `aria-busy` marks the in-flight window and
|
|
31
|
-
* `data-auto-submit-pending` the debounce window, for consumer CSS
|
|
32
|
-
*
|
|
33
|
+
* `data-auto-submit-pending` the debounce window, for consumer CSS; both hooks are
|
|
34
|
+
* rewound just before Turbo caches the page so they never burn into a snapshot.
|
|
35
|
+
* The `turbo:submit-end`/composition subscriptions follow the `form` target as it
|
|
36
|
+
* is added, replaced, or removed at runtime; unbinding a form drops its pending
|
|
37
|
+
* debounced submit, since that submit described a form that is going away. With
|
|
38
|
+
* no resolvable form (no target and a non-`<form>` root) the controller is inert.
|
|
39
|
+
* During IME composition it holds the submit until `compositionend` (the confirmed
|
|
33
40
|
* conversion) so it does not fire on each intermediate keystroke. The debounce
|
|
34
41
|
* timer and the `turbo:submit-end`/composition listeners are torn down on
|
|
35
42
|
* `disconnect()`.
|
|
@@ -56,7 +63,7 @@ declare class AutoSubmitController extends Controller<HTMLElement> {
|
|
|
56
63
|
};
|
|
57
64
|
};
|
|
58
65
|
static actions: readonly ["submit"];
|
|
59
|
-
static events: readonly ["submit", "done"];
|
|
66
|
+
static events: readonly ["submit", "done", "reconcile"];
|
|
60
67
|
readonly formTarget: HTMLFormElement;
|
|
61
68
|
readonly hasFormTarget: boolean;
|
|
62
69
|
debounceValue: number;
|
|
@@ -65,6 +72,10 @@ declare class AutoSubmitController extends Controller<HTMLElement> {
|
|
|
65
72
|
messageValue: string;
|
|
66
73
|
connect(): void;
|
|
67
74
|
disconnect(): void;
|
|
75
|
+
/** Follows a `form` target added (or swapped in) at runtime. */
|
|
76
|
+
formTargetConnected(): void;
|
|
77
|
+
/** Follows a `form` target removed (or swapped out) at runtime. */
|
|
78
|
+
formTargetDisconnected(): void;
|
|
68
79
|
/**
|
|
69
80
|
* Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
|
|
70
81
|
* allowlist so a configured subset (e.g. only `change`) is honored even when both
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/auto_submit_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/composition_tracker.ts
|
|
6
35
|
var CompositionTracker = class {
|
|
7
36
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -112,14 +141,18 @@ var AutoSubmitController = class extends Controller {
|
|
|
112
141
|
message: { type: String, default: "" }
|
|
113
142
|
};
|
|
114
143
|
static actions = ["submit"];
|
|
115
|
-
static events = ["submit", "done"];
|
|
144
|
+
static events = ["submit", "done", "reconcile"];
|
|
116
145
|
/** Debounce timer registry; one `clearAll()` in disconnect tears it down. */
|
|
117
146
|
#timers = new SafeTimeout();
|
|
118
147
|
/** Id of the pending debounce timer, so a new keystroke can reset it. */
|
|
119
148
|
#pendingId = 0;
|
|
149
|
+
/** The form the listeners are attached to; target callbacks rebind it. */
|
|
150
|
+
#boundForm = null;
|
|
151
|
+
/** Rewinds the transient state hooks just before Turbo snapshots the page. */
|
|
152
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
120
153
|
/** Clears `aria-busy` and emits completion once Turbo finishes the submit. */
|
|
121
154
|
#onSubmitEnd = () => {
|
|
122
|
-
this.#
|
|
155
|
+
this.#boundForm?.removeAttribute("aria-busy");
|
|
123
156
|
const message = this.messageValue;
|
|
124
157
|
this.dispatch("done", { detail: { message: message || void 0 } });
|
|
125
158
|
if (this.announceValue && message) {
|
|
@@ -133,15 +166,23 @@ var AutoSubmitController = class extends Controller {
|
|
|
133
166
|
}
|
|
134
167
|
});
|
|
135
168
|
connect() {
|
|
136
|
-
this.#
|
|
137
|
-
this.#
|
|
169
|
+
this.#beforeCache.activate();
|
|
170
|
+
this.#bindForm(this.#resolveForm());
|
|
138
171
|
}
|
|
139
172
|
disconnect() {
|
|
173
|
+
this.#beforeCache.deactivate();
|
|
174
|
+
this.#bindForm(null);
|
|
175
|
+
this.#composition.disconnect();
|
|
140
176
|
this.#timers.clearAll();
|
|
141
177
|
this.#pendingId = 0;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
178
|
+
}
|
|
179
|
+
/** Follows a `form` target added (or swapped in) at runtime. */
|
|
180
|
+
formTargetConnected() {
|
|
181
|
+
this.#bindForm(this.#resolveForm());
|
|
182
|
+
}
|
|
183
|
+
/** Follows a `form` target removed (or swapped out) at runtime. */
|
|
184
|
+
formTargetDisconnected() {
|
|
185
|
+
this.#bindForm(this.#resolveForm());
|
|
145
186
|
}
|
|
146
187
|
/**
|
|
147
188
|
* Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
|
|
@@ -155,21 +196,58 @@ var AutoSubmitController = class extends Controller {
|
|
|
155
196
|
}
|
|
156
197
|
/** Schedules (and coalesces) the debounced submit for the given trigger. */
|
|
157
198
|
#schedule(trigger) {
|
|
158
|
-
this.#
|
|
159
|
-
if (
|
|
199
|
+
const form = this.#boundForm;
|
|
200
|
+
if (!form) return;
|
|
201
|
+
form.setAttribute("data-auto-submit-pending", "true");
|
|
202
|
+
this.#cancelPending();
|
|
160
203
|
this.#pendingId = this.#timers.set(() => {
|
|
161
204
|
this.#pendingId = 0;
|
|
162
|
-
|
|
205
|
+
form.removeAttribute("data-auto-submit-pending");
|
|
163
206
|
this.dispatch("submit", { detail: { trigger } });
|
|
164
|
-
if (
|
|
165
|
-
|
|
207
|
+
if (form.checkValidity()) {
|
|
208
|
+
form.setAttribute("aria-busy", "true");
|
|
166
209
|
}
|
|
167
|
-
|
|
210
|
+
form.requestSubmit();
|
|
168
211
|
}, this.debounceValue);
|
|
169
212
|
}
|
|
170
|
-
/**
|
|
171
|
-
|
|
172
|
-
|
|
213
|
+
/** Cancels the pending debounced submit, if any (`clear` no-ops on unknown ids). */
|
|
214
|
+
#cancelPending() {
|
|
215
|
+
this.#timers.clear(this.#pendingId);
|
|
216
|
+
this.#pendingId = 0;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Replaces the subscribed form symmetrically. The outgoing form loses the
|
|
220
|
+
* `turbo:submit-end`/composition listeners, its pending hook, and any pending
|
|
221
|
+
* debounced submit (which described the outgoing form). An in-flight `aria-busy`
|
|
222
|
+
* is left for `turbo:submit-end` or the pre-cache rewind — removing it here
|
|
223
|
+
* would wipe a legitimately in-progress submission.
|
|
224
|
+
*/
|
|
225
|
+
#bindForm(form) {
|
|
226
|
+
if (form === this.#boundForm) return;
|
|
227
|
+
const previous = this.#boundForm;
|
|
228
|
+
if (previous) {
|
|
229
|
+
this.#cancelPending();
|
|
230
|
+
previous.removeAttribute("data-auto-submit-pending");
|
|
231
|
+
previous.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
232
|
+
this.#composition.unobserve(previous);
|
|
233
|
+
}
|
|
234
|
+
this.#boundForm = form;
|
|
235
|
+
if (!form) return;
|
|
236
|
+
form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
237
|
+
this.#composition.observe(form);
|
|
238
|
+
}
|
|
239
|
+
/** Resolves the form: the explicit `form` target, else a `<form>` root, else null. */
|
|
240
|
+
#resolveForm() {
|
|
241
|
+
if (this.hasFormTarget) return this.formTarget;
|
|
242
|
+
return this.element instanceof HTMLFormElement ? this.element : null;
|
|
243
|
+
}
|
|
244
|
+
/** Returns the transient state hooks to their initial (absent) state. */
|
|
245
|
+
#rewindForCache() {
|
|
246
|
+
const inProgress = this.#pendingId !== 0 || this.#boundForm?.hasAttribute("aria-busy") === true;
|
|
247
|
+
this.#cancelPending();
|
|
248
|
+
this.#boundForm?.removeAttribute("data-auto-submit-pending");
|
|
249
|
+
this.#boundForm?.removeAttribute("aria-busy");
|
|
250
|
+
if (inProgress) this.dispatch("reconcile", { detail: {} });
|
|
173
251
|
}
|
|
174
252
|
/** Whether `type` is one of the whitespace-separated event types in `on`. */
|
|
175
253
|
#triggers(type) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/utils/safe_timeout.ts","../../src/controllers/auto_submit_controller.ts"],"names":[],"mappings":"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","file":"auto_submit_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","/**\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 { CompositionTracker } from \"../utils/composition_tracker\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless **debounced auto-submit** for forms (no dedicated APG pattern). Submits\n * the form a configurable delay after `input`/`change`, so Rails search/filter\n * forms refresh via Turbo without a submit button.\n *\n * Markup contract (identifier: `stimeo--auto-submit`):\n * <form data-controller=\"stimeo--auto-submit\"\n * data-stimeo--auto-submit-debounce-value=\"300\"\n * data-action=\"input->stimeo--auto-submit#submit\n * change->stimeo--auto-submit#submit\">\n * <input type=\"search\" name=\"q\">\n * </form>\n *\n * <!-- Or with the form as a target nested under the controller element: -->\n * <div data-controller=\"stimeo--auto-submit\">\n * <form data-stimeo--auto-submit-target=\"form\"> … </form>\n * </div>\n *\n * `submit` dispatches `{ trigger }`; `done` dispatches `{ message? }`.\n *\n * @remarks\n * Behavior only — it owns *triggering* the submit (debounce + `requestSubmit`),\n * never the submit itself (Turbo / native form submission) or validation. It\n * **never moves focus** (WCAG 2.2 3.2.2 / 4.1.3): auto-submitting must not yank the\n * caret out of the field. While a result swap is silent for screen-reader users,\n * setting `announce` bridges the completion to the shared `stimeo--announcer`\n * as a safety net; apps can also listen for `stimeo--auto-submit:done`\n * and announce richer text themselves. `aria-busy` marks the in-flight window and\n * `data-auto-submit-pending` the debounce window, for consumer CSS. During IME\n * composition it holds the submit until `compositionend` (the confirmed\n * conversion) so it does not fire on each intermediate keystroke. The debounce\n * timer and the `turbo:submit-end`/composition listeners are torn down on\n * `disconnect()`.\n */\nexport class AutoSubmitController extends Controller<HTMLElement> {\n static override targets = [\"form\"];\n static override values = {\n debounce: { type: Number, default: 300 },\n on: { type: String, default: \"input change\" },\n announce: { type: Boolean, default: false },\n message: { type: String, default: \"\" },\n };\n static actions = [\"submit\"] as const;\n static events = [\"submit\", \"done\"] as const;\n\n declare readonly formTarget: HTMLFormElement;\n declare readonly hasFormTarget: boolean;\n\n declare debounceValue: number;\n declare onValue: string;\n declare announceValue: boolean;\n declare messageValue: string;\n\n /** Debounce timer registry; one `clearAll()` in disconnect tears it down. */\n readonly #timers = new SafeTimeout();\n /** Id of the pending debounce timer, so a new keystroke can reset it. */\n #pendingId = 0;\n\n /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */\n readonly #onSubmitEnd = (): void => {\n this.#form.removeAttribute(\"aria-busy\");\n const message = this.messageValue;\n this.dispatch(\"done\", { detail: { message: message || undefined } });\n // Bridge the silent result swap to the shared Announcer so SR users hear it.\n if (this.announceValue && message) {\n window.dispatchEvent(new CustomEvent(\"stimeo--announcer:announce\", { detail: { message } }));\n }\n };\n\n /** Owns delegated IME lifecycle state and submits confirmed input text. */\n readonly #composition = new CompositionTracker({\n onEnd: (event) => {\n if (this.#triggers(\"input\")) this.#schedule((event.target as HTMLElement | null) ?? null);\n },\n });\n\n override connect(): void {\n this.#form.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#composition.observe(this.#form);\n }\n\n override disconnect(): void {\n this.#timers.clearAll();\n this.#pendingId = 0;\n this.#composition.disconnect();\n this.#form.removeAttribute(\"data-auto-submit-pending\");\n this.#form.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n }\n\n /**\n * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an\n * allowlist so a configured subset (e.g. only `change`) is honored even when both\n * are bound in markup. Coalesces rapid events into a single `requestSubmit`.\n */\n submit(event: Event): void {\n if (!this.#triggers(event.type)) return;\n // Ignore `input` events fired mid-IME-composition (e.g. typing kana before the\n // Japanese conversion is confirmed); the confirmed text submits on\n // `compositionend` and the browser's final post-composition `input`.\n if (event.type === \"input\" && this.#composition.isComposing(event as InputEvent)) return;\n this.#schedule((event.target as HTMLElement | null) ?? null);\n }\n\n /** Schedules (and coalesces) the debounced submit for the given trigger. */\n #schedule(trigger: HTMLElement | null): void {\n this.#form.setAttribute(\"data-auto-submit-pending\", \"true\");\n if (this.#pendingId) this.#timers.clear(this.#pendingId);\n\n this.#pendingId = this.#timers.set(() => {\n this.#pendingId = 0;\n this.#form.removeAttribute(\"data-auto-submit-pending\");\n this.dispatch(\"submit\", { detail: { trigger } });\n // `requestSubmit()` runs native constraint validation. If the form is\n // invalid the actual submit never happens — the browser blocks it (or, when\n // a `stimeo--form-validation` set `novalidate`, that controller cancels the\n // submit) — so no `turbo:submit-end` arrives to clear `aria-busy`. Only mark\n // the form busy when the submit will really proceed; still call\n // `requestSubmit()` either way so the validation surfaces to the user.\n if (this.#form.checkValidity()) {\n this.#form.setAttribute(\"aria-busy\", \"true\");\n }\n this.#form.requestSubmit();\n }, this.debounceValue);\n }\n\n /** Resolves the form element (explicit `form` target, else the controller root). */\n get #form(): HTMLFormElement {\n return this.hasFormTarget ? this.formTarget : (this.element as HTMLFormElement);\n }\n\n /** Whether `type` is one of the whitespace-separated event types in `on`. */\n #triggers(type: string): boolean {\n return this.onValue.split(/\\s+/).filter(Boolean).includes(type);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/before_cache_reset.ts","../../src/utils/composition_tracker.ts","../../src/utils/safe_timeout.ts","../../src/controllers/auto_submit_controller.ts"],"names":[],"mappings":"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","file":"auto_submit_controller.js","sourcesContent":["/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way (the `MicrotaskCoalescer` split).\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","/**\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 { BeforeCacheReset } from \"../utils/before_cache_reset\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless **debounced auto-submit** for forms (no dedicated APG pattern). Submits\n * the form a configurable delay after `input`/`change`, so Rails search/filter\n * forms refresh via Turbo without a submit button.\n *\n * Markup contract (identifier: `stimeo--auto-submit`):\n * <form data-controller=\"stimeo--auto-submit\"\n * data-stimeo--auto-submit-debounce-value=\"300\"\n * data-action=\"input->stimeo--auto-submit#submit\n * change->stimeo--auto-submit#submit\">\n * <input type=\"search\" name=\"q\">\n * </form>\n *\n * <!-- Or with the form as a target nested under the controller element: -->\n * <div data-controller=\"stimeo--auto-submit\">\n * <form data-stimeo--auto-submit-target=\"form\"> … </form>\n * </div>\n *\n * `submit` dispatches `{ trigger }`; `done` dispatches `{ message? }`.\n * `reconcile` dispatches `{}` when the Turbo cache rewind drops a submission\n * that was pending or in flight — `done` would claim a response arrived.\n *\n * @remarks\n * Behavior only — it owns *triggering* the submit (debounce + `requestSubmit`),\n * never the submit itself (Turbo / native form submission) or validation. It\n * **never moves focus** (WCAG 2.2 3.2.2 / 4.1.3): auto-submitting must not yank the\n * caret out of the field. While a result swap is silent for screen-reader users,\n * setting `announce` bridges the completion to the shared `stimeo--announcer`\n * as a safety net; apps can also listen for `stimeo--auto-submit:done`\n * and announce richer text themselves. `aria-busy` marks the in-flight window and\n * `data-auto-submit-pending` the debounce window, for consumer CSS; both hooks are\n * rewound just before Turbo caches the page so they never burn into a snapshot.\n * The `turbo:submit-end`/composition subscriptions follow the `form` target as it\n * is added, replaced, or removed at runtime; unbinding a form drops its pending\n * debounced submit, since that submit described a form that is going away. With\n * no resolvable form (no target and a non-`<form>` root) the controller is inert.\n * During IME composition it holds the submit until `compositionend` (the confirmed\n * conversion) so it does not fire on each intermediate keystroke. The debounce\n * timer and the `turbo:submit-end`/composition listeners are torn down on\n * `disconnect()`.\n */\nexport class AutoSubmitController extends Controller<HTMLElement> {\n static override targets = [\"form\"];\n static override values = {\n debounce: { type: Number, default: 300 },\n on: { type: String, default: \"input change\" },\n announce: { type: Boolean, default: false },\n message: { type: String, default: \"\" },\n };\n static actions = [\"submit\"] as const;\n static events = [\"submit\", \"done\", \"reconcile\"] as const;\n\n declare readonly formTarget: HTMLFormElement;\n declare readonly hasFormTarget: boolean;\n\n declare debounceValue: number;\n declare onValue: string;\n declare announceValue: boolean;\n declare messageValue: string;\n\n /** Debounce timer registry; one `clearAll()` in disconnect tears it down. */\n readonly #timers = new SafeTimeout();\n /** Id of the pending debounce timer, so a new keystroke can reset it. */\n #pendingId = 0;\n /** The form the listeners are attached to; target callbacks rebind it. */\n #boundForm: HTMLFormElement | null = null;\n /** Rewinds the transient state hooks just before Turbo snapshots the page. */\n readonly #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());\n\n /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */\n readonly #onSubmitEnd = (): void => {\n this.#boundForm?.removeAttribute(\"aria-busy\");\n const message = this.messageValue;\n this.dispatch(\"done\", { detail: { message: message || undefined } });\n // Bridge the silent result swap to the shared Announcer so SR users hear it.\n if (this.announceValue && message) {\n window.dispatchEvent(new CustomEvent(\"stimeo--announcer:announce\", { detail: { message } }));\n }\n };\n\n /** Owns delegated IME lifecycle state and submits confirmed input text. */\n readonly #composition = new CompositionTracker({\n onEnd: (event) => {\n if (this.#triggers(\"input\")) this.#schedule((event.target as HTMLElement | null) ?? null);\n },\n });\n\n override connect(): void {\n this.#beforeCache.activate();\n this.#bindForm(this.#resolveForm());\n }\n\n override disconnect(): void {\n this.#beforeCache.deactivate();\n this.#bindForm(null);\n this.#composition.disconnect();\n this.#timers.clearAll();\n this.#pendingId = 0;\n }\n\n /** Follows a `form` target added (or swapped in) at runtime. */\n formTargetConnected(): void {\n this.#bindForm(this.#resolveForm());\n }\n\n /** Follows a `form` target removed (or swapped out) at runtime. */\n formTargetDisconnected(): void {\n this.#bindForm(this.#resolveForm());\n }\n\n /**\n * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an\n * allowlist so a configured subset (e.g. only `change`) is honored even when both\n * are bound in markup. Coalesces rapid events into a single `requestSubmit`.\n */\n submit(event: Event): void {\n if (!this.#triggers(event.type)) return;\n // Ignore `input` events fired mid-IME-composition (e.g. typing kana before the\n // Japanese conversion is confirmed); the confirmed text submits on\n // `compositionend` and the browser's final post-composition `input`.\n if (event.type === \"input\" && this.#composition.isComposing(event as InputEvent)) return;\n this.#schedule((event.target as HTMLElement | null) ?? null);\n }\n\n /** Schedules (and coalesces) the debounced submit for the given trigger. */\n #schedule(trigger: HTMLElement | null): void {\n const form = this.#boundForm;\n if (!form) return;\n form.setAttribute(\"data-auto-submit-pending\", \"true\");\n this.#cancelPending();\n\n this.#pendingId = this.#timers.set(() => {\n this.#pendingId = 0;\n form.removeAttribute(\"data-auto-submit-pending\");\n this.dispatch(\"submit\", { detail: { trigger } });\n // `requestSubmit()` runs native constraint validation. If the form is\n // invalid the actual submit never happens — the browser blocks it (or, when\n // a `stimeo--form-validation` set `novalidate`, that controller cancels the\n // submit) — so no `turbo:submit-end` arrives to clear `aria-busy`. Only mark\n // the form busy when the submit will really proceed; still call\n // `requestSubmit()` either way so the validation surfaces to the user.\n if (form.checkValidity()) {\n form.setAttribute(\"aria-busy\", \"true\");\n }\n form.requestSubmit();\n }, this.debounceValue);\n }\n\n /** Cancels the pending debounced submit, if any (`clear` no-ops on unknown ids). */\n #cancelPending(): void {\n this.#timers.clear(this.#pendingId);\n this.#pendingId = 0;\n }\n\n /**\n * Replaces the subscribed form symmetrically. The outgoing form loses the\n * `turbo:submit-end`/composition listeners, its pending hook, and any pending\n * debounced submit (which described the outgoing form). An in-flight `aria-busy`\n * is left for `turbo:submit-end` or the pre-cache rewind — removing it here\n * would wipe a legitimately in-progress submission.\n */\n #bindForm(form: HTMLFormElement | null): void {\n if (form === this.#boundForm) return;\n const previous = this.#boundForm;\n if (previous) {\n this.#cancelPending();\n previous.removeAttribute(\"data-auto-submit-pending\");\n previous.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#composition.unobserve(previous);\n }\n this.#boundForm = form;\n if (!form) return;\n form.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#composition.observe(form);\n }\n\n /** Resolves the form: the explicit `form` target, else a `<form>` root, else null. */\n #resolveForm(): HTMLFormElement | null {\n if (this.hasFormTarget) return this.formTarget;\n return this.element instanceof HTMLFormElement ? this.element : null;\n }\n\n /** Returns the transient state hooks to their initial (absent) state. */\n #rewindForCache(): void {\n const inProgress = this.#pendingId !== 0 || this.#boundForm?.hasAttribute(\"aria-busy\") === true;\n this.#cancelPending();\n this.#boundForm?.removeAttribute(\"data-auto-submit-pending\");\n this.#boundForm?.removeAttribute(\"aria-busy\");\n if (inProgress) this.dispatch(\"reconcile\", { detail: {} });\n }\n\n /** Whether `type` is one of the whitespace-separated event types in `on`. */\n #triggers(type: string): boolean {\n return this.onValue.split(/\\s+/).filter(Boolean).includes(type);\n }\n}\n"]}
|
|
@@ -8,43 +8,56 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
8
8
|
* aria-label="Featured"
|
|
9
9
|
* data-stimeo--carousel-autoplay-value="false"
|
|
10
10
|
* data-stimeo--carousel-interval-value="5000"
|
|
11
|
-
* data-stimeo--carousel-loop-value="true"
|
|
12
|
-
*
|
|
13
|
-
* mouseleave->stimeo--carousel#resume
|
|
14
|
-
* focusin->stimeo--carousel#pause
|
|
15
|
-
* focusout->stimeo--carousel#resume">
|
|
16
|
-
* <button data-stimeo--carousel-target="playToggle"
|
|
17
|
-
* data-action="stimeo--carousel#togglePlay">…</button>
|
|
11
|
+
* data-stimeo--carousel-loop-value="true">
|
|
12
|
+
* <button data-stimeo--carousel-target="playToggle">…</button>
|
|
18
13
|
* <div data-stimeo--carousel-target="viewport">
|
|
19
14
|
* <div role="tabpanel" data-stimeo--carousel-target="slide">…</div>
|
|
20
|
-
* <div role="tabpanel" data-stimeo--carousel-target="slide" hidden>…</div>
|
|
15
|
+
* <div role="tabpanel" data-stimeo--carousel-target="slide" hidden inert>…</div>
|
|
21
16
|
* </div>
|
|
22
|
-
* <button data-stimeo--carousel-target="prev"
|
|
23
|
-
* <button data-stimeo--carousel-target="next"
|
|
17
|
+
* <button data-stimeo--carousel-target="prev">‹</button>
|
|
18
|
+
* <button data-stimeo--carousel-target="next">›</button>
|
|
24
19
|
* <div role="tablist">
|
|
25
|
-
* <button role="tab" data-stimeo--carousel-target="picker"
|
|
26
|
-
* data-action="stimeo--carousel#goto
|
|
27
|
-
* keydown->stimeo--carousel#onPickerKeydown"></button>
|
|
20
|
+
* <button role="tab" data-stimeo--carousel-target="picker"></button>
|
|
28
21
|
* </div>
|
|
29
22
|
* </section>
|
|
30
23
|
*
|
|
31
|
-
* Implements the WAI-ARIA APG **Carousel**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* `
|
|
35
|
-
* `
|
|
24
|
+
* Implements the WAI-ARIA APG **Carousel** pattern. With pickers it is the tabbed
|
|
25
|
+
* variant: each picker is a `tab` carrying `aria-selected` and the single roving
|
|
26
|
+
* `tabindex`, and each slide is its `tabpanel`. Without pickers the slides are
|
|
27
|
+
* `group`s and `prev`/`next` drive them. The current slide is exposed through
|
|
28
|
+
* `data-state` (`active`/`inactive`); the rest carry `hidden` **and** `inert`, so
|
|
29
|
+
* they stay out of the focus order and the accessibility tree even when consumer
|
|
30
|
+
* CSS overrides `display` to lay the slides out as a track.
|
|
36
31
|
*
|
|
37
32
|
* @remarks
|
|
38
33
|
* Behavior only — transitions, layout, and visuals are the consumer's CSS.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* the
|
|
34
|
+
*
|
|
35
|
+
* **The controller owns its own wiring.** Clicks, picker keys, hover, and focus
|
|
36
|
+
* are delegated from the controller element, so no `data-action` is required and
|
|
37
|
+
* a target added at runtime works the moment it appears. Explicit `data-action`
|
|
38
|
+
* bindings to the declared actions still work and coexist with the delegated
|
|
39
|
+
* path: the binding runs first and marks the event, and the delegated listener
|
|
40
|
+
* stands down, so one interaction is never handled twice.
|
|
41
|
+
*
|
|
42
|
+
* **`autoplay` is the single source of truth for the rotation intent.** The
|
|
43
|
+
* toggle writes back to it, so the state survives a Turbo Drive cache restore
|
|
44
|
+
* without a second, competing signal; `aria-pressed` is a pure output the
|
|
45
|
+
* controller owns. Rotation is suspended — not cancelled — while the pointer
|
|
46
|
+
* rests on the carousel, while focus is inside it, and while the tab is hidden;
|
|
47
|
+
* each suspension lifts on its own (WCAG 2.2.2 is met by the toggle, which is
|
|
48
|
+
* the one control that stops rotation for good). A carousel with nothing left to
|
|
49
|
+
* advance to (one slide, or the last slide of a non-looping set) normalizes
|
|
50
|
+
* `autoplay` to `false` and marks the toggle `aria-disabled`. `prefers-reduced-motion`
|
|
51
|
+
* does the same at connect, leaving an explicit press free to start rotation.
|
|
52
|
+
*
|
|
53
|
+
* The interval is cleared on `disconnect()` (Turbo navigation included), and the
|
|
54
|
+
* leased ARIA is returned before Turbo caches the page. Picker arrow keys, `Home`,
|
|
55
|
+
* and `End` move focus only (manual activation); slide changes never steal focus
|
|
56
|
+
* from the control the user operated. A slide change the user drove — including an
|
|
57
|
+
* autoplay tick they started — is reported as `stimeo--carousel:change` with
|
|
58
|
+
* `{ index, total }`; the same detail arrives as `stimeo--carousel:reconcile` when
|
|
59
|
+
* the controller re-derives the position itself, whether a target came or went or
|
|
60
|
+
* a retained element's state attributes were rewritten in place.
|
|
48
61
|
*/
|
|
49
62
|
declare class CarouselController extends Controller<HTMLElement> {
|
|
50
63
|
#private;
|
|
@@ -67,18 +80,27 @@ declare class CarouselController extends Controller<HTMLElement> {
|
|
|
67
80
|
static events: readonly ["change", "pause", "play", "reconcile"];
|
|
68
81
|
readonly slideTargets: HTMLElement[];
|
|
69
82
|
readonly pickerTargets: HTMLElement[];
|
|
70
|
-
readonly
|
|
71
|
-
readonly
|
|
83
|
+
readonly viewportTarget: HTMLElement;
|
|
84
|
+
readonly hasViewportTarget: boolean;
|
|
85
|
+
readonly prevTargets: HTMLElement[];
|
|
86
|
+
readonly nextTargets: HTMLElement[];
|
|
87
|
+
readonly playToggleTargets: HTMLElement[];
|
|
72
88
|
autoplayValue: boolean;
|
|
73
89
|
intervalValue: number;
|
|
74
90
|
loopValue: boolean;
|
|
75
91
|
/**
|
|
76
|
-
* Renders the initial slide and starts autoplay
|
|
92
|
+
* Renders the initial slide, wires the delegated listeners, and starts autoplay
|
|
93
|
+
* when requested.
|
|
77
94
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
95
|
+
* Every suspension is re-derived from the environment rather than carried, so an
|
|
96
|
+
* in-page move — which Stimulus delivers to the *same* controller instance as
|
|
97
|
+
* `disconnect()` then `connect()` — cannot strand the carousel in a suspension
|
|
98
|
+
* whose lifting event will never arrive. The attribute observer starts after the
|
|
99
|
+
* first render so the controller's own opening writes are not fed back to it.
|
|
80
100
|
*/
|
|
81
101
|
connect(): void;
|
|
102
|
+
/** Releases every listener and observer, returns the leased ARIA, drops the suspensions. */
|
|
103
|
+
disconnect(): void;
|
|
82
104
|
/**
|
|
83
105
|
* Re-establishes the single selected picker when one is added after connect.
|
|
84
106
|
*
|
|
@@ -95,29 +117,41 @@ declare class CarouselController extends Controller<HTMLElement> {
|
|
|
95
117
|
slideTargetConnected(): void;
|
|
96
118
|
/** Re-clamps the active index after a slide is removed. */
|
|
97
119
|
slideTargetDisconnected(): void;
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
|
|
120
|
+
/** Follows a rotation intent the page changed at runtime. */
|
|
121
|
+
autoplayValueChanged(): void;
|
|
122
|
+
/** Re-arms the live interval at the new delay without reporting a state change. */
|
|
123
|
+
intervalValueChanged(): void;
|
|
124
|
+
/** Re-publishes the step controls and re-evaluates the non-looping end. */
|
|
125
|
+
loopValueChanged(): void;
|
|
126
|
+
/** Advances to the next slide. Delegated; `data-action` wiring is optional. */
|
|
127
|
+
next(event?: Event): void;
|
|
128
|
+
/** Returns to the previous slide. Delegated; `data-action` wiring is optional. */
|
|
129
|
+
prev(event?: Event): void;
|
|
104
130
|
/** Jumps to the slide whose picker was activated (click / Enter / Space). */
|
|
105
131
|
goto(event: Event): void;
|
|
106
|
-
/** Toggles autoplay on the user's explicit request and syncs the timer. */
|
|
107
|
-
togglePlay(): void;
|
|
108
132
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
133
|
+
* Flips the rotation intent on the user's explicit request.
|
|
134
|
+
*
|
|
135
|
+
* The intent is written back to the `autoplay` Value, which is where every other
|
|
136
|
+
* path reads it from. A carousel with nothing to advance to has no intent to
|
|
137
|
+
* flip: the toggle is marked `aria-disabled` and does nothing.
|
|
138
|
+
*/
|
|
139
|
+
togglePlay(event?: Event): void;
|
|
140
|
+
/**
|
|
141
|
+
* Suspends rotation. A `focus`-family event records that focus is inside the
|
|
142
|
+
* carousel; anything else — hover, or a bare programmatic call — records the
|
|
143
|
+
* pointer suspension. Both lift through the matching {@link resume}, and
|
|
144
|
+
* neither touches the rotation intent.
|
|
112
145
|
*/
|
|
113
146
|
pause(event?: Event): void;
|
|
114
147
|
/**
|
|
115
|
-
* Lifts
|
|
116
|
-
*
|
|
117
|
-
*
|
|
148
|
+
* Lifts the matching suspension. A `focusout` whose `relatedTarget` is still
|
|
149
|
+
* inside the carousel is focus moving between its own controls, which leaves the
|
|
150
|
+
* focus suspension in place — releasing it there would stop and restart the
|
|
151
|
+
* interval on every Tab press.
|
|
118
152
|
*/
|
|
119
153
|
resume(event?: Event): void;
|
|
120
|
-
/** Picker roving
|
|
154
|
+
/** Picker roving for authored bindings; the delegated path is `#onKeydown`. */
|
|
121
155
|
onPickerKeydown(event: KeyboardEvent): void;
|
|
122
156
|
}
|
|
123
157
|
|