stimeo-ui 0.10.0 → 0.12.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.
- package/CHANGELOG.md +145 -0
- package/dist/cable/index.d.ts +79 -14
- package/dist/cable/index.js +249 -45
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/count_up_controller.d.ts +12 -10
- package/dist/controllers/count_up_controller.js +74 -35
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.d.ts +20 -7
- package/dist/controllers/intersection_controller.js +55 -8
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.d.ts +31 -9
- package/dist/controllers/lazy_frame_controller.js +81 -19
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +20 -3
- package/dist/controllers/pointer_drag_controller.js +68 -18
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +16 -3
- package/dist/controllers/portal_controller.js +32 -11
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/preview_guard_controller.d.ts +22 -14
- package/dist/controllers/preview_guard_controller.js +180 -20
- package/dist/controllers/preview_guard_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.d.ts +18 -9
- package/dist/controllers/reading_progress_controller.js +177 -6
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/roving_controller.d.ts +15 -3
- package/dist/controllers/roving_controller.js +127 -14
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js +13 -2
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.d.ts +17 -8
- package/dist/controllers/smart_sticky_header_controller.js +52 -10
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +38 -8
- package/dist/controllers/sortable_controller.js +241 -67
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +23 -7
- package/dist/controllers/stick_to_bottom_controller.js +149 -28
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js +13 -2
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/index.js +934 -285
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js +1 -0
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +1 -0
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +4 -4
- package/dist/inspector/manifest.json +16 -56
- package/package.json +3 -3
|
@@ -1,12 +1,158 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/reading_progress_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/layout_observer.ts
|
|
35
|
+
var LayoutObserver = class {
|
|
36
|
+
#callback;
|
|
37
|
+
#resizeObserverFactory;
|
|
38
|
+
#resizeObserver = null;
|
|
39
|
+
#observingViewport = false;
|
|
40
|
+
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
41
|
+
#handleViewportResize = () => {
|
|
42
|
+
this.#callback();
|
|
43
|
+
};
|
|
44
|
+
constructor(callback, options = {}) {
|
|
45
|
+
this.#callback = callback;
|
|
46
|
+
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Starts observing an element's size. Repeated calls observe additional
|
|
50
|
+
* elements through the same shared observer. No-ops when no
|
|
51
|
+
* `ResizeObserver` implementation is available.
|
|
52
|
+
*/
|
|
53
|
+
observe(element) {
|
|
54
|
+
if (!this.#resizeObserverFactory) return;
|
|
55
|
+
if (!this.#resizeObserver) {
|
|
56
|
+
this.#resizeObserver = this.#resizeObserverFactory(() => {
|
|
57
|
+
this.#callback();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
this.#resizeObserver.observe(element);
|
|
61
|
+
}
|
|
62
|
+
/** Stops observing a single element while leaving any others in place. */
|
|
63
|
+
unobserve(element) {
|
|
64
|
+
this.#resizeObserver?.unobserve(element);
|
|
65
|
+
}
|
|
66
|
+
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
67
|
+
observeViewport() {
|
|
68
|
+
if (this.#observingViewport) return;
|
|
69
|
+
this.#observingViewport = true;
|
|
70
|
+
window.addEventListener("resize", this.#handleViewportResize);
|
|
71
|
+
}
|
|
72
|
+
/** Stops observing viewport resizes without affecting element observation. */
|
|
73
|
+
unobserveViewport() {
|
|
74
|
+
if (!this.#observingViewport) return;
|
|
75
|
+
this.#observingViewport = false;
|
|
76
|
+
window.removeEventListener("resize", this.#handleViewportResize);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and
|
|
80
|
+
* removes the viewport listener. Safe to call multiple times. Call this from a
|
|
81
|
+
* controller's `disconnect()`.
|
|
82
|
+
*/
|
|
83
|
+
disconnect() {
|
|
84
|
+
this.#resizeObserver?.disconnect();
|
|
85
|
+
this.#resizeObserver = null;
|
|
86
|
+
this.unobserveViewport();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/utils/style_property_lease.ts
|
|
91
|
+
var StylePropertyLease = class {
|
|
92
|
+
#property;
|
|
93
|
+
#records = /* @__PURE__ */ new Map();
|
|
94
|
+
/** @param property - The CSS property whose temporary values this lease owns. */
|
|
95
|
+
constructor(property) {
|
|
96
|
+
this.#property = property;
|
|
97
|
+
}
|
|
98
|
+
/** Writes or removes the leased declaration while preserving its authored value. */
|
|
99
|
+
write(element, value, priority = "") {
|
|
100
|
+
const existing = this.#records.get(element);
|
|
101
|
+
if (existing) {
|
|
102
|
+
existing.writtenValue = value;
|
|
103
|
+
existing.writtenPriority = value === null ? "" : priority;
|
|
104
|
+
} else {
|
|
105
|
+
this.#records.set(element, {
|
|
106
|
+
originalValue: element.style.getPropertyValue(this.#property),
|
|
107
|
+
originalPriority: element.style.getPropertyPriority(this.#property),
|
|
108
|
+
writtenValue: value,
|
|
109
|
+
writtenPriority: value === null ? "" : priority
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
this.#reflect(element, value, priority);
|
|
113
|
+
}
|
|
114
|
+
/** Returns one lease without overwriting a later consumer declaration. */
|
|
115
|
+
return(element) {
|
|
116
|
+
const record = this.#records.get(element);
|
|
117
|
+
if (!record) return;
|
|
118
|
+
this.#records.delete(element);
|
|
119
|
+
const style = element.style;
|
|
120
|
+
const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
|
|
121
|
+
if (stillOwned) {
|
|
122
|
+
this.#reflect(element, record.originalValue, record.originalPriority);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Returns every outstanding declaration lease. */
|
|
126
|
+
returnAll() {
|
|
127
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
128
|
+
}
|
|
129
|
+
/** Reflects only a real declaration transition. */
|
|
130
|
+
#reflect(element, value, priority) {
|
|
131
|
+
const style = element.style;
|
|
132
|
+
const nextValue = value ?? "";
|
|
133
|
+
const nextPriority = value === null ? "" : priority;
|
|
134
|
+
if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (value === null) style.removeProperty(this.#property);
|
|
138
|
+
else style.setProperty(this.#property, value, priority);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
3
142
|
// src/controllers/reading_progress_controller.ts
|
|
4
143
|
var PROGRESS_PROPERTY = "--stimeo--reading-progress";
|
|
5
144
|
var ReadingProgressController = class extends Controller {
|
|
6
145
|
static events = ["change", "complete"];
|
|
146
|
+
/** Owns both faces of the published property so teardown can hand them back. */
|
|
147
|
+
#lease = new StylePropertyLease(PROGRESS_PROPERTY);
|
|
148
|
+
/** The article's own box and the viewport: either changes the span. */
|
|
149
|
+
#layout = new LayoutObserver(() => this.#onScroll());
|
|
150
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
7
151
|
#frame = null;
|
|
8
152
|
/** Last published progress, so `change`/`complete` fire only on movement. */
|
|
9
153
|
#progress = -1;
|
|
154
|
+
/** False until the connect frame has run: `complete` needs real reading. */
|
|
155
|
+
#baselined = false;
|
|
10
156
|
#onScroll = () => {
|
|
11
157
|
if (this.#frame !== null) return;
|
|
12
158
|
this.#frame = requestAnimationFrame(() => {
|
|
@@ -16,20 +162,45 @@ var ReadingProgressController = class extends Controller {
|
|
|
16
162
|
};
|
|
17
163
|
connect() {
|
|
18
164
|
this.#progress = -1;
|
|
165
|
+
this.#baselined = false;
|
|
19
166
|
window.addEventListener("scroll", this.#onScroll, { passive: true, capture: true });
|
|
20
|
-
|
|
167
|
+
this.#layout.observe(this.element);
|
|
168
|
+
this.#layout.observeViewport();
|
|
169
|
+
this.#beforeCache.activate();
|
|
21
170
|
this.#measure();
|
|
171
|
+
this.#frame = requestAnimationFrame(() => {
|
|
172
|
+
this.#frame = null;
|
|
173
|
+
this.#measure();
|
|
174
|
+
this.#baselined = true;
|
|
175
|
+
});
|
|
22
176
|
}
|
|
23
177
|
disconnect() {
|
|
24
178
|
window.removeEventListener("scroll", this.#onScroll, { capture: true });
|
|
25
|
-
|
|
179
|
+
this.#layout.disconnect();
|
|
180
|
+
this.#beforeCache.deactivate();
|
|
181
|
+
this.#cancelFrame();
|
|
182
|
+
this.#lease.returnAll();
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Hands both declarations back before the page is snapshotted, so a restored
|
|
186
|
+
* page starts from the authored DOM rather than from someone else's progress.
|
|
187
|
+
* The baseline goes back with them: a cancelled visit leaves this page on
|
|
188
|
+
* screen, and the next measurement has to publish afresh rather than match a
|
|
189
|
+
* value that has already been handed back.
|
|
190
|
+
*/
|
|
191
|
+
#rewindForCache() {
|
|
192
|
+
this.#cancelFrame();
|
|
193
|
+
this.#lease.returnAll();
|
|
194
|
+
this.#progress = -1;
|
|
195
|
+
}
|
|
196
|
+
#cancelFrame() {
|
|
26
197
|
if (this.#frame !== null) cancelAnimationFrame(this.#frame);
|
|
27
198
|
this.#frame = null;
|
|
28
|
-
document.documentElement.style.removeProperty(PROGRESS_PROPERTY);
|
|
29
199
|
}
|
|
30
200
|
/** Computes and publishes the progress; emits on movement only. */
|
|
31
201
|
#measure() {
|
|
32
202
|
const rect = this.element.getBoundingClientRect();
|
|
203
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
33
204
|
const span = rect.height - window.innerHeight;
|
|
34
205
|
const raw = span > 0 ? -rect.top / span : rect.top <= 0 ? 1 : 0;
|
|
35
206
|
const progress = Math.min(1, Math.max(0, raw));
|
|
@@ -37,10 +208,10 @@ var ReadingProgressController = class extends Controller {
|
|
|
37
208
|
const previous = this.#progress;
|
|
38
209
|
this.#progress = progress;
|
|
39
210
|
const value = String(progress);
|
|
40
|
-
this.element
|
|
41
|
-
document.documentElement
|
|
211
|
+
this.#lease.write(this.element, value);
|
|
212
|
+
this.#lease.write(document.documentElement, value);
|
|
42
213
|
this.dispatch("change", { detail: { progress } });
|
|
43
|
-
if (progress === 1 && previous !== -1) this.dispatch("complete");
|
|
214
|
+
if (progress === 1 && previous !== -1 && this.#baselined) this.dispatch("complete");
|
|
44
215
|
}
|
|
45
216
|
};
|
|
46
217
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/reading_progress_controller.ts"],"names":[],"mappings":";;;AAGA,IAAM,iBAAA,GAAoB,4BAAA;AAoCnB,IAAM,yBAAA,GAAN,cAAwC,UAAA,CAAwB;AAAA,EACrE,OAAO,MAAA,GAAS,CAAC,QAAA,EAAU,UAAU,CAAA;AAAA,EAErC,MAAA,GAAwB,IAAA;AAAA;AAAA,EAExB,SAAA,GAAY,EAAA;AAAA,EAEH,YAAY,MAAY;AAC/B,IAAA,IAAI,IAAA,CAAK,WAAW,IAAA,EAAM;AAC1B,IAAA,IAAA,CAAK,MAAA,GAAS,sBAAsB,MAAM;AACxC,MAAA,IAAA,CAAK,MAAA,GAAS,IAAA;AACd,MAAA,IAAA,CAAK,QAAA,EAAS;AAAA,IAChB,CAAC,CAAA;AAAA,EACH,CAAA;AAAA,EAES,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,SAAA,GAAY,EAAA;AAIjB,IAAA,MAAA,CAAO,gBAAA,CAAiB,UAAU,IAAA,CAAK,SAAA,EAAW,EAAE,OAAA,EAAS,IAAA,EAAM,OAAA,EAAS,IAAA,EAAM,CAAA;AAClF,IAAA,MAAA,CAAO,iBAAiB,QAAA,EAAU,IAAA,CAAK,WAAW,EAAE,OAAA,EAAS,MAAM,CAAA;AACnE,IAAA,IAAA,CAAK,QAAA,EAAS;AAAA,EAChB;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,MAAA,CAAO,oBAAoB,QAAA,EAAU,IAAA,CAAK,WAAW,EAAE,OAAA,EAAS,MAAM,CAAA;AACtE,IAAA,MAAA,CAAO,mBAAA,CAAoB,QAAA,EAAU,IAAA,CAAK,SAAS,CAAA;AACnD,IAAA,IAAI,IAAA,CAAK,MAAA,KAAW,IAAA,EAAM,oBAAA,CAAqB,KAAK,MAAM,CAAA;AAC1D,IAAA,IAAA,CAAK,MAAA,GAAS,IAAA;AACd,IAAA,QAAA,CAAS,eAAA,CAAgB,KAAA,CAAM,cAAA,CAAe,iBAAiB,CAAA;AAAA,EACjE;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,OAAA,CAAQ,qBAAA,EAAsB;AAChD,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,MAAA,GAAS,MAAA,CAAO,WAAA;AAElC,IAAA,MAAM,GAAA,GAAM,IAAA,GAAO,CAAA,GAAI,CAAC,IAAA,CAAK,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,IAAO,CAAA,GAAI,CAAA,GAAI,CAAA;AAC9D,IAAA,MAAM,QAAA,GAAW,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,GAAA,CAAI,CAAA,EAAG,GAAG,CAAC,CAAA;AAC7C,IAAA,IAAI,QAAA,KAAa,KAAK,SAAA,EAAW;AAEjC,IAAA,MAAM,WAAW,IAAA,CAAK,SAAA;AACtB,IAAA,IAAA,CAAK,SAAA,GAAY,QAAA;AACjB,IAAA,MAAM,KAAA,GAAQ,OAAO,QAAQ,CAAA;AAC7B,IAAA,IAAA,CAAK,OAAA,CAAQ,KAAA,CAAM,WAAA,CAAY,iBAAA,EAAmB,KAAK,CAAA;AACvD,IAAA,QAAA,CAAS,eAAA,CAAgB,KAAA,CAAM,WAAA,CAAY,iBAAA,EAAmB,KAAK,CAAA;AACnE,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU,EAAE,QAAQ,EAAE,QAAA,IAAY,CAAA;AAChD,IAAA,IAAI,aAAa,CAAA,IAAK,QAAA,KAAa,EAAA,EAAI,IAAA,CAAK,SAAS,UAAU,CAAA;AAAA,EACjE;AACF","file":"reading_progress_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** Name of the CSS custom property exposing the reading progress (0..1). */\nconst PROGRESS_PROPERTY = \"--stimeo--reading-progress\";\n\n/**\n * Headless **reading progress**: how far the reader has scrolled *through* this\n * element (an article), published as a CSS custom property and a `change`\n * event stream — the classic top-of-page progress bar.\n * `IntersectionObserver` alone cannot express this (the ratio is constant\n * while a tall article scrolls through the viewport), so this controller owns\n * the scroll math; compose with `stimeo--intersection` when you also need\n * enter/exit triggers. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--reading-progress`):\n * <article data-controller=\"stimeo--reading-progress\">…</article>\n * <div class=\"progress-bar\" aria-hidden=\"true\"></div>\n * <!-- .progress-bar { width: calc(var(--stimeo--reading-progress, 0) * 100%); } -->\n *\n * Progress is `0` before the article's top reaches the viewport top and `1`\n * once its bottom fits the viewport: `-top / (height - viewportHeight)`,\n * clamped. The property is written on the controller element **and** on\n * `document.documentElement`, so a fixed bar anywhere in the page can consume\n * it without being a descendant. The `:root` copy makes this a\n * one-instance-per-page contract (two articles would fight last-writer-wins);\n * `complete` fires on *reaching* 1 — a connect-time measurement that is\n * already 1 (e.g. a Turbo restore at the bottom) only establishes the\n * baseline and does not fire it.\n *\n * `change` dispatches `{ progress }`.\n *\n * @remarks\n * Behavior only — the bar itself (and hiding it, e.g. before any scroll) is\n * the consumer's CSS; the progress value carries no ARIA (a decorative\n * indicator — mark the bar `aria-hidden`; a *semantic* progress belongs to\n * `stimeo--progress`). Scroll/resize work is rAF-throttled; the listeners and\n * any pending frame are released and the root custom property removed on\n * `disconnect()` (Turbo navigation included).\n */\nexport class ReadingProgressController extends Controller<HTMLElement> {\n static events = [\"change\", \"complete\"] as const;\n\n #frame: number | null = null;\n /** Last published progress, so `change`/`complete` fire only on movement. */\n #progress = -1;\n\n readonly #onScroll = (): void => {\n if (this.#frame !== null) return;\n this.#frame = requestAnimationFrame(() => {\n this.#frame = null;\n this.#measure();\n });\n };\n\n override connect(): void {\n this.#progress = -1;\n // Capture phase: element scrolls do not bubble, but they ARE observable at\n // the window in capture — so an article inside an overflow container still\n // drives the progress.\n window.addEventListener(\"scroll\", this.#onScroll, { passive: true, capture: true });\n window.addEventListener(\"resize\", this.#onScroll, { passive: true });\n this.#measure();\n }\n\n override disconnect(): void {\n window.removeEventListener(\"scroll\", this.#onScroll, { capture: true });\n window.removeEventListener(\"resize\", this.#onScroll);\n if (this.#frame !== null) cancelAnimationFrame(this.#frame);\n this.#frame = null;\n document.documentElement.style.removeProperty(PROGRESS_PROPERTY);\n }\n\n /** Computes and publishes the progress; emits on movement only. */\n #measure(): void {\n const rect = this.element.getBoundingClientRect();\n const span = rect.height - window.innerHeight;\n // Shorter than the viewport: reading it is binary (reached or not).\n const raw = span > 0 ? -rect.top / span : rect.top <= 0 ? 1 : 0;\n const progress = Math.min(1, Math.max(0, raw));\n if (progress === this.#progress) return;\n\n const previous = this.#progress;\n this.#progress = progress;\n const value = String(progress);\n this.element.style.setProperty(PROGRESS_PROPERTY, value);\n document.documentElement.style.setProperty(PROGRESS_PROPERTY, value);\n this.dispatch(\"change\", { detail: { progress } });\n if (progress === 1 && previous !== -1) this.dispatch(\"complete\");\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/before_cache_reset.ts","../../src/utils/layout_observer.ts","../../src/utils/style_property_lease.ts","../../src/controllers/reading_progress_controller.ts"],"names":[],"mappings":"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","file":"reading_progress_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","/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Widgets whose output is measured — an overflow boundary, a masonry column count,\n * an autosized textarea — need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\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 { LayoutObserver } from \"../utils/layout_observer\";\nimport { StylePropertyLease } from \"../utils/style_property_lease\";\n\n/** Name of the CSS custom property exposing the reading progress (0..1). */\nconst PROGRESS_PROPERTY = \"--stimeo--reading-progress\";\n\n/**\n * Headless **reading progress**: how far the reader has scrolled *through* this\n * element (an article), published as a CSS custom property and a `change`\n * event stream — the classic top-of-page progress bar.\n * `IntersectionObserver` alone cannot express this (the ratio is constant\n * while a tall article scrolls through the viewport), so this controller owns\n * the scroll math; compose with `stimeo--intersection` when you also need\n * enter/exit triggers. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--reading-progress`):\n * <article data-controller=\"stimeo--reading-progress\">…</article>\n * <div class=\"progress-bar\" aria-hidden=\"true\"></div>\n * <!-- .progress-bar { width: calc(var(--stimeo--reading-progress, 0) * 100%); } -->\n *\n * Progress is `0` before the article's top reaches the viewport top and `1`\n * once its bottom fits the viewport: `-top / (height - viewportHeight)`,\n * clamped. An article no taller than the viewport has no such span, so reading\n * it is binary: `1` from the moment its top reaches the viewport top. An\n * article with no layout box at all — a `display: none` ancestor, a collapsed\n * `<details>`, an inactive tab panel — is not measured: an empty rect sits at\n * the document origin and carries no reading position, so publishing anything\n * for it would report a place the reader never reached.\n *\n * The property is written on the controller element **and** on\n * `document.documentElement`, so a fixed bar anywhere in the page can consume\n * it without being a descendant. Both are written through a lease, so an\n * authored declaration comes back on teardown and a later writer is left alone.\n * `complete` fires on *reaching* 1, and never during the baseline: the frame in\n * which the controller connects belongs to establishing where the reader\n * already is (a restored scroll position lands there), not to reading.\n *\n * `change` dispatches `{ progress }`.\n *\n * @remarks\n * Behavior only — the bar itself (and hiding it, e.g. before any scroll) is\n * the consumer's CSS; the progress value carries no ARIA (a decorative\n * indicator — mark the bar `aria-hidden`; a *semantic* progress belongs to\n * `stimeo--progress`). The article's own box is watched as well as the\n * viewport, so content that settles late (images, fonts) re-measures instead of\n * leaving a stale span. Scroll and layout work is rAF-throttled; the listeners,\n * the observers, any pending frame and both leased declarations are released on\n * `disconnect()` and returned again for a `turbo:before-cache` snapshot.\n */\nexport class ReadingProgressController extends Controller<HTMLElement> {\n static events = [\"change\", \"complete\"] as const;\n\n /** Owns both faces of the published property so teardown can hand them back. */\n readonly #lease = new StylePropertyLease(PROGRESS_PROPERTY);\n /** The article's own box and the viewport: either changes the span. */\n readonly #layout = new LayoutObserver(() => this.#onScroll());\n readonly #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());\n #frame: number | null = null;\n /** Last published progress, so `change`/`complete` fire only on movement. */\n #progress = -1;\n /** False until the connect frame has run: `complete` needs real reading. */\n #baselined = false;\n\n readonly #onScroll = (): void => {\n if (this.#frame !== null) return;\n this.#frame = requestAnimationFrame(() => {\n this.#frame = null;\n this.#measure();\n });\n };\n\n override connect(): void {\n this.#progress = -1;\n this.#baselined = false;\n // Capture phase: element scrolls do not bubble, but they ARE observable at\n // the window in capture — so an article inside an overflow container still\n // drives the progress.\n window.addEventListener(\"scroll\", this.#onScroll, { passive: true, capture: true });\n this.#layout.observe(this.element);\n this.#layout.observeViewport();\n this.#beforeCache.activate();\n this.#measure();\n // The page's scroll position is restored *after* the controller connects,\n // so that jump arrives as a move the reader never made. Everything up to\n // the end of this frame is still the baseline — a restored scroll coalesces\n // into the frame below, and no reader can cross an article inside one.\n this.#frame = requestAnimationFrame(() => {\n this.#frame = null;\n this.#measure();\n this.#baselined = true;\n });\n }\n\n override disconnect(): void {\n window.removeEventListener(\"scroll\", this.#onScroll, { capture: true });\n this.#layout.disconnect();\n this.#beforeCache.deactivate();\n this.#cancelFrame();\n this.#lease.returnAll();\n }\n\n /**\n * Hands both declarations back before the page is snapshotted, so a restored\n * page starts from the authored DOM rather than from someone else's progress.\n * The baseline goes back with them: a cancelled visit leaves this page on\n * screen, and the next measurement has to publish afresh rather than match a\n * value that has already been handed back.\n */\n #rewindForCache(): void {\n this.#cancelFrame();\n this.#lease.returnAll();\n this.#progress = -1;\n }\n\n #cancelFrame(): void {\n if (this.#frame !== null) cancelAnimationFrame(this.#frame);\n this.#frame = null;\n }\n\n /** Computes and publishes the progress; emits on movement only. */\n #measure(): void {\n const rect = this.element.getBoundingClientRect();\n // No layout box (a `display: none` ancestor, a collapsed `<details>`): the\n // empty rect sits at the document origin, where the binary branch below\n // would read its `top` of 0 as \"the reader reached it\". There is no reading\n // position to publish, so the last one stands until the article is laid out\n // again — which the box observer reports.\n if (rect.width === 0 && rect.height === 0) return;\n\n const span = rect.height - window.innerHeight;\n // Shorter than the viewport: reading it is binary (reached or not).\n const raw = span > 0 ? -rect.top / span : rect.top <= 0 ? 1 : 0;\n const progress = Math.min(1, Math.max(0, raw));\n if (progress === this.#progress) return;\n\n const previous = this.#progress;\n this.#progress = progress;\n const value = String(progress);\n this.#lease.write(this.element, value);\n this.#lease.write(document.documentElement, value);\n this.dispatch(\"change\", { detail: { progress } });\n if (progress === 1 && previous !== -1 && this.#baselined) this.dispatch(\"complete\");\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 (
|
|
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
|
|
38
|
-
* position.
|
|
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
|
-
|
|
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 =
|
|
160
|
+
next = this.#step(current, 1, wrap);
|
|
143
161
|
} else if (horizontal && event.key === backwardKey || vertical && event.key === "ArrowUp") {
|
|
144
|
-
next =
|
|
145
|
-
} else if (this.homeEndValue && event.key === "Home") {
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
/**
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|