stimeo-ui 0.7.0 → 0.9.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 (77) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/dist/controllers/auto_submit_controller.d.ts +14 -3
  3. package/dist/controllers/auto_submit_controller.js +94 -16
  4. package/dist/controllers/auto_submit_controller.js.map +1 -1
  5. package/dist/controllers/bulk_select_controller.d.ts +62 -15
  6. package/dist/controllers/bulk_select_controller.js +139 -28
  7. package/dist/controllers/bulk_select_controller.js.map +1 -1
  8. package/dist/controllers/carousel_controller.d.ts +81 -47
  9. package/dist/controllers/carousel_controller.js +451 -100
  10. package/dist/controllers/carousel_controller.js.map +1 -1
  11. package/dist/controllers/clipboard_controller.d.ts +48 -15
  12. package/dist/controllers/clipboard_controller.js +102 -20
  13. package/dist/controllers/clipboard_controller.js.map +1 -1
  14. package/dist/controllers/color_picker_controller.d.ts +37 -6
  15. package/dist/controllers/color_picker_controller.js +180 -43
  16. package/dist/controllers/color_picker_controller.js.map +1 -1
  17. package/dist/controllers/currency_input_controller.d.ts +39 -5
  18. package/dist/controllers/currency_input_controller.js +305 -74
  19. package/dist/controllers/currency_input_controller.js.map +1 -1
  20. package/dist/controllers/data_grid_controller.d.ts +25 -9
  21. package/dist/controllers/data_grid_controller.js +150 -24
  22. package/dist/controllers/data_grid_controller.js.map +1 -1
  23. package/dist/controllers/direct_upload_controller.d.ts +3 -1
  24. package/dist/controllers/direct_upload_controller.js +12 -2
  25. package/dist/controllers/direct_upload_controller.js.map +1 -1
  26. package/dist/controllers/editable_controller.d.ts +34 -9
  27. package/dist/controllers/editable_controller.js +83 -30
  28. package/dist/controllers/editable_controller.js.map +1 -1
  29. package/dist/controllers/file_dropzone_controller.d.ts +123 -29
  30. package/dist/controllers/file_dropzone_controller.js +386 -63
  31. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  32. package/dist/controllers/filter_controller.d.ts +15 -3
  33. package/dist/controllers/filter_controller.js +32 -1
  34. package/dist/controllers/filter_controller.js.map +1 -1
  35. package/dist/controllers/flash_controller.d.ts +3 -1
  36. package/dist/controllers/flash_controller.js +3 -1
  37. package/dist/controllers/flash_controller.js.map +1 -1
  38. package/dist/controllers/frame_loading_controller.d.ts +4 -2
  39. package/dist/controllers/frame_loading_controller.js +2 -1
  40. package/dist/controllers/frame_loading_controller.js.map +1 -1
  41. package/dist/controllers/input_mask_controller.d.ts +42 -16
  42. package/dist/controllers/input_mask_controller.js +251 -76
  43. package/dist/controllers/input_mask_controller.js.map +1 -1
  44. package/dist/controllers/masonry_controller.d.ts +34 -5
  45. package/dist/controllers/masonry_controller.js +129 -17
  46. package/dist/controllers/masonry_controller.js.map +1 -1
  47. package/dist/controllers/nested_form_controller.d.ts +51 -14
  48. package/dist/controllers/nested_form_controller.js +450 -42
  49. package/dist/controllers/nested_form_controller.js.map +1 -1
  50. package/dist/controllers/otp_controller.d.ts +64 -25
  51. package/dist/controllers/otp_controller.js +485 -114
  52. package/dist/controllers/otp_controller.js.map +1 -1
  53. package/dist/controllers/reset_before_cache_controller.d.ts +25 -2
  54. package/dist/controllers/reset_before_cache_controller.js +51 -5
  55. package/dist/controllers/reset_before_cache_controller.js.map +1 -1
  56. package/dist/controllers/resizable_controller.d.ts +23 -7
  57. package/dist/controllers/resizable_controller.js +128 -55
  58. package/dist/controllers/resizable_controller.js.map +1 -1
  59. package/dist/controllers/spinner_controller.d.ts +3 -2
  60. package/dist/controllers/spinner_controller.js +7 -5
  61. package/dist/controllers/spinner_controller.js.map +1 -1
  62. package/dist/controllers/submit_once_controller.d.ts +4 -1
  63. package/dist/controllers/submit_once_controller.js +3 -1
  64. package/dist/controllers/submit_once_controller.js.map +1 -1
  65. package/dist/controllers/textarea_autosize_controller.d.ts +30 -10
  66. package/dist/controllers/textarea_autosize_controller.js +131 -3
  67. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  68. package/dist/index.js +2764 -923
  69. package/dist/index.js.map +1 -1
  70. package/dist/inspector/cli.d.ts +8 -1
  71. package/dist/inspector/cli.js +5 -1
  72. package/dist/inspector/cli.js.map +1 -1
  73. package/dist/inspector/cli_bin.js +5 -1
  74. package/dist/inspector/cli_bin.js.map +1 -1
  75. package/dist/inspector/examples.json +24 -24
  76. package/dist/inspector/manifest.json +193 -30
  77. package/package.json +2 -2
