stimeo-ui 0.5.0 → 0.7.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/dist/controllers/alert_dialog_controller.js +75 -4
  4. data/dist/controllers/aspect_ratio_controller.js +19 -11
  5. data/dist/controllers/avatar_controller.js +237 -40
  6. data/dist/controllers/carousel_controller.js +85 -9
  7. data/dist/controllers/character_counter_controller.js +338 -63
  8. data/dist/controllers/checkbox_controller.js +136 -25
  9. data/dist/controllers/color_picker_controller.js +35 -9
  10. data/dist/controllers/command_palette_controller.js +75 -4
  11. data/dist/controllers/conditional_fields_controller.js +345 -51
  12. data/dist/controllers/confirm_controller.js +75 -4
  13. data/dist/controllers/date_range_picker_controller.js +157 -30
  14. data/dist/controllers/dialog_controller.js +75 -4
  15. data/dist/controllers/direct_upload_controller.js +201 -45
  16. data/dist/controllers/dirty_form_controller.js +192 -29
  17. data/dist/controllers/dismissible_controller.js +83 -18
  18. data/dist/controllers/drawer_controller.js +75 -4
  19. data/dist/controllers/file_dropzone_controller.js +26 -3
  20. data/dist/controllers/focus_controller.js +75 -4
  21. data/dist/controllers/form_field_controller.js +280 -62
  22. data/dist/controllers/form_validation_controller.js +208 -83
  23. data/dist/controllers/idle_controller.js +27 -5
  24. data/dist/controllers/menubar_controller.js +5 -3
  25. data/dist/controllers/multi_select_controller.js +460 -151
  26. data/dist/controllers/number_input_controller.js +317 -51
  27. data/dist/controllers/overflow_menu_controller.js +6 -1
  28. data/dist/controllers/pagination_controller.js +35 -1
  29. data/dist/controllers/password_strength_controller.js +20 -2
  30. data/dist/controllers/persist_controller.js +449 -120
  31. data/dist/controllers/popover_controller.js +77 -4
  32. data/dist/controllers/radio_group_controller.js +540 -56
  33. data/dist/controllers/rating_controller.js +276 -89
  34. data/dist/controllers/resizable_controller.js +33 -0
  35. data/dist/controllers/roving_controller.js +60 -5
  36. data/dist/controllers/scroll_area_controller.js +557 -125
  37. data/dist/controllers/scroll_visibility_controller.js +33 -0
  38. data/dist/controllers/separator_controller.js +354 -38
  39. data/dist/controllers/sidebar_controller.js +83 -10
  40. data/dist/controllers/submit_once_controller.js +399 -121
  41. data/dist/controllers/tags_input_controller.js +356 -120
  42. data/dist/controllers/theme_controller.js +8 -6
  43. data/dist/controllers/time_picker_controller.js +296 -107
  44. data/dist/controllers/toggle_group_controller.js +378 -55
  45. data/dist/controllers/toolbar_controller.js +5 -3
  46. data/dist/controllers/tree_view_controller.js +7 -4
  47. data/dist/index.js +4963 -1581
  48. data/lib/stimeo/ui/version.rb +1 -1
  49. metadata +2 -2
@@ -20,6 +20,39 @@ function isReservedArrowChord(event, allow = []) {
20
20
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
21
  }
22
22
 
