stimeo-ui 0.2.1 → 0.4.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +160 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +32 -5
  5. data/dist/controllers/announcer_controller.js +255 -20
  6. data/dist/controllers/breadcrumb_controller.js +225 -13
  7. data/dist/controllers/calendar_controller.js +89 -22
  8. data/dist/controllers/carousel_controller.js +47 -6
  9. data/dist/controllers/collapsible_controller.js +2 -2
  10. data/dist/controllers/color_picker_controller.js +92 -7
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +226 -22
  13. data/dist/controllers/confirm_controller.js +32 -5
  14. data/dist/controllers/context_menu_controller.js +32 -10
  15. data/dist/controllers/countdown_controller.js +112 -12
  16. data/dist/controllers/data_grid_controller.js +82 -4
  17. data/dist/controllers/date_range_picker_controller.js +77 -3
  18. data/dist/controllers/dialog_controller.js +32 -5
  19. data/dist/controllers/drawer_controller.js +32 -5
  20. data/dist/controllers/editable_controller.js +1 -0
  21. data/dist/controllers/empty_state_controller.js +24 -10
  22. data/dist/controllers/focus_controller.js +32 -5
  23. data/dist/controllers/form_validation_controller.js +1 -1
  24. data/dist/controllers/frame_loading_controller.js +177 -15
  25. data/dist/controllers/intersection_controller.js +36 -11
  26. data/dist/controllers/lazy_frame_controller.js +31 -10
  27. data/dist/controllers/listbox_controller.js +257 -53
  28. data/dist/controllers/local_time_controller.js +102 -8
  29. data/dist/controllers/menu_controller.js +104 -17
  30. data/dist/controllers/menubar_controller.js +415 -63
  31. data/dist/controllers/meter_controller.js +145 -26
  32. data/dist/controllers/multi_select_controller.js +312 -29
  33. data/dist/controllers/navigation_menu_controller.js +154 -27
  34. data/dist/controllers/network_status_controller.js +28 -8
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +81 -13
  38. data/dist/controllers/overflow_menu_controller.js +408 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/persist_controller.js +6 -6
  41. data/dist/controllers/pointer_drag_controller.js +9 -1
  42. data/dist/controllers/popover_controller.js +2 -2
  43. data/dist/controllers/progress_controller.js +116 -9
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +100 -9
  46. data/dist/controllers/rating_controller.js +69 -2
  47. data/dist/controllers/read_more_controller.js +63 -19
  48. data/dist/controllers/relative_time_controller.js +133 -12
  49. data/dist/controllers/resizable_controller.js +65 -1
  50. data/dist/controllers/roving_controller.js +17 -2
  51. data/dist/controllers/scroll_area_controller.js +86 -12
  52. data/dist/controllers/scroll_restore_controller.js +1 -1
  53. data/dist/controllers/scroll_visibility_controller.js +33 -3
  54. data/dist/controllers/scrollspy_controller.js +346 -73
  55. data/dist/controllers/separator_controller.js +9 -0
  56. data/dist/controllers/sidebar_controller.js +37 -8
  57. data/dist/controllers/skeleton_controller.js +73 -20
  58. data/dist/controllers/slider_controller.js +49 -8
  59. data/dist/controllers/sortable_controller.js +34 -3
  60. data/dist/controllers/spinner_controller.js +228 -27
  61. data/dist/controllers/step_indicator_controller.js +82 -5
  62. data/dist/controllers/stick_to_bottom_controller.js +61 -10
  63. data/dist/controllers/sticky_observer_controller.js +32 -11
  64. data/dist/controllers/switch_controller.js +1 -0
  65. data/dist/controllers/tabs_controller.js +26 -3
  66. data/dist/controllers/tags_input_controller.js +22 -2
  67. data/dist/controllers/theme_controller.js +22 -3
  68. data/dist/controllers/time_picker_controller.js +20 -1
  69. data/dist/controllers/toast_controller.js +4 -5
  70. data/dist/controllers/toggle_group_controller.js +23 -2
  71. data/dist/controllers/toolbar_controller.js +230 -31
  72. data/dist/controllers/tree_view_controller.js +467 -51
  73. data/dist/index.js +4507 -907
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4f40e999f04533e49b382f7a5165c17b2338e8d520c702c34523843c12eff4c
4
- data.tar.gz: 2bd4c18004fd4f92998513dd6a0d51975c7f9fcbe452fcef89477eb3f87edd7b
3
+ metadata.gz: d5c989b2c56e2578e6c05c5e01c29e31cdba141e9a49110701ba4a40dc91198e
4
+ data.tar.gz: 6b123f7cad0a54ef52b13c525bb53cf2eb43f0cc09748ab0d8cbf7d738e1fca9
5
5
  SHA512:
