stimeo-ui 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/controllers/accordion_controller.d.ts +3 -2
  3. package/dist/controllers/accordion_controller.js.map +1 -1
  4. package/dist/controllers/alert_dialog_controller.d.ts +64 -0
  5. package/dist/controllers/alert_dialog_controller.js +318 -0
  6. package/dist/controllers/alert_dialog_controller.js.map +1 -0
  7. package/dist/controllers/carousel_controller.d.ts +100 -0
  8. package/dist/controllers/carousel_controller.js +272 -0
  9. package/dist/controllers/carousel_controller.js.map +1 -0
  10. package/dist/controllers/clipboard_controller.d.ts +72 -0
  11. package/dist/controllers/clipboard_controller.js +144 -0
  12. package/dist/controllers/clipboard_controller.js.map +1 -0
  13. package/dist/controllers/collapsible_controller.d.ts +70 -0
  14. package/dist/controllers/collapsible_controller.js +327 -0
  15. package/dist/controllers/collapsible_controller.js.map +1 -0
  16. package/dist/controllers/color_picker_controller.d.ts +76 -0
  17. package/dist/controllers/color_picker_controller.js +213 -0
  18. package/dist/controllers/color_picker_controller.js.map +1 -0
  19. package/dist/controllers/count_up_controller.js +8 -1
  20. package/dist/controllers/count_up_controller.js.map +1 -1
  21. package/dist/controllers/currency_input_controller.d.ts +72 -0
  22. package/dist/controllers/currency_input_controller.js +147 -0
  23. package/dist/controllers/currency_input_controller.js.map +1 -0
  24. package/dist/controllers/data_grid_controller.d.ts +71 -0
  25. package/dist/controllers/data_grid_controller.js +168 -0
  26. package/dist/controllers/data_grid_controller.js.map +1 -0
  27. package/dist/controllers/date_range_picker_controller.d.ts +87 -0
  28. package/dist/controllers/date_range_picker_controller.js +417 -0
  29. package/dist/controllers/date_range_picker_controller.js.map +1 -0
  30. package/dist/controllers/dismissible_controller.d.ts +57 -0
  31. package/dist/controllers/dismissible_controller.js +117 -0
  32. package/dist/controllers/dismissible_controller.js.map +1 -0
  33. package/dist/controllers/drawer_controller.d.ts +96 -0
  34. package/dist/controllers/drawer_controller.js +630 -0
  35. package/dist/controllers/drawer_controller.js.map +1 -0
  36. package/dist/controllers/editable_controller.d.ts +73 -0
  37. package/dist/controllers/editable_controller.js +168 -0
  38. package/dist/controllers/editable_controller.js.map +1 -0
  39. package/dist/controllers/file_dropzone_controller.d.ts +92 -0
  40. package/dist/controllers/file_dropzone_controller.js +165 -0
  41. package/dist/controllers/file_dropzone_controller.js.map +1 -0
  42. package/dist/controllers/filter_controller.d.ts +60 -0
  43. package/dist/controllers/filter_controller.js +86 -0
  44. package/dist/controllers/filter_controller.js.map +1 -0
  45. package/dist/controllers/flash_controller.js +36 -5
  46. package/dist/controllers/flash_controller.js.map +1 -1
  47. package/dist/controllers/highlight_controller.js +6 -4
  48. package/dist/controllers/highlight_controller.js.map +1 -1
  49. package/dist/controllers/intersection_controller.js +41 -18
  50. package/dist/controllers/intersection_controller.js.map +1 -1
  51. package/dist/controllers/lazy_frame_controller.js +33 -11
  52. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  53. package/dist/controllers/masonry_controller.d.ts +54 -0
  54. package/dist/controllers/masonry_controller.js +142 -0
  55. package/dist/controllers/masonry_controller.js.map +1 -0
  56. package/dist/controllers/menubar_controller.d.ts +62 -0
  57. package/dist/controllers/menubar_controller.js +433 -0
  58. package/dist/controllers/menubar_controller.js.map +1 -0
  59. package/dist/controllers/multi_select_controller.d.ts +115 -0
  60. package/dist/controllers/multi_select_controller.js +472 -0
  61. package/dist/controllers/multi_select_controller.js.map +1 -0
  62. package/dist/controllers/navigation_menu_controller.d.ts +99 -0
  63. package/dist/controllers/navigation_menu_controller.js +384 -0
  64. package/dist/controllers/navigation_menu_controller.js.map +1 -0
  65. package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
  66. package/dist/controllers/overflow_indicator_controller.js +178 -27
  67. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  68. package/dist/controllers/password_reveal_controller.d.ts +49 -0
  69. package/dist/controllers/password_reveal_controller.js +117 -0
  70. package/dist/controllers/password_reveal_controller.js.map +1 -0
  71. package/dist/controllers/range_slider_controller.d.ts +87 -0
  72. package/dist/controllers/range_slider_controller.js +166 -0
  73. package/dist/controllers/range_slider_controller.js.map +1 -0
  74. package/dist/controllers/read_more_controller.d.ts +52 -0
  75. package/dist/controllers/read_more_controller.js +194 -0
  76. package/dist/controllers/read_more_controller.js.map +1 -0
  77. package/dist/controllers/scroll_area_controller.js +15 -2
  78. package/dist/controllers/scroll_area_controller.js.map +1 -1
  79. package/dist/controllers/scroll_restore_controller.d.ts +54 -0
  80. package/dist/controllers/scroll_restore_controller.js +93 -0
  81. package/dist/controllers/scroll_restore_controller.js.map +1 -0
  82. package/dist/controllers/scroll_visibility_controller.js +8 -4
  83. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  84. package/dist/controllers/scrollspy_controller.js +33 -11
  85. package/dist/controllers/scrollspy_controller.js.map +1 -1
  86. package/dist/controllers/separator_controller.d.ts +54 -0
  87. package/dist/controllers/separator_controller.js +87 -0
  88. package/dist/controllers/separator_controller.js.map +1 -0
  89. package/dist/controllers/sidebar_controller.d.ts +107 -0
  90. package/dist/controllers/sidebar_controller.js +761 -0
  91. package/dist/controllers/sidebar_controller.js.map +1 -0
  92. package/dist/controllers/stepper_controller.d.ts +4 -0
  93. package/dist/controllers/stepper_controller.js +28 -12
  94. package/dist/controllers/stepper_controller.js.map +1 -1
  95. package/dist/controllers/stick_to_bottom_controller.js +8 -4
  96. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  97. package/dist/controllers/sticky_observer_controller.d.ts +17 -4
  98. package/dist/controllers/sticky_observer_controller.js +88 -20
  99. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  100. package/dist/controllers/tabs_controller.d.ts +3 -2
  101. package/dist/controllers/tabs_controller.js.map +1 -1
  102. package/dist/controllers/tags_input_controller.d.ts +91 -0
  103. package/dist/controllers/tags_input_controller.js +275 -0
  104. package/dist/controllers/tags_input_controller.js.map +1 -0
  105. package/dist/controllers/theme_controller.js +20 -10
  106. package/dist/controllers/theme_controller.js.map +1 -1
  107. package/dist/controllers/time_picker_controller.d.ts +67 -0
  108. package/dist/controllers/time_picker_controller.js +212 -0
  109. package/dist/controllers/time_picker_controller.js.map +1 -0
  110. package/dist/controllers/toast_controller.d.ts +1 -9
  111. package/dist/controllers/toast_controller.js +36 -9
  112. package/dist/controllers/toast_controller.js.map +1 -1
  113. package/dist/controllers/transition_controller.d.ts +14 -8
  114. package/dist/controllers/transition_controller.js +153 -38
  115. package/dist/controllers/transition_controller.js.map +1 -1
  116. package/dist/controllers/tree_view_controller.d.ts +54 -0
  117. package/dist/controllers/tree_view_controller.js +275 -0
  118. package/dist/controllers/tree_view_controller.js.map +1 -0
  119. package/dist/index.d.ts +27 -1818
  120. package/dist/index.js +811 -295
  121. package/dist/index.js.map +1 -1
  122. package/dist/inspector/examples.json +2 -2
  123. package/dist/inspector/manifest.json +15 -3
  124. package/package.json +1 -1