@@ -58,16 +58,72 @@ var LayoutObserver = class {
58
58
  }
59
59
  };
60
60
 
61
+ // src/utils/microtask_coalescer.ts
62
+ var MicrotaskCoalescer = class {
63
+ #run;
64
+ #queued = false;
65
+ #active = false;
66
+ #generation = 0;
67
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
68
+ constructor(run) {
69
+ this.#run = run;
70
+ }
71
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
72
+ activate() {
73
+ this.#active = true;
74
+ }
75
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
76
+ cancel() {
77
+ this.#active = false;
78
+ this.#queued = false;
79
+ this.#generation += 1;
80
+ }
81
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
82
+ schedule() {
83
+ if (!this.#active || this.#queued) return;
84
+ this.#queued = true;
85
+ const generation = this.#generation;
86
+ queueMicrotask(() => {
87
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
88
+ this.#queued = false;
89
+ this.#run();
90
+ });
91
+ }
92
+ };
93
+
61
94
  // src/controllers/masonry_controller.ts
62
95
  var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
96
+ var DEFAULT_MIN_COLUMN_WIDTH = 240;
97
+ var DEFAULT_GAP = 16;
98
+ function usableNumber(value, fallback) {
99
+ return Number.isFinite(value) ? value : fallback;
100
+ }
63
101
  var MasonryController = class extends Controller {
64
102
  static targets = ["item"];
65
103
  static values = {
66
- minColumnWidth: { type: Number, default: 240 },
67
- gap: { type: Number, default: 16 }
104
+ minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },
105
+ gap: { type: Number, default: DEFAULT_GAP }
68
106
  };
69
107
  static events = ["layout"];
70
- #layout = new LayoutObserver(() => this.#relayout());
108
+ /**
109
+ * The declared numbers after validation, so the layout path never sees a value
110
+ * it cannot compute with. Both are resolved once per declaration change rather
111
+ * than on every pass.
112
+ */
113
+ #minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;
114
+ #gap = DEFAULT_GAP;
115
+ /**
116
+ * Collapses every re-layout trigger of one DOM mutation into a single pass, and
117
+ * refuses to run before `connect()` or after `disconnect()`.
118
+ *
119
+ * The triggers arrive in bursts — a resize stream, a morph that syncs several
120
+ * attributes, a batch of rows — and each pass measures every item, so folding
121
+ * them keeps the work proportional to the batch rather than to the events in it.
122
+ */
123
+ #reconcile = new MicrotaskCoalescer(() => this.#relayout());
124
+ /** Items that left the target set and still carry the column hook. */
125
+ #released = /* @__PURE__ */ new Set();
126
+ #layout = new LayoutObserver(() => this.#reconcile.schedule());
71
127
  #mutationObserver = null;
72
128
  /** Last published column count, so `layout` fires only on real changes. */
73
129
  #lastColumns = 0;
@@ -77,20 +133,49 @@ var MasonryController = class extends Controller {
77
133
  * first pass ran before they settled; `load` does not bubble, so this is bound in
78
134
  * the capture phase to catch every descendant.
79
135
  */
80
- #onLoad = () => this.#relayout();
136
+ #onLoad = () => this.#reconcile.schedule();
137
+ /** Resolves the declared column width once, falling back when it is unreadable. */
138
+ minColumnWidthValueChanged() {
139
+ this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
140
+ this.#reconcile.schedule();
141
+ }
142
+ /** Resolves the declared gap once, falling back when it is unreadable. */
143
+ gapValueChanged() {
144
+ this.#gap = usableNumber(this.gapValue, DEFAULT_GAP);
145
+ this.#reconcile.schedule();
146
+ }
147
+ /** Packs an element that became an item without moving in the DOM. */
148
+ itemTargetConnected() {
149
+ this.#reconcile.schedule();
150
+ }
151
+ /**
152
+ * Queues the column hook of an element that stopped being an item for removal.
153
+ *
154
+ * The removal is queued rather than immediate because teardown reports every
155
+ * target as disconnected: doing it here would strip the whole grid just before
156
+ * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
157
+ * with the pass, so only a genuine target change reaches it.
158
+ */
159
+ itemTargetDisconnected(item) {
160
+ this.#released.add(item);
161
+ this.#reconcile.schedule();
162
+ }
81
163
  /** Observes size/content changes and performs the first layout pass. */
82
164
  connect() {
83
165
  this.#layout.observe(this.element);
84
166
  this.#layout.observeViewport();
85
167
  if (typeof MutationObserver !== "undefined") {
86
- this.#mutationObserver = new MutationObserver(() => this.#relayout());
168
+ this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
87
169
  this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
88
170
  }
89
171
  this.element.addEventListener("load", this.#onLoad, true);
90
172
  this.#relayout();
173
+ this.#reconcile.activate();
91
174
  }
92
175
  /** Releases both observers and the load listener so nothing fires after detach. */
93
176
  disconnect() {
177
+ this.#reconcile.cancel();
178
+ this.#released.clear();
94
179
  this.#layout.disconnect();
95
180
  this.#mutationObserver?.disconnect();
96
181
  this.#mutationObserver = null;
@@ -99,26 +184,53 @@ var MasonryController = class extends Controller {
99
184
  }
100
185
  /**
101
186
  * Recomputes the column count and assigns every item to the shortest column.
102
- * Runs automatically on connect, on resize, on item add/remove, and when a
103
- * descendant resource loads (private — there is no public action; the observers
104
- * and the capture-phase `load` listener drive it). Items are walked in DOM
105
- * order; each lands in the column with the least accumulated height, which
106
- * keeps the packing balanced without reordering the DOM.
187
+ * Runs automatically on connect, on resize, on item add/remove, when a declared
188
+ * number changes, and when a descendant resource loads (private — there is no
189
+ * public action; the observers, the target callbacks and the capture-phase
190
+ * `load` listener drive it). Items are walked in DOM order; each lands in the
191
+ * column with the least accumulated height, which keeps the packing balanced
192
+ * without reordering the DOM.
193
+ *
194
+ * Every box is measured before anything is written. Interleaving the two would
195
+ * make a consumer's `data-column` rule invalidate style once per item, and the
196
+ * next measurement then has to settle layout again — once per item instead of
197
+ * once per pass. The assignment is independent of the measurement because the
198
+ * columns are uniform in width, so the order of the two passes does not change
199
+ * the result.
200
+ *
201
+ * @stimeoRenderRoot
107
202
  */
108
203
  #relayout() {
109
204
  const items = this.itemTargets;
110
205
  const columns = this.#columnCount();
206
+ const boxes = items.map((item) => item.getBoundingClientRect().height);
207
+ let changed = false;
208
+ if (this.#released.size > 0) {
209
+ const owned = new Set(items);
210
+ for (const released of this.#released) {
211
+ if (owned.has(released)) continue;
212
+ if (released.hasAttribute("data-column")) {
213
+ released.removeAttribute("data-column");
214
+ changed = true;
215
+ }
216
+ }
217
+ this.#released.clear();
218
+ }
111
219
  const heights = new Array(columns).fill(0);
112
- for (const item of items) {
220
+ items.forEach((item, index) => {
113
221
  let shortest = 0;
114
222
  for (let col = 1; col < columns; col++) {
115
223
  if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
116
224
  }
117
- item.setAttribute("data-column", String(shortest));
118
- heights[shortest] = (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;
119
- }
225
+ const assigned = String(shortest);
226
+ if (item.getAttribute("data-column") !== assigned) {
227
+ item.setAttribute("data-column", assigned);
228
+ changed = true;
229
+ }
230
+ heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;
231
+ });
120
232
  this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
121
- if (columns !== this.#lastColumns) {
233
+ if (columns !== this.#lastColumns || changed) {
122
234
  this.#lastColumns = columns;
123
235
  this.dispatch("layout", { detail: { columns } });
124
236
  }
@@ -131,9 +243,9 @@ var MasonryController = class extends Controller {
131
243
  */
132
244
  #columnCount() {
133
245
  const width = this.element.getBoundingClientRect().width;
134
- const denominator = this.minColumnWidthValue + this.gapValue;
246
+ const denominator = this.#minColumnWidth + this.#gap;
135
247
  if (width <= 0 || denominator <= 0) return 1;
136
- return Math.max(1, Math.floor((width + this.gapValue) / denominator));
248
+ return Math.max(1, Math.floor((width + this.#gap) / denominator));
137
249
  }
138
250
  };
139
251
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/controllers/masonry_controller.ts"],"names":[],"mappings":"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","file":"masonry_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/** CSS custom property exposing the current column count to consumer CSS. */\nconst COLUMNS_PROPERTY = \"--stimeo--masonry-columns\";\n\n/**\n * Headless **Masonry** layout helper: assigns each item to the shortest column so\n * variable-height cards pack without vertical gaps. There is no APG widget — this\n * is a layout-only utility that emits state hooks, never visual structure.\n *\n * Markup contract (identifier: `stimeo--masonry`):\n * <div data-controller=\"stimeo--masonry\"\n * data-stimeo--masonry-min-column-width-value=\"240\"\n * data-stimeo--masonry-gap-value=\"16\">\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * </div>\n *\n * The column count is derived responsively from the container width and\n * `minColumnWidth`; each item is then placed into whichever column is currently\n * shortest (measured from item heights). The count is published on the controller\n * element as the `--stimeo--masonry-columns` custom property and each item gets a\n * `data-column` index, so the consumer's CSS owns the actual placement.\n *\n * `layout` dispatches `{ columns: number }`.\n *\n * @remarks\n * Behavior only. **DOM order is never changed** — reading order and focus order\n * stay the source markup order (WCAG 1.3.2). The visual packing is purely the\n * column assignment a consumer reads from `data-column`; this controller writes no\n * positioning styles. Re-layout runs on connect, on resize ({@link LayoutObserver}),\n * and on item add/remove ({@link MutationObserver}); both observers are released on\n * `disconnect()` (Turbo navigation included). Use only for independent cards whose\n * visual order carries no meaning.\n */\nexport class MasonryController extends Controller<HTMLElement> {\n static override targets = [\"item\"];\n static override values = {\n minColumnWidth: { type: Number, default: 240 },\n gap: { type: Number, default: 16 },\n };\n static events = [\"layout\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare minColumnWidthValue: number;\n declare gapValue: number;\n\n readonly #layout = new LayoutObserver(() => this.#relayout());\n #mutationObserver: MutationObserver | null = null;\n /** Last published column count, so `layout` fires only on real changes. */\n #lastColumns = 0;\n\n /**\n * Re-pack when a descendant resource finishes loading. Images/iframes report a\n * height of 0 until loaded, which would skew the shortest-column packing if the\n * first pass ran before they settled; `load` does not bubble, so this is bound in\n * the capture phase to catch every descendant.\n */\n readonly #onLoad = (): void => this.#relayout();\n\n /** Observes size/content changes and performs the first layout pass. */\n override connect(): void {\n this.#layout.observe(this.element);\n this.#layout.observeViewport();\n\n if (typeof MutationObserver !== \"undefined\") {\n this.#mutationObserver = new MutationObserver(() => this.#relayout());\n this.#mutationObserver.observe(this.element, { childList: true, subtree: true });\n }\n this.element.addEventListener(\"load\", this.#onLoad, true);\n this.#relayout();\n }\n\n /** Releases both observers and the load listener so nothing fires after detach. */\n override disconnect(): void {\n this.#layout.disconnect();\n this.#mutationObserver?.disconnect();\n this.#mutationObserver = null;\n this.element.removeEventListener(\"load\", this.#onLoad, true);\n this.#lastColumns = 0;\n }\n\n /**\n * Recomputes the column count and assigns every item to the shortest column.\n * Runs automatically on connect, on resize, on item add/remove, and when a\n * descendant resource loads (private — there is no public action; the observers\n * and the capture-phase `load` listener drive it). Items are walked in DOM\n * order; each lands in the column with the least accumulated height, which\n * keeps the packing balanced without reordering the DOM.\n */\n #relayout(): void {\n const items = this.itemTargets;\n const columns = this.#columnCount();\n\n const heights = new Array<number>(columns).fill(0);\n for (const item of items) {\n let shortest = 0;\n for (let col = 1; col < columns; col++) {\n if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;\n }\n item.setAttribute(\"data-column\", String(shortest));\n heights[shortest] =\n (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;\n }\n\n this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));\n\n if (columns !== this.#lastColumns) {\n this.#lastColumns = columns;\n this.dispatch(\"layout\", { detail: { columns } });\n }\n }\n\n /**\n * Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,\n * never fewer than one. When the width is unmeasurable (detached, or a layout\n * engine that reports `0`), it falls back to a single column so every item still\n * gets a valid `data-column`.\n */\n #columnCount(): number {\n const width = this.element.getBoundingClientRect().width;\n const denominator = this.minColumnWidthValue + this.gapValue;\n if (width <= 0 || denominator <= 0) return 1;\n return Math.max(1, Math.floor((width + this.gapValue) / denominator));\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/utils/microtask_coalescer.ts","../../src/controllers/masonry_controller.ts"],"names":[],"mappings":"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","file":"masonry_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","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** CSS custom property exposing the current column count to consumer CSS. */\nconst COLUMNS_PROPERTY = \"--stimeo--masonry-columns\";\n\n/** Column width assumed when the declaration is absent or unreadable. */\nconst DEFAULT_MIN_COLUMN_WIDTH = 240;\n/** Item spacing assumed when the declaration is absent or unreadable. */\nconst DEFAULT_GAP = 16;\n\n/**\n * Returns `value` when it is a number the column arithmetic can use, else\n * `fallback`.\n *\n * A unit suffix is the ordinary authoring slip here (`\"240px\"`), and Stimulus'\n * Number reader answers `NaN` rather than raising — which would reach the column\n * count and make the column bookkeeping impossible to allocate, leaving the grid\n * with no hooks at all. An infinity is rejected for the same reason: it divides\n * into itself as `NaN`.\n */\nfunction usableNumber(value: number, fallback: number): number {\n return Number.isFinite(value) ? value : fallback;\n}\n\n/**\n * Headless **Masonry** layout helper: assigns each item to the shortest column so\n * variable-height cards pack without vertical gaps. There is no APG widget — this\n * is a layout-only utility that emits state hooks, never visual structure.\n *\n * Markup contract (identifier: `stimeo--masonry`):\n * <div data-controller=\"stimeo--masonry\"\n * data-stimeo--masonry-min-column-width-value=\"240\"\n * data-stimeo--masonry-gap-value=\"16\">\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * </div>\n *\n * The column count is derived responsively from the container width and\n * `minColumnWidth`; each item is then placed into whichever column is currently\n * shortest (measured from item heights). The count is published on the controller\n * element as the `--stimeo--masonry-columns` custom property and each item gets a\n * `data-column` index, so the consumer's CSS owns the actual placement.\n *\n * `layout` dispatches `{ columns: number }` whenever the published result moves —\n * the column count changed, or some item landed in a different column. A pass that\n * reproduces the previous result stays silent.\n *\n * @remarks\n * Behavior only. **DOM order is never changed** — reading order and focus order\n * stay the source markup order (WCAG 1.3.2). The visual packing is purely the\n * column assignment a consumer reads from `data-column`; this controller writes no\n * positioning styles. Use only for independent cards whose visual order carries no\n * meaning.\n *\n * Re-layout runs on connect, on resize ({@link LayoutObserver}), on item\n * add/remove ({@link MutationObserver}), on an item joining or leaving the target\n * set, when a declared number changes, and when a descendant resource loads.\n * Everything but the first pass is folded into one microtask, so a burst of\n * triggers costs one pass. The observers, the `load` listener and any pending pass\n * are released on `disconnect()` (Turbo navigation included).\n *\n * Consumer contract:\n * - A declaration that cannot be read as a number (`\"240px\"`, an infinity) falls\n * back to that Value's default and the grid keeps working; `0` and negatives are\n * readable numbers and collapse to a single column instead.\n * - `data-column` belongs to this controller: it is written on every item it owns\n * and taken back from an element that stops being one.\n */\nexport class MasonryController extends Controller<HTMLElement> {\n static override targets = [\"item\"];\n static override values = {\n minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },\n gap: { type: Number, default: DEFAULT_GAP },\n };\n static events = [\"layout\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare minColumnWidthValue: number;\n declare gapValue: number;\n\n /**\n * The declared numbers after validation, so the layout path never sees a value\n * it cannot compute with. Both are resolved once per declaration change rather\n * than on every pass.\n */\n #minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;\n #gap = DEFAULT_GAP;\n\n /**\n * Collapses every re-layout trigger of one DOM mutation into a single pass, and\n * refuses to run before `connect()` or after `disconnect()`.\n *\n * The triggers arrive in bursts — a resize stream, a morph that syncs several\n * attributes, a batch of rows — and each pass measures every item, so folding\n * them keeps the work proportional to the batch rather than to the events in it.\n */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#relayout());\n\n /** Items that left the target set and still carry the column hook. */\n readonly #released = new Set<HTMLElement>();\n\n readonly #layout = new LayoutObserver(() => this.#reconcile.schedule());\n #mutationObserver: MutationObserver | null = null;\n /** Last published column count, so `layout` fires only on real changes. */\n #lastColumns = 0;\n\n /**\n * Re-pack when a descendant resource finishes loading. Images/iframes report a\n * height of 0 until loaded, which would skew the shortest-column packing if the\n * first pass ran before they settled; `load` does not bubble, so this is bound in\n * the capture phase to catch every descendant.\n */\n readonly #onLoad = (): void => this.#reconcile.schedule();\n\n /** Resolves the declared column width once, falling back when it is unreadable. */\n minColumnWidthValueChanged(): void {\n this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);\n this.#reconcile.schedule();\n }\n\n /** Resolves the declared gap once, falling back when it is unreadable. */\n gapValueChanged(): void {\n this.#gap = usableNumber(this.gapValue, DEFAULT_GAP);\n this.#reconcile.schedule();\n }\n\n /** Packs an element that became an item without moving in the DOM. */\n itemTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /**\n * Queues the column hook of an element that stopped being an item for removal.\n *\n * The removal is queued rather than immediate because teardown reports every\n * target as disconnected: doing it here would strip the whole grid just before\n * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue\n * with the pass, so only a genuine target change reaches it.\n */\n itemTargetDisconnected(item: HTMLElement): void {\n this.#released.add(item);\n this.#reconcile.schedule();\n }\n\n /** Observes size/content changes and performs the first layout pass. */\n override connect(): void {\n this.#layout.observe(this.element);\n this.#layout.observeViewport();\n\n if (typeof MutationObserver !== \"undefined\") {\n this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());\n this.#mutationObserver.observe(this.element, { childList: true, subtree: true });\n }\n this.element.addEventListener(\"load\", this.#onLoad, true);\n this.#relayout();\n this.#reconcile.activate();\n }\n\n /** Releases both observers and the load listener so nothing fires after detach. */\n override disconnect(): void {\n this.#reconcile.cancel();\n this.#released.clear();\n this.#layout.disconnect();\n this.#mutationObserver?.disconnect();\n this.#mutationObserver = null;\n this.element.removeEventListener(\"load\", this.#onLoad, true);\n this.#lastColumns = 0;\n }\n\n /**\n * Recomputes the column count and assigns every item to the shortest column.\n * Runs automatically on connect, on resize, on item add/remove, when a declared\n * number changes, and when a descendant resource loads (private — there is no\n * public action; the observers, the target callbacks and the capture-phase\n * `load` listener drive it). Items are walked in DOM order; each lands in the\n * column with the least accumulated height, which keeps the packing balanced\n * without reordering the DOM.\n *\n * Every box is measured before anything is written. Interleaving the two would\n * make a consumer's `data-column` rule invalidate style once per item, and the\n * next measurement then has to settle layout again — once per item instead of\n * once per pass. The assignment is independent of the measurement because the\n * columns are uniform in width, so the order of the two passes does not change\n * the result.\n *\n * @stimeoRenderRoot\n */\n #relayout(): void {\n const items = this.itemTargets;\n const columns = this.#columnCount();\n const boxes = items.map((item) => item.getBoundingClientRect().height);\n\n let changed = false;\n if (this.#released.size > 0) {\n // An element that left and rejoined the target set within one batch is\n // queued here while still being an item, so ownership is decided against\n // the set this pass sees rather than against the queue alone.\n const owned = new Set(items);\n for (const released of this.#released) {\n if (owned.has(released)) continue;\n if (released.hasAttribute(\"data-column\")) {\n released.removeAttribute(\"data-column\");\n changed = true;\n }\n }\n this.#released.clear();\n }\n\n const heights = new Array<number>(columns).fill(0);\n items.forEach((item, index) => {\n let shortest = 0;\n for (let col = 1; col < columns; col++) {\n if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;\n }\n const assigned = String(shortest);\n // Writing a value the item already carries would publish a change that did\n // not happen, and the same comparison is what tells the event whether the\n // published layout actually moved.\n if (item.getAttribute(\"data-column\") !== assigned) {\n item.setAttribute(\"data-column\", assigned);\n changed = true;\n }\n heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;\n });\n\n this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));\n\n if (columns !== this.#lastColumns || changed) {\n this.#lastColumns = columns;\n this.dispatch(\"layout\", { detail: { columns } });\n }\n }\n\n /**\n * Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,\n * never fewer than one. When the width is unmeasurable (detached, or a layout\n * engine that reports `0`), it falls back to a single column so every item still\n * gets a valid `data-column`.\n */\n #columnCount(): number {\n const width = this.element.getBoundingClientRect().width;\n const denominator = this.#minColumnWidth + this.#gap;\n if (width <= 0 || denominator <= 0) return 1;\n return Math.max(1, Math.floor((width + this.#gap) / denominator));\n }\n}\n"]}
@@ -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,18 +21,43 @@ import { Controller } from '@hotwired/stimulus';
22
21
  * data-action="click->stimeo--nested-form#add">Add</button>
23
22
  * </div>
24
23
  *
25
- * `add` dispatches `{ index, element }`; `remove` dispatches `{ element, persisted }`.
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.
26
33
  *
27
34
  * @remarks
28
35
  * Behavior only — server-side `accepts_nested_attributes_for`, per-field
29
36
  * validation, and reordering are out of scope. Row state lives **only** in the DOM
30
37
  * (inserted nodes + each `_destroy` hidden input); there is no module-scope index
31
- * counter, so the controller stays idempotent across Turbo swaps. Remove buttons
32
- * are handled by **delegation** on the container, so dynamically-added rows work
33
- * without per-row `data-action`. Adding a row moves focus to its first control and
34
- * removing returns focus to a neighbor (WCAG 2.2 2.4.3); count changes are announced
35
- * through the shared `stimeo--announcer` (WCAG 2.2 4.1.3) when `announce` +
36
- * `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()`.
37
61
  */
38
62
  declare class NestedFormController extends Controller<HTMLElement> {
39
63
  #private;
@@ -61,7 +85,7 @@ declare class NestedFormController extends Controller<HTMLElement> {
61
85
  };
62
86
  };
63
87
  static actions: readonly ["add"];
64
- static events: readonly ["add", "remove"];
88
+ static events: readonly ["add", "remove", "reconcile"];
65
89
  readonly listTarget: HTMLElement;
66
90
  readonly templateTarget: HTMLTemplateElement;
67
91
  readonly addTarget: HTMLButtonElement;
@@ -75,10 +99,23 @@ declare class NestedFormController extends Controller<HTMLElement> {
75
99
  countMessageValue: string;
76
100
  connect(): void;
77
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;
78
113
  /**
79
114
  * Clones the template row, replaces the index placeholder with a unique value,
80
- * appends it, focuses its first control, and announces the new count. No-ops at
81
- * `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).
82
119
  */
83
120
  add(): void;
84
121
  }