stimeo-ui 0.10.0 → 0.11.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.
@@ -1,19 +1,108 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/preview_guard_controller.ts
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/style_property_lease.ts
35
+ var StylePropertyLease = class {
36
+ #property;
37
+ #records = /* @__PURE__ */ new Map();
38
+ /** @param property - The CSS property whose temporary values this lease owns. */
39
+ constructor(property) {
40
+ this.#property = property;
41
+ }
42
+ /** Writes or removes the leased declaration while preserving its authored value. */
43
+ write(element, value, priority = "") {
44
+ const existing = this.#records.get(element);
45
+ if (existing) {
46
+ existing.writtenValue = value;
47
+ existing.writtenPriority = value === null ? "" : priority;
48
+ } else {
49
+ this.#records.set(element, {
50
+ originalValue: element.style.getPropertyValue(this.#property),
51
+ originalPriority: element.style.getPropertyPriority(this.#property),
52
+ writtenValue: value,
53
+ writtenPriority: value === null ? "" : priority
54
+ });
55
+ }
56
+ this.#reflect(element, value, priority);
57
+ }
58
+ /** Returns one lease without overwriting a later consumer declaration. */
59
+ return(element) {
60
+ const record = this.#records.get(element);
61
+ if (!record) return;
62
+ this.#records.delete(element);
63
+ const style = element.style;
64
+ const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
65
+ if (stillOwned) {
66
+ this.#reflect(element, record.originalValue, record.originalPriority);
67
+ }
68
+ }
69
+ /** Returns every outstanding declaration lease. */
70
+ returnAll() {
71
+ for (const element of Array.from(this.#records.keys())) this.return(element);
72
+ }
73
+ /** Reflects only a real declaration transition. */
74
+ #reflect(element, value, priority) {
75
+ const style = element.style;
76
+ const nextValue = value ?? "";
77
+ const nextPriority = value === null ? "" : priority;
78
+ if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
79
+ return;
80
+ }
81
+ if (value === null) style.removeProperty(this.#property);
82
+ else style.setProperty(this.#property, value, priority);
83
+ }
84
+ };
85
+
3
86
  // src/controllers/preview_guard_controller.ts
4
87
  var PreviewGuardController = class extends Controller {
5
88
  static values = {
6
- placeholder: { type: String, default: "" },
7
- mode: { type: String, default: "hide" }
89
+ placeholder: { type: String, default: "" }
8
90
  };
9
91
  static events = ["hide", "show"];
92
+ #visibility = new StylePropertyLease("visibility");
93
+ #beforeCache = new BeforeCacheReset(() => this.#restore());
10
94
  #observer = null;
95
+ #connected = false;
11
96
  #hidden = false;
12
- /** Saved inline visibility (hide mode), restored on show. */
13
- #savedVisibility = "";
14
- /** Saved text (placeholder mode); non-null marks that text — not visibility — was swapped. */
15
- #savedText = null;
97
+ /** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */
98
+ #savedNodes = null;
99
+ /** The descendant that held focus when the guard went up, so show can hand it back. */
100
+ #focused = null;
16
101
  connect() {
102
+ this.#connected = true;
103
+ if (this.#hidden) this.#reguard();
104
+ else this.element.removeAttribute("data-preview-hidden");
105
+ this.#beforeCache.activate();
17
106
  if (typeof MutationObserver !== "undefined") {
18
107
  this.#observer = new MutationObserver(() => this.#sync());
19
108
  this.#observer.observe(document.documentElement, {
@@ -24,44 +113,115 @@ var PreviewGuardController = class extends Controller {
24
113
  this.#sync();
25
114
  }
26
115
  disconnect() {
116
+ this.#connected = false;
117
+ this.#beforeCache.deactivate();
27
118
  this.#observer?.disconnect();
28
119
  this.#observer = null;
29
- this.#restore();
30
120
  }
31
- /** Reflects the current `data-turbo-preview` state onto the element. */
121
+ /**
122
+ * Re-guards to match a `placeholder` changed at runtime — the value is the content on
123
+ * display while the guard is up, so a morph that swaps it must not leave the old one.
124
+ */
125
+ placeholderValueChanged() {
126
+ if (!this.#connected || !this.#hidden) return;
127
+ this.#reguard();
128
+ }
129
+ /**
130
+ * Reflects the current `data-turbo-preview` state onto the element.
131
+ *
132
+ * @stimeoRenderRoot
133
+ */
32
134
  #sync() {
33
135
  const previewing = document.documentElement.hasAttribute("data-turbo-preview");
34
136
  if (previewing && !this.#hidden) this.#hide();
35
137
  else if (!previewing && this.#hidden) this.#show();
36
138
  }
37
139
  #hide() {
140
+ this.#focused = this.#focusedInside();
141
+ this.#applyGuard();
142
+ this.dispatch("hide", { detail: {} });
143
+ }
144
+ /** Puts the guard up in the form the current `placeholder` calls for. */
145
+ #applyGuard() {
38
146
  this.#hidden = true;
39
- if (this.modeValue === "placeholder") {
40
- this.#savedText = this.element.textContent;
41
- this.element.textContent = this.placeholderValue;
147
+ if (this.placeholderValue === "") {
148
+ this.#visibility.write(this.element, "hidden");
42
149
  } else {
43
- this.#savedVisibility = this.element.style.visibility;
44
- this.element.style.visibility = "hidden";
150
+ this.#savedNodes = document.createDocumentFragment();
151
+ this.#savedNodes.append(...this.element.childNodes);
152
+ this.element.textContent = this.placeholderValue;
45
153
  }
46
154
  this.element.setAttribute("data-preview-hidden", "true");
47
- this.dispatch("hide", { detail: {} });
155
+ }
156
+ /**
157
+ * Re-forms a guard that is already up so it matches the current `placeholder`.
158
+ *
159
+ * No event: `hide` reports that the guard went up, and it has not come down. Swapping
160
+ * one stand-in text for another writes only that text — putting the held content back
161
+ * first would reconnect the whole subtree for an instant. Only a change of *form* —
162
+ * to or from the empty placeholder — has to revert, and the focus the guard is holding
163
+ * carries across it rather than being handed back and taken again.
164
+ */
165
+ #reguard() {
166
+ if (this.#savedNodes && this.placeholderValue !== "") {
167
+ this.element.textContent = this.placeholderValue;
168
+ return;
169
+ }
170
+ const held = this.#focused;
171
+ this.#revert();
172
+ this.#applyGuard();
173
+ this.#focused = held;
48
174
  }
49
175
  #show() {
50
176
  this.#restore();
51
177
  this.dispatch("show", { detail: {} });
52
178
  }
53
- /** Reverts the guard. Safe to call when not hidden (no-op) — used by show and teardown. */
179
+ /** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */
54
180
  #restore() {
55
- if (!this.#hidden) return;
181
+ this.#revert();
182
+ this.#refocus();
183
+ }
184
+ /**
185
+ * Puts the element back the way the guard found it.
186
+ *
187
+ * Every step is a no-op on an element this controller never guarded: the lease returns
188
+ * only declarations it recorded, and the hook is removed whether or not it is there.
189
+ */
190
+ #revert() {
56
191
  this.#hidden = false;
57
- if (this.#savedText !== null) {
58
- this.element.textContent = this.#savedText;
59
- this.#savedText = null;
192
+ if (this.#savedNodes) {
193
+ this.element.textContent = "";
194
+ this.element.append(this.#savedNodes);
195
+ this.#savedNodes = null;
60
196
  } else {
61
- this.element.style.visibility = this.#savedVisibility;
197
+ this.#visibility.return(this.element);
62
198
  }
63
199
  this.element.removeAttribute("data-preview-hidden");
64
200
  }
201
+ /**
202
+ * The focused element the guard is about to make unfocusable, if any.
203
+ *
204
+ * The element itself counts as well as its descendants: guarding takes focus either way
205
+ * — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes
206
+ * outright — so the browser drops focus to `<body>` the moment the guard goes up.
207
+ */
208
+ #focusedInside() {
209
+ const active = document.activeElement;
210
+ return active instanceof HTMLElement && this.element.contains(active) ? active : null;
211
+ }
212
+ /**
213
+ * Hands focus back to the element the guard took it from.
214
+ *
215
+ * Only when that element is still in the document and focus has not moved on since —
216
+ * anything else is the user's or another controller's, and putting it back would be
217
+ * taking it.
218
+ */
219
+ #refocus() {
220
+ const target = this.#focused;
221
+ this.#focused = null;
222
+ if (!target?.isConnected || document.activeElement !== document.body) return;
223
+ target.focus();
224
+ }
65
225
  };
66
226
 
67
227
  export { PreviewGuardController };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controllers/preview_guard_controller.ts"],"names":[],"mappings":"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","file":"preview_guard_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **preview guard** (Hotwire-specific): hides or placeholders a volatile element\n * — a balance, a notification count, a live timestamp — *only while Turbo is showing a\n * preview* (`html[data-turbo-preview]`), so a stale cached snapshot does not briefly flash\n * the old value on a back/restore visit. No APG pattern; it keeps displayed information\n * accurate and never moves focus.\n *\n * Markup contract (identifier: `stimeo--preview-guard`):\n * <span data-controller=\"stimeo--preview-guard\"\n * data-stimeo--preview-guard-mode-value=\"placeholder\"\n * data-stimeo--preview-guard-placeholder-value=\"—\">¥123,456</span>\n *\n * Watches `<html>` for the `data-turbo-preview` attribute with a `MutationObserver`.\n * While it is present the element is guarded: `mode=\"hide\"` makes it `visibility: hidden`\n * (its box is kept, so nothing shifts), `mode=\"placeholder\"` swaps its text for\n * `placeholder`. The element carries `data-preview-hidden` and emits `hide`; when the\n * preview clears it is restored and `show` fires.\n *\n * `hide` and `show` dispatch `{}`.\n *\n * @remarks\n * Behavior only — restoring the *fresh* value is the normal render's job, not this\n * controller's (it just un-hides what was there). State is derived from the DOM (no\n * module-scope state) and the original value is saved on hide and put back on show, so a\n * guard that connects mid-preview hides immediately and a `disconnect()` (Turbo navigation\n * included) restores the element and severs the observer. Focus is never moved.\n */\nexport class PreviewGuardController extends Controller<HTMLElement> {\n static override values = {\n placeholder: { type: String, default: \"\" },\n mode: { type: String, default: \"hide\" },\n };\n static events = [\"hide\", \"show\"] as const;\n\n declare placeholderValue: string;\n declare modeValue: string;\n\n #observer: MutationObserver | null = null;\n #hidden = false;\n /** Saved inline visibility (hide mode), restored on show. */\n #savedVisibility = \"\";\n /** Saved text (placeholder mode); non-null marks that text — not visibility — was swapped. */\n #savedText: string | null = null;\n\n override connect(): void {\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver(() => this.#sync());\n this.#observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"data-turbo-preview\"],\n });\n }\n // Sync once in case we connect while a preview is already on screen.\n this.#sync();\n }\n\n override disconnect(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n this.#restore(); // never leave the element guarded after teardown\n }\n\n /** Reflects the current `data-turbo-preview` state onto the element. */\n #sync(): void {\n const previewing = document.documentElement.hasAttribute(\"data-turbo-preview\");\n if (previewing && !this.#hidden) this.#hide();\n else if (!previewing && this.#hidden) this.#show();\n }\n\n #hide(): void {\n this.#hidden = true;\n if (this.modeValue === \"placeholder\") {\n this.#savedText = this.element.textContent;\n this.element.textContent = this.placeholderValue;\n } else {\n this.#savedVisibility = this.element.style.visibility;\n this.element.style.visibility = \"hidden\";\n }\n this.element.setAttribute(\"data-preview-hidden\", \"true\");\n this.dispatch(\"hide\", { detail: {} });\n }\n\n #show(): void {\n this.#restore();\n this.dispatch(\"show\", { detail: {} });\n }\n\n /** Reverts the guard. Safe to call when not hidden (no-op) — used by show and teardown. */\n #restore(): void {\n if (!this.#hidden) return;\n this.#hidden = false;\n if (this.#savedText !== null) {\n this.element.textContent = this.#savedText;\n this.#savedText = null;\n } else {\n this.element.style.visibility = this.#savedVisibility;\n }\n this.element.removeAttribute(\"data-preview-hidden\");\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/before_cache_reset.ts","../../src/utils/style_property_lease.ts","../../src/controllers/preview_guard_controller.ts"],"names":[],"mappings":"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","file":"preview_guard_controller.js","sourcesContent":["/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way.\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","/** One temporarily controlled style property and the authored declaration it displaced. */\ninterface StylePropertyLeaseRecord {\n readonly originalValue: string;\n readonly originalPriority: string;\n writtenValue: string | null;\n writtenPriority: string;\n}\n\n/**\n * Temporarily controls one inline CSS property across a changing set of elements.\n *\n * Authored value and priority are restored only while the declaration still matches\n * the last leased write. A later consumer write therefore wins.\n *\n * The lease has no lifecycle of its own, so it never subscribes to document events:\n * a consumer returns its leases from its own `turbo:before-cache` rewind.\n */\nexport class StylePropertyLease<T extends HTMLElement = HTMLElement> {\n readonly #property: string;\n readonly #records = new Map<T, StylePropertyLeaseRecord>();\n\n /** @param property - The CSS property whose temporary values this lease owns. */\n constructor(property: string) {\n this.#property = property;\n }\n\n /** Writes or removes the leased declaration while preserving its authored value. */\n write(element: T, value: string | null, priority = \"\"): void {\n const existing = this.#records.get(element);\n if (existing) {\n existing.writtenValue = value;\n existing.writtenPriority = value === null ? \"\" : priority;\n } else {\n this.#records.set(element, {\n originalValue: element.style.getPropertyValue(this.#property),\n originalPriority: element.style.getPropertyPriority(this.#property),\n writtenValue: value,\n writtenPriority: value === null ? \"\" : priority,\n });\n }\n\n this.#reflect(element, value, priority);\n }\n\n /** Returns one lease without overwriting a later consumer declaration. */\n return(element: T): void {\n const record = this.#records.get(element);\n if (!record) return;\n this.#records.delete(element);\n\n const style = element.style;\n const stillOwned =\n style.getPropertyValue(this.#property) === (record.writtenValue ?? \"\") &&\n style.getPropertyPriority(this.#property) === record.writtenPriority;\n if (stillOwned) {\n this.#reflect(element, record.originalValue, record.originalPriority);\n }\n }\n\n /** Returns every outstanding declaration lease. */\n returnAll(): void {\n for (const element of Array.from(this.#records.keys())) this.return(element);\n }\n\n /** Reflects only a real declaration transition. */\n #reflect(element: T, value: string | null, priority: string): void {\n const style = element.style;\n const nextValue = value ?? \"\";\n const nextPriority = value === null ? \"\" : priority;\n if (\n style.getPropertyValue(this.#property) === nextValue &&\n style.getPropertyPriority(this.#property) === nextPriority\n ) {\n return;\n }\n if (value === null) style.removeProperty(this.#property);\n else style.setProperty(this.#property, value, priority);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { BeforeCacheReset } from \"../utils/before_cache_reset\";\nimport { StylePropertyLease } from \"../utils/style_property_lease\";\n\n/**\n * Headless **preview guard** (Hotwire-specific): hides or placeholders a volatile element\n * — a balance, a notification count, a live timestamp — *only while Turbo is showing a\n * preview* (`html[data-turbo-preview]`), so a stale cached snapshot does not briefly flash\n * the old value on a back/restore visit. No APG pattern; it keeps displayed information\n * accurate and hands focus back if guarding took it.\n *\n * Markup contract (identifier: `stimeo--preview-guard`):\n * <span data-controller=\"stimeo--preview-guard\"\n * data-stimeo--preview-guard-placeholder-value=\"—\">¥123,456</span>\n *\n * Watches `<html>` for the `data-turbo-preview` attribute with a `MutationObserver`.\n * While it is present the element is guarded, and `placeholder` alone decides how: empty\n * (the default) makes it `visibility: hidden` — its box is kept, so nothing shifts — and\n * any other value takes its place as the element's content. The element carries\n * `data-preview-hidden` and emits `hide`; when the preview clears it is restored and\n * `show` fires. A `placeholder` changed while guarded re-guards to match it.\n *\n * `hide` and `show` dispatch `{}`.\n *\n * @remarks\n * Behavior only — restoring the *fresh* value is the normal render's job, not this\n * controller's (it just un-hides what was there). State is derived from the DOM (no\n * module-scope state), and the guard is reversible in three ways that do not depend on\n * each other: the child nodes a placeholder displaced are held and put back intact, the\n * inline `visibility` is leased so an authored declaration — and one the consumer writes\n * while guarded — survives, and a fresh `connect()` clears a `data-preview-hidden` it\n * finds already on the element. The rewind Turbo's snapshot needs runs on\n * `turbo:before-cache` rather than `disconnect()`, so an in-page move keeps the guard\n * instead of flashing the stale value between the two lifecycle calls — and the instance\n * that comes back re-forms it rather than reading the hook as someone else's leftover.\n *\n * Focus is only ever handed back, never taken: an element that held focus when the guard\n * went up gets it back on show, and focus resting anywhere else is left alone.\n */\nexport class PreviewGuardController extends Controller<HTMLElement> {\n static override values = {\n placeholder: { type: String, default: \"\" },\n };\n static events = [\"hide\", \"show\"] as const;\n\n declare placeholderValue: string;\n\n readonly #visibility = new StylePropertyLease(\"visibility\");\n readonly #beforeCache = new BeforeCacheReset(() => this.#restore());\n\n #observer: MutationObserver | null = null;\n #connected = false;\n #hidden = false;\n /** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */\n #savedNodes: DocumentFragment | null = null;\n /** The descendant that held focus when the guard went up, so show can hand it back. */\n #focused: HTMLElement | null = null;\n\n override connect(): void {\n this.#connected = true;\n // The hook means \"the connection now running has this element guarded\", and which of\n // the two connects is happening is only knowable from the instance. A restored\n // snapshot brings a fresh controller to an element that still carries the hook, with\n // nothing in the DOM to say what was underneath it, so that one drops it. An in-page\n // move brings the *same* instance back still holding the guard, so that one re-forms\n // instead — against the `placeholder` as it stands now, since a morph can swap it\n // while the element is detached, where the value callback has no connection to act on.\n if (this.#hidden) this.#reguard();\n else this.element.removeAttribute(\"data-preview-hidden\");\n this.#beforeCache.activate();\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver(() => this.#sync());\n this.#observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"data-turbo-preview\"],\n });\n }\n // Sync once in case we connect while a preview is already on screen.\n this.#sync();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#beforeCache.deactivate();\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /**\n * Re-guards to match a `placeholder` changed at runtime — the value is the content on\n * display while the guard is up, so a morph that swaps it must not leave the old one.\n */\n placeholderValueChanged(): void {\n if (!this.#connected || !this.#hidden) return;\n this.#reguard();\n }\n\n /**\n * Reflects the current `data-turbo-preview` state onto the element.\n *\n * @stimeoRenderRoot\n */\n #sync(): void {\n const previewing = document.documentElement.hasAttribute(\"data-turbo-preview\");\n if (previewing && !this.#hidden) this.#hide();\n else if (!previewing && this.#hidden) this.#show();\n }\n\n #hide(): void {\n this.#focused = this.#focusedInside();\n this.#applyGuard();\n this.dispatch(\"hide\", { detail: {} });\n }\n\n /** Puts the guard up in the form the current `placeholder` calls for. */\n #applyGuard(): void {\n this.#hidden = true;\n if (this.placeholderValue === \"\") {\n this.#visibility.write(this.element, \"hidden\");\n } else {\n this.#savedNodes = document.createDocumentFragment();\n this.#savedNodes.append(...this.element.childNodes);\n this.element.textContent = this.placeholderValue;\n }\n this.element.setAttribute(\"data-preview-hidden\", \"true\");\n }\n\n /**\n * Re-forms a guard that is already up so it matches the current `placeholder`.\n *\n * No event: `hide` reports that the guard went up, and it has not come down. Swapping\n * one stand-in text for another writes only that text — putting the held content back\n * first would reconnect the whole subtree for an instant. Only a change of *form* —\n * to or from the empty placeholder — has to revert, and the focus the guard is holding\n * carries across it rather than being handed back and taken again.\n */\n #reguard(): void {\n if (this.#savedNodes && this.placeholderValue !== \"\") {\n this.element.textContent = this.placeholderValue;\n return;\n }\n const held = this.#focused;\n this.#revert();\n this.#applyGuard();\n this.#focused = held;\n }\n\n #show(): void {\n this.#restore();\n this.dispatch(\"show\", { detail: {} });\n }\n\n /** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */\n #restore(): void {\n this.#revert();\n this.#refocus();\n }\n\n /**\n * Puts the element back the way the guard found it.\n *\n * Every step is a no-op on an element this controller never guarded: the lease returns\n * only declarations it recorded, and the hook is removed whether or not it is there.\n */\n #revert(): void {\n this.#hidden = false;\n if (this.#savedNodes) {\n this.element.textContent = \"\";\n this.element.append(this.#savedNodes);\n this.#savedNodes = null;\n } else {\n this.#visibility.return(this.element);\n }\n this.element.removeAttribute(\"data-preview-hidden\");\n }\n\n /**\n * The focused element the guard is about to make unfocusable, if any.\n *\n * The element itself counts as well as its descendants: guarding takes focus either way\n * — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes\n * outright — so the browser drops focus to `<body>` the moment the guard goes up.\n */\n #focusedInside(): HTMLElement | null {\n const active = document.activeElement;\n return active instanceof HTMLElement && this.element.contains(active) ? active : null;\n }\n\n /**\n * Hands focus back to the element the guard took it from.\n *\n * Only when that element is still in the document and focus has not moved on since —\n * anything else is the user's or another controller's, and putting it back would be\n * taking it.\n */\n #refocus(): void {\n const target = this.#focused;\n this.#focused = null;\n if (!target?.isConnected || document.activeElement !== document.body) return;\n target.focus();\n }\n}\n"]}
@@ -4,7 +4,7 @@ import { Controller } from '@hotwired/stimulus';
4
4
  * Headless **roving tabindex**: makes a set of `item`s a single Tab stop and
5
5
  * moves focus between them with the arrow keys — the APG roving-tabindex
6
6
  * technique, surfaced as a standalone controller. It is the policy layer over the
7
- * shared {@link RovingTabindex} util (counterpart to Focus Scope over `FocusTrap`),
7
+ * shared {@link RovingTabindex} util (the same split `stimeo--focus` makes over its trap),
8
8
  * giving the orientation / wrap / Home-End the util deliberately leaves out. No
9
9
  * dedicated APG pattern; it is the keyboard primitive Toolbar / Menu / Radio Group
10
10
  * and friends build on. Core (zero dependencies).
@@ -26,6 +26,14 @@ import { Controller } from '@hotwired/stimulus';
26
26
  * `change` when a key press or an incoming focus moves the tabbable item; connect
27
27
  * and target reconciliation re-establish the tab stop silently.
28
28
  *
29
+ * Items that cannot take focus — `hidden` (their own attribute or a wrapper's up
30
+ * to the container) and natively `disabled`, including through a disabled
31
+ * `fieldset` — are neither move targets nor tab-stop candidates: the lone
32
+ * `tabindex="0"` sitting on one would take the whole set out of the Tab sequence
33
+ * while focus stayed behind. `aria-disabled` items stay reachable; suppressing
34
+ * their activation belongs to the consuming pattern. A key that resolves to no
35
+ * reachable item is left to the page (unlike Toolbar, which consumes it).
36
+ *
29
37
  * `change` dispatches `{ index, item }`.
30
38
  *
31
39
  * @remarks
@@ -34,8 +42,12 @@ import { Controller } from '@hotwired/stimulus';
34
42
  * selection / selection-follows-focus, typeahead, or activation (`Enter`/`Space`)
35
43
  * — those stay with the consuming pattern. `connect()` is idempotent: it keeps an
36
44
  * existing tab stop (reads it back from the DOM) and only defaults to the first
37
- * item when none is set, so a Turbo cache restore / morph never resets the user's
38
- * position. The delegated listeners are torn down on `disconnect()`.
45
+ * reachable item when none is set, so a Turbo cache restore / morph never resets
46
+ * the user's position. Re-establishing the stop after a batch of item changes
47
+ * follows DOM focus first, so an item added and focused in the same task keeps it;
48
+ * `connect()` deliberately does not, because the authored DOM is what it reads
49
+ * back. The delegated listeners and the state observer are torn down on
50
+ * `disconnect()`.
39
51
  */
40
52
  declare class RovingController extends Controller<HTMLElement> {
41
53
  #private;
@@ -12,6 +12,20 @@ function isReservedArrowChord(event, allow = []) {
12
12
  if (!event.key.startsWith("Arrow")) return false;
13
13
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
14
  }
15
+ function hasModifierChord(event) {
16
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
17
+ }
18
+
19
+ // src/utils/focus_candidate.ts
20
+ function inheritsFieldsetDisabled(control) {
21
+ let fieldset = control.closest("fieldset[disabled]");
22
+ while (fieldset) {
23
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
24
+ if (!legend?.contains(control)) return true;
25
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
26
+ }
27
+ return false;
28
+ }
15
29
 
16
30
  // src/utils/microtask_coalescer.ts
17
31
  var MicrotaskCoalescer = class {
@@ -88,6 +102,7 @@ function rovingMove(current, length, delta, wrap) {
88
102
  }
89
103
 
90
104
  // src/controllers/roving_controller.ts
105
+ var STATE_ATTRIBUTES = ["disabled", "hidden"];
91
106
  var RovingController = class extends Controller {
92
107
  static targets = ["item"];
93
108
  static values = {
@@ -97,12 +112,14 @@ var RovingController = class extends Controller {
97
112
  };
98
113
  static events = ["change"];
99
114
  #roving = new RovingTabindex(() => this.itemTargets);
100
- #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop());
115
+ #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop(true));
101
116
  #connected = false;
117
+ #observer = null;
102
118
  connect() {
103
- this.#ensureTabStop();
119
+ this.#ensureTabStop(false);
104
120
  this.element.addEventListener("keydown", this.#onKeydown);
105
121
  this.element.addEventListener("focusin", this.#onFocusin);
122
+ this.#watchState();
106
123
  this.#connected = true;
107
124
  this.#reconcile.activate();
108
125
  }
@@ -111,6 +128,8 @@ var RovingController = class extends Controller {
111
128
  this.#reconcile.cancel();
112
129
  this.element.removeEventListener("keydown", this.#onKeydown);
113
130
  this.element.removeEventListener("focusin", this.#onFocusin);
131
+ this.#observer?.disconnect();
132
+ this.#observer = null;
114
133
  }
115
134
  /** Drops a runtime-added item from the Tab sequence before batch reconciliation. */
116
135
  itemTargetConnected(item) {
@@ -126,10 +145,9 @@ var RovingController = class extends Controller {
126
145
  #onKeydown = (event) => {
127
146
  if (event.defaultPrevented) return;
128
147
  if (isReservedArrowChord(event)) return;
129
- const items = this.itemTargets;
148
+ if (event.isComposing) return;
130
149
  const current = this.#indexOf(event.target);
131
150
  if (current === -1) return;
132
- const length = items.length;
133
151
  const wrap = this.wrapValue ? "wrap" : "clamp";
134
152
  const orientation = this.orientationValue;
135
153
  const horizontal = orientation === "horizontal" || orientation === "both";
@@ -139,16 +157,16 @@ var RovingController = class extends Controller {
139
157
  const backwardKey = rtl ? "ArrowRight" : "ArrowLeft";
140
158
  let next;
141
159
  if (horizontal && event.key === forwardKey || vertical && event.key === "ArrowDown") {
142
- next = rovingMove(current, length, 1, wrap);
160
+ next = this.#step(current, 1, wrap);
143
161
  } else if (horizontal && event.key === backwardKey || vertical && event.key === "ArrowUp") {
144
- next = rovingMove(current, length, -1, wrap);
145
- } else if (this.homeEndValue && event.key === "Home") {
146
- next = 0;
147
- } else if (this.homeEndValue && event.key === "End") {
148
- next = length - 1;
162
+ next = this.#step(current, -1, wrap);
163
+ } else if (this.homeEndValue && (event.key === "Home" || event.key === "End")) {
164
+ if (hasModifierChord(event)) return;
165
+ next = event.key === "Home" ? this.#firstReachable() : this.#lastReachable();
149
166
  } else {
150
167
  return;
151
168
  }
169
+ if (next === -1) return;
152
170
  event.preventDefault();
153
171
  this.#activate(next, true);
154
172
  };
@@ -159,7 +177,8 @@ var RovingController = class extends Controller {
159
177
  */
160
178
  #onFocusin = (event) => {
161
179
  const index = this.#indexOf(event.target);
162
- if (index !== -1) this.#activate(index, false);
180
+ if (index === -1 || !this.#reachable(index)) return;
181
+ this.#activate(index, false);
163
182
  };
164
183
  /** Resolves the item index owning an event target (the item or a descendant). */
165
184
  #indexOf(target) {
@@ -175,10 +194,104 @@ var RovingController = class extends Controller {
175
194
  this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
176
195
  }
177
196
  }
178
- /** Keeps the first existing Tab stop, falling back to the first live item. */
179
- #ensureTabStop() {
197
+ /**
198
+ * Keeps the Tab stop on a reachable item.
199
+ *
200
+ * `followFocus` is on for re-establishment only: an item added and focused in
201
+ * the same task has already claimed the stop through `focusin`, and the batch
202
+ * that follows must not hand it back to the first item. `connect()` passes it
203
+ * off so the authored DOM decides the initial stop. With nothing reachable the
204
+ * DOM is left as it is — a group inside a collapsed region gets its stop back
205
+ * when the region opens, instead of losing it for good.
206
+ */
207
+ #ensureTabStop(followFocus) {
208
+ const items = this.itemTargets;
209
+ const focused = followFocus ? items.findIndex((item, i) => item === document.activeElement && this.#reachable(i)) : -1;
180
210
  const active = this.#roving.activeIndex;
181
- this.#roving.setActive(active === -1 ? 0 : active);
211
+ const kept = active !== -1 && this.#reachable(active) ? active : -1;
212
+ const index = focused !== -1 ? focused : kept !== -1 ? kept : this.#firstReachable();
213
+ if (index === -1) return;
214
+ this.#roving.setActive(index);
215
+ }
216
+ /**
217
+ * Resolves the next reachable index in `delta`'s direction, honouring `wrap`.
218
+ *
219
+ * An arrow on the widget's own axis is the widget's to consume even when the
220
+ * position does not change, so a clamped end resolves to the current item
221
+ * rather than to nothing. An unreachable origin escapes to the first reachable
222
+ * item instead of sitting in a dead end, and `-1` is left for the one case that
223
+ * really is not ours: no reachable item anywhere.
224
+ */
225
+ #step(current, delta, wrap) {
226
+ const length = this.itemTargets.length;
227
+ let index = current;
228
+ for (let taken = 0; taken < length; taken += 1) {
229
+ const candidate = rovingMove(index, length, delta, wrap);
230
+ if (candidate === index) break;
231
+ index = candidate;
232
+ if (this.#reachable(index)) return index;
233
+ }
234
+ return this.#reachable(current) ? current : this.#firstReachable();
235
+ }
236
+ /** Index of the first reachable item, or `-1`. */
237
+ #firstReachable() {
238
+ return this.itemTargets.findIndex((_item, i) => this.#reachable(i));
239
+ }
240
+ /** Index of the last reachable item, or `-1`. */
241
+ #lastReachable() {
242
+ for (let i = this.itemTargets.length - 1; i >= 0; i -= 1) {
243
+ if (this.#reachable(i)) return i;
244
+ }
245
+ return -1;
246
+ }
247
+ /**
248
+ * Whether the item at `index` can hold the Tab stop and take focus.
249
+ *
250
+ * `aria-disabled` is deliberately not consulted: it keeps an item reachable and
251
+ * only suppresses activation, which this part does not own.
252
+ */
253
+ #reachable(index) {
254
+ const item = this.itemTargets[index];
255
+ if (!item) return false;
256
+ if (this.#hidden(item)) return false;
257
+ if (!("disabled" in item)) return true;
258
+ if (item.disabled) return false;
259
+ return !inheritsFieldsetDisabled(item);
260
+ }
261
+ /**
262
+ * Whether `item`, or anything between it and the container, is `hidden`.
263
+ *
264
+ * The walk stops at the container on purpose: a group inside a hidden region is
265
+ * already out of the page's Tab order, and calling every item unreachable there
266
+ * would drop the stop with nothing left to restore it — the ancestor lies
267
+ * outside the subtree whose state attributes are watched.
268
+ */
269
+ #hidden(item) {
270
+ let node = item;
271
+ while (node && node !== this.element) {
272
+ if (node.hasAttribute("hidden")) return true;
273
+ node = node.parentElement;
274
+ }
275
+ return false;
276
+ }
277
+ /**
278
+ * Watches the attributes that decide reachability, so disabling the item that
279
+ * holds the Tab stop hands it to another one instead of taking the whole set
280
+ * out of the Tab sequence. An enclosing `fieldset` disables items from outside
281
+ * the observed subtree, so each one's own `disabled` is watched too.
282
+ */
283
+ #watchState() {
284
+ if (typeof MutationObserver === "undefined") return;
285
+ const observer = new MutationObserver(() => this.#reconcile.schedule());
286
+ observer.observe(this.element, {
287
+ subtree: true,
288
+ attributes: true,
289
+ attributeFilter: STATE_ATTRIBUTES
290
+ });
291
+ for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
292
+ observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
293
+ }
294
+ this.#observer = observer;
182
295
  }
183
296
  };
184
297