stimeo-ui 0.5.0 → 0.6.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 (96) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/controllers/aspect_ratio_controller.d.ts +1 -3
  3. package/dist/controllers/aspect_ratio_controller.js +19 -11
  4. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  5. package/dist/controllers/avatar_controller.d.ts +36 -15
  6. package/dist/controllers/avatar_controller.js +195 -40
  7. package/dist/controllers/avatar_controller.js.map +1 -1
  8. package/dist/controllers/carousel_controller.d.ts +12 -3
  9. package/dist/controllers/carousel_controller.js +85 -9
  10. package/dist/controllers/carousel_controller.js.map +1 -1
  11. package/dist/controllers/checkbox_controller.d.ts +31 -5
  12. package/dist/controllers/checkbox_controller.js +136 -25
  13. package/dist/controllers/checkbox_controller.js.map +1 -1
  14. package/dist/controllers/color_picker_controller.d.ts +7 -1
  15. package/dist/controllers/color_picker_controller.js +35 -9
  16. package/dist/controllers/color_picker_controller.js.map +1 -1
  17. package/dist/controllers/date_range_picker_controller.d.ts +23 -4
  18. package/dist/controllers/date_range_picker_controller.js +157 -30
  19. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  20. package/dist/controllers/file_dropzone_controller.d.ts +7 -1
  21. package/dist/controllers/file_dropzone_controller.js +26 -3
  22. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  23. package/dist/controllers/focus_controller.d.ts +2 -3
  24. package/dist/controllers/focus_controller.js.map +1 -1
  25. package/dist/controllers/hover_card_controller.d.ts +2 -2
  26. package/dist/controllers/hover_card_controller.js.map +1 -1
  27. package/dist/controllers/idle_controller.d.ts +3 -3
  28. package/dist/controllers/idle_controller.js +27 -5
  29. package/dist/controllers/idle_controller.js.map +1 -1
  30. package/dist/controllers/menubar_controller.js +5 -3
  31. package/dist/controllers/menubar_controller.js.map +1 -1
  32. package/dist/controllers/multi_select_controller.d.ts +46 -12
  33. package/dist/controllers/multi_select_controller.js +460 -151
  34. package/dist/controllers/multi_select_controller.js.map +1 -1
  35. package/dist/controllers/number_input_controller.d.ts +24 -6
  36. package/dist/controllers/number_input_controller.js +275 -51
  37. package/dist/controllers/number_input_controller.js.map +1 -1
  38. package/dist/controllers/overflow_menu_controller.js +4 -0
  39. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  40. package/dist/controllers/pagination_controller.js +33 -0
  41. package/dist/controllers/pagination_controller.js.map +1 -1
  42. package/dist/controllers/password_strength_controller.d.ts +3 -3
  43. package/dist/controllers/password_strength_controller.js +20 -2
  44. package/dist/controllers/password_strength_controller.js.map +1 -1
  45. package/dist/controllers/persist_controller.d.ts +4 -4
  46. package/dist/controllers/persist_controller.js +24 -5
  47. package/dist/controllers/persist_controller.js.map +1 -1
  48. package/dist/controllers/popover_controller.d.ts +2 -2
  49. package/dist/controllers/popover_controller.js.map +1 -1
  50. package/dist/controllers/portal_controller.d.ts +1 -2
  51. package/dist/controllers/portal_controller.js.map +1 -1
  52. package/dist/controllers/radio_group_controller.d.ts +41 -15
  53. package/dist/controllers/radio_group_controller.js +540 -56
  54. package/dist/controllers/radio_group_controller.js.map +1 -1
  55. package/dist/controllers/rating_controller.d.ts +36 -31
  56. package/dist/controllers/rating_controller.js +272 -89
  57. package/dist/controllers/rating_controller.js.map +1 -1
  58. package/dist/controllers/resizable_controller.js +33 -0
  59. package/dist/controllers/resizable_controller.js.map +1 -1
  60. package/dist/controllers/roving_controller.d.ts +4 -0
  61. package/dist/controllers/roving_controller.js +60 -5
  62. package/dist/controllers/roving_controller.js.map +1 -1
  63. package/dist/controllers/scroll_area_controller.d.ts +6 -0
  64. package/dist/controllers/scroll_area_controller.js +154 -22
  65. package/dist/controllers/scroll_area_controller.js.map +1 -1
  66. package/dist/controllers/scroll_visibility_controller.js +33 -0
  67. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  68. package/dist/controllers/tags_input_controller.d.ts +41 -11
  69. package/dist/controllers/tags_input_controller.js +356 -120
  70. package/dist/controllers/tags_input_controller.js.map +1 -1
  71. package/dist/controllers/time_picker_controller.d.ts +42 -13
  72. package/dist/controllers/time_picker_controller.js +296 -107
  73. package/dist/controllers/time_picker_controller.js.map +1 -1
  74. package/dist/controllers/toggle_group_controller.d.ts +39 -13
  75. package/dist/controllers/toggle_group_controller.js +378 -55
  76. package/dist/controllers/toggle_group_controller.js.map +1 -1
  77. package/dist/controllers/toolbar_controller.js +5 -3
  78. package/dist/controllers/toolbar_controller.js.map +1 -1
  79. package/dist/controllers/tooltip_controller.d.ts +2 -2
  80. package/dist/controllers/tooltip_controller.js.map +1 -1
  81. package/dist/controllers/transition_controller.d.ts +2 -3
  82. package/dist/controllers/transition_controller.js.map +1 -1
  83. package/dist/controllers/tree_view_controller.js +5 -3
  84. package/dist/controllers/tree_view_controller.js.map +1 -1
  85. package/dist/index.js +2637 -781
  86. package/dist/index.js.map +1 -1
  87. package/dist/inspector/cli.d.ts +16 -3
  88. package/dist/inspector/cli.js +22 -6
  89. package/dist/inspector/cli.js.map +1 -1
  90. package/dist/inspector/cli_bin.js +22 -6
  91. package/dist/inspector/cli_bin.js.map +1 -1
  92. package/dist/inspector/examples.json +8 -8
  93. package/dist/inspector/manifest.json +169 -37
  94. package/dist/positioning/index.d.ts +2 -2
  95. package/dist/positioning/index.js.map +1 -1
  96. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,121 @@ 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.6.0] - 2026-08-15
