stimeo-ui 0.5.0 → 0.7.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/dist/controllers/alert_dialog_controller.js +75 -4
  4. data/dist/controllers/aspect_ratio_controller.js +19 -11
  5. data/dist/controllers/avatar_controller.js +237 -40
  6. data/dist/controllers/carousel_controller.js +85 -9
  7. data/dist/controllers/character_counter_controller.js +338 -63
  8. data/dist/controllers/checkbox_controller.js +136 -25
  9. data/dist/controllers/color_picker_controller.js +35 -9
  10. data/dist/controllers/command_palette_controller.js +75 -4
  11. data/dist/controllers/conditional_fields_controller.js +345 -51
  12. data/dist/controllers/confirm_controller.js +75 -4
  13. data/dist/controllers/date_range_picker_controller.js +157 -30
  14. data/dist/controllers/dialog_controller.js +75 -4
  15. data/dist/controllers/direct_upload_controller.js +201 -45
  16. data/dist/controllers/dirty_form_controller.js +192 -29
  17. data/dist/controllers/dismissible_controller.js +83 -18
  18. data/dist/controllers/drawer_controller.js +75 -4
  19. data/dist/controllers/file_dropzone_controller.js +26 -3
  20. data/dist/controllers/focus_controller.js +75 -4
  21. data/dist/controllers/form_field_controller.js +280 -62
  22. data/dist/controllers/form_validation_controller.js +208 -83
  23. data/dist/controllers/idle_controller.js +27 -5
  24. data/dist/controllers/menubar_controller.js +5 -3
  25. data/dist/controllers/multi_select_controller.js +460 -151
  26. data/dist/controllers/number_input_controller.js +317 -51
  27. data/dist/controllers/overflow_menu_controller.js +6 -1
  28. data/dist/controllers/pagination_controller.js +35 -1
  29. data/dist/controllers/password_strength_controller.js +20 -2
  30. data/dist/controllers/persist_controller.js +449 -120
  31. data/dist/controllers/popover_controller.js +77 -4
  32. data/dist/controllers/radio_group_controller.js +540 -56
  33. data/dist/controllers/rating_controller.js +276 -89
  34. data/dist/controllers/resizable_controller.js +33 -0
  35. data/dist/controllers/roving_controller.js +60 -5
  36. data/dist/controllers/scroll_area_controller.js +557 -125
  37. data/dist/controllers/scroll_visibility_controller.js +33 -0
  38. data/dist/controllers/separator_controller.js +354 -38
  39. data/dist/controllers/sidebar_controller.js +83 -10
  40. data/dist/controllers/submit_once_controller.js +399 -121
  41. data/dist/controllers/tags_input_controller.js +356 -120
  42. data/dist/controllers/theme_controller.js +8 -6
  43. data/dist/controllers/time_picker_controller.js +296 -107
  44. data/dist/controllers/toggle_group_controller.js +378 -55
  45. data/dist/controllers/toolbar_controller.js +5 -3
  46. data/dist/controllers/tree_view_controller.js +7 -4
  47. data/dist/index.js +4963 -1581
  48. data/lib/stimeo/ui/version.rb +1 -1
  49. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a78d7192424e66f85cd4fbc901f75f260178f890fbcf040724fd5e518314dda7
4
- data.tar.gz: d14ba0559fd6659b4b1edc860c653815bb949439768f26e3540cb758dabee2f7
3
+ metadata.gz: 8e3f69e3521c7e5d2ffa5e4b948aabbb26dc9bf8429217fa6235c9cdcafb53b1
4
+ data.tar.gz: d9722417ea447febdc5188eb50812a3725987f65a6920e49f98e852b5f0076ba
5
5
  SHA512:
6
- metadata.gz: e02be89ae227eec783f0eb7f21c492a00ff75174c5285de3d8cf8418b3a826b6d9fb42d779b63a47845b77679d4b61adcade253746cf39ea83d7c924651f8a8e
7
- data.tar.gz: 7661666a77da707d43ad07bf9e7bb4a8b0cb63caea75f2d0d8abf9f43fd6b43421bfe8d24203f814e70042a506177995df72e14487c4c11aa49016129bd44f02
6
+ metadata.gz: 1fe28608ac28813a4e12b448b438853d19e2acdd948fe2af5d287b615e83439a933023881a72bec2a50d579c384141be0c132310a640540b5d7eaee5554e2f28
7
+ data.tar.gz: ddb5ffd05af127033055296b6a66e6e71344b89dbcc42f0c88a34bc7e88193cb2c48deb67f4f2250edd19a0bed15957914c5ee90afd137ecce1c11cfc818df5b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,182 @@ 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.7.0] - 2026-08-16
11
+
12
+ Minor release reworking the form stack, separator, and scroll-area, with
13
+ announcements moving onto the shared `stimeo--announcer`. The Inspector manifest
14
+ moves to schema v12, so `stimeo check` can report on views that passed under
15
+ 0.6.0.
16
+
17
+ ### Removed
18
+
19
+ - **Breaking** — direct-upload: the `status` target and the `announce` /
20
+ `doneLabel` / `errorLabel` Values. Use `announceDoneText` /
21
+ `announceErrorText` with `{name}` instead of `%{name}`.
22
+ - **Breaking** — persist: the unversioned draft format. Drafts written by earlier
23
+ releases are discarded, not migrated.
24
+
25
+ ### Added
26
+
27
+ - separator `min` / `max` / `value`; submit-once `finish` / `cancel` and
28
+ `idle` / `busy` targets; dirty-form `acceptRestore`; character-counter
29
+ `announceText`; form-field `setError(message, { focus })`; persist `error`;
30
+ `reconcile` on character-counter and conditional-fields.
31
+ - form-validation: localized constraint wording through
32
+ `data-stimeo--form-validation-message[-<constraint>]`, a declarative
33
+ `disallow="whitespace"`, and controls attached with `form="id"`.
34
+ - Inspector: schema v12 (`requiredActions`, `actionCompletion`) with the
35
+ `missing-required-action`, `missing-action-completion`, and
36
+ `missing-announcer` diagnostics.
37
+
38
+ ### Changed
39
+
40
+ - **Breaking** — announcing components need a `stimeo--announcer` on the page
41
+ plus the wording: character-counter `announceText`, direct-upload
42
+ `announceDoneText` / `announceErrorText`, submit-once `announceText` /
43
+ `announceReadyText`. Drop `role="alert"` from form-field's `error` target and
44
+ `aria-live` from character-counter's `output`.
45
+ - **Breaking** — separator: the Values are the only runtime input, so move
46
+ `role`, `tabindex`, and the orientation / range ARIA into `orientation`,
47
+ `focusable`, `min`, `max`, `step`, and `value`. A `focusable` separator also
48
+ needs `keydown->stimeo--separator#onKeydown` on itself and an accessible name.
49
+ - **Breaking** — submit-once: sessions are per form, and `preventDefault()` no
50
+ longer ends one — wire `finish` or `cancel`, or set a `timeout`. `start` /
51
+ `end` carry `{ form, submitter, … }`.
52
+ - **Breaking** — direct-upload: `done` fires only on success, and a row that
53
+ reached `error` stays there.
54
+ - **Breaking** — dirty-form: a successful submit re-baselines to what Turbo
55
+ submitted, so later edits stay dirty, and one native confirmation covers every
56
+ dirty form per navigation. With persist, add
57
+ `data-action="stimeo--persist:restore->stimeo--dirty-form#acceptRestore"`.
58
+ - **Breaking** — character-counter: `change` fires only when a committed edit
59
+ changes the length. form-field: `validate` fires only for `setError` /
60
+ `clearError`.
61
+ - persist: `exclude` defaults to Rails' `authenticity_token` / `_method` /
62
+ `utf8`; `password` is always excluded regardless of the list.
63
+
64
+ ### Fixed
65
+
66
+ - Attribute and style leases are released by the subscriber that owns the
67
+ lifecycle, so a cached page no longer retains detached elements.
68
+ - localStorage access goes through one guarded helper, so persist, sidebar, and
69
+ theme keep working when the browser denies storage.
70
+
71
+ ## [0.6.0] - 2026-08-15
72
+
73
+ Minor release that separates reconciled state from user edits: `change` now
74
+ means the user committed a value, and a controller that re-derives its own state
75
+ reports `reconcile` instead. Several markup contracts tightened, and authored
76
+ prose — remove-button names, announcements — moved out of the library. The
77
+ Inspector manifest stays on schema v11, but **`stimeo check` can report errors on
78
+ views that passed under 0.5.0**, so run it before deploying.
79
+
80
+ ### Upgrade notes
81
+
82
+ - Listening to `change` to observe controller-driven repair? Subscribe to
83
+ `reconcile` too: carousel, checkbox, color-picker, multi-select, number-input,
84
+ radio-group, rating, tags-input, time-picker. Neither fires on connect.
85
+ - rating: delete `max` and every `data-rating-value`. The DOM order of the
86
+ `symbol` targets is the scale.
87
+ - aspect-ratio: delete the `content` target.
88
+ - toggle-group, radio-group: items must be `<button type="button">` or a
89
+ non-interactive host with `role="button"` / `role="radio"`. Others are left
90
+ untouched at runtime.
91
+ - tags-input, multi-select: delete the `status` target, put a
92
+ `stimeo--announcer` on the page, and set `announceText` /
93
+ `announceRemovedText`. In the chip template add `label` and `remove` targets
94
+ and author the remove button's `aria-label` (`{label}` / `{value}` expand).
95
+ Drop `data-*-slot="label"` and tags-input's `removeTag` action.
96
+ - date-range-picker: declare unavailable days with `disabled-dates`; an
97
+ `aria-disabled` you write on a cell is overwritten on the next paint.
98
+ - time-picker: AM/PM is a `role="spinbutton"` segment, not an `aria-pressed`
99
+ toggle. `seconds` only decides whether the composed value carries seconds.
100
+ - List values are read as JSON strings — idle's `events`, password-strength's
101
+ `levels`, persist's `exclude`, date-range-picker's `disabled-dates`. The
102
+ markup is unchanged; the manifest reports `String` where it reported `Array`.
103
+
104
+ ### Removed
105
+
106
+ - **Breaking** — rating: `max` and `data-rating-value`. aspect-ratio: the
107
+ `content` target. tags-input, multi-select: the `status` target, tags-input's
108
+ `removeTag` action, and the English `Remove {value}` label the library wrote
109
+ onto generated remove buttons.
110
+
111
+ ### Added
112
+
113
+ - A `reconcile` event, carrying the same `detail` as that component's `change`,
114
+ on carousel, checkbox, color-picker, multi-select, number-input, radio-group,
115
+ rating, tags-input, and time-picker.
116
+ - checkbox: a group works without a `parent`; the root still reports
117
+ `all` / `partial` / `none`.
118
+ - rating: `Delete` and `Backspace` clear the rating when `clearable`.
119
+ - date-range-picker: `disabled-dates`, a `monthchange` event,
120
+ `Shift+PageUp` / `Shift+PageDown` for stepping a year, and month labels
121
+ formatted from `documentElement.lang`.
122
+ - multi-select: a `fields` target holding one hidden input per selected value,
123
+ with `name` and `form`, plus `announceText` / `announceRemovedText`.
124
+ tags-input: `label` and `remove` targets and the same two announce values.
125
+ - avatar: an `empty` state beside `loading`, `loaded`, and `error`.
126
+ - Inspector: `invalid-host` for radio-group and toggle-group items;
127
+ chip-template parts required *inside* the template; `aria-label` on remove
128
+ buttons and the group role on toggle groups; at most one checkbox `parent`
129
+ and one checked radio; a positive integer time-picker `step`. An empty
130
+ attribute value no longer satisfies a requirement that asks only for the
131
+ attribute's presence.
132
+
133
+ ### Changed
134
+
135
+ - **Breaking** — `change` is reserved for user-committed values. DOM
136
+ normalization, target churn, morphs, and runtime value changes report
137
+ `reconcile`, and a repair that leaves the committed state unchanged reports
138
+ nothing.
139
+ - **Breaking** — radio-group, time-picker: the hidden `field` fires a native
140
+ `change` only for user edits, so a server-rendered repaint cannot drive an
141
+ `auto-submit` companion into a resubmit loop.
142
+ - **Breaking** — multi-select: `max` is enforced at all times, not only on the
143
+ click path; surplus options normalize to `aria-selected="false"`.
144
+ - **Breaking** — avatar: a present `src` value wins over an authored `src`, and
145
+ an empty one means "no image". Remove the attribute to hand the element's own
146
+ `src` back.
147
+ - **Breaking** — checkbox: a parent toggle sets each child's `checked` without
148
+ synthesizing a native `change` per child; the root reports one event.
149
+ - toggle-group, radio-group, tags-input, multi-select: interaction is delegated
150
+ from the container, so items added at runtime work without per-item
151
+ `data-action`. Existing per-item actions stay compatible.
152
+ - toggle-group, radio-group: `aria-disabled` items stay reachable by arrow keys
153
+ with activation suppressed; natively disabled and `hidden` items are skipped.
154
+ - number-input, rating, scroll-area, time-picker: ARIA and `tabindex` the
155
+ controller writes are treated as loans and handed back to their authored
156
+ values, without overwriting a change you made in the meantime.
157
+ - date-range-picker: a preset that only partly overlaps `min` / `max` is
158
+ narrowed to the selectable span instead of committing an unavailable endpoint.
159
+ - multi-select, tags-input: `Backspace` in an empty input removes the last chip
160
+ and keeps focus in the input.
161
+ - time-picker: a unit that wraps past noon or midnight carries AM/PM with it,
162
+ and one operation reports one `change` with the final value.
163
+
164
+ ### Fixed
165
+
166
+ - avatar, carousel, checkbox, radio-group, rating, roving, toggle-group: targets
167
+ added, removed, or morphed at runtime settle on one state — a single tab stop,
168
+ a single checked or pressed item, and focus on a surviving item.
169
+ - date-range-picker: an unavailable day cannot become a committed endpoint
170
+ through a preset, a preview, or Enter/Space; a reversed stored range is
171
+ normalized on connect; a pending selection is rewound before Turbo caches the
172
+ page.
173
+ - number-input: commits compare numbers rather than the displayed text, and IME
174
+ composition never commits a value.
175
+ - time-picker: with `wrap` disabled, 11 and 12 no longer step into each other,
176
+ and an unfinished direct-entry buffer is discarded when focus leaves.
177
+ - multi-select, tags-input: a chip is built only when the template can produce a
178
+ complete one, only the `remove` target inside a chip acts on it, and a
179
+ replaced `tags` container is re-wired.
180
+ - file-dropzone, scroll-area: a replaced `list` / `viewport` keeps its state and
181
+ takes over the listeners; the old element stops responding.
182
+ - idle, password-strength, persist, date-range-picker: a malformed list value
183
+ falls back to its default instead of stopping the controller from connecting
184
+ and leaving the whole element inert.
185
+
10
186
  ## [0.5.0] - 2026-08-13
