stimeo-ui 0.16.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/file_dropzone_controller.ts
4
-
5
- // src/utils/announce.ts
6
3
  function announce(message, options = {}) {
7
4
  const text = message.trim();
8
5
  if (text.length === 0) return;
@@ -19,91 +16,190 @@ function fillTemplate(template, values) {
19
16
  });
20
17
  }
21
18
 
22
- // src/utils/before_cache_reset.ts
23
- var BeforeCacheReset = class _BeforeCacheReset {
24
- /** Every subscribed instance, iterated by the one shared document listener. */
25
- static #subscribers = /* @__PURE__ */ new Set();
26
- /** The shared listener; installed while at least one instance is subscribed. */
27
- static #onBeforeCache = () => {
28
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
29
- };
30
- #rewind;
31
- /** @param rewind - the pass that returns this controller's state to its initial form. */
32
- constructor(rewind) {
33
- this.#rewind = rewind;
34
- }
35
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
36
- activate() {
37
- const first = _BeforeCacheReset.#subscribers.size === 0;
38
- _BeforeCacheReset.#subscribers.add(this);
39
- if (first) {
40
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
19
+ function sharedRegistry(key, create) {
20
+ const symbol = Symbol.for(key);
21
+ const scope = globalThis;
22
+ const existing = scope[symbol];
23
+ if (existing !== void 0) return existing;
24
+ const registry = create();
25
+ Object.defineProperty(globalThis, symbol, { value: registry });
26
+ return registry;
27
+ }
28
+
29
+ function isAttributeValue(value) {
30
+ return value === null || typeof value === "string";
31
+ }
32
+ function readLeaseRecord(element, record) {
33
+ try {
34
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
35
+ if (!Array.isArray(parsed)) return void 0;
36
+ const [original, written, ...rest] = parsed;
37
+ const linked = rest[0] !== false;
38
+ const beneath = linked ? rest : rest.slice(1);
39
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
40
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
41
+ } catch {
42
+ return void 0;
43
+ }
44
+ }
45
+ var AttributeLease = class _AttributeLease {
46
+ static #registry = sharedRegistry(
47
+ "stimeo-ui.attribute-lease.registry.v1",
48
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
49
+ );
50
+ #attribute;
51
+ #record;
52
+ #base;
53
+ #held = /* @__PURE__ */ new Set();
54
+ constructor(attribute, owner, options = {}) {
55
+ this.#attribute = attribute;
56
+ this.#record = `data-${owner}-${attribute}-lease`;
57
+ this.#base = options.base === true;
58
+ const names = _AttributeLease.#registry.names;
59
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
60
+ }
61
+ write(element, value) {
62
+ const stack = this.#stack(element);
63
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
64
+ this.#held.add(element);
65
+ const shows = !this.#covered(element, stack, hold);
66
+ hold.written = value;
67
+ this.#mark(element, stack);
68
+ if (shows) this.#reflect(element, value);
69
+ }
70
+ return(element) {
71
+ const held = this.#held.delete(element);
72
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
73
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
74
+ const hold = this.#take(stack);
75
+ if (!hold) return false;
76
+ const carries = element.getAttribute(this.#attribute) === hold.written;
77
+ const owns = carries && !this.#covered(element, stack, hold);
78
+ const index = stack.indexOf(hold);
79
+ stack.splice(index, 1);
80
+ const above = stack[index];
81
+ if (above) above.linked &&= hold.linked;
82
+ this.#mark(element, stack);
83
+ if (!owns) return true;
84
+ const below = hold.linked ? stack[index - 1] : void 0;
85
+ this.#reflect(element, below ? below.written : hold.original);
86
+ return true;
87
+ }
88
+ returnAll() {
89
+ for (const element of Array.from(this.#held)) this.return(element);
90
+ }
91
+ #stack(element) {
92
+ const stacks = _AttributeLease.#registry.stacks;
93
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
94
+ stacks.set(element, byAttribute);
95
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
96
+ byAttribute.set(this.#attribute, stack);
97
+ return stack;
98
+ }
99
+ #seed(element) {
100
+ const found = [];
101
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
102
+ const read = readLeaseRecord(element, record);
103
+ if (read) found.push({ ...read, record });
104
+ }
105
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
106
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
107
+ lease: null,
108
+ record,
109
+ written,
110
+ original,
111
+ linked: linked && index > 0
112
+ }));
113
+ }
114
+ #take(stack) {
115
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
116
+ if (hold) hold.lease = this;
117
+ return hold;
118
+ }
119
+ #acquire(element, stack) {
120
+ const current = element.getAttribute(this.#attribute);
121
+ const top = stack.at(-1);
122
+ const bottom = stack[0];
123
+ const over = top?.written === current ? top : void 0;
124
+ const from = this.#base ? bottom : over;
125
+ const hold = {
126
+ lease: this,
127
+ record: this.#record,
128
+ written: current,
129
+ original: from ? from.original : current,
130
+ linked: !this.#base && over !== void 0
131
+ };
132
+ if (this.#base && bottom) {
133
+ bottom.linked = true;
134
+ stack.unshift(hold);
135
+ } else stack.push(hold);
136
+ return hold;
137
+ }
138
+ #covered(element, stack, hold) {
139
+ const current = element.getAttribute(this.#attribute);
140
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
141
+ }
142
+ #mark(element, stack) {
143
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
144
+ stack.forEach((hold, index) => {
145
+ const names = stack.slice(0, index).map((under) => under.record);
146
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
147
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
148
+ records.set(
149
+ hold.record,
150
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
151
+ );
152
+ });
153
+ for (const [name, recorded] of records) {
154
+ if (element.getAttribute(name) === recorded) continue;
155
+ if (recorded === null) element.removeAttribute(name);
156
+ else element.setAttribute(name, recorded);
41
157
  }
