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
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/form_field_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
+
5
16
  // src/utils/aria_ids.ts
6
17
  var counter = 0;
7
18
  function uniqueId(prefix = "stimeo") {
@@ -19,7 +30,111 @@ function ensureId(element, prefix = "stimeo") {
19
30
  return id;
20
31
  }
21
32
 
33
+ // src/utils/attribute_lease.ts
34
+ var AttributeLease = class {
35
+ #attribute;
36
+ #records = /* @__PURE__ */ new Map();
37
+ /** @param attribute - The attribute whose temporary values this lease owns. */
38
+ constructor(attribute) {
39
+ this.#attribute = attribute;
40
+ }
41
+ /** Writes or removes the leased attribute while preserving its authored value. */
42
+ write(element, value) {
43
+ const existing = this.#records.get(element);
44
+ if (existing) {
45
+ existing.written = value;
46
+ } else {
47
+ this.#records.set(element, {
48
+ original: element.getAttribute(this.#attribute),
49
+ written: value
50
+ });
51
+ }
52
+ this.#reflect(element, value);
53
+ }
54
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
55
+ return(element) {
56
+ const record = this.#records.get(element);
57
+ if (!record) return;
58
+ this.#records.delete(element);
59
+ const stillOwned = element.getAttribute(this.#attribute) === record.written;
60
+ if (stillOwned) this.#reflect(element, record.original);
61
+ }
62
+ /** Reflects only a real value transition, avoiding self-triggered mutation work. */
63
+ #reflect(element, value) {
64
+ if (element.getAttribute(this.#attribute) === value) return;
65
+ if (value === null) element.removeAttribute(this.#attribute);
66
+ else element.setAttribute(this.#attribute, value);
67
+ }
68
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
69
+ returnAll() {
70
+ for (const element of Array.from(this.#records.keys())) this.return(element);
71
+ }
72
+ };
73
+
74
+ // src/utils/before_cache_reset.ts
75
+ var BeforeCacheReset = class _BeforeCacheReset {
76
+ /** Every subscribed instance, iterated by the one shared document listener. */
77
+ static #subscribers = /* @__PURE__ */ new Set();
78
+ /** The shared listener; installed while at least one instance is subscribed. */
79
+ static #onBeforeCache = () => {
80
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
81
+ };
82
+ #rewind;
83
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
84
+ constructor(rewind) {
85
+ this.#rewind = rewind;
86
+ }
87
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
88
+ activate() {
89
+ const first = _BeforeCacheReset.#subscribers.size === 0;
90
+ _BeforeCacheReset.#subscribers.add(this);
91
+ if (first) {
92
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
93
+ }
94
+ }
95
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
96
+ deactivate() {
97
+ _BeforeCacheReset.#subscribers.delete(this);
98
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
99
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
100
+ }
101
+ };
102
+
103
+ // src/utils/microtask_coalescer.ts
104
+ var MicrotaskCoalescer = class {
105
+ #run;
106
+ #queued = false;
107
+ #active = false;
108
+ #generation = 0;
109
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
110
+ constructor(run) {
111
+ this.#run = run;
112
+ }
113
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
114
+ activate() {
115
+ this.#active = true;
116
+ }
117
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
118
+ cancel() {
119
+ this.#active = false;
120
+ this.#queued = false;
121
+ this.#generation += 1;
122
+ }
123
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
124
+ schedule() {
125
+ if (!this.#active || this.#queued) return;
126
+ this.#queued = true;
127
+ const generation = this.#generation;
128
+ queueMicrotask(() => {
129
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
130
+ this.#queued = false;
131
+ this.#run();
132
+ });
133
+ }
134
+ };
135
+
22
136
  // src/controllers/form_field_controller.ts
137
+ var OBSERVED_ATTRIBUTES = ["hidden", "id"];
23
138
  var FormFieldController = class _FormFieldController extends Controller {
24
139
  static targets = ["control", "description", "error"];
25
140
  static values = {
@@ -29,31 +144,85 @@ var FormFieldController = class _FormFieldController extends Controller {
29
144
  static events = ["validate"];
30
145
  /** Root attribute (CSS hook) reflecting the invalid state. */
31
146
  static #INVALID_ATTR = "data-stimeo--form-field-invalid";
32
- /**
33
- * `aria-describedby` tokens the consumer set on the control that the
34
- * controller does not own. Captured once so composition never clobbers them.
35
- */
147
+ /** Collapses one target/morph batch into one silent ARIA reconciliation. */
148
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
149
+ /** ARIA ownership is scoped to the current singular control target. */
150
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
151
+ #ariaDescribedBy = new AttributeLease("aria-describedby");
152
+ #ariaErrorMessage = new AttributeLease("aria-errormessage");
153
+ #ariaInvalid = new AttributeLease("aria-invalid");
154
+ /** Watches retained target ids, error visibility, and error content. */
155
+ #observer = new MutationObserver((records) => {
156
+ if (records.some((record) => this.#isRelevantMutation(record))) {
157
+ this.#reconcile.schedule();
158
+ }
159
+ });
160
+ #activeControl = null;
36
161
  #baseDescribedBy = [];
37
- /** Wires ids, captures consumer tokens, and reflects any initial error state. */
162
+ #explicitInvalid = false;
163
+ #initialized = false;
164
+ /** Wires the initial graph and starts retained-target reconciliation. */
38
165
  connect() {
39
- for (const description of this.descriptionTargets) {
40
- ensureId(description, "stimeo--form-field-desc");
41
- }
42
- for (const error of this.errorTargets) {
43
- ensureId(error, "stimeo--form-field-error");
166
+ this.#reconcile.activate();
167
+ this.#ensureAssociationIds();
168
+ this.#beforeCache.activate();
169
+ if (!this.#initialized) {
170
+ this.#explicitInvalid = this.element.hasAttribute(_FormFieldController.#INVALID_ATTR) && this.#shownErrors().length === 0;
171
+ this.#initialized = true;
44
172
  }
45
- this.#baseDescribedBy = this.#externalDescribedByTokens();
46
- this.#reflect();
173
+ this.#reconcileDom();
174
+ this.#observer.observe(this.element, {
175
+ attributes: true,
176
+ attributeFilter: OBSERVED_ATTRIBUTES,
177
+ characterData: true,
178
+ childList: true,
179
+ subtree: true
180
+ });
181
+ }
182
+ /** Releases observers, queued work, and ARIA borrowed on the current control. */
183
+ disconnect() {
184
+ this.#reconcile.cancel();
185
+ this.#observer.disconnect();
186
+ this.#beforeCache.deactivate();
187
+ this.#activeControl && this.#releaseControl(this.#activeControl);
188
+ }
189
+ /** Reconciles a control inserted or replaced at runtime. */
190
+ controlTargetConnected() {
191
+ this.#reconcile.schedule();
192
+ }
193
+ /** Schedules restoration of authored ARIA when a control leaves this field. */
194
+ controlTargetDisconnected() {
195
+ this.#reconcile.schedule();
196
+ }
197
+ /** Reconciles a description inserted or replaced at runtime. */
198
+ descriptionTargetConnected() {
199
+ this.#reconcile.schedule();
200
+ }
201
+ /** Removes a departed description from the control's association graph. */
202
+ descriptionTargetDisconnected() {
203
+ this.#reconcile.schedule();
204
+ }
205
+ /** Reconciles an error region inserted or replaced at runtime. */
206
+ errorTargetConnected() {
207
+ this.#reconcile.schedule();
208
+ }
209
+ /** Removes a departed error from invalid state and the association graph. */
210
+ errorTargetDisconnected() {
211
+ this.#reconcile.schedule();
47
212
  }
48
213
  /**
49
214
  * Marks the field invalid and shows the error message. Bound via `data-action`
50
215
  * (`#setError`) or callable directly.
51
216
  *
217
+ * A non-empty shown message is announced exactly once through the shared
218
+ * assertive announcer. Initial/server reconciliation is deliberately silent.
219
+ *
52
220
  * @param arg - Either the message string, or the action event whose
53
221
  * `data-stimeo--form-field-message-param` supplies it. When no message is
54
222
  * resolvable, any already-populated error targets are simply (re)shown.
223
+ * @param options - Programmatic overrides. Stimulus actions use the Values.
55
224
  */
56
- setError(arg) {
225
+ setError(arg, options = {}) {
57
226
  const message = this.#resolveMessage(arg);
58
227
  if (message !== null && this.hasErrorTarget) {
59
228
  this.errorTargets[0]?.replaceChildren(document.createTextNode(message));
@@ -61,70 +230,86 @@ var FormFieldController = class _FormFieldController extends Controller {
61
230
  for (const error of this.errorTargets) {
62
231
  error.hidden = (error.textContent ?? "").trim() === "";
63
232
  }
64
- this.#reflect(true);
65
- this.dispatch("validate", { detail: { valid: false, message: this.#shownMessage() } });
66
- if (this.focusOnErrorValue && this.hasControlTarget) {
67
- this.controlTarget.focus();
68
- }
233
+ this.#explicitInvalid = true;
234
+ this.#reconcileDom();
235
+ const shownMessage = this.#shownMessage();
236
+ const detail = { valid: false, message: shownMessage };
237
+ this.dispatch("validate", { detail });
238
+ announce(shownMessage, { assertive: true });
239
+ if (options.focus ?? this.focusOnErrorValue) this.#activeControl?.focus();
69
240
  }
70
241
  /**
71
242
  * Clears the error: empties and hides every error target and marks the field
72
243
  * valid. Bound via `data-action` (`#clearError`) or callable directly.
244
+ *
245
+ * Clearing is silent: the visual/ARIA state and validation event are sufficient,
246
+ * while success wording remains an explicit consumer announcement.
73
247
  */
74
248
  clearError() {
75
249
  for (const error of this.errorTargets) {
76
250
  error.replaceChildren();
77
251
  error.hidden = true;
78
252
  }
79
- this.#reflect();
80
- this.dispatch("validate", { detail: { valid: true, message: "" } });
253
+ this.#explicitInvalid = false;
254
+ this.#reconcileDom();
255
+ const detail = { valid: true, message: "" };
256
+ this.dispatch("validate", { detail });
81
257
  }
82
258
  /**
83
- * Synchronizes the control's ARIA wiring and the root CSS hook from the current
84
- * error targets. Idempotent, so it is safe to call on connect and after any
85
- * change (and survives Turbo morphing).
86
- *
87
- * @param force - When `true`, the field is marked invalid regardless of whether
88
- * a (visible, non-empty) error region exists. {@link setError} passes this so
89
- * the invalid state holds even with no error target; derivation from the DOM
90
- * (connect / {@link clearError}) leaves it `false`.
259
+ * Rebuilds ids and derived ARIA from the settled target graph without reporting
260
+ * a user validation action.
91
261
  */
92
- #reflect(force = false) {
262
+ #reconcileDom() {
263
+ this.#ensureAssociationIds();
264
+ this.#adoptCurrentControl();
93
265
  const shown = this.#shownErrors();
94
- const invalid = force || shown.length > 0;
95
- if (invalid) {
96
- this.element.setAttribute(_FormFieldController.#INVALID_ATTR, "");
97
- } else {
98
- this.element.removeAttribute(_FormFieldController.#INVALID_ATTR);
266
+ const invalid = this.#explicitInvalid || shown.length > 0;
267
+ this.element.toggleAttribute(_FormFieldController.#INVALID_ATTR, invalid);
268
+ const control = this.#activeControl;
269
+ if (!control) return;
270
+ this.#ariaInvalid.write(control, invalid ? "true" : "false");
271
+ const primaryErrorId = shown[0]?.id ?? null;
272
+ this.#ariaErrorMessage.write(control, primaryErrorId);
273
+ const associationIds = this.#orderedElements([...this.descriptionTargets, ...shown]).map(
274
+ (element) => element.id
275
+ );
276
+ const describedBy = this.#uniqueTokens([...this.#baseDescribedBy, ...associationIds]);
277
+ this.#ariaDescribedBy.write(control, describedBy.length > 0 ? describedBy.join(" ") : null);
278
+ }
279
+ /** Assigns stable ids before either capture or reflection uses them. */
280
+ #ensureAssociationIds() {
281
+ for (const description of this.descriptionTargets) {
282
+ ensureId(description, "stimeo--form-field-desc");
99
283
  }
100
- if (!this.hasControlTarget) return;
101
- const control = this.controlTarget;
102
- control.setAttribute("aria-invalid", invalid ? "true" : "false");
103
- const errorIds = shown.map((error) => error.id);
104
- const primaryErrorId = errorIds[0];
105
- if (primaryErrorId) {
106
- control.setAttribute("aria-errormessage", primaryErrorId);
107
- } else {
108
- control.removeAttribute("aria-errormessage");
284
+ for (const error of this.errorTargets) {
285
+ ensureId(error, "stimeo--form-field-error");
109
286
  }
110
- const describedBy = [
111
- ...this.#baseDescribedBy,
112
- ...this.descriptionTargets.map((description) => description.id),
113
- ...errorIds
114
- ];
115
- if (describedBy.length > 0) {
116
- control.setAttribute("aria-describedby", describedBy.join(" "));
117
- } else {
118
- control.removeAttribute("aria-describedby");
287
+ }
288
+ /** Switches ARIA ownership when the singular current control target changes. */
289
+ #adoptCurrentControl() {
290
+ const next = this.hasControlTarget ? this.controlTarget : null;
291
+ if (next === this.#activeControl) return;
292
+ this.#activeControl && this.#releaseControl(this.#activeControl);
293
+ this.#activeControl = next;
294
+ this.#baseDescribedBy = next ? this.#externalDescribedByTokens(next) : [];
295
+ }
296
+ /** Restores one departed control without overwriting later consumer edits. */
297
+ #releaseControl(control) {
298
+ this.#ariaDescribedBy.return(control);
299
+ this.#ariaErrorMessage.return(control);
300
+ this.#ariaInvalid.return(control);
301
+ if (control === this.#activeControl) {
302
+ this.#activeControl = null;
303
+ this.#baseDescribedBy = [];
119
304
  }
120
305
  }
121
- /** Error targets currently visible and non-empty. */
306
+ /** Error targets currently visible and non-empty, in document order. */
122
307
  #shownErrors() {
123
- return this.errorTargets.filter(
124
- (error) => !error.hidden && (error.textContent ?? "").trim() !== ""
308
+ return this.#orderedElements(
309
+ this.errorTargets.filter((error) => !error.hidden && (error.textContent ?? "").trim() !== "")
125
310
  );
126
311
  }
127
- /** Text of the first shown error, for the `validate` event detail. */
312
+ /** Text of the first shown error, for validation detail and announcement. */
128
313
  #shownMessage() {
129
314
  return (this.#shownErrors()[0]?.textContent ?? "").trim();
130
315
  }
@@ -135,17 +320,50 @@ var FormFieldController = class _FormFieldController extends Controller {
135
320
  return typeof message === "string" ? message : null;
136
321
  }
137
322
  /**
138
- * Tokens already in the control's `aria-describedby` that are not ids of this
139
- * controller's own description/error targets.
323
+ * Tokens authored on a newly adopted control, excluding ids this controller
324
+ * owns through its current description/error targets.
140
325
  */
141
- #externalDescribedByTokens() {
142
- if (!this.hasControlTarget) return [];
326
+ #externalDescribedByTokens(control) {
143
327
  const owned = /* @__PURE__ */ new Set([
144
328
  ...this.descriptionTargets.map((description) => description.id),
145
329
  ...this.errorTargets.map((error) => error.id)
146
330
  ]);
147
- const existing = this.controlTarget.getAttribute("aria-describedby") ?? "";
148
- return existing.split(/\s+/).filter((token) => token.length > 0 && !owned.has(token));
331
+ const existing = control.getAttribute("aria-describedby") ?? "";
332
+ return this.#uniqueTokens(
333
+ existing.split(/\s+/).filter((token) => token.length > 0 && !owned.has(token))
334
+ );
335
+ }
336
+ /** Deduplicates ARIA tokens without disturbing their first occurrence. */
337
+ #uniqueTokens(tokens) {
338
+ const seen = /* @__PURE__ */ new Set();
339
+ return tokens.filter((token) => {
340
+ if (token.length === 0 || seen.has(token)) return false;
341
+ seen.add(token);
342
+ return true;
343
+ });
344
+ }
345
+ /** Returns unique current targets in DOM order before serializing IDREF lists. */
346
+ #orderedElements(elements) {
347
+ const candidates = new Set(elements);
348
+ return [this.element, ...this.element.querySelectorAll("*")].filter(
349
+ (element) => candidates.has(element)
350
+ );
351
+ }
352
+ /** Whether one retained-node mutation can alter this controller's derived state. */
353
+ #isRelevantMutation(record) {
354
+ const target = record.target;
355
+ if (record.type === "attributes") {
356
+ if (record.attributeName === "hidden")
357
+ return this.errorTargets.includes(target);
358
+ return record.attributeName === "id" && [...this.descriptionTargets, ...this.errorTargets].includes(target);
359
+ }
360
+ return this.errorTargets.some((error) => error === target || error.contains(target));
361
+ }
362
+ /** Returns borrowed control ARIA before Turbo snapshots the page. */
363
+ #rewindForCache() {
364
+ this.#ariaDescribedBy.returnAll();
365
+ this.#ariaErrorMessage.returnAll();
366
+ this.#ariaInvalid.returnAll();
149
367
  }
150
368
  };
151
369