stimeo-ui 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/dist/controllers/alert_dialog_controller.d.ts +2 -0
  3. package/dist/controllers/alert_dialog_controller.js +75 -4
  4. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  5. package/dist/controllers/auto_submit_controller.d.ts +16 -3
  6. package/dist/controllers/auto_submit_controller.js +94 -16
  7. package/dist/controllers/auto_submit_controller.js.map +1 -1
  8. package/dist/controllers/avatar_controller.js +47 -5
  9. package/dist/controllers/avatar_controller.js.map +1 -1
  10. package/dist/controllers/breadcrumb_controller.d.ts +2 -0
  11. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  12. package/dist/controllers/bulk_select_controller.d.ts +2 -0
  13. package/dist/controllers/bulk_select_controller.js.map +1 -1
  14. package/dist/controllers/calendar_controller.d.ts +2 -0
  15. package/dist/controllers/calendar_controller.js.map +1 -1
  16. package/dist/controllers/carousel_controller.d.ts +81 -47
  17. package/dist/controllers/carousel_controller.js +451 -100
  18. package/dist/controllers/carousel_controller.js.map +1 -1
  19. package/dist/controllers/character_counter_controller.d.ts +52 -20
  20. package/dist/controllers/character_counter_controller.js +338 -63
  21. package/dist/controllers/character_counter_controller.js.map +1 -1
  22. package/dist/controllers/checkbox_controller.d.ts +3 -0
  23. package/dist/controllers/checkbox_controller.js.map +1 -1
  24. package/dist/controllers/color_picker_controller.d.ts +3 -0
  25. package/dist/controllers/color_picker_controller.js.map +1 -1
  26. package/dist/controllers/combobox_controller.d.ts +2 -0
  27. package/dist/controllers/combobox_controller.js.map +1 -1
  28. package/dist/controllers/command_palette_controller.d.ts +2 -0
  29. package/dist/controllers/command_palette_controller.js +75 -4
  30. package/dist/controllers/command_palette_controller.js.map +1 -1
  31. package/dist/controllers/conditional_fields_controller.d.ts +42 -14
  32. package/dist/controllers/conditional_fields_controller.js +345 -51
  33. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  34. package/dist/controllers/confirm_controller.d.ts +2 -0
  35. package/dist/controllers/confirm_controller.js +75 -4
  36. package/dist/controllers/confirm_controller.js.map +1 -1
  37. package/dist/controllers/count_up_controller.d.ts +2 -0
  38. package/dist/controllers/count_up_controller.js.map +1 -1
  39. package/dist/controllers/countdown_controller.d.ts +4 -0
  40. package/dist/controllers/countdown_controller.js.map +1 -1
  41. package/dist/controllers/currency_input_controller.d.ts +40 -4
  42. package/dist/controllers/currency_input_controller.js +305 -74
  43. package/dist/controllers/currency_input_controller.js.map +1 -1
  44. package/dist/controllers/data_grid_controller.d.ts +3 -0
  45. package/dist/controllers/data_grid_controller.js.map +1 -1
  46. package/dist/controllers/dialog_controller.js +75 -4
  47. package/dist/controllers/dialog_controller.js.map +1 -1
  48. package/dist/controllers/direct_upload_controller.d.ts +59 -25
  49. package/dist/controllers/direct_upload_controller.js +212 -46
  50. package/dist/controllers/direct_upload_controller.js.map +1 -1
  51. package/dist/controllers/dirty_form_controller.d.ts +14 -6
  52. package/dist/controllers/dirty_form_controller.js +192 -29
  53. package/dist/controllers/dirty_form_controller.js.map +1 -1
  54. package/dist/controllers/dismissible_controller.d.ts +2 -0
  55. package/dist/controllers/dismissible_controller.js +83 -18
  56. package/dist/controllers/dismissible_controller.js.map +1 -1
  57. package/dist/controllers/drawer_controller.js +75 -4
  58. package/dist/controllers/drawer_controller.js.map +1 -1
  59. package/dist/controllers/empty_state_controller.d.ts +2 -0
  60. package/dist/controllers/empty_state_controller.js.map +1 -1
  61. package/dist/controllers/file_dropzone_controller.d.ts +123 -27
  62. package/dist/controllers/file_dropzone_controller.js +386 -63
  63. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  64. package/dist/controllers/filter_controller.d.ts +2 -0
  65. package/dist/controllers/filter_controller.js.map +1 -1
  66. package/dist/controllers/flash_controller.d.ts +7 -1
  67. package/dist/controllers/flash_controller.js +3 -1
  68. package/dist/controllers/flash_controller.js.map +1 -1
  69. package/dist/controllers/focus_controller.d.ts +2 -0
  70. package/dist/controllers/focus_controller.js +75 -4
  71. package/dist/controllers/focus_controller.js.map +1 -1
  72. package/dist/controllers/form_field_controller.d.ts +50 -10
  73. package/dist/controllers/form_field_controller.js +280 -62
  74. package/dist/controllers/form_field_controller.js.map +1 -1
  75. package/dist/controllers/form_validation_controller.d.ts +10 -8
  76. package/dist/controllers/form_validation_controller.js +208 -83
  77. package/dist/controllers/form_validation_controller.js.map +1 -1
  78. package/dist/controllers/frame_loading_controller.d.ts +5 -1
  79. package/dist/controllers/frame_loading_controller.js +2 -1
  80. package/dist/controllers/frame_loading_controller.js.map +1 -1
  81. package/dist/controllers/highlight_controller.d.ts +2 -0
  82. package/dist/controllers/highlight_controller.js.map +1 -1
  83. package/dist/controllers/idle_controller.d.ts +2 -0
  84. package/dist/controllers/idle_controller.js.map +1 -1
  85. package/dist/controllers/input_mask_controller.d.ts +43 -15
  86. package/dist/controllers/input_mask_controller.js +251 -76
  87. package/dist/controllers/input_mask_controller.js.map +1 -1
  88. package/dist/controllers/lazy_frame_controller.d.ts +2 -0
  89. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  90. package/dist/controllers/listbox_controller.d.ts +2 -0
  91. package/dist/controllers/listbox_controller.js.map +1 -1
  92. package/dist/controllers/local_time_controller.d.ts +2 -0
  93. package/dist/controllers/local_time_controller.js.map +1 -1
  94. package/dist/controllers/masonry_controller.d.ts +2 -0
  95. package/dist/controllers/masonry_controller.js.map +1 -1
  96. package/dist/controllers/meter_controller.d.ts +2 -0
  97. package/dist/controllers/meter_controller.js.map +1 -1
  98. package/dist/controllers/multi_select_controller.d.ts +2 -0
  99. package/dist/controllers/multi_select_controller.js.map +1 -1
  100. package/dist/controllers/nested_form_controller.d.ts +52 -13
  101. package/dist/controllers/nested_form_controller.js +450 -42
  102. package/dist/controllers/nested_form_controller.js.map +1 -1
  103. package/dist/controllers/network_status_controller.d.ts +2 -0
  104. package/dist/controllers/network_status_controller.js.map +1 -1
  105. package/dist/controllers/number_input_controller.d.ts +2 -0
  106. package/dist/controllers/number_input_controller.js +47 -5
  107. package/dist/controllers/number_input_controller.js.map +1 -1
  108. package/dist/controllers/otp_controller.d.ts +62 -23
  109. package/dist/controllers/otp_controller.js +472 -114
  110. package/dist/controllers/otp_controller.js.map +1 -1
  111. package/dist/controllers/overflow_indicator_controller.d.ts +2 -0
  112. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  113. package/dist/controllers/overflow_menu_controller.d.ts +2 -0
  114. package/dist/controllers/overflow_menu_controller.js +2 -1
  115. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  116. package/dist/controllers/pagination_controller.d.ts +2 -0
  117. package/dist/controllers/pagination_controller.js +2 -1
  118. package/dist/controllers/pagination_controller.js.map +1 -1
  119. package/dist/controllers/password_reveal_controller.d.ts +2 -0
  120. package/dist/controllers/password_reveal_controller.js.map +1 -1
  121. package/dist/controllers/password_strength_controller.d.ts +2 -0
  122. package/dist/controllers/password_strength_controller.js.map +1 -1
  123. package/dist/controllers/persist_controller.d.ts +30 -16
  124. package/dist/controllers/persist_controller.js +432 -122
  125. package/dist/controllers/persist_controller.js.map +1 -1
  126. package/dist/controllers/pointer_drag_controller.d.ts +3 -0
  127. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  128. package/dist/controllers/popover_controller.js +77 -4
  129. package/dist/controllers/popover_controller.js.map +1 -1
  130. package/dist/controllers/portal_controller.d.ts +2 -0
  131. package/dist/controllers/portal_controller.js.map +1 -1
  132. package/dist/controllers/preview_guard_controller.d.ts +2 -0
  133. package/dist/controllers/preview_guard_controller.js.map +1 -1
  134. package/dist/controllers/progress_controller.d.ts +3 -0
  135. package/dist/controllers/progress_controller.js.map +1 -1
  136. package/dist/controllers/radio_group_controller.d.ts +3 -0
  137. package/dist/controllers/radio_group_controller.js.map +1 -1
  138. package/dist/controllers/rating_controller.d.ts +2 -0
  139. package/dist/controllers/rating_controller.js +9 -5
  140. package/dist/controllers/rating_controller.js.map +1 -1
  141. package/dist/controllers/reading_progress_controller.d.ts +2 -0
  142. package/dist/controllers/reading_progress_controller.js.map +1 -1
  143. package/dist/controllers/resizable_controller.d.ts +2 -0
  144. package/dist/controllers/resizable_controller.js.map +1 -1
  145. package/dist/controllers/roving_controller.d.ts +2 -0
  146. package/dist/controllers/roving_controller.js.map +1 -1
  147. package/dist/controllers/scroll_area_controller.d.ts +23 -15
  148. package/dist/controllers/scroll_area_controller.js +462 -162
  149. package/dist/controllers/scroll_area_controller.js.map +1 -1
  150. package/dist/controllers/scroll_visibility_controller.d.ts +2 -0
  151. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  152. package/dist/controllers/scrollspy_controller.d.ts +2 -0
  153. package/dist/controllers/scrollspy_controller.js.map +1 -1
  154. package/dist/controllers/separator_controller.d.ts +54 -11
  155. package/dist/controllers/separator_controller.js +354 -38
  156. package/dist/controllers/separator_controller.js.map +1 -1
  157. package/dist/controllers/sidebar_controller.js +83 -10
  158. package/dist/controllers/sidebar_controller.js.map +1 -1
  159. package/dist/controllers/skeleton_controller.d.ts +2 -0
  160. package/dist/controllers/skeleton_controller.js.map +1 -1
  161. package/dist/controllers/slider_controller.d.ts +2 -0
  162. package/dist/controllers/slider_controller.js.map +1 -1
  163. package/dist/controllers/smart_sticky_header_controller.d.ts +2 -0
  164. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  165. package/dist/controllers/spinner_controller.d.ts +4 -1
  166. package/dist/controllers/spinner_controller.js +7 -5
  167. package/dist/controllers/spinner_controller.js.map +1 -1
  168. package/dist/controllers/step_indicator_controller.d.ts +2 -0
  169. package/dist/controllers/step_indicator_controller.js.map +1 -1
  170. package/dist/controllers/stepper_controller.d.ts +2 -0
  171. package/dist/controllers/stepper_controller.js.map +1 -1
  172. package/dist/controllers/stick_to_bottom_controller.d.ts +2 -0
  173. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  174. package/dist/controllers/sticky_observer_controller.d.ts +2 -0
  175. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  176. package/dist/controllers/submit_once_controller.d.ts +98 -39
  177. package/dist/controllers/submit_once_controller.js +402 -122
  178. package/dist/controllers/submit_once_controller.js.map +1 -1
  179. package/dist/controllers/switch_controller.d.ts +2 -0
  180. package/dist/controllers/switch_controller.js.map +1 -1
  181. package/dist/controllers/tags_input_controller.d.ts +2 -0
  182. package/dist/controllers/tags_input_controller.js.map +1 -1
  183. package/dist/controllers/textarea_autosize_controller.d.ts +31 -9
  184. package/dist/controllers/textarea_autosize_controller.js +131 -3
  185. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  186. package/dist/controllers/theme_controller.d.ts +2 -0
  187. package/dist/controllers/theme_controller.js +8 -6
  188. package/dist/controllers/theme_controller.js.map +1 -1
  189. package/dist/controllers/time_picker_controller.d.ts +2 -0
  190. package/dist/controllers/time_picker_controller.js.map +1 -1
  191. package/dist/controllers/toast_controller.d.ts +4 -0
  192. package/dist/controllers/toast_controller.js.map +1 -1
  193. package/dist/controllers/toggle_group_controller.d.ts +2 -0
  194. package/dist/controllers/toggle_group_controller.js.map +1 -1
  195. package/dist/controllers/toolbar_controller.js.map +1 -1
  196. package/dist/controllers/transition_controller.d.ts +2 -0
  197. package/dist/controllers/transition_controller.js.map +1 -1
  198. package/dist/controllers/tree_view_controller.d.ts +2 -0
  199. package/dist/controllers/tree_view_controller.js +2 -1
  200. package/dist/controllers/tree_view_controller.js.map +1 -1
  201. package/dist/index.js +4293 -1447
  202. package/dist/index.js.map +1 -1
  203. package/dist/inspector/cli.d.ts +115 -4
  204. package/dist/inspector/cli.js +195 -15
  205. package/dist/inspector/cli.js.map +1 -1
  206. package/dist/inspector/cli_bin.js +257 -47
  207. package/dist/inspector/cli_bin.js.map +1 -1
  208. package/dist/inspector/examples.json +11 -11
  209. package/dist/inspector/manifest.json +615 -66
  210. package/dist/positioning/index.d.ts +2 -0
  211. package/dist/positioning/index.js.map +1 -1
  212. package/package.json +2 -2