6
- metadata.gz: 06ed197e53937fa086971d86c22d66efa4aed0f2accf14000046711be6aa73c3f486671c3912264a4ce5ca7c6da97a9836d7fe79c1d3ce106e1b368e5013f0d1
7
- data.tar.gz: e49c3e2ccbc075def36be9d4e926d167fa4714cb66710389ad983ad7725f86e91d525fddfbb809090b4af09bc0cdaed44541466cb57391db53566119613f8863
6
+ metadata.gz: b95db793fc2a866c4c099f98ca1a689a36b5f15419d6b89b1c2f6423f694e03421c68d1bf930c0920c6d8feeb0c554a430e03523367fdabfa24578769a922c9b
7
+ data.tar.gz: 06ee52f5e9a6a56c9fea57f9e2f291e58336228e17a5bc5685238be3d98e00718a81837eb4196ddd08a230006492cd5e23d950537285f1e088c6bb2767b4fc12
data/CHANGELOG.md CHANGED
@@ -7,6 +7,163 @@ 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.4.0] - 2026-08-11
11
+
12
+ The feedback and status components announce through the shared
13
+ `stimeo--announcer` and survive Turbo caching and morphs. The Inspector reads the
14
+ `data:` hashes Rails helpers render. Two public API entries were removed.
15
+
16
+ ### Removed
17
+
18
+ - **Breaking** — sidebar: the `beforeCache` action. Delete
19
+ `data-action="turbo:before-cache@document->stimeo--sidebar#beforeCache"`; the
20
+ behavior is built in, and leaving it makes `stimeo check` report an unknown
21
+ action.
22
+ - **Breaking** — empty-state: the `announce` boolean, and the `role="status"` /
23
+ `aria-live="polite"` it applied to the `empty` target. Use `announceText` /
24
+ `announceFilledText` instead.
25
+
26
+ ### Added
27
+
28
+ - countdown, empty-state, frame-loading, meter, network-status, progress,
29
+ skeleton, spinner: `announceText`, plus `announceFilledText` /
30
+ `announceOnlineText` / `announceReadyText` where a component has two
31
+ transitions to report. Wording is yours and defaults to empty, and the page
32
+ needs a `stimeo--announcer` for any of it to be read.
33
+ - spinner: `timeout`, with a `stimeo--spinner:timeout` event. Off by default.
34
+ - stick-to-bottom: `pinOnConnect`. Off by default.
35
+ - Inspector: Stimulus wiring in a Rails helper's literal `data:` hash is checked
36
+ like static attributes. Re-run `stimeo check` — it can report new diagnostics.
37
+
38
+ ### Changed
39
+
40
+ - meter, progress: an `aria-valuetext` you authored is preserved across renders.
41
+ - local-time, relative-time: the locale falls back to the nearest ancestor
42
+ carrying `lang`, not just the element's own and then the document's.
43
+ - countdown: `data-state` is the source of truth for the run state; `autostart`
44
+ governs only markup that states none.
45
+
46
+ ### Fixed
47
+
48
+ - Turbo caching and morphs across announcer, color-picker, countdown,
49
+ date-range-picker, frame-loading, local-time, meter, overflow-menu, progress,
50
+ range-slider, rating, relative-time, sidebar, spinner, step-indicator: the
51
+ page is rewound before the snapshot and repaints when values are swapped,
52
+ instead of restoring stuck state or leaving a stale reading.
53
+ - network-status: transitions are actually announced.
54
+ - countdown: resume steps by one unit, and a settled timer no longer re-emits
55
+ `complete` on reconnect.
56
+ - step-indicator: runtime step changes re-derive the set, and a non-numeric
57
+ `current` falls back to the first step.
58
+ - local-time: a `datetime` with a space separator (`2026-06-08 12:30`) parses.
59
+ - relative-time: a malformed `locale` no longer throws.
60
+ - skeleton, frame-loading: a repeated signal no longer restarts or stacks the
61
+ minimum-duration wait.
62
+ - Empty, inverted, and non-numeric ranges floor to `0` across meter, progress,
63
+ slider, and range-slider.
64
+
65
+ ## [0.3.0] - 2026-08-09
66
+
67
+ Minor release focused on consistent keyboard and selection behavior, resilient
68
+ runtime DOM changes, and substantially stronger Inspector diagnostics. No
69
+ controller, target, value, action, event, or package export was removed. The
70
+ Inspector manifest advances from schema v5 to v8, so tools that read the raw
71
+ manifest must accept the new schema and fields.
72
+
73
+ ### Upgrade notes
74
+
75
+ - Run `stimeo check` against existing views before deploying. New structural
76
+ checks can report errors for incomplete optional target groups, missing
77
+ companion controllers, undeclared role-bearing targets, and invalid
78
+ cardinality. New ARIA checks can also add warnings without failing the command.
79
+ - In RTL containers, horizontal arrow keys now follow logical inline direction
80
+ across composite widgets. LTR behavior is unchanged.
81
+ - Slider, range-slider, and color-picker pointer/arrow mirroring remains opt-in:
82
+ set their new `logicalTrack` value only when the consuming CSS mirrors the
83
+ track in RTL.
84
+ - calendar's `stimeo--calendar:monthchange` no longer fires while the grid
85
+ settles on its initial month, so code that ran from that first event must now
86
+ run once at initialization. Listeners that re-apply per-date `aria-disabled`
87
+ marks are the common case.
88
+
89
+ ### Added
90
+
91
+ - breadcrumb: a public `update` action for reconciling collapsible state after
92
+ application-driven DOM changes.
93
+ - tree-view: a public `toggle` action for explicitly expanding or collapsing an
94
+ item from application markup.
95
+ - scrollspy: a `focusSection` value for opting into focus movement when a link
96
+ activates a section.
97
+ - slider, range-slider, color-picker: the `logicalTrack` value described above.
98
+ - Inspector schema v6-v8: conditional target requirements and the new
99
+ `missing-companion`, `undeclared-target`, `cardinality-violation`,
100
+ `forbidden-aria`, and `missing-conditional-target` diagnostics. Rules can now
101
+ depend on controller values, element/document conditions, contained targets,
102
+ and ARIA requirement severity.
103
+
104
+ ### Changed
105
+
106
+ - accordion, carousel, calendar, combobox, command-palette, context-menu,
107
+ data-grid, date-range-picker, listbox, menu, menubar, multi-select,
108
+ navigation-menu, OTP, radio-group, roving, tabs, tags-input, time-picker,
109
+ toggle-group, toolbar, tree-view, and related horizontal controls now resolve
110
+ Left/Right behavior from the effective text direction. Sortable geometry also
111
+ follows logical inline direction.
112
+ - Arrow-key handlers yield modified browser shortcuts, and composite handlers
113
+ yield events already consumed by a nested widget. IME composition continues to
114
+ take precedence over component shortcuts.
115
+ - Selection widgets normalize controller-owned `aria-selected` state, preserve a
116
+ single active/selected item where the pattern requires one, and reconcile that
117
+ state when options are inserted, removed, or replaced at runtime.
118
+ - menu and overflow-menu delegate item interaction from their containers, so
119
+ dynamically added items work without per-item actions. Existing action markup
120
+ remains compatible.
121
+ - `aria-disabled="true"` items remain discoverable by composite keyboard
122
+ navigation while activation, expansion, and consumer actions are suppressed.
123
+ - Outside pointer listeners for dismissible composites — click and context-menu
124
+ alike — run in the capture phase so nested application code cannot leave an
125
+ open layer stuck by stopping bubbling.
126
+ - Inspector accessible-name diagnostics now follow ARIA's Required, Recommended,
127
+ and conditional levels instead of treating every role alike.
128
+
129
+ ### Fixed
130
+
131
+ - combobox, command-palette, listbox, and multi-select no longer retain stale
132
+ active IDs, selection, chips, or hidden fields after Turbo morphs and runtime
133
+ target replacement. A disconnect/reconnect in the same task can no longer let
134
+ an old reconciliation microtask consume the fresh generation's pass.
135
+ - breadcrumb fails open when its optional disclosure targets are incomplete and
136
+ restores items that leave the collapsible set instead of leaving them hidden.
137
+ - menu, menubar, navigation-menu, overflow-menu, pagination, and tree-view now
138
+ recover focus consistently across empty lists, disabled fieldsets, hidden
139
+ ancestors, item banking, and runtime item removal.
140
+ - toolbar restores its single tab stop when an enclosing `<fieldset>` is
141
+ re-enabled, so unlocking a form brings the group back into the Tab sequence.
142
+ - multi-select keeps chip focus when the selection was made in an order other
143
+ than the option order. The chips are reconciled against the selected value set
144
+ rather than position by position, and their labels are paired by value.
145
+ - overflow-menu measures items adopted from server-rendered overflow in the bar
146
+ rather than inside the closed menu, so an already-overflowed bar no longer
147
+ returns every item to the bar on first paint.
148
+ - combobox commits a clicked option after its input target is removed instead of
149
+ throwing, matching the tolerance the popup already had for that state.
150
+ - color-picker falls back to the per-channel range when a slider omits
151
+ `aria-valuemin` / `aria-valuemax`, instead of pinning the channel at zero.
152
+ - calendar maintains exactly one roving tab stop while selection and available
153
+ days change.
154
+ - calendar reports `monthchange` from the month it paints, so a malformed
155
+ `month` value can no longer put a raw non-`YYYY-MM` string in `detail.month`.
156
+ The report is driven by the repaint rather than by the `month` value, so a
157
+ fallback month moved by `selected` or by selecting a neighbouring month's cell
158
+ is announced as it happens instead of arriving on a later repaint. Re-applying
159
+ the same month, as a Turbo cache restore does, stays silent.
160
+ - scrollspy re-evaluates links and sections after every supported entry path;
161
+ intersection-based controllers align fallback thresholds, hidden-layout
162
+ handling, and reduced-motion behavior.
163
+ - `stimeo catalog --json` and large `stimeo check --json` reports are no longer
164
+ truncated when stdout is piped; the CLI now lets buffered output flush before
165
+ exiting, and the build audit exercises the real piped catalog.
166
+
10
167
  ## [0.2.1] - 2026-07-26