42
158
  }
43
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
44
- deactivate() {
45
- _BeforeCacheReset.#subscribers.delete(this);
46
- if (_BeforeCacheReset.#subscribers.size > 0) return;
47
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
159
+ #reflect(element, value) {
160
+ if (element.getAttribute(this.#attribute) === value) return;
161
+ if (value === null) element.removeAttribute(this.#attribute);
162
+ else element.setAttribute(this.#attribute, value);
48
163
  }
49
164
  };
50
165
 
51
- // src/utils/detach_gate.ts
52
166
  var DetachGate = class _DetachGate {
53
- /** Set while a probe is queued, waiting for a reconnect to cancel it. */
54
167
  #pending = false;
55
- /**
56
- * True while a probe is queued — the last disconnect was ambiguous and no
57
- * reconnect has cancelled it yet. Read it from `connect()` to tell the
58
- * reconnect half of an in-page move from a first connect: a controller whose
59
- * initialisation restarts a measurement (a min-duration floor, an elapsed
60
- * counter) must skip it for the move, where nothing actually restarted.
61
- */
62
168
  get pending() {
63
169
  return this.#pending;
64
170
  }
65
- /**
66
- * True when the disconnect is definitely a real detach — the element left
67
- * the document, or `data-controller` no longer lists the identifier. False
68
- * means ambiguous (in-page move or observed-root exit), NOT "alive".
69
- */
70
171
  static isDetached(host) {
71
- if (!host.element.isConnected) return true;
72
- const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
73
- return !tokens.includes(host.identifier);
74
- }
75
- /**
76
- * Call from `disconnect()`: runs `teardown` synchronously on a definite
77
- * detach (fast path), otherwise defers it one microtask — a reconnect
78
- * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
79
- * One microtask is the whole probe window: Stimulus reconnects a moved
80
- * element within the same mutation batch, before the checkpoint drains.
81
- */
172
+ return !host.element.isConnected || !_DetachGate.#listed(host);
173
+ }
82
174
  disconnected(host, teardown) {
83
- if (_DetachGate.isDetached(host)) {
175
+ const carried = _DetachGate.#carried(host);
176
+ if (!carried && _DetachGate.isDetached(host)) {
84
177
  this.#pending = false;
85
178
  teardown();
86
179
  return;
87
180
  }
88
181
  this.#pending = true;
89
- queueMicrotask(() => {
182
+ const probe = () => {
90
183
  if (!this.#pending) return;
91
184
  this.#pending = false;
92
185
  teardown();
93
- });
186
+ };
187
+ if (carried) setTimeout(probe, 0);
188
+ else queueMicrotask(probe);
189
+ }
190
+ static #carried(host) {
191
+ const element = host.element;
192
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
193
+ }
194
+ static #listed(host) {
195
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
196
+ return tokens.includes(host.identifier);
94
197
  }
95
- /**
96
- * Disarms a pending probe. Call from `connect()` (the reconnect that proves
97
- * an in-page move) and from the head of any teardown path not routed through
98
- * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
99
- * never run the teardown a second time.
100
- */
101
198
  cancel() {
102
199
  this.#pending = false;
103
200
  }
104
201
  };
105
202
 
106
- // src/utils/element_part.ts
107
203
  function matchingPart(root, selector) {
108
204
  return root.matches(selector) ? root : root.querySelector(selector);
109
205
  }
@@ -122,7 +218,14 @@ function writeLabel(slot, text) {
122
218
  else slot.prepend(text);
123
219
  }
124
220
 
125
- // src/utils/focus_candidate.ts
221
+ function ownerIndex(candidates, node) {
222
+ if (!(node instanceof Node)) return -1;
223
+ return candidates.findIndex((candidate) => candidate.contains(node));
224
+ }
225
+ function ownerOf(candidates, node) {
226
+ return candidates[ownerIndex(candidates, node)] ?? null;
227
+ }
228
+
126
229
  function inheritsFieldsetDisabled(control) {
127
230
  let fieldset = control.closest("fieldset[disabled]");
128
231
  while (fieldset) {
@@ -133,12 +236,54 @@ function inheritsFieldsetDisabled(control) {
133
236
  return false;
134
237
  }
135
238
 
136
- // src/utils/target_selector.ts
239
+ var NUMBER_BOUNDS = {
240
+ nonNegative: { finite: true, min: 0 }};
241
+ function matchesNumberBounds(value, bounds) {
242
+ if (!Number.isFinite(value)) {
243
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
244
+ if (direction === null) return false;
245
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
246
+ }
247
+ if (bounds.min !== void 0 && value < bounds.min) return false;
248
+ if (bounds.max !== void 0 && value > bounds.max) return false;
249
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
250
+ if (bounds.integer && !Number.isInteger(value)) return false;
251
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
252
+ return true;
253
+ }
254
+
255
+ function readNumber(raw, fallback, bounds) {
256
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
257
+ }
258
+
259
+ var NumberValueReader = class {
260
+ #lastRejected = /* @__PURE__ */ new Map();
261
+ read(owner, name, raw, fallback, bounds) {
262
+ const resolved = readNumber(raw, fallback, bounds);
263
+ if (matchesNumberBounds(raw, bounds)) {
264
+ this.#lastRejected.delete(name);
265
+ return resolved;
266
+ }
267
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
268
+ const literal = owner.element.getAttribute(attribute);
269
+ if (literal === null) {
270
+ this.#lastRejected.delete(name);
271
+ return resolved;
272
+ }
273
+ if (this.#lastRejected.get(name) !== literal) {
274
+ this.#lastRejected.set(name, literal);
275
+ console.warn(
276
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
277
+ );
278
+ }
279
+ return resolved;
280
+ }
281
+ };
282
+
137
283
  function targetSelector(identifier, name) {
138
284
  return `[data-${identifier}-target~="${name}"]`;
139
285
  }
140
286
 
141
- // src/utils/template_row.ts
142
287
  function cloneTemplateRoot(template) {
143
288
  const root = template.content.firstElementChild;
144
289
  return root instanceof HTMLElement ? root.cloneNode(true) : null;
@@ -149,22 +294,15 @@ var TemplateRow = class {
149
294
  constructor(options) {
150
295
  this.#options = options;
151
296
  }
152
- /** The attribute selector for one declared part, in this controller's namespace. */
153
297
  selector(name) {
154
298
  return targetSelector(this.#options.identifier, name);
155
299
  }
156
- /** Re-arms the once-per-connection diagnostic. */
157
300
  connect() {
158
301
  this.#warned = false;
159
302
  }
160
- /**
161
- * Names one missing part on the console, at most once per connection, and
162
- * returns `null` so a caller can hand it straight back.
163
- */
164
303
  report(missing) {
165
304
  return this.#say(`lacks ${missing}`);
166
305
  }
167
- /** Writes one diagnostic per connection, saying what the template got wrong. */
168
306
  #say(problem) {
169
307
  if (this.#warned) return null;
170
308
  this.#warned = true;
@@ -172,10 +310,6 @@ var TemplateRow = class {
172
310
  console.warn(`Stimeo UI: "${identifier}" ${outcome} because its ${noun} ${problem}.`);
173
311
  return null;
174
312
  }
175
- /**
176
- * Clones the row and resolves its parts, or names what the template got wrong
177
- * and returns `null`. `values` fills the button's authored `aria-label`.
178
- */
179
313
  instantiate(template, values) {
180
314
  const { root: rootName, required, optional, button: buttonName } = this.#options;
181
315
  const root = cloneTemplateRoot(template);
@@ -203,7 +337,6 @@ var TemplateRow = class {
203
337
  button.setAttribute("aria-label", fillTemplate(name, values));
204
338
  return { root, slots, button };
205
339
  }
206
- /** The row may be its own part; otherwise the first descendant carrying the target. */
207
340
  #resolve(root, name) {
208
341
  return matchingPart(root, this.selector(name));
209
342
  }
@@ -212,11 +345,11 @@ var TemplateRow = class {
212
345
  }
213
346
  };
214
347
 
215
- // src/controllers/file_dropzone_controller.ts
216
348
  var DRAGOVER_ATTRIBUTE = "data-dragover";
217
349
  var INVALID_ATTRIBUTE = "invalid";
218
- var FileDropzoneController = class extends Controller {
219
- /** The hook above, in the namespace this controller is registered under. */
350
+ var GENERATED_ATTRIBUTE = "generated";
351
+ var FileDropzoneController = class _FileDropzoneController extends Controller {
352
+ #numbers = new NumberValueReader();
220
353
  get #invalidAttribute() {
221
354
  return `data-${this.identifier}-${INVALID_ATTRIBUTE}`;
222
355
  }
@@ -243,13 +376,17 @@ var FileDropzoneController = class extends Controller {
243
376
  announceRejectedDuplicateText: { type: String, default: "" },
244
377
  announceRejectedCountText: { type: String, default: "" }
245
378
  };
379
+ static valueConstraints = {
380
+ maxSize: NUMBER_BOUNDS.nonNegative,
381
+ maxFiles: NUMBER_BOUNDS.nonNegative
382
+ };
246
383
  static actions = ["onChange", "onDragLeave", "onDragOver", "onDrop", "openDialog"];
247
384
  static events = ["change", "reject", "reconcile"];
248
- /** Selected files paired with their rendered item and any preview objectURL. */
249
385
  #entries = [];
250
- /** Whether a drag is currently over the zone; the source for `data-dragover`. */
251
386
  #dragging = false;
252
- /** Builds one preview item from the authored template and owns its diagnostic. */
387
+ #invalid = false;
388
+ #dragoverHook = new AttributeLease(DRAGOVER_ATTRIBUTE, this.identifier);
389
+ #invalidHook = new AttributeLease(this.#invalidAttribute, this.identifier);
253
390
  #rows = new TemplateRow({
254
391
  identifier: this.identifier,
255
392
  root: "item",
@@ -260,84 +397,97 @@ var FileDropzoneController = class extends Controller {
260
397
  noun: "item template"
261
398
  });
262
399
  #gate = new DetachGate();
263
- #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
264
- /** Subscribes to the Turbo cache rewind and re-arms the template diagnostic. */
400
+ #listWatch = new MutationObserver((records) => {
401
+ const lost = records.some(
402
+ (record) => Array.from(record.removedNodes).some(
403
+ (node) => this.#entries.some((entry) => entry.item === node)
404
+ )
405
+ );
406
+ if (!lost) return;
407
+ this.#rehome();
408
+ this.#paintZone();
409
+ });
410
+ #connected = false;
411
+ get #generatedAttribute() {
412
+ return `data-${this.identifier}-${GENERATED_ATTRIBUTE}`;
413
+ }
265
414
  connect() {
415
+ const moved = this.#gate.pending;
266
416
  this.#gate.cancel();
417
+ this.#connected = true;
267
418
  this.#rows.connect();
268
- this.#beforeCache.activate();
419
+ if (!moved) this.#discardInherited();
269
420
  }
270
- /**
271
- * Releases the delegated listeners. The selection itself survives an in-page
272
- * move and is released only once the gate proves a real detach — revoking a
273
- * preview URL on a move would leave a live item pointing at a dead `blob:`.
274
- */
275
421
  disconnect() {
422
+ this.#connected = false;
276
423
  for (const list of this.listTargets) list.removeEventListener("click", this.#onItemClick);
277
- this.#beforeCache.deactivate();
424
+ this.#listWatch.disconnect();
278
425
  this.#gate.disconnected(this, () => this.#teardown());
279
426
  }
280
- /**
281
- * Binds removal and restores client-only previews for every list this controller
282
- * renders into — the one present at connect and any Turbo puts in its place.
283
- * This is the only place the listener is attached, so the pair with
284
- * {@link listTargetDisconnected} keeps it from outliving the element it is on.
285
- */
427
+ #discardInherited() {
428
+ let discarded = false;
429
+ for (const list of this.listTargets) {
430
+ for (const item of Array.from(list.children)) {
431
+ if (!item.hasAttribute(this.#generatedAttribute)) continue;
432
+ item.remove();
433
+ discarded = true;
434
+ }
435
+ }
436
+ for (const zone of this.zoneTargets) {
437
+ this.#dragoverHook.return(zone);
438
+ this.#invalidHook.return(zone);
439
+ }
440
+ if (!discarded) return;
441
+ this.#dragging = false;
442
+ this.#invalid = false;
443
+ this.#syncInput();
444
+ this.dispatch("reconcile", { detail: { files: this.#files } });
445
+ }
286
446
  listTargetConnected(list) {
287
- this.#bindList(list);
447
+ list.addEventListener("click", this.#onItemClick);
448
+ this.#listWatch.observe(list, { childList: true });
449
+ this.#rehome();
288
450
  }
289
- /** Releases only the list target that actually disconnected. */
290
451
  listTargetDisconnected(list) {
291
452
  list.removeEventListener("click", this.#onItemClick);
453
+ if (this.#connected) this.#rehome();
292
454
  }
293
- /** Binds delegated removal once and moves live preview items into the current list. */
294
- #bindList(list) {
295
- list.addEventListener("click", this.#onItemClick);
296
- for (const entry of this.#entries) {
297
- if (!list.contains(entry.item)) list.appendChild(entry.item);
298
- }
455
+ inputTargetConnected() {
456
+ if (this.#connected) this.#syncInput();
457
+ }
458
+ inputTargetDisconnected() {
459
+ if (this.#connected) this.#syncInput();
460
+ }
461
+ zoneTargetConnected() {
462
+ if (this.#connected) this.#paintZone();
463
+ }
464
+ zoneTargetDisconnected(zone) {
465
+ if (this.zoneTargets.includes(zone)) return;
466
+ this.#dragoverHook.return(zone);
467
+ this.#invalidHook.return(zone);
468
+ if (this.#connected) this.#paintZone();
299
469
  }
300
- /** Opens the native file dialog. Bound via `data-action` (trigger click). */
301
470
  openDialog() {
302
471
  if (this.#isDisabled) return;
303
472
  this.inputTarget.click();
304
473
  }
305
- /**
306
- * Adds the files chosen through the native dialog. The input holds only what
307
- * the dialog just returned, so the accepted set is written back over it once
308
- * the batch is validated.
309
- */
310
- onChange() {
311
- const files = this.inputTarget.files;
474
+ onChange(event) {
475
+ const files = (ownerOf(this.inputTargets, event?.target) ?? this.inputTarget).files;
312
476
  if (files) this.#addFiles(files);
313
- else this.#syncInput();
314
- }
315
- /**
316
- * Marks the zone as a drop target and announces the affordance once per drag.
317
- * Bound via `data-action` (dragover). Leaving the default alone is what refuses
318
- * the drop, so a disabled field and a drag an inner dropzone already claimed
319
- * both fall through untouched.
320
- */
477
+ }
321
478
  onDragOver(event) {
322
479
  if (event.defaultPrevented || this.#isDisabled) return;
323
480
  event.preventDefault();
324
481
  if (this.#dragging) return;
325
482
  this.#dragging = true;
326
- if (this.hasZoneTarget) this.zoneTarget.setAttribute(DRAGOVER_ATTRIBUTE, "");
483
+ this.#paintZone();
327
484
  announce(fillTemplate(this.announceDragTextValue, { total: this.#entries.length }));
328
485
  }
329
- /**
330
- * Clears the drag-over flag when the pointer leaves the zone. Bound via
331
- * `data-action` (dragleave). `dragleave` bubbles from every descendant the
332
- * pointer crosses, so the flag only drops when the element being entered is
333
- * outside the zone (or there is none, the pointer having left the window).
334
- */
335
486
  onDragLeave(event) {
336
487
  const next = event.relatedTarget;
337
488
  if (this.hasZoneTarget && next instanceof Node && this.zoneTarget.contains(next)) return;
338
489
  this.#endDrag();
339
490
  }
340
- /** Accepts dropped files, clearing the drag-over state. Bound via `data-action` (drop). */
341
491
  onDrop(event) {
342
492
  if (event.defaultPrevented) return;
343
493
  this.#endDrag();
@@ -345,15 +495,6 @@ var FileDropzoneController = class extends Controller {
345
495
  event.preventDefault();
346
496
  if (event.dataTransfer?.files) this.#addFiles(event.dataTransfer.files);
347
497
  }
348
- /**
349
- * Removes the file whose remove button was clicked. Delegated on the list
350
- * container rather than bound per item via `data-action`, so it works the instant
351
- * an item is appended without waiting on Stimulus to wire a freshly created element.
352
- * Only the item's declared `remove` target counts, so an authored second control
353
- * inside an item does what it says instead of silently discarding the file. The
354
- * button has to belong to a tracked item, which is what makes a list this
355
- * controller no longer renders into inert — its items moved out with it.
356
- */
357
498
  #onItemClick = (event) => {
358
499
  const button = event.target.closest(
359
500
  `button${this.#rows.selector("remove")}`
@@ -361,15 +502,9 @@ var FileDropzoneController = class extends Controller {
361
502
  const index = this.#entries.findIndex((entry) => entry.item.contains(button));
362
503
  if (index !== -1) this.#removeAt(index);
363
504
  };
364
- /**
365
- * Validates each incoming file, renders the accepted ones, and reports the batch.
366
- *
367
- * @stimeoRuntimeOnly The limits decide which files of this one drop are taken, and the texts word
368
- * the announcements it makes.
369
- */
370
505
  #addFiles(files) {
371
506
  this.#rehome();
372
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute(this.#invalidAttribute);
507
+ this.#invalid = false;
373
508
  const rejected = /* @__PURE__ */ new Map();
374
509
  const turnedAway = [];
375
510
  let addedName = "";
@@ -377,7 +512,7 @@ var FileDropzoneController = class extends Controller {
377
512
  for (const file of Array.from(files)) {
378
513
  const reason = this.#validate(file);
379
514
  if (reason !== null) {
380
- if (this.hasZoneTarget) this.zoneTarget.setAttribute(this.#invalidAttribute, "");
515
+ this.#invalid = true;
381
516
  const batch = rejected.get(reason);
382
517
  if (batch) batch.count += 1;
383
518
  else rejected.set(reason, { name: file.name, count: 1 });
@@ -388,6 +523,7 @@ var FileDropzoneController = class extends Controller {
388
523
  if (added === 0) addedName = file.name;
389
524
  added += 1;
390
525
  }
526
+ this.#paintZone();
391
527
  this.#syncInput();
392
528
  if (added > 0) {
393
529
  this.#announce(this.announceAddedTextValue, addedName, added);
@@ -400,14 +536,9 @@ var FileDropzoneController = class extends Controller {
400
536
  this.#announce(this.#rejectText(reason), batch.name, batch.count);
401
537
  }
402
538
  }
403
- /**
404
- * Returns the rejection reason for `file`, or `null` when it is acceptable.
405
- * The file's own defects are decided first, so a full list still tells the user
406
- * which files it would never have taken.
407
- */
408
539
  #validate(file) {
409
540
  if (!this.#matchesAccept(file)) return "type";
410
- if (this.maxSizeValue > 0 && file.size > this.maxSizeValue) return "size";
541
+ if (this.#safeMaxSize > 0 && file.size > this.#safeMaxSize) return "size";
411
542
  if (!this.allowDuplicatesValue && this.#entries.some((entry) => this.#isSame(entry.file, file))) {
412
543
  return "duplicate";
413
544
  }
@@ -415,21 +546,9 @@ var FileDropzoneController = class extends Controller {
415
546
  if (limit > 0 && this.#entries.length >= limit) return "count";
416
547
  return null;
417
548
  }
418
- /**
419
- * Whether two files are the same selection. `File` objects from separate picks
420
- * are never the same reference, so identity is the triple the platform exposes.
421
- */
422
549
  #isSame(a, b) {
423
550
  return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
424
551
  }
425
- /**
426
- * Builds one preview item (name, optional thumbnail, remove button) and reports
427
- * whether it was rendered.
428
- *
429
- * A refused row leaves the addition a no-op — nothing about the selection, the
430
- * native input, the announcements, or the events changes — and the row reports
431
- * why on the console once per connection.
432
- */
433
552
  #appendFile(file) {
434
553
  if (!this.hasListTarget) {
435
554
  this.#rows.report('a "list" target to render into');
@@ -442,6 +561,7 @@ var FileDropzoneController = class extends Controller {
442
561
  const row = this.#rows.instantiate(this.itemTemplateTarget, { name: file.name });
443
562
  if (!row) return false;
444
563
  const item = row.root;
564
+ item.setAttribute(this.#generatedAttribute, "");
445
565
  writeLabel(row.slots.name, file.name);
446
566
  const thumb = row.slots.thumb;
447
567
  let url;
@@ -457,7 +577,6 @@ var FileDropzoneController = class extends Controller {
457
577
  this.#entries.push({ file, item, url });
458
578
  return true;
459
579
  }
460
- /** Removes entry `index`, revokes its preview, and re-homes focus. */
461
580
  #removeAt(index) {
462
581
  const entry = this.#entries[index];
463
582
  if (!entry) return;
@@ -474,7 +593,6 @@ var FileDropzoneController = class extends Controller {
474
593
  (buttons[index] ?? buttons[buttons.length - 1])?.focus();
475
594
  }
476
595
  }
477
- /** Whether `file` satisfies the input's `accept` list (empty accepts all). */
478
596
  #matchesAccept(file) {
479
597
  const accept = this.inputTarget.accept.trim();
480
598
  if (accept === "") return true;
@@ -488,15 +606,9 @@ var FileDropzoneController = class extends Controller {
488
606
  return type === token;
489
607
  });
490
608
  }
491
- /**
492
- * Sends one consumer-worded message to the page's shared announcer. `{name}` is
493
- * the file the message is about, `{count}` how many files it covers, and
494
- * `{total}` how many are selected once the batch has settled.
495
- */
496
609
  #announce(template, name, count) {
497
610
  announce(fillTemplate(template, { name, count, total: this.#entries.length }));
498
611
  }
499
- /** The consumer's wording for one rejection reason. */
500
612
  #rejectText(reason) {
501
613
  switch (reason) {
502
614
  case "type":
@@ -509,11 +621,6 @@ var FileDropzoneController = class extends Controller {
509
621
  return this.announceRejectedCountTextValue;
510
622
  }
511
623
  }
512
- /**
513
- * Mirrors the accepted set onto the native input, so a plain form submit carries
514
- * the dropped files. Skipped where `DataTransfer` cannot be constructed: the
515
- * widget keeps working and the consumer still receives every `File` on `change`.
516
- */
517
624
  #syncInput() {
518
625
  if (!this.hasInputTarget) return;
519
626
  const transfer = this.#newTransfer();
@@ -521,7 +628,6 @@ var FileDropzoneController = class extends Controller {
521
628
  for (const entry of this.#entries) transfer.items.add(entry.file);
522
629
  this.inputTarget.files = transfer.files;
523
630
  }
524
- /** A usable empty `DataTransfer`, or `null` where the platform has none. */
525
631
  #newTransfer() {
526
632
  try {
527
633
  return new DataTransfer();
@@ -529,35 +635,23 @@ var FileDropzoneController = class extends Controller {
529
635
  return null;
530
636
  }
531
637
  }
532
- /**
533
- * Moves surviving preview items back under the current list. A morph that
534
- * empties the list in place leaves the selection with no rendering, and the
535
- * files it holds cannot be rebuilt from the DOM, so the items are re-homed
536
- * rather than forgotten.
537
- */
538
638
  #rehome() {
539
639
  if (!this.hasListTarget) return;
540
640
  for (const entry of this.#entries) {
541
641
  if (!this.listTarget.contains(entry.item)) this.listTarget.appendChild(entry.item);
542
642
  }
543
643
  }
544
- /** Drops the drag-over state, whether the drag ended in a drop or left the zone. */
545
644
  #endDrag() {
546
645
  this.#dragging = false;
547
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute(DRAGOVER_ATTRIBUTE);
548
- }
549
- /**
550
- * Discards the selection and every state attribute this controller wrote, so
551
- * neither a cached snapshot nor a stranded subtree keeps items whose files are
552
- * gone. Silent: `change` means a selection the user changed, and the cache
553
- * rewind reports itself as `reconcile` instead.
554
- */
555
- #rewindForCache() {
556
- const had = this.#entries.length > 0;
557
- this.#reset();
558
- if (had) this.dispatch("reconcile", { detail: { files: this.#files } });
646
+ this.#paintZone();
647
+ }
648
+ #paintZone() {
649
+ if (!this.hasZoneTarget) return;
650
+ this.#dragoverHook.write(this.zoneTarget, this.#dragging ? "" : null);
651
+ this.#invalidHook.write(this.zoneTarget, this.#invalid ? "" : null);
559
652
  }
560
653
  #reset() {
654
+ this.#invalid = false;
561
655
  for (const entry of this.#entries) {
562
656
  if (entry.url) URL.revokeObjectURL(entry.url);
563
657
  entry.item.remove();
@@ -565,23 +659,17 @@ var FileDropzoneController = class extends Controller {
565
659
  this.#entries.length = 0;
566
660
  this.#syncInput();
567
661
  this.#endDrag();
568
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute(this.#invalidAttribute);
569
662
  }
570
- /** Releases the selection once the disconnect is known to be a real detach. */
571
663
  #teardown() {
572
- this.#gate.cancel();
573
664
  this.#reset();
574
665
  }
575
- /** Whether the field refuses input, natively or through an ancestor `fieldset`. */
576
666
  get #isDisabled() {
577
667
  return this.inputTarget.disabled || inheritsFieldsetDisabled(this.inputTarget);
578
668
  }
579
- /** Effective file cap: `maxFiles`, or 1 when the input is single-select. */
580
669
  get #effectiveMaxFiles() {
581
- if (this.maxFilesValue > 0) return this.maxFilesValue;
670
+ if (this.#safeMaxFiles > 0) return this.#safeMaxFiles;
582
671
  return this.inputTarget.multiple ? 0 : 1;
583
672
  }
584
- /** The declared remove button of each rendered item, in selection order. */
585
673
  get #removeButtons() {
586
674
  const buttons = [];
587
675
  for (const entry of this.#entries) {
@@ -593,12 +681,28 @@ var FileDropzoneController = class extends Controller {
593
681
  }
594
682
  return buttons;
595
683
  }
596
- /** The accepted files in selection order. */
597
684
  get #files() {
598
685
  return this.#entries.map((entry) => entry.file);
599
686
  }
687
+ get #safeMaxSize() {
688
+ return this.#numbers.read(
689
+ this,
690
+ "maxSize",
691
+ this.maxSizeValue,
692
+ _FileDropzoneController.values.maxSize.default,
693
+ _FileDropzoneController.valueConstraints.maxSize
694
+ );
695
+ }
696
+ get #safeMaxFiles() {
697
+ return this.#numbers.read(
698
+ this,
699
+ "maxFiles",
700
+ this.maxFilesValue,
701
+ _FileDropzoneController.values.maxFiles.default,
702
+ _FileDropzoneController.valueConstraints.maxFiles
703
+ );
704
+ }
600
705
  };
601
706
 
602
707
  export { FileDropzoneController };
603
- //# sourceMappingURL=file_dropzone_controller.js.map
604
708
  //# sourceMappingURL=file_dropzone_controller.js.map