@@ -2,10 +2,9 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  /**
4
4
  * Headless **nested / dynamic fields** for Rails `fields_for` +
5
- * `accepts_nested_attributes_for` (no dedicated APG pattern — form editing). The
6
- * Headless successor to the cocoon / nested_form gems: clone a `<template>` row,
7
- * renumber its index, and remove rows by flagging `_destroy` (persisted) or
8
- * dropping them from the DOM (unsaved).
5
+ * `accepts_nested_attributes_for` (no dedicated APG pattern — form editing).
6
+ * Clone a `<template>` row, renumber its index, and remove rows by flagging
7
+ * `_destroy` (persisted) or dropping them from the DOM (unsaved).
9
8
  *
10
9
  * Markup contract (identifier: `stimeo--nested-form`):
11
10
  * <div data-controller="stimeo--nested-form" data-stimeo--nested-form-min-value="1">
@@ -22,16 +21,43 @@ import { Controller } from '@hotwired/stimulus';
22
21
  * data-action="click->stimeo--nested-form#add">Add</button>
23
22
  * </div>
24
23
  *
24
+ * Values: `min` / `max` bound the effective row count (`max` `0` = unlimited;
25
+ * both are followed at runtime), `indexPlaceholder` is the template token
26
+ * replaced per row (default `__INDEX__`), and `announce` + `countMessage` (a
27
+ * `{count}` template) opt into the announcer bridge.
28
+ *
29
+ * `add` dispatches `{ index, element }`; `remove` dispatches `{ element, persisted }`;
30
+ * `reconcile` dispatches `{ count, atMin, atMax }` when a change the controller did
31
+ * not perform itself — rows appended or removed by Turbo Streams / a morph, or a
32
+ * runtime `min` / `max` change — moves the published state.
33
+ *
25
34
  * @remarks
26
35
  * Behavior only — server-side `accepts_nested_attributes_for`, per-field
27
36
  * validation, and reordering are out of scope. Row state lives **only** in the DOM
28
37
  * (inserted nodes + each `_destroy` hidden input); there is no module-scope index
29
- * counter, so the controller stays idempotent across Turbo swaps. Remove buttons
30
- * are handled by **delegation** on the container, so dynamically-added rows work
31
- * without per-row `data-action`. Adding a row moves focus to its first control and
32
- * removing returns focus to a neighbor (WCAG 2.2 2.4.3); count changes are announced
33
- * through the shared `stimeo--announcer` (WCAG 2.2 4.1.3) when `announce` +
34
- * `countMessage` are set. The delegated listener is removed on `disconnect()`.
38
+ * counter, so the controller stays idempotent across Turbo swaps. A row counts as
39
+ * destroyed when its own `_destroy` flag holds a truthy value — `hidden` is the
40
+ * visual half the controller writes alongside the flag, so a consumer hiding rows
41
+ * for other reasons does not affect the count. Remove buttons and destroy flags
42
+ * are resolved by **delegation scoped to their nearest nested-form root**, so
43
+ * dynamically-added rows work without per-row `data-action` and one instance
44
+ * nested inside another never acts on the inner instance's buttons or flags.
45
+ * External row changes are observed on the list and reconciled once per mutation
46
+ * batch. Adding a row moves focus to its first tab stop; removing returns focus to
47
+ * the nearest surviving row's first tab stop, falling back to the add button and
48
+ * finally to the root via a temporary `tabindex` (WCAG 2.2 2.4.3) — candidates
49
+ * that cannot take focus (natively `disabled`, inside `fieldset[disabled]`, or not
50
+ * rendered) are skipped. Count changes from the controller's own add / remove are
51
+ * announced through the shared `stimeo--announcer` (WCAG 2.2 4.1.3) when
52
+ * `announce` + `countMessage` are set; reconciliation stays silent to assistive
53
+ * tech. The add button's `disabled` is managed only while `max` is set, and the
54
+ * authored value is restored on teardown. A template must produce exactly one
55
+ * root element; markup lacking the required `list` / `template` targets, or a
56
+ * template producing anything else, is named on the console once per connection
57
+ * and every operation stays a safe no-op with nothing left in the list. Clicking
58
+ * remove on a row whose flag is already truthy only completes its hiding —
59
+ * nothing effective changes, so no event and no announcement. The delegated
60
+ * listener, the observer, and every lease are released on `disconnect()`.
35
61
  */