11
168
 
12
169
  Patch release from a review of the disclosure and layout components, plus a
@@ -234,6 +391,9 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
234
391
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
235
392
  RubyGems.
236
393
 
394
+ [0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
395
+ [0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
396
+ [0.2.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.1
237
397
  [0.2.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.0
238
398
  [0.1.0-beta.3]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.3
239
399
  [0.1.0-beta.2]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.2
@@ -1,5 +1,13 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/accordion_controller.ts
4
+
5
+ // src/utils/arrow_step.ts
6
+ function isReservedArrowChord(event, allow = []) {
7
+ if (!event.key.startsWith("Arrow")) return false;
8
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
+ }
10
+
3
11
  // src/controllers/accordion_controller.ts
4
12
  var AccordionController = class extends Controller {
5
13
  static targets = ["trigger", "panel"];
@@ -39,6 +47,8 @@ var AccordionController = class extends Controller {
39
47
  * `.focus()` lands on nothing and navigation appears to stall.
40
48
  */
41
49
  onKeydown(event) {
50
+ if (event.defaultPrevented) return;
51
+ if (isReservedArrowChord(event)) return;
42
52
  const current = event.currentTarget;
43
53
  if (this.triggerTargets.indexOf(current) === -1) return;
44
54
  const navigable = this.triggerTargets.filter((trigger) => trigger.closest("[hidden]") === null);
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/alert_dialog_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
5
34
  // src/utils/escape_layer.ts
6
35
  var EscapeLayer = class _EscapeLayer {
7
36
  static #registries = /* @__PURE__ */ new WeakMap();
@@ -135,7 +164,7 @@ var FocusTrap = class {
135
164
  }
136
165
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
137
166
  document.addEventListener("keydown", this.#onKeydown);
138
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
167
+ this.#beforeCache.activate();
139
168
  const onEscape = this.#options.onEscape;
140
169
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
141
170
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -152,7 +181,7 @@ var FocusTrap = class {
152
181
  this.#activeState = false;
153
182
  this.#escapeLayer.deactivate();
154
183
  document.removeEventListener("keydown", this.#onKeydown);
155
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
184
+ this.#beforeCache.deactivate();
156
185
  if (this.#scrollLocked) {
157
186
  document.body.style.overflow = this.#previousBodyOverflow;
158
187
  this.#scrollLocked = false;
@@ -176,9 +205,7 @@ var FocusTrap = class {
176
205
  * untouched (restore-open designs reopen against a clean baseline), and focus
177
206
  * is left alone mid-navigation. The listener lives only while active.
178
207
  */
179
- #onBeforeCache = () => {
180
- this.deactivate({ restoreFocus: false });
181
- };
208
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
182
209
  /**
183
210
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
211
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
@@ -2,6 +2,68 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/announcer_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
34
+ // src/utils/microtask_coalescer.ts
35
+ var MicrotaskCoalescer = class {
36
+ #run;
37
+ #queued = false;
38
+ #active = false;
39
+ #generation = 0;
40
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
41
+ constructor(run) {
42
+ this.#run = run;
43
+ }
44
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
45
+ activate() {
46
+ this.#active = true;
47
+ }
48
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
49
+ cancel() {
50
+ this.#active = false;
51
+ this.#queued = false;
52
+ this.#generation += 1;
53
+ }
54
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
55
+ schedule() {
56
+ if (!this.#active || this.#queued) return;
57
+ this.#queued = true;
58
+ const generation = this.#generation;
59
+ queueMicrotask(() => {
60
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
61
+ this.#queued = false;
62
+ this.#run();
63
+ });
64
+ }
65
+ };
66
+
5
67
  // src/utils/safe_timeout.ts
6
68
  var TimerRegistry = class {
7
69
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -56,6 +118,7 @@ var SafeTimeout = class extends TimerRegistry {
56
118
  };
57
119
 
58
120
  // src/controllers/announcer_controller.ts
121
+ var LEVELS = ["polite", "assertive"];
59
122
  var AnnouncerController = class extends Controller {
60
123
  static targets = ["polite", "assertive"];
61
124
  static values = {
@@ -67,6 +130,30 @@ var AnnouncerController = class extends Controller {
67
130
  #timers = new SafeTimeout();
68
131
  /** Live regions generated to stand in for absent targets, for teardown. */
69
132
  #generated = /* @__PURE__ */ new Map();
133
+ /** Messages waiting to be written, oldest first, one queue per politeness. */
134
+ #queues = /* @__PURE__ */ new Map();
135
+ /** Politeness levels whose next drain is already armed. */
136
+ #draining = /* @__PURE__ */ new Set();
137
+ /** Collapses a batch of target callbacks (and morph removals) into one pass. */
138
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileRegions());
139
+ /**
140
+ * Watches the host's own children for a generated region disappearing. A morph
141
+ * drops it — the server's HTML never had it — and no target callback reports
142
+ * that, because a generated region carries no target attribute. `subtree` stays
143
+ * off so writing a message inside a region does not re-enter this pass.
144
+ */
145
+ #hostWatch = new MutationObserver(() => {
146
+ this.#reconcile.schedule();
147
+ });
148
+ /** Rewinds to an announceable initial state for the snapshot; see the remarks. */
149
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
150
+ /**
151
+ * The one timer a region may have outstanding — its dedupe re-set, then its
152
+ * auto-clear. Held weakly so a swapped-out target is not retained; a leftover
153
+ * id is harmless because {@link SafeTimeout.clear} no-ops on an id it does not
154
+ * own.
155
+ */
156
+ #pending = /* @__PURE__ */ new WeakMap();
70
157
  /**
71
158
  * Guards against handling the same CustomEvent twice. An event dispatched on
72
159
  * the controller element with `bubbles: true` reaches both the element and the
@@ -83,13 +170,86 @@ var AnnouncerController = class extends Controller {
83
170
  this.#announce(message, this.#assertiveFromDetail(detail));
84
171
  };
85
172
  connect() {
173
+ this.#reconcile.activate();
174
+ this.#beforeCache.activate();
175
+ this.#reconcileRegions();
176
+ this.#hostWatch.observe(this.element, { childList: true });
86
177
  this.element.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
87
178
  window.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
88
179
  }
89
180
  disconnect() {
181
+ this.#reconcile.cancel();
182
+ this.#hostWatch.disconnect();
183
+ this.#beforeCache.deactivate();
90
184
  this.element.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
91
185
  window.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
92
186
  this.#timers.clearAll();
187
+ this.#queues.clear();
188
+ this.#draining.clear();
189
+ this.#removeGenerated();
190
+ }
191
+ /** Retires the stand-in once the consumer supplies a polite region. */
192
+ politeTargetConnected() {
193
+ this.#reconcile.schedule();
194
+ }
195
+ /** Materialises a stand-in once the consumer's polite region goes away. */
196
+ politeTargetDisconnected() {
197
+ this.#reconcile.schedule();
198
+ }
199
+ /** Retires the stand-in once the consumer supplies an assertive region. */
200
+ assertiveTargetConnected() {
201
+ this.#reconcile.schedule();
202
+ }
203
+ /** Materialises a stand-in once the consumer's assertive region goes away. */
204
+ assertiveTargetDisconnected() {
205
+ this.#reconcile.schedule();
206
+ }
207
+ /**
208
+ * Brings the region set back to exactly one region per politeness and reports
209
+ * whether anything had to be created.
210
+ *
211
+ * A region created here is not written to in the same task: assistive tech
212
+ * reports changes to regions it already knows about, so {@link drain} waits a
213
+ * task whenever this says a region is new.
214
+ */
215
+ #reconcileRegions() {
216
+ let created = false;
217
+ for (const level of LEVELS) {
218
+ if (this.#hasTargetFor(level)) {
219
+ const generated = this.#generated.get(level);
220
+ if (generated) {
221
+ generated.remove();
222
+ this.#generated.delete(level);
223
+ }
224
+ continue;
225
+ }
226
+ const existing = this.#generated.get(level);
227
+ if (existing?.isConnected) continue;
228
+ this.#generated.set(level, this.#createRegion(level));
229
+ created = true;
230
+ }
231
+ return created;
232
+ }
233
+ /** Whether the consumer supplied a target for `level`. */
234
+ #hasTargetFor(level) {
235
+ return level === "assertive" ? this.hasAssertiveTarget : this.hasPoliteTarget;
236
+ }
237
+ /** Builds a visually hidden live region for `level` and attaches it. */
238
+ #createRegion(level) {
239
+ const region = document.createElement("div");
240
+ region.setAttribute("aria-live", level);
241
+ region.setAttribute("aria-atomic", "true");
242
+ visuallyHide(region);
243
+ this.element.appendChild(region);
244
+ return region;
245
+ }
246
+ /**
247
+ * Removes and forgets every region this controller generated. Authored targets
248
+ * belong to the consumer and are left untouched. Forgetting them is what keeps
249
+ * the live page working after a snapshot rewind: the next announcement finds an
250
+ * empty map and materialises a fresh region.
251
+ */
252
+ #removeGenerated() {
93
253
  for (const region of this.#generated.values()) {
94
254
  region.remove();
95
255
  }
@@ -111,47 +271,122 @@ var AnnouncerController = class extends Controller {
111
271
  this.#announce(message, assertive);
112
272
  }
113
273
  /**
114
- * Writes `message` into the matching live region and schedules its clear.
274
+ * Queues `message` for its politeness and arms the drain.
115
275
  *
116
- * When the region already holds the same text, an aria-atomic region is not
117
- * re-read by assistive tech (the node did not change). If `dedupeReannounce`
118
- * is on, the text is cleared and re-set on a later task so the mutation is
119
- * observed and announced again.
276
+ * Queuing is what makes a burst audible: assistive tech announces the changes it
277
+ * observes, so several messages written into one region within a single task are
278
+ * one change and only the last is read.
120
279
  */
121
280
  #announce(message, assertive) {
122
- const region = this.#regionFor(assertive ? "assertive" : "polite");
281
+ const level = assertive ? "assertive" : "polite";
282
+ const queue = this.#queues.get(level);
283
+ if (queue) {
284
+ queue.push(message);
285
+ } else {
286
+ this.#queues.set(level, [message]);
287
+ }
288
+ this.#scheduleDrain(level);
289
+ }
290
+ /** Arms one drain pass for `level`; further messages ride the pass already armed. */
291
+ #scheduleDrain(level) {
292
+ if (this.#draining.has(level)) return;
293
+ this.#draining.add(level);
294
+ this.#timers.set(() => {
295
+ this.#draining.delete(level);
296
+ this.#drain(level);
297
+ }, 0);
298
+ }
299
+ /**
300
+ * Writes one queued message, then arms the next pass while the queue holds more.
301
+ *
302
+ * Two steps take a whole pass without consuming the message: materialising a
303
+ * region (it has to be in the accessibility tree before the text arrives) and
304
+ * emptying a region that already holds this exact text (an unchanged node is not
305
+ * re-read, so `dedupeReannounce` clears first and writes on the following pass).
306
+ */
307
+ #drain(level) {
308
+ const queue = this.#queues.get(level);
309
+ const message = queue?.[0];
310
+ if (queue === void 0 || message === void 0) return;
311
+ if (this.#reconcileRegions()) {
312
+ this.#scheduleDrain(level);
313
+ return;
314
+ }
315
+ const region = this.#regionFor(level);
123
316
  if (this.dedupeReannounceValue && region.textContent === message) {
317
+ this.#cancelPending(region);
124
318
  region.textContent = "";
125
- this.#timers.set(() => {
126
- region.textContent = message;
127
- this.#scheduleClear(region, message);
128
- }, 0);
319
+ this.#scheduleDrain(level);
129
320
  return;
130
321
  }
322
+ queue.shift();
323
+ this.#cancelPending(region);
131
324
  region.textContent = message;
132
325
  this.#scheduleClear(region, message);
326
+ if (queue.length > 0) this.#scheduleDrain(level);
133
327
  }
