stimeo-ui 0.6.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/dist/controllers/alert_dialog_controller.js +75 -4
  4. data/dist/controllers/avatar_controller.js +47 -5
  5. data/dist/controllers/character_counter_controller.js +338 -63
  6. data/dist/controllers/command_palette_controller.js +75 -4
  7. data/dist/controllers/conditional_fields_controller.js +345 -51
  8. data/dist/controllers/confirm_controller.js +75 -4
  9. data/dist/controllers/dialog_controller.js +75 -4
  10. data/dist/controllers/direct_upload_controller.js +201 -45
  11. data/dist/controllers/dirty_form_controller.js +192 -29
  12. data/dist/controllers/dismissible_controller.js +83 -18
  13. data/dist/controllers/drawer_controller.js +75 -4
  14. data/dist/controllers/focus_controller.js +75 -4
  15. data/dist/controllers/form_field_controller.js +280 -62
  16. data/dist/controllers/form_validation_controller.js +208 -83
  17. data/dist/controllers/number_input_controller.js +47 -5
  18. data/dist/controllers/overflow_menu_controller.js +2 -1
  19. data/dist/controllers/pagination_controller.js +2 -1
  20. data/dist/controllers/persist_controller.js +432 -122
  21. data/dist/controllers/popover_controller.js +77 -4
  22. data/dist/controllers/rating_controller.js +9 -5
  23. data/dist/controllers/scroll_area_controller.js +462 -162
  24. data/dist/controllers/separator_controller.js +354 -38
  25. data/dist/controllers/sidebar_controller.js +83 -10
  26. data/dist/controllers/submit_once_controller.js +399 -121
  27. data/dist/controllers/theme_controller.js +8 -6
  28. data/dist/controllers/tree_view_controller.js +2 -1
  29. data/dist/index.js +2387 -861
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2883d44061c4c7ec8e5cd1a3b021c1d7fa8bf34bf8f2f74df1a3f496fe99f578
4
- data.tar.gz: 9024ef97e2802777390ebc1a18cbbc202df8643ccac875d59b49a06bf6ae7f3e
3
+ metadata.gz: 8e3f69e3521c7e5d2ffa5e4b948aabbb26dc9bf8429217fa6235c9cdcafb53b1
4
+ data.tar.gz: d9722417ea447febdc5188eb50812a3725987f65a6920e49f98e852b5f0076ba
5
5
  SHA512:
6
- metadata.gz: a6dd511725546fc75ffa9e150ff02640d22c09404238f9ac418b8042eac78fab90bf6c19e8266933a5b2c47178ccc13dea00793c76c7e4184f708e9619afb84c
7
- data.tar.gz: 926ccd828054b1e374bc5e9039d94d0eb4b0c398a45b235090af702ce037993e2bff6b774f6932d39e8eabdb27e69ae80fd953e25b2a8ead7525c1ee6a59d585
6
+ metadata.gz: 1fe28608ac28813a4e12b448b438853d19e2acdd948fe2af5d287b615e83439a933023881a72bec2a50d579c384141be0c132310a640540b5d7eaee5554e2f28
7
+ data.tar.gz: ddb5ffd05af127033055296b6a66e6e71344b89dbcc42f0c88a34bc7e88193cb2c48deb67f4f2250edd19a0bed15957914c5ee90afd137ecce1c11cfc818df5b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
8
8
  change between releases.
9
9
 
