stimeo-ui 0.1.0-alpha.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 (238) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/dist/controllers/accordion_controller.d.ts +59 -0
  4. package/dist/controllers/accordion_controller.js +76 -0
  5. package/dist/controllers/accordion_controller.js.map +1 -0
  6. package/dist/controllers/announcer_controller.d.ts +78 -0
  7. package/dist/controllers/announcer_controller.js +184 -0
  8. package/dist/controllers/announcer_controller.js.map +1 -0
  9. package/dist/controllers/aspect_ratio_controller.d.ts +39 -0
  10. package/dist/controllers/aspect_ratio_controller.js +36 -0
  11. package/dist/controllers/aspect_ratio_controller.js.map +1 -0
  12. package/dist/controllers/auto_submit_controller.d.ts +74 -0
  13. package/dist/controllers/auto_submit_controller.js +147 -0
  14. package/dist/controllers/auto_submit_controller.js.map +1 -0
  15. package/dist/controllers/avatar_controller.d.ts +50 -0
  16. package/dist/controllers/avatar_controller.js +66 -0
  17. package/dist/controllers/avatar_controller.js.map +1 -0
  18. package/dist/controllers/breadcrumb_controller.d.ts +60 -0
  19. package/dist/controllers/breadcrumb_controller.js +123 -0
  20. package/dist/controllers/breadcrumb_controller.js.map +1 -0
  21. package/dist/controllers/bulk_select_controller.d.ts +67 -0
  22. package/dist/controllers/bulk_select_controller.js +104 -0
  23. package/dist/controllers/bulk_select_controller.js.map +1 -0
  24. package/dist/controllers/calendar_controller.d.ts +100 -0
  25. package/dist/controllers/calendar_controller.js +394 -0
  26. package/dist/controllers/calendar_controller.js.map +1 -0
  27. package/dist/controllers/character_counter_controller.d.ts +63 -0
  28. package/dist/controllers/character_counter_controller.js +179 -0
  29. package/dist/controllers/character_counter_controller.js.map +1 -0
  30. package/dist/controllers/checkbox_controller.d.ts +52 -0
  31. package/dist/controllers/checkbox_controller.js +73 -0
  32. package/dist/controllers/checkbox_controller.js.map +1 -0
  33. package/dist/controllers/combobox_controller.d.ts +76 -0
  34. package/dist/controllers/combobox_controller.js +186 -0
  35. package/dist/controllers/combobox_controller.js.map +1 -0
  36. package/dist/controllers/command_palette_controller.d.ts +103 -0
  37. package/dist/controllers/command_palette_controller.js +381 -0
  38. package/dist/controllers/command_palette_controller.js.map +1 -0
  39. package/dist/controllers/conditional_fields_controller.d.ts +56 -0
  40. package/dist/controllers/conditional_fields_controller.js +112 -0
  41. package/dist/controllers/conditional_fields_controller.js.map +1 -0
  42. package/dist/controllers/confirm_controller.d.ts +83 -0
  43. package/dist/controllers/confirm_controller.js +276 -0
  44. package/dist/controllers/confirm_controller.js.map +1 -0
  45. package/dist/controllers/context_menu_controller.d.ts +81 -0
  46. package/dist/controllers/context_menu_controller.js +112 -0
  47. package/dist/controllers/context_menu_controller.js.map +1 -0
  48. package/dist/controllers/countdown_controller.d.ts +93 -0
  49. package/dist/controllers/countdown_controller.js +202 -0
  50. package/dist/controllers/countdown_controller.js.map +1 -0
  51. package/dist/controllers/dialog_controller.d.ts +57 -0
  52. package/dist/controllers/dialog_controller.js +207 -0
  53. package/dist/controllers/dialog_controller.js.map +1 -0
  54. package/dist/controllers/direct_upload_controller.d.ts +77 -0
  55. package/dist/controllers/direct_upload_controller.js +212 -0
  56. package/dist/controllers/direct_upload_controller.js.map +1 -0
  57. package/dist/controllers/dirty_form_controller.d.ts +52 -0
  58. package/dist/controllers/dirty_form_controller.js +128 -0
  59. package/dist/controllers/dirty_form_controller.js.map +1 -0
  60. package/dist/controllers/dropdown_controller.d.ts +49 -0
  61. package/dist/controllers/dropdown_controller.js +66 -0
  62. package/dist/controllers/dropdown_controller.js.map +1 -0
  63. package/dist/controllers/empty_state_controller.d.ts +60 -0
  64. package/dist/controllers/empty_state_controller.js +67 -0
  65. package/dist/controllers/empty_state_controller.js.map +1 -0
  66. package/dist/controllers/flash_controller.d.ts +63 -0
  67. package/dist/controllers/flash_controller.js +221 -0
  68. package/dist/controllers/flash_controller.js.map +1 -0
  69. package/dist/controllers/focus_controller.d.ts +69 -0
  70. package/dist/controllers/focus_controller.js +216 -0
  71. package/dist/controllers/focus_controller.js.map +1 -0
  72. package/dist/controllers/form_field_controller.d.ts +79 -0
  73. package/dist/controllers/form_field_controller.js +154 -0
  74. package/dist/controllers/form_field_controller.js.map +1 -0
  75. package/dist/controllers/form_validation_controller.d.ts +114 -0
  76. package/dist/controllers/form_validation_controller.js +202 -0
  77. package/dist/controllers/form_validation_controller.js.map +1 -0
  78. package/dist/controllers/frame_loading_controller.d.ts +58 -0
  79. package/dist/controllers/frame_loading_controller.js +177 -0
  80. package/dist/controllers/frame_loading_controller.js.map +1 -0
  81. package/dist/controllers/highlight_controller.d.ts +50 -0
  82. package/dist/controllers/highlight_controller.js +107 -0
  83. package/dist/controllers/highlight_controller.js.map +1 -0
  84. package/dist/controllers/hover_card_controller.d.ts +79 -0
  85. package/dist/controllers/hover_card_controller.js +165 -0
  86. package/dist/controllers/hover_card_controller.js.map +1 -0
  87. package/dist/controllers/idle_controller.d.ts +51 -0
  88. package/dist/controllers/idle_controller.js +141 -0
  89. package/dist/controllers/idle_controller.js.map +1 -0
  90. package/dist/controllers/input_mask_controller.d.ts +74 -0
  91. package/dist/controllers/input_mask_controller.js +166 -0
  92. package/dist/controllers/input_mask_controller.js.map +1 -0
  93. package/dist/controllers/lazy_frame_controller.d.ts +53 -0
  94. package/dist/controllers/lazy_frame_controller.js +68 -0
  95. package/dist/controllers/lazy_frame_controller.js.map +1 -0
  96. package/dist/controllers/listbox_controller.d.ts +88 -0
  97. package/dist/controllers/listbox_controller.js +256 -0
  98. package/dist/controllers/listbox_controller.js.map +1 -0
  99. package/dist/controllers/local_time_controller.d.ts +60 -0
  100. package/dist/controllers/local_time_controller.js +81 -0
  101. package/dist/controllers/local_time_controller.js.map +1 -0
  102. package/dist/controllers/menu_controller.d.ts +81 -0
  103. package/dist/controllers/menu_controller.js +134 -0
  104. package/dist/controllers/menu_controller.js.map +1 -0
  105. package/dist/controllers/meter_controller.d.ts +93 -0
  106. package/dist/controllers/meter_controller.js +96 -0
  107. package/dist/controllers/meter_controller.js.map +1 -0
  108. package/dist/controllers/nested_form_controller.d.ts +85 -0
  109. package/dist/controllers/nested_form_controller.js +131 -0
  110. package/dist/controllers/nested_form_controller.js.map +1 -0
  111. package/dist/controllers/network_status_controller.d.ts +49 -0
  112. package/dist/controllers/network_status_controller.js +126 -0
  113. package/dist/controllers/network_status_controller.js.map +1 -0
  114. package/dist/controllers/number_input_controller.d.ts +93 -0
  115. package/dist/controllers/number_input_controller.js +306 -0
  116. package/dist/controllers/number_input_controller.js.map +1 -0
  117. package/dist/controllers/otp_controller.d.ts +68 -0
  118. package/dist/controllers/otp_controller.js +201 -0
  119. package/dist/controllers/otp_controller.js.map +1 -0
  120. package/dist/controllers/overflow_indicator_controller.d.ts +62 -0
  121. package/dist/controllers/overflow_indicator_controller.js +169 -0
  122. package/dist/controllers/overflow_indicator_controller.js.map +1 -0
  123. package/dist/controllers/overflow_menu_controller.d.ts +64 -0
  124. package/dist/controllers/overflow_menu_controller.js +274 -0
  125. package/dist/controllers/overflow_menu_controller.js.map +1 -0
  126. package/dist/controllers/pagination_controller.d.ts +69 -0
  127. package/dist/controllers/pagination_controller.js +89 -0
  128. package/dist/controllers/pagination_controller.js.map +1 -0
  129. package/dist/controllers/password_strength_controller.d.ts +67 -0
  130. package/dist/controllers/password_strength_controller.js +175 -0
  131. package/dist/controllers/password_strength_controller.js.map +1 -0
  132. package/dist/controllers/persist_controller.d.ts +63 -0
  133. package/dist/controllers/persist_controller.js +259 -0
  134. package/dist/controllers/persist_controller.js.map +1 -0
  135. package/dist/controllers/popover_controller.d.ts +63 -0
  136. package/dist/controllers/popover_controller.js +94 -0
  137. package/dist/controllers/popover_controller.js.map +1 -0
  138. package/dist/controllers/portal_controller.d.ts +58 -0
  139. package/dist/controllers/portal_controller.js +63 -0
  140. package/dist/controllers/portal_controller.js.map +1 -0
  141. package/dist/controllers/preview_guard_controller.d.ts +47 -0
  142. package/dist/controllers/preview_guard_controller.js +69 -0
  143. package/dist/controllers/preview_guard_controller.js.map +1 -0
  144. package/dist/controllers/progress_controller.d.ts +85 -0
  145. package/dist/controllers/progress_controller.js +93 -0
  146. package/dist/controllers/progress_controller.js.map +1 -0
  147. package/dist/controllers/radio_group_controller.d.ts +49 -0
  148. package/dist/controllers/radio_group_controller.js +128 -0
  149. package/dist/controllers/radio_group_controller.js.map +1 -0
  150. package/dist/controllers/rating_controller.d.ts +82 -0
  151. package/dist/controllers/rating_controller.js +179 -0
  152. package/dist/controllers/rating_controller.js.map +1 -0
  153. package/dist/controllers/relative_time_controller.d.ts +48 -0
  154. package/dist/controllers/relative_time_controller.js +129 -0
  155. package/dist/controllers/relative_time_controller.js.map +1 -0
  156. package/dist/controllers/reset_before_cache_controller.d.ts +54 -0
  157. package/dist/controllers/reset_before_cache_controller.js +62 -0
  158. package/dist/controllers/reset_before_cache_controller.js.map +1 -0
  159. package/dist/controllers/resizable_controller.d.ts +81 -0
  160. package/dist/controllers/resizable_controller.js +163 -0
  161. package/dist/controllers/resizable_controller.js.map +1 -0
  162. package/dist/controllers/roving_controller.d.ts +63 -0
  163. package/dist/controllers/roving_controller.js +116 -0
  164. package/dist/controllers/roving_controller.js.map +1 -0
  165. package/dist/controllers/scroll_area_controller.d.ts +45 -0
  166. package/dist/controllers/scroll_area_controller.js +183 -0
  167. package/dist/controllers/scroll_area_controller.js.map +1 -0
  168. package/dist/controllers/scroll_visibility_controller.d.ts +72 -0
  169. package/dist/controllers/scroll_visibility_controller.js +103 -0
  170. package/dist/controllers/scroll_visibility_controller.js.map +1 -0
  171. package/dist/controllers/scrollspy_controller.d.ts +65 -0
  172. package/dist/controllers/scrollspy_controller.js +171 -0
  173. package/dist/controllers/scrollspy_controller.js.map +1 -0
  174. package/dist/controllers/skeleton_controller.d.ts +50 -0
  175. package/dist/controllers/skeleton_controller.js +125 -0
  176. package/dist/controllers/skeleton_controller.js.map +1 -0
  177. package/dist/controllers/slider_controller.d.ts +76 -0
  178. package/dist/controllers/slider_controller.js +109 -0
  179. package/dist/controllers/slider_controller.js.map +1 -0
  180. package/dist/controllers/spinner_controller.d.ts +62 -0
  181. package/dist/controllers/spinner_controller.js +164 -0
  182. package/dist/controllers/spinner_controller.js.map +1 -0
  183. package/dist/controllers/step_indicator_controller.d.ts +55 -0
  184. package/dist/controllers/step_indicator_controller.js +55 -0
  185. package/dist/controllers/step_indicator_controller.js.map +1 -0
  186. package/dist/controllers/stepper_controller.d.ts +66 -0
  187. package/dist/controllers/stepper_controller.js +78 -0
  188. package/dist/controllers/stepper_controller.js.map +1 -0
  189. package/dist/controllers/stick_to_bottom_controller.d.ts +61 -0
  190. package/dist/controllers/stick_to_bottom_controller.js +100 -0
  191. package/dist/controllers/stick_to_bottom_controller.js.map +1 -0
  192. package/dist/controllers/sticky_observer_controller.d.ts +54 -0
  193. package/dist/controllers/sticky_observer_controller.js +53 -0
  194. package/dist/controllers/sticky_observer_controller.js.map +1 -0
  195. package/dist/controllers/submit_once_controller.d.ts +66 -0
  196. package/dist/controllers/submit_once_controller.js +206 -0
  197. package/dist/controllers/submit_once_controller.js.map +1 -0
  198. package/dist/controllers/switch_controller.d.ts +43 -0
  199. package/dist/controllers/switch_controller.js +50 -0
  200. package/dist/controllers/switch_controller.js.map +1 -0
  201. package/dist/controllers/tabs_controller.d.ts +48 -0
  202. package/dist/controllers/tabs_controller.js +63 -0
  203. package/dist/controllers/tabs_controller.js.map +1 -0
  204. package/dist/controllers/textarea_autosize_controller.d.ts +47 -0
  205. package/dist/controllers/textarea_autosize_controller.js +72 -0
  206. package/dist/controllers/textarea_autosize_controller.js.map +1 -0
  207. package/dist/controllers/theme_controller.d.ts +70 -0
  208. package/dist/controllers/theme_controller.js +154 -0
  209. package/dist/controllers/theme_controller.js.map +1 -0
  210. package/dist/controllers/toast_controller.d.ts +123 -0
  211. package/dist/controllers/toast_controller.js +310 -0
  212. package/dist/controllers/toast_controller.js.map +1 -0
  213. package/dist/controllers/toggle_group_controller.d.ts +56 -0
  214. package/dist/controllers/toggle_group_controller.js +130 -0
  215. package/dist/controllers/toggle_group_controller.js.map +1 -0
  216. package/dist/controllers/toolbar_controller.d.ts +59 -0
  217. package/dist/controllers/toolbar_controller.js +113 -0
  218. package/dist/controllers/toolbar_controller.js.map +1 -0
  219. package/dist/controllers/tooltip_controller.d.ts +74 -0
  220. package/dist/controllers/tooltip_controller.js +165 -0
  221. package/dist/controllers/tooltip_controller.js.map +1 -0
  222. package/dist/controllers/transition_controller.d.ts +86 -0
  223. package/dist/controllers/transition_controller.js +203 -0
  224. package/dist/controllers/transition_controller.js.map +1 -0
  225. package/dist/index.d.ts +1931 -0
  226. package/dist/index.js +12241 -0
  227. package/dist/index.js.map +1 -0
  228. package/dist/inspector/cli.d.ts +149 -0
  229. package/dist/inspector/cli.js +663 -0
  230. package/dist/inspector/cli.js.map +1 -0
  231. package/dist/inspector/cli_bin.d.ts +2 -0
  232. package/dist/inspector/cli_bin.js +665 -0
  233. package/dist/inspector/cli_bin.js.map +1 -0
  234. package/dist/inspector/manifest.json +2235 -0
  235. package/dist/positioning/index.d.ts +226 -0
  236. package/dist/positioning/index.js +145 -0
  237. package/dist/positioning/index.js.map +1 -0
  238. package/package.json +93 -0