11
+
12
+ Minor release that separates reconciled state from user edits: `change` now
13
+ means the user committed a value, and a controller that re-derives its own state
14
+ reports `reconcile` instead. Several markup contracts tightened, and authored
15
+ prose — remove-button names, announcements — moved out of the library. The
16
+ Inspector manifest stays on schema v11, but **`stimeo check` can report errors on
17
+ views that passed under 0.5.0**, so run it before deploying.
18
+
19
+ ### Upgrade notes
20
+
21
+ - Listening to `change` to observe controller-driven repair? Subscribe to
22
+ `reconcile` too: carousel, checkbox, color-picker, multi-select, number-input,
23
+ radio-group, rating, tags-input, time-picker. Neither fires on connect.
24
+ - rating: delete `max` and every `data-rating-value`. The DOM order of the
25
+ `symbol` targets is the scale.
26
+ - aspect-ratio: delete the `content` target.
27
+ - toggle-group, radio-group: items must be `<button type="button">` or a
28
+ non-interactive host with `role="button"` / `role="radio"`. Others are left
29
+ untouched at runtime.
30
+ - tags-input, multi-select: delete the `status` target, put a
31
+ `stimeo--announcer` on the page, and set `announceText` /
32
+ `announceRemovedText`. In the chip template add `label` and `remove` targets
33
+ and author the remove button's `aria-label` (`{label}` / `{value}` expand).
34
+ Drop `data-*-slot="label"` and tags-input's `removeTag` action.
35
+ - date-range-picker: declare unavailable days with `disabled-dates`; an
36
+ `aria-disabled` you write on a cell is overwritten on the next paint.
37
+ - time-picker: AM/PM is a `role="spinbutton"` segment, not an `aria-pressed`
38
+ toggle. `seconds` only decides whether the composed value carries seconds.
39
+ - List values are read as JSON strings — idle's `events`, password-strength's
40
+ `levels`, persist's `exclude`, date-range-picker's `disabled-dates`. The
41
+ markup is unchanged; the manifest reports `String` where it reported `Array`.
42
+
43
+ ### Removed
44
+
45
+ - **Breaking** — rating: `max` and `data-rating-value`. aspect-ratio: the
46
+ `content` target. tags-input, multi-select: the `status` target, tags-input's
47
+ `removeTag` action, and the English `Remove {value}` label the library wrote
48
+ onto generated remove buttons.
49
+
50
+ ### Added
51
+
52
+ - A `reconcile` event, carrying the same `detail` as that component's `change`,
53
+ on carousel, checkbox, color-picker, multi-select, number-input, radio-group,
54
+ rating, tags-input, and time-picker.
55
+ - checkbox: a group works without a `parent`; the root still reports
56
+ `all` / `partial` / `none`.
57
+ - rating: `Delete` and `Backspace` clear the rating when `clearable`.
58
+ - date-range-picker: `disabled-dates`, a `monthchange` event,
59
+ `Shift+PageUp` / `Shift+PageDown` for stepping a year, and month labels
60
+ formatted from `documentElement.lang`.
61
+ - multi-select: a `fields` target holding one hidden input per selected value,
62
+ with `name` and `form`, plus `announceText` / `announceRemovedText`.
63
+ tags-input: `label` and `remove` targets and the same two announce values.
64
+ - avatar: an `empty` state beside `loading`, `loaded`, and `error`.
65
+ - Inspector: `invalid-host` for radio-group and toggle-group items;
66
+ chip-template parts required *inside* the template; `aria-label` on remove
67
+ buttons and the group role on toggle groups; at most one checkbox `parent`
68
+ and one checked radio; a positive integer time-picker `step`. An empty
69
+ attribute value no longer satisfies a requirement that asks only for the
70
+ attribute's presence.
71
+
72
+ ### Changed
73
+
74
+ - **Breaking** — `change` is reserved for user-committed values. DOM
75
+ normalization, target churn, morphs, and runtime value changes report
76
+ `reconcile`, and a repair that leaves the committed state unchanged reports
77
+ nothing.
78
+ - **Breaking** — radio-group, time-picker: the hidden `field` fires a native
79
+ `change` only for user edits, so a server-rendered repaint cannot drive an
80
+ `auto-submit` companion into a resubmit loop.
81
+ - **Breaking** — multi-select: `max` is enforced at all times, not only on the
82
+ click path; surplus options normalize to `aria-selected="false"`.
83
+ - **Breaking** — avatar: a present `src` value wins over an authored `src`, and
84
+ an empty one means "no image". Remove the attribute to hand the element's own
85
+ `src` back.
86
+ - **Breaking** — checkbox: a parent toggle sets each child's `checked` without
87
+ synthesizing a native `change` per child; the root reports one event.
88
+ - toggle-group, radio-group, tags-input, multi-select: interaction is delegated
89
+ from the container, so items added at runtime work without per-item
90
+ `data-action`. Existing per-item actions stay compatible.
91
+ - toggle-group, radio-group: `aria-disabled` items stay reachable by arrow keys
92
+ with activation suppressed; natively disabled and `hidden` items are skipped.
93
+ - number-input, rating, scroll-area, time-picker: ARIA and `tabindex` the
94
+ controller writes are treated as loans and handed back to their authored
95
+ values, without overwriting a change you made in the meantime.
96
+ - date-range-picker: a preset that only partly overlaps `min` / `max` is
97
+ narrowed to the selectable span instead of committing an unavailable endpoint.
98
+ - multi-select, tags-input: `Backspace` in an empty input removes the last chip
99
+ and keeps focus in the input.
100
+ - time-picker: a unit that wraps past noon or midnight carries AM/PM with it,
101
+ and one operation reports one `change` with the final value.
102
+
103
+ ### Fixed
104
+
105
+ - avatar, carousel, checkbox, radio-group, rating, roving, toggle-group: targets
106
+ added, removed, or morphed at runtime settle on one state — a single tab stop,
107
+ a single checked or pressed item, and focus on a surviving item.
108
+ - date-range-picker: an unavailable day cannot become a committed endpoint
109
+ through a preset, a preview, or Enter/Space; a reversed stored range is
110
+ normalized on connect; a pending selection is rewound before Turbo caches the
111
+ page.
112
+ - number-input: commits compare numbers rather than the displayed text, and IME
113
+ composition never commits a value.
114
+ - time-picker: with `wrap` disabled, 11 and 12 no longer step into each other,
115
+ and an unfinished direct-entry buffer is discarded when focus leaves.
116
+ - multi-select, tags-input: a chip is built only when the template can produce a
117
+ complete one, only the `remove` target inside a chip acts on it, and a
118
+ replaced `tags` container is re-wired.
119
+ - file-dropzone, scroll-area: a replaced `list` / `viewport` keeps its state and
120
+ takes over the listeners; the old element stops responding.
121
+ - idle, password-strength, persist, date-range-picker: a malformed list value
122
+ falls back to its default instead of stopping the controller from connecting
123
+ and leaving the whole element inert.
124
+
10
125
  ## [0.5.0] - 2026-08-13
11
126
 
12
127
  ### Changed
@@ -458,6 +573,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
458
573
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
459
574
  RubyGems.
460
575
 