36
62
  declare class NestedFormController extends Controller<HTMLElement> {
37
63
  #private;
@@ -59,7 +85,7 @@ declare class NestedFormController extends Controller<HTMLElement> {
59
85
  };
60
86
  };
61
87
  static actions: readonly ["add"];
62
- static events: readonly ["add", "remove"];
88
+ static events: readonly ["add", "remove", "reconcile"];
63
89
  readonly listTarget: HTMLElement;
64
90
  readonly templateTarget: HTMLTemplateElement;
65
91
  readonly addTarget: HTMLButtonElement;
@@ -73,10 +99,23 @@ declare class NestedFormController extends Controller<HTMLElement> {
73
99
  countMessageValue: string;
74
100
  connect(): void;
75
101
  disconnect(): void;
102
+ /** Follows an arriving or swapped-in list: rebind to the primary, then reconcile. */
103
+ listTargetConnected(): void;
104
+ /** Follows a departing list the same way — the primary may have changed. */
105
+ listTargetDisconnected(): void;
106
+ /** Returns the lease with a departing add button; a new one re-arms on refresh. */
107
+ addTargetDisconnected(target: HTMLButtonElement): void;
108
+ addTargetConnected(): void;
109
+ /** Re-clamps when application code or a Turbo morph changes `min`. */
110
+ minValueChanged(): void;
111
+ /** Re-clamps when application code or a Turbo morph changes `max`. */
112
+ maxValueChanged(): void;
76
113
  /**
77
114
  * Clones the template row, replaces the index placeholder with a unique value,
78
- * appends it, focuses its first control, and announces the new count. No-ops at
79
- * `max`.
115
+ * appends it, focuses its first tab stop, and announces the new count. No-ops at
116
+ * `max`, when the required targets are missing (named on the console once per
117
+ * connection), or when the template does not produce exactly one root element
118
+ * (also named once; the insertion is rolled back so nothing accumulates).
80
119
  */
81
120
  add(): void;
82
121
  }