11
187
 
12
188
  ### Changed
@@ -458,6 +634,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
458
634
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
459
635
  RubyGems.
460
636
 
637
+ [0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
638
+ [0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.0
461
639
  [0.5.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.5.0
462
640
  [0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
463
641
  [0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.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 Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
282
- (el) => !el.hidden
283
- );
354
+ return tabStopsWithin(this.#getContainer());
284
355
  }
285
356
  };
286
357
 
@@ -1,33 +1,41 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/aspect_ratio_controller.ts
4
+ var DEFAULT_RATIO = "1 / 1";
5
+ var RATIO_PROPERTY = "--stimeo--aspect-ratio";
6
+ var CSS_NUMBER_PATTERN = /^[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?$/;
4
7
  var AspectRatioController = class extends Controller {
5
- static targets = ["content"];
6
8
  static values = {
7
9
  ratio: { type: String, default: "1/1" }
8
10
  };
9
11
  /** Applies the ratio on connect and whenever the value changes. */
10
12
  ratioValueChanged() {
11
- this.element.style.setProperty("--stimeo--aspect-ratio", this.#normalizeRatio(this.ratioValue));
13
+ this.element.style.setProperty(RATIO_PROPERTY, this.#normalizeRatio(this.ratioValue));
12
14
  }
13
15
  /**
14
16
  * Normalizes a ratio string to a valid CSS `<ratio>`:
15
- * - `"16/9"` / `"16 / 9"` → `"16 / 9"` (both parts must be positive numbers)
16
- * - `"1.5"` → `"1.5"` (a bare positive number)
17
+ * - `"16/9"` / `"16 / 9"` → `"16 / 9"` (both parts must be positive CSS numbers)
18
+ * - `"1.5"` → `"1.5"` (a bare positive CSS number)
17
19
  * - anything else → `"1 / 1"` (the default), so the custom property is always valid.
18
20
  */
19
21
  #normalizeRatio(raw) {
20
22
  const value = raw.trim();
21
23
  if (value.includes("/")) {
22
- const [w, h] = value.split("/").map((part) => Number.parseFloat(part.trim()));
23
- if (this.#isPositive(w) && this.#isPositive(h)) return `${w} / ${h}`;
24
- return "1 / 1";
24
+ const parts = value.split("/");
25
+ if (parts.length !== 2) return DEFAULT_RATIO;
26
+ const width = this.#parsePositiveNumber(parts[0] ?? "");
27
+ const height = this.#parsePositiveNumber(parts[1] ?? "");
28
+ return width !== void 0 && height !== void 0 ? `${width} / ${height}` : DEFAULT_RATIO;
25
29
  }
26
- const single = Number.parseFloat(value);
27
- return this.#isPositive(single) ? String(single) : "1 / 1";
30
+ const single = this.#parsePositiveNumber(value);
31
+ return single === void 0 ? DEFAULT_RATIO : String(single);
28
32
  }
29
- #isPositive(value) {
30
- return value !== void 0 && Number.isFinite(value) && value > 0;
33
+ /** Parses one complete CSS `<number>` token and rejects units or trailing syntax. */
34
+ #parsePositiveNumber(raw) {
35
+ const token = raw.trim();
36
+ if (!CSS_NUMBER_PATTERN.test(token)) return void 0;
37
+ const value = Number(token);
38
+ return Number.isFinite(value) && value > 0 ? value : void 0;
31
39
  }
32
40
  };
33
41
 
@@ -1,63 +1,260 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/avatar_controller.ts
4
+
5
+ // src/utils/attribute_lease.ts
6
+ var AttributeLease = class {
7
+ #attribute;
8
+ #records = /* @__PURE__ */ new Map();
9
+ /** @param attribute - The attribute whose temporary values this lease owns. */
10
+ constructor(attribute) {
11
+ this.#attribute = attribute;
12
+ }
13
+ /** Writes or removes the leased attribute while preserving its authored value. */
14
+ write(element, value) {
15
+ const existing = this.#records.get(element);
16
+ if (existing) {
17
+ existing.written = value;
18
+ } else {
19
+ this.#records.set(element, {
20
+ original: element.getAttribute(this.#attribute),
21
+ written: value
22
+ });
23
+ }
24
+ this.#reflect(element, value);
25
+ }
26
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
27
+ return(element) {
28
+ const record = this.#records.get(element);
29
+ if (!record) return;
30
+ this.#records.delete(element);
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);
39
+ }
40
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
41
+ returnAll() {
42
+ for (const element of Array.from(this.#records.keys())) this.return(element);
43
+ }
44
+ };
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
+
75
+ // src/utils/microtask_coalescer.ts
76
+ var MicrotaskCoalescer = class {
77
+ #run;
78
+ #queued = false;
79
+ #active = false;
80
+ #generation = 0;
81
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
82
+ constructor(run) {
83
+ this.#run = run;
84
+ }
85
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
86
+ activate() {
87
+ this.#active = true;
88
+ }
89
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
90
+ cancel() {
91
+ this.#active = false;
92
+ this.#queued = false;
93
+ this.#generation += 1;
94
+ }
95
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
96
+ schedule() {
97
+ if (!this.#active || this.#queued) return;
98
+ this.#queued = true;
99
+ const generation = this.#generation;
100
+ queueMicrotask(() => {
101
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
102
+ this.#queued = false;
103
+ this.#run();
104
+ });
105
+ }
106
+ };
107
+
3
108
  // src/controllers/avatar_controller.ts
4
109
  var AvatarController = class extends Controller {
5
110
  static targets = ["image", "fallback"];
6
- static values = {
7
- src: { type: String, default: "" }
8
- };
111
+ static values = { src: String };
9
112
  static actions = ["onError", "onLoad"];
10
113
  static events = ["error"];
114
+ /** Collapses one morph's Value, target, and direct-attribute signals into one pass. */
115
+ #reconcile = new MicrotaskCoalescer(() => this.#render());
116
+ /** Preserves directly-authored source and visibility while targets are controlled. */
117
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
118
+ #src = new AttributeLease("src");
119
+ #imageHidden = new AttributeLease("hidden");
120
+ #fallbackHidden = new AttributeLease("hidden");
121
+ /** True only while target departure means ownership was actually relinquished. */
122
+ #connected = false;
123
+ /** Watches the non-Value render input used by the direct `<img src>` form. */
124
+ #srcObserver = new MutationObserver((records) => {
125
+ if (records.some((record) => this.hasImageTarget && record.target === this.imageTarget)) {
126
+ this.#reconcile.schedule();
127
+ }
128
+ });
129
+ /** Starts retained-DOM observation and silently derives the current phase. */
11
130
  connect() {
12
- if (!this.hasImageTarget) {
13
- this.#showFallback();
14
- return;
131
+ this.#connected = true;
132
+ this.#reconcile.activate();
133
+ this.#beforeCache.activate();
134
+ this.#srcObserver.observe(this.element, {
135
+ attributes: true,
136
+ attributeFilter: ["src"],
137
+ subtree: true
138
+ });
139
+ this.#render();
140
+ }
141
+ /** Cancels asynchronous work while retaining the last materialized visual state. */
142
+ disconnect() {
143
+ this.#connected = false;
144
+ this.#reconcile.cancel();
145
+ this.#beforeCache.deactivate();
146
+ this.#srcObserver.disconnect();
147
+ }
148
+ /** Repaints when application code or a Turbo morph changes the source Value. */
149
+ srcValueChanged() {
150
+ this.#reconcile.schedule();
151
+ }
152
+ /** Applies the current source and phase to an image inserted or replaced at runtime. */
153
+ imageTargetConnected() {
154
+ this.#reconcile.schedule();
155
+ }
156
+ /** Returns attributes borrowed from an image that leaves this live avatar. */
157
+ imageTargetDisconnected(image) {
158
+ if (this.#connected) {
159
+ this.#src.return(image);
160
+ this.#imageHidden.return(image);
15
161
  }
16
- if (this.srcValue) {
17
- this.imageTarget.src = this.srcValue;
162
+ this.#reconcile.schedule();
163
+ }
164
+ /** Applies the current phase to a fallback inserted or replaced at runtime. */
165
+ fallbackTargetConnected() {
166
+ this.#reconcile.schedule();
167
+ }
168
+ /** Returns authored visibility when a fallback leaves this live avatar. */
169
+ fallbackTargetDisconnected(fallback) {
170
+ if (this.#connected) this.#fallbackHidden.return(fallback);
171
+ this.#reconcile.schedule();
172
+ }
173
+ /** Reveals the current image after its native `load` event. */
174
+ onLoad(event) {
175
+ const image = this.#eventImage(event);
176
+ if (!image) return;
177
+ if (!image.getAttribute("src")) {
178
+ this.#render();
179
+ return;
18
180
  }
19
- const src = this.imageTarget.getAttribute("src");
181
+ this.#reflect("loaded");
182
+ }
183
+ /**
184
+ * Reveals the fallback after the current image's native `error` event.
185
+ *
186
+ * Dispatches `stimeo--avatar:error` with `{ src: string }`, where `src` is
187
+ * the attempted attribute value. Empty/stale/detached image events are ignored.
188
+ */
189
+ onError(event) {
190
+ const image = this.#eventImage(event);
191
+ if (!image) return;
192
+ const src = image.getAttribute("src");
20
193
  if (!src) {
21
- this.#showFallback();
194
+ this.#render();
22
195
  return;
23
196
  }
24
- if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) {
25
- this.#showImage();
197
+ this.#reflect("error");
198
+ this.dispatch("error", { detail: { src } });
199
+ }
200
+ /**
201
+ * Materializes the phase from the final source/target DOM after one mutation batch.
202
+ *
203
+ * Cached success and failure are state reconstruction, not new load events, so
204
+ * this pass never dispatches the public `error` event.
205
+ *
206
+ * @stimeoRenderRoot
207
+ */
208
+ #render() {
209
+ if (!this.hasImageTarget) {
210
+ this.#reflect("empty");
211
+ return;
212
+ }
213
+ const image = this.imageTarget;
214
+ const src = this.#applySource(image);
215
+ if (!src) {
216
+ this.#reflect("empty");
26
217
  return;
27
218
  }
28
- if (this.imageTarget.complete && this.imageTarget.naturalWidth === 0 && src) {
29
- this.onError();
219
+ if (!image.complete) {
220
+ this.#reflect("loading");
30
221
  return;
31
222
  }
32
- this.#enterLoading();
223
+ this.#reflect(image.naturalWidth > 0 ? "loaded" : "error");
33
224
  }
34
- /** Reveals the image once it has loaded successfully. */
35
- onLoad() {
36
- this.#showImage();
225
+ /** Applies Value precedence and returns the effective raw `src` attribute. */
226
+ #applySource(image) {
227
+ if (!this.element.hasAttribute("data-stimeo--avatar-src-value")) {
228
+ this.#src.return(image);
229
+ return image.getAttribute("src");
230
+ }
231
+ const desired = this.srcValue.length > 0 ? this.srcValue : null;
232
+ if (image.getAttribute("src") !== desired) this.#src.write(image, desired);
233
+ return desired;
37
234
  }
38
- /** Swaps to the fallback when the image fails and emits `error`. */
39
- onError() {
40
- const src = this.hasImageTarget ? this.imageTarget.getAttribute("src") ?? "" : "";
41
- this.#showFallback();
42
- this.dispatch("error", { detail: { src } });
235
+ /** Accepts an action only from the singular image target currently controlled. */
236
+ #eventImage(event) {
237
+ const candidate = event.currentTarget;
238
+ if (!(candidate instanceof HTMLImageElement)) return null;
239
+ if (!this.hasImageTarget || candidate !== this.imageTarget) return null;
240
+ return candidate;
241
+ }
242
+ /** Writes the visibility pair and public root state as one controller-owned output. */
243
+ #reflect(state) {
244
+ const showImage = state === "loading" || state === "loaded";
245
+ if (this.hasImageTarget) {
246
+ this.#imageHidden.write(this.imageTarget, showImage ? null : "");
247
+ }
248
+ if (this.hasFallbackTarget) {
249
+ this.#fallbackHidden.write(this.fallbackTarget, showImage ? "" : null);
250
+ }
251
+ this.element.setAttribute("data-state", state);
43
252
  }
44
- /** Loading phase: keep the image visible (per markup) while it fetches. */
45
- #enterLoading() {
46
- if (this.hasImageTarget) this.imageTarget.hidden = false;
47
- if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;
48
- this.element.setAttribute("data-state", "loading");
49
- }
50
- /** Loaded phase: image visible, fallback hidden. */
51
- #showImage() {
52
- if (this.hasImageTarget) this.imageTarget.hidden = false;
53
- if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;
54
- this.element.setAttribute("data-state", "loaded");
55
- }
56
- /** Error / no-src phase: fallback visible, image hidden. */
57
- #showFallback() {
58
- if (this.hasImageTarget) this.imageTarget.hidden = true;
59
- if (this.hasFallbackTarget) this.fallbackTarget.hidden = false;
60
- this.element.setAttribute("data-state", "error");
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();
61
258
  }
62
259
  };
63
260