23
+ // src/utils/microtask_coalescer.ts
24
+ var MicrotaskCoalescer = class {
25
+ #run;
26
+ #queued = false;
27
+ #active = false;
28
+ #generation = 0;
29
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
30
+ constructor(run) {
31
+ this.#run = run;
32
+ }
33
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
34
+ activate() {
35
+ this.#active = true;
36
+ }
37
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
38
+ cancel() {
39
+ this.#active = false;
40
+ this.#queued = false;
41
+ this.#generation += 1;
42
+ }
43
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
44
+ schedule() {
45
+ if (!this.#active || this.#queued) return;
46
+ this.#queued = true;
47
+ const generation = this.#generation;
48
+ queueMicrotask(() => {
49
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
50
+ this.#queued = false;
51
+ this.#run();
52
+ });
53
+ }
54
+ };
55
+
23
56
  // src/utils/roving_tabindex.ts
24
57
  var RovingTabindex = class {
25
58
  /** Returns the current ordered item elements; called on every operation. */
@@ -42,10 +75,12 @@ var RovingTabindex = class {
42
75
  * "nothing is currently tabbable".
43
76
  *
44
77
  * @param index - Position of the item to make tabbable.
45
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
78
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
79
+ * and `items` to reuse an event-scoped collection snapshot.
46
80
  */
47
- setActive(index, { focus = false } = {}) {
48
- const items = this.#getItems();
81
+ setActive(index, options = {}) {
82
+ const { focus = false } = options;
83
+ const items = options.items ?? this.#getItems();
49
84
  items.forEach((item, i) => {
50
85
  item.tabIndex = i === index ? 0 : -1;
51
86
  });
@@ -120,9 +155,14 @@ var CarouselController = class extends Controller {
120
155
  "resume",
121
156
  "togglePlay"
122
157
  ];
123
- static events = ["change", "pause", "play"];
158
+ static events = ["change", "pause", "play", "reconcile"];
124
159
  #roving = new RovingTabindex(() => this.pickerTargets);
125
- /** Gates the target callback so it does not repaint once per authored picker on mount. */
160
+ #reconcileTargets = new MicrotaskCoalescer(() => this.#reconcileTargetSet());
161
+ /**
162
+ * Whether `connect()` has run. Scheduling is already inert outside that window
163
+ * ({@link MicrotaskCoalescer}), so this only gates the Tab stop a picker
164
+ * present at mount authored for itself.
165
+ */
126
166
  #connected = false;
127
167
  #intervals = new SafeInterval();
128
168
  /** Index of the visible slide. */
@@ -148,6 +188,7 @@ var CarouselController = class extends Controller {
148
188
  this.#render({ focus: false });
149
189
  this.#syncTimer();
150
190
  this.#connected = true;
191
+ this.#reconcileTargets.activate();
151
192
  }
152
193
  /**
153
194
  * Re-establishes the single selected picker when one is added after connect.
@@ -158,9 +199,22 @@ var CarouselController = class extends Controller {
158
199
  * repaint re-derives every picker from `#index`, so a late arrival never steals
159
200
  * the selection.
160
201
  */
161
- pickerTargetConnected() {
202
+ pickerTargetConnected(picker) {
162
203
  if (!this.#connected) return;
163
- this.#render({ focus: false });
204
+ picker.tabIndex = -1;
205
+ this.#reconcileTargets.schedule();
206
+ }
207
+ /** Repairs selection and roving after a picker leaves a retained carousel. */
208
+ pickerTargetDisconnected() {
209
+ this.#reconcileTargets.schedule();
210
+ }
211
+ /** Reconciles a slide added in the same DOM batch as its picker. */
212
+ slideTargetConnected() {
213
+ this.#reconcileTargets.schedule();
214
+ }
215
+ /** Re-clamps the active index after a slide is removed. */
216
+ slideTargetDisconnected() {
217
+ this.#reconcileTargets.schedule();
164
218
  }
165
219
  /**
166
220
  * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
@@ -178,6 +232,7 @@ var CarouselController = class extends Controller {
178
232
  /** Clears the autoplay interval so it never fires after teardown. */
179
233
  disconnect() {
180
234
  this.#connected = false;
235
+ this.#reconcileTargets.cancel();
181
236
  this.#intervals.clearAll();
182
237
  this.#timerId = null;
183
238
  }
@@ -282,10 +337,31 @@ var CarouselController = class extends Controller {
282
337
  slide.setAttribute("data-state", active ? "active" : "inactive");
283
338
  slide.hidden = !active;
284
339
  });
340
+ const pickerIndex = Math.min(this.#index, this.pickerTargets.length - 1);
285
341
  this.pickerTargets.forEach((picker, i) => {
286
- picker.setAttribute("aria-selected", i === this.#index ? "true" : "false");
342
+ picker.setAttribute("aria-selected", i === pickerIndex ? "true" : "false");
287
343
  });
288
- this.#roving.setActive(this.#index, { focus });
344
+ this.#roving.setActive(pickerIndex, { focus });
345
+ }
346
+ /** Keeps the live active slide when possible and otherwise selects the nearest survivor. */
347
+ #reconcileTargetSet() {
348
+ const activeSlide = this.slideTargets.findIndex(
349
+ (slide) => slide.getAttribute("data-state") === "active"
350
+ );
351
+ const selectedPicker = this.pickerTargets.findIndex(
352
+ (picker) => picker.getAttribute("aria-selected") === "true"
353
+ );
354
+ const lastSlide = this.slideTargets.length - 1;
355
+ const candidate = activeSlide !== -1 ? activeSlide : selectedPicker !== -1 ? selectedPicker : this.#index;
356
+ const previous = this.#index;
357
+ this.#index = lastSlide < 0 ? 0 : Math.min(lastSlide, Math.max(0, candidate));
358
+ this.#render({ focus: false });
359
+ this.#syncTimer();
360
+ if (this.#index !== previous) {
361
+ this.dispatch("reconcile", {
362
+ detail: { index: this.#index, total: this.slideTargets.length }
363
+ });
364
+ }
289
365
  }
290
366
  /**
291
367
  * Drives the autoplay interval toward the desired state. Autoplay should run
@@ -2,6 +2,93 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/character_counter_controller.ts
4
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
+
5
92
  // src/utils/composition_tracker.ts
6
93
  var CompositionTracker = class {
7
94
  #observedTargets = /* @__PURE__ */ new Set();
@@ -49,6 +136,39 @@ var CompositionTracker = class {
49
136
  };
50
137
  };
51
138
 
139
+ // src/utils/microtask_coalescer.ts
140
+ var MicrotaskCoalescer = class {
141
+ #run;
142
+ #queued = false;
143
+ #active = false;
144
+ #generation = 0;
145
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
146
+ constructor(run) {
147
+ this.#run = run;
148
+ }
149
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
150
+ activate() {
151
+ this.#active = true;
152
+ }
153
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
154
+ cancel() {
155
+ this.#active = false;
156
+ this.#queued = false;
157
+ this.#generation += 1;
158
+ }
159
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
160
+ schedule() {
161
+ if (!this.#active || this.#queued) return;
162
+ this.#queued = true;
163
+ const generation = this.#generation;
164
+ queueMicrotask(() => {
165
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
166
+ this.#queued = false;
167
+ this.#run();
168
+ });
169
+ }
170
+ };
171
+
52
172
  // src/utils/safe_timeout.ts
53
173
  var TimerRegistry = class {
54
174
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -103,110 +223,265 @@ var SafeTimeout = class extends TimerRegistry {
103
223
  };
104
224
 
105
225
  // src/controllers/character_counter_controller.ts
106
- var ORIGINAL_INVALID = "data-character-counter-original-invalid";
107
226
  var CharacterCounterController = class _CharacterCounterController extends Controller {
108
227
  static targets = ["input", "output"];
109
228
  static values = {
110
229
  max: { type: Number, default: 0 },
111
230
  warnAt: { type: Number, default: 0 },
112
- mode: { type: String, default: "remaining" }
231
+ mode: { type: String, default: "remaining" },
232
+ announceText: { type: String, default: "" }
113
233
  };
114
- static events = ["change"];
115
- /** Delay (ms) before the live-region count is written, to throttle SR flooding. */
234
+ static events = ["change", "reconcile"];
235
+ /** Delay (ms) before one settled count is sent to the shared announcer. */
116
236
  static #announceDelay = 200;
117
237
  #timeouts = new SafeTimeout();
238
+ #ariaInvalid = new AttributeLease("aria-invalid");
239
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
240
+ #repaint = new MicrotaskCoalescer(() => this.#reconcile(true));
241
+ #composition = new CompositionTracker({
242
+ onEnd: (event) => this.#commitFrom(event.currentTarget)
243
+ });
118
244
  #announceId = null;
119
- /** Owns IME lifecycle state and applies the confirmed count once. */
120
- #composition = new CompositionTracker({ onEnd: () => this.#update() });
245
+ #boundField = null;
246
+ #lastLength = null;
247
+ #lastDetail = null;
248
+ #reflectedOver = null;
121
249
  #onInput = (event) => {
250
+ const field = this.#boundField;
251
+ if (!field || event.currentTarget !== field || this.#field !== field) return;
122
252
  if (this.#composition.isComposing(event)) return;
123
- this.#update();
253
+ this.#commit(field);
124
254
  };
255
+ /** Reflects the current DOM state and opens the mutation-reconciliation window. */
125
256
  connect() {
126
- const field = this.#field;
127
- if (!field) return;
128
- field.addEventListener("input", this.#onInput);
129
- this.#composition.observe(field);
130
- this.#update({ announce: false });
257
+ this.#repaint.activate();
258
+ this.#beforeCache.activate();
259
+ this.#reconcile(false);
131
260
  }
261
+ /** Releases listeners, timers, borrowed ARIA, and controller-owned state hooks. */
132
262
  disconnect() {
133
- const field = this.#field;
134
- field?.removeEventListener("input", this.#onInput);
263
+ this.#repaint.cancel();
264
+ this.#beforeCache.deactivate();
265
+ this.#cancelAnnouncement();
266
+ this.#bindField(null);
135
267
  this.#composition.disconnect();
136
- this.#timeouts.clearAll();
137
- this.#announceId = null;
268
+ this.#clearStateHooks();
269
+ this.#lastLength = null;
270
+ this.#lastDetail = null;
271
+ }
272
+ /** Rebinds and repaints after an input target is added or replaced at runtime. */
273
+ inputTargetConnected() {
274
+ this.#repaint.schedule();
275
+ }
276
+ /** Releases and repaints after an input target is removed or replaced at runtime. */
277
+ inputTargetDisconnected() {
278
+ this.#repaint.schedule();
279
+ }
280
+ /** Initializes a display target inserted after the controller connected. */
281
+ outputTargetConnected() {
282
+ this.#repaint.schedule();
283
+ }
284
+ /** Reconciles the optional display target set after a removal or replacement. */
285
+ outputTargetDisconnected() {
286
+ this.#repaint.schedule();
287
+ }
288
+ /** Repaints when application code or a Turbo morph changes `max`. */
289
+ maxValueChanged() {
290
+ this.#repaint.schedule();
291
+ }
292
+ /** Repaints when application code or a Turbo morph changes `warnAt`. */
293
+ warnAtValueChanged() {
294
+ this.#repaint.schedule();
295
+ }
296
+ /** Repaints when application code or a Turbo morph changes `mode`. */
297
+ modeValueChanged() {
298
+ this.#repaint.schedule();
299
+ }
300
+ /** Cancels a pending old message when its consumer-authored template changes. */
301
+ announceTextValueChanged() {
302
+ this.#repaint.schedule();
303
+ }
304
+ /** Rebinds one mutation batch and reports a changed controller-derived state. */
305
+ #reconcile(report) {
306
+ const previous = this.#lastDetail;
307
+ const owedField = this.#announceId === null ? null : this.#boundField;
308
+ this.#cancelAnnouncement();
309
+ this.#bindField(this.#field);
310
+ if (this.#composition.isComposing()) return;
311
+ const field = this.#boundField;
312
+ if (!field) {
313
+ this.#renderEmpty();
314
+ this.#lastLength = null;
315
+ this.#lastDetail = null;
316
+ return;
317
+ }
318
+ const reading = this.#render(field);
319
+ const detail = this.#detail(reading);
320
+ this.#lastLength = reading.length;
321
+ this.#lastDetail = detail;
322
+ if (owedField === field) this.#scheduleAnnouncement(reading);
323
+ if (report && previous && this.#detailsDiffer(previous, detail)) {
324
+ this.dispatch("reconcile", { detail });
325
+ }
326
+ }
327
+ /** Commits a composition only when it came from the field still owned here. */
328
+ #commitFrom(target) {
329
+ const field = this.#boundField;
330
+ if (!field || target !== field || this.#field !== field) return;
331
+ this.#commit(field);
332
+ }
333
+ /** Reflects one confirmed user input and reports only a real length transition. */
334
+ #commit(field) {
335
+ const reading = this.#render(field);
336
+ const previousLength = this.#lastLength;
337
+ const previousDetail = this.#lastDetail;
338
+ const detail = this.#detail(reading);
339
+ this.#lastLength = reading.length;
340
+ this.#lastDetail = detail;
341
+ if (previousLength === reading.length) {
342
+ if (previousDetail && this.#detailsDiffer(previousDetail, detail)) {
343
+ this.dispatch("reconcile", { detail });
344
+ }
345
+ return;
346
+ }
347
+ this.dispatch("change", { detail });
348
+ this.#scheduleAnnouncement(reading);
349
+ }
350
+ /** Selects the public event state from the richer internal reading. */
351
+ #detail(reading) {
352
+ return {
353
+ length: reading.length,
354
+ remaining: reading.remaining,
355
+ over: reading.over
356
+ };
357
+ }
358
+ /** Compares exactly the state carried by `change` and `reconcile`. */
359
+ #detailsDiffer(left, right) {
360
+ return left.length !== right.length || left.remaining !== right.remaining || left.over !== right.over;
138
361
  }
139
362
  /**
140
- * Recomputes length-derived state. Non-text state (data hooks, `aria-invalid`)
141
- * and the `change` event apply immediately; the live-region count text is
142
- * debounced unless `announce` is `false` (initial render).
363
+ * Synchronizes visible count, state hooks, and temporary validation ARIA.
364
+ *
365
+ * @stimeoRenderRoot
143
366
  */
144
- #update(options = {}) {
145
- const field = this.#field;
146
- if (!field) return;
367
+ #render(field) {
147
368
  const length = field.value.length;
148
- const hasLimit = this.maxValue > 0;
149
- const remaining = hasLimit ? this.maxValue - length : null;
150
- const over = hasLimit && length > this.maxValue;
151
- const near = hasLimit && this.warnAtValue > 0 && !over && remaining !== null && remaining <= this.warnAtValue;
369
+ const max = this.#normalizeCount(this.maxValue);
370
+ const warnAt = this.#normalizeCount(this.warnAtValue);
371
+ const remaining = max > 0 ? max - length : null;
372
+ const over = remaining !== null && remaining < 0;
373
+ const near = remaining !== null && warnAt > 0 && !over && remaining <= warnAt;
152
374
  this.#toggle("data-over-limit", over);
153
375
  this.#toggle("data-near-limit", near);
154
- if (hasLimit && over) {
155
- if (!field.hasAttribute(ORIGINAL_INVALID)) {
156
- field.setAttribute(ORIGINAL_INVALID, field.getAttribute("aria-invalid") ?? "");
157
- }
158
- field.setAttribute("aria-invalid", "true");
159
- } else if (field.hasAttribute(ORIGINAL_INVALID)) {
160
- const original = field.getAttribute(ORIGINAL_INVALID);
161
- if (original) {
162
- field.setAttribute("aria-invalid", original);
163
- } else {
164
- field.removeAttribute("aria-invalid");
165
- }
166
- field.removeAttribute(ORIGINAL_INVALID);
167
- }
168
- const text = this.#format(length, remaining);
169
- if (options.announce === false) {
170
- this.#writeOutput(text);
171
- return;
376
+ this.#reflectInvalid(field, over);
377
+ const text = this.#format(length, remaining, max);
378
+ this.#writeOutput(text);
379
+ return { length, remaining, over, max, text };
380
+ }
381
+ /** Clears derived output when the declarative input set has no usable field. */
382
+ #renderEmpty() {
383
+ this.#ariaInvalid.returnAll();
384
+ this.#reflectedOver = null;
385
+ this.#clearStateHooks();
386
+ this.#writeOutput("");
387
+ }
388
+ /** Replaces the observed field symmetrically, returning state from the old one. */
389
+ #bindField(field) {
390
+ if (field === this.#boundField) return;
391
+ const previous = this.#boundField;
392
+ if (previous) {
393
+ previous.removeEventListener("input", this.#onInput);
394
+ this.#composition.unobserve(previous);
395
+ this.#ariaInvalid.return(previous);
172
396
  }
173
- this.dispatch("change", { detail: { length, remaining, over } });
174
- if (this.#announceId !== null) this.#timeouts.clear(this.#announceId);
175
- this.#announceId = this.#timeouts.set(() => {
176
- this.#writeOutput(text);
177
- this.#announceId = null;
178
- }, _CharacterCounterController.#announceDelay);
397
+ this.#boundField = field;
398
+ this.#lastLength = null;
399
+ this.#reflectedOver = null;
400
+ if (!field) return;
401
+ field.addEventListener("input", this.#onInput);
402
+ this.#composition.observe(field);
403
+ }
404
+ /** Leases `aria-invalid` only on the edge into over-limit, then returns it. */
405
+ #reflectInvalid(field, over) {
406
+ if (this.#reflectedOver === over) return;
407
+ this.#reflectedOver = over;
408
+ if (over) this.#ariaInvalid.write(field, "true");
409
+ else this.#ariaInvalid.return(field);
179
410
  }
180
- /** Builds the count text for the active `mode`. */
181
- #format(length, remaining) {
411
+ /** Builds the visible count for the normalized display mode. */
412
+ #format(length, remaining, max) {
182
413
  if (remaining === null) return String(length);
183
- switch (this.modeValue) {
414
+ switch (this.#mode) {
184
415
  case "used":
185
416
  return String(length);
186
417
  case "both":
187
- return `${length}/${this.maxValue}`;
418
+ return `${length}/${max}`;
188
419
  default:
189
420
  return String(remaining);
190
421
  }
191
422
  }
423
+ /** Debounces one i18n-neutral message into the shared polite announcer. */
424
+ #scheduleAnnouncement(reading) {
425
+ this.#cancelAnnouncement();
426
+ const message = fillTemplate(this.announceTextValue, {
427
+ count: reading.text,
428
+ length: reading.length,
429
+ remaining: reading.remaining ?? "",
430
+ max: reading.max,
431
+ over: String(reading.over)
432
+ });
433
+ if (message.trim().length === 0) return;
434
+ this.#announceId = this.#timeouts.set(() => {
435
+ announce(message);
436
+ this.#announceId = null;
437
+ }, _CharacterCounterController.#announceDelay);
438
+ }
439
+ /** Cancels the one outstanding announcement without touching visible output. */
440
+ #cancelAnnouncement() {
441
+ if (this.#announceId !== null) this.#timeouts.clear(this.#announceId);
442
+ this.#announceId = null;
443
+ }
444
+ /** Writes the optional display only when its text actually changed. */
192
445
  #writeOutput(text) {
193
- if (this.hasOutputTarget) this.outputTarget.textContent = text;
446
+ if (!this.hasOutputTarget || this.outputTarget.textContent === text) return;
447
+ this.outputTarget.textContent = text;
194
448
  }
195
- /** Sets a presence-style boolean data hook (attribute present when `on`). */
449
+ /** Reflects a presence-style state hook without redundant attribute writes. */
196
450
  #toggle(name, on) {
197
451
  if (on) {
198
- this.element.setAttribute(name, "true");
199
- } else {
452
+ if (this.element.getAttribute(name) !== "true") this.element.setAttribute(name, "true");
453
+ } else if (this.element.hasAttribute(name)) {
200
454
  this.element.removeAttribute(name);
201
455
  }
202
456
  }
203
- /** The watched field: the `input` target, or the element itself when it is one. */
457
+ /** Removes both state hooks owned by this controller. */
458
+ #clearStateHooks() {
459
+ this.#toggle("data-over-limit", false);
460
+ this.#toggle("data-near-limit", false);
461
+ }
462
+ /** Returns borrowed ARIA and transient hooks before Turbo snapshots the page. */
463
+ #rewindForCache() {
464
+ this.#cancelAnnouncement();
465
+ this.#ariaInvalid.returnAll();
466
+ this.#reflectedOver = null;
467
+ this.#clearStateHooks();
468
+ }
469
+ /** Converts a public count Value into a finite non-negative integer. */
470
+ #normalizeCount(raw) {
471
+ if (!Number.isFinite(raw)) return 0;
472
+ return Math.max(0, Math.trunc(raw));
473
+ }
474
+ /** Falls back to `remaining` when an authored display mode is unknown. */
475
+ get #mode() {
476
+ if (this.modeValue === "used" || this.modeValue === "both") return this.modeValue;
477
+ return "remaining";
478
+ }
479
+ /** The watched field: the first target, or a directly controlled form field. */
204
480
  get #field() {
205
481
  if (this.hasInputTarget) return this.inputTarget;
206
- const el = this.element;
207
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
208
- return el;
209
- }
482
+ const element = this.element;
483
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)
484
+ return element;
210
485
  return null;
211
486
  }
212
487
  };