134
328
  /** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
135
329
  #scheduleClear(region, message) {
136
330
  if (this.clearAfterValue <= 0) return;
137
- this.#timers.set(() => {
138
- if (region.textContent === message) region.textContent = "";
139
- }, this.clearAfterValue);
331
+ this.#schedule(
332
+ region,
333
+ () => {
334
+ if (region.textContent === message) region.textContent = "";
335
+ },
336
+ this.clearAfterValue
337
+ );
140
338
  }
141
- /** Resolves the live region for a politeness level, generating it if absent. */
339
+ /**
340
+ * Arms `region`'s single pending timer. Callers reach here with the slot
341
+ * already free — `#announce` releases it, and a fired timer clears its own
342
+ * entry below — so this does not cancel again.
343
+ */
344
+ #schedule(region, callback, delay) {
345
+ const id = this.#timers.set(() => {
346
+ this.#pending.delete(region);
347
+ callback();
348
+ }, delay);
349
+ this.#pending.set(region, id);
350
+ }
351
+ /** Releases `region`'s pending timer, if it has one. */
352
+ #cancelPending(region) {
353
+ this.#timers.clear(this.#pending.get(region) ?? -1);
354
+ this.#pending.delete(region);
355
+ }
356
+ /**
357
+ * Resolves the live region for a politeness level.
358
+ *
359
+ * The remembered stand-in is used only while it is still in the document: a morph
360
+ * can drop it, and writing into the detached node would announce nothing at all.
361
+ */
142
362
  #regionFor(level) {