@@ -0,0 +1,213 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/color_picker_controller.ts
4
+ var COLOR_PROPERTY = "--stimeo-color";
5
+ var CHANNEL_RANGE = {
6
+ hue: [0, 360],
7
+ saturation: [0, 100],
8
+ lightness: [0, 100],
9
+ alpha: [0, 100]
10
+ };
11
+ var ColorPickerController = class extends Controller {
12
+ static targets = ["slider", "hex", "preview", "field"];
13
+ static values = {
14
+ value: { type: String, default: "#000000" },
15
+ alpha: { type: Boolean, default: false }
16
+ };
17
+ static actions = ["onHexInput", "onKeydown", "onPointerDown"];
18
+ static events = ["change"];
19
+ /** The current color in the editing model. */
20
+ #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
21
+ /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
22
+ #dragAbort = null;
23
+ /** Seeds the model from the initial hex value and renders every surface. */
24
+ connect() {
25
+ const parsed = hexToHsla(this.valueValue);
26
+ if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
27
+ this.#render();
28
+ }
29
+ /** Cancels any active pointer drag so document listeners never leak. */
30
+ disconnect() {
31
+ this.#dragAbort?.abort();
32
+ this.#dragAbort = null;
33
+ }
34
+ /** Keyboard stepping on the focused channel slider (APG Slider model). */
35
+ onKeydown(event) {
36
+ const slider = event.currentTarget;
37
+ const channel = this.#channelOf(slider);
38
+ if (!channel) return;
39
+ const [min, max] = this.#rangeOf(slider, channel);
40
+ const value = this.#color[channel];
41
+ let next = null;
42
+ switch (event.key) {
43
+ case "ArrowRight":
44
+ case "ArrowUp":
45
+ next = value + 1;
46
+ break;
47
+ case "ArrowLeft":
48
+ case "ArrowDown":
49
+ next = value - 1;
50
+ break;
51
+ case "PageUp":
52
+ next = value + 10;
53
+ break;
54
+ case "PageDown":
55
+ next = value - 10;
56
+ break;
57
+ case "Home":
58
+ next = min;
59
+ break;
60
+ case "End":
61
+ next = max;
62
+ break;
63
+ default:
64
+ return;
65
+ }
66
+ event.preventDefault();
67
+ this.#setChannel(channel, next, min, max);
68
+ }
69
+ /** Begins a pointer drag on a channel slider and tracks movement. */
70
+ onPointerDown(event) {
71
+ const slider = event.currentTarget;
72
+ const channel = this.#channelOf(slider);
73
+ if (!channel) return;
74
+ event.preventDefault();
75
+ slider.focus();
76
+ const [min, max] = this.#rangeOf(slider, channel);
77
+ const update = (clientX) => {
78
+ const rect = slider.getBoundingClientRect();
79
+ if (rect.width === 0) return;
80
+ const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
81
+ this.#setChannel(channel, min + fraction * (max - min), min, max);
82
+ };
83
+ update(event.clientX);
84
+ this.#dragAbort?.abort();
85
+ const abort = new AbortController();
86
+ this.#dragAbort = abort;
87
+ const onMove = (move) => update(move.clientX);
88
+ const onUp = () => {
89
+ abort.abort();
90
+ this.#dragAbort = null;
91
+ };
92
+ document.addEventListener("pointermove", onMove, { signal: abort.signal });
93
+ document.addEventListener("pointerup", onUp, { signal: abort.signal });
94
+ document.addEventListener("pointercancel", onUp, { signal: abort.signal });
95
+ }
96
+ /** Parses the hex input on confirm and syncs every channel + surface. */
97
+ onHexInput() {
98
+ if (!this.hasHexTarget) return;
99
+ const parsed = hexToHsla(this.hexTarget.value);
100
+ if (!parsed) {
101
+ this.hexTarget.value = this.#hexString();
102
+ return;
103
+ }
104
+ this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
105
+ this.#render();
106
+ }
107
+ /** Clamps and snaps one channel to an integer, then re-renders + emits change. */
108
+ #setChannel(channel, raw, min, max) {
109
+ this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
110
+ this.#render();
111
+ }
112
+ /** Reflects the model onto sliders, the hex input, preview, and form field. */
113
+ #render() {
114
+ for (const slider of this.sliderTargets) {
115
+ const channel = this.#channelOf(slider);
116
+ if (!channel) continue;
117
+ const value = this.#color[channel];
118
+ slider.setAttribute("aria-valuenow", String(value));
119
+ slider.setAttribute("aria-valuetext", valueText(channel, value));
120
+ }
121
+ const hex = this.#hexString();
122
+ if (this.hasHexTarget) this.hexTarget.value = hex;
123
+ for (const field of this.fieldTargets) field.value = hex;
124
+ for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
125
+ this.element.style.setProperty(COLOR_PROPERTY, hex);
126
+ const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
127
+ this.dispatch("change", {
128
+ detail: { value: hex, rgba: { ...rgb, a: this.#color.alpha / 100 } }
129
+ });
130
+ }
131
+ /** The current color as `#RRGGBB`, or `#RRGGBBAA` when alpha is enabled. */
132
+ #hexString() {
133
+ const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
134
+ const base = `#${hex2(rgb.r)}${hex2(rgb.g)}${hex2(rgb.b)}`;
135
+ if (!this.alphaValue) return base;
136
+ return `${base}${hex2(Math.round(this.#color.alpha / 100 * 255))}`;
137
+ }
138
+ /** Reads a slider's `data-channel`, if it is a known channel. */
139
+ #channelOf(slider) {
140
+ const channel = slider.getAttribute("data-channel");
141
+ return channel && channel in CHANNEL_RANGE ? channel : null;
142
+ }
143
+ /** A slider's `[min, max]` from aria-valuemin/max, falling back per channel. */
144
+ #rangeOf(slider, channel) {
145
+ const [defMin, defMax] = CHANNEL_RANGE[channel];
146
+ const min = Number(slider.getAttribute("aria-valuemin"));
147
+ const max = Number(slider.getAttribute("aria-valuemax"));
148
+ return [Number.isFinite(min) ? min : defMin, Number.isFinite(max) ? max : defMax];
149
+ }
150
+ };
151
+ function valueText(channel, value) {
152
+ const label = channel.charAt(0).toUpperCase() + channel.slice(1);
153
+ const unit = channel === "hue" ? "degrees" : "percent";
154
+ return `${label} ${value} ${unit}`;
155
+ }
156
+ function hex2(value) {
157
+ return Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
158
+ }
159
+ function hslToRgb(h, s, l) {
160
+ const sat = s / 100;
161
+ const light = l / 100;
162
+ const c = (1 - Math.abs(2 * light - 1)) * sat;
163
+ const hp = (h % 360 + 360) % 360 / 60;
164
+ const x = c * (1 - Math.abs(hp % 2 - 1));
165
+ let r = 0;
166
+ let g = 0;
167
+ let b = 0;
168
+ if (hp >= 0 && hp < 1) [r, g, b] = [c, x, 0];
169
+ else if (hp < 2) [r, g, b] = [x, c, 0];
170
+ else if (hp < 3) [r, g, b] = [0, c, x];
171
+ else if (hp < 4) [r, g, b] = [0, x, c];
172
+ else if (hp < 5) [r, g, b] = [x, 0, c];
173
+ else [r, g, b] = [c, 0, x];
174
+ const m = light - c / 2;
175
+ return {
176
+ r: Math.round((r + m) * 255),
177
+ g: Math.round((g + m) * 255),
178
+ b: Math.round((b + m) * 255)
179
+ };
180
+ }
181
+ function rgbToHsl(r, g, b) {
182
+ const rn = r / 255;
183
+ const gn = g / 255;
184
+ const bn = b / 255;
185
+ const max = Math.max(rn, gn, bn);
186
+ const min = Math.min(rn, gn, bn);
187
+ const delta = max - min;
188
+ const l = (max + min) / 2;
189
+ let h = 0;
190
+ if (delta !== 0) {
191
+ if (max === rn) h = (gn - bn) / delta % 6;
192
+ else if (max === gn) h = (bn - rn) / delta + 2;
193
+ else h = (rn - gn) / delta + 4;
194
+ h = (h * 60 + 360) % 360;
195
+ }
196
+ const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
197
+ return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100) };
198
+ }
199
+ function hexToHsla(input) {
200
+ const hex = input.trim().replace(/^#/, "");
201
+ if (!/^(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(hex)) return null;
202
+ const full = hex.length <= 4 ? hex.split("").map((char) => char + char).join("") : hex;
203
+ const r = Number.parseInt(full.slice(0, 2), 16);
204
+ const g = Number.parseInt(full.slice(2, 4), 16);
205
+ const b = Number.parseInt(full.slice(4, 6), 16);
206
+ const a = full.length === 8 ? Number.parseInt(full.slice(6, 8), 16) : 255;
207
+ const { h, s, l } = rgbToHsl(r, g, b);
208
+ return { hue: h, saturation: s, lightness: l, alpha: Math.round(a / 255 * 100) };
209
+ }
210
+
211
+ export { ColorPickerController };
212
+ //# sourceMappingURL=color_picker_controller.js.map
213
+ //# sourceMappingURL=color_picker_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/color_picker_controller.ts"],"names":[],"mappings":";;;AAGA,IAAM,cAAA,GAAiB,gBAAA;AAMvB,IAAM,aAAA,GAAmD;AAAA,EACvD,GAAA,EAAK,CAAC,CAAA,EAAG,GAAG,CAAA;AAAA,EACZ,UAAA,EAAY,CAAC,CAAA,EAAG,GAAG,CAAA;AAAA,EACnB,SAAA,EAAW,CAAC,CAAA,EAAG,GAAG,CAAA;AAAA,EAClB,KAAA,EAAO,CAAC,CAAA,EAAG,GAAG;AAChB,CAAA;AAiDO,IAAM,qBAAA,GAAN,cAAoC,UAAA,CAAwB;AAAA,EACjE,OAAgB,OAAA,GAAU,CAAC,QAAA,EAAU,KAAA,EAAO,WAAW,OAAO,CAAA;AAAA,EAC9D,OAAgB,MAAA,GAAS;AAAA,IACvB,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,SAAA,EAAU;AAAA,IAC1C,KAAA,EAAO,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,KAAA;AAAM,GACzC;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,YAAA,EAAc,aAAa,eAAe,CAAA;AAAA,EAC5D,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA;AAAA,EAWzB,MAAA,GAAe,EAAE,GAAA,EAAK,CAAA,EAAG,YAAY,CAAA,EAAG,SAAA,EAAW,CAAA,EAAG,KAAA,EAAO,GAAA,EAAI;AAAA;AAAA,EAEjE,UAAA,GAAqC,IAAA;AAAA;AAAA,EAG5B,OAAA,GAAgB;AACvB,IAAA,MAAM,MAAA,GAAS,SAAA,CAAU,IAAA,CAAK,UAAU,CAAA;AAIxC,IAAA,IAAI,MAAA,EAAQ,IAAA,CAAK,MAAA,GAAS,IAAA,CAAK,UAAA,GAAa,SAAS,EAAE,GAAG,MAAA,EAAQ,KAAA,EAAO,GAAA,EAAI;AAC7E,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf;AAAA;AAAA,EAGS,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,YAAY,KAAA,EAAM;AACvB,IAAA,IAAA,CAAK,UAAA,GAAa,IAAA;AAAA,EACpB;AAAA;AAAA,EAGA,UAAU,KAAA,EAA4B;AACpC,IAAA,MAAM,SAAS,KAAA,CAAM,aAAA;AACrB,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,UAAA,CAAW,MAAM,CAAA;AACtC,IAAA,IAAI,CAAC,OAAA,EAAS;AAEd,IAAA,MAAM,CAAC,GAAA,EAAK,GAAG,IAAI,IAAA,CAAK,QAAA,CAAS,QAAQ,OAAO,CAAA;AAChD,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,OAAO,CAAA;AACjC,IAAA,IAAI,IAAA,GAAsB,IAAA;AAC1B,IAAA,QAAQ,MAAM,GAAA;AAAK,MACjB,KAAK,YAAA;AAAA,MACL,KAAK,SAAA;AACH,QAAA,IAAA,GAAO,KAAA,GAAQ,CAAA;AACf,QAAA;AAAA,MACF,KAAK,WAAA;AAAA,MACL,KAAK,WAAA;AACH,QAAA,IAAA,GAAO,KAAA,GAAQ,CAAA;AACf,QAAA;AAAA,MACF,KAAK,QAAA;AACH,QAAA,IAAA,GAAO,KAAA,GAAQ,EAAA;AACf,QAAA;AAAA,MACF,KAAK,UAAA;AACH,QAAA,IAAA,GAAO,KAAA,GAAQ,EAAA;AACf,QAAA;AAAA,MACF,KAAK,MAAA;AACH,QAAA,IAAA,GAAO,GAAA;AACP,QAAA;AAAA,MACF,KAAK,KAAA;AACH,QAAA,IAAA,GAAO,GAAA;AACP,QAAA;AAAA,MACF;AACE,QAAA;AAAA;AAEJ,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAA,CAAK,WAAA,CAAY,OAAA,EAAS,IAAA,EAAM,GAAA,EAAK,GAAG,CAAA;AAAA,EAC1C;AAAA;AAAA,EAGA,cAAc,KAAA,EAA2B;AACvC,IAAA,MAAM,SAAS,KAAA,CAAM,aAAA;AACrB,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,UAAA,CAAW,MAAM,CAAA;AACtC,IAAA,IAAI,CAAC,OAAA,EAAS;AACd,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,MAAA,CAAO,KAAA,EAAM;AAEb,IAAA,MAAM,CAAC,GAAA,EAAK,GAAG,IAAI,IAAA,CAAK,QAAA,CAAS,QAAQ,OAAO,CAAA;AAChD,IAAA,MAAM,MAAA,GAAS,CAAC,OAAA,KAA0B;AACxC,MAAA,MAAM,IAAA,GAAO,OAAO,qBAAA,EAAsB;AAC1C,MAAA,IAAI,IAAA,CAAK,UAAU,CAAA,EAAG;AACtB,MAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,GAAA,CAAI,CAAA,EAAA,CAAI,OAAA,GAAU,IAAA,CAAK,IAAA,IAAQ,IAAA,CAAK,KAAK,CAAC,CAAA;AAC5E,MAAA,IAAA,CAAK,YAAY,OAAA,EAAS,GAAA,GAAM,YAAY,GAAA,GAAM,GAAA,CAAA,EAAM,KAAK,GAAG,CAAA;AAAA,IAClE,CAAA;AACA,IAAA,MAAA,CAAO,MAAM,OAAO,CAAA;AAEpB,IAAA,IAAA,CAAK,YAAY,KAAA,EAAM;AACvB,IAAA,MAAM,KAAA,GAAQ,IAAI,eAAA,EAAgB;AAClC,IAAA,IAAA,CAAK,UAAA,GAAa,KAAA;AAClB,IAAA,MAAM,MAAA,GAAS,CAAC,IAAA,KAA6B,MAAA,CAAO,KAAK,OAAO,CAAA;AAChE,IAAA,MAAM,OAAO,MAAY;AACvB,MAAA,KAAA,CAAM,KAAA,EAAM;AACZ,MAAA,IAAA,CAAK,UAAA,GAAa,IAAA;AAAA,IACpB,CAAA;AACA,IAAA,QAAA,CAAS,iBAAiB,aAAA,EAAe,MAAA,EAAQ,EAAE,MAAA,EAAQ,KAAA,CAAM,QAAQ,CAAA;AACzE,IAAA,QAAA,CAAS,iBAAiB,WAAA,EAAa,IAAA,EAAM,EAAE,MAAA,EAAQ,KAAA,CAAM,QAAQ,CAAA;AACrE,IAAA,QAAA,CAAS,iBAAiB,eAAA,EAAiB,IAAA,EAAM,EAAE,MAAA,EAAQ,KAAA,CAAM,QAAQ,CAAA;AAAA,EAC3E;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,IAAI,CAAC,KAAK,YAAA,EAAc;AACxB,IAAA,MAAM,MAAA,GAAS,SAAA,CAAU,IAAA,CAAK,SAAA,CAAU,KAAK,CAAA;AAC7C,IAAA,IAAI,CAAC,MAAA,EAAQ;AAEX,MAAA,IAAA,CAAK,SAAA,CAAU,KAAA,GAAQ,IAAA,CAAK,UAAA,EAAW;AACvC,MAAA;AAAA,IACF;AACA,IAAA,IAAA,CAAK,MAAA,GAAS,KAAK,UAAA,GAAa,MAAA,GAAS,EAAE,GAAG,MAAA,EAAQ,OAAO,GAAA,EAAI;AACjE,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf;AAAA;AAAA,EAGA,WAAA,CAAY,OAAA,EAAkB,GAAA,EAAa,GAAA,EAAa,GAAA,EAAmB;AACzE,IAAA,IAAA,CAAK,MAAA,CAAO,OAAO,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,GAAA,CAAI,GAAA,EAAK,IAAA,CAAK,GAAA,CAAI,GAAA,EAAK,GAAG,CAAC,CAAC,CAAA;AACnE,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,KAAA,MAAW,MAAA,IAAU,KAAK,aAAA,EAAe;AACvC,MAAA,MAAM,OAAA,GAAU,IAAA,CAAK,UAAA,CAAW,MAAM,CAAA;AACtC,MAAA,IAAI,CAAC,OAAA,EAAS;AACd,MAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,OAAO,CAAA;AACjC,MAAA,MAAA,CAAO,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,KAAK,CAAC,CAAA;AAClD,MAAA,MAAA,CAAO,YAAA,CAAa,gBAAA,EAAkB,SAAA,CAAU,OAAA,EAAS,KAAK,CAAC,CAAA;AAAA,IACjE;AAEA,IAAA,MAAM,GAAA,GAAM,KAAK,UAAA,EAAW;AAC5B,IAAA,IAAI,IAAA,CAAK,YAAA,EAAc,IAAA,CAAK,SAAA,CAAU,KAAA,GAAQ,GAAA;AAC9C,IAAA,KAAA,MAAW,KAAA,IAAS,IAAA,CAAK,YAAA,EAAc,KAAA,CAAM,KAAA,GAAQ,GAAA;AACrD,IAAA,KAAA,MAAW,WAAW,IAAA,CAAK,cAAA,UAAwB,KAAA,CAAM,WAAA,CAAY,gBAAgB,GAAG,CAAA;AACxF,IAAA,IAAA,CAAK,OAAA,CAAQ,KAAA,CAAM,WAAA,CAAY,cAAA,EAAgB,GAAG,CAAA;AAElD,IAAA,MAAM,GAAA,GAAM,QAAA,CAAS,IAAA,CAAK,MAAA,CAAO,GAAA,EAAK,KAAK,MAAA,CAAO,UAAA,EAAY,IAAA,CAAK,MAAA,CAAO,SAAS,CAAA;AACnF,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU;AAAA,MACtB,MAAA,EAAQ,EAAE,KAAA,EAAO,GAAA,EAAK,IAAA,EAAM,EAAE,GAAG,GAAA,EAAK,CAAA,EAAG,IAAA,CAAK,MAAA,CAAO,KAAA,GAAQ,KAAI;AAAE,KACpE,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,UAAA,GAAqB;AACnB,IAAA,MAAM,GAAA,GAAM,QAAA,CAAS,IAAA,CAAK,MAAA,CAAO,GAAA,EAAK,KAAK,MAAA,CAAO,UAAA,EAAY,IAAA,CAAK,MAAA,CAAO,SAAS,CAAA;AACnF,IAAA,MAAM,IAAA,GAAO,CAAA,CAAA,EAAI,IAAA,CAAK,GAAA,CAAI,CAAC,CAAC,CAAA,EAAG,IAAA,CAAK,GAAA,CAAI,CAAC,CAAC,CAAA,EAAG,IAAA,CAAK,GAAA,CAAI,CAAC,CAAC,CAAA,CAAA;AACxD,IAAA,IAAI,CAAC,IAAA,CAAK,UAAA,EAAY,OAAO,IAAA;AAC7B,IAAA,OAAO,CAAA,EAAG,IAAI,CAAA,EAAG,IAAA,CAAK,IAAA,CAAK,KAAA,CAAO,IAAA,CAAK,MAAA,CAAO,KAAA,GAAQ,GAAA,GAAO,GAAG,CAAC,CAAC,CAAA,CAAA;AAAA,EACpE;AAAA;AAAA,EAGA,WAAW,MAAA,EAAqC;AAC9C,IAAA,MAAM,OAAA,GAAU,MAAA,CAAO,YAAA,CAAa,cAAc,CAAA;AAClD,IAAA,OAAO,OAAA,IAAW,OAAA,IAAW,aAAA,GAAiB,OAAA,GAAsB,IAAA;AAAA,EACtE;AAAA;AAAA,EAGA,QAAA,CAAS,QAAqB,OAAA,EAAoC;AAChE,IAAA,MAAM,CAAC,MAAA,EAAQ,MAAM,CAAA,GAAI,cAAc,OAAO,CAAA;AAC9C,IAAA,MAAM,GAAA,GAAM,MAAA,CAAO,MAAA,CAAO,YAAA,CAAa,eAAe,CAAC,CAAA;AACvD,IAAA,MAAM,GAAA,GAAM,MAAA,CAAO,MAAA,CAAO,YAAA,CAAa,eAAe,CAAC,CAAA;AACvD,IAAA,OAAO,CAAC,MAAA,CAAO,QAAA,CAAS,GAAG,CAAA,GAAI,GAAA,GAAM,MAAA,EAAQ,MAAA,CAAO,QAAA,CAAS,GAAG,CAAA,GAAI,GAAA,GAAM,MAAM,CAAA;AAAA,EAClF;AACF;AAGA,SAAS,SAAA,CAAU,SAAkB,KAAA,EAAuB;AAC1D,EAAA,MAAM,KAAA,GAAQ,QAAQ,MAAA,CAAO,CAAC,EAAE,WAAA,EAAY,GAAI,OAAA,CAAQ,KAAA,CAAM,CAAC,CAAA;AAC/D,EAAA,MAAM,IAAA,GAAO,OAAA,KAAY,KAAA,GAAQ,SAAA,GAAY,SAAA;AAC7C,EAAA,OAAO,CAAA,EAAG,KAAK,CAAA,CAAA,EAAI,KAAK,IAAI,IAAI,CAAA,CAAA;AAClC;AAGA,SAAS,KAAK,KAAA,EAAuB;AACnC,EAAA,OAAO,KAAK,KAAA,CAAM,IAAA,CAAK,GAAA,CAAI,GAAA,EAAK,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,CAAC,CAAC,CAAA,CAChD,QAAA,CAAS,EAAE,CAAA,CACX,QAAA,CAAS,GAAG,GAAG,CAAA;AACpB;AAGA,SAAS,QAAA,CAAS,CAAA,EAAW,CAAA,EAAW,CAAA,EAAgD;AACtF,EAAA,MAAM,MAAM,CAAA,GAAI,GAAA;AAChB,EAAA,MAAM,QAAQ,CAAA,GAAI,GAAA;AAClB,EAAA,MAAM,KAAK,CAAA,GAAI,IAAA,CAAK,IAAI,CAAA,GAAI,KAAA,GAAQ,CAAC,CAAA,IAAK,GAAA;AAC1C,EAAA,MAAM,EAAA,GAAA,CAAQ,CAAA,GAAI,GAAA,GAAO,GAAA,IAAO,GAAA,GAAO,EAAA;AACvC,EAAA,MAAM,IAAI,CAAA,IAAK,CAAA,GAAI,KAAK,GAAA,CAAK,EAAA,GAAK,IAAK,CAAC,CAAA,CAAA;AACxC,EAAA,IAAI,CAAA,GAAI,CAAA;AACR,EAAA,IAAI,CAAA,GAAI,CAAA;AACR,EAAA,IAAI,CAAA,GAAI,CAAA;AACR,EAAA,IAAI,EAAA,IAAM,CAAA,IAAK,EAAA,GAAK,CAAA,EAAG,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA,GAAI,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA;AAAA,OAAA,IAClC,EAAA,GAAK,CAAA,EAAG,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA,GAAI,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA;AAAA,OAAA,IAC5B,EAAA,GAAK,CAAA,EAAG,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA,GAAI,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA;AAAA,OAAA,IAC5B,EAAA,GAAK,CAAA,EAAG,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA,GAAI,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA;AAAA,OAAA,IAC5B,EAAA,GAAK,CAAA,EAAG,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA,GAAI,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA;AAAA,OAChC,CAAC,GAAG,CAAA,EAAG,CAAC,IAAI,CAAC,CAAA,EAAG,GAAG,CAAC,CAAA;AACzB,EAAA,MAAM,CAAA,GAAI,QAAQ,CAAA,GAAI,CAAA;AACtB,EAAA,OAAO;AAAA,IACL,CAAA,EAAG,IAAA,CAAK,KAAA,CAAA,CAAO,CAAA,GAAI,KAAK,GAAG,CAAA;AAAA,IAC3B,CAAA,EAAG,IAAA,CAAK,KAAA,CAAA,CAAO,CAAA,GAAI,KAAK,GAAG,CAAA;AAAA,IAC3B,CAAA,EAAG,IAAA,CAAK,KAAA,CAAA,CAAO,CAAA,GAAI,KAAK,GAAG;AAAA,GAC7B;AACF;AAGA,SAAS,QAAA,CAAS,CAAA,EAAW,CAAA,EAAW,CAAA,EAAgD;AACtF,EAAA,MAAM,KAAK,CAAA,GAAI,GAAA;AACf,EAAA,MAAM,KAAK,CAAA,GAAI,GAAA;AACf,EAAA,MAAM,KAAK,CAAA,GAAI,GAAA;AACf,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,EAAA,EAAI,IAAI,EAAE,CAAA;AAC/B,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,EAAA,EAAI,IAAI,EAAE,CAAA;AAC/B,EAAA,MAAM,QAAQ,GAAA,GAAM,GAAA;AACpB,EAAA,MAAM,CAAA,GAAA,CAAK,MAAM,GAAA,IAAO,CAAA;AAExB,EAAA,IAAI,CAAA,GAAI,CAAA;AACR,EAAA,IAAI,UAAU,CAAA,EAAG;AACf,IAAA,IAAI,GAAA,KAAQ,EAAA,EAAI,CAAA,GAAA,CAAM,EAAA,GAAK,MAAM,KAAA,GAAS,CAAA;AAAA,SAAA,IACjC,GAAA,KAAQ,EAAA,EAAI,CAAA,GAAA,CAAK,EAAA,GAAK,MAAM,KAAA,GAAQ,CAAA;AAAA,SACxC,CAAA,GAAA,CAAK,EAAA,GAAK,EAAA,IAAM,KAAA,GAAQ,CAAA;AAC7B,IAAA,CAAA,GAAA,CAAK,CAAA,GAAI,KAAK,GAAA,IAAO,GAAA;AAAA,EACvB;AACA,EAAA,MAAM,CAAA,GAAI,KAAA,KAAU,CAAA,GAAI,CAAA,GAAI,KAAA,IAAS,IAAI,IAAA,CAAK,GAAA,CAAI,CAAA,GAAI,CAAA,GAAI,CAAC,CAAA,CAAA;AAC3D,EAAA,OAAO,EAAE,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,CAAC,GAAG,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,CAAA,GAAI,GAAG,CAAA,EAAG,CAAA,EAAG,KAAK,KAAA,CAAM,CAAA,GAAI,GAAG,CAAA,EAAE;AAC5E;AAMA,SAAS,UAAU,KAAA,EAA4B;AAC7C,EAAA,MAAM,MAAM,KAAA,CAAM,IAAA,EAAK,CAAE,OAAA,CAAQ,MAAM,EAAE,CAAA;AACzC,EAAA,IAAI,CAAC,sDAAA,CAAuD,IAAA,CAAK,GAAG,GAAG,OAAO,IAAA;AAG9E,EAAA,MAAM,OACJ,GAAA,CAAI,MAAA,IAAU,CAAA,GACV,GAAA,CACG,MAAM,EAAE,CAAA,CACR,GAAA,CAAI,CAAC,SAAS,IAAA,GAAO,IAAI,CAAA,CACzB,IAAA,CAAK,EAAE,CAAA,GACV,GAAA;AAEN,EAAA,MAAM,CAAA,GAAI,OAAO,QAAA,CAAS,IAAA,CAAK,MAAM,CAAA,EAAG,CAAC,GAAG,EAAE,CAAA;AAC9C,EAAA,MAAM,CAAA,GAAI,OAAO,QAAA,CAAS,IAAA,CAAK,MAAM,CAAA,EAAG,CAAC,GAAG,EAAE,CAAA;AAC9C,EAAA,MAAM,CAAA,GAAI,OAAO,QAAA,CAAS,IAAA,CAAK,MAAM,CAAA,EAAG,CAAC,GAAG,EAAE,CAAA;AAC9C,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,MAAA,KAAW,CAAA,GAAI,MAAA,CAAO,QAAA,CAAS,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,CAAC,CAAA,EAAG,EAAE,CAAA,GAAI,GAAA;AAEtE,EAAA,MAAM,EAAE,GAAG,CAAA,EAAG,CAAA,KAAM,QAAA,CAAS,CAAA,EAAG,GAAG,CAAC,CAAA;AACpC,EAAA,OAAO,EAAE,GAAA,EAAK,CAAA,EAAG,UAAA,EAAY,CAAA,EAAG,SAAA,EAAW,CAAA,EAAG,KAAA,EAAO,IAAA,CAAK,KAAA,CAAO,CAAA,GAAI,GAAA,GAAO,GAAG,CAAA,EAAE;AACnF","file":"color_picker_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** CSS custom property exposing the current color to consumer CSS. */\nconst COLOR_PROPERTY = \"--stimeo-color\";\n\n/** A color channel slider, identified by its `data-channel` attribute. */\ntype Channel = \"hue\" | \"saturation\" | \"lightness\" | \"alpha\";\n\n/** Default `[min, max]` per channel when the slider omits aria-valuemin/max. */\nconst CHANNEL_RANGE: Record<Channel, [number, number]> = {\n hue: [0, 360],\n saturation: [0, 100],\n lightness: [0, 100],\n alpha: [0, 100],\n};\n\n/** Internal color model: HSL plus an alpha percentage (0–100). */\ninterface Hsla {\n hue: number;\n saturation: number;\n lightness: number;\n alpha: number;\n}\n\n/**\n * Headless, accessible **Color Picker** behavior.\n *\n * Markup contract (identifier: `stimeo--color-picker`):\n * <div data-controller=\"stimeo--color-picker\"\n * data-stimeo--color-picker-value-value=\"#3366cc\">\n * <div role=\"slider\" aria-label=\"Hue\" data-channel=\"hue\" tabindex=\"0\"\n * aria-valuemin=\"0\" aria-valuemax=\"360\" aria-valuenow=\"210\"\n * data-stimeo--color-picker-target=\"slider\"\n * data-action=\"keydown->stimeo--color-picker#onKeydown\n * pointerdown->stimeo--color-picker#onPointerDown\"></div>\n * <!-- saturation / lightness / alpha sliders share the same contract -->\n * <input type=\"text\" aria-label=\"Hex color\"\n * data-stimeo--color-picker-target=\"hex\"\n * data-action=\"change->stimeo--color-picker#onHexInput\" />\n * <div data-stimeo--color-picker-target=\"preview\" aria-hidden=\"true\"></div>\n * <input type=\"hidden\" data-stimeo--color-picker-target=\"field\" />\n * </div>\n *\n * Decomposes color selection into independent APG **Slider** channels (hue,\n * saturation, lightness, optional alpha) instead of a 2-D palette, so every\n * adjustment is keyboard- and screen-reader-operable. Each slider exposes\n * `aria-valuenow` and a human-readable `aria-valuetext` (e.g. \"Hue 210 degrees\");\n * the hex input stays two-way synced; the current color is published on the\n * preview and root as the `--stimeo-color` custom property and mirrored into a\n * hidden form field.\n *\n * @remarks\n * Behavior only — the swatch/gradient visuals are the consumer's CSS/canvas, fed\n * by `--stimeo-color`. Pointer-drag listeners on `document` are bound to an\n * {@link AbortController} and released on drag end and on `disconnect()` (Turbo\n * navigation included). Color is fully reconstructable from the `value` (hex), so\n * there is no transient state to restore after a Turbo cache/morph.\n *\n * The internal model is integer HSL(A), so a hex → HSL → hex round-trip is not\n * exactly bijective: a typed hex can normalize to a near (not identical) value\n * once the HSL sliders are touched. This keeps the model small and zero-dep; use a\n * dedicated color library on the consumer side if exact hex preservation matters.\n */\nexport class ColorPickerController extends Controller<HTMLElement> {\n static override targets = [\"slider\", \"hex\", \"preview\", \"field\"];\n static override values = {\n value: { type: String, default: \"#000000\" },\n alpha: { type: Boolean, default: false },\n };\n static actions = [\"onHexInput\", \"onKeydown\", \"onPointerDown\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly sliderTargets: HTMLElement[];\n declare readonly hexTarget: HTMLInputElement;\n declare readonly hasHexTarget: boolean;\n declare readonly previewTargets: HTMLElement[];\n declare readonly fieldTargets: HTMLInputElement[];\n declare valueValue: string;\n declare alphaValue: boolean;\n\n /** The current color in the editing model. */\n #color: Hsla = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };\n /** Aborts in-progress pointer-drag listeners on drag end / teardown. */\n #dragAbort: AbortController | null = null;\n\n /** Seeds the model from the initial hex value and renders every surface. */\n override connect(): void {\n const parsed = hexToHsla(this.valueValue);\n // When alpha is disabled, drop any alpha carried by an `#RRGGBBAA` value so\n // the model stays opaque — otherwise `hexString()` would emit `#RRGGBB`\n // while `change` reported `rgba.a < 1`. Mirrors `onHexInput()`.\n if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };\n this.#render();\n }\n\n /** Cancels any active pointer drag so document listeners never leak. */\n override disconnect(): void {\n this.#dragAbort?.abort();\n this.#dragAbort = null;\n }\n\n /** Keyboard stepping on the focused channel slider (APG Slider model). */\n onKeydown(event: KeyboardEvent): void {\n const slider = event.currentTarget as HTMLElement;\n const channel = this.#channelOf(slider);\n if (!channel) return;\n\n const [min, max] = this.#rangeOf(slider, channel);\n const value = this.#color[channel];\n let next: number | null = null;\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = value + 1;\n break;\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = value - 1;\n break;\n case \"PageUp\":\n next = value + 10;\n break;\n case \"PageDown\":\n next = value - 10;\n break;\n case \"Home\":\n next = min;\n break;\n case \"End\":\n next = max;\n break;\n default:\n return;\n }\n event.preventDefault();\n this.#setChannel(channel, next, min, max);\n }\n\n /** Begins a pointer drag on a channel slider and tracks movement. */\n onPointerDown(event: PointerEvent): void {\n const slider = event.currentTarget as HTMLElement;\n const channel = this.#channelOf(slider);\n if (!channel) return;\n event.preventDefault();\n slider.focus();\n\n const [min, max] = this.#rangeOf(slider, channel);\n const update = (clientX: number): void => {\n const rect = slider.getBoundingClientRect();\n if (rect.width === 0) return;\n const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n this.#setChannel(channel, min + fraction * (max - min), min, max);\n };\n update(event.clientX);\n\n this.#dragAbort?.abort();\n const abort = new AbortController();\n this.#dragAbort = abort;\n const onMove = (move: PointerEvent): void => update(move.clientX);\n const onUp = (): void => {\n abort.abort();\n this.#dragAbort = null;\n };\n document.addEventListener(\"pointermove\", onMove, { signal: abort.signal });\n document.addEventListener(\"pointerup\", onUp, { signal: abort.signal });\n document.addEventListener(\"pointercancel\", onUp, { signal: abort.signal });\n }\n\n /** Parses the hex input on confirm and syncs every channel + surface. */\n onHexInput(): void {\n if (!this.hasHexTarget) return;\n const parsed = hexToHsla(this.hexTarget.value);\n if (!parsed) {\n // Reject invalid input by restoring the last valid hex.\n this.hexTarget.value = this.#hexString();\n return;\n }\n this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };\n this.#render();\n }\n\n /** Clamps and snaps one channel to an integer, then re-renders + emits change. */\n #setChannel(channel: Channel, raw: number, min: number, max: number): void {\n this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));\n this.#render();\n }\n\n /** Reflects the model onto sliders, the hex input, preview, and form field. */\n #render(): void {\n for (const slider of this.sliderTargets) {\n const channel = this.#channelOf(slider);\n if (!channel) continue;\n const value = this.#color[channel];\n slider.setAttribute(\"aria-valuenow\", String(value));\n slider.setAttribute(\"aria-valuetext\", valueText(channel, value));\n }\n\n const hex = this.#hexString();\n if (this.hasHexTarget) this.hexTarget.value = hex;\n for (const field of this.fieldTargets) field.value = hex;\n for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);\n this.element.style.setProperty(COLOR_PROPERTY, hex);\n\n const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);\n this.dispatch(\"change\", {\n detail: { value: hex, rgba: { ...rgb, a: this.#color.alpha / 100 } },\n });\n }\n\n /** The current color as `#RRGGBB`, or `#RRGGBBAA` when alpha is enabled. */\n #hexString(): string {\n const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);\n const base = `#${hex2(rgb.r)}${hex2(rgb.g)}${hex2(rgb.b)}`;\n if (!this.alphaValue) return base;\n return `${base}${hex2(Math.round((this.#color.alpha / 100) * 255))}`;\n }\n\n /** Reads a slider's `data-channel`, if it is a known channel. */\n #channelOf(slider: HTMLElement): Channel | null {\n const channel = slider.getAttribute(\"data-channel\");\n return channel && channel in CHANNEL_RANGE ? (channel as Channel) : null;\n }\n\n /** A slider's `[min, max]` from aria-valuemin/max, falling back per channel. */\n #rangeOf(slider: HTMLElement, channel: Channel): [number, number] {\n const [defMin, defMax] = CHANNEL_RANGE[channel];\n const min = Number(slider.getAttribute(\"aria-valuemin\"));\n const max = Number(slider.getAttribute(\"aria-valuemax\"));\n return [Number.isFinite(min) ? min : defMin, Number.isFinite(max) ? max : defMax];\n }\n}\n\n/** Capitalizes a channel name for `aria-valuetext` (e.g. \"Hue\"). */\nfunction valueText(channel: Channel, value: number): string {\n const label = channel.charAt(0).toUpperCase() + channel.slice(1);\n const unit = channel === \"hue\" ? \"degrees\" : \"percent\";\n return `${label} ${value} ${unit}`;\n}\n\n/** Formats a 0–255 byte as a 2-digit lowercase hex pair. */\nfunction hex2(value: number): string {\n return Math.round(Math.min(255, Math.max(0, value)))\n .toString(16)\n .padStart(2, \"0\");\n}\n\n/** Converts HSL (h:0–360, s/l:0–100) to RGB bytes (0–255). */\nfunction hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {\n const sat = s / 100;\n const light = l / 100;\n const c = (1 - Math.abs(2 * light - 1)) * sat;\n const hp = (((h % 360) + 360) % 360) / 60;\n const x = c * (1 - Math.abs((hp % 2) - 1));\n let r = 0;\n let g = 0;\n let b = 0;\n if (hp >= 0 && hp < 1) [r, g, b] = [c, x, 0];\n else if (hp < 2) [r, g, b] = [x, c, 0];\n else if (hp < 3) [r, g, b] = [0, c, x];\n else if (hp < 4) [r, g, b] = [0, x, c];\n else if (hp < 5) [r, g, b] = [x, 0, c];\n else [r, g, b] = [c, 0, x];\n const m = light - c / 2;\n return {\n r: Math.round((r + m) * 255),\n g: Math.round((g + m) * 255),\n b: Math.round((b + m) * 255),\n };\n}\n\n/** Converts RGB bytes (0–255) to HSL (h:0–360, s/l:0–100). */\nfunction rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {\n const rn = r / 255;\n const gn = g / 255;\n const bn = b / 255;\n const max = Math.max(rn, gn, bn);\n const min = Math.min(rn, gn, bn);\n const delta = max - min;\n const l = (max + min) / 2;\n\n let h = 0;\n if (delta !== 0) {\n if (max === rn) h = ((gn - bn) / delta) % 6;\n else if (max === gn) h = (bn - rn) / delta + 2;\n else h = (rn - gn) / delta + 4;\n h = (h * 60 + 360) % 360;\n }\n const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));\n return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100) };\n}\n\n/**\n * Parses `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA` into the HSLA model, or\n * returns null when the string is not a valid hex color.\n */\nfunction hexToHsla(input: string): Hsla | null {\n const hex = input.trim().replace(/^#/, \"\");\n if (!/^(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(hex)) return null;\n\n // Expand shorthand (#RGB / #RGBA) so every channel is a full byte pair.\n const full =\n hex.length <= 4\n ? hex\n .split(\"\")\n .map((char) => char + char)\n .join(\"\")\n : hex;\n\n const r = Number.parseInt(full.slice(0, 2), 16);\n const g = Number.parseInt(full.slice(2, 4), 16);\n const b = Number.parseInt(full.slice(4, 6), 16);\n const a = full.length === 8 ? Number.parseInt(full.slice(6, 8), 16) : 255;\n\n const { h, s, l } = rgbToHsl(r, g, b);\n return { hue: h, saturation: s, lightness: l, alpha: Math.round((a / 255) * 100) };\n}\n"]}
@@ -1,5 +1,12 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/count_up_controller.ts
4
+
5
+ // src/utils/reduced_motion.ts
6
+ function prefersReducedMotion() {
7
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
+ }
9
+
3
10
  // src/controllers/count_up_controller.ts
4
11
  var CountUpController = class extends Controller {
5
12
  static values = {
@@ -32,7 +39,7 @@ var CountUpController = class extends Controller {
32
39
  this.#finalText = this.element.textContent ?? "";
33
40
  const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, ""), 10);
34
41
  if (Number.isNaN(target)) return;
35
- if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
42
+ if (prefersReducedMotion()) {
36
43
  this.element.setAttribute("data-count-up-done", "true");
37
44
  this.dispatch("end", { detail: { value: target } });
38
45
  return;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controllers/count_up_controller.ts"],"names":[],"mappings":"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","file":"count_up_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **count-up**: animates a number from `from` up to the value already\n * in the DOM — typically started the moment the element scrolls into view by\n * composing with the `stimeo--intersection` primitive (the classic \"animate\n * when visible\"). Distinct from `stimeo--countdown` (time-based). Core\n * (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--count-up`):\n * <span data-controller=\"stimeo--intersection stimeo--count-up\"\n * data-stimeo--intersection-once-value=\"true\"\n * data-action=\"stimeo--intersection:enter->stimeo--count-up#start\">1200</span>\n *\n * The **final number stays authored in the markup** (SEO / no-JS / SR read the\n * real value); `start` animates the displayed text from `from` to it over\n * `duration` ms with an ease-out curve, then restores the exact authored text.\n * With `once` (default) later starts are ignored (`data-count-up-done` records\n * a finished run across Turbo cache restores).\n *\n * @remarks\n * Behavior only — no formatting is imposed: the authored text is parsed for\n * its integer value (separators are ignored) and restored verbatim at the end;\n * intermediate frames render plain integers. Accessibility: when the user\n * prefers reduced motion the animation is skipped entirely (the value just\n * stays final — WCAG 2.3.3). The element is not a live region, so the ticking\n * intermediate numbers are never actively announced; during the run the\n * authored value is additionally kept in `aria-label` (best-effort — generic\n * roles may ignore it) and that lingering label doubles as the\n * interrupted-run marker `connect()` restores from after a Turbo cache\n * snapshot taken mid-animation. The animation frame is canceled on\n * `disconnect()` (Turbo navigation included) and the authored text restored.\n */\nexport class CountUpController extends Controller<HTMLElement> {\n static override values = {\n duration: { type: Number, default: 1200 },\n from: { type: Number, default: 0 },\n once: { type: Boolean, default: true },\n };\n static actions = [\"start\"] as const;\n static events = [\"end\"] as const;\n\n declare durationValue: number;\n declare fromValue: number;\n declare onceValue: boolean;\n\n #frame: number | null = null;\n /** The authored final text, restored verbatim when the run settles. */\n #finalText = \"\";\n\n override connect(): void {\n // Turbo snapshots the page BEFORE the body swap, so a cached page can hold\n // a mid-animation frame (disconnect()'s settle runs too late for it). The\n // OWN-label marker (never a bare aria-label — that may be authored) flags\n // the interrupted run: the label it owns still holds the authored text.\n if (this.element.hasAttribute(\"data-count-up-label\")) {\n this.element.textContent =\n this.element.getAttribute(\"aria-label\") ?? this.element.textContent;\n this.#restoreLabel();\n this.element.setAttribute(\"data-count-up-done\", \"true\");\n }\n }\n\n override disconnect(): void {\n // A run cannot survive the element: settle instantly so the cached\n // snapshot holds the real value, never a mid-animation frame.\n if (this.#frame !== null) this.#settle();\n }\n\n /**\n * Starts the animation (typically from `stimeo--intersection:enter` via\n * `data-action`). No-ops while running, and after a finished run when `once`.\n */\n start(): void {\n if (this.#frame !== null) return;\n if (this.onceValue && this.element.hasAttribute(\"data-count-up-done\")) return;\n\n this.#finalText = this.element.textContent ?? \"\";\n const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, \"\"), 10);\n if (Number.isNaN(target)) return;\n\n // Reduced motion: no ticking, just the final value (WCAG 2.3.3).\n if (window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) {\n this.element.setAttribute(\"data-count-up-done\", \"true\");\n this.dispatch(\"end\", { detail: { value: target } });\n return;\n }\n\n // AT keeps the real value while the visible text ticks. An authored\n // aria-label is parked (save-restore, never clobbered) and the override is\n // marker-owned so connect()/settle() only ever touch what this set.\n const authored = this.element.getAttribute(\"aria-label\");\n if (authored !== null) {\n this.element.setAttribute(\"data-count-up-original-label\", authored);\n }\n this.element.setAttribute(\"data-count-up-label\", \"true\");\n this.element.setAttribute(\"aria-label\", this.#finalText);\n const started = performance.now();\n const step = (now: number): void => {\n const t = Math.min((now - started) / this.durationValue, 1);\n const eased = 1 - (1 - t) ** 3; // ease-out cubic\n this.element.textContent = String(\n Math.round(this.fromValue + (target - this.fromValue) * eased),\n );\n if (t < 1) {\n this.#frame = requestAnimationFrame(step);\n } else {\n this.#settle();\n this.dispatch(\"end\", { detail: { value: target } });\n }\n };\n this.#frame = requestAnimationFrame(step);\n }\n\n /** Ends the run: cancels the frame and restores the authored presentation. */\n #settle(): void {\n if (this.#frame !== null) cancelAnimationFrame(this.#frame);\n this.#frame = null;\n this.element.textContent = this.#finalText;\n this.#restoreLabel();\n this.element.setAttribute(\"data-count-up-done\", \"true\");\n }\n\n /** Releases the marker-owned aria-label, restoring any parked authored value. */\n #restoreLabel(): void {\n if (!this.element.hasAttribute(\"data-count-up-label\")) return;\n const original = this.element.getAttribute(\"data-count-up-original-label\");\n if (original !== null) {\n this.element.setAttribute(\"aria-label\", original);\n this.element.removeAttribute(\"data-count-up-original-label\");\n } else {\n this.element.removeAttribute(\"aria-label\");\n }\n this.element.removeAttribute(\"data-count-up-label\");\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/reduced_motion.ts","../../src/controllers/count_up_controller.ts"],"names":[],"mappings":"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","file":"count_up_controller.js","sourcesContent":["/**\n * Shared `prefers-reduced-motion` lookup for the motion-aware controllers\n * (count-up, highlight, overflow-indicator, scroll-visibility, stick-to-bottom,\n * transition).\n *\n * Each of those controllers used to duplicate the same guarded `matchMedia`\n * read; this one-liner keeps the media query string and the environment guard\n * single-sourced. The preference is intentionally re-read on every call — the\n * controllers check it at each animation/scroll start (WCAG 2.2 **2.3.3**), so\n * flipping the OS setting takes effect immediately without any listener or\n * cache bookkeeping here.\n */\n\n/**\n * Whether the user currently requests reduced motion.\n *\n * @returns `true` when `(prefers-reduced-motion: reduce)` matches; `false`\n * otherwise, including environments without `window.matchMedia` (treated as\n * \"no preference\").\n */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { prefersReducedMotion } from \"../utils/reduced_motion\";\n\n/**\n * Headless **count-up**: animates a number from `from` up to the value already\n * in the DOM — typically started the moment the element scrolls into view by\n * composing with the `stimeo--intersection` primitive (the classic \"animate\n * when visible\"). Distinct from `stimeo--countdown` (time-based). Core\n * (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--count-up`):\n * <span data-controller=\"stimeo--intersection stimeo--count-up\"\n * data-stimeo--intersection-once-value=\"true\"\n * data-action=\"stimeo--intersection:enter->stimeo--count-up#start\">1200</span>\n *\n * The **final number stays authored in the markup** (SEO / no-JS / SR read the\n * real value); `start` animates the displayed text from `from` to it over\n * `duration` ms with an ease-out curve, then restores the exact authored text.\n * With `once` (default) later starts are ignored (`data-count-up-done` records\n * a finished run across Turbo cache restores).\n *\n * @remarks\n * Behavior only — no formatting is imposed: the authored text is parsed for\n * its integer value (separators are ignored) and restored verbatim at the end;\n * intermediate frames render plain integers. Accessibility: when the user\n * prefers reduced motion the animation is skipped entirely (the value just\n * stays final — WCAG 2.3.3). The element is not a live region, so the ticking\n * intermediate numbers are never actively announced; during the run the\n * authored value is additionally kept in `aria-label` (best-effort — generic\n * roles may ignore it) and that lingering label doubles as the\n * interrupted-run marker `connect()` restores from after a Turbo cache\n * snapshot taken mid-animation. The animation frame is canceled on\n * `disconnect()` (Turbo navigation included) and the authored text restored.\n */\nexport class CountUpController extends Controller<HTMLElement> {\n static override values = {\n duration: { type: Number, default: 1200 },\n from: { type: Number, default: 0 },\n once: { type: Boolean, default: true },\n };\n static actions = [\"start\"] as const;\n static events = [\"end\"] as const;\n\n declare durationValue: number;\n declare fromValue: number;\n declare onceValue: boolean;\n\n #frame: number | null = null;\n /** The authored final text, restored verbatim when the run settles. */\n #finalText = \"\";\n\n override connect(): void {\n // Turbo snapshots the page BEFORE the body swap, so a cached page can hold\n // a mid-animation frame (disconnect()'s settle runs too late for it). The\n // OWN-label marker (never a bare aria-label — that may be authored) flags\n // the interrupted run: the label it owns still holds the authored text.\n if (this.element.hasAttribute(\"data-count-up-label\")) {\n this.element.textContent =\n this.element.getAttribute(\"aria-label\") ?? this.element.textContent;\n this.#restoreLabel();\n this.element.setAttribute(\"data-count-up-done\", \"true\");\n }\n }\n\n override disconnect(): void {\n // A run cannot survive the element: settle instantly so the cached\n // snapshot holds the real value, never a mid-animation frame.\n if (this.#frame !== null) this.#settle();\n }\n\n /**\n * Starts the animation (typically from `stimeo--intersection:enter` via\n * `data-action`). No-ops while running, and after a finished run when `once`.\n */\n start(): void {\n if (this.#frame !== null) return;\n if (this.onceValue && this.element.hasAttribute(\"data-count-up-done\")) return;\n\n this.#finalText = this.element.textContent ?? \"\";\n const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, \"\"), 10);\n if (Number.isNaN(target)) return;\n\n // Reduced motion: no ticking, just the final value (WCAG 2.3.3).\n if (prefersReducedMotion()) {\n this.element.setAttribute(\"data-count-up-done\", \"true\");\n this.dispatch(\"end\", { detail: { value: target } });\n return;\n }\n\n // AT keeps the real value while the visible text ticks. An authored\n // aria-label is parked (save-restore, never clobbered) and the override is\n // marker-owned so connect()/settle() only ever touch what this set.\n const authored = this.element.getAttribute(\"aria-label\");\n if (authored !== null) {\n this.element.setAttribute(\"data-count-up-original-label\", authored);\n }\n this.element.setAttribute(\"data-count-up-label\", \"true\");\n this.element.setAttribute(\"aria-label\", this.#finalText);\n const started = performance.now();\n const step = (now: number): void => {\n const t = Math.min((now - started) / this.durationValue, 1);\n const eased = 1 - (1 - t) ** 3; // ease-out cubic\n this.element.textContent = String(\n Math.round(this.fromValue + (target - this.fromValue) * eased),\n );\n if (t < 1) {\n this.#frame = requestAnimationFrame(step);\n } else {\n this.#settle();\n this.dispatch(\"end\", { detail: { value: target } });\n }\n };\n this.#frame = requestAnimationFrame(step);\n }\n\n /** Ends the run: cancels the frame and restores the authored presentation. */\n #settle(): void {\n if (this.#frame !== null) cancelAnimationFrame(this.#frame);\n this.#frame = null;\n this.element.textContent = this.#finalText;\n this.#restoreLabel();\n this.element.setAttribute(\"data-count-up-done\", \"true\");\n }\n\n /** Releases the marker-owned aria-label, restoring any parked authored value. */\n #restoreLabel(): void {\n if (!this.element.hasAttribute(\"data-count-up-label\")) return;\n const original = this.element.getAttribute(\"data-count-up-original-label\");\n if (original !== null) {\n this.element.setAttribute(\"aria-label\", original);\n this.element.removeAttribute(\"data-count-up-original-label\");\n } else {\n this.element.removeAttribute(\"aria-label\");\n }\n this.element.removeAttribute(\"data-count-up-label\");\n }\n}\n"]}
@@ -0,0 +1,72 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless currency / amount input behavior: groups digits for display while
5
+ * keeping a machine-readable number in a hidden field, and mirrors the
6
+ * normalized value to a visually-hidden span so assistive tech reads the real
7
+ * amount rather than the grouped display string.
8
+ *
9
+ * Markup contract (identifier: `stimeo--currency-input`):
10
+ * <div data-controller="stimeo--currency-input"
11
+ * data-stimeo--currency-input-locale-value="en-US"
12
+ * data-stimeo--currency-input-currency-value="USD"
13
+ * data-stimeo--currency-input-precision-value="2">
14
+ * <input type="text" inputmode="decimal"
15
+ * aria-describedby="amt-sr"
16
+ * data-stimeo--currency-input-target="display"
17
+ * data-action="input->stimeo--currency-input#onInput
18
+ * blur->stimeo--currency-input#format" />
19
+ * <span id="amt-sr" class="visually-hidden"
20
+ * data-stimeo--currency-input-target="srValue"></span>
21
+ * <input type="hidden" data-stimeo--currency-input-target="field" />
22
+ * </div>
23
+ *
24
+ * @remarks
25
+ * Behavior only — no styling, no validation (range/required belong to the
26
+ * consumer or Form Field). The display field is the sole Tab stop and keeps its
27
+ * native text-editing behavior; this controller never steals focus. Grouping is
28
+ * applied on every input while preserving the caret (counted by digits to its
29
+ * left), and the fixed-precision rounding is applied on `blur`.
30
+ *
31
+ * Honest a11y note: a hidden `<input>` is not exposed to assistive tech, so the
32
+ * normalized value is *also* published as text in the `srValue` span referenced
33
+ * by the display's `aria-describedby` — that, not the hidden field, is what a
34
+ * screen reader announces.
35
+ */
36
+ declare class CurrencyInputController extends Controller<HTMLElement> {
37
+ #private;
38
+ static targets: string[];
39
+ static values: {
40
+ locale: {
41
+ type: StringConstructor;
42
+ default: string;
43
+ };
44
+ currency: {
45
+ type: StringConstructor;
46
+ default: string;
47
+ };
48
+ precision: {
49
+ type: NumberConstructor;
50
+ default: number;
51
+ };
52
+ };
53
+ static actions: readonly ["format", "onInput"];
54
+ static events: readonly ["change"];
55
+ readonly displayTarget: HTMLInputElement;
56
+ readonly fieldTarget: HTMLInputElement;
57
+ readonly srValueTarget: HTMLElement;
58
+ readonly hasDisplayTarget: boolean;
59
+ readonly hasFieldTarget: boolean;
60
+ readonly hasSrValueTarget: boolean;
61
+ localeValue: string;
62
+ currencyValue: string;
63
+ precisionValue: number;
64
+ /** Normalizes any pre-filled display value to its fixed-precision form. */
65
+ connect(): void;
66
+ /** Re-groups digits as the user types, preserving the caret position. */
67
+ onInput(): void;
68
+ /** Applies the fixed-precision rounding on blur. */
69
+ format(): void;
70
+ }
71
+
72
+ export { CurrencyInputController };
@@ -0,0 +1,147 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/currency_input_controller.ts
4
+ var CurrencyInputController = class extends Controller {
5
+ static targets = ["display", "field", "srValue"];
6
+ static values = {
7
+ locale: { type: String, default: "en-US" },
8
+ currency: { type: String, default: "" },
9
+ precision: { type: Number, default: 2 }
10
+ };
11
+ static actions = ["format", "onInput"];
12
+ static events = ["change"];
13
+ /** Last committed numeric value, to suppress duplicate `change` dispatches. */
14
+ #lastValue = null;
15
+ /** Normalizes any pre-filled display value to its fixed-precision form. */
16
+ connect() {
17
+ if (!this.hasDisplayTarget) return;
18
+ const parsed = this.#parse(this.displayTarget.value);
19
+ this.#lastValue = parsed === null ? null : round(parsed, this.precisionValue);
20
+ if (this.displayTarget.value.trim() !== "") {
21
+ this.#reformat(true);
22
+ } else {
23
+ this.#reflect(null, "");
24
+ }
25
+ }
26
+ /** Re-groups digits as the user types, preserving the caret position. */
27
+ onInput() {
28
+ this.#reformat(false);
29
+ }
30
+ /** Applies the fixed-precision rounding on blur. */
31
+ format() {
32
+ this.#reformat(true);
33
+ }
34
+ /**
35
+ * Parses the display value, rewrites it grouped (optionally at fixed
36
+ * precision), keeps the caret stable by digit count, and syncs the field,
37
+ * the screen-reader span, and the `change` event.
38
+ */
39
+ #reformat(fixedPrecision) {
40
+ if (!this.hasDisplayTarget) return;
41
+ const raw = this.displayTarget.value;
42
+ const number = this.#parse(raw);
43
+ if (number === null) {
44
+ this.displayTarget.value = "";
45
+ this.#reflect(null, "");
46
+ return;
47
+ }
48
+ const value = fixedPrecision ? round(number, this.precisionValue) : number;
49
+ const caret = this.displayTarget.selectionStart;
50
+ const digitsBeforeCaret = typeof caret === "number" ? countDigits(raw.slice(0, caret)) : null;
51
+ const formatted = this.#formatNumber(value, fixedPrecision);
52
+ this.displayTarget.value = formatted;
53
+ if (digitsBeforeCaret !== null) this.#restoreCaret(formatted, digitsBeforeCaret);
54
+ this.#reflect(value, formatted);
55
+ }
56
+ /** Restores the caret to sit just after the n-th digit of the new string. */
57
+ #restoreCaret(formatted, digitsBefore) {
58
+ let seen = 0;
59
+ let position = formatted.length;
60
+ for (let i = 0; i < formatted.length; i++) {
61
+ if (seen >= digitsBefore) {
62
+ position = i;
63
+ break;
64
+ }
65
+ if (/\d/.test(formatted[i])) seen += 1;
66
+ }
67
+ try {
68
+ this.displayTarget.setSelectionRange(position, position);
69
+ } catch {
70
+ }
71
+ }
72
+ /** Writes the normalized value to the field, the SR span, and `change`. */
73
+ #reflect(value, formatted) {
74
+ const isEmpty = value === null;
75
+ if (this.hasFieldTarget) this.fieldTarget.value = isEmpty ? "" : String(value);
76
+ if (this.hasSrValueTarget) {
77
+ this.srValueTarget.textContent = isEmpty ? "" : this.#accessibleText(value);
78
+ }
79
+ this.element.toggleAttribute("data-stimeo--currency-input-empty", isEmpty);
80
+ if (value !== this.#lastValue) {
81
+ this.#lastValue = value;
82
+ if (!isEmpty) this.dispatch("change", { detail: { value, formatted } });
83
+ }
84
+ }
85
+ /** Parses arbitrary input text into a finite number, or `null` when blank. */
86
+ #parse(text) {
87
+ if (text.trim() === "") return null;
88
+ const { decimal } = this.#separators();
89
+ let cleaned = "";
90
+ let sawDot = false;
91
+ for (let i = 0; i < text.length; i++) {
92
+ const ch = text[i];
93
+ if (ch >= "0" && ch <= "9") cleaned += ch;
94
+ else if ((ch === "-" || ch === "+") && cleaned === "") cleaned += ch;
95
+ else if ((ch === decimal || ch === ".") && !sawDot) {
96
+ cleaned += ".";
97
+ sawDot = true;
98
+ }
99
+ }
100
+ if (cleaned === "" || cleaned === "-" || cleaned === "+" || cleaned === ".") return null;
101
+ const value = Number(cleaned);
102
+ return Number.isFinite(value) ? value : null;
103
+ }
104
+ /** Formats a number with grouping for the display field. */
105
+ #formatNumber(value, fixedPrecision) {
106
+ const formatter = new Intl.NumberFormat(this.localeValue, {
107
+ useGrouping: true,
108
+ minimumFractionDigits: fixedPrecision ? this.precisionValue : 0,
109
+ maximumFractionDigits: this.precisionValue
110
+ });
111
+ return formatter.format(value);
112
+ }
113
+ /** The text announced to assistive tech (currency-aware when configured). */
114
+ #accessibleText(value) {
115
+ if (this.currencyValue) {
116
+ return new Intl.NumberFormat(this.localeValue, {
117
+ style: "currency",
118
+ currency: this.currencyValue
119
+ }).format(value);
120
+ }
121
+ return new Intl.NumberFormat(this.localeValue, {
122
+ minimumFractionDigits: this.precisionValue,
123
+ maximumFractionDigits: this.precisionValue
124
+ }).format(value);
125
+ }
126
+ /** Resolves the locale's grouping and decimal separator characters. */
127
+ #separators() {
128
+ const parts = new Intl.NumberFormat(this.localeValue).formatToParts(11111.1);
129
+ const group = parts.find((p) => p.type === "group")?.value ?? ",";
130
+ const decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
131
+ return { group, decimal };
132
+ }
133
+ };
134
+ function countDigits(text) {
135
+ let count = 0;
136
+ for (const ch of text) if (ch >= "0" && ch <= "9") count += 1;
137
+ return count;
138
+ }
139
+ function round(value, precision) {
140
+ const factor = 10 ** Math.max(0, precision);
141
+ const rounded = Math.round(value * factor) / factor;
142
+ return rounded === 0 ? 0 : rounded;
143
+ }
144
+
145
+ export { CurrencyInputController };
146
+ //# sourceMappingURL=currency_input_controller.js.map
147
+ //# sourceMappingURL=currency_input_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/currency_input_controller.ts"],"names":[],"mappings":"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","file":"currency_input_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless currency / amount input behavior: groups digits for display while\n * keeping a machine-readable number in a hidden field, and mirrors the\n * normalized value to a visually-hidden span so assistive tech reads the real\n * amount rather than the grouped display string.\n *\n * Markup contract (identifier: `stimeo--currency-input`):\n * <div data-controller=\"stimeo--currency-input\"\n * data-stimeo--currency-input-locale-value=\"en-US\"\n * data-stimeo--currency-input-currency-value=\"USD\"\n * data-stimeo--currency-input-precision-value=\"2\">\n * <input type=\"text\" inputmode=\"decimal\"\n * aria-describedby=\"amt-sr\"\n * data-stimeo--currency-input-target=\"display\"\n * data-action=\"input->stimeo--currency-input#onInput\n * blur->stimeo--currency-input#format\" />\n * <span id=\"amt-sr\" class=\"visually-hidden\"\n * data-stimeo--currency-input-target=\"srValue\"></span>\n * <input type=\"hidden\" data-stimeo--currency-input-target=\"field\" />\n * </div>\n *\n * @remarks\n * Behavior only — no styling, no validation (range/required belong to the\n * consumer or Form Field). The display field is the sole Tab stop and keeps its\n * native text-editing behavior; this controller never steals focus. Grouping is\n * applied on every input while preserving the caret (counted by digits to its\n * left), and the fixed-precision rounding is applied on `blur`.\n *\n * Honest a11y note: a hidden `<input>` is not exposed to assistive tech, so the\n * normalized value is *also* published as text in the `srValue` span referenced\n * by the display's `aria-describedby` — that, not the hidden field, is what a\n * screen reader announces.\n */\nexport class CurrencyInputController extends Controller<HTMLElement> {\n static override targets = [\"display\", \"field\", \"srValue\"];\n static override values = {\n locale: { type: String, default: \"en-US\" },\n currency: { type: String, default: \"\" },\n precision: { type: Number, default: 2 },\n };\n static actions = [\"format\", \"onInput\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly displayTarget: HTMLInputElement;\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly srValueTarget: HTMLElement;\n declare readonly hasDisplayTarget: boolean;\n declare readonly hasFieldTarget: boolean;\n declare readonly hasSrValueTarget: boolean;\n declare localeValue: string;\n declare currencyValue: string;\n declare precisionValue: number;\n\n /** Last committed numeric value, to suppress duplicate `change` dispatches. */\n #lastValue: number | null = null;\n\n /** Normalizes any pre-filled display value to its fixed-precision form. */\n override connect(): void {\n if (!this.hasDisplayTarget) return;\n // Seed lastValue with the *rounded* initial value so the idempotent\n // connect-time reformat does not dispatch a spurious `change`.\n const parsed = this.#parse(this.displayTarget.value);\n this.#lastValue = parsed === null ? null : round(parsed, this.precisionValue);\n if (this.displayTarget.value.trim() !== \"\") {\n this.#reformat(true);\n } else {\n this.#reflect(null, \"\");\n }\n }\n\n /** Re-groups digits as the user types, preserving the caret position. */\n onInput(): void {\n this.#reformat(false);\n }\n\n /** Applies the fixed-precision rounding on blur. */\n format(): void {\n this.#reformat(true);\n }\n\n /**\n * Parses the display value, rewrites it grouped (optionally at fixed\n * precision), keeps the caret stable by digit count, and syncs the field,\n * the screen-reader span, and the `change` event.\n */\n #reformat(fixedPrecision: boolean): void {\n if (!this.hasDisplayTarget) return;\n const raw = this.displayTarget.value;\n const number = this.#parse(raw);\n\n if (number === null) {\n this.displayTarget.value = \"\";\n this.#reflect(null, \"\");\n return;\n }\n\n const value = fixedPrecision ? round(number, this.precisionValue) : number;\n const caret = this.displayTarget.selectionStart;\n const digitsBeforeCaret = typeof caret === \"number\" ? countDigits(raw.slice(0, caret)) : null;\n\n const formatted = this.#formatNumber(value, fixedPrecision);\n this.displayTarget.value = formatted;\n if (digitsBeforeCaret !== null) this.#restoreCaret(formatted, digitsBeforeCaret);\n\n this.#reflect(value, formatted);\n }\n\n /** Restores the caret to sit just after the n-th digit of the new string. */\n #restoreCaret(formatted: string, digitsBefore: number): void {\n let seen = 0;\n let position = formatted.length;\n for (let i = 0; i < formatted.length; i++) {\n if (seen >= digitsBefore) {\n position = i;\n break;\n }\n if (/\\d/.test(formatted[i] as string)) seen += 1;\n }\n try {\n this.displayTarget.setSelectionRange(position, position);\n } catch {\n // Some hosts disallow selection on certain input states; the value is\n // already correct, so a failed caret restore is non-fatal.\n }\n }\n\n /** Writes the normalized value to the field, the SR span, and `change`. */\n #reflect(value: number | null, formatted: string): void {\n const isEmpty = value === null;\n if (this.hasFieldTarget) this.fieldTarget.value = isEmpty ? \"\" : String(value);\n if (this.hasSrValueTarget) {\n this.srValueTarget.textContent = isEmpty ? \"\" : this.#accessibleText(value);\n }\n this.element.toggleAttribute(\"data-stimeo--currency-input-empty\", isEmpty);\n\n if (value !== this.#lastValue) {\n this.#lastValue = value;\n if (!isEmpty) this.dispatch(\"change\", { detail: { value, formatted } });\n }\n }\n\n /** Parses arbitrary input text into a finite number, or `null` when blank. */\n #parse(text: string): number | null {\n if (text.trim() === \"\") return null;\n const { decimal } = this.#separators();\n // Keep digits, a leading sign, and decimal marks; normalize the locale's\n // decimal separator to \".\" and drop everything else (grouping, symbols).\n let cleaned = \"\";\n let sawDot = false;\n for (let i = 0; i < text.length; i++) {\n const ch = text[i] as string;\n if (ch >= \"0\" && ch <= \"9\") cleaned += ch;\n else if ((ch === \"-\" || ch === \"+\") && cleaned === \"\") cleaned += ch;\n else if ((ch === decimal || ch === \".\") && !sawDot) {\n cleaned += \".\";\n sawDot = true;\n }\n }\n if (cleaned === \"\" || cleaned === \"-\" || cleaned === \"+\" || cleaned === \".\") return null;\n const value = Number(cleaned);\n return Number.isFinite(value) ? value : null;\n }\n\n /** Formats a number with grouping for the display field. */\n #formatNumber(value: number, fixedPrecision: boolean): string {\n const formatter = new Intl.NumberFormat(this.localeValue, {\n useGrouping: true,\n minimumFractionDigits: fixedPrecision ? this.precisionValue : 0,\n maximumFractionDigits: this.precisionValue,\n });\n return formatter.format(value);\n }\n\n /** The text announced to assistive tech (currency-aware when configured). */\n #accessibleText(value: number): string {\n if (this.currencyValue) {\n return new Intl.NumberFormat(this.localeValue, {\n style: \"currency\",\n currency: this.currencyValue,\n }).format(value);\n }\n return new Intl.NumberFormat(this.localeValue, {\n minimumFractionDigits: this.precisionValue,\n maximumFractionDigits: this.precisionValue,\n }).format(value);\n }\n\n /** Resolves the locale's grouping and decimal separator characters. */\n #separators(): { group: string; decimal: string } {\n const parts = new Intl.NumberFormat(this.localeValue).formatToParts(11111.1);\n const group = parts.find((p) => p.type === \"group\")?.value ?? \",\";\n const decimal = parts.find((p) => p.type === \"decimal\")?.value ?? \".\";\n return { group, decimal };\n }\n}\n\n/** Counts the digit characters in a string. */\nfunction countDigits(text: string): number {\n let count = 0;\n for (const ch of text) if (ch >= \"0\" && ch <= \"9\") count += 1;\n return count;\n}\n\n/** Rounds `value` to `precision` decimal places, avoiding `-0`. */\nfunction round(value: number, precision: number): number {\n const factor = 10 ** Math.max(0, precision);\n const rounded = Math.round(value * factor) / factor;\n return rounded === 0 ? 0 : rounded;\n}\n"]}