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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +178 -0
- data/dist/controllers/alert_dialog_controller.js +75 -4
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +237 -40
- data/dist/controllers/carousel_controller.js +85 -9
- data/dist/controllers/character_counter_controller.js +338 -63
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/color_picker_controller.js +35 -9
- data/dist/controllers/command_palette_controller.js +75 -4
- data/dist/controllers/conditional_fields_controller.js +345 -51
- data/dist/controllers/confirm_controller.js +75 -4
- data/dist/controllers/date_range_picker_controller.js +157 -30
- data/dist/controllers/dialog_controller.js +75 -4
- data/dist/controllers/direct_upload_controller.js +201 -45
- data/dist/controllers/dirty_form_controller.js +192 -29
- data/dist/controllers/dismissible_controller.js +83 -18
- data/dist/controllers/drawer_controller.js +75 -4
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/focus_controller.js +75 -4
- data/dist/controllers/form_field_controller.js +280 -62
- data/dist/controllers/form_validation_controller.js +208 -83
- data/dist/controllers/idle_controller.js +27 -5
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/number_input_controller.js +317 -51
- data/dist/controllers/overflow_menu_controller.js +6 -1
- data/dist/controllers/pagination_controller.js +35 -1
- data/dist/controllers/password_strength_controller.js +20 -2
- data/dist/controllers/persist_controller.js +449 -120
- data/dist/controllers/popover_controller.js +77 -4
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/rating_controller.js +276 -89
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +557 -125
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/separator_controller.js +354 -38
- data/dist/controllers/sidebar_controller.js +83 -10
- data/dist/controllers/submit_once_controller.js +399 -121
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/theme_controller.js +8 -6
- data/dist/controllers/time_picker_controller.js +296 -107
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +7 -4
- data/dist/index.js +4963 -1581
- data/lib/stimeo/ui/version.rb +1 -1
- 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,
|
|
48
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
342
|
+
picker.setAttribute("aria-selected", i === pickerIndex ? "true" : "false");
|
|
287
343
|
});
|
|
288
|
-
this.#roving.setActive(
|
|
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
|
|
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
|
-
|
|
120
|
-
#
|
|
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.#
|
|
253
|
+
this.#commit(field);
|
|
124
254
|
};
|
|
255
|
+
/** Reflects the current DOM state and opens the mutation-reconciliation window. */
|
|
125
256
|
connect() {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
134
|
-
|
|
263
|
+
this.#repaint.cancel();
|
|
264
|
+
this.#beforeCache.deactivate();
|
|
265
|
+
this.#cancelAnnouncement();
|
|
266
|
+
this.#bindField(null);
|
|
135
267
|
this.#composition.disconnect();
|
|
136
|
-
this.#
|
|
137
|
-
this.#
|
|
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
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
363
|
+
* Synchronizes visible count, state hooks, and temporary validation ARIA.
|
|
364
|
+
*
|
|
365
|
+
* @stimeoRenderRoot
|
|
143
366
|
*/
|
|
144
|
-
#
|
|
145
|
-
const field = this.#field;
|
|
146
|
-
if (!field) return;
|
|
367
|
+
#render(field) {
|
|
147
368
|
const length = field.value.length;
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
const
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
this.#
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
|
414
|
+
switch (this.#mode) {
|
|
184
415
|
case "used":
|
|
185
416
|
return String(length);
|
|
186
417
|
case "both":
|
|
187
|
-
return `${length}/${
|
|
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
|
|
446
|
+
if (!this.hasOutputTarget || this.outputTarget.textContent === text) return;
|
|
447
|
+
this.outputTarget.textContent = text;
|
|
194
448
|
}
|
|
195
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
207
|
-
if (
|
|
208
|
-
return
|
|
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
|
};
|