@@ -1,6 +1,238 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/nested_form_controller.ts
4
+
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
22
+ // src/utils/attribute_lease.ts
23
+ var AttributeLease = class {
24
+ #attribute;
25
+ #records = /* @__PURE__ */ new Map();
26
+ /** @param attribute - The attribute whose temporary values this lease owns. */
27
+ constructor(attribute) {
28
+ this.#attribute = attribute;
29
+ }
30
+ /** Writes or removes the leased attribute while preserving its authored value. */
31
+ write(element, value) {
32
+ const existing = this.#records.get(element);
33
+ if (existing) {
34
+ existing.written = value;
35
+ } else {
36
+ this.#records.set(element, {
37
+ original: element.getAttribute(this.#attribute),
38
+ written: value
39
+ });
40
+ }
41
+ this.#reflect(element, value);
42
+ }
43
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
44
+ return(element) {
45
+ const record = this.#records.get(element);
46
+ if (!record) return;
47
+ this.#records.delete(element);
48
+ const stillOwned = element.getAttribute(this.#attribute) === record.written;
49
+ if (stillOwned) this.#reflect(element, record.original);
50
+ }
51
+ /** Reflects only a real value transition, avoiding self-triggered mutation work. */
52
+ #reflect(element, value) {
53
+ if (element.getAttribute(this.#attribute) === value) return;
54
+ if (value === null) element.removeAttribute(this.#attribute);
55
+ else element.setAttribute(this.#attribute, value);
56
+ }
57
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
58
+ returnAll() {
59
+ for (const element of Array.from(this.#records.keys())) this.return(element);
60
+ }
61
+ };
62
+
63
+ // src/utils/before_cache_reset.ts
64
+ var BeforeCacheReset = class _BeforeCacheReset {
65
+ /** Every subscribed instance, iterated by the one shared document listener. */
66
+ static #subscribers = /* @__PURE__ */ new Set();
67
+ /** The shared listener; installed while at least one instance is subscribed. */
68
+ static #onBeforeCache = () => {
69
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
70
+ };
71
+ #rewind;
72
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
73
+ constructor(rewind) {
74
+ this.#rewind = rewind;
75
+ }
76
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
77
+ activate() {
78
+ const first = _BeforeCacheReset.#subscribers.size === 0;
79
+ _BeforeCacheReset.#subscribers.add(this);
80
+ if (first) {
81
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
82
+ }
83
+ }
84
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
85
+ deactivate() {
86
+ _BeforeCacheReset.#subscribers.delete(this);
87
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
88
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
89
+ }
90
+ };
91
+
92
+ // src/utils/focus_candidate.ts
93
+ function inheritsFieldsetDisabled(control) {
94
+ let fieldset = control.closest("fieldset[disabled]");
95
+ while (fieldset) {
96
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
97
+ if (!legend?.contains(control)) return true;
98
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
99
+ }
100
+ return false;
101
+ }
102
+ function canTakeFocus(element) {
103
+ if (element.closest("[hidden], [inert]")) return false;
104
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
105
+ if (!("disabled" in element)) return true;
106
+ if (element.disabled) return false;
107
+ return !inheritsFieldsetDisabled(element);
108
+ }
109
+ var TAB_STOP_CANDIDATE_SELECTOR = [
110
+ "a[href]",
111
+ "area[href]",
112
+ "button",
113
+ "input",
114
+ "select",
115
+ "textarea",
116
+ "summary",
117
+ "iframe",
118
+ "audio[controls]",
119
+ "video[controls]",
120
+ "[tabindex]",
121
+ "[contenteditable]"
122
+ ].join(",");
123
+ function isRenderedForFocus(element) {
124
+ const check = element.checkVisibility;
125
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
126
+ }
127
+ function authoredTabindex(element) {
128
+ const value = element.getAttribute("tabindex");
129
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
130
+ return Number(value);
131
+ }
132
+ function hasNativeTabStop(element) {
133
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
134
+ return element.hasAttribute("href");
135
+ }
136
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
137
+ return true;
138
+ }
139
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
140
+ if (element instanceof HTMLIFrameElement) return true;
141
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
142
+ return element.hasAttribute("controls");
143
+ }
144
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
145
+ const details = element.parentElement;
146
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
147
+ }
148
+ return false;
149
+ }
150
+ function hasEditableTabStop(element) {
151
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
152
+ return value === "" || value === "true" || value === "plaintext-only";
153
+ }
154
+ function isTabStop(element) {
155
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
156
+ const tabindex = authoredTabindex(element);
157
+ if (tabindex !== null) return tabindex >= 0;
158
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
159
+ }
160
+ function firstTabStop(root) {
161
+ for (const candidate of root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)) {
162
+ if (isTabStop(candidate)) return candidate;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ // src/utils/microtask_coalescer.ts
168
+ var MicrotaskCoalescer = class {
169
+ #run;
170
+ #queued = false;
171
+ #active = false;
172
+ #generation = 0;
173
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
174
+ constructor(run) {
175
+ this.#run = run;
176
+ }
177
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
178
+ activate() {
179
+ this.#active = true;
180
+ }
181
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
182
+ cancel() {
183
+ this.#active = false;
184
+ this.#queued = false;
185
+ this.#generation += 1;
186
+ }
187
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
188
+ schedule() {
189
+ if (!this.#active || this.#queued) return;
190
+ this.#queued = true;
191
+ const generation = this.#generation;
192
+ queueMicrotask(() => {
193
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
194
+ this.#queued = false;
195
+ this.#run();
196
+ });
197
+ }
198
+ };
199
+
200
+ // src/utils/tabindex_loan.ts
201
+ var TabindexLoan = class {
202
+ #value;
203
+ #lent = /* @__PURE__ */ new Set();
204
+ /** Returns live loans before Turbo can copy them into its page snapshot. */
205
+ #beforeCache = new BeforeCacheReset(() => this.returnAll());
206
+ /**
207
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
208
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
209
+ * which a scroll region with no focusable content of its own needs.
210
+ */
211
+ constructor(value = "-1") {
212
+ this.#value = value;
213
+ }
214
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
215
+ lend(element) {
216
+ if (element.hasAttribute("tabindex")) return;
217
+ element.setAttribute("tabindex", this.#value);
218
+ this.#lent.add(element);
219
+ this.#beforeCache.activate();
220
+ }
221
+ /** Takes back every loan whose value is still the one that was lent. */
222
+ returnAll() {
223
+ for (const element of this.#lent) {
224
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
225
+ }
226
+ this.#lent.clear();
227
+ this.#beforeCache.deactivate();
228
+ }
229
+ };
230
+
231
+ // src/controllers/nested_form_controller.ts
232
+ var ROOT_SELECTOR = '[data-controller~="stimeo--nested-form"]';
233
+ var REMOVE_SELECTOR = '[data-stimeo--nested-form-target="remove"]';
234
+ var DESTROY_FLAG_SELECTOR = '[data-stimeo--nested-form-target="destroyFlag"]';
235
+ var DESTROYED_VALUES = /* @__PURE__ */ new Set(["1", "true"]);
4
236
  var NestedFormController = class extends Controller {
5
237
  static targets = ["list", "template", "add", "remove", "destroyFlag"];
6
238
  static values = {
@@ -11,92 +243,251 @@ var NestedFormController = class extends Controller {
11
243
  countMessage: { type: String, default: "" }
12
244
  };
13
245
  static actions = ["add"];
14
- static events = ["add", "remove"];
246
+ static events = ["add", "remove", "reconcile"];
15
247
  /** Monotonic source for unique row indices; never a row-state counter. */
16
248
  #lastIndex = 0;
17
- /** Delegated click handler for the per-row remove buttons (dynamic-safe). */
249
+ #warnedMissing = false;
250
+ #warnedTemplate = false;
251
+ /** The state last written to the hooks; reconciliation reports only real moves. */
252
+ #published = null;
253
+ /** Watches the list for row changes the controller did not perform itself. */
254
+ #observer = null;
255
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());
256
+ /** Restores the authored add-button `disabled` when a lease ends. */
257
+ #addDisabled = new AttributeLease("disabled");
258
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
259
+ /** Makes the root a programmatic focus destination when no other candidate survives. */
260
+ #tabindex = new TabindexLoan();
261
+ /**
262
+ * Delegated click handler for the per-row remove buttons (dynamic-safe). Only
263
+ * buttons whose nearest nested-form root is this instance are acted on, so a
264
+ * nested inner form's buttons never remove an outer row.
265
+ */
18
266
  #onClick = (event) => {
19
267
  const target = event.target;
20
- const button = target?.closest('[data-stimeo--nested-form-target="remove"]');
21
- if (!button || !this.element.contains(button)) return;
268
+ const button = target?.closest(REMOVE_SELECTOR);
269
+ if (!button || this.#ownerOf(button) !== this.element) return;
22
270
  const row = this.#rowContaining(button);
23
271
  if (row) this.#removeRow(row);
24
272
  };
25
273
  connect() {
274
+ this.#warnedMissing = false;
275
+ this.#warnedTemplate = false;
26
276
  this.element.addEventListener("click", this.#onClick);
277
+ this.#reconcile.activate();
278
+ this.#beforeCache.activate();
279
+ if (!this.hasListTarget || !this.hasTemplateTarget) this.#warnMissing();
27
280
  this.#refresh();
28
281
  }
29
282
  disconnect() {
30
283
  this.element.removeEventListener("click", this.#onClick);
284
+ this.#observer?.disconnect();
285
+ this.#observer = null;
286
+ this.#reconcile.cancel();
287
+ this.#beforeCache.deactivate();
288
+ this.#rewindForCache();
289
+ this.#tabindex.returnAll();
290
+ this.#published = null;
291
+ }
292
+ /** Follows an arriving or swapped-in list: rebind to the primary, then reconcile. */
293
+ listTargetConnected() {
294
+ this.#rebindObserver();
295
+ this.#reconcile.schedule();
296
+ }
297
+ /** Follows a departing list the same way — the primary may have changed. */
298
+ listTargetDisconnected() {
299
+ this.#rebindObserver();
300
+ this.#reconcile.schedule();
301
+ }
302
+ /**
303
+ * Points the observer at the current primary list. Re-deriving on every list
304
+ * arrival and departure makes the binding independent of the order Stimulus
305
+ * reports an overlapping swap in — a staggered swap (successor appended before
306
+ * the old list leaves) ends observed and reconciled either way.
307
+ */
308
+ #rebindObserver() {
309
+ this.#observer?.disconnect();
310
+ this.#observer = null;
311
+ if (!this.hasListTarget) return;
312
+ this.#observer = new MutationObserver(() => this.#reconcile.schedule());
313
+ this.#observer.observe(this.listTarget, {
314
+ childList: true,
315
+ subtree: true,
316
+ attributes: true,
317
+ attributeFilter: ["value"]
318
+ });
319
+ }
320
+ /** Returns the lease with a departing add button; a new one re-arms on refresh. */
321
+ addTargetDisconnected(target) {
322
+ this.#addDisabled.return(target);
323
+ this.#reconcile.schedule();
324
+ }
325
+ addTargetConnected() {
326
+ this.#reconcile.schedule();
327
+ }
328
+ /** Re-clamps when application code or a Turbo morph changes `min`. */
329
+ minValueChanged() {
330
+ this.#reconcile.schedule();
331
+ }
332
+ /** Re-clamps when application code or a Turbo morph changes `max`. */
333
+ maxValueChanged() {
334
+ this.#reconcile.schedule();
31
335
  }
32
336
  /**
33
337
  * Clones the template row, replaces the index placeholder with a unique value,
34
- * appends it, focuses its first control, and announces the new count. No-ops at
35
- * `max`.
338
+ * appends it, focuses its first tab stop, and announces the new count. No-ops at
339
+ * `max`, when the required targets are missing (named on the console once per
340
+ * connection), or when the template does not produce exactly one root element
341
+ * (also named once; the insertion is rolled back so nothing accumulates).
36
342
  */
37
343
  add() {
38
- if (!this.hasTemplateTarget || !this.hasListTarget || this.#atMax) return;
344
+ if (!this.hasListTarget || !this.hasTemplateTarget) {
345
+ this.#warnMissing();
346
+ return;
347
+ }
348
+ if (this.#atMax) return;
39
349
  const index = this.#nextIndex();
40
350
  const markup = this.templateTarget.innerHTML.replaceAll(
41
351
  this.indexPlaceholderValue,
42
352
  String(index)
43
353
  );
44
- this.listTarget.insertAdjacentHTML("beforeend", markup);
45
- const row = this.listTarget.lastElementChild;
46
- if (!row) return;
354
+ const list = this.listTarget;
355
+ const beforeNodes = list.childNodes.length;
356
+ const beforeElements = list.childElementCount;
357
+ list.insertAdjacentHTML("beforeend", markup);
358
+ const added = Array.from(list.children).slice(beforeElements);
359
+ if (added.length !== 1) {
360
+ while (list.childNodes.length > beforeNodes) list.lastChild?.remove();
361
+ this.#warnBadTemplate(added.length);
362
+ return;
363
+ }
364
+ const row = added[0];
47
365
  this.#refresh();
48
- this.#firstControl(row)?.focus();
366
+ firstTabStop(row)?.focus();
49
367
  this.dispatch("add", { detail: { index, element: row } });
50
368
  this.#announce();
51
369
  }
52
370
  /**
53
- * Removes a row: a persisted row (one carrying a `destroyFlag`) has its flag set
54
- * to `1` and is hidden so Rails destroys it on submit; an unsaved row is dropped
55
- * from the DOM. Returns focus to a neighboring row. No-ops at `min`.
371
+ * Removes a row: a persisted row (one carrying its own `destroyFlag`) has the
372
+ * flag set to `1` and is hidden so Rails destroys it on submit; an unsaved row
373
+ * is dropped from the DOM. Returns focus to a surviving row. No-ops at `min`.
56
374
  */
57
375
  #removeRow(row) {
58
- if (this.#effectiveRows.length <= this.minValue) return;
59
- const neighbors = this.#effectiveRows;
60
- const position = neighbors.indexOf(row);
61
- const neighbor = neighbors[position + 1] ?? neighbors[position - 1] ?? null;
62
- const flag = row.querySelector(
63
- '[data-stimeo--nested-form-target="destroyFlag"]'
64
- );
376
+ const rows = this.#effectiveRows;
377
+ if (this.#destroyed(row)) {
378
+ row.hidden = true;
379
+ this.#focusAfterRemove(this.#positionAmong(rows, row));
380
+ return;
381
+ }
382
+ if (rows.length <= this.minValue) return;
383
+ const position = rows.indexOf(row);
384
+ const flag = this.#destroyFlagOf(row);
65
385
  const persisted = flag !== null;
66
- if (persisted) {
386
+ if (flag) {
67
387
  flag.value = "1";
68
388
  row.hidden = true;
69
389
  } else {
70
390
  row.remove();
71
391
  }
72
392
  this.#refresh();
73
- const focusTarget = neighbor ? this.#firstControl(neighbor) : this.hasAddTarget ? this.addTarget : null;
74
- focusTarget?.focus();
393
+ this.#focusAfterRemove(Math.max(0, position));
75
394
  this.dispatch("remove", { detail: { element: row, persisted } });
76
395
  this.#announce();
77
396
  }
78
- /** Recomputes the live count and the min/max state hooks from the DOM. */
397
+ /**
398
+ * The index of the first effective row following `row` in document order.
399
+ * -1 (no following row) feeds the focus slices as a negative index, which
400
+ * yields the same fully-reversed nearest-first order as `rows.length` would.
401
+ */
402
+ #positionAmong(rows, row) {
403
+ return rows.findIndex(
404
+ (candidate) => (row.compareDocumentPosition(candidate) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
405
+ );
406
+ }
407
+ /**
408
+ * Moves focus to the first tab stop of the nearest surviving row — following
409
+ * rows first, then preceding ones — falling back to the add button and finally
410
+ * to the root via a temporary `tabindex`.
411
+ */
412
+ #focusAfterRemove(position) {
413
+ const rows = this.#effectiveRows;
414
+ const ordered = [...rows.slice(position), ...rows.slice(0, position).reverse()];
415
+ for (const row of ordered) {
416
+ const stop = firstTabStop(row);
417
+ if (stop) {
418
+ stop.focus();
419
+ return;
420
+ }
421
+ }
422
+ if (this.hasAddTarget && isTabStop(this.addTarget)) {
423
+ this.addTarget.focus();
424
+ return;
425
+ }
426
+ this.#tabindex.lend(this.element);
427
+ this.element.focus();
428
+ }
429
+ /** Recomputes the count and min/max hooks from the DOM and records them as published. */
79
430
  #refresh() {
431
+ if (!this.hasListTarget) return;
80
432
  const count = this.#effectiveRows.length;
433
+ const atMin = count <= this.minValue;
434
+ const atMax = this.maxValue > 0 && count >= this.maxValue;
81
435
  this.element.setAttribute("data-nested-count", String(count));
82
- this.#reflect("data-nested-at-max", this.maxValue > 0 && count >= this.maxValue);
83
- this.#reflect("data-nested-at-min", count <= this.minValue);
84
- if (this.hasAddTarget) this.addTarget.disabled = this.#atMax;
436
+ this.#reflect("data-nested-at-max", atMax);
437
+ this.#reflect("data-nested-at-min", atMin);
438
+ if (this.hasAddTarget) {
439
+ if (this.maxValue > 0) this.#addDisabled.write(this.addTarget, atMax ? "" : null);
440
+ else this.#addDisabled.return(this.addTarget);
441
+ }
442
+ this.#published = { count, atMin, atMax };
443
+ }
444
+ /**
445
+ * Applies row changes the controller did not perform itself (Turbo Streams,
446
+ * morphs, runtime Value changes): refreshes the hooks and reports a moved
447
+ * public state as `reconcile`. The controller's own operations refresh
448
+ * synchronously first, so their observer echo arrives here as a no-move.
449
+ */
450
+ #reconcileNow() {
451
+ const previous = this.#published;
452
+ this.#refresh();
453
+ const current = this.#published;
454
+ if (!previous || !current) return;
455
+ const moved = previous.count !== current.count || previous.atMin !== current.atMin || previous.atMax !== current.atMax;
456
+ if (moved) this.dispatch("reconcile", { detail: { ...current } });
85
457
  }
86
458
  /** Bridges the count change to the shared announcer when configured. */
87
459
  #announce() {
88
- if (!this.announceValue || !this.countMessageValue) return;
89
- const message = this.countMessageValue.replaceAll(
90
- "{count}",
91
- String(this.#effectiveRows.length)
92
- );
93
- window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
460
+ if (!this.announceValue || this.countMessageValue === "") return;
461
+ announce(fillTemplate(this.countMessageValue, { count: this.#effectiveRows.length }));
94
462
  }
95
463
  /** Sets `attribute` to `"true"` when `on`, else removes it. */
96
464
  #reflect(attribute, on) {
97
465
  if (on) this.element.setAttribute(attribute, "true");
98
466
  else this.element.removeAttribute(attribute);
99
467
  }
468
+ /** Returns the disabled lease so an authored value never leaks into a snapshot. */
469
+ #rewindForCache() {
470
+ if (this.hasAddTarget) this.#addDisabled.return(this.addTarget);
471
+ }
472
+ /** Names the missing required target(s) once per connection. */
473
+ #warnMissing() {
474
+ if (this.#warnedMissing) return;
475
+ this.#warnedMissing = true;
476
+ const missing = [
477
+ this.hasListTarget ? null : 'a "list" target',
478
+ this.hasTemplateTarget ? null : 'a "template" target'
479
+ ].filter((part) => part !== null).join(" and ");
480
+ console.warn(
481
+ `Stimeo UI: "${this.identifier}" cannot manage rows because its markup lacks ${missing}.`
482
+ );
483
+ }
484
+ /** Names a template that does not produce exactly one element, once per connection. */
485
+ #warnBadTemplate(produced) {
486
+ if (this.#warnedTemplate) return;
487
+ this.#warnedTemplate = true;
488
+ const reason = produced === 0 ? "produces no element" : "must produce exactly one root element";
489
+ console.warn(`Stimeo UI: "${this.identifier}" added no row because its template ${reason}.`);
490
+ }
100
491
  /** A strictly-increasing unique index (collision-free even on rapid adds). */
101
492
  #nextIndex() {
102
493
  const index = Math.max(Date.now(), this.#lastIndex + 1);
@@ -106,24 +497,41 @@ var NestedFormController = class extends Controller {
106
497
  get #atMax() {
107
498
  return this.maxValue > 0 && this.#effectiveRows.length >= this.maxValue;
108
499
  }
109
- /** Direct child rows of the list that are not flagged for destruction. */
500
+ /**
501
+ * Direct child rows of the list whose own destroy flag is not set. Callers
502
+ * reach this only behind a list-presence gate (`add`, `#refresh`, and the
503
+ * click path through `#rowContaining`).
504
+ */
110
505
  get #effectiveRows() {
111
- return Array.from(this.listTarget.children).filter((row) => !row.hidden);
506
+ return Array.from(this.listTarget.children).filter(
507
+ (row) => !this.#destroyed(row)
508
+ );
509
+ }
510
+ /** Whether `row` is flagged for destruction; the flag value is the truth source. */
511
+ #destroyed(row) {
512
+ const flag = this.#destroyFlagOf(row);
513
+ return flag !== null && DESTROYED_VALUES.has(flag.value);
514
+ }
515
+ /** The row's own destroy flag, skipping flags owned by a nested inner form. */
516
+ #destroyFlagOf(row) {
517
+ for (const flag of row.querySelectorAll(DESTROY_FLAG_SELECTOR)) {
518
+ if (this.#ownerOf(flag) === this.element) return flag;
519
+ }
520
+ return null;
521
+ }
522
+ /** The nearest nested-form root that owns `el`. */
523
+ #ownerOf(el) {
524
+ return el.closest(ROOT_SELECTOR);
112
525
  }
113
526
  /** The nearest ancestor of `el` that is a direct child of the list, else null. */
114
527
  #rowContaining(el) {
528
+ if (!this.hasListTarget) return null;
115
529
  let node = el;
116
530
  while (node && node.parentElement !== this.listTarget) {
117
531
  node = node.parentElement;
118
532
  }
119
533
  return node;
120
534
  }
121
- /** First visible focusable control inside `row` (skips hidden inputs). */
122
- #firstControl(row) {
123
- return row.querySelector(
124
- 'input:not([type="hidden"]), select, textarea, button, [tabindex]'
125
- );
126
- }
127
535
  };
128
536
 
129
537
  export { NestedFormController };