@@ -0,0 +1,68 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible One-Time Password / PIN input logic.
5
+ *
6
+ * Markup contract (identifier: `stimeo--otp`):
7
+ * <div data-controller="stimeo--otp"
8
+ * data-stimeo--otp-length-value="6"
9
+ * data-stimeo--otp-pattern-value="[0-9]"
10
+ * role="group" aria-label="One-time passcode">
11
+ * <input data-stimeo--otp-target="field" inputmode="numeric" maxlength="1"
12
+ * data-action="input->stimeo--otp#onInput
13
+ * keydown->stimeo--otp#onKeydown
14
+ * paste->stimeo--otp#onPaste" />
15
+ * <input type="hidden" data-stimeo--otp-target="value" />
16
+ * </div>
17
+ *
18
+ * Implements highly polished UX conventions for single-digit field groups:
19
+ * - Dynamic autofocus advancement upon valid inputs.
20
+ * - Auto-selection `select()` upon gaining focus to enable frictionless digit overwrites.
21
+ * - Retroactive Backspace key focus stepping to clear preceding values safely.
22
+ * - Clipboard intercepting paste division across fields with trailing focus positioning.
23
+ * - Hidden form sync and complete/change event dispatching.
24
+ *
25
+ * @remarks
26
+ * Behavior only. The controller binds events dynamically to pre-rendered inputs,
27
+ * updates `data-filled` and hidden value bindings, and emits Stimulus custom events.
28
+ */
29
+ declare class OtpController extends Controller<HTMLElement> {
30
+ #private;
31
+ static targets: string[];
32
+ static values: {
33
+ length: {
34
+ type: NumberConstructor;
35
+ default: number;
36
+ };
37
+ pattern: {
38
+ type: StringConstructor;
39
+ default: string;
40
+ };
41
+ };
42
+ static actions: readonly ["onInput", "onKeydown", "onPaste"];
43
+ static events: readonly ["change", "complete", "invalid"];
44
+ readonly fieldTargets: HTMLInputElement[];
45
+ readonly valueTarget: HTMLInputElement;
46
+ readonly errorTarget: HTMLElement;
47
+ readonly hasValueTarget: boolean;
48
+ readonly hasErrorTarget: boolean;
49
+ lengthValue: number;
50
+ patternValue: string;
51
+ connect(): void;
52
+ disconnect(): void;
53
+ /**
54
+ * Stimulus lifecycle callback when a new field target enters the DOM.
55
+ * Ensures new additions are also wired with overwriting support.
56
+ */
57
+ fieldTargetConnected(element: HTMLInputElement): void;
58
+ /** Removes focus listeners when fields are dropped. */
59
+ fieldTargetDisconnected(element: HTMLInputElement): void;
60
+ /** Handles keystroke inputs and advances focus to the next field. */
61
+ onInput(event: Event): void;
62
+ /** Handles Backspace retreating, arrows, and home/end navigation. */
63
+ onKeydown(event: KeyboardEvent): void;
64
+ /** Divides pasted string characters across available input fields. */
65
+ onPaste(event: ClipboardEvent): void;
66
+ }
67
+
68
+ export { OtpController };
@@ -0,0 +1,201 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/otp_controller.ts
4
+ var OtpController = class extends Controller {
5
+ static targets = ["field", "value", "error"];
6
+ static values = {
7
+ length: { type: Number, default: 6 },
8
+ pattern: { type: String, default: "[0-9]" }
9
+ };
10
+ static actions = ["onInput", "onKeydown", "onPaste"];
11
+ static events = ["change", "complete", "invalid"];
12
+ #isComposing = /* @__PURE__ */ new Map();
13
+ connect() {
14
+ for (const field of this.fieldTargets) {
15
+ field.addEventListener("focus", this.#onFieldFocus);
16
+ field.addEventListener("compositionstart", this.#onCompositionStart);
17
+ field.addEventListener("compositionend", this.#onCompositionEnd);
18
+ }
19
+ }
20
+ disconnect() {
21
+ for (const field of this.fieldTargets) {
22
+ field.removeEventListener("focus", this.#onFieldFocus);
23
+ field.removeEventListener("compositionstart", this.#onCompositionStart);
24
+ field.removeEventListener("compositionend", this.#onCompositionEnd);
25
+ }
26
+ this.#isComposing.clear();
27
+ }
28
+ /**
29
+ * Stimulus lifecycle callback when a new field target enters the DOM.
30
+ * Ensures new additions are also wired with overwriting support.
31
+ */
32
+ fieldTargetConnected(element) {
33
+ element.addEventListener("focus", this.#onFieldFocus);
34
+ element.addEventListener("compositionstart", this.#onCompositionStart);
35
+ element.addEventListener("compositionend", this.#onCompositionEnd);
36
+ }
37
+ /** Removes focus listeners when fields are dropped. */
38
+ fieldTargetDisconnected(element) {
39
+ element.removeEventListener("focus", this.#onFieldFocus);
40
+ element.removeEventListener("compositionstart", this.#onCompositionStart);
41
+ element.removeEventListener("compositionend", this.#onCompositionEnd);
42
+ this.#isComposing.delete(element);
43
+ }
44
+ /** Handles keystroke inputs and advances focus to the next field. */
45
+ onInput(event) {
46
+ const input = event.currentTarget;
47
+ if (!input) return;
48
+ if (this.#isComposing.get(input)) return;
49
+ this.#handleInputValidation(input);
50
+ }
51
+ /** Handles Backspace retreating, arrows, and home/end navigation. */
52
+ onKeydown(event) {
53
+ const input = event.currentTarget;
54
+ if (!input) return;
55
+ const index = this.fieldTargets.indexOf(input);
56
+ if (index === -1) return;
57
+ if (this.#isComposing.get(input)) return;
58
+ switch (event.key) {
59
+ case "Backspace":
60
+ if (!input.value) {
61
+ if (index > 0) {
62
+ event.preventDefault();
63
+ const prevField = this.fieldTargets[index - 1];
64
+ if (prevField) {
65
+ prevField.value = "";
66
+ prevField.removeAttribute("data-filled");
67
+ prevField.focus();
68
+ this.#clearError();
69
+ this.#syncAndDispatch();
70
+ }
71
+ }
72
+ } else {
73
+ input.value = "";
74
+ input.removeAttribute("data-filled");
75
+ this.#clearError();
76
+ this.#syncAndDispatch();
77
+ }
78
+ break;
79
+ case "ArrowLeft":
80
+ if (index > 0) {
81
+ event.preventDefault();
82
+ this.fieldTargets[index - 1]?.focus();
83
+ }
84
+ break;
85
+ case "ArrowRight":
86
+ if (index < this.lengthValue - 1) {
87
+ event.preventDefault();
88
+ this.fieldTargets[index + 1]?.focus();
89
+ }
90
+ break;
91
+ case "Home":
92
+ event.preventDefault();
93
+ this.fieldTargets[0]?.focus();
94
+ break;
95
+ case "End":
96
+ event.preventDefault();
97
+ this.fieldTargets[this.lengthValue - 1]?.focus();
98
+ break;
99
+ }
100
+ }
101
+ /** Divides pasted string characters across available input fields. */
102
+ onPaste(event) {
103
+ const input = event.currentTarget;
104
+ if (!input) return;
105
+ const startIndex = this.fieldTargets.indexOf(input);
106
+ if (startIndex === -1) return;
107
+ event.preventDefault();
108
+ const rawText = event.clipboardData?.getData("text") || "";
109
+ const text = this.#normalizeValue(rawText);
110
+ const regex = new RegExp(`^${this.patternValue}$`);
111
+ const validChars = Array.from(text).filter((char) => regex.test(char));
112
+ if (validChars.length === 0) {
113
+ this.#showError();
114
+ return;
115
+ }
116
+ const limit = Math.min(validChars.length, this.lengthValue - startIndex);
117
+ let lastFocusedIndex = startIndex;
118
+ for (let i = 0; i < limit; i++) {
119
+ const fieldIndex = startIndex + i;
120
+ const field = this.fieldTargets[fieldIndex];
121
+ const char = validChars[i];
122
+ if (field && char) {
123
+ field.value = char;
124
+ field.setAttribute("data-filled", "true");
125
+ lastFocusedIndex = fieldIndex;
126
+ }
127
+ }
128
+ this.#clearError();
129
+ const focusTargetIndex = lastFocusedIndex < this.lengthValue - 1 ? lastFocusedIndex + 1 : lastFocusedIndex;
130
+ this.fieldTargets[focusTargetIndex]?.focus();
131
+ this.#syncAndDispatch();
132
+ }
133
+ #onFieldFocus = (event) => {
134
+ const input = event.currentTarget;
135
+ if (input) {
136
+ input.select();
137
+ }
138
+ };
139
+ #onCompositionStart = (event) => {
140
+ const input = event.currentTarget;
141
+ this.#isComposing.set(input, true);
142
+ };
143
+ #onCompositionEnd = (event) => {
144
+ const input = event.currentTarget;
145
+ this.#isComposing.set(input, false);
146
+ this.#handleInputValidation(input);
147
+ };
148
+ #handleInputValidation(input) {
149
+ const index = this.fieldTargets.indexOf(input);
150
+ if (index === -1) return;
151
+ const rawValue = input.value;
152
+ const normalized = this.#normalizeValue(rawValue);
153
+ const regex = new RegExp(`^${this.patternValue}$`);
154
+ if (normalized && regex.test(normalized)) {
155
+ input.value = normalized;
156
+ input.setAttribute("data-filled", "true");
157
+ this.#clearError();
158
+ if (index < this.lengthValue - 1) {
159
+ const nextField = this.fieldTargets[index + 1];
160
+ nextField?.focus();
161
+ }
162
+ } else if (normalized) {
163
+ input.value = "";
164
+ input.removeAttribute("data-filled");
165
+ this.#showError();
166
+ } else {
167
+ input.removeAttribute("data-filled");
168
+ }
169
+ this.#syncAndDispatch();
170
+ }
171
+ #normalizeValue(val) {
172
+ return val.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 65248));
173
+ }
174
+ #showError() {
175
+ if (this.hasErrorTarget) {
176
+ this.errorTarget.removeAttribute("hidden");
177
+ }
178
+ this.dispatch("invalid", { detail: { pattern: this.patternValue } });
179
+ }
180
+ #clearError() {
181
+ if (this.hasErrorTarget) {
182
+ this.errorTarget.setAttribute("hidden", "true");
183
+ }
184
+ }
185
+ #syncAndDispatch() {
186
+ const fields = this.fieldTargets;
187
+ const combinedValue = fields.map((f) => f.value).join("");
188
+ if (this.hasValueTarget) {
189
+ this.valueTarget.value = combinedValue;
190
+ }
191
+ this.dispatch("change", { detail: { value: combinedValue } });
192
+ const isCompleted = fields.every((f) => f.value.length > 0) && combinedValue.length === this.lengthValue;
193
+ if (isCompleted) {
194
+ this.dispatch("complete", { detail: { value: combinedValue } });
195
+ }
196
+ }
197
+ };
198
+
199
+ export { OtpController };
200
+ //# sourceMappingURL=otp_controller.js.map
201
+ //# sourceMappingURL=otp_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/otp_controller.ts"],"names":[],"mappings":"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","file":"otp_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible One-Time Password / PIN input logic.\n *\n * Markup contract (identifier: `stimeo--otp`):\n * <div data-controller=\"stimeo--otp\"\n * data-stimeo--otp-length-value=\"6\"\n * data-stimeo--otp-pattern-value=\"[0-9]\"\n * role=\"group\" aria-label=\"One-time passcode\">\n * <input data-stimeo--otp-target=\"field\" inputmode=\"numeric\" maxlength=\"1\"\n * data-action=\"input->stimeo--otp#onInput\n * keydown->stimeo--otp#onKeydown\n * paste->stimeo--otp#onPaste\" />\n * <input type=\"hidden\" data-stimeo--otp-target=\"value\" />\n * </div>\n *\n * Implements highly polished UX conventions for single-digit field groups:\n * - Dynamic autofocus advancement upon valid inputs.\n * - Auto-selection `select()` upon gaining focus to enable frictionless digit overwrites.\n * - Retroactive Backspace key focus stepping to clear preceding values safely.\n * - Clipboard intercepting paste division across fields with trailing focus positioning.\n * - Hidden form sync and complete/change event dispatching.\n *\n * @remarks\n * Behavior only. The controller binds events dynamically to pre-rendered inputs,\n * updates `data-filled` and hidden value bindings, and emits Stimulus custom events.\n */\nexport class OtpController extends Controller<HTMLElement> {\n static override targets = [\"field\", \"value\", \"error\"];\n static override values = {\n length: { type: Number, default: 6 },\n pattern: { type: String, default: \"[0-9]\" },\n };\n static actions = [\"onInput\", \"onKeydown\", \"onPaste\"] as const;\n static events = [\"change\", \"complete\", \"invalid\"] as const;\n\n declare readonly fieldTargets: HTMLInputElement[];\n declare readonly valueTarget: HTMLInputElement;\n declare readonly errorTarget: HTMLElement;\n declare readonly hasValueTarget: boolean;\n declare readonly hasErrorTarget: boolean;\n\n declare lengthValue: number;\n declare patternValue: string;\n\n #isComposing = new Map<HTMLInputElement, boolean>();\n\n override connect(): void {\n // Dynamically bind auto-selection and IME tracking logic to all connected inputs\n for (const field of this.fieldTargets) {\n field.addEventListener(\"focus\", this.#onFieldFocus);\n field.addEventListener(\"compositionstart\", this.#onCompositionStart);\n field.addEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n }\n\n override disconnect(): void {\n for (const field of this.fieldTargets) {\n field.removeEventListener(\"focus\", this.#onFieldFocus);\n field.removeEventListener(\"compositionstart\", this.#onCompositionStart);\n field.removeEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n this.#isComposing.clear();\n }\n\n /**\n * Stimulus lifecycle callback when a new field target enters the DOM.\n * Ensures new additions are also wired with overwriting support.\n */\n fieldTargetConnected(element: HTMLInputElement): void {\n element.addEventListener(\"focus\", this.#onFieldFocus);\n element.addEventListener(\"compositionstart\", this.#onCompositionStart);\n element.addEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n\n /** Removes focus listeners when fields are dropped. */\n fieldTargetDisconnected(element: HTMLInputElement): void {\n element.removeEventListener(\"focus\", this.#onFieldFocus);\n element.removeEventListener(\"compositionstart\", this.#onCompositionStart);\n element.removeEventListener(\"compositionend\", this.#onCompositionEnd);\n this.#isComposing.delete(element);\n }\n\n /** Handles keystroke inputs and advances focus to the next field. */\n onInput(event: Event): void {\n const input = event.currentTarget as HTMLInputElement | null;\n if (!input) return;\n\n // Guard during active composition to prevent premature focus switching\n if (this.#isComposing.get(input)) return;\n\n this.#handleInputValidation(input);\n }\n\n /** Handles Backspace retreating, arrows, and home/end navigation. */\n onKeydown(event: KeyboardEvent): void {\n const input = event.currentTarget as HTMLInputElement | null;\n if (!input) return;\n\n const index = this.fieldTargets.indexOf(input);\n if (index === -1) return;\n\n // Do not trigger keydown actions during composition\n if (this.#isComposing.get(input)) return;\n\n switch (event.key) {\n case \"Backspace\":\n if (!input.value) {\n // Empty field: step backward, wipe previous digit, and focus it\n if (index > 0) {\n event.preventDefault();\n const prevField = this.fieldTargets[index - 1];\n if (prevField) {\n prevField.value = \"\";\n prevField.removeAttribute(\"data-filled\");\n prevField.focus();\n this.#clearError();\n this.#syncAndDispatch();\n }\n }\n } else {\n // Filled field: clear current value\n input.value = \"\";\n input.removeAttribute(\"data-filled\");\n this.#clearError();\n this.#syncAndDispatch();\n }\n break;\n\n case \"ArrowLeft\":\n if (index > 0) {\n event.preventDefault();\n this.fieldTargets[index - 1]?.focus();\n }\n break;\n\n case \"ArrowRight\":\n if (index < this.lengthValue - 1) {\n event.preventDefault();\n this.fieldTargets[index + 1]?.focus();\n }\n break;\n\n case \"Home\":\n event.preventDefault();\n this.fieldTargets[0]?.focus();\n break;\n\n case \"End\":\n event.preventDefault();\n this.fieldTargets[this.lengthValue - 1]?.focus();\n break;\n\n default:\n break;\n }\n }\n\n /** Divides pasted string characters across available input fields. */\n onPaste(event: ClipboardEvent): void {\n const input = event.currentTarget as HTMLInputElement | null;\n if (!input) return;\n\n const startIndex = this.fieldTargets.indexOf(input);\n if (startIndex === -1) return;\n\n event.preventDefault();\n\n const rawText = event.clipboardData?.getData(\"text\") || \"\";\n const text = this.#normalizeValue(rawText);\n const regex = new RegExp(`^${this.patternValue}$`);\n const validChars = Array.from(text).filter((char) => regex.test(char));\n\n if (validChars.length === 0) {\n this.#showError();\n return;\n }\n\n const limit = Math.min(validChars.length, this.lengthValue - startIndex);\n let lastFocusedIndex = startIndex;\n\n for (let i = 0; i < limit; i++) {\n const fieldIndex = startIndex + i;\n const field = this.fieldTargets[fieldIndex];\n const char = validChars[i];\n\n if (field && char) {\n field.value = char;\n field.setAttribute(\"data-filled\", \"true\");\n lastFocusedIndex = fieldIndex;\n }\n }\n\n this.#clearError();\n\n // Set trailing focus position to the last filled box (or next empty if one exists)\n const focusTargetIndex =\n lastFocusedIndex < this.lengthValue - 1 ? lastFocusedIndex + 1 : lastFocusedIndex;\n this.fieldTargets[focusTargetIndex]?.focus();\n\n this.#syncAndDispatch();\n }\n\n readonly #onFieldFocus = (event: FocusEvent): void => {\n const input = event.currentTarget as HTMLInputElement | null;\n if (input) {\n // Auto-selection enables effortless character overwrites\n input.select();\n }\n };\n\n readonly #onCompositionStart = (event: CompositionEvent): void => {\n const input = event.currentTarget as HTMLInputElement;\n this.#isComposing.set(input, true);\n };\n\n readonly #onCompositionEnd = (event: CompositionEvent): void => {\n const input = event.currentTarget as HTMLInputElement;\n this.#isComposing.set(input, false);\n\n // Force validation after IME conversion finishes\n this.#handleInputValidation(input);\n };\n\n #handleInputValidation(input: HTMLInputElement): void {\n const index = this.fieldTargets.indexOf(input);\n if (index === -1) return;\n\n const rawValue = input.value;\n const normalized = this.#normalizeValue(rawValue);\n const regex = new RegExp(`^${this.patternValue}$`);\n\n if (normalized && regex.test(normalized)) {\n input.value = normalized;\n input.setAttribute(\"data-filled\", \"true\");\n this.#clearError();\n\n // Step focus forward\n if (index < this.lengthValue - 1) {\n const nextField = this.fieldTargets[index + 1];\n nextField?.focus();\n }\n } else if (normalized) {\n input.value = \"\";\n input.removeAttribute(\"data-filled\");\n this.#showError();\n } else {\n input.removeAttribute(\"data-filled\");\n }\n\n this.#syncAndDispatch();\n }\n\n #normalizeValue(val: string): string {\n // Replaces full-width numbers (0-9) with standard half-width ones (0-9)\n return val.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xfee0));\n }\n\n #showError(): void {\n if (this.hasErrorTarget) {\n this.errorTarget.removeAttribute(\"hidden\");\n }\n // Behavior only: emit a neutral payload and let the consumer compose/localize\n // the user-facing message. The pattern is reported so consumers can word it.\n this.dispatch(\"invalid\", { detail: { pattern: this.patternValue } });\n }\n\n #clearError(): void {\n if (this.hasErrorTarget) {\n this.errorTarget.setAttribute(\"hidden\", \"true\");\n }\n }\n\n #syncAndDispatch(): void {\n const fields = this.fieldTargets;\n const combinedValue = fields.map((f) => f.value).join(\"\");\n\n if (this.hasValueTarget) {\n this.valueTarget.value = combinedValue;\n }\n\n this.dispatch(\"change\", { detail: { value: combinedValue } });\n\n // Completed state when all fields contain a digit\n const isCompleted =\n fields.every((f) => f.value.length > 0) && combinedValue.length === this.lengthValue;\n if (isCompleted) {\n this.dispatch(\"complete\", { detail: { value: combinedValue } });\n }\n }\n}\n"]}
@@ -0,0 +1,62 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless **Overflow Indicator**: detects whether a scroll container can still
5
+ * scroll toward its start and/or end and publishes it as `data-overflow-start` /
6
+ * `data-overflow-end`. No APG widget — a state-detection utility. Consumers draw
7
+ * edge shadows or arrows in CSS to signal "more content this way".
8
+ *
9
+ * Markup contract (identifier: `stimeo--overflow-indicator`):
10
+ * <div data-controller="stimeo--overflow-indicator"
11
+ * data-stimeo--overflow-indicator-orientation-value="horizontal">
12
+ * <button type="button" aria-label="Prev"
13
+ * data-stimeo--overflow-indicator-direction-param="start"
14
+ * data-action="click->stimeo--overflow-indicator#scrollByPage">‹</button>
15
+ * <div data-stimeo--overflow-indicator-target="viewport"
16
+ * data-action="scroll->stimeo--overflow-indicator#update"
17
+ * tabindex="0" role="region" aria-label="Products"
18
+ * style="overflow-x: auto;"><!-- items --></div>
19
+ * <button type="button" aria-label="Next"
20
+ * data-stimeo--overflow-indicator-direction-param="end"
21
+ * data-action="click->stimeo--overflow-indicator#scrollByPage">›</button>
22
+ * </div>
23
+ *
24
+ * The viewport's scroll position and size are watched (via the wired `scroll`
25
+ * action, plus {@link LayoutObserver} for resize and a {@link MutationObserver}
26
+ * for content changes). Optional page buttons scroll one viewport at a time and
27
+ * have their `disabled` synced to the matching direction's remaining room.
28
+ *
29
+ * @remarks
30
+ * Behavior only — shadows, arrows, and gradients are the consumer's CSS;
31
+ * `data-overflow-*` carry no ARIA semantics. All observers/listeners are released
32
+ * on `disconnect()` (Turbo navigation included). `scrollByPage` honors
33
+ * `prefers-reduced-motion`.
34
+ */
35
+ declare class OverflowIndicatorController extends Controller<HTMLElement> {
36
+ #private;
37
+ static targets: string[];
38
+ static values: {
39
+ orientation: {
40
+ type: StringConstructor;
41
+ default: string;
42
+ };
43
+ threshold: {
44
+ type: NumberConstructor;
45
+ default: number;
46
+ };
47
+ };
48
+ static actions: readonly ["scrollByPage", "update"];
49
+ static events: readonly ["change"];
50
+ readonly viewportTarget: HTMLElement;
51
+ readonly hasViewportTarget: boolean;
52
+ orientationValue: string;
53
+ thresholdValue: number;
54
+ connect(): void;
55
+ disconnect(): void;
56
+ /** Re-measures remaining scroll room and reflects the state hooks. Public so it can be wired to the viewport's `scroll`. */
57
+ update(): void;
58
+ /** Scrolls one viewport page toward the `direction` param (`start`/`end`). */
59
+ scrollByPage(event: Event): void;
60
+ }
61
+
62
+ export { OverflowIndicatorController };
@@ -0,0 +1,169 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/overflow_indicator_controller.ts
4
+
5
+ // src/utils/layout_observer.ts
6
+ var LayoutObserver = class {
7
+ #callback;
8
+ #resizeObserverFactory;
9
+ #resizeObserver = null;
10
+ #observingViewport = false;
11
+ /** Stable bound handler so add/removeEventListener target the same reference. */
12
+ #handleViewportResize = () => {
13
+ this.#callback();
14
+ };
15
+ constructor(callback, options = {}) {
16
+ this.#callback = callback;
17
+ this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
18
+ }
19
+ /**
20
+ * Starts observing an element's size. Repeated calls observe additional
21
+ * elements through the same shared observer. No-ops when no
22
+ * `ResizeObserver` implementation is available.
23
+ */
24
+ observe(element) {
25
+ if (!this.#resizeObserverFactory) return;
26
+ if (!this.#resizeObserver) {
27
+ this.#resizeObserver = this.#resizeObserverFactory(() => {
28
+ this.#callback();
29
+ });
30
+ }
31
+ this.#resizeObserver.observe(element);
32
+ }
33
+ /** Stops observing a single element while leaving any others in place. */
34
+ unobserve(element) {
35
+ this.#resizeObserver?.unobserve(element);
36
+ }
37
+ /** Starts observing viewport resizes. Idempotent: the listener is added once. */
38
+ observeViewport() {
39
+ if (this.#observingViewport) return;
40
+ this.#observingViewport = true;
41
+ window.addEventListener("resize", this.#handleViewportResize);
42
+ }
43
+ /** Stops observing viewport resizes without affecting element observation. */
44
+ unobserveViewport() {
45
+ if (!this.#observingViewport) return;
46
+ this.#observingViewport = false;
47
+ window.removeEventListener("resize", this.#handleViewportResize);
48
+ }
49
+ /**
50
+ * Releases every observation: disconnects the {@link ResizeObserver} and
51
+ * removes the viewport listener. Safe to call multiple times. Call this from a
52
+ * controller's `disconnect()`.
53
+ */
54
+ disconnect() {
55
+ this.#resizeObserver?.disconnect();
56
+ this.#resizeObserver = null;
57
+ this.unobserveViewport();
58
+ }
59
+ };
60
+
61
+ // src/controllers/overflow_indicator_controller.ts
62
+ var OverflowIndicatorController = class extends Controller {
63
+ static targets = ["viewport"];
64
+ static values = {
65
+ orientation: { type: String, default: "horizontal" },
66
+ threshold: { type: Number, default: 1 }
67
+ };
68
+ static actions = ["scrollByPage", "update"];
69
+ static events = ["change"];
70
+ #layout = new LayoutObserver(() => this.update());
71
+ #mutationObserver = null;
72
+ /** Last reported room, so `change` fires only on transitions. */
73
+ #state = null;
74
+ connect() {
75
+ if (!this.hasViewportTarget) return;
76
+ this.#layout.observe(this.viewportTarget);
77
+ this.#layout.observeViewport();
78
+ if (typeof MutationObserver !== "undefined") {
79
+ this.#mutationObserver = new MutationObserver(() => this.update());
80
+ this.#mutationObserver.observe(this.viewportTarget, {
81
+ childList: true,
82
+ subtree: true,
83
+ characterData: true
84
+ });
85
+ }
86
+ this.update();
87
+ }
88
+ disconnect() {
89
+ this.#layout.disconnect();
90
+ this.#mutationObserver?.disconnect();
91
+ this.#mutationObserver = null;
92
+ this.#state = null;
93
+ }
94
+ /** Re-measures remaining scroll room and reflects the state hooks. Public so it can be wired to the viewport's `scroll`. */
95
+ update() {
96
+ if (!this.hasViewportTarget) return;
97
+ const vp = this.viewportTarget;
98
+ const horizontal = this.orientationValue !== "vertical";
99
+ const t = this.thresholdValue;
100
+ const scrollPos = horizontal ? vp.scrollLeft : vp.scrollTop;
101
+ const maxScroll = horizontal ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
102
+ const start = scrollPos > t;
103
+ const end = scrollPos < maxScroll - t;
104
+ vp.setAttribute("data-overflow-start", start ? "true" : "false");
105
+ vp.setAttribute("data-overflow-end", end ? "true" : "false");
106
+ this.#syncButtons(start, end);
107
+ if (!this.#state || this.#state.start !== start || this.#state.end !== end) {
108
+ this.#state = { start, end };
109
+ this.dispatch("change", { detail: { start, end } });
110
+ }
111
+ }
112
+ /** Scrolls one viewport page toward the `direction` param (`start`/`end`). */
113
+ scrollByPage(event) {
114
+ if (!this.hasViewportTarget) return;
115
+ const direction = this.#directionFromEvent(event);
116
+ if (!direction) return;
117
+ const vp = this.viewportTarget;
118
+ const horizontal = this.orientationValue !== "vertical";
119
+ const page = horizontal ? vp.clientWidth : vp.clientHeight;
120
+ const delta = direction === "start" ? -page : page;
121
+ const behavior = this.#prefersReducedMotion() ? "auto" : "smooth";
122
+ if (horizontal) {
123
+ vp.scrollBy({ left: delta, behavior });
124
+ } else {
125
+ vp.scrollBy({ top: delta, behavior });
126
+ }
127
+ }
128
+ /** Mirrors remaining room onto any direction buttons by toggling `disabled`. */
129
+ #syncButtons(start, end) {
130
+ const buttons = this.element.querySelectorAll(
131
+ "[data-stimeo--overflow-indicator-direction-param]"
132
+ );
133
+ for (const button of buttons) {
134
+ const direction = button.getAttribute("data-stimeo--overflow-indicator-direction-param");
135
+ if (direction === "start") this.#toggleButton(button, start);
136
+ else if (direction === "end") this.#toggleButton(button, end);
137
+ }
138
+ }
139
+ /**
140
+ * Reflects the remaining room onto a page button's `disabled`, owning only the
141
+ * `disabled` it sets itself via a marker (`data-overflow-indicator-disabled`,
142
+ * like `number-input`/`conditional-fields`). An author-disabled button (e.g. the
143
+ * whole control disabled) is therefore never blindly re-enabled.
144
+ */
145
+ #toggleButton(button, hasRoom) {
146
+ if (hasRoom) {
147
+ if (button.hasAttribute("data-overflow-indicator-disabled")) {
148
+ button.disabled = false;
149
+ button.removeAttribute("data-overflow-indicator-disabled");
150
+ }
151
+ return;
152
+ }
153
+ if (button.disabled) return;
154
+ button.disabled = true;
155
+ button.setAttribute("data-overflow-indicator-disabled", "");
156
+ }
157
+ #directionFromEvent(event) {
158
+ const params = event.params;
159
+ const direction = params?.direction;
160
+ return direction === "start" || direction === "end" ? direction : null;
161
+ }
162
+ #prefersReducedMotion() {
163
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
164
+ }
165
+ };
166
+
167
+ export { OverflowIndicatorController };
168
+ //# sourceMappingURL=overflow_indicator_controller.js.map
169
+ //# sourceMappingURL=overflow_indicator_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/controllers/overflow_indicator_controller.ts"],"names":[],"mappings":"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","file":"overflow_indicator_controller.js","sourcesContent":["/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Layout-sensitive widgets (sliders, resizable panes, scroll spies, popovers)\n * need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\n\n/**\n * Headless **Overflow Indicator**: detects whether a scroll container can still\n * scroll toward its start and/or end and publishes it as `data-overflow-start` /\n * `data-overflow-end`. No APG widget — a state-detection utility. Consumers draw\n * edge shadows or arrows in CSS to signal \"more content this way\".\n *\n * Markup contract (identifier: `stimeo--overflow-indicator`):\n * <div data-controller=\"stimeo--overflow-indicator\"\n * data-stimeo--overflow-indicator-orientation-value=\"horizontal\">\n * <button type=\"button\" aria-label=\"Prev\"\n * data-stimeo--overflow-indicator-direction-param=\"start\"\n * data-action=\"click->stimeo--overflow-indicator#scrollByPage\">‹</button>\n * <div data-stimeo--overflow-indicator-target=\"viewport\"\n * data-action=\"scroll->stimeo--overflow-indicator#update\"\n * tabindex=\"0\" role=\"region\" aria-label=\"Products\"\n * style=\"overflow-x: auto;\"><!-- items --></div>\n * <button type=\"button\" aria-label=\"Next\"\n * data-stimeo--overflow-indicator-direction-param=\"end\"\n * data-action=\"click->stimeo--overflow-indicator#scrollByPage\">›</button>\n * </div>\n *\n * The viewport's scroll position and size are watched (via the wired `scroll`\n * action, plus {@link LayoutObserver} for resize and a {@link MutationObserver}\n * for content changes). Optional page buttons scroll one viewport at a time and\n * have their `disabled` synced to the matching direction's remaining room.\n *\n * @remarks\n * Behavior only — shadows, arrows, and gradients are the consumer's CSS;\n * `data-overflow-*` carry no ARIA semantics. All observers/listeners are released\n * on `disconnect()` (Turbo navigation included). `scrollByPage` honors\n * `prefers-reduced-motion`.\n */\nexport class OverflowIndicatorController extends Controller<HTMLElement> {\n static override targets = [\"viewport\"];\n static override values = {\n orientation: { type: String, default: \"horizontal\" },\n threshold: { type: Number, default: 1 },\n };\n static actions = [\"scrollByPage\", \"update\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly viewportTarget: HTMLElement;\n declare readonly hasViewportTarget: boolean;\n\n declare orientationValue: string;\n declare thresholdValue: number;\n\n readonly #layout = new LayoutObserver(() => this.update());\n #mutationObserver: MutationObserver | null = null;\n /** Last reported room, so `change` fires only on transitions. */\n #state: { start: boolean; end: boolean } | null = null;\n\n override connect(): void {\n if (!this.hasViewportTarget) return;\n this.#layout.observe(this.viewportTarget);\n this.#layout.observeViewport();\n\n if (typeof MutationObserver !== \"undefined\") {\n this.#mutationObserver = new MutationObserver(() => this.update());\n this.#mutationObserver.observe(this.viewportTarget, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n this.update();\n }\n\n override disconnect(): void {\n this.#layout.disconnect();\n this.#mutationObserver?.disconnect();\n this.#mutationObserver = null;\n this.#state = null;\n }\n\n /** Re-measures remaining scroll room and reflects the state hooks. Public so it can be wired to the viewport's `scroll`. */\n update(): void {\n if (!this.hasViewportTarget) return;\n const vp = this.viewportTarget;\n const horizontal = this.orientationValue !== \"vertical\";\n const t = this.thresholdValue;\n\n const scrollPos = horizontal ? vp.scrollLeft : vp.scrollTop;\n const maxScroll = horizontal\n ? vp.scrollWidth - vp.clientWidth\n : vp.scrollHeight - vp.clientHeight;\n\n const start = scrollPos > t;\n const end = scrollPos < maxScroll - t;\n\n vp.setAttribute(\"data-overflow-start\", start ? \"true\" : \"false\");\n vp.setAttribute(\"data-overflow-end\", end ? \"true\" : \"false\");\n this.#syncButtons(start, end);\n\n if (!this.#state || this.#state.start !== start || this.#state.end !== end) {\n this.#state = { start, end };\n this.dispatch(\"change\", { detail: { start, end } });\n }\n }\n\n /** Scrolls one viewport page toward the `direction` param (`start`/`end`). */\n scrollByPage(event: Event): void {\n if (!this.hasViewportTarget) return;\n const direction = this.#directionFromEvent(event);\n if (!direction) return;\n\n const vp = this.viewportTarget;\n const horizontal = this.orientationValue !== \"vertical\";\n const page = horizontal ? vp.clientWidth : vp.clientHeight;\n const delta = direction === \"start\" ? -page : page;\n const behavior: ScrollBehavior = this.#prefersReducedMotion() ? \"auto\" : \"smooth\";\n\n if (horizontal) {\n vp.scrollBy({ left: delta, behavior });\n } else {\n vp.scrollBy({ top: delta, behavior });\n }\n }\n\n /** Mirrors remaining room onto any direction buttons by toggling `disabled`. */\n #syncButtons(start: boolean, end: boolean): void {\n const buttons = this.element.querySelectorAll<HTMLButtonElement>(\n \"[data-stimeo--overflow-indicator-direction-param]\",\n );\n for (const button of buttons) {\n const direction = button.getAttribute(\"data-stimeo--overflow-indicator-direction-param\");\n if (direction === \"start\") this.#toggleButton(button, start);\n else if (direction === \"end\") this.#toggleButton(button, end);\n }\n }\n\n /**\n * Reflects the remaining room onto a page button's `disabled`, owning only the\n * `disabled` it sets itself via a marker (`data-overflow-indicator-disabled`,\n * like `number-input`/`conditional-fields`). An author-disabled button (e.g. the\n * whole control disabled) is therefore never blindly re-enabled.\n */\n #toggleButton(button: HTMLButtonElement, hasRoom: boolean): void {\n if (hasRoom) {\n if (button.hasAttribute(\"data-overflow-indicator-disabled\")) {\n button.disabled = false;\n button.removeAttribute(\"data-overflow-indicator-disabled\");\n }\n return;\n }\n if (button.disabled) return; // already disabled (possibly by the author) — leave it\n button.disabled = true;\n button.setAttribute(\"data-overflow-indicator-disabled\", \"\");\n }\n\n #directionFromEvent(event: Event): \"start\" | \"end\" | null {\n const params = (event as Event & { params?: { direction?: unknown } }).params;\n const direction = params?.direction;\n return direction === \"start\" || direction === \"end\" ? direction : null;\n }\n\n #prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n }\n}\n"]}
@@ -0,0 +1,64 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless **overflow / priority menu**: items that no longer fit their container are
5
+ * moved into a "More" dropdown (lowest `data-priority` first) and moved back as space
6
+ * returns, watched with a {@link ResizeObserver} (no dedicated APG pattern — a
7
+ * composition; the menu's a11y is delegated to Menu, so it is not re-implemented).
8
+ *
9
+ * Markup contract (identifier: `stimeo--overflow-menu`):
10
+ * <div data-controller="stimeo--overflow-menu" role="toolbar">
11
+ * <div data-stimeo--overflow-menu-target="items">
12
+ * <a data-priority="1">Save</a><a data-priority="2">Duplicate</a><a>Delete</a>
13
+ * </div>
14
+ * <div data-controller="stimeo--menu" data-stimeo--overflow-menu-target="more" hidden>
15
+ * <button data-stimeo--menu-target="trigger">More</button>
16
+ * <ul role="menu" data-stimeo--menu-target="menu" hidden></ul>
17
+ * </div>
18
+ * </div>
19
+ *
20
+ * On connect (and every debounced resize) it measures the items against the container
21
+ * width and, when they overflow, banks the lowest-priority ones into the More menu —
22
+ * giving each `role="menuitem"` / `tabindex="-1"` and the menu's `item` target so Menu
23
+ * drives it — until the rest fit beside the More button. When nothing overflows the
24
+ * More button is `hidden`. The controller element carries `data-overflowing` /
25
+ * `data-overflow-count`, and a `change` event fires on each transition.
26
+ *
27
+ * @remarks
28
+ * Items are *moved* (not cloned). When a focused item retreats into the (collapsed)
29
+ * menu it would otherwise be hidden and lose focus, so focus is redirected to the More
30
+ * trigger. Priority: lower `data-priority` is kept longer; items without one drop first.
31
+ * Behavior only — no styling. The `ResizeObserver` and debounce timer are released on
32
+ * `disconnect()` (Turbo navigation included); state is derived from the DOM each pass
33
+ * (no module-scope state), so `connect()` re-syncs after a Turbo morph. Captures the
34
+ * managed item set at connect; call the `update` action to re-measure after adding
35
+ * items dynamically.
36
+ */
37
+ declare class OverflowMenuController extends Controller<HTMLElement> {
38
+ #private;
39
+ static targets: string[];
40
+ static values: {
41
+ moreLabel: {
42
+ type: StringConstructor;
43
+ default: string;
44
+ };
45
+ debounce: {
46
+ type: NumberConstructor;
47
+ default: number;
48
+ };
49
+ };
50
+ static actions: readonly ["update"];
51
+ static events: readonly ["change"];
52
+ readonly itemsTarget: HTMLElement;
53
+ readonly moreTarget: HTMLElement;
54
+ readonly hasItemsTarget: boolean;
55
+ readonly hasMoreTarget: boolean;
56
+ moreLabelValue: string;
57
+ debounceValue: number;
58
+ connect(): void;
59
+ disconnect(): void;
60
+ /** Re-measures and rebalances items between the bar and the More menu. */
61
+ update(): void;
62
+ }
63
+
64
+ export { OverflowMenuController };