feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user