Compare commits
4 Commits
3ab94768a4
...
a094514a3e
| Author | SHA1 | Date | |
|---|---|---|---|
| a094514a3e | |||
| 53e831894a | |||
| a8e5f63415 | |||
| 1cab48ca48 |
@@ -67,7 +67,7 @@
|
|||||||
"tsdown": "catalog:"
|
"tsdown": "catalog:"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": ">=9.39.4"
|
"eslint": ">=10.8.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/fetch",
|
"name": "@robonen/fetch",
|
||||||
"version": "0.0.1",
|
"version": "0.0.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "A lightweight, type-safe fetch wrapper with interceptors, retry, and V8-optimized internals",
|
"description": "A lightweight, type-safe fetch wrapper with interceptors, retry, and V8-optimized internals",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { assertType, describe, expectTypeOf, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { FetchOptions } from './types';
|
||||||
|
import { createFetch } from './fetch';
|
||||||
|
|
||||||
|
type Body = FetchOptions['body'];
|
||||||
|
|
||||||
|
describe('FetchOptions body', () => {
|
||||||
|
it('accepts a named interface without a cast', () => {
|
||||||
|
// The regression under test: a named `interface` has no implicit index
|
||||||
|
// signature, so a `Record<string, unknown>` body type would reject it and
|
||||||
|
// force a cast at every call site. It must assign directly.
|
||||||
|
interface CreateUser { name: string; age: number }
|
||||||
|
expectTypeOf<CreateUser>().toExtend<Body>();
|
||||||
|
assertType<Body>({ name: 'Alice', age: 30 } satisfies CreateUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts plain objects, arrays, BodyInit strings and null', () => {
|
||||||
|
assertType<Body>({ a: 1 });
|
||||||
|
assertType<Body>([1, 2, 3]);
|
||||||
|
assertType<Body>('raw string');
|
||||||
|
assertType<Body>(new FormData());
|
||||||
|
assertType<Body>(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bare primitives', () => {
|
||||||
|
// @ts-expect-error a bare number is not a valid request body
|
||||||
|
assertType<Body>(42);
|
||||||
|
// @ts-expect-error a bare boolean is not a valid request body
|
||||||
|
assertType<Body>(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a typed interface flow through the method shortcuts', () => {
|
||||||
|
interface CreateDeal { title: string; amount: number }
|
||||||
|
const $fetch = createFetch();
|
||||||
|
const deal: CreateDeal = { title: 'x', amount: 1 };
|
||||||
|
// Must type-check with no cast on the body.
|
||||||
|
expectTypeOf($fetch.post).toBeCallableWith('/deals', { body: deal });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,8 +79,14 @@ export interface FetchOptions<R extends ResponseType = 'json', T = unknown>
|
|||||||
FetchHooks<T, R> {
|
FetchHooks<T, R> {
|
||||||
/** Base URL prepended to all relative request URLs */
|
/** Base URL prepended to all relative request URLs */
|
||||||
baseURL?: string;
|
baseURL?: string;
|
||||||
/** Request body — plain objects are automatically JSON-serialized */
|
/**
|
||||||
body?: RequestInit['body'] | Record<string, unknown> | unknown[] | null;
|
* Request body. `BodyInit` values (string, Blob, FormData, streams, …) are
|
||||||
|
* sent as-is; any other object or array is JSON-serialized. Typed as `object`
|
||||||
|
* rather than `Record<string, unknown>` so a named `interface` assigns
|
||||||
|
* directly — interfaces carry no implicit index signature and would otherwise
|
||||||
|
* force every caller to cast the body.
|
||||||
|
*/
|
||||||
|
body?: RequestInit['body'] | object | null;
|
||||||
/** Suppress throwing on 4xx/5xx responses */
|
/** Suppress throwing on 4xx/5xx responses */
|
||||||
ignoreResponseError?: boolean;
|
ignoreResponseError?: boolean;
|
||||||
/** URL query parameters serialized and appended to the request URL */
|
/** URL query parameters serialized and appended to the request URL */
|
||||||
|
|||||||
@@ -3,5 +3,11 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
// Type tests (*.test-d.ts) are statically analyzed with `tsc --noEmit`
|
||||||
|
// alongside the runtime suite.
|
||||||
|
typecheck: {
|
||||||
|
enabled: true,
|
||||||
|
tsconfig: './tsconfig.src.json',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/stdlib",
|
"name": "@robonen/stdlib",
|
||||||
"version": "0.0.10",
|
"version": "0.0.11",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "A collection of tools, utilities, and helpers for TypeScript",
|
"description": "A collection of tools, utilities, and helpers for TypeScript",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { FenwickTree } from '.';
|
||||||
|
|
||||||
|
/** Deterministic LCG so failures reproduce. */
|
||||||
|
function lcg(seed: number): () => number {
|
||||||
|
let state = seed;
|
||||||
|
return () => {
|
||||||
|
state = (state * 48271) % 2147483647;
|
||||||
|
return state / 2147483647;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FenwickTree', () => {
|
||||||
|
it('should match naive sums after build', () => {
|
||||||
|
const rand = lcg(1);
|
||||||
|
const values = Array.from({ length: 137 }, () => Math.floor(rand() * 100));
|
||||||
|
const tree = new FenwickTree(values.length);
|
||||||
|
tree.build(values);
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i <= values.length; i++) {
|
||||||
|
expect(tree.prefix(i)).toBe(sum);
|
||||||
|
if (i < values.length)
|
||||||
|
sum += values[i]!;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep prefix sums consistent with a naive array under updates', () => {
|
||||||
|
const rand = lcg(2);
|
||||||
|
const length = 64;
|
||||||
|
const naive = Array.from({ length }, () => Math.floor(rand() * 50));
|
||||||
|
const tree = new FenwickTree(length);
|
||||||
|
tree.build(naive);
|
||||||
|
|
||||||
|
for (let op = 0; op < 500; op++) {
|
||||||
|
const index = Math.floor(rand() * length);
|
||||||
|
const delta = Math.floor(rand() * 40) - 20;
|
||||||
|
naive[index]! += delta;
|
||||||
|
tree.update(index, delta);
|
||||||
|
|
||||||
|
const probe = Math.floor(rand() * (length + 1));
|
||||||
|
const expected = naive.slice(0, probe).reduce((a, b) => a + b, 0);
|
||||||
|
expect(tree.prefix(probe)).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match a linear scan in lowerBound, including stride and zero values', () => {
|
||||||
|
const rand = lcg(3);
|
||||||
|
for (let round = 0; round < 20; round++) {
|
||||||
|
const length = 1 + Math.floor(rand() * 40);
|
||||||
|
const values = Array.from({ length }, () => rand() < 0.2 ? 0 : Math.floor(rand() * 60));
|
||||||
|
const stride = round % 3 === 0 ? 0 : Math.floor(rand() * 10);
|
||||||
|
const tree = new FenwickTree(length);
|
||||||
|
tree.build(values);
|
||||||
|
|
||||||
|
const total = values.reduce((a, b) => a + b, 0) + length * stride;
|
||||||
|
for (const target of [-5, 0, 1, total / 3, total / 2, total - 1, total, total + 100]) {
|
||||||
|
let expected = 0;
|
||||||
|
for (let c = 0; c <= length; c++) {
|
||||||
|
const g = values.slice(0, c).reduce((a, b) => a + b, 0) + c * stride;
|
||||||
|
if (g <= target)
|
||||||
|
expected = c;
|
||||||
|
else
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (target < 0)
|
||||||
|
expected = 0;
|
||||||
|
expect(tree.lowerBound(target, stride), `length=${length} stride=${stride} target=${target}`).toBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle an empty tree', () => {
|
||||||
|
const tree = new FenwickTree(0);
|
||||||
|
|
||||||
|
expect(tree.prefix(0)).toBe(0);
|
||||||
|
expect(tree.lowerBound(0)).toBe(0);
|
||||||
|
expect(tree.lowerBound(100)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rebuild in place via build', () => {
|
||||||
|
const tree = new FenwickTree(4);
|
||||||
|
tree.build([1, 2, 3, 4]);
|
||||||
|
expect(tree.prefix(4)).toBe(10);
|
||||||
|
|
||||||
|
tree.build([10, 10, 10, 10]);
|
||||||
|
expect(tree.prefix(2)).toBe(20);
|
||||||
|
expect(tree.prefix(4)).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* @name FenwickTree
|
||||||
|
* @category Data Structures
|
||||||
|
* @description Fenwick (Binary Indexed) tree over an array of non-negative
|
||||||
|
* numbers: O(log n) prefix sums, point updates, and monotonic lower-bound
|
||||||
|
* search, plus O(n) bulk rebuild. `lowerBound` assumes all values are
|
||||||
|
* non-negative (the prefix function must be non-decreasing)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const tree = new FenwickTree(5);
|
||||||
|
* tree.build([10, 20, 30, 40, 50]);
|
||||||
|
* tree.prefix(3); // 60
|
||||||
|
* tree.update(1, 5); // value at index 1 becomes 25
|
||||||
|
* tree.lowerBound(65); // 3 — largest c with prefix(c) <= 65
|
||||||
|
*
|
||||||
|
* @since 0.0.11
|
||||||
|
*/
|
||||||
|
export class FenwickTree {
|
||||||
|
readonly size: number;
|
||||||
|
private readonly tree: Float64Array;
|
||||||
|
private readonly highBit: number;
|
||||||
|
|
||||||
|
constructor(size: number) {
|
||||||
|
this.size = size;
|
||||||
|
this.tree = new Float64Array(size + 1);
|
||||||
|
this.highBit = size > 0 ? 1 << (31 - Math.clz32(size)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk (re)initialization from raw values, O(n)
|
||||||
|
*
|
||||||
|
* @param {ArrayLike<number>} values The values to load, `values.length` must equal `size`
|
||||||
|
*/
|
||||||
|
build(values: ArrayLike<number>): void {
|
||||||
|
const { tree, size } = this;
|
||||||
|
tree.fill(0);
|
||||||
|
for (let i = 1; i <= size; i++) {
|
||||||
|
tree[i]! += values[i - 1]!;
|
||||||
|
const parent = i + (i & -i);
|
||||||
|
if (parent <= size)
|
||||||
|
tree[parent]! += tree[i]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add `delta` to the value at `index`, O(log n)
|
||||||
|
*
|
||||||
|
* @param {number} index Zero-based index of the value to change
|
||||||
|
* @param {number} delta Amount to add (may be negative)
|
||||||
|
*/
|
||||||
|
update(index: number, delta: number): void {
|
||||||
|
for (let i = index + 1; i <= this.size; i += i & -i)
|
||||||
|
this.tree[i]! += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum of the first `count` values, O(log n)
|
||||||
|
*
|
||||||
|
* @param {number} count How many leading values to sum
|
||||||
|
* @returns {number} The prefix sum
|
||||||
|
*/
|
||||||
|
prefix(count: number): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = count; i > 0; i -= i & -i)
|
||||||
|
sum += this.tree[i]!;
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largest `c` in `[0, size]` with `prefix(c) + c * stride <= target`, O(log n).
|
||||||
|
* `stride` models a constant per-item addition (e.g. a layout gap) without
|
||||||
|
* storing it in the tree
|
||||||
|
*
|
||||||
|
* @param {number} target The offset to search for
|
||||||
|
* @param {number} stride Constant added per item, defaults to `0`
|
||||||
|
* @returns {number} The largest count whose strided prefix does not exceed `target`
|
||||||
|
*/
|
||||||
|
lowerBound(target: number, stride = 0): number {
|
||||||
|
if (target < 0)
|
||||||
|
return 0;
|
||||||
|
let pos = 0;
|
||||||
|
let sum = 0;
|
||||||
|
for (let step = this.highBit; step > 0; step >>= 1) {
|
||||||
|
const next = pos + step;
|
||||||
|
if (next <= this.size) {
|
||||||
|
const candidate = sum + this.tree[next]!;
|
||||||
|
if (candidate + next * stride <= target) {
|
||||||
|
pos = next;
|
||||||
|
sum = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './BinaryHeap';
|
export * from './BinaryHeap';
|
||||||
export * from './CircularBuffer';
|
export * from './CircularBuffer';
|
||||||
export * from './Deque';
|
export * from './Deque';
|
||||||
|
export * from './FenwickTree';
|
||||||
export * from './LinkedList';
|
export * from './LinkedList';
|
||||||
export * from './PriorityQueue';
|
export * from './PriorityQueue';
|
||||||
export * from './Queue';
|
export * from './Queue';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/vue",
|
"name": "@robonen/vue",
|
||||||
"version": "0.0.14",
|
"version": "0.1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Collection of powerful tools for Vue",
|
"description": "Collection of powerful tools for Vue",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -2,45 +2,84 @@
|
|||||||
import { computed, ref, shallowRef } from 'vue';
|
import { computed, ref, shallowRef } from 'vue';
|
||||||
import { useVirtualList } from './index';
|
import { useVirtualList } from './index';
|
||||||
|
|
||||||
// 10,000 rows — only the visible window (plus overscan) is ever in the DOM.
|
interface Message {
|
||||||
const total = 10000;
|
id: number;
|
||||||
const items = shallowRef(
|
author: string;
|
||||||
Array.from({ length: total }, (_, i) => ({
|
text: string;
|
||||||
id: i,
|
expanded: boolean;
|
||||||
label: `Row #${(i + 1).toString().padStart(5, '0')}`,
|
}
|
||||||
hue: (i * 37) % 360,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const itemHeight = 44;
|
const WORDS = 'virtual scrolling keeps the DOM small while the list pretends to be infinite and every row is free to size itself'.split(' ');
|
||||||
|
|
||||||
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(items, {
|
let nextId = 0;
|
||||||
itemHeight,
|
function makeMessage(): Message {
|
||||||
|
const id = nextId++;
|
||||||
|
const length = 4 + (id * 31) % 60; // deterministic variable length
|
||||||
|
const text = Array.from({ length }, (_, i) => WORDS[(id + i) % WORDS.length]).join(' ');
|
||||||
|
return { id, author: `user-${id % 7}`, text, expanded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMessages(count: number): Message[] {
|
||||||
|
return Array.from({ length: count }, makeMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = shallowRef<Message[]>(makeMessages(10000));
|
||||||
|
|
||||||
|
const CHARS_PER_LINE = 58; // calibrated against the docs demo card width
|
||||||
|
|
||||||
|
const { list, containerProps, wrapperProps, scrollTo, isScrolling } = useVirtualList(messages, {
|
||||||
|
// Rows are genuinely variable (1–2 clamped lines collapsed, full text
|
||||||
|
// expanded), so the estimate is data-driven. It only has to be close, not
|
||||||
|
// exact — measured sizes replace it per row and are cached by key. What
|
||||||
|
// hurts is *systematic* error: a big overestimate makes every revealed row
|
||||||
|
// shrink on measure, and anchoring then fights the scroll.
|
||||||
|
estimateSize: message => 37 + Math.min(2, Math.ceil(message.text.length / CHARS_PER_LINE)) * 20,
|
||||||
|
getItemKey: message => message.id, // measurements survive prepend/reorder
|
||||||
|
followOutput: true, // pinned to the newest message when the user is at the end
|
||||||
overscan: 6,
|
overscan: 6,
|
||||||
|
gap: 6,
|
||||||
|
paddingStart: 8,
|
||||||
|
paddingEnd: 8,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function prepend() {
|
||||||
|
// Immutable update: getItemKey keeps measurements attached to the right
|
||||||
|
// messages and scroll anchoring keeps the viewport visually still.
|
||||||
|
messages.value = [...makeMessages(20), ...messages.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function append() {
|
||||||
|
// With followOutput the view stays glued to the end if the user is there.
|
||||||
|
messages.value = [...messages.value, ...makeMessages(5)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(message: Message) {
|
||||||
|
messages.value = messages.value.map(current =>
|
||||||
|
current === message ? { ...current, expanded: !current.expanded } : current,
|
||||||
|
);
|
||||||
|
// No manual remeasure: the row's ResizeObserver sees the new height
|
||||||
|
// before paint and the layout shifts without flicker.
|
||||||
|
}
|
||||||
|
|
||||||
const jumpTo = ref(5000);
|
const jumpTo = ref(5000);
|
||||||
|
|
||||||
function go() {
|
function go() {
|
||||||
const index = Math.min(Math.max(jumpTo.value || 0, 0), total - 1);
|
scrollTo(jumpTo.value || 0, { align: 'center' });
|
||||||
scrollTo(index, { behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleRange = computed(() => {
|
const visibleRange = computed(() => {
|
||||||
if (list.value.length === 0)
|
if (list.value.length === 0)
|
||||||
return '—';
|
return '—';
|
||||||
const first = list.value[0]!.index;
|
return `${list.value[0]!.index}–${list.value[list.value.length - 1]!.index}`;
|
||||||
const last = list.value[list.value.length - 1]!.index;
|
|
||||||
return `${first}–${last}`;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="demo-stack max-w-sm">
|
<div class="demo-stack max-w-sm">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="demo-label">Virtual list</span>
|
<span class="demo-label">Dynamic virtual list</span>
|
||||||
<span class="demo-badge">
|
<span class="demo-badge">
|
||||||
{{ total.toLocaleString() }} rows
|
{{ messages.length.toLocaleString() }} rows
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,25 +88,32 @@ const visibleRange = computed(() => {
|
|||||||
class="demo-card h-64"
|
class="demo-card h-64"
|
||||||
>
|
>
|
||||||
<div v-bind="wrapperProps">
|
<div v-bind="wrapperProps">
|
||||||
<div
|
<article
|
||||||
v-for="{ data, index } in list"
|
v-for="item in list"
|
||||||
:key="index"
|
:key="item.key"
|
||||||
class="flex items-center gap-3 border-b border-border px-3"
|
v-bind="item.props"
|
||||||
:style="{ height: `${itemHeight}px` }"
|
class="cursor-pointer border-b border-border px-3 py-2"
|
||||||
|
@click="toggle(item.data)"
|
||||||
>
|
>
|
||||||
<span
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
class="size-6 shrink-0 rounded-md border border-border"
|
<span class="font-mono text-xs text-fg-subtle">{{ item.data.author }}</span>
|
||||||
:style="{ backgroundColor: `hsl(${data.hue} 65% 55%)` }"
|
<span class="text-xs text-fg-subtle tabular-nums">#{{ item.index }}</span>
|
||||||
/>
|
</div>
|
||||||
<span class="flex-1 truncate font-mono text-sm text-fg tabular-nums">{{ data.label }}</span>
|
<!-- natural height: collapsed rows clamp to two lines (still variable),
|
||||||
<span class="text-xs text-fg-subtle">idx {{ index }}</span>
|
expanded rows grow to the full text -->
|
||||||
</div>
|
<p
|
||||||
|
class="mt-1 text-sm text-fg"
|
||||||
|
:class="item.data.expanded ? '' : 'line-clamp-2'"
|
||||||
|
>
|
||||||
|
{{ item.data.text }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums flex items-center justify-between">
|
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums flex items-center justify-between">
|
||||||
<span class="text-fg-muted">rendered</span>
|
<span class="text-fg-muted">rendered</span>
|
||||||
<span>{{ list.length }} nodes · idx {{ visibleRange }}</span>
|
<span>{{ list.length }} nodes · idx {{ visibleRange }}<span v-if="isScrolling"> · scrolling</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-end gap-2">
|
||||||
@@ -77,7 +123,7 @@ const visibleRange = computed(() => {
|
|||||||
v-model.number="jumpTo"
|
v-model.number="jumpTo"
|
||||||
type="number"
|
type="number"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="total - 1"
|
:max="messages.length - 1"
|
||||||
class="demo-input"
|
class="demo-input"
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
@@ -88,6 +134,20 @@ const visibleRange = computed(() => {
|
|||||||
>
|
>
|
||||||
Jump
|
Jump
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-bg px-3 py-2 text-sm font-medium text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="prepend"
|
||||||
|
>
|
||||||
|
Prepend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-bg px-3 py-2 text-sm font-medium text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="append"
|
||||||
|
>
|
||||||
|
Append
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,35 +1,54 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { effectScope, nextTick, ref } from 'vue';
|
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||||
import { useVirtualList } from '.';
|
import { useVirtualList } from '.';
|
||||||
|
|
||||||
|
type ObserverRecord = InstanceType<typeof StubResizeObserver>;
|
||||||
|
|
||||||
|
const observers: ObserverRecord[] = [];
|
||||||
|
|
||||||
class StubResizeObserver {
|
class StubResizeObserver {
|
||||||
|
callback: ResizeObserverCallback;
|
||||||
observe = vi.fn();
|
observe = vi.fn();
|
||||||
disconnect = vi.fn();
|
|
||||||
unobserve = vi.fn();
|
unobserve = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
observers.push(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeContainer(overrides: Partial<{
|
function makeContainer(overrides: Partial<{
|
||||||
clientWidth: number;
|
clientWidth: number;
|
||||||
clientHeight: number;
|
clientHeight: number;
|
||||||
scrollWidth: number;
|
|
||||||
scrollHeight: number;
|
|
||||||
}> = {}) {
|
}> = {}) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
Object.defineProperties(el, {
|
Object.defineProperties(el, {
|
||||||
clientWidth: { value: overrides.clientWidth ?? 100, configurable: true },
|
clientWidth: { value: overrides.clientWidth ?? 100, configurable: true },
|
||||||
clientHeight: { value: overrides.clientHeight ?? 100, configurable: true },
|
clientHeight: { value: overrides.clientHeight ?? 100, configurable: true },
|
||||||
scrollWidth: { value: overrides.scrollWidth ?? 10000, configurable: true },
|
|
||||||
scrollHeight: { value: overrides.scrollHeight ?? 10000, configurable: true },
|
|
||||||
});
|
});
|
||||||
el.scrollTop = 0;
|
el.scrollTop = 0;
|
||||||
el.scrollLeft = 0;
|
el.scrollLeft = 0;
|
||||||
el.scrollTo = vi.fn((opts: ScrollToOptions) => {
|
el.scrollTo = vi.fn((opts: ScrollToOptions) => {
|
||||||
if (typeof opts.top === 'number') el.scrollTop = opts.top;
|
if (typeof opts.top === 'number')
|
||||||
if (typeof opts.left === 'number') el.scrollLeft = opts.left;
|
el.scrollTop = opts.top;
|
||||||
|
if (typeof opts.left === 'number')
|
||||||
|
el.scrollLeft = opts.left;
|
||||||
}) as unknown as typeof el.scrollTo;
|
}) as unknown as typeof el.scrollTo;
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeRow(index: number): HTMLElement {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.dataset.index = String(index);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeEntry(target: Element, blockSize: number, inlineSize = 50): ResizeObserverEntry {
|
||||||
|
return { target, borderBoxSize: [{ blockSize, inlineSize }] } as unknown as ResizeObserverEntry;
|
||||||
|
}
|
||||||
|
|
||||||
function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effectScope> } {
|
function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effectScope> } {
|
||||||
const scope = effectScope();
|
const scope = effectScope();
|
||||||
let result!: T;
|
let result!: T;
|
||||||
@@ -41,181 +60,318 @@ function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effect
|
|||||||
|
|
||||||
describe(useVirtualList, () => {
|
describe(useVirtualList, () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
observers.length = 0;
|
||||||
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
||||||
});
|
});
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
it('renders an empty window before the container mounts (SSR-safe)', () => {
|
const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20 }));
|
|
||||||
|
|
||||||
|
it('renders the initial window from estimates and initialContainerSize', () => {
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
overscan: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// top=0 → start 0; bottom=200 → lowerBound=5 (5*40 ≤ 200) → end 6 → +overscan
|
||||||
|
expect(result.range.value).toEqual({ start: 0, end: 7 });
|
||||||
|
expect(result.list.value).toHaveLength(7);
|
||||||
|
expect(result.list.value[0]!.start).toBe(0);
|
||||||
|
expect(result.list.value[3]!.start).toBe(120);
|
||||||
|
expect(result.totalSize.value).toBe(1000 * 40);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies paddingStart and gap to offsets and totalSize', () => {
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 10), {
|
||||||
|
estimateSize: 40,
|
||||||
|
gap: 8,
|
||||||
|
paddingStart: 12,
|
||||||
|
paddingEnd: 20,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
overscan: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.list.value[0]!.start).toBe(12);
|
||||||
|
expect(result.list.value[1]!.start).toBe(12 + 40 + 8);
|
||||||
|
expect(result.totalSize.value).toBe(12 + 10 * 40 + 9 * 8 + 20);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes item and index to the estimate function', () => {
|
||||||
|
const estimateSize = vi.fn((item: { id: number }, _index: number) => 10 + item.id);
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 3), {
|
||||||
|
estimateSize,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(estimateSize).toHaveBeenCalledWith(items[0], 0);
|
||||||
|
expect(result.totalSize.value).toBe(10 + 11 + 12);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds when the source ref is replaced', async () => {
|
||||||
|
const source = shallowRef(items.slice(0, 10));
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(source, {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.totalSize.value).toBe(400);
|
||||||
|
|
||||||
|
source.value = items.slice(0, 3);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result.totalSize.value).toBe(120);
|
||||||
|
expect(result.range.value.end).toBeLessThanOrEqual(3);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the range when the source shrinks to empty', async () => {
|
||||||
|
const source = shallowRef(items.slice(0, 10));
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(source, {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
source.value = [];
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result.range.value).toEqual({ start: 0, end: 0 });
|
||||||
expect(result.list.value).toEqual([]);
|
expect(result.list.value).toEqual([]);
|
||||||
expect(result.containerProps.ref.value).toBeNull();
|
expect(result.totalSize.value).toBe(0);
|
||||||
expect(result.containerProps.style).toEqual({ overflowY: 'auto' });
|
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes the documented return shape', () => {
|
it('getOffsetForIndex honors align and clamps to content bounds', () => {
|
||||||
const { result, scope } = withScope(() => useVirtualList([1, 2, 3], { itemHeight: 20 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 100), {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
}));
|
||||||
|
|
||||||
expect(result).toHaveProperty('list');
|
expect(result.getOffsetForIndex(0, 'start')).toBe(0);
|
||||||
expect(result).toHaveProperty('scrollTo');
|
expect(result.getOffsetForIndex(50, 'start')).toBe(2000);
|
||||||
expect(result).toHaveProperty('containerProps');
|
expect(result.getOffsetForIndex(50, 'center')).toBe(2000 - (200 - 40) / 2);
|
||||||
expect(result).toHaveProperty('wrapperProps');
|
expect(result.getOffsetForIndex(50, 'end')).toBe(2000 - 200 + 40);
|
||||||
expect(typeof result.scrollTo).toBe('function');
|
// clamp: the last item can't be aligned past max scroll
|
||||||
expect(typeof result.containerProps.onScroll).toBe('function');
|
expect(result.getOffsetForIndex(99, 'start')).toBe(100 * 40 - 200);
|
||||||
|
// 'auto' on a visible item → keep the current offset
|
||||||
|
expect(result.getOffsetForIndex(1, 'auto')).toBe(0);
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('slices the visible window plus overscan (vertical, fixed height)', async () => {
|
it('resolves auto-align with nearest-edge semantics for oversized items', () => {
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
// item 50 is taller than the 200px viewport and lies below it
|
||||||
|
const below = withScope(() => useVirtualList(() => items.slice(0, 100), {
|
||||||
|
estimateSize: (_item, index) => index === 50 ? 500 : 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
}));
|
||||||
|
// nearest: approaching an oversized item from above aligns its start
|
||||||
|
expect(below.result.getOffsetForIndex(50, 'auto')).toBe(2000);
|
||||||
|
below.scope.stop();
|
||||||
|
|
||||||
|
// item 0 is taller than the viewport and already covers it → no-op
|
||||||
|
const covering = withScope(() => useVirtualList(() => items.slice(0, 100), {
|
||||||
|
estimateSize: (_item, index) => index === 0 ? 500 : 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
}));
|
||||||
|
expect(covering.result.getOffsetForIndex(0, 'auto')).toBe(0);
|
||||||
|
covering.scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrollTo re-syncs a same-tick source replacement synchronously', () => {
|
||||||
|
const source = shallowRef(items.slice(0, 10));
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(source, {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.totalSize.value).toBe(400);
|
||||||
|
|
||||||
|
source.value = items.slice(0, 100);
|
||||||
|
// no nextTick: the canonical "append then scroll to newest" gesture
|
||||||
|
result.scrollTo(99);
|
||||||
|
|
||||||
|
expect(result.totalSize.value).toBe(4000);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrollTo and remeasure are safe no-ops without a scroll element', () => {
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 10), {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
result.scrollTo(5);
|
||||||
|
result.scrollToOffset(100);
|
||||||
|
result.remeasure();
|
||||||
|
result.remeasure(2);
|
||||||
|
result.updateLayout();
|
||||||
|
}).not.toThrow();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slices the window with correct data and indices once the container mounts', async () => {
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 2 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
overscan: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// pre-mount: a small fallback window instead of an empty flash
|
||||||
|
expect(result.list.value.length).toBeGreaterThan(0);
|
||||||
|
expect(result.list.value.length).toBeLessThanOrEqual(1 + 2);
|
||||||
|
expect(result.containerProps.style).toMatchObject({ overflowY: 'auto', overflowAnchor: 'none' });
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// offset(0) = 0, capacity = ceil(100/20) = 5, overscan 2 -> start 0, end 7.
|
// capacity = 100/20 = 5 → end 6, +overscan 2 → 8 rows
|
||||||
expect(result.list.value[0]).toEqual({ data: 0, index: 0 });
|
expect(result.list.value[0]).toMatchObject({ data: items[0], index: 0, start: 0, size: 20 });
|
||||||
expect(result.list.value).toHaveLength(7);
|
expect(result.list.value).toHaveLength(8);
|
||||||
expect(result.list.value.at(-1)).toEqual({ data: 6, index: 6 });
|
expect(result.list.value[0]!.props['data-index']).toBe(0);
|
||||||
|
expect(result.list.value[0]!.props.style.transform).toBe('translateY(0px)');
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recomputes the window on scroll with correct original indices', async () => {
|
it('recomputes the window on scroll with correct original indices', async () => {
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 2 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
overscan: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
el.scrollTop = 400; // offset = floor(400/20) = 20
|
el.scrollTop = 400; // first visible = 400/20 = 20
|
||||||
el.dispatchEvent(new Event('scroll'));
|
el.dispatchEvent(new Event('scroll'));
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// start = 20 - 2 = 18, end = 20 + 5 + 2 = 27
|
expect(result.list.value[0]!.index).toBe(18); // 20 - overscan
|
||||||
expect(result.list.value[0]).toEqual({ data: 18, index: 18 });
|
expect(result.isScrolling.value).toBeTruthy();
|
||||||
expect(result.list.value.at(-1)).toEqual({ data: 26, index: 26 });
|
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes total height and offset spacers via wrapperProps', async () => {
|
it('scrollTo writes the scroll offset for the requested alignment', async () => {
|
||||||
const data = Array.from({ length: 50 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
overscan: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// total height = 50 * 20 = 1000; at top, marginTop = 0
|
result.scrollTo(30, { align: 'start' });
|
||||||
expect(result.wrapperProps.value.style.height).toBe('1000px');
|
|
||||||
expect(result.wrapperProps.value.style.marginTop).toBe('0px');
|
|
||||||
expect(result.wrapperProps.value.style.width).toBe('100%');
|
|
||||||
|
|
||||||
el.scrollTop = 200; // offset = 10, start = 10
|
|
||||||
el.dispatchEvent(new Event('scroll'));
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
// marginTop = distance(10) = 200px; remaining height = 1000 - 200 = 800px
|
|
||||||
expect(result.wrapperProps.value.style.marginTop).toBe('200px');
|
|
||||||
expect(result.wrapperProps.value.style.height).toBe('800px');
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports horizontal layout with itemWidth', async () => {
|
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientWidth: 100 });
|
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemWidth: 25, overscan: 1 }));
|
|
||||||
|
|
||||||
expect(result.containerProps.style).toEqual({ overflowX: 'auto' });
|
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
// capacity = ceil(100/25) = 4, overscan 1 -> start 0, end 5
|
|
||||||
expect(result.list.value).toHaveLength(5);
|
|
||||||
expect(result.wrapperProps.value.style.display).toBe('flex');
|
|
||||||
expect(result.wrapperProps.value.style.height).toBe('100%');
|
|
||||||
expect(result.wrapperProps.value.style.marginLeft).toBe('0px');
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports variable item heights via a getter (prefix-sum metrics)', async () => {
|
|
||||||
const data = Array.from({ length: 100 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
|
||||||
// even indices: 40px, odd: 10px
|
|
||||||
const itemHeight = (i: number) => (i % 2 === 0 ? 40 : 10);
|
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight, overscan: 0 }));
|
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect(result.list.value[0]).toEqual({ data: 0, index: 0 });
|
|
||||||
|
|
||||||
// total = 50 * 40 + 50 * 10 = 2500
|
|
||||||
expect(result.wrapperProps.value.style.height).toBe('2500px');
|
|
||||||
|
|
||||||
// distance to index 4 = sizes[0..3] = 40+10+40+10 = 100
|
|
||||||
el.scrollTop = 100;
|
|
||||||
el.dispatchEvent(new Event('scroll'));
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect(result.list.value[0]).toEqual({ data: 4, index: 4 });
|
|
||||||
expect(result.wrapperProps.value.style.marginTop).toBe('100px');
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scrollTo moves the container and re-slices', async () => {
|
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
|
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
result.scrollTo(30);
|
|
||||||
// distance(30) = 30 * 20 = 600; block 'start' keeps offset 0
|
|
||||||
expect(el.scrollTop).toBe(600);
|
expect(el.scrollTop).toBe(600);
|
||||||
expect(result.list.value[0]).toEqual({ data: 30, index: 30 });
|
|
||||||
|
result.scrollTo(30, { align: 'center' });
|
||||||
|
expect(el.scrollTop).toBe(600 - (100 - 20) / 2);
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scrollTo is a no-op when the container is not mounted', () => {
|
it('applies measured sizes delivered by the ResizeObserver', async () => {
|
||||||
const { result, scope } = withScope(() => useVirtualList([1, 2, 3], { itemHeight: 20 }));
|
|
||||||
expect(() => result.scrollTo(2)).not.toThrow();
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reacts to a changing source ref', async () => {
|
|
||||||
const data = ref(Array.from({ length: 10 }, (_, i) => i));
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
overscan: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// total = 10 * 20 = 200
|
const observer = observers[0]!;
|
||||||
expect(result.wrapperProps.value.style.height).toBe('200px');
|
const row = makeRow(0);
|
||||||
|
result.measureElement(row);
|
||||||
|
expect(observer.observe).toHaveBeenCalledWith(row, { box: 'border-box' });
|
||||||
|
|
||||||
data.value = Array.from({ length: 100 }, (_, i) => i);
|
observer.callback([resizeEntry(row, 90)], observer as unknown as ResizeObserver);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// total = 100 * 20 = 2000
|
// row 0: 20 → 90, total grows by 70
|
||||||
expect(result.wrapperProps.value.style.height).toBe('2000px');
|
expect(result.totalSize.value).toBe(1000 * 20 + 70);
|
||||||
|
expect(result.list.value[0]!.size).toBe(90);
|
||||||
|
expect(result.list.value[1]!.start).toBe(90);
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps the window to the source bounds', async () => {
|
it('compensates the scroll offset when an item above the viewport grows', async () => {
|
||||||
const data = Array.from({ length: 3 }, (_, i) => i);
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const el = makeContainer({ clientHeight: 1000 });
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 5 }));
|
estimateSize: 20,
|
||||||
|
overscan: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(result.list.value).toHaveLength(3);
|
el.scrollTop = 400;
|
||||||
expect(result.list.value.at(-1)).toEqual({ data: 2, index: 2 });
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const observer = observers[0]!;
|
||||||
|
const row = makeRow(0); // starts at 0, above the viewport top (400)
|
||||||
|
result.measureElement(row);
|
||||||
|
observer.callback([resizeEntry(row, 100)], observer as unknown as ResizeObserver);
|
||||||
|
await nextTick(); // deferred compensation write lands post-patch
|
||||||
|
|
||||||
|
// growth of 80 above the viewport → scrollTop compensated to 480
|
||||||
|
expect(el.scrollTop).toBe(480);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures component instances through $el', () => {
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const row = makeRow(3);
|
||||||
|
result.measureElement({ $el: row });
|
||||||
|
|
||||||
|
expect(observers[0]!.observe).toHaveBeenCalledWith(row, { box: 'border-box' });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns once when the ref target cannot be measured', () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
result.measureElement({ $el: document.createTextNode('fragment anchor') });
|
||||||
|
result.measureElement({ $el: null });
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
warn.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports horizontal layout', async () => {
|
||||||
|
const el = makeContainer({ clientWidth: 100 });
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 25,
|
||||||
|
axis: 'x',
|
||||||
|
overscan: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.containerProps.style).toMatchObject({ overflowX: 'auto' });
|
||||||
|
|
||||||
|
result.containerProps.ref.value = el;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// capacity = 100/25 = 4 → end 5, +overscan 1 → 6 rows
|
||||||
|
expect(result.list.value).toHaveLength(6);
|
||||||
|
expect(result.list.value[1]!.props.style.transform).toBe('translateX(25px)');
|
||||||
|
expect(result.wrapperProps.value.style.width).toBe(`${1000 * 25}px`);
|
||||||
|
expect(result.wrapperProps.value.style.height).toBe('100%');
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user