10
+ ## [0.7.0] - 2026-08-16
11
+
12
+ Minor release reworking the form stack, separator, and scroll-area, with
13
+ announcements moving onto the shared `stimeo--announcer`. The Inspector manifest
14
+ moves to schema v12, so `stimeo check` can report on views that passed under
15
+ 0.6.0.
16
+
17
+ ### Removed
18
+
19
+ - **Breaking** — direct-upload: the `status` target and the `announce` /
20
+ `doneLabel` / `errorLabel` Values. Use `announceDoneText` /
21
+ `announceErrorText` with `{name}` instead of `%{name}`.
22
+ - **Breaking** — persist: the unversioned draft format. Drafts written by earlier
23
+ releases are discarded, not migrated.
24
+
25
+ ### Added
26
+
27
+ - separator `min` / `max` / `value`; submit-once `finish` / `cancel` and
28
+ `idle` / `busy` targets; dirty-form `acceptRestore`; character-counter
29
+ `announceText`; form-field `setError(message, { focus })`; persist `error`;
30
+ `reconcile` on character-counter and conditional-fields.
31
+ - form-validation: localized constraint wording through
32
+ `data-stimeo--form-validation-message[-<constraint>]`, a declarative
33
+ `disallow="whitespace"`, and controls attached with `form="id"`.
34
+ - Inspector: schema v12 (`requiredActions`, `actionCompletion`) with the
35
+ `missing-required-action`, `missing-action-completion`, and
36
+ `missing-announcer` diagnostics.
37
+
38
+ ### Changed
39
+
40
+ - **Breaking** — announcing components need a `stimeo--announcer` on the page
41
+ plus the wording: character-counter `announceText`, direct-upload
42
+ `announceDoneText` / `announceErrorText`, submit-once `announceText` /
43
+ `announceReadyText`. Drop `role="alert"` from form-field's `error` target and
44
+ `aria-live` from character-counter's `output`.
45
+ - **Breaking** — separator: the Values are the only runtime input, so move
46
+ `role`, `tabindex`, and the orientation / range ARIA into `orientation`,
47
+ `focusable`, `min`, `max`, `step`, and `value`. A `focusable` separator also
48
+ needs `keydown->stimeo--separator#onKeydown` on itself and an accessible name.
49
+ - **Breaking** — submit-once: sessions are per form, and `preventDefault()` no
50
+ longer ends one — wire `finish` or `cancel`, or set a `timeout`. `start` /
51
+ `end` carry `{ form, submitter, … }`.
52
+ - **Breaking** — direct-upload: `done` fires only on success, and a row that
53
+ reached `error` stays there.
54
+ - **Breaking** — dirty-form: a successful submit re-baselines to what Turbo
55
+ submitted, so later edits stay dirty, and one native confirmation covers every
56
+ dirty form per navigation. With persist, add
57
+ `data-action="stimeo--persist:restore->stimeo--dirty-form#acceptRestore"`.
58
+ - **Breaking** — character-counter: `change` fires only when a committed edit
59
+ changes the length. form-field: `validate` fires only for `setError` /
60
+ `clearError`.
61
+ - persist: `exclude` defaults to Rails' `authenticity_token` / `_method` /
62
+ `utf8`; `password` is always excluded regardless of the list.
63
+
64
+ ### Fixed
65
+
66
+ - Attribute and style leases are released by the subscriber that owns the
67
+ lifecycle, so a cached page no longer retains detached elements.
68
+ - localStorage access goes through one guarded helper, so persist, sidebar, and
69
+ theme keep working when the browser denies storage.
70
+
10
71
  ## [0.6.0] - 2026-08-15
11
72
 
12
73
  Minor release that separates reconciled state from user edits: `change` now
@@ -573,6 +634,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
573
634
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
574
635
  RubyGems.
575
636
 
637
+ [0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
576
638
  [0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.0
577
639
  [0.5.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.5.0
578
640
  [0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
@@ -115,8 +115,81 @@ var EscapeLayer = class _EscapeLayer {
115
115
  }
116
116
  };
117
117
 
118
+ // src/utils/focus_candidate.ts
119
+ function inheritsFieldsetDisabled(control) {
120
+ let fieldset = control.closest("fieldset[disabled]");
121
+ while (fieldset) {
122
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
123
+ if (!legend?.contains(control)) return true;
124
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
125
+ }
126
+ return false;
127
+ }
128
+ function canTakeFocus(element) {
129
+ if (element.closest("[hidden], [inert]")) return false;
130
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
131
+ if (!("disabled" in element)) return true;
132
+ if (element.disabled) return false;
133
+ return !inheritsFieldsetDisabled(element);
134
+ }
135
+ var TAB_STOP_CANDIDATE_SELECTOR = [
136
+ "a[href]",
137
+ "area[href]",
138
+ "button",
139
+ "input",
140
+ "select",
141
+ "textarea",
142
+ "summary",
143
+ "iframe",
144
+ "audio[controls]",
145
+ "video[controls]",
146
+ "[tabindex]",
147
+ "[contenteditable]"
148
+ ].join(",");
149
+ function isRenderedForFocus(element) {
150
+ const check = element.checkVisibility;
151
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
152
+ }
153
+ function authoredTabindex(element) {
154
+ const value = element.getAttribute("tabindex");
155
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
156
+ return Number(value);
157
+ }
158
+ function hasNativeTabStop(element) {
159
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
160
+ return element.hasAttribute("href");
161
+ }
162
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
163
+ return true;
164
+ }
165
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
166
+ if (element instanceof HTMLIFrameElement) return true;
167
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
168
+ return element.hasAttribute("controls");
169
+ }
170
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
171
+ const details = element.parentElement;
172
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
173
+ }
174
+ return false;
175
+ }
176
+ function hasEditableTabStop(element) {
177
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
178
+ return value === "" || value === "true" || value === "plaintext-only";
179
+ }
180
+ function isTabStop(element) {
181
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
182
+ const tabindex = authoredTabindex(element);
183
+ if (tabindex !== null) return tabindex >= 0;
184
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
185
+ }
186
+ function tabStopsWithin(root) {
187
+ return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
188
+ isTabStop
189
+ );
190
+ }
191
+
118
192
  // src/utils/focus_trap.ts
