@excom/kit-scroller 0.1.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/.rush/temp/chunked-rush-logs/kit-scroller.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/index.ts +330 -0
- package/package.json +35 -0
- package/rush-logs/kit-scroller.apply-exports.cache.log +1 -0
- package/rush-logs/kit-scroller.apply-exports.log +1 -0
- package/support/tests/kit-scroller.test.ts +150 -0
- package/support/tests/scroll-into-view.test.ts +364 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
|
package/config/rig.json
ADDED
package/index.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// TODO replace with a battle-tested library; most of this was vibecoded
|
|
2
|
+
|
|
3
|
+
type Axis = "y" | "x";
|
|
4
|
+
type ScrollBehaviorOption = "auto" | "smooth" | "instant";
|
|
5
|
+
type Align = "start" | "center" | "end" | "nearest";
|
|
6
|
+
|
|
7
|
+
interface ScrollAdvancedOptions {
|
|
8
|
+
behavior?: ScrollBehaviorOption; // default: 'smooth'
|
|
9
|
+
block?: Align; // default: 'start'
|
|
10
|
+
inline?: Align; // default: 'nearest'
|
|
11
|
+
offsetBlock?: number; // default: 0 (applies along Y axis)
|
|
12
|
+
offsetInline?: number; // default: 0 (applies along X axis)
|
|
13
|
+
onlyIfNeeded?: boolean; // default true: skip if already fully visible
|
|
14
|
+
padInView?: number; // default 0: extra slack when testing visibility
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ScrollContainer = Element | Document;
|
|
18
|
+
|
|
19
|
+
function isViewportContainer(c: ScrollContainer | null): c is Document {
|
|
20
|
+
return !c || c === document || c === document.scrollingElement;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getAxisProps(axis: Axis) {
|
|
24
|
+
return axis === "y"
|
|
25
|
+
? {
|
|
26
|
+
overflowProp: "overflowY" as const,
|
|
27
|
+
scrollSize: "scrollHeight" as const,
|
|
28
|
+
clientSize: "clientHeight" as const,
|
|
29
|
+
scrollPos: "scrollTop" as const,
|
|
30
|
+
}
|
|
31
|
+
: {
|
|
32
|
+
overflowProp: "overflowX" as const,
|
|
33
|
+
scrollSize: "scrollWidth" as const,
|
|
34
|
+
clientSize: "clientWidth" as const,
|
|
35
|
+
scrollPos: "scrollLeft" as const,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Nearest scrollable ancestor on this axis (walks through shadow roots). */
|
|
40
|
+
export function getNearestScrollableContainer(
|
|
41
|
+
node: Node,
|
|
42
|
+
axis: Axis = "y"
|
|
43
|
+
): Element | Document | null {
|
|
44
|
+
const props = getAxisProps(axis);
|
|
45
|
+
|
|
46
|
+
const isScrollableElement = (el: Element) => {
|
|
47
|
+
const cs = getComputedStyle(el);
|
|
48
|
+
const val = cs[props.overflowProp];
|
|
49
|
+
if (!/(auto|scroll|overlay)/.test(val)) return false;
|
|
50
|
+
const canScroll =
|
|
51
|
+
(el as any)[props.scrollSize] > (el as any)[props.clientSize];
|
|
52
|
+
// Still a candidate when overflow isn't `visible`, even if not overflowing now
|
|
53
|
+
return canScroll && !["hidden", "visible"].includes(val);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let cur: Node | null = node;
|
|
57
|
+
|
|
58
|
+
while (cur) {
|
|
59
|
+
if (cur instanceof Element) {
|
|
60
|
+
// `position: fixed` descendants scroll with the viewport
|
|
61
|
+
const cs = getComputedStyle(cur);
|
|
62
|
+
if (cs.position === "fixed") break;
|
|
63
|
+
|
|
64
|
+
if (isScrollableElement(cur)) return cur;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (cur.parentNode) {
|
|
68
|
+
cur = cur.parentNode;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Hop out of shadow root to host
|
|
73
|
+
const root = (cur as any).getRootNode?.();
|
|
74
|
+
if (root && (root as ShadowRoot).host) {
|
|
75
|
+
cur = (root as ShadowRoot).host;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return document.scrollingElement ? document : document; // Viewport is always `Document`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fully visible on this axis inside the container (or viewport)? */
|
|
86
|
+
function isFullyInViewOnAxis(
|
|
87
|
+
container: ScrollContainer | null,
|
|
88
|
+
el: Element,
|
|
89
|
+
axis: Axis,
|
|
90
|
+
pad = 0
|
|
91
|
+
): boolean {
|
|
92
|
+
const r = el.getBoundingClientRect();
|
|
93
|
+
|
|
94
|
+
if (isViewportContainer(container)) {
|
|
95
|
+
if (axis === "y")
|
|
96
|
+
return r.top >= pad && r.bottom <= window.innerHeight - pad;
|
|
97
|
+
else return r.left >= pad && r.right <= window.innerWidth - pad;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const cRect = (container as Element).getBoundingClientRect();
|
|
101
|
+
if (axis === "y") {
|
|
102
|
+
const top = cRect.top + pad;
|
|
103
|
+
const bottom = cRect.bottom - pad;
|
|
104
|
+
return r.top >= top && r.bottom <= bottom;
|
|
105
|
+
} else {
|
|
106
|
+
const left = cRect.left + pad;
|
|
107
|
+
const right = cRect.right - pad;
|
|
108
|
+
return r.left >= left && r.right <= right;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Target scroll position for one axis, given alignment and offset. */
|
|
113
|
+
function computeTargetForAxis(params: {
|
|
114
|
+
container: ScrollContainer | null;
|
|
115
|
+
el: Element;
|
|
116
|
+
axis: Axis;
|
|
117
|
+
align: Align; // 'start' | 'center' | 'end' | 'nearest'
|
|
118
|
+
offset: number; // additional px offset along that axis
|
|
119
|
+
padInView: number; // visibility pad used for 'onlyIfNeeded' and 'nearest'
|
|
120
|
+
}) {
|
|
121
|
+
const { container, el, axis, align, offset, padInView } = params;
|
|
122
|
+
const props = getAxisProps(axis);
|
|
123
|
+
const elRect = el.getBoundingClientRect();
|
|
124
|
+
|
|
125
|
+
// Current scroll position and sizes
|
|
126
|
+
if (isViewportContainer(container)) {
|
|
127
|
+
const current = axis === "y" ? window.scrollY : window.scrollX;
|
|
128
|
+
const vpSize = axis === "y" ? window.innerHeight : window.innerWidth;
|
|
129
|
+
|
|
130
|
+
// Positions relative to the viewport origin
|
|
131
|
+
const startPos = current + (axis === "y" ? elRect.top : elRect.left);
|
|
132
|
+
const endPos = current + (axis === "y" ? elRect.bottom : elRect.right);
|
|
133
|
+
const elSize = axis === "y" ? elRect.height : elRect.width;
|
|
134
|
+
const centerEl = startPos + elSize / 2;
|
|
135
|
+
const vpStart = current;
|
|
136
|
+
const vpEnd = current + vpSize;
|
|
137
|
+
// const centerVp = vpStart + vpSize / 2;
|
|
138
|
+
|
|
139
|
+
let target: number;
|
|
140
|
+
|
|
141
|
+
switch (align) {
|
|
142
|
+
case "start":
|
|
143
|
+
target = startPos + offset;
|
|
144
|
+
break;
|
|
145
|
+
case "end":
|
|
146
|
+
target = endPos - vpSize + offset;
|
|
147
|
+
break;
|
|
148
|
+
case "center":
|
|
149
|
+
target = centerEl - vpSize / 2 + offset;
|
|
150
|
+
break;
|
|
151
|
+
case "nearest": {
|
|
152
|
+
// Already fully visible (with pad): keep current
|
|
153
|
+
const fully = isFullyInViewOnAxis(document, el, axis, padInView);
|
|
154
|
+
if (fully) return { needed: false, target: current };
|
|
155
|
+
// Smaller move to bring into view (start vs end)
|
|
156
|
+
const toStart = startPos - (vpStart + padInView);
|
|
157
|
+
const toEnd = endPos - (vpEnd - padInView);
|
|
158
|
+
// Extends past both edges: pick the larger magnitude
|
|
159
|
+
if (toStart < 0 && toEnd > 0) {
|
|
160
|
+
// Larger than the viewport: prefer start unless end is closer
|
|
161
|
+
target =
|
|
162
|
+
Math.abs(toStart) < Math.abs(toEnd)
|
|
163
|
+
? current + toStart + offset
|
|
164
|
+
: current + toEnd + offset;
|
|
165
|
+
} else if (toStart < 0) {
|
|
166
|
+
target = current + toStart + offset; // up / left
|
|
167
|
+
} else {
|
|
168
|
+
target = current + toEnd + offset; // down / right
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
target = startPos + offset;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { needed: true, target };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Element container
|
|
180
|
+
const c = container as Element;
|
|
181
|
+
const cRect = c.getBoundingClientRect();
|
|
182
|
+
const current = (c as any)[props.scrollPos] as number;
|
|
183
|
+
const cSize = (c as any)[props.clientSize] as number;
|
|
184
|
+
|
|
185
|
+
const elStart =
|
|
186
|
+
(axis === "y" ? elRect.top : elRect.left) -
|
|
187
|
+
(axis === "y" ? cRect.top : cRect.left) +
|
|
188
|
+
current;
|
|
189
|
+
const elEnd = elStart + (axis === "y" ? elRect.height : elRect.width);
|
|
190
|
+
const cStart = current;
|
|
191
|
+
const cEnd = current + cSize;
|
|
192
|
+
const elCenter = (elStart + elEnd) / 2;
|
|
193
|
+
// const cCenter = (cStart + cEnd) / 2;
|
|
194
|
+
|
|
195
|
+
let target: number;
|
|
196
|
+
|
|
197
|
+
switch (align) {
|
|
198
|
+
case "start":
|
|
199
|
+
target = elStart + offset;
|
|
200
|
+
break;
|
|
201
|
+
case "end":
|
|
202
|
+
target = elEnd - cSize + offset;
|
|
203
|
+
break;
|
|
204
|
+
case "center":
|
|
205
|
+
target = elCenter - cSize / 2 + offset;
|
|
206
|
+
break;
|
|
207
|
+
case "nearest": {
|
|
208
|
+
const fully = isFullyInViewOnAxis(c, el, axis, padInView);
|
|
209
|
+
if (fully) return { needed: false, target: current };
|
|
210
|
+
const toStart = elStart - (cStart + padInView);
|
|
211
|
+
const toEnd = elEnd - (cEnd - padInView);
|
|
212
|
+
if (toStart < 0 && toEnd > 0) {
|
|
213
|
+
target =
|
|
214
|
+
Math.abs(toStart) < Math.abs(toEnd)
|
|
215
|
+
? current + toStart + offset
|
|
216
|
+
: current + toEnd + offset;
|
|
217
|
+
} else if (toStart < 0) {
|
|
218
|
+
target = current + toStart + offset;
|
|
219
|
+
} else {
|
|
220
|
+
target = current + toEnd + offset;
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
default:
|
|
225
|
+
target = elStart + offset;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { needed: true, target };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Scroll an element into view. Picks the nearest scrollable ancestor
|
|
233
|
+
* per axis, can do Y, X, or both, and honors start / center / end /
|
|
234
|
+
* nearest like native `scrollIntoView`.
|
|
235
|
+
*/
|
|
236
|
+
export function scrollElementIntoView(
|
|
237
|
+
el: Element,
|
|
238
|
+
{
|
|
239
|
+
behavior = "smooth",
|
|
240
|
+
block,
|
|
241
|
+
inline,
|
|
242
|
+
offsetBlock = 0,
|
|
243
|
+
offsetInline = 0,
|
|
244
|
+
onlyIfNeeded = true,
|
|
245
|
+
padInView = 0,
|
|
246
|
+
}: ScrollAdvancedOptions = {}
|
|
247
|
+
): { y?: ScrollContainer; x?: ScrollContainer } {
|
|
248
|
+
if (!el || !el.getBoundingClientRect) return {};
|
|
249
|
+
|
|
250
|
+
const result: { y?: ScrollContainer; x?: ScrollContainer } = {};
|
|
251
|
+
const axes = [inline && "x", block && "y"];
|
|
252
|
+
|
|
253
|
+
if (el.checkVisibility() === false) {
|
|
254
|
+
// No box to scroll to (`display: none` or `display: contents`).
|
|
255
|
+
throw new Error(
|
|
256
|
+
"scrollElementIntoView: element.checkVisibility() failed. Cannot scroll element into view."
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
// Y axis
|
|
260
|
+
if (axes.includes("y")) {
|
|
261
|
+
const cy = getNearestScrollableContainer(el, "y");
|
|
262
|
+
const fullyY = onlyIfNeeded
|
|
263
|
+
? isFullyInViewOnAxis(
|
|
264
|
+
cy,
|
|
265
|
+
el,
|
|
266
|
+
"y",
|
|
267
|
+
Math.max(padInView, Math.abs(offsetBlock))
|
|
268
|
+
)
|
|
269
|
+
: false;
|
|
270
|
+
|
|
271
|
+
if (!(onlyIfNeeded && fullyY)) {
|
|
272
|
+
const { needed, target } = computeTargetForAxis({
|
|
273
|
+
container: cy,
|
|
274
|
+
el,
|
|
275
|
+
axis: "y",
|
|
276
|
+
align: block ?? "start",
|
|
277
|
+
offset: offsetBlock,
|
|
278
|
+
padInView,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (!onlyIfNeeded || needed) {
|
|
282
|
+
if (isViewportContainer(cy)) {
|
|
283
|
+
window.scrollTo({ top: Math.round(target), behavior });
|
|
284
|
+
} else {
|
|
285
|
+
(cy as Element).scrollTo({ top: Math.round(target), behavior });
|
|
286
|
+
}
|
|
287
|
+
result.y = cy || document;
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
result.y = cy || document;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// X axis
|
|
295
|
+
if (axes.includes("x")) {
|
|
296
|
+
const cx = getNearestScrollableContainer(el, "x");
|
|
297
|
+
const fullyX = onlyIfNeeded
|
|
298
|
+
? isFullyInViewOnAxis(
|
|
299
|
+
cx,
|
|
300
|
+
el,
|
|
301
|
+
"x",
|
|
302
|
+
Math.max(padInView, Math.abs(offsetInline))
|
|
303
|
+
)
|
|
304
|
+
: false;
|
|
305
|
+
|
|
306
|
+
if (!(onlyIfNeeded && fullyX)) {
|
|
307
|
+
const { needed, target } = computeTargetForAxis({
|
|
308
|
+
container: cx,
|
|
309
|
+
el,
|
|
310
|
+
axis: "x",
|
|
311
|
+
align: inline ?? "nearest",
|
|
312
|
+
offset: offsetInline,
|
|
313
|
+
padInView,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (!onlyIfNeeded || needed) {
|
|
317
|
+
if (isViewportContainer(cx)) {
|
|
318
|
+
window.scrollTo({ left: Math.round(target), behavior });
|
|
319
|
+
} else {
|
|
320
|
+
(cx as Element).scrollTo({ left: Math.round(target), behavior });
|
|
321
|
+
}
|
|
322
|
+
result.x = cx || document;
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
result.x = cx || document;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return result;
|
|
330
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@excom/kit-scroller",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "kit-scroller library",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24.13.0"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"dependencies": {},
|
|
11
|
+
"peerDependencies": {},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@excom/heft-rig": "^0.1.0"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"url": "excom-dev/nucleus",
|
|
17
|
+
"directory": "packages/kit-scroller"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/excom-dev/nucleus/tree/main/packages/kit-scroller/support/docs/README.md",
|
|
20
|
+
"bugs": "https://github.com/excom-dev/nucleus/issues",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"kit-scroller"
|
|
23
|
+
],
|
|
24
|
+
"excom": {
|
|
25
|
+
"documented": false,
|
|
26
|
+
"packageType": "library"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "node node_modules/@excom/heft-rig/scripts/vite-build.mjs",
|
|
30
|
+
"build:watch": "node node_modules/@excom/heft-rig/scripts/vite-build-watch.mjs",
|
|
31
|
+
"format": "node node_modules/@excom/heft-rig/scripts/format.mjs",
|
|
32
|
+
"test": "node node_modules/@excom/heft-rig/scripts/vitest.mjs",
|
|
33
|
+
"coverage": "node node_modules/@excom/heft-rig/scripts/coverage.mjs"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Caching has been disabled for this project's "apply-exports" command.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { getNearestScrollableContainer, scrollElementIntoView } from "../../index";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
} from "@excom/heft-rig/node_modules/vitest";
|
|
9
|
+
|
|
10
|
+
describe("getNearestScrollableContainer", () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns document when no scrollable ancestors exist", () => {
|
|
16
|
+
const child = document.createElement("div");
|
|
17
|
+
document.body.appendChild(child);
|
|
18
|
+
const result = getNearestScrollableContainer(child);
|
|
19
|
+
expect(result).toBe(document);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns nearest scrollable ancestor on y-axis", () => {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
container.style.overflowY = "auto";
|
|
25
|
+
Object.defineProperty(container, "scrollHeight", {
|
|
26
|
+
value: 200,
|
|
27
|
+
configurable: true,
|
|
28
|
+
});
|
|
29
|
+
Object.defineProperty(container, "clientHeight", {
|
|
30
|
+
value: 100,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
const child = document.createElement("div");
|
|
34
|
+
container.appendChild(child);
|
|
35
|
+
document.body.appendChild(container);
|
|
36
|
+
const result = getNearestScrollableContainer(child);
|
|
37
|
+
expect(result).toBe(container);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns nearest scrollable ancestor on x-axis", () => {
|
|
41
|
+
const container = document.createElement("div");
|
|
42
|
+
container.style.overflowX = "auto";
|
|
43
|
+
Object.defineProperty(container, "scrollWidth", {
|
|
44
|
+
value: 400,
|
|
45
|
+
configurable: true,
|
|
46
|
+
});
|
|
47
|
+
Object.defineProperty(container, "clientWidth", {
|
|
48
|
+
value: 200,
|
|
49
|
+
configurable: true,
|
|
50
|
+
});
|
|
51
|
+
const child = document.createElement("div");
|
|
52
|
+
container.appendChild(child);
|
|
53
|
+
document.body.appendChild(container);
|
|
54
|
+
const result = getNearestScrollableContainer(child, "x");
|
|
55
|
+
expect(result).toBe(container);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("skips elements with overflow hidden", () => {
|
|
59
|
+
const hidden = document.createElement("div");
|
|
60
|
+
hidden.style.overflowY = "hidden";
|
|
61
|
+
Object.defineProperty(hidden, "scrollHeight", {
|
|
62
|
+
value: 200,
|
|
63
|
+
configurable: true,
|
|
64
|
+
});
|
|
65
|
+
Object.defineProperty(hidden, "clientHeight", {
|
|
66
|
+
value: 100,
|
|
67
|
+
configurable: true,
|
|
68
|
+
});
|
|
69
|
+
const child = document.createElement("div");
|
|
70
|
+
hidden.appendChild(child);
|
|
71
|
+
document.body.appendChild(hidden);
|
|
72
|
+
const result = getNearestScrollableContainer(child);
|
|
73
|
+
expect(result).toBe(document);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips non-scrollable overflow auto elements", () => {
|
|
77
|
+
const container = document.createElement("div");
|
|
78
|
+
container.style.overflowY = "auto";
|
|
79
|
+
Object.defineProperty(container, "scrollHeight", {
|
|
80
|
+
value: 100,
|
|
81
|
+
configurable: true,
|
|
82
|
+
});
|
|
83
|
+
Object.defineProperty(container, "clientHeight", {
|
|
84
|
+
value: 100,
|
|
85
|
+
configurable: true,
|
|
86
|
+
});
|
|
87
|
+
const child = document.createElement("div");
|
|
88
|
+
container.appendChild(child);
|
|
89
|
+
document.body.appendChild(container);
|
|
90
|
+
const result = getNearestScrollableContainer(child);
|
|
91
|
+
expect(result).toBe(document);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("finds deeply nested scrollable container", () => {
|
|
95
|
+
const outer = document.createElement("div");
|
|
96
|
+
const middle = document.createElement("div");
|
|
97
|
+
middle.style.overflowY = "scroll";
|
|
98
|
+
Object.defineProperty(middle, "scrollHeight", {
|
|
99
|
+
value: 300,
|
|
100
|
+
configurable: true,
|
|
101
|
+
});
|
|
102
|
+
Object.defineProperty(middle, "clientHeight", {
|
|
103
|
+
value: 100,
|
|
104
|
+
configurable: true,
|
|
105
|
+
});
|
|
106
|
+
const inner = document.createElement("div");
|
|
107
|
+
const child = document.createElement("div");
|
|
108
|
+
inner.appendChild(child);
|
|
109
|
+
middle.appendChild(inner);
|
|
110
|
+
outer.appendChild(middle);
|
|
111
|
+
document.body.appendChild(outer);
|
|
112
|
+
expect(getNearestScrollableContainer(child)).toBe(middle);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("scrollElementIntoView", () => {
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
document.body.innerHTML = "";
|
|
119
|
+
vi.restoreAllMocks();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns empty object when element is null", () => {
|
|
123
|
+
const result = scrollElementIntoView(null as any);
|
|
124
|
+
expect(result).toEqual({});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("throws when element is not visible", () => {
|
|
128
|
+
const el = document.createElement("div");
|
|
129
|
+
el.checkVisibility = () => false;
|
|
130
|
+
document.body.appendChild(el);
|
|
131
|
+
expect(() => scrollElementIntoView(el, { block: "start" })).toThrow(
|
|
132
|
+
"checkVisibility",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns empty when no axis specified", () => {
|
|
137
|
+
const el = document.createElement("div");
|
|
138
|
+
document.body.appendChild(el);
|
|
139
|
+
const result = scrollElementIntoView(el);
|
|
140
|
+
expect(result).toEqual({});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("scrolls on y-axis when block is specified", () => {
|
|
144
|
+
const el = document.createElement("div");
|
|
145
|
+
document.body.appendChild(el);
|
|
146
|
+
const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
|
147
|
+
scrollElementIntoView(el, { block: "start", onlyIfNeeded: false });
|
|
148
|
+
expect(scrollToSpy).toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getNearestScrollableContainer,
|
|
3
|
+
scrollElementIntoView,
|
|
4
|
+
} from "../../index";
|
|
5
|
+
import {
|
|
6
|
+
afterEach,
|
|
7
|
+
beforeEach,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
it,
|
|
11
|
+
vi,
|
|
12
|
+
} from "@excom/heft-rig/node_modules/vitest";
|
|
13
|
+
|
|
14
|
+
type Box = { top?: number; bottom?: number; left?: number; right?: number };
|
|
15
|
+
|
|
16
|
+
/** Stub an element's layout box (happy-dom has no layout). */
|
|
17
|
+
const setRect = (el: Element, { top = 0, bottom = 0, left = 0, right = 0 }: Box) => {
|
|
18
|
+
(el as any).getBoundingClientRect = () => ({
|
|
19
|
+
top,
|
|
20
|
+
bottom,
|
|
21
|
+
left,
|
|
22
|
+
right,
|
|
23
|
+
height: bottom - top,
|
|
24
|
+
width: right - left,
|
|
25
|
+
x: left,
|
|
26
|
+
y: top,
|
|
27
|
+
toJSON: () => ({}),
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const defineOwn = (target: object, key: string, value: unknown) =>
|
|
32
|
+
Object.defineProperty(target, key, { value, configurable: true, writable: true });
|
|
33
|
+
|
|
34
|
+
/** A scroll container that `getNearestScrollableContainer` recognizes. */
|
|
35
|
+
const makeScroller = (
|
|
36
|
+
axis: "x" | "y",
|
|
37
|
+
{ scrollSize, clientSize, scrollPos, box }: { scrollSize: number; clientSize: number; scrollPos: number; box: Box }
|
|
38
|
+
) => {
|
|
39
|
+
const c = document.createElement("div");
|
|
40
|
+
if (axis === "y") {
|
|
41
|
+
c.style.overflowY = "auto";
|
|
42
|
+
defineOwn(c, "scrollHeight", scrollSize);
|
|
43
|
+
defineOwn(c, "clientHeight", clientSize);
|
|
44
|
+
c.scrollTop = scrollPos;
|
|
45
|
+
} else {
|
|
46
|
+
c.style.overflowX = "auto";
|
|
47
|
+
defineOwn(c, "scrollWidth", scrollSize);
|
|
48
|
+
defineOwn(c, "clientWidth", clientSize);
|
|
49
|
+
c.scrollLeft = scrollPos;
|
|
50
|
+
}
|
|
51
|
+
setRect(c, box);
|
|
52
|
+
const el = document.createElement("span");
|
|
53
|
+
c.appendChild(el);
|
|
54
|
+
document.body.appendChild(c);
|
|
55
|
+
const scrollTo = vi.spyOn(c, "scrollTo").mockImplementation(() => {});
|
|
56
|
+
return { c, el, scrollTo };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("getNearestScrollableContainer (extra branches)", () => {
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
document.body.innerHTML = "";
|
|
62
|
+
delete (document as any).scrollingElement;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("stops at fixed-position elements and falls back to the document", () => {
|
|
66
|
+
const scroller = makeScroller("y", { scrollSize: 500, clientSize: 100, scrollPos: 0, box: {} }).c;
|
|
67
|
+
const fixed = document.createElement("div");
|
|
68
|
+
fixed.style.position = "fixed";
|
|
69
|
+
const child = document.createElement("div");
|
|
70
|
+
fixed.appendChild(child);
|
|
71
|
+
scroller.appendChild(fixed);
|
|
72
|
+
expect(getNearestScrollableContainer(child)).toBe(document);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("hops out of a shadow root to find a scrollable host ancestor", () => {
|
|
76
|
+
const { c: scroller } = makeScroller("y", { scrollSize: 500, clientSize: 100, scrollPos: 0, box: {} });
|
|
77
|
+
const host = document.createElement("div");
|
|
78
|
+
scroller.appendChild(host);
|
|
79
|
+
const root = host.attachShadow({ mode: "open" });
|
|
80
|
+
const inner = document.createElement("p");
|
|
81
|
+
root.appendChild(inner);
|
|
82
|
+
expect(getNearestScrollableContainer(inner)).toBe(scroller);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns the document for a detached node", () => {
|
|
86
|
+
const orphan = document.createElement("div");
|
|
87
|
+
expect(getNearestScrollableContainer(orphan)).toBe(document);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("still normalizes to the document when scrollingElement is null", () => {
|
|
91
|
+
Object.defineProperty(document, "scrollingElement", { get: () => null, configurable: true });
|
|
92
|
+
const child = document.createElement("div");
|
|
93
|
+
document.body.appendChild(child);
|
|
94
|
+
expect(getNearestScrollableContainer(child)).toBe(document);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("scrollElementIntoView in the viewport", () => {
|
|
99
|
+
let scrollTo: ReturnType<typeof vi.spyOn>;
|
|
100
|
+
let el: HTMLElement;
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
window.innerHeight = 500;
|
|
104
|
+
window.innerWidth = 800;
|
|
105
|
+
defineOwn(window, "scrollY", 100);
|
|
106
|
+
defineOwn(window, "scrollX", 20);
|
|
107
|
+
scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
|
108
|
+
el = document.createElement("div");
|
|
109
|
+
document.body.appendChild(el);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
document.body.innerHTML = "";
|
|
114
|
+
delete (window as any).scrollY;
|
|
115
|
+
delete (window as any).scrollX;
|
|
116
|
+
vi.restoreAllMocks();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("ignores objects without getBoundingClientRect", () => {
|
|
120
|
+
expect(scrollElementIntoView({} as any, { block: "start" })).toEqual({});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("block start honours offsetBlock and behavior", () => {
|
|
124
|
+
setRect(el, { top: 200, bottom: 250 });
|
|
125
|
+
const result = scrollElementIntoView(el, {
|
|
126
|
+
block: "start",
|
|
127
|
+
offsetBlock: -10,
|
|
128
|
+
onlyIfNeeded: false,
|
|
129
|
+
behavior: "instant",
|
|
130
|
+
});
|
|
131
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 290, behavior: "instant" });
|
|
132
|
+
expect(result).toEqual({ y: document });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("block end aligns the bottom edge with the viewport bottom", () => {
|
|
136
|
+
setRect(el, { top: 200, bottom: 250 });
|
|
137
|
+
scrollElementIntoView(el, { block: "end", onlyIfNeeded: false });
|
|
138
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: -150, behavior: "smooth" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("block center centers the element", () => {
|
|
142
|
+
setRect(el, { top: 200, bottom: 250 });
|
|
143
|
+
scrollElementIntoView(el, { block: "center", onlyIfNeeded: false });
|
|
144
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 75, behavior: "smooth" });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("unknown alignment falls back to start", () => {
|
|
148
|
+
setRect(el, { top: 200, bottom: 250 });
|
|
149
|
+
scrollElementIntoView(el, { block: "bogus" as any, onlyIfNeeded: false });
|
|
150
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 300, behavior: "smooth" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("block nearest keeps the current position when fully visible and forced", () => {
|
|
154
|
+
setRect(el, { top: 200, bottom: 250 });
|
|
155
|
+
scrollElementIntoView(el, { block: "nearest", onlyIfNeeded: false });
|
|
156
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 100, behavior: "smooth" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("block nearest scrolls up for an element above the viewport", () => {
|
|
160
|
+
setRect(el, { top: -50, bottom: 0 });
|
|
161
|
+
const result = scrollElementIntoView(el, { block: "nearest" });
|
|
162
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 50, behavior: "smooth" });
|
|
163
|
+
expect(result.y).toBe(document);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("block nearest scrolls down for an element below the viewport", () => {
|
|
167
|
+
setRect(el, { top: 600, bottom: 650 });
|
|
168
|
+
scrollElementIntoView(el, { block: "nearest" });
|
|
169
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 250, behavior: "smooth" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("block nearest picks the smaller move for an element taller than the viewport", () => {
|
|
173
|
+
setRect(el, { top: -50, bottom: 600 });
|
|
174
|
+
scrollElementIntoView(el, { block: "nearest" });
|
|
175
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 50, behavior: "smooth" });
|
|
176
|
+
|
|
177
|
+
setRect(el, { top: -300, bottom: 550 });
|
|
178
|
+
scrollElementIntoView(el, { block: "nearest" });
|
|
179
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 150, behavior: "smooth" });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does not scroll when onlyIfNeeded and fully in view", () => {
|
|
183
|
+
setRect(el, { top: 200, bottom: 250 });
|
|
184
|
+
const result = scrollElementIntoView(el, { block: "start" });
|
|
185
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
186
|
+
expect(result).toEqual({ y: document });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("respects padInView when deciding whether a scroll is needed", () => {
|
|
190
|
+
setRect(el, { top: 5, bottom: 50 });
|
|
191
|
+
scrollElementIntoView(el, { block: "start", padInView: 10 });
|
|
192
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 105, behavior: "smooth" });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("skips the scroll when only the offset pad is violated but nearest is satisfied", () => {
|
|
196
|
+
setRect(el, { top: 5, bottom: 50 });
|
|
197
|
+
const result = scrollElementIntoView(el, { block: "nearest", offsetBlock: 10 });
|
|
198
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
199
|
+
expect(result).toEqual({});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("inline start scrolls horizontally with offsetInline", () => {
|
|
203
|
+
setRect(el, { left: 100, right: 200 });
|
|
204
|
+
const result = scrollElementIntoView(el, {
|
|
205
|
+
inline: "start",
|
|
206
|
+
offsetInline: 5,
|
|
207
|
+
onlyIfNeeded: false,
|
|
208
|
+
});
|
|
209
|
+
expect(scrollTo).toHaveBeenCalledWith({ left: 125, behavior: "smooth" });
|
|
210
|
+
expect(result).toEqual({ x: document });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("inline end and center", () => {
|
|
214
|
+
setRect(el, { left: 100, right: 200 });
|
|
215
|
+
scrollElementIntoView(el, { inline: "end", onlyIfNeeded: false });
|
|
216
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ left: -580, behavior: "smooth" });
|
|
217
|
+
scrollElementIntoView(el, { inline: "center", onlyIfNeeded: false });
|
|
218
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ left: -230, behavior: "smooth" });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("inline nearest scrolls left for an element off the left edge", () => {
|
|
222
|
+
setRect(el, { left: -30, right: 10 });
|
|
223
|
+
scrollElementIntoView(el, { inline: "nearest" });
|
|
224
|
+
expect(scrollTo).toHaveBeenCalledWith({ left: -10, behavior: "smooth" });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("inline nearest scrolls right for an element off the right edge", () => {
|
|
228
|
+
setRect(el, { left: 900, right: 950 });
|
|
229
|
+
scrollElementIntoView(el, { inline: "nearest" });
|
|
230
|
+
expect(scrollTo).toHaveBeenCalledWith({ left: 170, behavior: "smooth" });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("inline nearest handles elements wider than the viewport", () => {
|
|
234
|
+
setRect(el, { left: -10, right: 900 });
|
|
235
|
+
scrollElementIntoView(el, { inline: "nearest" });
|
|
236
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ left: 10, behavior: "smooth" });
|
|
237
|
+
setRect(el, { left: -400, right: 810 });
|
|
238
|
+
scrollElementIntoView(el, { inline: "nearest" });
|
|
239
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ left: 30, behavior: "smooth" });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("inline defaults to nearest and does nothing when already visible", () => {
|
|
243
|
+
setRect(el, { left: 100, right: 200 });
|
|
244
|
+
const result = scrollElementIntoView(el, { inline: "nearest" });
|
|
245
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
246
|
+
expect(result).toEqual({ x: document });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("scrolls both axes in one call", () => {
|
|
250
|
+
setRect(el, { top: 600, bottom: 650, left: 900, right: 950 });
|
|
251
|
+
const result = scrollElementIntoView(el, { block: "start", inline: "start" });
|
|
252
|
+
expect(scrollTo).toHaveBeenCalledTimes(2);
|
|
253
|
+
expect(result).toEqual({ x: document, y: document });
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("scrollElementIntoView inside a scroll container", () => {
|
|
258
|
+
afterEach(() => {
|
|
259
|
+
document.body.innerHTML = "";
|
|
260
|
+
vi.restoreAllMocks();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const yScroller = () =>
|
|
264
|
+
makeScroller("y", {
|
|
265
|
+
scrollSize: 1000,
|
|
266
|
+
clientSize: 200,
|
|
267
|
+
scrollPos: 50,
|
|
268
|
+
box: { top: 100, bottom: 300, left: 0, right: 400 },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("block start / end / center / fallback target the container", () => {
|
|
272
|
+
const { c, el, scrollTo } = yScroller();
|
|
273
|
+
setRect(el, { top: 350, bottom: 400 });
|
|
274
|
+
|
|
275
|
+
const result = scrollElementIntoView(el, { block: "start", onlyIfNeeded: false });
|
|
276
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 300, behavior: "smooth" });
|
|
277
|
+
expect(result).toEqual({ y: c });
|
|
278
|
+
|
|
279
|
+
scrollElementIntoView(el, { block: "end", onlyIfNeeded: false, behavior: "auto" });
|
|
280
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 150, behavior: "auto" });
|
|
281
|
+
|
|
282
|
+
scrollElementIntoView(el, { block: "center", onlyIfNeeded: false, offsetBlock: 1 });
|
|
283
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 226, behavior: "smooth" });
|
|
284
|
+
|
|
285
|
+
scrollElementIntoView(el, { block: "bogus" as any, onlyIfNeeded: false });
|
|
286
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 300, behavior: "smooth" });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("does not scroll when the element is already visible in the container", () => {
|
|
290
|
+
const { c, el, scrollTo } = yScroller();
|
|
291
|
+
setRect(el, { top: 150, bottom: 200 });
|
|
292
|
+
const result = scrollElementIntoView(el, { block: "start" });
|
|
293
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
294
|
+
expect(result).toEqual({ y: c });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("block nearest keeps position when visible and forced", () => {
|
|
298
|
+
const { el, scrollTo } = yScroller();
|
|
299
|
+
setRect(el, { top: 150, bottom: 200 });
|
|
300
|
+
scrollElementIntoView(el, { block: "nearest", onlyIfNeeded: false });
|
|
301
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 50, behavior: "smooth" });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("block nearest scrolls up / down for elements outside the container", () => {
|
|
305
|
+
const { el, scrollTo } = yScroller();
|
|
306
|
+
setRect(el, { top: 50, bottom: 80 });
|
|
307
|
+
scrollElementIntoView(el, { block: "nearest" });
|
|
308
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: "smooth" });
|
|
309
|
+
|
|
310
|
+
setRect(el, { top: 350, bottom: 400 });
|
|
311
|
+
scrollElementIntoView(el, { block: "nearest" });
|
|
312
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 150, behavior: "smooth" });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("block nearest picks the smaller move for oversized elements", () => {
|
|
316
|
+
const { el, scrollTo } = yScroller();
|
|
317
|
+
setRect(el, { top: 90, bottom: 330 });
|
|
318
|
+
scrollElementIntoView(el, { block: "nearest" });
|
|
319
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 40, behavior: "smooth" });
|
|
320
|
+
|
|
321
|
+
setRect(el, { top: 50, bottom: 310 });
|
|
322
|
+
scrollElementIntoView(el, { block: "nearest" });
|
|
323
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 60, behavior: "smooth" });
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("uses padInView against the container edges", () => {
|
|
327
|
+
const { el, scrollTo } = yScroller();
|
|
328
|
+
setRect(el, { top: 105, bottom: 150 });
|
|
329
|
+
scrollElementIntoView(el, { block: "start", padInView: 10 });
|
|
330
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 55, behavior: "smooth" });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("handles the inline axis inside an x scroll container", () => {
|
|
334
|
+
const { c, el, scrollTo } = makeScroller("x", {
|
|
335
|
+
scrollSize: 2000,
|
|
336
|
+
clientSize: 400,
|
|
337
|
+
scrollPos: 30,
|
|
338
|
+
box: { top: 0, bottom: 100, left: 100, right: 500 },
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
setRect(el, { left: 200, right: 300 });
|
|
342
|
+
let result = scrollElementIntoView(el, { inline: "start" });
|
|
343
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
344
|
+
expect(result).toEqual({ x: c });
|
|
345
|
+
|
|
346
|
+
result = scrollElementIntoView(el, { inline: "start", onlyIfNeeded: false, offsetInline: -5 });
|
|
347
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ left: 125, behavior: "smooth" });
|
|
348
|
+
expect(result).toEqual({ x: c });
|
|
349
|
+
|
|
350
|
+
scrollElementIntoView(el, { inline: "end", onlyIfNeeded: false });
|
|
351
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ left: -170, behavior: "smooth" });
|
|
352
|
+
|
|
353
|
+
scrollElementIntoView(el, { inline: "center", onlyIfNeeded: false });
|
|
354
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ left: -20, behavior: "smooth" });
|
|
355
|
+
|
|
356
|
+
setRect(el, { left: 20, right: 60 });
|
|
357
|
+
scrollElementIntoView(el, { inline: "nearest" });
|
|
358
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ left: -50, behavior: "smooth" });
|
|
359
|
+
|
|
360
|
+
setRect(el, { left: 600, right: 650 });
|
|
361
|
+
scrollElementIntoView(el, { inline: "nearest" });
|
|
362
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ left: 180, behavior: "smooth" });
|
|
363
|
+
});
|
|
364
|
+
});
|