Skip to content

Commit af21e2b

Browse files
authored
Support flex-direction: column-reverse and writing-mode (#824)
* Support flex-direction: column-reverse and writing-mode * Fix dom order * Refactor offset condition
1 parent d2fcc09 commit af21e2b

29 files changed

+272
-1044
lines changed

.size-limit.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"name": "vue VList",
3434
"path": "lib/vue/index.js",
3535
"import": "{ VList }",
36-
"limit": "4.2 kB"
36+
"limit": "4.3 kB"
3737
},
3838
{
3939
"name": "vue Virtualizer",

e2e/VList.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
findFirstVisibleItem,
2929
findLastVisibleItem,
3030
isVerticalScrollBarVisible,
31+
ScrollableLocator,
3132
} from "./utils";
3233

3334
test.describe("smoke", () => {
@@ -109,6 +110,27 @@ test.describe("smoke", () => {
109110
).toBeVisible();
110111
});
111112

113+
test("horizontally scrollable (writing-mode: vertical-rl)", async ({ page }) => {
114+
await page.goto(storyUrl("basics-vlist--rtl"));
115+
116+
const component = page.locator(
117+
'*[style*="writing-mode"]'
118+
) as ScrollableLocator;
119+
120+
// check if start is displayed
121+
const first = component.getByText("列 0", { exact: true });
122+
await expect(first).toBeVisible();
123+
expect(await relativeRight(component, first)).toEqual(0);
124+
125+
// scroll to the end
126+
await scrollToLeft(component);
127+
128+
// check if the end is displayed
129+
await expect(
130+
component.getByText("列 999", { exact: true })
131+
).toBeVisible();
132+
});
133+
112134
test("display: none", async ({ page }) => {
113135
await page.goto(storyUrl("basics-vlist--default"));
114136

e2e/Virtualizer.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
relativeTop,
1212
scrollBy,
1313
isVerticalScrollBarVisible,
14+
scrollToTop,
1415
} from "./utils";
1516

1617
test("header and footer", async ({ page }) => {
@@ -138,6 +139,23 @@ test("overflow", async ({ page }) => {
138139
}
139140
});
140141

142+
test("reverse with flex-direction: column-reverse", async ({ page }) => {
143+
await page.goto(storyUrl("basics-virtualizer--inverted"));
144+
145+
const component = await getScrollable(page);
146+
147+
// check if start is displayed
148+
const first = component.getByText("0", { exact: true });
149+
await expect(first).toBeVisible();
150+
expect(await relativeBottom(component, first)).toEqual(0);
151+
152+
// scroll to the end
153+
await scrollToTop(component);
154+
155+
// check if the end is displayed
156+
await expect(component.getByText("999", { exact: true })).toBeVisible();
157+
});
158+
141159
test.describe("aligned to bottom", () => {
142160
test("reverse", async ({ page }) => {
143161
await page.goto(storyUrl("basics-virtualizer--reverse"), {

e2e/utils.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const setRTL = async (page: Page) => {
1010
};
1111

1212
declare const scrollableSymbol: unique symbol;
13-
type ScrollableLocator = Locator & { [scrollableSymbol]: never };
13+
export type ScrollableLocator = Locator & { [scrollableSymbol]: never };
1414

1515
export const getScrollable = async (page: Page): Promise<ScrollableLocator> => {
1616
const locator = page.locator(
@@ -230,6 +230,35 @@ export const scrollToBottom = (
230230
});
231231
};
232232

233+
export const scrollToTop = (scrollable: ScrollableLocator): Promise<void> => {
234+
return scrollable.evaluate((e) => {
235+
return new Promise<void>((resolve) => {
236+
let timer: ReturnType<typeof setTimeout> | null = null;
237+
238+
const onScroll = () => {
239+
e.scrollTop = -e.scrollHeight;
240+
241+
if (timer !== null) {
242+
clearTimeout(timer);
243+
}
244+
timer = setTimeout(() => {
245+
if (
246+
e.scrollTop - (e as HTMLElement).offsetHeight <=
247+
-e.scrollHeight
248+
) {
249+
e.removeEventListener("scroll", onScroll);
250+
resolve();
251+
} else {
252+
onScroll();
253+
}
254+
}, 50);
255+
};
256+
e.addEventListener("scroll", onScroll);
257+
258+
onScroll();
259+
});
260+
});
261+
};
233262
export const scrollToRight = async (
234263
scrollable: ScrollableLocator
235264
): Promise<void> => {

src/core/environment.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,22 @@ import { once } from "./utils.js";
55
*/
66
export const isBrowser = typeof window !== "undefined";
77

8-
const getDocumentElement = () => document.documentElement;
9-
108
/**
119
* @internal
1210
*/
13-
export const getCurrentDocument = (node: HTMLElement): Document =>
14-
node.ownerDocument;
11+
export const getDocumentElement = (doc: Document): HTMLElement =>
12+
doc.documentElement;
1513

1614
/**
1715
* @internal
1816
*/
19-
export const getCurrentWindow = (doc: Document) => doc.defaultView!;
17+
export const getCurrentDocument = (node: HTMLElement): Document =>
18+
node.ownerDocument;
2019

2120
/**
2221
* @internal
2322
*/
24-
export const isRTLDocument = /*#__PURE__*/ once((): boolean => {
25-
// TODO support SSR in rtl
26-
return isBrowser
27-
? getComputedStyle(getDocumentElement()).direction === "rtl"
28-
: false;
29-
});
23+
export const getCurrentWindow = (doc: Document) => doc.defaultView!;
3024

3125
/**
3226
* Currently, all browsers on iOS/iPadOS are WebKit, including WebView.
@@ -47,5 +41,5 @@ export const isIOSWebKit = /*#__PURE__*/ once((): boolean => {
4741
* @internal
4842
*/
4943
export const isSmoothScrollSupported = /*#__PURE__*/ once((): boolean => {
50-
return "scrollBehavior" in getDocumentElement().style;
44+
return "scrollBehavior" in getDocumentElement(document).style;
5145
});

src/core/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@ export {
2121
createGridResizer,
2222
type GridResizer,
2323
} from "./resizer.js";
24-
export { isRTLDocument, isBrowser } from "./environment.js";
24+
export { isBrowser } from "./environment.js";
2525
export { microtask, sort } from "./utils.js";
2626
export type { CacheSnapshot, ScrollToIndexOpts, ItemsRange } from "./types.js";

src/core/scroller.ts

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {
22
getCurrentDocument,
33
getCurrentWindow,
4+
getDocumentElement,
45
isIOSWebKit,
5-
isRTLDocument,
66
isSmoothScrollSupported,
77
} from "./environment.js";
88
import {
@@ -39,19 +39,18 @@ const debounce = <T extends () => void>(fn: T, ms: number) => {
3939
};
4040

4141
/**
42-
* scrollLeft is negative value in rtl direction.
42+
* scrollTop/scrollLeft can be negative value under certain styles.
43+
* - direction: rtl https://github.com/othree/jquery.rtl-scroll-type
44+
* - writing-mode https://people.igalia.com/fwang/scrollable-elements-in-non-default-writing-modes/
45+
* - flex-direction: column-reverse/row-reverse
4346
*
44-
* left right
45-
* 0 100 spec compliant (ltr)
46-
* -100 0 spec compliant (rtl)
47-
* https://github.com/othree/jquery.rtl-scroll-type
47+
* top/left bottom/right
48+
* 0 100 spec compliant bottom/right overflow, or possibly top/left overflow in Chrome earlier than v85
49+
* -100 0 spec compliant top/left overflow
50+
* https://drafts.csswg.org/cssom-view/#scroll-an-element
4851
*/
49-
const normalizeOffset = (offset: number, isHorizontal: boolean): number => {
50-
if (isHorizontal && isRTLDocument()) {
51-
return -offset;
52-
} else {
53-
return offset;
54-
}
52+
const normalizeScrollOffset = (offset: number, isNegative: boolean): number => {
53+
return isNegative ? -offset : offset;
5554
};
5655

5756
const createScrollObserver = (
@@ -283,6 +282,7 @@ const createScrollScheduler = (
283282
export type Scroller = {
284283
$observe: (viewportElement: HTMLElement) => void;
285284
$dispose(): void;
285+
$isNegative(): boolean;
286286
$scrollTo: (offset: number) => void;
287287
$scrollBy: (offset: number) => void;
288288
$scrollToIndex: (index: number, opts?: ScrollToIndexOpts) => void;
@@ -299,14 +299,15 @@ export const createScroller = (
299299
let viewportElement: HTMLElement | undefined;
300300
let scrollObserver: ScrollObserver | undefined;
301301
let initialized = createPromise<boolean>();
302+
let isNegative = false;
302303
const scrollOffsetKey = isHorizontal ? "scrollLeft" : "scrollTop";
303304
const overflowKey = isHorizontal ? "overflowX" : "overflowY";
304305

305306
const [scheduleScroll, cancelScroll] = createScrollScheduler(
306307
store,
307308
() => initialized[0],
308309
(offset, smooth) => {
309-
offset = normalizeOffset(offset, isHorizontal);
310+
offset = normalizeScrollOffset(offset, isNegative);
310311

311312
if (smooth) {
312313
viewportElement!.scrollTo({
@@ -323,11 +324,33 @@ export const createScroller = (
323324
$observe(viewport) {
324325
viewportElement = viewport;
325326

327+
const clean = store.$subscribe(UPDATE_SIZE_EVENT, () => {
328+
const viewportSize = store.$getViewportSize();
329+
if (viewportSize) {
330+
const prev = viewport[scrollOffsetKey];
331+
332+
// Detect overflowed direction after the initial viewport measurement
333+
const dummy = getCurrentDocument(viewport).createElement("div");
334+
dummy.style.cssText = `visibility:hidden;min-${
335+
isHorizontal ? "width" : "height"
336+
}:${viewportSize + 1}px`;
337+
viewport.appendChild(dummy);
338+
viewport[scrollOffsetKey] = 1;
339+
// It can be positive under some specific situations even if negative mode, so we use `<` for now.
340+
isNegative = viewport[scrollOffsetKey] < 1;
341+
viewport.removeChild(dummy);
342+
343+
viewport[scrollOffsetKey] = prev;
344+
345+
clean();
346+
}
347+
});
348+
326349
scrollObserver = createScrollObserver(
327350
store,
328351
viewport,
329352
isHorizontal,
330-
() => normalizeOffset(viewport[scrollOffsetKey], isHorizontal),
353+
() => normalizeScrollOffset(viewport[scrollOffsetKey], isNegative),
331354
(jump, shift, isMomentumScrolling) => {
332355
// If we update scroll position while touching on iOS, the position will be reverted.
333356
// However iOS WebKit fires touch events only once at the beginning of momentum scrolling.
@@ -344,9 +367,9 @@ export const createScroller = (
344367

345368
// Use absolute position not to exceed scrollable bounds
346369
// https://github.com/inokawa/virtua/discussions/475
347-
viewport[scrollOffsetKey] = normalizeOffset(
370+
viewport[scrollOffsetKey] = normalizeScrollOffset(
348371
store.$getScrollOffset() + jump,
349-
isHorizontal
372+
isNegative
350373
);
351374
if (shift) {
352375
// https://github.com/inokawa/virtua/issues/357
@@ -363,6 +386,7 @@ export const createScroller = (
363386
// https://github.com/inokawa/virtua/pull/765
364387
initialized = createPromise();
365388
},
389+
$isNegative: () => isNegative,
366390
$scrollTo(offset) {
367391
scheduleScroll(() => offset);
368392
},
@@ -415,6 +439,7 @@ export const createScroller = (
415439
export type WindowScroller = {
416440
$observe(containerElement: HTMLElement): void;
417441
$dispose(): void;
442+
$isNegative(): boolean;
418443
$scrollToIndex: (index: number, opts?: ScrollToIndexOpts) => void;
419444
$fixScrollJump: () => void;
420445
};
@@ -429,13 +454,14 @@ export const createWindowScroller = (
429454
let containerElement: HTMLElement | undefined;
430455
let scrollObserver: ScrollObserver | undefined;
431456
let initialized = createPromise<boolean>();
457+
let isNegative = false;
432458
const scrollToKey = isHorizontal ? "left" : "top";
433459

434460
const [scheduleScroll] = createScrollScheduler(
435461
store,
436462
() => initialized[0],
437463
(offset, smooth) => {
438-
offset = normalizeOffset(offset, isHorizontal);
464+
offset = normalizeScrollOffset(offset, isNegative);
439465

440466
const window = getCurrentWindow(getCurrentDocument(containerElement!));
441467

@@ -463,7 +489,7 @@ export const createWindowScroller = (
463489
const offsetKey = isHorizontal ? "offsetLeft" : "offsetTop";
464490
const offsetSum =
465491
offset +
466-
(isHorizontal && isRTLDocument()
492+
(isHorizontal && isNegative
467493
? window.innerWidth - node[offsetKey] - node.offsetWidth
468494
: node[offsetKey]);
469495

@@ -489,25 +515,31 @@ export const createWindowScroller = (
489515
const document = getCurrentDocument(container);
490516
const window = getCurrentWindow(document);
491517

518+
if (isHorizontal) {
519+
// Detect RTL document
520+
isNegative =
521+
getComputedStyle(getDocumentElement(document)).direction === "rtl";
522+
}
523+
492524
scrollObserver = createScrollObserver(
493525
store,
494526
window,
495527
isHorizontal,
496-
() => normalizeOffset(window[scrollOffsetKey], isHorizontal),
528+
() => normalizeScrollOffset(window[scrollOffsetKey], isNegative),
497529
(jump, shift) => {
498530
// TODO support case two window scrollers exist in the same view
499531
if (shift) {
500532
// Use absolute position not to exceed scrollable bounds
501533
window.scroll({
502-
[scrollToKey]: normalizeOffset(
534+
[scrollToKey]: normalizeScrollOffset(
503535
store.$getScrollOffset() + jump,
504-
isHorizontal
536+
isNegative
505537
),
506538
});
507539
} else {
508540
// Use window.scrollBy here, which causes less layout shift for some reason.
509541
window.scrollBy({
510-
[scrollToKey]: normalizeOffset(jump, isHorizontal),
542+
[scrollToKey]: normalizeScrollOffset(jump, isNegative),
511543
});
512544
}
513545
},
@@ -524,6 +556,7 @@ export const createWindowScroller = (
524556
// https://github.com/inokawa/virtua/pull/765
525557
initialized = createPromise();
526558
},
559+
$isNegative: () => isNegative,
527560
$fixScrollJump: () => {
528561
scrollObserver && scrollObserver._fixScrollJump();
529562
},
@@ -550,7 +583,7 @@ export const createWindowScroller = (
550583

551584
const document = getCurrentDocument(containerElement);
552585
const window = getCurrentWindow(document);
553-
const html = document.documentElement;
586+
const html = getDocumentElement(document);
554587
const getScrollbarSize = () =>
555588
store.$getViewportSize() -
556589
(isHorizontal ? html.clientWidth : html.clientHeight);
@@ -587,6 +620,7 @@ export const createWindowScroller = (
587620
export type GridScroller = {
588621
$observe: (viewportElement: HTMLElement) => void;
589622
$dispose(): void;
623+
$isNegative(): boolean;
590624
$scrollTo: (offsetX?: number, offsetY?: number) => void;
591625
$scrollBy: (offsetX?: number, offsetY?: number) => void;
592626
$scrollToIndex: (indexX?: number, indexY?: number) => void;
@@ -611,6 +645,7 @@ export const createGridScroller = (
611645
rowScroller.$dispose();
612646
colScroller.$dispose();
613647
},
648+
$isNegative: colScroller.$isNegative,
614649
$scrollTo(row, col) {
615650
if (row != null) {
616651
rowScroller.$scrollTo(row);

0 commit comments

Comments
 (0)