143
363
  if (level === "assertive" && this.hasAssertiveTarget) return this.assertiveTarget;
144
364
  if (level === "polite" && this.hasPoliteTarget) return this.politeTarget;
145
365
  const existing = this.#generated.get(level);
146
- if (existing) return existing;
147
- const region = document.createElement("div");
148
- region.setAttribute("aria-live", level);
149
- region.setAttribute("aria-atomic", "true");
150
- visuallyHide(region);
151
- this.element.appendChild(region);
366
+ if (existing?.isConnected) return existing;
367
+ const region = this.#createRegion(level);
152
368
  this.#generated.set(level, region);
153
369
  return region;
154
370
  }
371
+ /**
372
+ * Restores the announceable initial state for the snapshot Turbo is about to
373
+ * take: queued and displayed messages go, generated regions go, and the live
374
+ * page — which keeps running when a visit is aborted — gets its regions back on
375
+ * the next task, after the clone.
376
+ */
377
+ #rewindForCache() {
378
+ this.#queues.clear();
379
+ this.#draining.clear();
380
+ this.#timers.clearAll();
381
+ for (const level of LEVELS) {
382
+ if (this.#hasTargetFor(level)) {
383
+ const target = level === "assertive" ? this.assertiveTarget : this.politeTarget;
384
+ target.textContent = "";
385
+ }
386
+ }
387
+ this.#removeGenerated();
388
+ this.#timers.set(() => this.#reconcileRegions(), 0);
389
+ }
155
390
  /** Extracts a non-empty string `message` from a CustomEvent detail, else null. */
156
391
  #messageFromDetail(detail) {
157
392
  if (detail && typeof detail === "object" && "message" in detail) {