119
- var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
120
193
  var FocusTrap = class {
121
194
  /** The element focused before activation, restored on deactivation. */
122
195
  #previouslyFocused = null;
@@ -278,9 +351,7 @@ var FocusTrap = class {
278
351
  }
279
352
  /** Collects the container's currently focusable descendants in DOM order. */
280
353
  #focusableElements() {
281
- return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
282
- (el) => !el.hidden
283
- );
354
+ return tabStopsWithin(this.#getContainer());
284
355
  }
285
356
  };
286
357
 
@@ -21,17 +21,21 @@ var AttributeLease = class {
21
21
  written: value
22
22
  });
23
23
  }
24
- if (value === null) element.removeAttribute(this.#attribute);
25
- else element.setAttribute(this.#attribute, value);
24
+ this.#reflect(element, value);
26
25
  }
27
26
  /** Returns one lease without overwriting a value subsequently authored by a consumer. */
28
27
  return(element) {
29
28
  const record = this.#records.get(element);
30
29
  if (!record) return;
31
30
  this.#records.delete(element);
32
- if (element.getAttribute(this.#attribute) !== record.written) return;
33
- if (record.original === null) element.removeAttribute(this.#attribute);
34
- else element.setAttribute(this.#attribute, record.original);
31
+ const stillOwned = element.getAttribute(this.#attribute) === record.written;
32
+ if (stillOwned) this.#reflect(element, record.original);
33
+ }
34
+ /** Reflects only a real value transition, avoiding self-triggered mutation work. */
35
+ #reflect(element, value) {
36
+ if (element.getAttribute(this.#attribute) === value) return;
37
+ if (value === null) element.removeAttribute(this.#attribute);
38
+ else element.setAttribute(this.#attribute, value);
35
39
  }
36
40
  /** Returns every outstanding lease using the same ownership check as {@link return}. */
37
41
  returnAll() {
@@ -39,6 +43,35 @@ var AttributeLease = class {
39
43
  }
40
44
  };
41
45
 
46
+ // src/utils/before_cache_reset.ts
47
+ var BeforeCacheReset = class _BeforeCacheReset {
48
+ /** Every subscribed instance, iterated by the one shared document listener. */
49
+ static #subscribers = /* @__PURE__ */ new Set();
50
+ /** The shared listener; installed while at least one instance is subscribed. */
51
+ static #onBeforeCache = () => {
52
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
53
+ };
54
+ #rewind;
55
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
56
+ constructor(rewind) {
57
+ this.#rewind = rewind;
58
+ }
59
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
60
+ activate() {
61
+ const first = _BeforeCacheReset.#subscribers.size === 0;
62
+ _BeforeCacheReset.#subscribers.add(this);
63
+ if (first) {
64
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
65
+ }
66
+ }
67
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
68
+ deactivate() {
69
+ _BeforeCacheReset.#subscribers.delete(this);
70
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
71
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
72
+ }
73
+ };
74
+
42
75
  // src/utils/microtask_coalescer.ts
43
76
  var MicrotaskCoalescer = class {
44
77
  #run;
@@ -81,6 +114,7 @@ var AvatarController = class extends Controller {
81
114
  /** Collapses one morph's Value, target, and direct-attribute signals into one pass. */
82
115
  #reconcile = new MicrotaskCoalescer(() => this.#render());
83
116
  /** Preserves directly-authored source and visibility while targets are controlled. */
117
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
84
118
  #src = new AttributeLease("src");
85
119
  #imageHidden = new AttributeLease("hidden");
86
120
  #fallbackHidden = new AttributeLease("hidden");
@@ -96,6 +130,7 @@ var AvatarController = class extends Controller {
96
130
  connect() {
97
131
  this.#connected = true;
98
132
  this.#reconcile.activate();
133
+ this.#beforeCache.activate();
99
134
  this.#srcObserver.observe(this.element, {
100
135
  attributes: true,
101
136
  attributeFilter: ["src"],
@@ -107,6 +142,7 @@ var AvatarController = class extends Controller {
107
142
  disconnect() {
108
143
  this.#connected = false;
109
144
  this.#reconcile.cancel();
145
+ this.#beforeCache.deactivate();
110
146
  this.#srcObserver.disconnect();
111
147
  }
112
148
  /** Repaints when application code or a Turbo morph changes the source Value. */
@@ -214,6 +250,12 @@ var AvatarController = class extends Controller {
214
250
  }
215
251
  this.element.setAttribute("data-state", state);
216
252
  }
253
+ /** Returns borrowed image and fallback semantics before Turbo snapshots the page. */
254
+ #rewindForCache() {
255
+ this.#src.returnAll();
256
+ this.#imageHidden.returnAll();
257
+ this.#fallbackHidden.returnAll();
258
+ }
217
259
  };
218
260
 
219
261
  export { AvatarController };