stimeo-ui 0.2.0 → 0.3.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 +163 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +313 -0
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +252 -0
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +194 -17
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +246 -0
- data/dist/controllers/date_range_picker_controller.js +441 -0
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/editable_controller.js +169 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +67 -19
- data/dist/controllers/lazy_frame_controller.js +54 -11
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +2 -2
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +785 -0
- data/dist/controllers/multi_select_controller.js +755 -0
- data/dist/controllers/navigation_menu_controller.js +511 -0
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +246 -27
- data/dist/controllers/overflow_menu_controller.js +381 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +192 -0
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +238 -0
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +101 -14
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +40 -6
- data/dist/controllers/scrollspy_controller.js +369 -74
- data/dist/controllers/separator_controller.js +96 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/skeleton_controller.js +1 -1
- data/dist/controllers/slider_controller.js +32 -6
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +1 -1
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +9 -4
- data/dist/controllers/sticky_observer_controller.js +109 -20
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +295 -0
- data/dist/controllers/theme_controller.js +42 -13
- data/dist/controllers/time_picker_controller.js +231 -0
- data/dist/controllers/toast_controller.js +40 -14
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +691 -0
- data/dist/index.js +4256 -915
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +28 -2
|
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/rating_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function isReservedArrowChord(event, allow = []) {
|
|
12
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
13
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
// src/utils/roving_tabindex.ts
|
|
6
17
|
var RovingTabindex = class {
|
|
7
18
|
/** Returns the current ordered item elements; called on every operation. */
|
|
@@ -77,16 +88,19 @@ var RatingController = class extends Controller {
|
|
|
77
88
|
}
|
|
78
89
|
/** Arrow/Home/End/Space keyboard control, clamped (no wrap). */
|
|
79
90
|
onKeydown(event) {
|
|
91
|
+
if (event.defaultPrevented) return;
|
|
92
|
+
if (isReservedArrowChord(event)) return;
|
|
80
93
|
if (this.readonlyValue) return;
|
|
81
94
|
let next = null;
|
|
95
|
+
const rtl = isRtl(this.element);
|
|
82
96
|
switch (event.key) {
|
|
83
97
|
case "ArrowRight":
|
|
84
98
|
case "ArrowUp":
|
|
85
|
-
next = this.valueValue + 1;
|
|
99
|
+
next = this.valueValue + (event.key === "ArrowRight" && rtl ? -1 : 1);
|
|
86
100
|
break;
|
|
87
101
|
case "ArrowLeft":
|
|
88
102
|
case "ArrowDown":
|
|
89
|
-
next = this.valueValue - 1;
|
|
103
|
+
next = this.valueValue - (event.key === "ArrowLeft" && rtl ? -1 : 1);
|
|
90
104
|
break;
|
|
91
105
|
case "Home":
|
|
92
106
|
next = this.#minValue;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/read_more_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/blur_deferral.ts
|
|
6
|
+
var BlurDeferral = class {
|
|
7
|
+
/** Elements currently holding an update back, mapped to their `blur` listener. */
|
|
8
|
+
#pending = /* @__PURE__ */ new Map();
|
|
9
|
+
/** Called after a pending element blurs and has been detached. */
|
|
10
|
+
#onRelease;
|
|
11
|
+
/** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
|
|
12
|
+
constructor(onRelease) {
|
|
13
|
+
this.#onRelease = onRelease;
|
|
14
|
+
}
|
|
15
|
+
/** Number of elements currently holding an update back. */
|
|
16
|
+
get size() {
|
|
17
|
+
return this.#pending.size;
|
|
18
|
+
}
|
|
19
|
+
/** Snapshot of the pending elements, safe to iterate while releasing them. */
|
|
20
|
+
get elements() {
|
|
21
|
+
return [...this.#pending.keys()];
|
|
22
|
+
}
|
|
23
|
+
/** Whether `element` is currently holding an update back. */
|
|
24
|
+
has(element) {
|
|
25
|
+
return this.#pending.has(element);
|
|
26
|
+
}
|
|
27
|
+
/** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
|
|
28
|
+
defer(element) {
|
|
29
|
+
if (this.#pending.has(element)) return;
|
|
30
|
+
const onBlur = () => {
|
|
31
|
+
this.#detach(element);
|
|
32
|
+
this.#onRelease(element);
|
|
33
|
+
};
|
|
34
|
+
this.#pending.set(element, onBlur);
|
|
35
|
+
element.addEventListener("blur", onBlur);
|
|
36
|
+
}
|
|
37
|
+
/** Defers `element` as the only pending entry, cancelling any others. */
|
|
38
|
+
deferOnly(element) {
|
|
39
|
+
for (const pending of this.elements) {
|
|
40
|
+
if (pending !== element) this.#detach(pending);
|
|
41
|
+
}
|
|
42
|
+
this.defer(element);
|
|
43
|
+
}
|
|
44
|
+
/** Cancels `element`'s deferral without completing it; no-ops when not pending. */
|
|
45
|
+
release(element) {
|
|
46
|
+
this.#detach(element);
|
|
47
|
+
}
|
|
48
|
+
/** Cancels every deferral without completing any of them. */
|
|
49
|
+
releaseAll() {
|
|
50
|
+
for (const element of this.elements) this.#detach(element);
|
|
51
|
+
}
|
|
52
|
+
/** Removes the `blur` listener for `element` and forgets it. */
|
|
53
|
+
#detach(element) {
|
|
54
|
+
const onBlur = this.#pending.get(element);
|
|
55
|
+
if (onBlur) element.removeEventListener("blur", onBlur);
|
|
56
|
+
this.#pending.delete(element);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/utils/layout_observer.ts
|
|
61
|
+
var LayoutObserver = class {
|
|
62
|
+
#callback;
|
|
63
|
+
#resizeObserverFactory;
|
|
64
|
+
#resizeObserver = null;
|
|
65
|
+
#observingViewport = false;
|
|
66
|
+
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
67
|
+
#handleViewportResize = () => {
|
|
68
|
+
this.#callback();
|
|
69
|
+
};
|
|
70
|
+
constructor(callback, options = {}) {
|
|
71
|
+
this.#callback = callback;
|
|
72
|
+
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Starts observing an element's size. Repeated calls observe additional
|
|
76
|
+
* elements through the same shared observer. No-ops when no
|
|
77
|
+
* `ResizeObserver` implementation is available.
|
|
78
|
+
*/
|
|
79
|
+
observe(element) {
|
|
80
|
+
if (!this.#resizeObserverFactory) return;
|
|
81
|
+
if (!this.#resizeObserver) {
|
|
82
|
+
this.#resizeObserver = this.#resizeObserverFactory(() => {
|
|
83
|
+
this.#callback();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
this.#resizeObserver.observe(element);
|
|
87
|
+
}
|
|
88
|
+
/** Stops observing a single element while leaving any others in place. */
|
|
89
|
+
unobserve(element) {
|
|
90
|
+
this.#resizeObserver?.unobserve(element);
|
|
91
|
+
}
|
|
92
|
+
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
93
|
+
observeViewport() {
|
|
94
|
+
if (this.#observingViewport) return;
|
|
95
|
+
this.#observingViewport = true;
|
|
96
|
+
window.addEventListener("resize", this.#handleViewportResize);
|
|
97
|
+
}
|
|
98
|
+
/** Stops observing viewport resizes without affecting element observation. */
|
|
99
|
+
unobserveViewport() {
|
|
100
|
+
if (!this.#observingViewport) return;
|
|
101
|
+
this.#observingViewport = false;
|
|
102
|
+
window.removeEventListener("resize", this.#handleViewportResize);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and
|
|
106
|
+
* removes the viewport listener. Safe to call multiple times. Call this from a
|
|
107
|
+
* controller's `disconnect()`.
|
|
108
|
+
*/
|
|
109
|
+
disconnect() {
|
|
110
|
+
this.#resizeObserver?.disconnect();
|
|
111
|
+
this.#resizeObserver = null;
|
|
112
|
+
this.unobserveViewport();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// src/controllers/read_more_controller.ts
|
|
117
|
+
var ReadMoreController = class extends Controller {
|
|
118
|
+
static targets = ["content", "trigger"];
|
|
119
|
+
static values = {
|
|
120
|
+
collapsed: { type: Boolean, default: true }
|
|
121
|
+
};
|
|
122
|
+
static actions = ["toggle"];
|
|
123
|
+
#connected = false;
|
|
124
|
+
#collapsed = true;
|
|
125
|
+
#observedContent = null;
|
|
126
|
+
#contentMutationObserver = null;
|
|
127
|
+
#update = () => {
|
|
128
|
+
if (this.#connected) this.#evaluateOverflow();
|
|
129
|
+
};
|
|
130
|
+
#layout = new LayoutObserver(this.#update);
|
|
131
|
+
/** Holds the trigger's hide back while it has focus; re-evaluates on blur. */
|
|
132
|
+
#deferredHide = new BlurDeferral(() => {
|
|
133
|
+
this.#update();
|
|
134
|
+
});
|
|
135
|
+
connect() {
|
|
136
|
+
this.#connected = true;
|
|
137
|
+
this.#collapsed = this.#initialCollapsed();
|
|
138
|
+
this.#syncTargets();
|
|
139
|
+
}
|
|
140
|
+
disconnect() {
|
|
141
|
+
this.#connected = false;
|
|
142
|
+
this.#stopObservingContent();
|
|
143
|
+
this.#layout.disconnect();
|
|
144
|
+
}
|
|
145
|
+
contentTargetConnected() {
|
|
146
|
+
this.#syncTargets();
|
|
147
|
+
}
|
|
148
|
+
contentTargetDisconnected() {
|
|
149
|
+
this.#syncTargets();
|
|
150
|
+
}
|
|
151
|
+
triggerTargetConnected() {
|
|
152
|
+
this.#syncTargets();
|
|
153
|
+
}
|
|
154
|
+
triggerTargetDisconnected(trigger) {
|
|
155
|
+
this.#deferredHide.release(trigger);
|
|
156
|
+
this.#syncTargets();
|
|
157
|
+
}
|
|
158
|
+
/** Toggles between the collapsed (clamped) and expanded states. */
|
|
159
|
+
toggle() {
|
|
160
|
+
if (!this.#connected) return;
|
|
161
|
+
this.#collapsed = !this.#collapsed;
|
|
162
|
+
this.#reflect();
|
|
163
|
+
this.#evaluateOverflow();
|
|
164
|
+
}
|
|
165
|
+
#initialCollapsed() {
|
|
166
|
+
if (this.hasContentTarget) {
|
|
167
|
+
const state = this.contentTarget.getAttribute("data-state");
|
|
168
|
+
if (state === "expanded") return false;
|
|
169
|
+
if (state === "collapsed") return true;
|
|
170
|
+
}
|
|
171
|
+
return this.collapsedValue;
|
|
172
|
+
}
|
|
173
|
+
#reflect() {
|
|
174
|
+
if (this.hasContentTarget) {
|
|
175
|
+
this.contentTarget.setAttribute("data-state", this.#collapsed ? "collapsed" : "expanded");
|
|
176
|
+
}
|
|
177
|
+
if (this.hasTriggerTarget) {
|
|
178
|
+
this.triggerTarget.setAttribute("aria-expanded", this.#collapsed ? "false" : "true");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
#syncTargets() {
|
|
182
|
+
if (!this.#connected) return;
|
|
183
|
+
this.#syncContentObservation();
|
|
184
|
+
this.#reflect();
|
|
185
|
+
this.#evaluateOverflow();
|
|
186
|
+
}
|
|
187
|
+
#syncContentObservation() {
|
|
188
|
+
const next = this.hasContentTarget ? this.contentTarget : null;
|
|
189
|
+
if (next === this.#observedContent) return;
|
|
190
|
+
this.#stopObservingContent();
|
|
191
|
+
if (!next) return;
|
|
192
|
+
this.#observedContent = next;
|
|
193
|
+
this.#layout.observe(next);
|
|
194
|
+
this.#layout.observeViewport();
|
|
195
|
+
next.addEventListener("load", this.#update, true);
|
|
196
|
+
if (typeof MutationObserver !== "undefined") {
|
|
197
|
+
this.#contentMutationObserver = new MutationObserver(this.#update);
|
|
198
|
+
this.#contentMutationObserver.observe(next, {
|
|
199
|
+
childList: true,
|
|
200
|
+
subtree: true,
|
|
201
|
+
characterData: true
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
#stopObservingContent() {
|
|
206
|
+
this.#deferredHide.releaseAll();
|
|
207
|
+
if (this.#observedContent) {
|
|
208
|
+
this.#layout.unobserve(this.#observedContent);
|
|
209
|
+
this.#observedContent.removeEventListener("load", this.#update, true);
|
|
210
|
+
}
|
|
211
|
+
this.#observedContent = null;
|
|
212
|
+
this.#contentMutationObserver?.disconnect();
|
|
213
|
+
this.#contentMutationObserver = null;
|
|
214
|
+
this.#layout.unobserveViewport();
|
|
215
|
+
}
|
|
216
|
+
#evaluateOverflow() {
|
|
217
|
+
if (!this.hasTriggerTarget || !this.hasContentTarget) return;
|
|
218
|
+
const trigger = this.triggerTarget;
|
|
219
|
+
const content = this.contentTarget;
|
|
220
|
+
const useful = !this.#collapsed || content.scrollHeight > content.clientHeight;
|
|
221
|
+
if (useful) {
|
|
222
|
+
this.#deferredHide.releaseAll();
|
|
223
|
+
trigger.hidden = false;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (document.activeElement === trigger) {
|
|
227
|
+
trigger.hidden = false;
|
|
228
|
+
this.#deferredHide.deferOnly(trigger);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.#deferredHide.releaseAll();
|
|
232
|
+
trigger.hidden = true;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export { ReadMoreController };
|
|
237
|
+
//# sourceMappingURL=read_more_controller.js.map
|
|
238
|
+
//# sourceMappingURL=read_more_controller.js.map
|
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/resizable_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/arrow_step.ts
|
|
6
|
+
function isReservedArrowChord(event, allow = []) {
|
|
7
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
8
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/utils/tabindex_loan.ts
|
|
12
|
+
var TabindexLoan = class {
|
|
13
|
+
#value;
|
|
14
|
+
#lent = /* @__PURE__ */ new Set();
|
|
15
|
+
/**
|
|
16
|
+
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
17
|
+
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
18
|
+
* which a scroll region with no focusable content of its own needs.
|
|
19
|
+
*/
|
|
20
|
+
constructor(value = "-1") {
|
|
21
|
+
this.#value = value;
|
|
22
|
+
}
|
|
23
|
+
/** Lends `element` the value; no-ops when it already carries a `tabindex`. */
|
|
24
|
+
lend(element) {
|
|
25
|
+
if (element.hasAttribute("tabindex")) return;
|
|
26
|
+
element.setAttribute("tabindex", this.#value);
|
|
27
|
+
this.#lent.add(element);
|
|
28
|
+
}
|
|
29
|
+
/** Takes back every loan whose value is still the one that was lent. */
|
|
30
|
+
returnAll() {
|
|
31
|
+
for (const element of this.#lent) {
|
|
32
|
+
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
33
|
+
}
|
|
34
|
+
this.#lent.clear();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
3
38
|
// src/controllers/resizable_controller.ts
|
|
4
39
|
var ResizableController = class extends Controller {
|
|
5
40
|
static targets = ["primary", "secondary", "separator"];
|
|
@@ -11,18 +46,46 @@ var ResizableController = class extends Controller {
|
|
|
11
46
|
};
|
|
12
47
|
static actions = ["onKeydown", "onPointerDown", "toggle"];
|
|
13
48
|
static events = ["change"];
|
|
14
|
-
/**
|
|
49
|
+
/** The value held before the current collapse, restored when toggling back. */
|
|
15
50
|
#valueBeforeCollapse = 50;
|
|
16
51
|
/** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
|
|
17
52
|
#dragAbort = null;
|
|
53
|
+
/** `tabindex` lent to a pane so `F6` can put focus on it; panes carry none. */
|
|
54
|
+
#paneTabindex = new TabindexLoan();
|
|
55
|
+
/** Releases the pane cycle listener bound in {@link connect}. */
|
|
56
|
+
#cycleAbort = null;
|
|
18
57
|
connect() {
|
|
19
58
|
this.#clampAndSync();
|
|
59
|
+
this.#cycleAbort = new AbortController();
|
|
60
|
+
this.element.addEventListener("keydown", this.#onCycleKeydown, {
|
|
61
|
+
signal: this.#cycleAbort.signal
|
|
62
|
+
});
|
|
20
63
|
}
|
|
21
64
|
/** Cancels any active pointer drag so listeners never leak past disconnect. */
|
|
22
65
|
disconnect() {
|
|
23
66
|
this.#dragAbort?.abort();
|
|
24
67
|
this.#dragAbort = null;
|
|
68
|
+
this.#cycleAbort?.abort();
|
|
69
|
+
this.#cycleAbort = null;
|
|
70
|
+
this.#paneTabindex.returnAll();
|
|
25
71
|
}
|
|
72
|
+
/** Moves focus to the next pane on `F6`, wrapping; entering at the first one. */
|
|
73
|
+
#onCycleKeydown = (event) => {
|
|
74
|
+
if (event.key !== "F6") return;
|
|
75
|
+
if (event.defaultPrevented) return;
|
|
76
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
77
|
+
const panes = [];
|
|
78
|
+
if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
|
|
79
|
+
if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
|
|
80
|
+
if (panes.length === 0) return;
|
|
81
|
+
const target = event.target;
|
|
82
|
+
const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
|
|
83
|
+
const next = panes[(current + 1) % panes.length];
|
|
84
|
+
if (!next) return;
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
this.#paneTabindex.lend(next);
|
|
87
|
+
next.focus();
|
|
88
|
+
};
|
|
26
89
|
/**
|
|
27
90
|
* Stimulus lifecycle callback when the valueValue changes.
|
|
28
91
|
* Keeps CSS fractions and ARIA status completely aligned.
|
|
@@ -47,6 +110,7 @@ var ResizableController = class extends Controller {
|
|
|
47
110
|
}
|
|
48
111
|
/** Keydown adjustments for ArrowUp/Down/Left/Right and Home/End. */
|
|
49
112
|
onKeydown(event) {
|
|
113
|
+
if (isReservedArrowChord(event)) return;
|
|
50
114
|
if (!this.hasSeparatorTarget) return;
|
|
51
115
|
const orientation = this.separatorTarget.getAttribute("aria-orientation") || "vertical";
|
|
52
116
|
const isVertical = orientation === "vertical";
|
|
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/roving_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function isReservedArrowChord(event, allow = []) {
|
|
12
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
13
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
// src/utils/roving_tabindex.ts
|
|
6
17
|
var RovingTabindex = class {
|
|
7
18
|
/** Returns the current ordered item elements; called on every operation. */
|
|
@@ -64,6 +75,7 @@ var RovingController = class extends Controller {
|
|
|
64
75
|
/** Arrow keys move focus + the tab stop; Home/End jump to the ends. */
|
|
65
76
|
#onKeydown = (event) => {
|
|
66
77
|
if (event.defaultPrevented) return;
|
|
78
|
+
if (isReservedArrowChord(event)) return;
|
|
67
79
|
const items = this.itemTargets;
|
|
68
80
|
const current = this.#indexOf(event.target);
|
|
69
81
|
if (current === -1) return;
|
|
@@ -72,10 +84,13 @@ var RovingController = class extends Controller {
|
|
|
72
84
|
const orientation = this.orientationValue;
|
|
73
85
|
const horizontal = orientation === "horizontal" || orientation === "both";
|
|
74
86
|
const vertical = orientation === "vertical" || orientation === "both";
|
|
87
|
+
const rtl = horizontal && isRtl(this.element);
|
|
88
|
+
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
|
|
89
|
+
const backwardKey = rtl ? "ArrowRight" : "ArrowLeft";
|
|
75
90
|
let next;
|
|
76
|
-
if (horizontal && event.key ===
|
|
91
|
+
if (horizontal && event.key === forwardKey || vertical && event.key === "ArrowDown") {
|
|
77
92
|
next = rovingMove(current, length, 1, wrap);
|
|
78
|
-
} else if (horizontal && event.key ===
|
|
93
|
+
} else if (horizontal && event.key === backwardKey || vertical && event.key === "ArrowUp") {
|
|
79
94
|
next = rovingMove(current, length, -1, wrap);
|
|
80
95
|
} else if (this.homeEndValue && event.key === "Home") {
|
|
81
96
|
next = 0;
|
|
@@ -58,6 +58,47 @@ var LayoutObserver = class {
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
// src/utils/logical_scroll.ts
|
|
62
|
+
function isRtl(element) {
|
|
63
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
64
|
+
}
|
|
65
|
+
function logicalScrollMetrics(element, horizontal) {
|
|
66
|
+
const max = Math.max(
|
|
67
|
+
0,
|
|
68
|
+
horizontal ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight
|
|
69
|
+
);
|
|
70
|
+
const raw = horizontal ? element.scrollLeft : element.scrollTop;
|
|
71
|
+
const position = horizontal && isRtl(element) ? -raw : raw;
|
|
72
|
+
return { position: Math.min(max, Math.max(0, position)), max };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/utils/tabindex_loan.ts
|
|
76
|
+
var TabindexLoan = class {
|
|
77
|
+
#value;
|
|
78
|
+
#lent = /* @__PURE__ */ new Set();
|
|
79
|
+
/**
|
|
80
|
+
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
81
|
+
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
82
|
+
* which a scroll region with no focusable content of its own needs.
|
|
83
|
+
*/
|
|
84
|
+
constructor(value = "-1") {
|
|
85
|
+
this.#value = value;
|
|
86
|
+
}
|
|
87
|
+
/** Lends `element` the value; no-ops when it already carries a `tabindex`. */
|
|
88
|
+
lend(element) {
|
|
89
|
+
if (element.hasAttribute("tabindex")) return;
|
|
90
|
+
element.setAttribute("tabindex", this.#value);
|
|
91
|
+
this.#lent.add(element);
|
|
92
|
+
}
|
|
93
|
+
/** Takes back every loan whose value is still the one that was lent. */
|
|
94
|
+
returnAll() {
|
|
95
|
+
for (const element of this.#lent) {
|
|
96
|
+
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
97
|
+
}
|
|
98
|
+
this.#lent.clear();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
61
102
|
// src/controllers/scroll_area_controller.ts
|
|
62
103
|
var FOCUSABLE_SELECTOR = [
|
|
63
104
|
"a[href]",
|
|
@@ -76,10 +117,12 @@ var ScrollAreaController = class extends Controller {
|
|
|
76
117
|
};
|
|
77
118
|
static events = ["reach"];
|
|
78
119
|
#layout = new LayoutObserver(() => this.#update());
|
|
120
|
+
/** Re-checks the tab stop when the viewport's focusable content comes or goes. */
|
|
121
|
+
#content = null;
|
|
79
122
|
/** Last edge reported via `reach`, so the event fires once per arrival. */
|
|
80
123
|
#lastEdge = null;
|
|
81
124
|
/** Whether this controller added `tabindex`, so teardown only removes its own. */
|
|
82
|
-
#
|
|
125
|
+
#tabindex = new TabindexLoan("0");
|
|
83
126
|
/** Whether this controller added `role="region"`, for symmetric teardown. */
|
|
84
127
|
#addedRole = false;
|
|
85
128
|
#onScroll = () => {
|
|
@@ -90,6 +133,18 @@ var ScrollAreaController = class extends Controller {
|
|
|
90
133
|
this.viewportTarget.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
91
134
|
this.#layout.observe(this.viewportTarget);
|
|
92
135
|
this.#layout.observeViewport();
|
|
136
|
+
if (typeof MutationObserver !== "undefined") {
|
|
137
|
+
this.#content = new MutationObserver(() => {
|
|
138
|
+
if (!this.hasViewportTarget) return;
|
|
139
|
+
const vp = this.viewportTarget;
|
|
140
|
+
this.#syncKeyboardReach(vp, this.#syncOverflow(vp));
|
|
141
|
+
});
|
|
142
|
+
this.#content.observe(this.viewportTarget, {
|
|
143
|
+
subtree: true,
|
|
144
|
+
childList: true,
|
|
145
|
+
attributes: true
|
|
146
|
+
});
|
|
147
|
+
}
|
|
93
148
|
this.#update();
|
|
94
149
|
}
|
|
95
150
|
disconnect() {
|
|
@@ -98,14 +153,15 @@ var ScrollAreaController = class extends Controller {
|
|
|
98
153
|
this.#clearAddedAttributes(this.viewportTarget);
|
|
99
154
|
}
|
|
100
155
|
this.#layout.disconnect();
|
|
156
|
+
this.#content?.disconnect();
|
|
157
|
+
this.#content = null;
|
|
101
158
|
this.#lastEdge = null;
|
|
102
159
|
}
|
|
103
160
|
/** Re-measures overflow and scroll position and reflects the state hooks. */
|
|
104
161
|
#update() {
|
|
105
162
|
if (!this.hasViewportTarget) return;
|
|
106
163
|
const vp = this.viewportTarget;
|
|
107
|
-
const overflowing = this.#
|
|
108
|
-
this.element.setAttribute("data-overflow", overflowing ? "true" : "false");
|
|
164
|
+
const overflowing = this.#syncOverflow(vp);
|
|
109
165
|
this.#syncKeyboardReach(vp, overflowing);
|
|
110
166
|
const { position, progress } = this.#measurePosition(vp);
|
|
111
167
|
this.element.setAttribute("data-scroll", position);
|
|
@@ -119,6 +175,21 @@ var ScrollAreaController = class extends Controller {
|
|
|
119
175
|
}
|
|
120
176
|
}
|
|
121
177
|
/** Whether the viewport can scroll on the configured axis. */
|
|
178
|
+
/**
|
|
179
|
+
* Measures overflow and reflects the `data-overflow` hook.
|
|
180
|
+
*
|
|
181
|
+
* The write is skipped when the value is unchanged. An identical `setAttribute` still
|
|
182
|
+
* queues a MutationRecord, and markup that puts the viewport target on the controller
|
|
183
|
+
* element itself would then have the content observer trigger its own next callback.
|
|
184
|
+
*/
|
|
185
|
+
#syncOverflow(vp) {
|
|
186
|
+
const overflowing = this.#measureOverflow(vp);
|
|
187
|
+
const next = overflowing ? "true" : "false";
|
|
188
|
+
if (this.element.getAttribute("data-overflow") !== next) {
|
|
189
|
+
this.element.setAttribute("data-overflow", next);
|
|
190
|
+
}
|
|
191
|
+
return overflowing;
|
|
192
|
+
}
|
|
122
193
|
#measureOverflow(vp) {
|
|
123
194
|
const o = this.orientationValue;
|
|
124
195
|
const vertical = o !== "horizontal" && vp.scrollHeight > vp.clientHeight + EDGE_EPSILON;
|
|
@@ -131,8 +202,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
131
202
|
*/
|
|
132
203
|
#measurePosition(vp) {
|
|
133
204
|
const horizontalPrimary = this.orientationValue === "horizontal" || this.orientationValue === "both" && vp.scrollHeight <= vp.clientHeight + EDGE_EPSILON;
|
|
134
|
-
const scrollPos
|
|
135
|
-
const maxScroll = horizontalPrimary ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
|
|
205
|
+
const { position: scrollPos, max: maxScroll } = logicalScrollMetrics(vp, horizontalPrimary);
|
|
136
206
|
if (maxScroll <= EDGE_EPSILON) return { position: "start", progress: 0 };
|
|
137
207
|
const progress = Math.min(1, Math.max(0, scrollPos / maxScroll));
|
|
138
208
|
if (scrollPos <= EDGE_EPSILON) return { position: "start", progress };
|
|
@@ -147,10 +217,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
147
217
|
#syncKeyboardReach(vp, overflowing) {
|
|
148
218
|
const wantsTabindex = overflowing && !this.#hasFocusableContent(vp);
|
|
149
219
|
if (wantsTabindex) {
|
|
150
|
-
|
|
151
|
-
vp.setAttribute("tabindex", "0");
|
|
152
|
-
this.#addedTabindex = true;
|
|
153
|
-
}
|
|
220
|
+
this.#tabindex.lend(vp);
|
|
154
221
|
if (!vp.hasAttribute("role") && this.#hasAccessibleName(vp)) {
|
|
155
222
|
vp.setAttribute("role", "region");
|
|
156
223
|
this.#addedRole = true;
|
|
@@ -161,17 +228,37 @@ var ScrollAreaController = class extends Controller {
|
|
|
161
228
|
}
|
|
162
229
|
/** Removes (and resets the flags for) only the attributes this controller added. */
|
|
163
230
|
#clearAddedAttributes(vp) {
|
|
164
|
-
|
|
165
|
-
vp.removeAttribute("tabindex");
|
|
166
|
-
this.#addedTabindex = false;
|
|
167
|
-
}
|
|
231
|
+
this.#tabindex.returnAll();
|
|
168
232
|
if (this.#addedRole) {
|
|
169
233
|
vp.removeAttribute("role");
|
|
170
234
|
this.#addedRole = false;
|
|
171
235
|
}
|
|
172
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Whether the viewport owns something the user can Tab to *right now*.
|
|
239
|
+
*
|
|
240
|
+
* The selector alone is not enough: a `display: none` button still matches it,
|
|
241
|
+
* so a viewport whose only control is revealed on demand would never get a tab
|
|
242
|
+
* stop — leaving it unreachable by keyboard exactly while it has nothing else to
|
|
243
|
+
* offer. Only rendered candidates count.
|
|
244
|
+
*/
|
|
173
245
|
#hasFocusableContent(vp) {
|
|
174
|
-
return vp.
|
|
246
|
+
return Array.from(vp.querySelectorAll(FOCUSABLE_SELECTOR)).some(
|
|
247
|
+
(el) => this.#isRendered(el)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Whether `el` is actually rendered, and so can hold focus.
|
|
252
|
+
*
|
|
253
|
+
* `checkVisibility()` answers this for every way CSS can remove a box, including
|
|
254
|
+
* a class-driven `display: none` that no attribute reveals. The `hidden` walk in
|
|
255
|
+
* front of it is not redundant: it is the one case a DOM-only environment with no
|
|
256
|
+
* layout engine has to be told about explicitly.
|
|
257
|
+
*/
|
|
258
|
+
#isRendered(el) {
|
|
259
|
+
if (el.closest("[hidden]") !== null) return false;
|
|
260
|
+
const check = el.checkVisibility;
|
|
261
|
+
return typeof check === "function" ? check.call(el, { visibilityProperty: true }) : true;
|
|
175
262
|
}
|
|
176
263
|
#hasAccessibleName(vp) {
|
|
177
264
|
return vp.hasAttribute("aria-label") || vp.hasAttribute("aria-labelledby");
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/scroll_restore_controller.ts
|
|
4
|
+
var ScrollRestoreController = class extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
key: { type: String, default: "" },
|
|
7
|
+
axis: { type: String, default: "vertical" }
|
|
8
|
+
};
|
|
9
|
+
/** Pending rAF id that coalesces scroll bursts into one save. */
|
|
10
|
+
#rafId = null;
|
|
11
|
+
/** Resolved storage key; empty disables persistence (no key and no id). */
|
|
12
|
+
#storageKey = "";
|
|
13
|
+
/** Last offset captured while the element was live; persisted as-is on teardown. */
|
|
14
|
+
#lastTop = 0;
|
|
15
|
+
#lastLeft = 0;
|
|
16
|
+
#onScroll = () => {
|
|
17
|
+
this.#capture();
|
|
18
|
+
if (this.#rafId !== null) return;
|
|
19
|
+
this.#rafId = requestAnimationFrame(() => {
|
|
20
|
+
this.#rafId = null;
|
|
21
|
+
this.#persist();
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
connect() {
|
|
25
|
+
this.#storageKey = this.#resolveKey();
|
|
26
|
+
if (!this.#storageKey) return;
|
|
27
|
+
this.#restore();
|
|
28
|
+
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
29
|
+
}
|
|
30
|
+
disconnect() {
|
|
31
|
+
if (!this.#storageKey) return;
|
|
32
|
+
this.element.removeEventListener("scroll", this.#onScroll);
|
|
33
|
+
if (this.#rafId !== null) {
|
|
34
|
+
cancelAnimationFrame(this.#rafId);
|
|
35
|
+
this.#rafId = null;
|
|
36
|
+
}
|
|
37
|
+
this.#persist();
|
|
38
|
+
}
|
|
39
|
+
/** Records the live scroll offset for the configured axis. */
|
|
40
|
+
#capture() {
|
|
41
|
+
if (this.#tracksVertical) this.#lastTop = this.element.scrollTop;
|
|
42
|
+
if (this.#tracksHorizontal) this.#lastLeft = this.element.scrollLeft;
|
|
43
|
+
}
|
|
44
|
+
/** Persists the last captured scroll offset for the configured axis. */
|
|
45
|
+
#persist() {
|
|
46
|
+
const data = {};
|
|
47
|
+
if (this.#tracksVertical) data.top = this.#lastTop;
|
|
48
|
+
if (this.#tracksHorizontal) data.left = this.#lastLeft;
|
|
49
|
+
try {
|
|
50
|
+
sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Applies the persisted scroll offset, if any, without moving focus. */
|
|
55
|
+
#restore() {
|
|
56
|
+
let raw = null;
|
|
57
|
+
try {
|
|
58
|
+
raw = sessionStorage.getItem(this.#storageKey);
|
|
59
|
+
} catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (raw === null) return;
|
|
63
|
+
let data;
|
|
64
|
+
try {
|
|
65
|
+
data = JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (this.#tracksVertical && typeof data.top === "number") {
|
|
70
|
+
this.element.scrollTop = data.top;
|
|
71
|
+
this.#lastTop = data.top;
|
|
72
|
+
}
|
|
73
|
+
if (this.#tracksHorizontal && typeof data.left === "number") {
|
|
74
|
+
this.element.scrollLeft = data.left;
|
|
75
|
+
this.#lastLeft = data.left;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */
|
|
79
|
+
#resolveKey() {
|
|
80
|
+
const base = this.keyValue || this.element.id;
|
|
81
|
+
return base ? `stimeo--scroll-restore:${base}` : "";
|
|
82
|
+
}
|
|
83
|
+
get #tracksVertical() {
|
|
84
|
+
return this.axisValue !== "horizontal";
|
|
85
|
+
}
|
|
86
|
+
get #tracksHorizontal() {
|
|
87
|
+
return this.axisValue === "horizontal" || this.axisValue === "both";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export { ScrollRestoreController };
|
|
92
|
+
//# sourceMappingURL=scroll_restore_controller.js.map
|
|
93
|
+
//# sourceMappingURL=scroll_restore_controller.js.map
|