stimeo-ui 0.9.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -0
- data/dist/cable/index.js +226 -43
- data/dist/controllers/alert_dialog_controller.js +17 -7
- data/dist/controllers/announcer_controller.js +1 -1
- data/dist/controllers/bulk_select_controller.js +2 -2
- data/dist/controllers/collapsible_controller.js +1 -1
- data/dist/controllers/command_palette_controller.js +15 -5
- data/dist/controllers/confirm_controller.js +15 -5
- data/dist/controllers/countdown_controller.js +1 -1
- data/dist/controllers/data_grid_controller.js +1 -1
- data/dist/controllers/dialog_controller.js +15 -5
- data/dist/controllers/direct_upload_controller.js +1 -5
- data/dist/controllers/drawer_controller.js +17 -7
- data/dist/controllers/focus_controller.js +45 -7
- data/dist/controllers/form_field_controller.js +1 -1
- data/dist/controllers/menubar_controller.js +2 -2
- data/dist/controllers/multi_select_controller.js +1 -1
- data/dist/controllers/overflow_menu_controller.js +2 -2
- data/dist/controllers/password_reveal_controller.js +102 -3
- data/dist/controllers/password_strength_controller.js +287 -42
- data/dist/controllers/pointer_drag_controller.js +64 -18
- data/dist/controllers/portal_controller.js +32 -11
- data/dist/controllers/preview_guard_controller.js +180 -20
- data/dist/controllers/resizable_controller.js +1 -1
- data/dist/controllers/roving_controller.js +127 -14
- data/dist/controllers/scroll_restore_controller.js +127 -27
- data/dist/controllers/scroll_visibility_controller.js +167 -14
- data/dist/controllers/sidebar_controller.js +15 -5
- data/dist/controllers/step_indicator_controller.js +0 -5
- data/dist/controllers/stick_to_bottom_controller.js +149 -28
- data/dist/controllers/theme_controller.js +151 -40
- data/dist/controllers/toast_controller.js +2 -2
- data/dist/controllers/transition_controller.js +79 -15
- data/dist/index.js +1188 -318
- data/dist/positioning/index.js +84 -25
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -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
|
-
/**
|
|
13
|
-
#
|
|
14
|
-
/**
|
|
15
|
-
#
|
|
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
|
-
/**
|
|
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.
|
|
40
|
-
this.#
|
|
41
|
-
this.element.textContent = this.placeholderValue;
|
|
147
|
+
if (this.placeholderValue === "") {
|
|
148
|
+
this.#visibility.write(this.element, "hidden");
|
|
42
149
|
} else {
|
|
43
|
-
this.#
|
|
44
|
-
this.element.
|
|
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
|
-
|
|
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
|
|
179
|
+
/** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */
|
|
54
180
|
#restore() {
|
|
55
|
-
|
|
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.#
|
|
58
|
-
this.element.textContent =
|
|
59
|
-
this.#
|
|
192
|
+
if (this.#savedNodes) {
|
|
193
|
+
this.element.textContent = "";
|
|
194
|
+
this.element.append(this.#savedNodes);
|
|
195
|
+
this.#savedNodes = null;
|
|
60
196
|
} else {
|
|
61
|
-
this.element
|
|
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 };
|
|
@@ -251,7 +251,7 @@ var ResizableController = class extends Controller {
|
|
|
251
251
|
this.#dispatchChange();
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
|
-
/**
|
|
254
|
+
/** Collapses the primary pane to its minimum, or returns it to the last position. */
|
|
255
255
|
toggle() {
|
|
256
256
|
const { min, max } = this.#range;
|
|
257
257
|
if (this.#position > min) {
|
|
@@ -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
|
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
// src/controllers/scroll_restore_controller.ts
|
|
4
|
+
function parseStored(raw) {
|
|
5
|
+
if (raw === null) return null;
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(raw);
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function storedOffset(value) {
|
|
13
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
14
|
+
}
|
|
4
15
|
var ScrollRestoreController = class extends Controller {
|
|
5
16
|
static values = {
|
|
6
17
|
key: { type: String, default: "" },
|
|
@@ -13,6 +24,26 @@ var ScrollRestoreController = class extends Controller {
|
|
|
13
24
|
/** Last offset captured while the element was live; persisted as-is on teardown. */
|
|
14
25
|
#lastTop = 0;
|
|
15
26
|
#lastLeft = 0;
|
|
27
|
+
/**
|
|
28
|
+
* The offsets this controller's own restore produced, awaiting the `scroll`
|
|
29
|
+
* the engine fires for them. A restore the layout cannot reach yet lands short,
|
|
30
|
+
* so treating that echo as the reader's position would cut the saved offset
|
|
31
|
+
* down to whatever the unfinished layout allowed.
|
|
32
|
+
*/
|
|
33
|
+
#echoTop = null;
|
|
34
|
+
#echoLeft = null;
|
|
35
|
+
/** Offsets stored for an axis that is not tracked, carried through saves. */
|
|
36
|
+
#carried = {};
|
|
37
|
+
/**
|
|
38
|
+
* Whether each axis holds an offset worth saving — one this namespace restored
|
|
39
|
+
* or the reader moved. A save writes only the axes that are marked, so an
|
|
40
|
+
* offset taken under a different key or axis is never re-published as if the
|
|
41
|
+
* reader had left it there.
|
|
42
|
+
*/
|
|
43
|
+
#capturedTop = false;
|
|
44
|
+
#capturedLeft = false;
|
|
45
|
+
/** Whether the controller is connected; Value callbacks outside that window do not resync. */
|
|
46
|
+
#connected = false;
|
|
16
47
|
#onScroll = () => {
|
|
17
48
|
this.#capture();
|
|
18
49
|
if (this.#rafId !== null) return;
|
|
@@ -22,58 +53,127 @@ var ScrollRestoreController = class extends Controller {
|
|
|
22
53
|
});
|
|
23
54
|
};
|
|
24
55
|
connect() {
|
|
56
|
+
this.#connected = true;
|
|
25
57
|
this.#storageKey = this.#resolveKey();
|
|
26
|
-
if (!this.#storageKey) return;
|
|
27
|
-
this.#restore();
|
|
28
58
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
59
|
+
if (this.#storageKey) this.#restore();
|
|
29
60
|
}
|
|
30
61
|
disconnect() {
|
|
31
|
-
|
|
62
|
+
this.#connected = false;
|
|
32
63
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
64
|
+
this.#cancelFrame();
|
|
65
|
+
this.#persist();
|
|
66
|
+
}
|
|
67
|
+
/** Re-derives the namespace when application code or a Turbo morph moves `key`. */
|
|
68
|
+
keyValueChanged() {
|
|
69
|
+
this.#resync();
|
|
70
|
+
}
|
|
71
|
+
/** Re-derives the tracked axes when application code or a Turbo morph moves `axis`. */
|
|
72
|
+
axisValueChanged() {
|
|
73
|
+
this.#resync();
|
|
74
|
+
}
|
|
75
|
+
/** Rebuilds the persistence state around the Values as they now read. */
|
|
76
|
+
#resync() {
|
|
77
|
+
if (!this.#connected) return;
|
|
78
|
+
this.#cancelFrame();
|
|
79
|
+
this.#storageKey = this.#resolveKey();
|
|
80
|
+
this.#echoTop = null;
|
|
81
|
+
this.#echoLeft = null;
|
|
82
|
+
this.#carried = {};
|
|
83
|
+
this.#capturedTop = false;
|
|
84
|
+
this.#capturedLeft = false;
|
|
85
|
+
if (this.#storageKey) this.#restore();
|
|
86
|
+
}
|
|
87
|
+
/** Drops the pending coalesced save, if one is queued. */
|
|
88
|
+
#cancelFrame() {
|
|
33
89
|
if (this.#rafId !== null) {
|
|
34
90
|
cancelAnimationFrame(this.#rafId);
|
|
35
91
|
this.#rafId = null;
|
|
36
92
|
}
|
|
37
|
-
this.#persist();
|
|
38
93
|
}
|
|
39
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Records the live scroll offset for the configured axis.
|
|
96
|
+
*
|
|
97
|
+
* An axis holds its echo until that axis actually moves. A scroll event names
|
|
98
|
+
* no axis, so consuming both on the first one to arrive would leave the other
|
|
99
|
+
* unguarded: on `both`, moving one axis would take the clamped reading of the
|
|
100
|
+
* untouched axis as the reader's own position and save it, cutting the stored
|
|
101
|
+
* offset down to whatever the layout could reach at restore time.
|
|
102
|
+
*/
|
|
40
103
|
#capture() {
|
|
41
|
-
if (this.#tracksVertical)
|
|
42
|
-
|
|
104
|
+
if (this.#tracksVertical) {
|
|
105
|
+
const top = this.element.scrollTop;
|
|
106
|
+
if (top !== this.#echoTop) {
|
|
107
|
+
this.#echoTop = null;
|
|
108
|
+
this.#lastTop = top;
|
|
109
|
+
this.#capturedTop = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (this.#tracksHorizontal) {
|
|
113
|
+
const left = this.element.scrollLeft;
|
|
114
|
+
if (left !== this.#echoLeft) {
|
|
115
|
+
this.#echoLeft = null;
|
|
116
|
+
this.#lastLeft = left;
|
|
117
|
+
this.#capturedLeft = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
43
120
|
}
|
|
44
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Writes the marked axes, carrying through any offset held for an untracked one.
|
|
123
|
+
* Without a key, or with neither axis marked, it writes nothing.
|
|
124
|
+
*/
|
|
45
125
|
#persist() {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (this.#
|
|
126
|
+
if (!this.#storageKey || !(this.#capturedTop || this.#capturedLeft)) return;
|
|
127
|
+
const data = { ...this.#carried };
|
|
128
|
+
if (this.#tracksVertical && this.#capturedTop) data.top = this.#lastTop;
|
|
129
|
+
if (this.#tracksHorizontal && this.#capturedLeft) data.left = this.#lastLeft;
|
|
49
130
|
try {
|
|
50
|
-
sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
131
|
+
window.sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
51
132
|
} catch {
|
|
52
133
|
}
|
|
53
134
|
}
|
|
54
135
|
/** Applies the persisted scroll offset, if any, without moving focus. */
|
|
55
136
|
#restore() {
|
|
56
|
-
let raw
|
|
57
|
-
try {
|
|
58
|
-
raw = sessionStorage.getItem(this.#storageKey);
|
|
59
|
-
} catch {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
if (raw === null) return;
|
|
63
|
-
let data;
|
|
137
|
+
let raw;
|
|
64
138
|
try {
|
|
65
|
-
|
|
139
|
+
raw = window.sessionStorage.getItem(this.#storageKey);
|
|
66
140
|
} catch {
|
|
67
141
|
return;
|
|
68
142
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
143
|
+
const data = parseStored(raw);
|
|
144
|
+
if (data === null) return;
|
|
145
|
+
this.#echoTop = null;
|
|
146
|
+
this.#echoLeft = null;
|
|
147
|
+
const top = storedOffset(data.top);
|
|
148
|
+
const left = storedOffset(data.left);
|
|
149
|
+
const options = { behavior: "instant" };
|
|
150
|
+
let requested = false;
|
|
151
|
+
if (this.#tracksVertical) {
|
|
152
|
+
if (top !== null) {
|
|
153
|
+
options.top = top;
|
|
154
|
+
this.#lastTop = top;
|
|
155
|
+
this.#capturedTop = true;
|
|
156
|
+
requested = true;
|
|
157
|
+
}
|
|
158
|
+
} else if (data.top !== void 0) {
|
|
159
|
+
this.#carried.top = data.top;
|
|
72
160
|
}
|
|
73
|
-
if (this.#tracksHorizontal
|
|
74
|
-
|
|
75
|
-
|
|
161
|
+
if (this.#tracksHorizontal) {
|
|
162
|
+
if (left !== null) {
|
|
163
|
+
options.left = left;
|
|
164
|
+
this.#lastLeft = left;
|
|
165
|
+
this.#capturedLeft = true;
|
|
166
|
+
requested = true;
|
|
167
|
+
}
|
|
168
|
+
} else if (data.left !== void 0) {
|
|
169
|
+
this.#carried.left = data.left;
|
|
76
170
|
}
|
|
171
|
+
if (!requested) return;
|
|
172
|
+
const beforeTop = this.element.scrollTop;
|
|
173
|
+
const beforeLeft = this.element.scrollLeft;
|
|
174
|
+
this.element.scrollTo(options);
|
|
175
|
+
if (this.element.scrollTop !== beforeTop) this.#echoTop = this.element.scrollTop;
|
|
176
|
+
if (this.element.scrollLeft !== beforeLeft) this.#echoLeft = this.element.scrollLeft;
|
|
77
177
|
}
|
|
78
178
|
/** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */
|
|
79
179
|
#resolveKey() {
|