From f0e3d3e36da04d98eb3f78ddad335ae012c29375 Mon Sep 17 00:00:00 2001 From: Mikhail Teslya Date: Wed, 11 Nov 2026 14:16:40 +0300 Subject: [PATCH] feat(Virtual): support viewport bounds in edge and auto lists --- docs/primitives/virtual.md | 21 ++ src/primitives/Virtual.tsx | 88 +++++++- tests/contract-virtual-scroll-end.test.tsx | 221 +++++++++++++++++++++ tests/webgl/virtual-scroll-end.test.tsx | 111 +++++++++++ 4 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 tests/contract-virtual-scroll-end.test.tsx create mode 100644 tests/webgl/virtual-scroll-end.test.tsx diff --git a/docs/primitives/virtual.md b/docs/primitives/virtual.md index 52886ca2..319130ca 100644 --- a/docs/primitives/virtual.md +++ b/docs/primitives/virtual.md @@ -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. diff --git a/src/primitives/Virtual.tsx b/src/primitives/Virtual.tsx index 67e5fa83..40c7818f 100644 --- a/src/primitives/Virtual.tsx +++ b/src/primitives/Virtual.tsx @@ -43,6 +43,19 @@ function createVirtual( 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 && @@ -393,6 +406,11 @@ function createVirtual( break; } + if (usesViewportBounds()) { + start = boundWindowStart(start, total); + selected = c - start; + } + let newSlice = prev.slice; if (start !== prev.start || newSlice.length === 0) { const all = items(); @@ -422,6 +440,35 @@ function createVirtual( 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; @@ -475,7 +522,10 @@ function createVirtual( 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 = {}; @@ -500,8 +550,10 @@ function createVirtual( .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]; } } @@ -574,9 +626,20 @@ function createVirtual( 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; @@ -591,7 +654,11 @@ function createVirtual( // 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; @@ -636,9 +703,22 @@ function createVirtual( } 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) { diff --git a/tests/contract-virtual-scroll-end.test.tsx b/tests/contract-virtual-scroll-end.test.tsx new file mode 100644 index 00000000..993524f4 --- /dev/null +++ b/tests/contract-virtual-scroll-end.test.tsx @@ -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((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 ( + + + {(item) => ( + + )} + + + ); + }) 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); + }, + ); + } +} diff --git a/tests/webgl/virtual-scroll-end.test.tsx b/tests/webgl/virtual-scroll-end.test.tsx new file mode 100644 index 00000000..a7c7c0a1 --- /dev/null +++ b/tests/webgl/virtual-scroll-end.test.tsx @@ -0,0 +1,111 @@ +import * as v from 'vitest'; +import * as s from 'solid-js'; +import { activeElement, Config, type ElementNode } from '@solidtv/solid'; +import { + VirtualRow, + VirtualColumn, + useFocusManager, + type KeyEventTarget, +} from '@solidtv/solid/primitives'; +import { render, settle } from './setup.js'; + +for (const [Component, axis, key] of [ + [VirtualRow, 'x', 'ArrowRight'], + [VirtualColumn, 'y', 'ArrowDown'], +] as const) { + for (const mode of ['edge', 'auto'] as const) { + v.it( + `${axis}, ${mode}: viewport bounds align the end through animated resize`, + async () => { + const animationsEnabled = Config.animationsEnabled; + Config.animationsEnabled = false; + const keys = new EventTarget(); + const [viewport, setViewport] = s.createSignal(795); + let row!: ElementNode; + const dispose = render(() => { + useFocusManager(undefined, keys as unknown as KeyEventTarget); + return ( + + i)} + displaySize={Math.floor((viewport() + 20) / 216)} + bufferSize={3} + gap={20} + scroll={mode} + > + {(item) => ( + + )} + + + ); + }); + try { + await settle(); + for (let i = 0; i < 9; i++) { + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + keys.dispatchEvent(new KeyboardEvent('keyup', { key })); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + await settle(); + v.expect(activeElement()?.id).toBe('peek-9'); + v.expect(row.children.length).toBeLessThanOrEqual(9); + + Config.animationsEnabled = true; + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + keys.dispatchEvent(new KeyboardEvent('keyup', { key })); + await new Promise((resolve) => queueMicrotask(resolve)); + // Resize before the queued animation's first frame. Its old target + // must not overwrite the recalculated viewport position on later frames. + setViewport(500); + await settle(); + v.expect(activeElement()?.id).toBe('peek-10'); + ( + row as unknown as { scrollToIndex(index: number): void } + ).scrollToIndex(49); + await settle(); + const last = row.children[row.children.length - 1]!; + v.expect(row[axis] + last[axis] + 196).toBe(500); + v.expect(activeElement()?.id).toBe('peek-49'); + keys.dispatchEvent( + new KeyboardEvent('keydown', { + key: key === 'ArrowRight' ? 'ArrowLeft' : 'ArrowUp', + }), + ); + keys.dispatchEvent( + new KeyboardEvent('keyup', { + key: key === 'ArrowRight' ? 'ArrowLeft' : 'ArrowUp', + }), + ); + await settle(); + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + keys.dispatchEvent(new KeyboardEvent('keyup', { key })); + await settle(); + const finalItem = row.children.find((child) => child.item === 49)!; + // settle() waits for layout, but the renderer can keep animating + // while frameRequested is false. Wait for the final geometry too. + await v.vi.waitFor( + () => { + v.expect(row[axis] + finalItem[axis] + 196).toBe(500); + }, + { timeout: 2000, interval: 16 }, + ); + v.expect(activeElement()?.id).toBe('peek-49'); + } finally { + dispose(); + Config.animationsEnabled = animationsEnabled; + } + }, + ); + } +}