stimeo-ui 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +124 -0
- data/dist/controllers/alert_dialog_controller.js +75 -4
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/avatar_controller.js +47 -5
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/character_counter_controller.js +338 -63
- data/dist/controllers/command_palette_controller.js +75 -4
- data/dist/controllers/conditional_fields_controller.js +345 -51
- data/dist/controllers/confirm_controller.js +75 -4
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/dialog_controller.js +75 -4
- data/dist/controllers/direct_upload_controller.js +212 -46
- data/dist/controllers/dirty_form_controller.js +192 -29
- data/dist/controllers/dismissible_controller.js +83 -18
- data/dist/controllers/drawer_controller.js +75 -4
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/focus_controller.js +75 -4
- data/dist/controllers/form_field_controller.js +280 -62
- data/dist/controllers/form_validation_controller.js +208 -83
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/number_input_controller.js +47 -5
- data/dist/controllers/otp_controller.js +472 -114
- data/dist/controllers/overflow_menu_controller.js +2 -1
- data/dist/controllers/pagination_controller.js +2 -1
- data/dist/controllers/persist_controller.js +432 -122
- data/dist/controllers/popover_controller.js +77 -4
- data/dist/controllers/rating_controller.js +9 -5
- data/dist/controllers/scroll_area_controller.js +462 -162
- data/dist/controllers/separator_controller.js +354 -38
- data/dist/controllers/sidebar_controller.js +83 -10
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +402 -122
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/controllers/theme_controller.js +8 -6
- data/dist/controllers/tree_view_controller.js +2 -1
- data/dist/index.js +4293 -1447
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7dc7faa9f1a7d87af3398be60736f22e76e8e1b6ef534d0b8605e3fcdb9f4eb
|
|
4
|
+
data.tar.gz: 05bd47298cc4e0592f1247140c83e1cedff106c91b6a972734e5d9d3a507e3ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa22c951a7b4d5ba07c34f5f17c86227f8079d1222c40d895a1198c274c021198dc89edad6c129df4526b79001455834c88bd7ffdd46eb95dbd76166dba5975e
|
|
7
|
+
data.tar.gz: f95da4d63fee6006b75ec78a7dd91a73d3535fe82b456a298dd7a273e1f415df829c0fe08fc4c065d0d680d70d8434874226f30eadb5eccf4e5fc1b5ef5c99cb
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,128 @@ 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
|
+
|
|
71
|
+
## [0.7.0] - 2026-08-16
|
|
72
|
+
|
|
73
|
+
Minor release reworking the form stack, separator, and scroll-area, with
|
|
74
|
+
announcements moving onto the shared `stimeo--announcer`. The Inspector manifest
|
|
75
|
+
moves to schema v12, so `stimeo check` can report on views that passed under
|
|
76
|
+
0.6.0.
|
|
77
|
+
|
|
78
|
+
### Removed
|
|
79
|
+
|
|
80
|
+
- **Breaking** — direct-upload: the `status` target and the `announce` /
|
|
81
|
+
`doneLabel` / `errorLabel` Values. Use `announceDoneText` /
|
|
82
|
+
`announceErrorText` with `{name}` instead of `%{name}`.
|
|
83
|
+
- **Breaking** — persist: the unversioned draft format. Drafts written by earlier
|
|
84
|
+
releases are discarded, not migrated.
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
|
|
88
|
+
- separator `min` / `max` / `value`; submit-once `finish` / `cancel` and
|
|
89
|
+
`idle` / `busy` targets; dirty-form `acceptRestore`; character-counter
|
|
90
|
+
`announceText`; form-field `setError(message, { focus })`; persist `error`;
|
|
91
|
+
`reconcile` on character-counter and conditional-fields.
|
|
92
|
+
- form-validation: localized constraint wording through
|
|
93
|
+
`data-stimeo--form-validation-message[-<constraint>]`, a declarative
|
|
94
|
+
`disallow="whitespace"`, and controls attached with `form="id"`.
|
|
95
|
+
- Inspector: schema v12 (`requiredActions`, `actionCompletion`) with the
|
|
96
|
+
`missing-required-action`, `missing-action-completion`, and
|
|
97
|
+
`missing-announcer` diagnostics.
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
|
|
101
|
+
- **Breaking** — announcing components need a `stimeo--announcer` on the page
|
|
102
|
+
plus the wording: character-counter `announceText`, direct-upload
|
|
103
|
+
`announceDoneText` / `announceErrorText`, submit-once `announceText` /
|
|
104
|
+
`announceReadyText`. Drop `role="alert"` from form-field's `error` target and
|
|
105
|
+
`aria-live` from character-counter's `output`.
|
|
106
|
+
- **Breaking** — separator: the Values are the only runtime input, so move
|
|
107
|
+
`role`, `tabindex`, and the orientation / range ARIA into `orientation`,
|
|
108
|
+
`focusable`, `min`, `max`, `step`, and `value`. A `focusable` separator also
|
|
109
|
+
needs `keydown->stimeo--separator#onKeydown` on itself and an accessible name.
|
|
110
|
+
- **Breaking** — submit-once: sessions are per form, and `preventDefault()` no
|
|
111
|
+
longer ends one — wire `finish` or `cancel`, or set a `timeout`. `start` /
|
|
112
|
+
`end` carry `{ form, submitter, … }`.
|
|
113
|
+
- **Breaking** — direct-upload: `done` fires only on success, and a row that
|
|
114
|
+
reached `error` stays there.
|
|
115
|
+
- **Breaking** — dirty-form: a successful submit re-baselines to what Turbo
|
|
116
|
+
submitted, so later edits stay dirty, and one native confirmation covers every
|
|
117
|
+
dirty form per navigation. With persist, add
|
|
118
|
+
`data-action="stimeo--persist:restore->stimeo--dirty-form#acceptRestore"`.
|
|
119
|
+
- **Breaking** — character-counter: `change` fires only when a committed edit
|
|
120
|
+
changes the length. form-field: `validate` fires only for `setError` /
|
|
121
|
+
`clearError`.
|
|
122
|
+
- persist: `exclude` defaults to Rails' `authenticity_token` / `_method` /
|
|
123
|
+
`utf8`; `password` is always excluded regardless of the list.
|
|
124
|
+
|
|
125
|
+
### Fixed
|
|
126
|
+
|
|
127
|
+
- Attribute and style leases are released by the subscriber that owns the
|
|
128
|
+
lifecycle, so a cached page no longer retains detached elements.
|
|
129
|
+
- localStorage access goes through one guarded helper, so persist, sidebar, and
|
|
130
|
+
theme keep working when the browser denies storage.
|
|
131
|
+
|
|
10
132
|
## [0.6.0] - 2026-08-15
|
|
11
133
|
|
|
12
134
|
Minor release that separates reconciled state from user edits: `change` now
|
|
@@ -573,6 +695,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
573
695
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
574
696
|
RubyGems.
|
|
575
697
|
|
|
698
|
+
[0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
|
|
699
|
+
[0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
|
|
576
700
|
[0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.0
|
|
577
701
|
[0.5.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.5.0
|
|
578
702
|
[0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
|
|
@@ -115,8 +115,81 @@ var EscapeLayer = class _EscapeLayer {
|
|
|
115
115
|
}
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
// src/utils/focus_candidate.ts
|
|
119
|
+
function inheritsFieldsetDisabled(control) {
|
|
120
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
121
|
+
while (fieldset) {
|
|
122
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
123
|
+
if (!legend?.contains(control)) return true;
|
|
124
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
function canTakeFocus(element) {
|
|
129
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
130
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
131
|
+
if (!("disabled" in element)) return true;
|
|
132
|
+
if (element.disabled) return false;
|
|
133
|
+
return !inheritsFieldsetDisabled(element);
|
|
134
|
+
}
|
|
135
|
+
var TAB_STOP_CANDIDATE_SELECTOR = [
|
|
136
|
+
"a[href]",
|
|
137
|
+
"area[href]",
|
|
138
|
+
"button",
|
|
139
|
+
"input",
|
|
140
|
+
"select",
|
|
141
|
+
"textarea",
|
|
142
|
+
"summary",
|
|
143
|
+
"iframe",
|
|
144
|
+
"audio[controls]",
|
|
145
|
+
"video[controls]",
|
|
146
|
+
"[tabindex]",
|
|
147
|
+
"[contenteditable]"
|
|
148
|
+
].join(",");
|
|
149
|
+
function isRenderedForFocus(element) {
|
|
150
|
+
const check = element.checkVisibility;
|
|
151
|
+
return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
|
|
152
|
+
}
|
|
153
|
+
function authoredTabindex(element) {
|
|
154
|
+
const value = element.getAttribute("tabindex");
|
|
155
|
+
if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
|
|
156
|
+
return Number(value);
|
|
157
|
+
}
|
|
158
|
+
function hasNativeTabStop(element) {
|
|
159
|
+
if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
|
|
160
|
+
return element.hasAttribute("href");
|
|
161
|
+
}
|
|
162
|
+
if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
if (element instanceof HTMLInputElement) return element.type !== "hidden";
|
|
166
|
+
if (element instanceof HTMLIFrameElement) return true;
|
|
167
|
+
if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
|
|
168
|
+
return element.hasAttribute("controls");
|
|
169
|
+
}
|
|
170
|
+
if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
|
|
171
|
+
const details = element.parentElement;
|
|
172
|
+
return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
function hasEditableTabStop(element) {
|
|
177
|
+
const value = element.getAttribute("contenteditable")?.toLowerCase();
|
|
178
|
+
return value === "" || value === "true" || value === "plaintext-only";
|
|
179
|
+
}
|
|
180
|
+
function isTabStop(element) {
|
|
181
|
+
if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
|
|
182
|
+
const tabindex = authoredTabindex(element);
|
|
183
|
+
if (tabindex !== null) return tabindex >= 0;
|
|
184
|
+
return hasNativeTabStop(element) || hasEditableTabStop(element);
|
|
185
|
+
}
|
|
186
|
+
function tabStopsWithin(root) {
|
|
187
|
+
return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
|
|
188
|
+
isTabStop
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
118
192
|
// src/utils/focus_trap.ts
|
|
119
|
-
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
120
193
|
var FocusTrap = class {
|
|
121
194
|
/** The element focused before activation, restored on deactivation. */
|
|
122
195
|
#previouslyFocused = null;
|
|
@@ -278,9 +351,7 @@ var FocusTrap = class {
|
|
|
278
351
|
}
|
|
279
352
|
/** Collects the container's currently focusable descendants in DOM order. */
|
|
280
353
|
#focusableElements() {
|
|
281
|
-
return
|
|
282
|
-
(el) => !el.hidden
|
|
283
|
-
);
|
|
354
|
+
return tabStopsWithin(this.#getContainer());
|
|
284
355
|
}
|
|
285
356
|
};
|
|
286
357
|
|
|
@@ -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) {
|
|
@@ -21,17 +21,21 @@ var AttributeLease = class {
|
|
|
21
21
|
written: value
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
else element.setAttribute(this.#attribute, value);
|
|
24
|
+
this.#reflect(element, value);
|
|
26
25
|
}
|
|
27
26
|
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
28
27
|
return(element) {
|
|
29
28
|
const record = this.#records.get(element);
|
|
30
29
|
if (!record) return;
|
|
31
30
|
this.#records.delete(element);
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
31
|
+
const stillOwned = element.getAttribute(this.#attribute) === record.written;
|
|
32
|
+
if (stillOwned) this.#reflect(element, record.original);
|
|
33
|
+
}
|
|
34
|
+
/** Reflects only a real value transition, avoiding self-triggered mutation work. */
|
|
35
|
+
#reflect(element, value) {
|
|
36
|
+
if (element.getAttribute(this.#attribute) === value) return;
|
|
37
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
38
|
+
else element.setAttribute(this.#attribute, value);
|
|
35
39
|
}
|
|
36
40
|
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
37
41
|
returnAll() {
|
|
@@ -39,6 +43,35 @@ var AttributeLease = class {
|
|
|
39
43
|
}
|
|
40
44
|
};
|
|
41
45
|
|
|
46
|
+
// src/utils/before_cache_reset.ts
|
|
47
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
48
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
49
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
50
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
51
|
+
static #onBeforeCache = () => {
|
|
52
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
53
|
+
};
|
|
54
|
+
#rewind;
|
|
55
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
56
|
+
constructor(rewind) {
|
|
57
|
+
this.#rewind = rewind;
|
|
58
|
+
}
|
|
59
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
60
|
+
activate() {
|
|
61
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
62
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
63
|
+
if (first) {
|
|
64
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
68
|
+
deactivate() {
|
|
69
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
70
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
71
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
42
75
|
// src/utils/microtask_coalescer.ts
|
|
43
76
|
var MicrotaskCoalescer = class {
|
|
44
77
|
#run;
|
|
@@ -81,6 +114,7 @@ var AvatarController = class extends Controller {
|
|
|
81
114
|
/** Collapses one morph's Value, target, and direct-attribute signals into one pass. */
|
|
82
115
|
#reconcile = new MicrotaskCoalescer(() => this.#render());
|
|
83
116
|
/** Preserves directly-authored source and visibility while targets are controlled. */
|
|
117
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
84
118
|
#src = new AttributeLease("src");
|
|
85
119
|
#imageHidden = new AttributeLease("hidden");
|
|
86
120
|
#fallbackHidden = new AttributeLease("hidden");
|
|
@@ -96,6 +130,7 @@ var AvatarController = class extends Controller {
|
|
|
96
130
|
connect() {
|
|
97
131
|
this.#connected = true;
|
|
98
132
|
this.#reconcile.activate();
|
|
133
|
+
this.#beforeCache.activate();
|
|
99
134
|
this.#srcObserver.observe(this.element, {
|
|
100
135
|
attributes: true,
|
|
101
136
|
attributeFilter: ["src"],
|
|
@@ -107,6 +142,7 @@ var AvatarController = class extends Controller {
|
|
|
107
142
|
disconnect() {
|
|
108
143
|
this.#connected = false;
|
|
109
144
|
this.#reconcile.cancel();
|
|
145
|
+
this.#beforeCache.deactivate();
|
|
110
146
|
this.#srcObserver.disconnect();
|
|
111
147
|
}
|
|
112
148
|
/** Repaints when application code or a Turbo morph changes the source Value. */
|
|
@@ -214,6 +250,12 @@ var AvatarController = class extends Controller {
|
|
|
214
250
|
}
|
|
215
251
|
this.element.setAttribute("data-state", state);
|
|
216
252
|
}
|
|
253
|
+
/** Returns borrowed image and fallback semantics before Turbo snapshots the page. */
|
|
254
|
+
#rewindForCache() {
|
|
255
|
+
this.#src.returnAll();
|
|
256
|
+
this.#imageHidden.returnAll();
|
|
257
|
+
this.#fallbackHidden.returnAll();
|
|
258
|
+
}
|
|
217
259
|
};
|
|
218
260
|
|
|
219
261
|
export { AvatarController };
|