576
+ [0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.0
461
577
  [0.5.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.5.0
462
578
  [0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
463
579
  [0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
@@ -7,8 +7,7 @@ import { Controller } from '@hotwired/stimulus';
7
7
  * Markup contract (identifier: `stimeo--aspect-ratio`):
8
8
  * <div data-controller="stimeo--aspect-ratio"
9
9
  * data-stimeo--aspect-ratio-ratio-value="16/9">
10
- * <img src="/cover.jpg" alt="Cover"
11
- * data-stimeo--aspect-ratio-target="content" />
10
+ * <img src="/cover.jpg" alt="Cover" />
12
11
  * </div>
13
12
  *
14
13
  * Supplies the requested ratio as the `--stimeo--aspect-ratio` custom property on
@@ -24,7 +23,6 @@ import { Controller } from '@hotwired/stimulus';
24
23
  */
25
24
  declare class AspectRatioController extends Controller<HTMLElement> {
26
25
  #private;
27
- static targets: string[];
28
26
  static values: {
29
27
  ratio: {
30
28
  type: StringConstructor;
@@ -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 +1 @@
1
- {"version":3,"sources":["../../src/controllers/aspect_ratio_controller.ts"],"names":[],"mappings":";;;AAwBO,IAAM,qBAAA,GAAN,cAAoC,UAAA,CAAwB;AAAA,EACjE,OAAgB,OAAA,GAAU,CAAC,SAAS,CAAA;AAAA,EACpC,OAAgB,MAAA,GAAS;AAAA,IACvB,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,KAAA;AAAM,GACxC;AAAA;AAAA,EAKA,iBAAA,GAA0B;AACxB,IAAA,IAAA,CAAK,OAAA,CAAQ,MAAM,WAAA,CAAY,wBAAA,EAA0B,KAAK,eAAA,CAAgB,IAAA,CAAK,UAAU,CAAC,CAAA;AAAA,EAChG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,gBAAgB,GAAA,EAAqB;AACnC,IAAA,MAAM,KAAA,GAAQ,IAAI,IAAA,EAAK;AACvB,IAAA,IAAI,KAAA,CAAM,QAAA,CAAS,GAAG,CAAA,EAAG;AACvB,MAAA,MAAM,CAAC,CAAA,EAAG,CAAC,CAAA,GAAI,KAAA,CAAM,MAAM,GAAG,CAAA,CAAE,GAAA,CAAI,CAAC,SAAS,MAAA,CAAO,UAAA,CAAW,IAAA,CAAK,IAAA,EAAM,CAAC,CAAA;AAC5E,MAAA,IAAI,IAAA,CAAK,WAAA,CAAY,CAAC,CAAA,IAAK,IAAA,CAAK,WAAA,CAAY,CAAC,CAAA,EAAG,OAAO,CAAA,EAAG,CAAC,CAAA,GAAA,EAAM,CAAC,CAAA,CAAA;AAClE,MAAA,OAAO,OAAA;AAAA,IACT;AACA,IAAA,MAAM,MAAA,GAAS,MAAA,CAAO,UAAA,CAAW,KAAK,CAAA;AACtC,IAAA,OAAO,KAAK,WAAA,CAAY,MAAM,CAAA,GAAI,MAAA,CAAO,MAAM,CAAA,GAAI,OAAA;AAAA,EACrD;AAAA,EAEA,YAAY,KAAA,EAA4C;AACtD,IAAA,OAAO,UAAU,MAAA,IAAa,MAAA,CAAO,QAAA,CAAS,KAAK,KAAK,KAAA,GAAQ,CAAA;AAAA,EAClE;AACF","file":"aspect_ratio_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **Aspect Ratio** helper. No APG pattern — a pure layout utility with\n * no role or state.\n *\n * Markup contract (identifier: `stimeo--aspect-ratio`):\n * <div data-controller=\"stimeo--aspect-ratio\"\n * data-stimeo--aspect-ratio-ratio-value=\"16/9\">\n * <img src=\"/cover.jpg\" alt=\"Cover\"\n * data-stimeo--aspect-ratio-target=\"content\" />\n * </div>\n *\n * Supplies the requested ratio as the `--stimeo--aspect-ratio` custom property on\n * the host so consumer CSS can drive the box (`aspect-ratio: var(--stimeo--aspect-ratio)`)\n * or a padding-hack fallback. The drawing itself — `aspect-ratio`, `object-fit`,\n * cropping — stays in the consumer's stylesheet.\n *\n * @remarks\n * Behavior only. The value accepts the CSS `<ratio>` forms `\"16/9\"` and a bare\n * number string (`\"1.5\"`); it is normalized to `\"w / h\"` (or the number) and an\n * unparseable value falls back to the default `1 / 1` rather than writing garbage\n * into the custom property. The reflection re-runs when the value changes.\n */\nexport class AspectRatioController extends Controller<HTMLElement> {\n static override targets = [\"content\"];\n static override values = {\n ratio: { type: String, default: \"1/1\" },\n };\n\n declare ratioValue: string;\n\n /** Applies the ratio on connect and whenever the value changes. */\n ratioValueChanged(): void {\n this.element.style.setProperty(\"--stimeo--aspect-ratio\", this.#normalizeRatio(this.ratioValue));\n }\n\n /**\n * Normalizes a ratio string to a valid CSS `<ratio>`:\n * - `\"16/9\"` / `\"16 / 9\"` → `\"16 / 9\"` (both parts must be positive numbers)\n * - `\"1.5\"` → `\"1.5\"` (a bare positive number)\n * - anything else → `\"1 / 1\"` (the default), so the custom property is always valid.\n */\n #normalizeRatio(raw: string): string {\n const value = raw.trim();\n if (value.includes(\"/\")) {\n const [w, h] = value.split(\"/\").map((part) => Number.parseFloat(part.trim()));\n if (this.#isPositive(w) && this.#isPositive(h)) return `${w} / ${h}`;\n return \"1 / 1\";\n }\n const single = Number.parseFloat(value);\n return this.#isPositive(single) ? String(single) : \"1 / 1\";\n }\n\n #isPositive(value: number | undefined): value is number {\n return value !== undefined && Number.isFinite(value) && value > 0;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/controllers/aspect_ratio_controller.ts"],"names":[],"mappings":";;;AAEA,IAAM,aAAA,GAAgB,OAAA;AACtB,IAAM,cAAA,GAAiB,wBAAA;AACvB,IAAM,kBAAA,GAAqB,0CAAA;AAuBpB,IAAM,qBAAA,GAAN,cAAoC,UAAA,CAAwB;AAAA,EACjE,OAAgB,MAAA,GAAS;AAAA,IACvB,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,KAAA;AAAM,GACxC;AAAA;AAAA,EAKA,iBAAA,GAA0B;AACxB,IAAA,IAAA,CAAK,OAAA,CAAQ,MAAM,WAAA,CAAY,cAAA,EAAgB,KAAK,eAAA,CAAgB,IAAA,CAAK,UAAU,CAAC,CAAA;AAAA,EACtF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,gBAAgB,GAAA,EAAqB;AACnC,IAAA,MAAM,KAAA,GAAQ,IAAI,IAAA,EAAK;AACvB,IAAA,IAAI,KAAA,CAAM,QAAA,CAAS,GAAG,CAAA,EAAG;AACvB,MAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,KAAA,CAAM,GAAG,CAAA;AAC7B,MAAA,IAAI,KAAA,CAAM,MAAA,KAAW,CAAA,EAAG,OAAO,aAAA;AAE/B,MAAA,MAAM,QAAQ,IAAA,CAAK,oBAAA,CAAqB,KAAA,CAAM,CAAC,KAAK,EAAE,CAAA;AACtD,MAAA,MAAM,SAAS,IAAA,CAAK,oBAAA,CAAqB,KAAA,CAAM,CAAC,KAAK,EAAE,CAAA;AACvD,MAAA,OAAO,KAAA,KAAU,UAAa,MAAA,KAAW,MAAA,GAAY,GAAG,KAAK,CAAA,GAAA,EAAM,MAAM,CAAA,CAAA,GAAK,aAAA;AAAA,IAChF;AAEA,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,oBAAA,CAAqB,KAAK,CAAA;AAC9C,IAAA,OAAO,MAAA,KAAW,MAAA,GAAY,aAAA,GAAgB,MAAA,CAAO,MAAM,CAAA;AAAA,EAC7D;AAAA;AAAA,EAGA,qBAAqB,GAAA,EAAiC;AACpD,IAAA,MAAM,KAAA,GAAQ,IAAI,IAAA,EAAK;AACvB,IAAA,IAAI,CAAC,kBAAA,CAAmB,IAAA,CAAK,KAAK,GAAG,OAAO,MAAA;AAE5C,IAAA,MAAM,KAAA,GAAQ,OAAO,KAAK,CAAA;AAC1B,IAAA,OAAO,OAAO,QAAA,CAAS,KAAK,CAAA,IAAK,KAAA,GAAQ,IAAI,KAAA,GAAQ,MAAA;AAAA,EACvD;AACF","file":"aspect_ratio_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\nconst DEFAULT_RATIO = \"1 / 1\";\nconst RATIO_PROPERTY = \"--stimeo--aspect-ratio\";\nconst CSS_NUMBER_PATTERN = /^[+-]?(?:\\d+|\\d*\\.\\d+)(?:[eE][+-]?\\d+)?$/;\n\n/**\n * Headless **Aspect Ratio** helper. No APG pattern — a pure layout utility with\n * no role or state.\n *\n * Markup contract (identifier: `stimeo--aspect-ratio`):\n * <div data-controller=\"stimeo--aspect-ratio\"\n * data-stimeo--aspect-ratio-ratio-value=\"16/9\">\n * <img src=\"/cover.jpg\" alt=\"Cover\" />\n * </div>\n *\n * Supplies the requested ratio as the `--stimeo--aspect-ratio` custom property on\n * the host so consumer CSS can drive the box (`aspect-ratio: var(--stimeo--aspect-ratio)`)\n * or a padding-hack fallback. The drawing itself — `aspect-ratio`, `object-fit`,\n * cropping — stays in the consumer's stylesheet.\n *\n * @remarks\n * Behavior only. The value accepts the CSS `<ratio>` forms `\"16/9\"` and a bare\n * number string (`\"1.5\"`); it is normalized to `\"w / h\"` (or the number) and an\n * unparseable value falls back to the default `1 / 1` rather than writing garbage\n * into the custom property. The reflection re-runs when the value changes.\n */\nexport class AspectRatioController extends Controller<HTMLElement> {\n static override values = {\n ratio: { type: String, default: \"1/1\" },\n };\n\n declare ratioValue: string;\n\n /** Applies the ratio on connect and whenever the value changes. */\n ratioValueChanged(): void {\n this.element.style.setProperty(RATIO_PROPERTY, this.#normalizeRatio(this.ratioValue));\n }\n\n /**\n * Normalizes a ratio string to a valid CSS `<ratio>`:\n * - `\"16/9\"` / `\"16 / 9\"` → `\"16 / 9\"` (both parts must be positive CSS numbers)\n * - `\"1.5\"` → `\"1.5\"` (a bare positive CSS number)\n * - anything else → `\"1 / 1\"` (the default), so the custom property is always valid.\n */\n #normalizeRatio(raw: string): string {\n const value = raw.trim();\n if (value.includes(\"/\")) {\n const parts = value.split(\"/\");\n if (parts.length !== 2) return DEFAULT_RATIO;\n\n const width = this.#parsePositiveNumber(parts[0] ?? \"\");\n const height = this.#parsePositiveNumber(parts[1] ?? \"\");\n return width !== undefined && height !== undefined ? `${width} / ${height}` : DEFAULT_RATIO;\n }\n\n const single = this.#parsePositiveNumber(value);\n return single === undefined ? DEFAULT_RATIO : String(single);\n }\n\n /** Parses one complete CSS `<number>` token and rejects units or trailing syntax. */\n #parsePositiveNumber(raw: string): number | undefined {\n const token = raw.trim();\n if (!CSS_NUMBER_PATTERN.test(token)) return undefined;\n\n const value = Number(token);\n return Number.isFinite(value) && value > 0 ? value : undefined;\n }\n}\n"]}
@@ -13,25 +13,28 @@ import { Controller } from '@hotwired/stimulus';
13
13
  * <span aria-hidden="true" hidden data-stimeo--avatar-target="fallback">JD</span>
14
14
  * </span>
15
15
  *
16
- * Watches the `<img>` `load`/`error` events and swaps to the author-provided
17
- * fallback when the image fails or no `src` is given. The accessible name lives
18
- * on the container (`role="img"` + `aria-label`); the inner `<img>` and the
19
- * fallback are `aria-hidden` so assistive tech reads the name once, regardless of
20
- * which side is visible.
16
+ * The `image` and `fallback` targets are optional. A present `src` Value owns the
17
+ * current image's `src`, including an empty Value that explicitly clears it. When
18
+ * the Value is absent, a directly authored `<img src>` is used instead. Runtime
19
+ * Value changes, direct `src` changes, and target replacements all repaint.
20
+ *
21
+ * The accessible name lives on the container (`role="img"` + `aria-label`); the
22
+ * inner image and fallback are `aria-hidden` so assistive technology reads the
23
+ * name once in every visual state.
21
24
  *
22
25
  * @remarks
23
26
  * Behavior only — shape, size, and colour are the consumer's CSS. Initials or
24
- * colour generation are out of scope: the fallback content comes from markup. The
25
- * loading/loaded/error phase is exposed on `data-state` so CSS can style each.
27
+ * colour generation are out of scope: fallback content comes from markup. The
28
+ * `empty`, `loading`, `loaded`, and `error` phases are exposed on `data-state`.
29
+ * A native error from the current image dispatches `stimeo--avatar:error` with
30
+ * `{ src: string }`; reconnecting or reconciling an already-broken image is
31
+ * silent because no new failure event occurred.
26
32
  */
27
33
  declare class AvatarController extends Controller<HTMLElement> {
28
34
  #private;
29
35
  static targets: string[];
30
36
  static values: {
31
- src: {
32
- type: StringConstructor;
33
- default: string;
34
- };
37
+ src: StringConstructor;
35
38
  };
36
39
  static actions: readonly ["onError", "onLoad"];
37
40
  static events: readonly ["error"];
@@ -40,11 +43,29 @@ declare class AvatarController extends Controller<HTMLElement> {
40
43
  readonly hasImageTarget: boolean;
41
44
  readonly hasFallbackTarget: boolean;
42
45
  srcValue: string;
46
+ /** Starts retained-DOM observation and silently derives the current phase. */
43
47
  connect(): void;
44
- /** Reveals the image once it has loaded successfully. */
45
- onLoad(): void;
46
- /** Swaps to the fallback when the image fails and emits `error`. */
47
- onError(): void;
48
+ /** Cancels asynchronous work while retaining the last materialized visual state. */
49
+ disconnect(): void;
50
+ /** Repaints when application code or a Turbo morph changes the source Value. */
51
+ srcValueChanged(): void;
52
+ /** Applies the current source and phase to an image inserted or replaced at runtime. */
53
+ imageTargetConnected(): void;
54
+ /** Returns attributes borrowed from an image that leaves this live avatar. */
55
+ imageTargetDisconnected(image: HTMLImageElement): void;
56
+ /** Applies the current phase to a fallback inserted or replaced at runtime. */
57
+ fallbackTargetConnected(): void;
58
+ /** Returns authored visibility when a fallback leaves this live avatar. */
59
+ fallbackTargetDisconnected(fallback: HTMLElement): void;
60
+ /** Reveals the current image after its native `load` event. */
61
+ onLoad(event: Event): void;
62
+ /**
63
+ * Reveals the fallback after the current image's native `error` event.
64
+ *
65
+ * Dispatches `stimeo--avatar:error` with `{ src: string }`, where `src` is
66
+ * the attempted attribute value. Empty/stale/detached image events are ignored.
67
+ */
68
+ onError(event: Event): void;
48
69
  }
49
70
 
50
71
  export { AvatarController };
@@ -1,63 +1,218 @@
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
+ if (value === null) element.removeAttribute(this.#attribute);
25
+ else element.setAttribute(this.#attribute, value);
26
+ }
27
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
28
+ return(element) {
29
+ const record = this.#records.get(element);
30
+ if (!record) return;
31
+ this.#records.delete(element);
32
+ if (element.getAttribute(this.#attribute) !== record.written) return;
33
+ if (record.original === null) element.removeAttribute(this.#attribute);
34
+ else element.setAttribute(this.#attribute, record.original);
35
+ }
36
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
37
+ returnAll() {
38
+ for (const element of Array.from(this.#records.keys())) this.return(element);
39
+ }
40
+ };
41
+
42
+ // src/utils/microtask_coalescer.ts
43
+ var MicrotaskCoalescer = class {
44
+ #run;
45
+ #queued = false;
46
+ #active = false;
47
+ #generation = 0;
48
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
49
+ constructor(run) {
50
+ this.#run = run;
51
+ }
52
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
53
+ activate() {
54
+ this.#active = true;
55
+ }
56
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
57
+ cancel() {
58
+ this.#active = false;
59
+ this.#queued = false;
60
+ this.#generation += 1;
61
+ }
62
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
63
+ schedule() {
64
+ if (!this.#active || this.#queued) return;
65
+ this.#queued = true;
66
+ const generation = this.#generation;
67
+ queueMicrotask(() => {
68
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
69
+ this.#queued = false;
70
+ this.#run();
71
+ });
72
+ }
73
+ };
74
+
3
75
  // src/controllers/avatar_controller.ts
4
76
  var AvatarController = class extends Controller {
5
77
  static targets = ["image", "fallback"];
6
- static values = {
7
- src: { type: String, default: "" }
8
- };
78
+ static values = { src: String };
9
79
  static actions = ["onError", "onLoad"];
10
80
  static events = ["error"];
81
+ /** Collapses one morph's Value, target, and direct-attribute signals into one pass. */
82
+ #reconcile = new MicrotaskCoalescer(() => this.#render());
83
+ /** Preserves directly-authored source and visibility while targets are controlled. */
84
+ #src = new AttributeLease("src");
85
+ #imageHidden = new AttributeLease("hidden");
86
+ #fallbackHidden = new AttributeLease("hidden");
87
+ /** True only while target departure means ownership was actually relinquished. */
88
+ #connected = false;
89
+ /** Watches the non-Value render input used by the direct `<img src>` form. */
90
+ #srcObserver = new MutationObserver((records) => {
91
+ if (records.some((record) => this.hasImageTarget && record.target === this.imageTarget)) {
92
+ this.#reconcile.schedule();
93
+ }
94
+ });
95
+ /** Starts retained-DOM observation and silently derives the current phase. */
11
96
  connect() {
12
- if (!this.hasImageTarget) {
13
- this.#showFallback();
14
- return;
97
+ this.#connected = true;
98
+ this.#reconcile.activate();
99
+ this.#srcObserver.observe(this.element, {
100
+ attributes: true,
101
+ attributeFilter: ["src"],
102
+ subtree: true
103
+ });
104
+ this.#render();
105
+ }
106
+ /** Cancels asynchronous work while retaining the last materialized visual state. */
107
+ disconnect() {
108
+ this.#connected = false;
109
+ this.#reconcile.cancel();
110
+ this.#srcObserver.disconnect();
111
+ }
112
+ /** Repaints when application code or a Turbo morph changes the source Value. */
113
+ srcValueChanged() {
114
+ this.#reconcile.schedule();
115
+ }
116
+ /** Applies the current source and phase to an image inserted or replaced at runtime. */
117
+ imageTargetConnected() {
118
+ this.#reconcile.schedule();
119
+ }
120
+ /** Returns attributes borrowed from an image that leaves this live avatar. */
121
+ imageTargetDisconnected(image) {
122
+ if (this.#connected) {
123
+ this.#src.return(image);
124
+ this.#imageHidden.return(image);
15
125
  }
16
- if (this.srcValue) {
17
- this.imageTarget.src = this.srcValue;
126
+ this.#reconcile.schedule();
127
+ }
128
+ /** Applies the current phase to a fallback inserted or replaced at runtime. */
129
+ fallbackTargetConnected() {
130
+ this.#reconcile.schedule();
131
+ }
132
+ /** Returns authored visibility when a fallback leaves this live avatar. */
133
+ fallbackTargetDisconnected(fallback) {
134
+ if (this.#connected) this.#fallbackHidden.return(fallback);
135
+ this.#reconcile.schedule();
136
+ }
137
+ /** Reveals the current image after its native `load` event. */
138
+ onLoad(event) {
139
+ const image = this.#eventImage(event);
140
+ if (!image) return;
141
+ if (!image.getAttribute("src")) {
142
+ this.#render();
143
+ return;
18
144
  }
19
- const src = this.imageTarget.getAttribute("src");
145
+ this.#reflect("loaded");
146
+ }
147
+ /**
148
+ * Reveals the fallback after the current image's native `error` event.
149
+ *
150
+ * Dispatches `stimeo--avatar:error` with `{ src: string }`, where `src` is
151
+ * the attempted attribute value. Empty/stale/detached image events are ignored.
152
+ */
153
+ onError(event) {
154
+ const image = this.#eventImage(event);
155
+ if (!image) return;
156
+ const src = image.getAttribute("src");
20
157
  if (!src) {
21
- this.#showFallback();
158
+ this.#render();
159
+ return;
160
+ }
161
+ this.#reflect("error");
162
+ this.dispatch("error", { detail: { src } });
163
+ }
164
+ /**
165
+ * Materializes the phase from the final source/target DOM after one mutation batch.
166
+ *
167
+ * Cached success and failure are state reconstruction, not new load events, so
168
+ * this pass never dispatches the public `error` event.
169
+ *
170
+ * @stimeoRenderRoot
171
+ */
172
+ #render() {
173
+ if (!this.hasImageTarget) {
174
+ this.#reflect("empty");
22
175
  return;
23
176
  }
24
- if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) {
25
- this.#showImage();
177
+ const image = this.imageTarget;
178
+ const src = this.#applySource(image);
179
+ if (!src) {
180
+ this.#reflect("empty");
26
181
  return;
27
182
  }
28
- if (this.imageTarget.complete && this.imageTarget.naturalWidth === 0 && src) {
29
- this.onError();
183
+ if (!image.complete) {
184
+ this.#reflect("loading");
30
185
  return;
31
186
  }
32
- this.#enterLoading();
187
+ this.#reflect(image.naturalWidth > 0 ? "loaded" : "error");
33
188
  }
34
- /** Reveals the image once it has loaded successfully. */
35
- onLoad() {
36
- this.#showImage();
189
+ /** Applies Value precedence and returns the effective raw `src` attribute. */
190
+ #applySource(image) {
191
+ if (!this.element.hasAttribute("data-stimeo--avatar-src-value")) {
192
+ this.#src.return(image);
193
+ return image.getAttribute("src");
194
+ }
195
+ const desired = this.srcValue.length > 0 ? this.srcValue : null;
196
+ if (image.getAttribute("src") !== desired) this.#src.write(image, desired);
197
+ return desired;
37
198
  }
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 } });
199
+ /** Accepts an action only from the singular image target currently controlled. */
200
+ #eventImage(event) {
201
+ const candidate = event.currentTarget;
202
+ if (!(candidate instanceof HTMLImageElement)) return null;
203
+ if (!this.hasImageTarget || candidate !== this.imageTarget) return null;
204
+ return candidate;
43
205
  }
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");
206
+ /** Writes the visibility pair and public root state as one controller-owned output. */
207
+ #reflect(state) {
208
+ const showImage = state === "loading" || state === "loaded";
209
+ if (this.hasImageTarget) {
210
+ this.#imageHidden.write(this.imageTarget, showImage ? null : "");
211
+ }
212
+ if (this.hasFallbackTarget) {
213
+ this.#fallbackHidden.write(this.fallbackTarget, showImage ? "" : null);
214
+ }
215
+ this.element.setAttribute("data-state", state);
61
216
  }
62
217
  };
63
218
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controllers/avatar_controller.ts"],"names":[],"mappings":";;;AA0BO,IAAM,gBAAA,GAAN,cAA+B,UAAA,CAAwB;AAAA,EAC5D,OAAgB,OAAA,GAAU,CAAC,OAAA,EAAS,UAAU,CAAA;AAAA,EAC9C,OAAgB,MAAA,GAAS;AAAA,IACvB,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GACnC;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,SAAA,EAAW,QAAQ,CAAA;AAAA,EACrC,OAAO,MAAA,GAAS,CAAC,OAAO,CAAA;AAAA,EASf,OAAA,GAAgB;AACvB,IAAA,IAAI,CAAC,KAAK,cAAA,EAAgB;AAExB,MAAA,IAAA,CAAK,aAAA,EAAc;AACnB,MAAA;AAAA,IACF;AAIA,IAAA,IAAI,KAAK,QAAA,EAAU;AACjB,MAAA,IAAA,CAAK,WAAA,CAAY,MAAM,IAAA,CAAK,QAAA;AAAA,IAC9B;AAEA,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,WAAA,CAAY,YAAA,CAAa,KAAK,CAAA;AAC/C,IAAA,IAAI,CAAC,GAAA,EAAK;AAGR,MAAA,IAAA,CAAK,aAAA,EAAc;AACnB,MAAA;AAAA,IACF;AAIA,IAAA,IAAI,KAAK,WAAA,CAAY,QAAA,IAAY,IAAA,CAAK,WAAA,CAAY,eAAe,CAAA,EAAG;AAClE,MAAA,IAAA,CAAK,UAAA,EAAW;AAChB,MAAA;AAAA,IACF;AACA,IAAA,IAAI,KAAK,WAAA,CAAY,QAAA,IAAY,KAAK,WAAA,CAAY,YAAA,KAAiB,KAAK,GAAA,EAAK;AAE3E,MAAA,IAAA,CAAK,OAAA,EAAQ;AACb,MAAA;AAAA,IACF;AAEA,IAAA,IAAA,CAAK,aAAA,EAAc;AAAA,EACrB;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAA,CAAK,UAAA,EAAW;AAAA,EAClB;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,MAAM,GAAA,GAAM,KAAK,cAAA,GAAkB,IAAA,CAAK,YAAY,YAAA,CAAa,KAAK,KAAK,EAAA,GAAM,EAAA;AACjF,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,IAAA,CAAK,SAAS,OAAA,EAAS,EAAE,QAAQ,EAAE,GAAA,IAAO,CAAA;AAAA,EAC5C;AAAA;AAAA,EAGA,aAAA,GAAsB;AACpB,IAAA,IAAI,IAAA,CAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,MAAA,GAAS,KAAA;AACnD,IAAA,IAAI,IAAA,CAAK,iBAAA,EAAmB,IAAA,CAAK,cAAA,CAAe,MAAA,GAAS,IAAA;AACzD,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,SAAS,CAAA;AAAA,EACnD;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,IAAI,IAAA,CAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,MAAA,GAAS,KAAA;AACnD,IAAA,IAAI,IAAA,CAAK,iBAAA,EAAmB,IAAA,CAAK,cAAA,CAAe,MAAA,GAAS,IAAA;AACzD,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,QAAQ,CAAA;AAAA,EAClD;AAAA;AAAA,EAGA,aAAA,GAAsB;AACpB,IAAA,IAAI,IAAA,CAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,MAAA,GAAS,IAAA;AACnD,IAAA,IAAI,IAAA,CAAK,iBAAA,EAAmB,IAAA,CAAK,cAAA,CAAe,MAAA,GAAS,KAAA;AACzD,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,OAAO,CAAA;AAAA,EACjD;AACF","file":"avatar_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **Avatar** behavior with image-load fallback. No dedicated APG\n * pattern; it follows the non-text-content practice (WCAG 1.1.1).\n *\n * Markup contract (identifier: `stimeo--avatar`):\n * <span data-controller=\"stimeo--avatar\" role=\"img\" aria-label=\"Jane Doe\"\n * data-stimeo--avatar-src-value=\"/u/123.jpg\">\n * <img alt=\"\" aria-hidden=\"true\"\n * data-stimeo--avatar-target=\"image\"\n * data-action=\"load->stimeo--avatar#onLoad error->stimeo--avatar#onError\" />\n * <span aria-hidden=\"true\" hidden data-stimeo--avatar-target=\"fallback\">JD</span>\n * </span>\n *\n * Watches the `<img>` `load`/`error` events and swaps to the author-provided\n * fallback when the image fails or no `src` is given. The accessible name lives\n * on the container (`role=\"img\"` + `aria-label`); the inner `<img>` and the\n * fallback are `aria-hidden` so assistive tech reads the name once, regardless of\n * which side is visible.\n *\n * @remarks\n * Behavior only — shape, size, and colour are the consumer's CSS. Initials or\n * colour generation are out of scope: the fallback content comes from markup. The\n * loading/loaded/error phase is exposed on `data-state` so CSS can style each.\n */\nexport class AvatarController extends Controller<HTMLElement> {\n static override targets = [\"image\", \"fallback\"];\n static override values = {\n src: { type: String, default: \"\" },\n };\n static actions = [\"onError\", \"onLoad\"] as const;\n static events = [\"error\"] as const;\n\n declare readonly imageTarget: HTMLImageElement;\n declare readonly fallbackTarget: HTMLElement;\n declare readonly hasImageTarget: boolean;\n declare readonly hasFallbackTarget: boolean;\n\n declare srcValue: string;\n\n override connect(): void {\n if (!this.hasImageTarget) {\n // Nothing to monitor: surface the fallback so the container is not empty.\n this.#showFallback();\n return;\n }\n\n // A `src` Value, when present, is the source of truth and is applied to the\n // image; otherwise the markup's own `src` attribute (if any) is honored.\n if (this.srcValue) {\n this.imageTarget.src = this.srcValue;\n }\n\n const src = this.imageTarget.getAttribute(\"src\");\n if (!src) {\n // No image to load at all — go straight to the fallback without emitting an\n // error event (there was no failed load attempt).\n this.#showFallback();\n return;\n }\n\n // A cached image may already be complete by the time the controller connects,\n // in which case the `load` event has fired and won't fire again.\n if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) {\n this.#showImage();\n return;\n }\n if (this.imageTarget.complete && this.imageTarget.naturalWidth === 0 && src) {\n // Complete but with no intrinsic size means the load already failed.\n this.onError();\n return;\n }\n\n this.#enterLoading();\n }\n\n /** Reveals the image once it has loaded successfully. */\n onLoad(): void {\n this.#showImage();\n }\n\n /** Swaps to the fallback when the image fails and emits `error`. */\n onError(): void {\n const src = this.hasImageTarget ? (this.imageTarget.getAttribute(\"src\") ?? \"\") : \"\";\n this.#showFallback();\n this.dispatch(\"error\", { detail: { src } });\n }\n\n /** Loading phase: keep the image visible (per markup) while it fetches. */\n #enterLoading(): void {\n if (this.hasImageTarget) this.imageTarget.hidden = false;\n if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;\n this.element.setAttribute(\"data-state\", \"loading\");\n }\n\n /** Loaded phase: image visible, fallback hidden. */\n #showImage(): void {\n if (this.hasImageTarget) this.imageTarget.hidden = false;\n if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;\n this.element.setAttribute(\"data-state\", \"loaded\");\n }\n\n /** Error / no-src phase: fallback visible, image hidden. */\n #showFallback(): void {\n if (this.hasImageTarget) this.imageTarget.hidden = true;\n if (this.hasFallbackTarget) this.fallbackTarget.hidden = false;\n this.element.setAttribute(\"data-state\", \"error\");\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/attribute_lease.ts","../../src/utils/microtask_coalescer.ts","../../src/controllers/avatar_controller.ts"],"names":[],"mappings":"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","file":"avatar_controller.js","sourcesContent":["/** One temporarily controlled attribute value and the authored value it displaced. */\ninterface AttributeLeaseRecord {\n readonly original: string | null;\n written: string | null;\n}\n\n/**\n * Temporarily controls one attribute across a changing set of elements.\n *\n * The first write remembers the authored value, including the distinction between\n * an absent attribute and an authored empty string. Returning a lease restores that\n * value only while the attribute still matches the controller's last write. If a\n * consumer changed it in the meantime, the consumer owns the new value and teardown\n * leaves it alone.\n *\n * A `null` write deliberately removes the attribute while retaining the lease. This\n * is useful for derived ARIA whose valid absence is itself controller state, such as\n * an unbounded `aria-valuemin` or a blank spinbutton's `aria-valuenow`.\n */\nexport class AttributeLease<T extends Element = Element> {\n readonly #attribute: string;\n readonly #records = new Map<T, AttributeLeaseRecord>();\n\n /** @param attribute - The attribute whose temporary values this lease owns. */\n constructor(attribute: string) {\n this.#attribute = attribute;\n }\n\n /** Writes or removes the leased attribute while preserving its authored value. */\n write(element: T, value: string | null): void {\n const existing = this.#records.get(element);\n if (existing) {\n existing.written = value;\n } else {\n this.#records.set(element, {\n original: element.getAttribute(this.#attribute),\n written: value,\n });\n }\n\n if (value === null) element.removeAttribute(this.#attribute);\n else element.setAttribute(this.#attribute, value);\n }\n\n /** Returns one lease without overwriting a value subsequently authored by a consumer. */\n return(element: T): void {\n const record = this.#records.get(element);\n if (!record) return;\n this.#records.delete(element);\n if (element.getAttribute(this.#attribute) !== record.written) return;\n\n if (record.original === null) element.removeAttribute(this.#attribute);\n else element.setAttribute(this.#attribute, record.original);\n }\n\n /** Returns every outstanding lease using the same ownership check as {@link return}. */\n returnAll(): void {\n for (const element of Array.from(this.#records.keys())) this.return(element);\n }\n}\n","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\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 #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { AttributeLease } from \"../utils/attribute_lease\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** Public rendering phases exposed through the root's `data-state`. */\ntype AvatarState = \"empty\" | \"loading\" | \"loaded\" | \"error\";\n\n/**\n * Headless **Avatar** behavior with image-load fallback. No dedicated APG\n * pattern; it follows the non-text-content practice (WCAG 1.1.1).\n *\n * Markup contract (identifier: `stimeo--avatar`):\n * <span data-controller=\"stimeo--avatar\" role=\"img\" aria-label=\"Jane Doe\"\n * data-stimeo--avatar-src-value=\"/u/123.jpg\">\n * <img alt=\"\" aria-hidden=\"true\"\n * data-stimeo--avatar-target=\"image\"\n * data-action=\"load->stimeo--avatar#onLoad error->stimeo--avatar#onError\" />\n * <span aria-hidden=\"true\" hidden data-stimeo--avatar-target=\"fallback\">JD</span>\n * </span>\n *\n * The `image` and `fallback` targets are optional. A present `src` Value owns the\n * current image's `src`, including an empty Value that explicitly clears it. When\n * the Value is absent, a directly authored `<img src>` is used instead. Runtime\n * Value changes, direct `src` changes, and target replacements all repaint.\n *\n * The accessible name lives on the container (`role=\"img\"` + `aria-label`); the\n * inner image and fallback are `aria-hidden` so assistive technology reads the\n * name once in every visual state.\n *\n * @remarks\n * Behavior only — shape, size, and colour are the consumer's CSS. Initials or\n * colour generation are out of scope: fallback content comes from markup. The\n * `empty`, `loading`, `loaded`, and `error` phases are exposed on `data-state`.\n * A native error from the current image dispatches `stimeo--avatar:error` with\n * `{ src: string }`; reconnecting or reconciling an already-broken image is\n * silent because no new failure event occurred.\n */\nexport class AvatarController extends Controller<HTMLElement> {\n static override targets = [\"image\", \"fallback\"];\n static override values = { src: String };\n static actions = [\"onError\", \"onLoad\"] as const;\n static events = [\"error\"] as const;\n\n declare readonly imageTarget: HTMLImageElement;\n declare readonly fallbackTarget: HTMLElement;\n declare readonly hasImageTarget: boolean;\n declare readonly hasFallbackTarget: boolean;\n declare srcValue: string;\n\n /** Collapses one morph's Value, target, and direct-attribute signals into one pass. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#render());\n /** Preserves directly-authored source and visibility while targets are controlled. */\n readonly #src = new AttributeLease<HTMLImageElement>(\"src\");\n readonly #imageHidden = new AttributeLease<HTMLImageElement>(\"hidden\");\n readonly #fallbackHidden = new AttributeLease<HTMLElement>(\"hidden\");\n /** True only while target departure means ownership was actually relinquished. */\n #connected = false;\n /** Watches the non-Value render input used by the direct `<img src>` form. */\n readonly #srcObserver = new MutationObserver((records) => {\n if (records.some((record) => this.hasImageTarget && record.target === this.imageTarget)) {\n this.#reconcile.schedule();\n }\n });\n\n /** Starts retained-DOM observation and silently derives the current phase. */\n override connect(): void {\n this.#connected = true;\n this.#reconcile.activate();\n this.#srcObserver.observe(this.element, {\n attributes: true,\n attributeFilter: [\"src\"],\n subtree: true,\n });\n this.#render();\n }\n\n /** Cancels asynchronous work while retaining the last materialized visual state. */\n override disconnect(): void {\n this.#connected = false;\n this.#reconcile.cancel();\n this.#srcObserver.disconnect();\n }\n\n /** Repaints when application code or a Turbo morph changes the source Value. */\n srcValueChanged(): void {\n this.#reconcile.schedule();\n }\n\n /** Applies the current source and phase to an image inserted or replaced at runtime. */\n imageTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Returns attributes borrowed from an image that leaves this live avatar. */\n imageTargetDisconnected(image: HTMLImageElement): void {\n if (this.#connected) {\n this.#src.return(image);\n this.#imageHidden.return(image);\n }\n this.#reconcile.schedule();\n }\n\n /** Applies the current phase to a fallback inserted or replaced at runtime. */\n fallbackTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Returns authored visibility when a fallback leaves this live avatar. */\n fallbackTargetDisconnected(fallback: HTMLElement): void {\n if (this.#connected) this.#fallbackHidden.return(fallback);\n this.#reconcile.schedule();\n }\n\n /** Reveals the current image after its native `load` event. */\n onLoad(event: Event): void {\n const image = this.#eventImage(event);\n if (!image) return;\n if (!image.getAttribute(\"src\")) {\n this.#render();\n return;\n }\n this.#reflect(\"loaded\");\n }\n\n /**\n * Reveals the fallback after the current image's native `error` event.\n *\n * Dispatches `stimeo--avatar:error` with `{ src: string }`, where `src` is\n * the attempted attribute value. Empty/stale/detached image events are ignored.\n */\n onError(event: Event): void {\n const image = this.#eventImage(event);\n if (!image) return;\n const src = image.getAttribute(\"src\");\n if (!src) {\n this.#render();\n return;\n }\n this.#reflect(\"error\");\n this.dispatch(\"error\", { detail: { src } });\n }\n\n /**\n * Materializes the phase from the final source/target DOM after one mutation batch.\n *\n * Cached success and failure are state reconstruction, not new load events, so\n * this pass never dispatches the public `error` event.\n *\n * @stimeoRenderRoot\n */\n #render(): void {\n if (!this.hasImageTarget) {\n this.#reflect(\"empty\");\n return;\n }\n\n const image = this.imageTarget;\n const src = this.#applySource(image);\n if (!src) {\n this.#reflect(\"empty\");\n return;\n }\n if (!image.complete) {\n this.#reflect(\"loading\");\n return;\n }\n this.#reflect(image.naturalWidth > 0 ? \"loaded\" : \"error\");\n }\n\n /** Applies Value precedence and returns the effective raw `src` attribute. */\n #applySource(image: HTMLImageElement): string | null {\n if (!this.element.hasAttribute(\"data-stimeo--avatar-src-value\")) {\n this.#src.return(image);\n return image.getAttribute(\"src\");\n }\n\n const desired = this.srcValue.length > 0 ? this.srcValue : null;\n if (image.getAttribute(\"src\") !== desired) this.#src.write(image, desired);\n return desired;\n }\n\n /** Accepts an action only from the singular image target currently controlled. */\n #eventImage(event: Event): HTMLImageElement | null {\n const candidate = event.currentTarget;\n if (!(candidate instanceof HTMLImageElement)) return null;\n if (!this.hasImageTarget || candidate !== this.imageTarget) return null;\n return candidate;\n }\n\n /** Writes the visibility pair and public root state as one controller-owned output. */\n #reflect(state: AvatarState): void {\n const showImage = state === \"loading\" || state === \"loaded\";\n if (this.hasImageTarget) {\n this.#imageHidden.write(this.imageTarget, showImage ? null : \"\");\n }\n if (this.hasFallbackTarget) {\n this.#fallbackHidden.write(this.fallbackTarget, showImage ? \"\" : null);\n }\n this.element.setAttribute(\"data-state\", state);\n }\n}\n"]}