Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/primitives/virtual.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,27 @@ import { VirtualRow, VirtualColumn } from './primitives/Virtual';

Use `cursor` property on the node to get the absolute index in the list of items.

### Viewport bounds in edge and auto modes

For a non-wrapping, uniform `VirtualRow` with an explicit positive numeric
`width` (or `VirtualColumn` with `height`), `scroll="edge"` and `scroll="auto"`
align the last item to the end of that viewport. The first item aligns to the
start; a short list that fits in the viewport stays at the start. Partially
visible leading items remain mounted so aligning the tail does not expose a gap.

Set `scrollStopLast={true}` to retain the previous slot-based stopping position
instead of aligning the last item. Configure `displaySize` for the viewport as
usual and clip the visible area with a parent view.

Viewport resize, initial `selected`, `scrollToIndex` and data changes use the
same bounds. An in-flight scroll is stopped when a resize places the list at a
new position. Focus stays on the current item, or the nearest remaining item
when the list shrinks.

`always`, `none` and `center` keep their existing behavior, as do wrapping lists,
non-uniform items and lists without an explicit viewport dimension. These
bounds do not add neighboring item previews or change the focus anchor mode.

### Performance Optimization

- Renders only a subset of the full list (`slice`) for improved memory and render-time performance.
Expand Down
88 changes: 84 additions & 4 deletions src/primitives/Virtual.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ function createVirtual<T>(
const items = s.createMemo(() => props.each || []);
const itemCount = s.createMemo(() => items().length);
const scrollType = s.createMemo(() => props.scroll || 'auto');
const viewportSize = () => props[isRow ? 'width' : 'height'];
const hasViewportBounds = () => {
const size = viewportSize();
return (
(scrollType() === 'edge' || scrollType() === 'auto') &&
!props.wrap &&
props.uniformSize !== false &&
typeof size === 'number' &&
size > 0 &&
size < Infinity
);
};
const usesViewportBounds = () => hasViewportBounds() && !props.scrollStopLast;

const initiallyLocked =
!!props.skipInitialWrap &&
Expand Down Expand Up @@ -393,6 +406,11 @@ function createVirtual<T>(
break;
}

if (usesViewportBounds()) {
start = boundWindowStart(start, total);
selected = c - start;
}

let newSlice = prev.slice;
if (start !== prev.start || newSlice.length === 0) {
const all = items();
Expand Down Expand Up @@ -422,6 +440,35 @@ function createVirtual<T>(

let viewRef!: lngp.NavigableElement;

function boundWindowStart(start: number, total: number) {
const slot = computeSize();
if (!usesViewportBounds() || !slot) return start;
// Include the partially visible leading item when the tail meets the viewport.
const visible = Math.ceil(
((viewportSize() as number) + (viewRef.gap || 0)) / slot,
);
return utils.clamp(start, 0, Math.max(0, total - visible));
}

function boundPosition(position: number, index: number) {
if (!usesViewportBounds()) return position;
const slot = computeSize();
if (!slot) return position;
const initial = originalPosition ?? 0;
const minimum = Math.min(
0,
(viewportSize() as number) - (itemCount() * slot - (viewRef.gap || 0)),
);
const base = initial + slice.start * slot;
const offset =
index === 0 || minimum === 0
? 0
: index === itemCount() - 1
? minimum
: utils.clamp(position - base, minimum, 0);
return base + offset;
}

function scrollToIndex(this: lng.ElementNode, index: number) {
s.untrack(() => {
if (itemCount() === 0) return;
Expand Down Expand Up @@ -475,7 +522,10 @@ function createVirtual<T>(

if (lng.Config.animationsEnabled) {
view.lng[axis] = shiftPrevChildPos - active[axis];
targetPosition = view.lng[axis] + childSize * slice.shiftBy;
targetPosition = boundPosition(
view.lng[axis] + childSize * slice.shiftBy,
cursor(),
);
const settings = view.animationSettings;
if (settings !== shiftSettingsFrom) {
shiftSettings = {};
Expand All @@ -500,8 +550,10 @@ function createVirtual<T>(
.animate(shiftProps, shiftSettings)
.start();
} else {
view.lng[axis] =
shiftPrevChildPos - active[axis] + childSize * slice.shiftBy;
view.lng[axis] = boundPosition(
shiftPrevChildPos - active[axis] + childSize * slice.shiftBy,
cursor(),
);
targetPosition = view.lng[axis];
}
}
Expand Down Expand Up @@ -574,9 +626,20 @@ function createVirtual<T>(
const item = items()[safeSel];
setCursor(safeSel);
const shiftBy = computeSlice(safeSel, 0).shiftBy;
const bounded = hasViewportBounds();
if (bounded) viewRef.selected = slice.selected;

queueMicrotask(() => {
if (bounded && safeSel !== cursor()) return;
if (layoutPending) viewRef.updateLayout();
// On initial mount the item size is only available after the first layout.
if (
bounded &&
boundWindowStart(slice.start, itemCount()) !== slice.start
) {
computeSlice(safeSel, 0);
if (layoutPending) viewRef.updateLayout();
}
const activeIndex = viewRef.children.findIndex((x) => x.item === item);
if (activeIndex === -1) return;
viewRef.selected = activeIndex;
Expand All @@ -591,7 +654,11 @@ function createVirtual<T>(
// the current position moved an already scrolled row a second slot.
let position: number = originalPosition;
if (shiftBy !== 0) position -= computeSize(slice.selected);
if (cachedAnimationController?.state === 'running') {
position = boundPosition(position, safeSel);
if (
cachedAnimationController?.state === 'running' ||
(bounded && cachedAnimationController?.state === 'scheduled')
) {
cachedAnimationController.stop();
}
viewRef.lng[axis] = position;
Expand Down Expand Up @@ -636,9 +703,22 @@ function createVirtual<T>(
}
slice.slice = noItems;
viewRef.selected = computeSlice(c, 0).selected;
if (hasViewportBounds()) updateSelected([c]);
}),
);

s.createEffect(
s.on(
[() => props.width, () => props.height, () => props.scrollStopLast],
() => {
if (!hasViewportBounds()) return;
slice.slice = noItems;
updateSelected([s.untrack(cursor)]);
},
{ defer: true },
),
);

// Clears layoutPending: the row's flex pass has run since the window
// changed. The app's onLayout still runs, read when called.
function onLayout(this: lng.ElementNode, target: lng.ElementNode) {
Expand Down
221 changes: 221 additions & 0 deletions tests/contract-virtual-scroll-end.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import * as v from 'vitest';
import * as s from 'solid-js';
import * as lng from '@solidtv/solid';
import {
VirtualRow,
VirtualColumn,
useFocusManager,
type KeyEventTarget,
} from '@solidtv/solid/primitives';
import { renderer } from './setup.js';

const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const items = Array.from({ length: 50 }, (_, i) => i);
let dispose: (() => void) | undefined;
let animationsEnabled: boolean;

v.beforeEach(() => {
animationsEnabled = lng.Config.animationsEnabled;
lng.Config.animationsEnabled = false;
});
v.afterEach(() => {
dispose?.();
dispose = undefined;
lng.Config.animationsEnabled = animationsEnabled;
});

for (const [Component, axis, forward, back] of [
[VirtualRow, 'x', 'ArrowRight', 'ArrowLeft'],
[VirtualColumn, 'y', 'ArrowDown', 'ArrowUp'],
] as const) {
const origin = axis === 'x' ? 37 : 19;
async function mount(
initialWidth: number,
initialItems = items,
scroll: 'edge' | 'auto' | 'always' | 'none' | 'center' = 'edge',
initialStopLast = false,
selected?: number,
) {
const [width, setWidth] = s.createSignal(initialWidth);
const [stopLast, setStopLast] = s.createSignal(initialStopLast);
const [data, setData] = s.createSignal(initialItems);
const keys = new EventTarget();
let row!: lng.ElementNode;
dispose = renderer.render(() => {
useFocusManager(undefined, keys as unknown as KeyEventTarget);
return (
<view width={1600} height={1600}>
<Component
ref={row}
autofocus
x={37}
y={19}
width={axis === 'x' ? width() : 196}
height={axis === 'y' ? width() : 196}
flexBoundary="contain"
each={data()}
displaySize={Math.max(1, Math.floor((width() + 20) / 216))}
bufferSize={3}
gap={20}
scroll={scroll}
scrollStopLast={stopLast()}
selected={selected}
>
{(item) => (
<view
id={`bound-${item()}`}
item={item()}
width={196}
height={196}
/>
)}
</Component>
</view>
);
}) as unknown as () => void;
await flush();
const press = async (key: string) => {
keys.dispatchEvent(new KeyboardEvent('keydown', { key }));
keys.dispatchEvent(new KeyboardEvent('keyup', { key }));
await flush();
};
const fragments = () => {
const positions = row.children.map(
(child) => row[axis] + child[axis] - origin,
);
return [
positions.some((p) => p < 0 && p + 196 > 0),
positions.some((p) => p < width() && p + 196 > width()),
];
};
return { row, press, fragments, setWidth, setStopLast, setData };
}

for (const mode of ['edge', 'auto'] as const) {
for (const width of [500, 648, 795]) {
v.it(
`${axis}, ${mode}, ${width}: aligns the last item and returns to the start`,
async () => {
const { row, press } = await mount(width, items.slice(0, 15), mode);
for (let i = 1; i < 15; i++) {
await press(forward);
v.expect(lng.activeElement()?.id).toBe(`bound-${i}`);
const active = lng.activeElement()!;
const left = row[axis] + active[axis] - origin;
v.expect(left).toBeGreaterThanOrEqual(0);
v.expect(left + 196).toBeLessThanOrEqual(width);
}
const last = row.children.find((child) => child.item === 14)!;
v.expect(row[axis] + last[axis] + 196 - origin).toBe(width);
v.expect(
row[axis] + row.children[0]![axis] - origin,
).toBeLessThanOrEqual(0);
for (let i = 14; i > 0; i--) await press(back);
v.expect(row[axis]).toBe(origin);
v.expect(lng.activeElement()?.id).toBe('bound-0');
},
);
}

v.it(
`${axis}, ${mode}: a short list stays at its original position`,
async () => {
const { row, press } = await mount(795, [0, 1, 2], mode);
await press(forward);
await press(forward);
v.expect(row[axis]).toBe(origin);
v.expect(lng.activeElement()?.id).toBe('bound-2');
},
);

v.it(
`${axis}, ${mode}: initial selected at the end uses the same alignment`,
async () => {
const { row } = await mount(795, items.slice(0, 15), mode, false, 14);
const last = row.children.find((child) => child.item === 14)!;
v.expect(row[axis] + last[axis] + 196 - origin).toBe(795);
v.expect(lng.activeElement()?.id).toBe('bound-14');
v.expect(
row[axis] + row.children[0]![axis] - origin,
).toBeLessThanOrEqual(0);
},
);

v.it(
`${axis}, ${mode}: resize followed immediately by navigation keeps focus consistent`,
async () => {
const { row, press, setWidth } = await mount(795, items, mode);
for (let i = 0; i < 9; i++) await press(forward);
setWidth(500);
await press(forward);
v.expect(row.cursor).toBe(10);
v.expect(lng.activeElement()?.id).toBe('bound-10');
v.expect(row.children[row.selected!]!.item).toBe(10);
},
);

v.it(
`${axis}, ${mode}: shrinking a scrolled list restores a short list to the start`,
async () => {
const { row, press, setData } = await mount(795, items, mode);
for (let i = 0; i < 9; i++) await press(forward);
setData([0, 1]);
await flush();
v.expect(row[axis]).toBe(origin);
v.expect(lng.activeElement()?.id).toBe('bound-1');
},
);

v.it(
`${axis}, ${mode}: scrollStopLast retains native placement`,
async () => {
const { row, press } = await mount(795, items.slice(0, 15), mode, true);
for (let i = 0; i < 14; i++) await press(forward);
const last = row.children.find((child) => child.item === 14)!;
const expectedGap = mode === 'edge' ? 167 : 383;
v.expect(795 - (row[axis] + last[axis] + 196 - origin)).toBe(
expectedGap,
);
},
);
}

for (const mode of ['edge', 'auto'] as const) {
v.it(
`${axis}, ${mode}: changing scrollStopLast updates the tail without changing focus`,
async () => {
const { row, press, setStopLast } = await mount(
795,
items.slice(0, 15),
mode,
);
for (let i = 0; i < 14; i++) await press(forward);
const right = () => {
const last = row.children.find((child) => child.item === 14)!;
return row[axis] + last[axis] + 196 - origin;
};
v.expect(right()).toBe(795);
setStopLast(true);
await flush();
v.expect(795 - right()).toBe(mode === 'edge' ? 167 : 383);
setStopLast(false);
await flush();
v.expect(right()).toBe(795);
v.expect(lng.activeElement()?.id).toBe('bound-14');
},
);
}

for (const mode of ['always', 'none', 'center'] as const) {
v.it(
`${axis}, ${mode}: viewport bounds do not change the native mode`,
async () => {
const { row, press } = await mount(795, items.slice(0, 15), mode);
for (let i = 0; i < 14; i++) await press(forward);
v.expect(lng.activeElement()?.id).toBe('bound-14');
const left = row[axis] + lng.activeElement()![axis] - origin;
v.expect(left).toBe(mode === 'always' ? 0 : 1080);
},
);
}
}
Loading