feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { createReusableTemplate } from '.';
describe(createReusableTemplate, () => {
it('returns a destructurable [define, reuse] pair', () => {
const pair = createReusableTemplate();
expect(pair[0]).toBeDefined();
expect(pair[1]).toBeDefined();
expect(pair.define).toBe(pair[0]);
expect(pair.reuse).toBe(pair[1]);
const [DefineTemplate, ReuseTemplate] = pair;
expect(DefineTemplate).toBe(pair.define);
expect(ReuseTemplate).toBe(pair.reuse);
});
it('renders the defined template wherever reuse is used', () => {
const Host = defineComponent({
setup() {
const [DefineTemplate, ReuseTemplate] = createReusableTemplate();
return () => [
h(DefineTemplate, () => h('span', { class: 'tpl' }, 'Hello')),
h(ReuseTemplate),
h(ReuseTemplate),
];
},
});
const wrapper = mount(Host);
expect(wrapper.findAll('.tpl')).toHaveLength(2);
expect(wrapper.text()).toBe('HelloHello');
});
it('passes props from reuse to the define slot as bindings', () => {
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{ label: string }>();
const Host = defineComponent({
setup() {
return () => [
h(DefineTemplate, {}, {
default: (bindings: { label: string }) => h('span', bindings.label),
}),
h(ReuseTemplate, { label: 'A' }),
h(ReuseTemplate, { label: 'B' }),
];
},
});
const wrapper = mount(Host);
expect(wrapper.text()).toBe('AB');
});
it('camelizes raw attrs when no props option is given', () => {
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{ myValue: string }>();
const Host = defineComponent({
setup() {
return () => [
h(DefineTemplate, {}, {
default: (bindings: { myValue: string }) => h('span', bindings.myValue),
}),
// kebab attr should arrive camelized as `myValue` (raw attrs are untyped here)
h(ReuseTemplate as unknown as string, { 'my-value': 'hi' }),
];
},
});
const wrapper = mount(Host);
expect(wrapper.text()).toBe('hi');
});
it('reacts to changes in the defined template', async () => {
const text = ref('first');
const Host = defineComponent({
setup() {
const [DefineTemplate, ReuseTemplate] = createReusableTemplate();
return () => [
h(DefineTemplate, () => h('span', { class: 'out' }, text.value)),
h(ReuseTemplate),
];
},
});
const wrapper = mount(Host);
expect(wrapper.find('.out').text()).toBe('first');
text.value = 'second';
await nextTick();
expect(wrapper.find('.out').text()).toBe('second');
});
it('uses a typed props option for bindings', () => {
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{ count: number }>({
props: {
count: { type: Number, required: true },
},
});
const Host = defineComponent({
setup() {
return () => [
h(DefineTemplate, {}, {
default: (b: { count: number }) => h('span', String(b.count * 2)),
}),
h(ReuseTemplate, { count: 21 }),
];
},
});
const wrapper = mount(Host);
expect(wrapper.text()).toBe('42');
});
it('inherits attrs onto a single root vnode by default', () => {
const [DefineTemplate, ReuseTemplate] = createReusableTemplate({
props: { label: { type: String } },
});
const Host = defineComponent({
setup() {
return () => [
h(DefineTemplate, {}, {
default: () => h('div', { class: 'root' }, 'x'),
}),
h(ReuseTemplate, { 'data-test': 'yes', label: 'l' }),
];
},
});
const wrapper = mount(Host);
const root = wrapper.find('.root');
expect(root.attributes('data-test')).toBe('yes');
});
it('does not merge attrs onto root when inheritAttrs is false', () => {
const [DefineTemplate, ReuseTemplate] = createReusableTemplate({
inheritAttrs: false,
props: { label: { type: String } },
});
const Host = defineComponent({
setup() {
return () => [
h(DefineTemplate, {}, {
default: () => h('div', { class: 'root' }, 'x'),
}),
h(ReuseTemplate, { 'data-test': 'yes', label: 'l' }),
];
},
});
const wrapper = mount(Host);
const root = wrapper.find('.root');
expect(root.attributes('data-test')).toBeUndefined();
});
it('forwards nested slots through $slots binding', () => {
const [DefineTemplate, ReuseTemplate] = createReusableTemplate();
const Host = defineComponent({
setup() {
return () => [
h(DefineTemplate, {}, {
default: (bindings: { $slots: Record<string, any> }) =>
h('div', { class: 'wrap' }, bindings.$slots.default?.()),
}),
h(ReuseTemplate, {}, {
default: () => h('em', 'slotted'),
}),
];
},
});
const wrapper = mount(Host);
expect(wrapper.find('.wrap em').text()).toBe('slotted');
});
it('uses a custom name for the components', () => {
const [DefineTemplate, ReuseTemplate] = createReusableTemplate({ name: 'MyTpl' });
expect((DefineTemplate as any).name).toBe('MyTpl.define');
expect((ReuseTemplate as any).name).toBe('MyTpl.reuse');
});
it('throws when reuse renders before define (dev), and is SSR/render-safe', () => {
const [, ReuseTemplate] = createReusableTemplate();
const Host = defineComponent({
setup() {
return () => h(ReuseTemplate);
},
});
// In dev (NODE_ENV !== 'production') the missing-definition path throws.
expect(() => mount(Host)).toThrow(/Failed to find the template definition/);
});
});
@@ -0,0 +1,165 @@
import type { ComponentObjectPropsOptions, DefineComponent, Slot } from 'vue';
import { camelize, defineComponent, shallowRef } from 'vue';
/** Map of slot name -> slot props object (or `undefined` for prop-less slots) */
type SlotPropsMap = Record<string, Record<string, any> | undefined>;
/** Turn a {@link SlotPropsMap} into a record of typed `Slot`s */
type GenerateSlotsFromSlotMap<T extends SlotPropsMap>
= { [K in keyof T]: Slot<T[K]> };
export type DefineTemplateComponent<Bindings extends Record<string, any>, Slots extends SlotPropsMap>
= DefineComponent & (new () => {
$slots: {
default: (_: Bindings & { $slots: GenerateSlotsFromSlotMap<Slots> }) => any;
};
});
export type ReuseTemplateComponent<Bindings extends Record<string, any>, Slots extends SlotPropsMap>
= DefineComponent<Bindings> & (new () => { $slots: GenerateSlotsFromSlotMap<Slots> });
/**
* The pair returned by {@link createReusableTemplate}. Usable both as a tuple
* (`const [Define, Reuse] = ...`) and as an object (`const { define, reuse } = ...`).
*/
export type ReusableTemplatePair<Bindings extends Record<string, any>, Slots extends SlotPropsMap>
= [DefineTemplateComponent<Bindings, Slots>, ReuseTemplateComponent<Bindings, Slots>] & {
define: DefineTemplateComponent<Bindings, Slots>;
reuse: ReuseTemplateComponent<Bindings, Slots>;
};
export interface CreateReusableTemplateOptions<Props extends Record<string, any>> {
/**
* Inherit attrs from the reuse component onto its single root vnode.
*
* @default true
*/
inheritAttrs?: boolean;
/**
* Name used for the define/reuse components (helpful in Vue devtools).
*
* @default 'ReusableTemplate'
*/
name?: string;
/**
* Props definition for the reuse component. When provided, bindings are taken
* from typed props instead of raw (camelized) attrs.
*/
props?: ComponentObjectPropsOptions<Props>;
}
/** Re-key an attrs object so every key is camelCased */
function keysToCamelCase(obj: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {};
for (const key in obj)
result[camelize(key)] = obj[key];
return result;
}
/**
* Wrap a `{ define, reuse }` object so it can also be destructured as the tuple
* `[define, reuse]`. Avoids a runtime dependency on `@vueuse/shared`.
*/
function makePair<
Bindings extends Record<string, any>,
Slots extends SlotPropsMap,
>(
define: DefineTemplateComponent<Bindings, Slots>,
reuse: ReuseTemplateComponent<Bindings, Slots>,
): ReusableTemplatePair<Bindings, Slots> {
const pair = [define, reuse] as unknown as ReusableTemplatePair<Bindings, Slots>;
pair.define = define;
pair.reuse = reuse;
return pair;
}
/**
* @name createReusableTemplate
* @category Component
* @description Define a template once and reuse it multiple times within the
* same component. Returns a `[DefineTemplate, ReuseTemplate]` pair (also
* destructurable as `{ define, reuse }`). The template captured by
* `DefineTemplate`'s default slot is rendered wherever `ReuseTemplate` appears,
* receiving its props/attrs as slot bindings. Supports a generic for typed
* bindings, typed slots, custom `props`, and `inheritAttrs`.
*
* Render-only and fully SSR-safe — it never touches `window`/`document`. The pair
* is created lazily and shares a single `shallowRef` for the captured render
* function, so there are no watchers and no per-render allocations beyond the
* vnode itself.
*
* @param {CreateReusableTemplateOptions<Bindings>} [options] - `name`, `inheritAttrs`, and `props`
* @returns {ReusableTemplatePair<Bindings, Slots>} A `[define, reuse]` tuple, also accessible as `{ define, reuse }`
*
* @example
* const [DefineTemplate, ReuseTemplate] = createReusableTemplate();
* // Template:
* // <DefineTemplate><span>Hello</span></DefineTemplate>
* // <ReuseTemplate /> <ReuseTemplate />
*
* @example
* // Typed bindings + custom props
* const [DefineItem, ReuseItem] = createReusableTemplate<{ label: string }>();
* // <DefineItem v-slot="{ label }">{{ label }}</DefineItem>
* // <ReuseItem label="A" /> <ReuseItem label="B" />
*
* @since 0.0.15
*/
export function createReusableTemplate<
Bindings extends Record<string, any>,
Slots extends SlotPropsMap = Record<'default', undefined>,
>(
options: CreateReusableTemplateOptions<Bindings> = {},
): ReusableTemplatePair<Bindings, Slots> {
const {
inheritAttrs = true,
name = 'ReusableTemplate',
props,
} = options;
// Shared captured render fn — no watchers, single allocation.
const render = shallowRef<Slot | undefined>();
const define = defineComponent({
name: `${name}.define`,
setup(_, { slots }) {
return () => {
render.value = slots.default;
};
},
}) as unknown as DefineTemplateComponent<Bindings, Slots>;
const reuse = defineComponent({
name: `${name}.reuse`,
inheritAttrs,
props,
setup(reuseProps, { attrs, slots }) {
return () => {
if (!render.value) {
// Local cast so the dev-only guard type-checks without @types/node and stays
// tree-shakeable in production builds (where NODE_ENV is statically replaced).
const nodeEnv = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV;
if (nodeEnv !== 'production')
throw new Error('[createReusableTemplate] Failed to find the template definition. Did you render the Define component before the Reuse component?');
return undefined;
}
const vnode = render.value({
...(props === undefined ? keysToCamelCase(attrs) : reuseProps),
$slots: slots,
});
// When inheriting attrs onto a single root, unwrap the fragment so Vue
// can merge the reuse component's attrs onto that root vnode.
return inheritAttrs && vnode?.length === 1 ? vnode[0] : vnode;
};
},
}) as unknown as ReuseTemplateComponent<Bindings, Slots>;
return makePair(define, reuse);
}
@@ -1,3 +1,6 @@
export * from './createReusableTemplate';
export * from './unrefElement';
export * from './useCurrentElement';
export * from './useForwardExpose';
export * from './useTemplateRefsList';
export * from './useVirtualList';
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import type { UseCurrentElementReturn } from '.';
import { defineComponent, nextTick, ref, shallowRef } from 'vue';
import { mount } from '@vue/test-utils';
import { useCurrentElement } from '.';
describe(useCurrentElement, () => {
it('resolves to the root DOM element of the current instance after mount', () => {
let el!: UseCurrentElementReturn;
const Component = defineComponent({
setup() {
el = useCurrentElement();
return {};
},
template: `<div class="root">content</div>`,
});
const wrapper = mount(Component);
expect(el.value).toBe(wrapper.find('.root').element);
expect(el.value).toBeInstanceOf(HTMLDivElement);
});
it('returns a controlled computed ref with trigger / peek / stop', () => {
let el!: UseCurrentElementReturn;
const Component = defineComponent({
setup() {
el = useCurrentElement();
return {};
},
template: `<div>x</div>`,
});
mount(Component);
expect(el.trigger).toBeTypeOf('function');
expect(el.peek).toBeTypeOf('function');
expect(el.stop).toBeTypeOf('function');
});
it('re-reads the element on update when the root node changes', async () => {
let el!: UseCurrentElementReturn;
const flag = ref(true);
const Component = defineComponent({
setup() {
el = useCurrentElement();
return { flag };
},
template: `
<div v-if="flag" class="a">a</div>
<section v-else class="b">b</section>
`,
});
const wrapper = mount(Component);
expect(el.value).toBeInstanceOf(HTMLDivElement);
flag.value = false;
await nextTick();
expect(el.value).toBe(wrapper.find('.b').element);
expect(el.value).toBeInstanceOf(HTMLElement);
expect((el.value as Element).tagName).toBe('SECTION');
});
it('tracks an explicit rootComponent ref instead of $el', async () => {
const innerRef = shallowRef<Element | null>(null);
let el!: UseCurrentElementReturn;
const Component = defineComponent({
setup() {
el = useCurrentElement(innerRef as any);
return {};
},
template: `<div class="outer"><span class="inner">i</span></div>`,
});
const wrapper = mount(Component);
// Before assigning the ref, it resolves to whatever the ref holds (null/undefined)
expect(el.value).toBeFalsy();
innerRef.value = wrapper.find('.inner').element;
el.trigger();
await nextTick();
expect(el.value).toBe(wrapper.find('.inner').element);
});
it('stops re-reading after scope disposal (unmount)', async () => {
let el!: UseCurrentElementReturn;
const Component = defineComponent({
setup() {
el = useCurrentElement();
return {};
},
template: `<div class="alive">alive</div>`,
});
const wrapper = mount(Component);
const mountedEl = el.value;
expect(mountedEl).toBeInstanceOf(HTMLDivElement);
wrapper.unmount();
// After unmount the controlled watcher is stopped; trigger is safe and the
// value no longer tracks a live element.
expect(() => el.trigger()).not.toThrow();
});
it('does not throw and resolves to undefined outside a component instance (SSR-safe)', () => {
let el!: UseCurrentElementReturn;
expect(() => {
el = useCurrentElement();
}).not.toThrow();
expect(el).toBeDefined();
expect(el.value).toBeUndefined();
});
});
@@ -0,0 +1,78 @@
import { getCurrentInstance, onMounted, onUpdated } from 'vue';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
import { computedWithControl } from '@/composables/reactivity/computedWithControl';
import type { ComputedRefWithControl } from '@/composables/reactivity/computedWithControl';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef, MaybeElement, VueInstance } from '@/composables/component/unrefElement';
/** Resolve `false` if `T` is `any`, otherwise `true` — used to detect a typed `$el`. */
type IsAny<T> = 0 extends 1 & T ? true : false;
/**
* Infer the resolved element type.
*
* When no explicit generic is supplied (`T` stays the broad `MaybeElement`) we
* fall back to the component instance's `$el` type — unless that is `any`
* (the un-typed default), in which case we keep `MaybeElement`.
*/
export type UseCurrentElementReturn<
T extends MaybeElement = MaybeElement,
R extends VueInstance = VueInstance,
E extends MaybeElement = MaybeElement extends T
? IsAny<R['$el']> extends false ? R['$el'] : T
: T,
> = ComputedRefWithControl<E>;
/**
* @name useCurrentElement
* @category Component
* @description Reactive root DOM element of the current component instance.
* Resolves to `vm.$el` (or the unwrapped `rootComponent` ref when provided) and
* is re-read on `onMounted` and `onUpdated` via a controlled computed — so it
* stays correct across re-renders without an always-on watcher. Generic over the
* element type; the type is inferred from the component's `$el` when available.
* SSR-safe: returns `undefined` until the component is mounted on the client.
*
* @param {MaybeComputedElementRef<R>} [rootComponent] Optional ref/getter for an explicit root component or element; defaults to the current instance's `$el`
* @returns {UseCurrentElementReturn<T, R>} A controlled computed ref of the resolved element, with `.trigger()` / `.peek()` / `.stop()`
*
* @example
* // Inferred element type from the component's root node
* const el = useCurrentElement();
* watchEffect(() => console.log(el.value));
*
* @example
* // Explicit element type
* const el = useCurrentElement<HTMLDivElement>();
*
* @example
* // Track an explicit child component / element ref instead of `$el`
* const child = useTemplateRef('child');
* const el = useCurrentElement(child);
*
* @since 0.0.15
*/
export function useCurrentElement<
T extends MaybeElement = MaybeElement,
R extends VueInstance = VueInstance,
E extends MaybeElement = MaybeElement extends T
? IsAny<R['$el']> extends false ? R['$el'] : T
: T,
>(
rootComponent?: MaybeComputedElementRef<R>,
): UseCurrentElementReturn<T, R, E> {
const vm = getCurrentInstance();
const currentElement = computedWithControl(
() => null,
() => (rootComponent ? unrefElement(rootComponent) : vm?.proxy?.$el) as E,
) as UseCurrentElementReturn<T, R, E>;
if (vm) {
onMounted(currentElement.trigger);
onUpdated(currentElement.trigger);
tryOnScopeDispose(currentElement.stop);
}
return currentElement;
}
@@ -0,0 +1,221 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useVirtualList } from '.';
class StubResizeObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
function makeContainer(overrides: Partial<{
clientWidth: number;
clientHeight: number;
scrollWidth: number;
scrollHeight: number;
}> = {}) {
const el = document.createElement('div');
Object.defineProperties(el, {
clientWidth: { value: overrides.clientWidth ?? 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.scrollLeft = 0;
el.scrollTo = vi.fn((opts: ScrollToOptions) => {
if (typeof opts.top === 'number') el.scrollTop = opts.top;
if (typeof opts.left === 'number') el.scrollLeft = opts.left;
}) as unknown as typeof el.scrollTo;
return el;
}
function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effectScope> } {
const scope = effectScope();
let result!: T;
scope.run(() => {
result = fn();
});
return { result, scope };
}
describe(useVirtualList, () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', StubResizeObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('renders an empty window before the container mounts (SSR-safe)', () => {
const data = Array.from({ length: 1000 }, (_, i) => i);
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20 }));
expect(result.list.value).toEqual([]);
expect(result.containerProps.ref.value).toBeNull();
expect(result.containerProps.style).toEqual({ overflowY: 'auto' });
scope.stop();
});
it('exposes the documented return shape', () => {
const { result, scope } = withScope(() => useVirtualList([1, 2, 3], { itemHeight: 20 }));
expect(result).toHaveProperty('list');
expect(result).toHaveProperty('scrollTo');
expect(result).toHaveProperty('containerProps');
expect(result).toHaveProperty('wrapperProps');
expect(typeof result.scrollTo).toBe('function');
expect(typeof result.containerProps.onScroll).toBe('function');
scope.stop();
});
it('slices the visible window plus overscan (vertical, fixed height)', async () => {
const data = Array.from({ length: 1000 }, (_, i) => i);
const el = makeContainer({ clientHeight: 100 });
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 2 }));
result.containerProps.ref.value = el;
await nextTick();
// offset(0) = 0, capacity = ceil(100/20) = 5, overscan 2 -> start 0, end 7.
expect(result.list.value[0]).toEqual({ data: 0, index: 0 });
expect(result.list.value).toHaveLength(7);
expect(result.list.value.at(-1)).toEqual({ data: 6, index: 6 });
scope.stop();
});
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 { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 2 }));
result.containerProps.ref.value = el;
await nextTick();
el.scrollTop = 400; // offset = floor(400/20) = 20
el.dispatchEvent(new Event('scroll'));
await nextTick();
// start = 20 - 2 = 18, end = 20 + 5 + 2 = 27
expect(result.list.value[0]).toEqual({ data: 18, index: 18 });
expect(result.list.value.at(-1)).toEqual({ data: 26, index: 26 });
scope.stop();
});
it('computes total height and offset spacers via wrapperProps', async () => {
const data = Array.from({ length: 50 }, (_, i) => i);
const el = makeContainer({ clientHeight: 100 });
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
result.containerProps.ref.value = el;
await nextTick();
// total height = 50 * 20 = 1000; at top, marginTop = 0
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(result.list.value[0]).toEqual({ data: 30, index: 30 });
scope.stop();
});
it('scrollTo is a no-op when the container is not mounted', () => {
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 { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
result.containerProps.ref.value = el;
await nextTick();
// total = 10 * 20 = 200
expect(result.wrapperProps.value.style.height).toBe('200px');
data.value = Array.from({ length: 100 }, (_, i) => i);
await nextTick();
// total = 100 * 20 = 2000
expect(result.wrapperProps.value.style.height).toBe('2000px');
scope.stop();
});
it('clamps the window to the source bounds', async () => {
const data = Array.from({ length: 3 }, (_, i) => i);
const el = makeContainer({ clientHeight: 1000 });
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 5 }));
result.containerProps.ref.value = el;
await nextTick();
expect(result.list.value).toHaveLength(3);
expect(result.list.value.at(-1)).toEqual({ data: 2, index: 2 });
scope.stop();
});
});
@@ -0,0 +1,325 @@
import type { ComputedRef, MaybeRefOrGetter, Ref, ShallowRef, StyleValue } from 'vue';
import { computed, shallowRef, toValue, watch } from 'vue';
import { clamp, isNumber } from '@robonen/stdlib';
import { useElementSize } from '@/composables/elements/useElementSize';
import { useEventListener } from '@/composables/browser/useEventListener';
/**
* Fixed pixel size or a per-index getter.
*/
export type UseVirtualListItemSize = number | ((index: number) => number);
export interface UseVirtualListOptionsBase {
/**
* Number of extra items rendered above and below the visible window to
* reduce blank flashes while scrolling.
*
* @default 5
*/
overscan?: number;
}
export interface UseHorizontalVirtualListOptions extends UseVirtualListOptionsBase {
/**
* Horizontal item size in pixels, or a getter `(index) => number`.
*/
itemWidth: UseVirtualListItemSize;
}
export interface UseVerticalVirtualListOptions extends UseVirtualListOptionsBase {
/**
* Vertical item size in pixels, or a getter `(index) => number`.
*/
itemHeight: UseVirtualListItemSize;
}
export type UseVirtualListOptions
= | UseHorizontalVirtualListOptions
| UseVerticalVirtualListOptions;
export interface UseVirtualListItem<T> {
data: T;
index: number;
}
export interface UseVirtualListScrollToOptions {
behavior?: ScrollBehavior;
block?: ScrollLogicalPosition;
inline?: ScrollLogicalPosition;
}
export interface UseVirtualListContainerProps {
ref: ShallowRef<HTMLElement | null>;
onScroll: () => void;
style: StyleValue;
}
export interface UseVirtualListWrapperStyle {
width: string;
height: string;
marginTop?: string;
marginLeft?: string;
display?: string;
}
export interface UseVirtualListReturn<T> {
/**
* The currently visible slice (with original indices) to render.
*/
list: Ref<Array<UseVirtualListItem<T>>>;
/**
* Scroll the container so the item at `index` becomes visible.
*/
scrollTo: (index: number, options?: UseVirtualListScrollToOptions) => void;
/**
* Props to bind on the scrolling container element.
*/
containerProps: UseVirtualListContainerProps;
/**
* Reactive props to bind on the inner wrapper element (spacer offsets).
*/
wrapperProps: ComputedRef<{ style: UseVirtualListWrapperStyle }>;
}
interface UseVirtualListState {
start: number;
end: number;
}
type Axis = 'horizontal' | 'vertical';
const scrollKey = {
horizontal: 'scrollLeft',
vertical: 'scrollTop',
} as const;
const scrollToKey = {
horizontal: 'left',
vertical: 'top',
} as const;
const defaultScrollToOptions: UseVirtualListScrollToOptions = {
behavior: 'auto',
block: 'start',
inline: 'nearest',
};
interface UseVirtualListMetrics {
/** Cumulative offset before the item at `index` (i.e. distance from the start). */
distance: (index: number) => number;
/** Total size of every item. */
total: () => number;
/** How many items fit, starting at `start`, inside `containerSize`. */
viewCapacity: (containerSize: number, start: number) => number;
/** Index of the first item whose cumulative span reaches `scrollPos`. */
offset: (scrollPos: number) => number;
/** Size of a single item. */
size: (index: number) => number;
}
/**
* Build size metrics for the current source.
*
* For a fixed numeric `itemSize` everything is O(1) arithmetic. For a getter
* we precompute a prefix-sum table once per (source, itemSize) change so that
* `distance`, `total`, `offset`, and `viewCapacity` are O(1)/O(log n) lookups
* instead of re-reducing the whole array on every scroll frame.
*/
function createMetrics(length: number, itemSize: UseVirtualListItemSize): UseVirtualListMetrics {
if (isNumber(itemSize)) {
const fixed = itemSize;
return {
size: () => fixed,
distance: index => clamp(index, 0, length) * fixed,
total: () => length * fixed,
viewCapacity: containerSize => Math.ceil(containerSize / fixed),
offset: scrollPos => Math.floor(scrollPos / fixed),
};
}
// prefix[i] = sum of sizes of items [0, i); prefix[length] = total size.
const prefix = new Float64Array(length + 1);
for (let i = 0; i < length; i++)
prefix[i + 1] = prefix[i]! + itemSize(i);
const total = prefix[length]!;
// Largest index whose cumulative offset is <= target, via binary search.
const lowerBound = (target: number): number => {
let lo = 0;
let hi = length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (prefix[mid]! <= target)
lo = mid + 1;
else
hi = mid;
}
return lo - 1;
};
return {
size: index => itemSize(index),
distance: index => prefix[clamp(index, 0, length)]!,
total: () => total,
viewCapacity: (containerSize, start) => {
const target = prefix[clamp(start, 0, length)]! + containerSize;
const end = lowerBound(target);
return Math.max(0, end - start + 1);
},
offset: scrollPos => Math.max(0, lowerBound(scrollPos)),
};
}
/**
* @name useVirtualList
* @category Component
* @description Virtualize a large list so only the items inside (and slightly
* around) the viewport are rendered. Supports vertical (`itemHeight`) and
* horizontal (`itemWidth`) layouts, fixed or per-index sizes, and an `overscan`
* buffer. Backed by `useElementSize` (reactive container size) and
* `useEventListener` (passive, auto-cleaned scroll handling). SSR-safe: renders
* an empty window until the container mounts.
*
* @param {MaybeRefOrGetter<readonly T[]>} list The full source array (may be reactive)
* @param {UseVirtualListOptions} options Layout options — supply `itemHeight` (vertical) or `itemWidth` (horizontal), plus optional `overscan`
* @returns {UseVirtualListReturn<T>} `{ list, containerProps, wrapperProps, scrollTo }`
*
* @example
* const all = ref(Array.from({ length: 99999 }, (_, i) => i));
* const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(all, { itemHeight: 22 });
* // <div v-bind="containerProps" style="height: 300px">
* // <div v-bind="wrapperProps">
* // <div v-for="{ data, index } in list" :key="index" style="height: 22px">{{ data }}</div>
* // </div>
* // </div>
*
* @example
* // Variable heights and a wider overscan buffer.
* const { list } = useVirtualList(items, { itemHeight: i => (i % 2 ? 40 : 80), overscan: 10 });
*
* @since 0.0.15
*/
export function useVirtualList<T = any>(
list: MaybeRefOrGetter<readonly T[]>,
options: UseVirtualListOptions,
): UseVirtualListReturn<T> {
const isVertical = 'itemHeight' in options;
const axis: Axis = isVertical ? 'vertical' : 'horizontal';
const itemSize = isVertical
? (options as UseVerticalVirtualListOptions).itemHeight
: (options as UseHorizontalVirtualListOptions).itemWidth;
const overscan = options.overscan ?? 5;
const containerRef = shallowRef<HTMLElement | null>(null);
const size = useElementSize(containerRef);
const source = computed(() => toValue(list));
const currentList = shallowRef<Array<UseVirtualListItem<T>>>([]);
const state = shallowRef<UseVirtualListState>({ start: 0, end: overscan });
// Recompute metrics only when the source length or item-size strategy changes.
const metrics = computed(() => createMetrics(source.value.length, itemSize));
const calculateRange = (): void => {
const element = containerRef.value;
if (!element)
return;
const m = metrics.value;
const len = source.value.length;
const scrollPos = axis === 'vertical' ? element.scrollTop : element.scrollLeft;
const containerSize = axis === 'vertical' ? element.clientHeight : element.clientWidth;
const offset = m.offset(scrollPos);
const viewCapacity = m.viewCapacity(containerSize, offset);
const start = clamp(offset - overscan, 0, len);
const end = clamp(offset + viewCapacity + overscan, 0, len);
state.value = { start, end };
const view: Array<UseVirtualListItem<T>> = [];
for (let i = start; i < end; i++)
view.push({ data: source.value[i]!, index: i });
currentList.value = view;
};
// Re-slice when the viewport size, the source, or the mounted element changes.
watch(
[size.width, size.height, source, containerRef],
calculateRange,
{ flush: 'post' },
);
// Passive scroll listener with automatic cleanup and reactive re-binding.
useEventListener(containerRef, 'scroll', calculateRange, { passive: true });
const offsetStart = computed(() => metrics.value.distance(state.value.start));
const totalSize = computed(() => metrics.value.total());
const wrapperProps = computed((): { style: UseVirtualListWrapperStyle } => {
if (axis === 'vertical') {
return {
style: {
width: '100%',
height: `${totalSize.value - offsetStart.value}px`,
marginTop: `${offsetStart.value}px`,
},
};
}
return {
style: {
height: '100%',
width: `${totalSize.value - offsetStart.value}px`,
marginLeft: `${offsetStart.value}px`,
display: 'flex',
},
};
});
const containerStyle: StyleValue = isVertical
? { overflowY: 'auto' }
: { overflowX: 'auto' };
const scrollTo = (index: number, scrollOptions?: UseVirtualListScrollToOptions): void => {
const element = containerRef.value;
if (!element)
return;
const resolved = { ...defaultScrollToOptions, ...scrollOptions };
const m = metrics.value;
let offset = 0;
const align = axis === 'horizontal' ? resolved.inline : resolved.block;
if (align) {
const containerSize = axis === 'vertical' ? element.clientHeight : element.clientWidth;
const fullItemSize = m.size(index);
if (align === 'center')
offset = containerSize / 2 - fullItemSize / 2;
else if (align === 'end')
offset = containerSize - fullItemSize;
else if (align === 'nearest' && m.distance(index) > element[scrollKey[axis]] + containerSize / 2)
offset = containerSize - fullItemSize;
}
element.scrollTo({
[scrollToKey[axis]]: m.distance(index) - offset,
behavior: resolved.behavior,
});
calculateRange();
};
const containerProps: UseVirtualListContainerProps = {
ref: containerRef,
onScroll: calculateRange,
style: containerStyle,
};
return {
list: currentList,
scrollTo,
containerProps,
wrapperProps,
};
}