mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 02:44:45 +00:00
feat(monorepo): migrate vue packages and apply oxlint refactors
This commit is contained in:
15
vue/primitives/README.md
Normal file
15
vue/primitives/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# @robonen/primitives
|
||||
|
||||
Collection of reusable UI primitives.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @robonen/primitives
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import {} from '@robonen/primitives';
|
||||
```
|
||||
7
vue/primitives/jsr.json
Normal file
7
vue/primitives/jsr.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/primitives",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.0.1",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
4
vue/primitives/oxlint.config.ts
Normal file
4
vue/primitives/oxlint.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||
54
vue/primitives/package.json
Normal file
54
vue/primitives/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@robonen/primitives",
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Collection of UI primitives",
|
||||
"keywords": [
|
||||
"ui",
|
||||
"primitives",
|
||||
"components",
|
||||
"tools"
|
||||
],
|
||||
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "vue/primitives"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint:check": "oxlint -c oxlint.config.ts",
|
||||
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||
"test": "vitest run",
|
||||
"bench": "vitest bench",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"@stylistic/eslint-plugin": "catalog:",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/shared": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
1
vue/primitives/src/env.d.ts
vendored
Normal file
1
vue/primitives/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare const __DEV__: boolean;
|
||||
1
vue/primitives/src/index.ts
Normal file
1
vue/primitives/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './primitive';
|
||||
22
vue/primitives/src/primitive/Primitive.ts
Normal file
22
vue/primitives/src/primitive/Primitive.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Component, IntrinsicElementAttributes, SetupContext } from 'vue';
|
||||
import { h } from 'vue';
|
||||
import { Slot } from './Slot';
|
||||
|
||||
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
|
||||
|
||||
export interface PrimitiveProps {
|
||||
as?: keyof IntrinsicElementAttributes | Component;
|
||||
}
|
||||
|
||||
export function Primitive(props: PrimitiveProps, ctx: FunctionalComponentContext) {
|
||||
return props.as === 'template'
|
||||
? h(Slot, ctx.attrs, ctx.slots)
|
||||
: h(props.as!, ctx.attrs, ctx.slots);
|
||||
}
|
||||
|
||||
Primitive.props = {
|
||||
as: {
|
||||
type: [String, Object],
|
||||
default: 'div' as const,
|
||||
},
|
||||
};
|
||||
29
vue/primitives/src/primitive/Slot.ts
Normal file
29
vue/primitives/src/primitive/Slot.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { SetupContext } from 'vue';
|
||||
import { cloneVNode, warn } from 'vue';
|
||||
import { getRawChildren } from '../utils/getRawChildren';
|
||||
|
||||
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
|
||||
|
||||
/**
|
||||
* A component that renders a single child from its default slot,
|
||||
* applying the provided attributes to it.
|
||||
*
|
||||
* @param _ - Props (unused)
|
||||
* @param context - Setup context containing slots and attrs
|
||||
* @returns Cloned VNode with merged attrs or null
|
||||
*/
|
||||
export function Slot(_: Record<string, unknown>, { slots, attrs }: FunctionalComponentContext) {
|
||||
if (!slots.default) return null;
|
||||
|
||||
const children = getRawChildren(slots.default());
|
||||
|
||||
if (!children.length) return null;
|
||||
|
||||
if (__DEV__ && children.length > 1) {
|
||||
warn('<Slot> can only be used on a single element or component.');
|
||||
}
|
||||
|
||||
return cloneVNode(children[0]!, attrs, true);
|
||||
}
|
||||
|
||||
Slot.inheritAttrs = false;
|
||||
116
vue/primitives/src/primitive/__test__/Primitive.bench.ts
Normal file
116
vue/primitives/src/primitive/__test__/Primitive.bench.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { cloneVNode, Comment, createVNode, h } from 'vue';
|
||||
import { Primitive, Slot } from '..';
|
||||
|
||||
// -- Attribute sets of increasing size --
|
||||
|
||||
const attrs1 = { class: 'a' };
|
||||
|
||||
const attrs5 = { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' };
|
||||
|
||||
const attrs15 = {
|
||||
'class': 'a',
|
||||
'id': 'b',
|
||||
'style': { color: 'red' },
|
||||
'onClick': () => {},
|
||||
'role': 'button',
|
||||
'tabindex': '0',
|
||||
'title': 'tip',
|
||||
'data-a': '1',
|
||||
'data-b': '2',
|
||||
'data-c': '3',
|
||||
'data-d': '4',
|
||||
'data-e': '5',
|
||||
'data-f': '6',
|
||||
'data-g': '7',
|
||||
'data-h': '8',
|
||||
};
|
||||
|
||||
const defaultSlot = { default: () => [h('span', 'content')] };
|
||||
const noop = () => {};
|
||||
|
||||
// ---- Baselines (raw Vue calls) ----
|
||||
|
||||
describe('baseline: raw h()', () => {
|
||||
bench('h() — 1 attr', () => {
|
||||
h('div', attrs1, defaultSlot);
|
||||
});
|
||||
|
||||
bench('h() — 5 attrs', () => {
|
||||
h('div', attrs5, defaultSlot);
|
||||
});
|
||||
|
||||
bench('h() — 15 attrs', () => {
|
||||
h('div', attrs15, defaultSlot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline: raw cloneVNode()', () => {
|
||||
const child = h('div', 'content');
|
||||
|
||||
bench('cloneVNode — 1 attr', () => {
|
||||
cloneVNode(child, attrs1, true);
|
||||
});
|
||||
|
||||
bench('cloneVNode — 5 attrs', () => {
|
||||
cloneVNode(child, attrs5, true);
|
||||
});
|
||||
|
||||
bench('cloneVNode — 15 attrs', () => {
|
||||
cloneVNode(child, attrs15, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Primitive overhead vs raw h() ----
|
||||
|
||||
describe('Primitive vs h()', () => {
|
||||
bench('h("div") — baseline', () => {
|
||||
h('div', attrs5, defaultSlot);
|
||||
});
|
||||
|
||||
bench('Primitive({ as: "div" })', () => {
|
||||
Primitive({ as: 'div' }, { attrs: attrs5, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
|
||||
bench('Primitive({ as: "template" }) — Slot mode', () => {
|
||||
Primitive({ as: 'template' }, { attrs: attrs5, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Slot scaling by attribute count ----
|
||||
|
||||
describe('Slot — scaling by attrs', () => {
|
||||
bench('1 attr', () => {
|
||||
Slot({} as never, { attrs: attrs1, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
|
||||
bench('5 attrs', () => {
|
||||
Slot({} as never, { attrs: attrs5, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
|
||||
bench('15 attrs (mixed types)', () => {
|
||||
Slot({} as never, { attrs: attrs15, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Slot edge cases ----
|
||||
|
||||
describe('Slot — edge cases', () => {
|
||||
bench('child with comments to skip', () => {
|
||||
Slot({} as never, {
|
||||
attrs: attrs5,
|
||||
slots: {
|
||||
default: () => [
|
||||
createVNode(Comment, null, 'skip'),
|
||||
createVNode(Comment, null, 'skip'),
|
||||
h('span', 'content'),
|
||||
],
|
||||
},
|
||||
emit: noop,
|
||||
});
|
||||
});
|
||||
|
||||
bench('no default slot', () => {
|
||||
Slot({} as never, { attrs: attrs5, slots: {}, emit: noop });
|
||||
});
|
||||
});
|
||||
482
vue/primitives/src/primitive/__test__/Primitive.test.ts
Normal file
482
vue/primitives/src/primitive/__test__/Primitive.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import type { PrimitiveProps } from '..';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createVNode, Comment, h, defineComponent, markRaw, nextTick, ref, shallowRef } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { Primitive, Slot } from '..';
|
||||
|
||||
// --- Slot ---
|
||||
|
||||
describe(Slot, () => {
|
||||
it('returns null when no default slot is provided', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(Slot);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders the first valid child from the slot', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(Slot, null, { default: () => [h('span', 'hello')] });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('<span>hello</span>');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('applies attrs to the slotted child', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Slot, { class: 'custom', id: 'test' }, { default: () => [h('div')] });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.find('div').classes()).toContain('custom');
|
||||
expect(wrapper.find('div').attributes('id')).toBe('test');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('skips Comment nodes and picks the first element', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Slot, null, {
|
||||
default: () => [createVNode(Comment, null, 'skip'), h('em', 'content')],
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('<em>content</em>');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('warns in DEV mode when multiple valid children are provided', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Slot, null, {
|
||||
default: () => [h('div', 'a'), h('span', 'b')],
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls.some(args =>
|
||||
args.some(arg => typeof arg === 'string' && arg.includes('<Slot>')),
|
||||
)).toBe(true);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders null when slot has only comments', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Slot, null, {
|
||||
default: () => [
|
||||
createVNode(Comment, null, 'a'),
|
||||
createVNode(Comment, null, 'b'),
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Primitive ---
|
||||
|
||||
describe(Primitive, () => {
|
||||
it('renders a div by default', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
slots: { default: () => 'content' },
|
||||
});
|
||||
|
||||
expect(wrapper.element.tagName).toBe('DIV');
|
||||
expect(wrapper.text()).toBe('content');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders the element specified by "as" prop', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'button' },
|
||||
slots: { default: () => 'click me' },
|
||||
});
|
||||
|
||||
expect(wrapper.element.tagName).toBe('BUTTON');
|
||||
expect(wrapper.text()).toBe('click me');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders a span element', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'span' },
|
||||
slots: { default: () => 'text' },
|
||||
});
|
||||
|
||||
expect(wrapper.element.tagName).toBe('SPAN');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('passes attributes to the rendered element', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'input' },
|
||||
attrs: { type: 'text', placeholder: 'enter' },
|
||||
});
|
||||
|
||||
expect(wrapper.attributes('type')).toBe('text');
|
||||
expect(wrapper.attributes('placeholder')).toBe('enter');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('passes class and style attributes', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'div' },
|
||||
attrs: { class: 'my-class', style: 'color: red' },
|
||||
slots: { default: () => 'styled' },
|
||||
});
|
||||
|
||||
expect(wrapper.classes()).toContain('my-class');
|
||||
expect(wrapper.attributes('style')).toBe('color: red;');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('forwards event listeners', async () => {
|
||||
const onClick = vi.fn();
|
||||
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'button' },
|
||||
attrs: { onClick },
|
||||
slots: { default: () => 'click' },
|
||||
});
|
||||
|
||||
await wrapper.trigger('click');
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders a custom Vue component via "as"', () => {
|
||||
const Custom = markRaw(defineComponent({
|
||||
props: { label: String },
|
||||
setup(props) {
|
||||
return () => h('span', { class: 'custom' }, props.label);
|
||||
},
|
||||
}));
|
||||
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: Custom },
|
||||
attrs: { label: 'hello' },
|
||||
});
|
||||
|
||||
expect(wrapper.find('.custom').exists()).toBe(true);
|
||||
expect(wrapper.text()).toBe('hello');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders in Slot mode when as="template"', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
slots: { default: () => h('section', 'slot content') },
|
||||
});
|
||||
|
||||
expect(wrapper.element.tagName).toBe('SECTION');
|
||||
expect(wrapper.text()).toBe('slot content');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('merges attrs onto the slotted child in template mode', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
attrs: { 'class': 'merged', 'data-testid': 'slot' },
|
||||
slots: { default: () => h('div', 'child') },
|
||||
});
|
||||
|
||||
expect(wrapper.classes()).toContain('merged');
|
||||
expect(wrapper.attributes('data-testid')).toBe('slot');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('forwards event listeners in template mode', async () => {
|
||||
const onClick = vi.fn();
|
||||
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
attrs: { onClick },
|
||||
slots: { default: () => h('button', 'click me') },
|
||||
});
|
||||
|
||||
await wrapper.trigger('click');
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders empty when template mode has no slot', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toBe('');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('can switch element via reactive "as" prop', async () => {
|
||||
const Wrapper = defineComponent({
|
||||
props: { tag: { type: String, default: 'div' } },
|
||||
setup(props) {
|
||||
return () => h(Primitive, { as: props.tag as PrimitiveProps['as'] }, { default: () => 'test' });
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(Wrapper, { props: { tag: 'div' } });
|
||||
|
||||
expect(wrapper.element.tagName).toBe('DIV');
|
||||
|
||||
await wrapper.setProps({ tag: 'span' });
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.element.tagName).toBe('SPAN');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes root element via template ref', async () => {
|
||||
const primitiveRef = shallowRef<Element | null>(null);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Primitive, { ref: primitiveRef, as: 'button' }, { default: () => 'click' });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLButtonElement);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes slotted element via template ref in template mode', async () => {
|
||||
const primitiveRef = shallowRef<Element | null>(null);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
Primitive,
|
||||
{ ref: primitiveRef, as: 'template' },
|
||||
{ default: () => h('section', 'content') },
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLElement);
|
||||
expect((primitiveRef.value as HTMLElement).tagName).toBe('SECTION');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('updates template ref when element changes', async () => {
|
||||
const primitiveRef = shallowRef<Element | null>(null);
|
||||
const tag = ref<PrimitiveProps['as']>('div');
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
// @ts-expect-error — h() struggles with ref + broad PrimitiveProps['as'] union type
|
||||
h(Primitive, { ref: primitiveRef, as: tag.value }, { default: () => 'test' });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLDivElement);
|
||||
|
||||
tag.value = 'span';
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLSpanElement);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Nested as="template" ---
|
||||
|
||||
describe.each([1, 2, 3])('Primitive nested as="template" (depth=%i)', (depth) => {
|
||||
function wrapInTemplate(attrs: Array<Record<string, unknown>>, slot: () => ReturnType<typeof h>) {
|
||||
let current = slot;
|
||||
|
||||
for (let i = attrs.length - 1; i >= 0; i--) {
|
||||
const inner = current;
|
||||
current = () => h(Primitive, { as: 'template', ...attrs[i] }, { default: inner });
|
||||
}
|
||||
|
||||
return current();
|
||||
}
|
||||
|
||||
function makeAttrsPerLevel(base: string, depth: number) {
|
||||
return Array.from({ length: depth }, (_, i) => ({ [`data-level-${i}`]: `${base}-${i}` }));
|
||||
}
|
||||
|
||||
it('renders the inner child element', () => {
|
||||
const attrs = makeAttrsPerLevel('v', depth);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('section', 'leaf'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.element.tagName).toBe('SECTION');
|
||||
expect(wrapper.text()).toBe('leaf');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('merges data attrs from all levels onto the leaf', () => {
|
||||
const attrs = makeAttrsPerLevel('v', depth);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('div', 'child'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
expect(wrapper.attributes(`data-level-${i}`)).toBe(`v-${i}`);
|
||||
}
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('merges classes from all levels', () => {
|
||||
const attrs = Array.from({ length: depth }, (_, i) => ({ class: `level-${i}` }));
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('div', { class: 'leaf' }, 'child'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
expect(wrapper.classes()).toContain(`level-${i}`);
|
||||
}
|
||||
expect(wrapper.classes()).toContain('leaf');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('forwards event listeners from all levels', async () => {
|
||||
const handlers = Array.from({ length: depth }, () => vi.fn());
|
||||
const attrs = handlers.map(fn => ({ onClick: fn }));
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('button', 'click'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await wrapper.trigger('click');
|
||||
|
||||
for (const handler of handlers) {
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes inner element via template ref', async () => {
|
||||
const primitiveRef = shallowRef<Element | null>(null);
|
||||
const attrs = makeAttrsPerLevel('v', depth);
|
||||
attrs[0] = { ...attrs[0], ref: primitiveRef };
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('section', 'content'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLElement);
|
||||
expect((primitiveRef.value as HTMLElement).tagName).toBe('SECTION');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders empty when innermost slot is missing', () => {
|
||||
const attrs = makeAttrsPerLevel('v', depth);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h(Slot));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
2
vue/primitives/src/primitive/index.ts
Normal file
2
vue/primitives/src/primitive/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Primitive, type PrimitiveProps } from './Primitive';
|
||||
export { Slot } from './Slot';
|
||||
142
vue/primitives/src/utils/__test__/getRawChildren.bench.ts
Normal file
142
vue/primitives/src/utils/__test__/getRawChildren.bench.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { createVNode, Comment, Fragment, h, render } from 'vue';
|
||||
import { PatchFlags } from '@vue/shared';
|
||||
import { getRawChildren } from '../getRawChildren';
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
function keyedFragment(children: Array<ReturnType<typeof h>>) {
|
||||
return createVNode(Fragment, null, children, PatchFlags.KEYED_FRAGMENT);
|
||||
}
|
||||
|
||||
const flatChildren = [h('div'), h('span'), h('p')];
|
||||
|
||||
const keyedChildren = Array.from({ length: 10 }, (_, i) =>
|
||||
h('div', { key: i }, `child-${i}`),
|
||||
);
|
||||
|
||||
// ---- Processing cost ----
|
||||
|
||||
describe('getRawChildren', () => {
|
||||
bench('flat elements', () => {
|
||||
getRawChildren(flatChildren);
|
||||
});
|
||||
|
||||
bench('mixed elements and comments', () => {
|
||||
getRawChildren([
|
||||
createVNode(Comment, null, 'c'),
|
||||
h('div'),
|
||||
createVNode(Comment, null, 'c'),
|
||||
h('span'),
|
||||
createVNode(Comment, null, 'c'),
|
||||
]);
|
||||
});
|
||||
|
||||
bench('single fragment with children', () => {
|
||||
getRawChildren([createVNode(Fragment, null, [h('a'), h('b'), h('c')])]);
|
||||
});
|
||||
|
||||
bench('nested fragments (depth 5)', () => {
|
||||
let current: ReturnType<typeof h> = h('div');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
current = createVNode(Fragment, null, [current, h('span')]);
|
||||
}
|
||||
getRawChildren([current]);
|
||||
});
|
||||
|
||||
bench('wide fragment (50 children)', () => {
|
||||
const children = Array.from({ length: 50 }, (_, i) => h('div', `child-${i}`));
|
||||
getRawChildren([createVNode(Fragment, null, children)]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- BAIL path cost ----
|
||||
|
||||
describe('getRawChildren — BAIL path', () => {
|
||||
bench('1 keyed fragment (no BAIL)', () => {
|
||||
getRawChildren([keyedFragment([...keyedChildren])]);
|
||||
});
|
||||
|
||||
bench('2 keyed fragments (BAIL triggered)', () => {
|
||||
getRawChildren([
|
||||
keyedFragment(keyedChildren.slice(0, 5)),
|
||||
keyedFragment(keyedChildren.slice(5)),
|
||||
]);
|
||||
});
|
||||
|
||||
bench('3 keyed fragments (BAIL triggered)', () => {
|
||||
getRawChildren([
|
||||
keyedFragment(keyedChildren.slice(0, 3)),
|
||||
keyedFragment(keyedChildren.slice(3, 7)),
|
||||
keyedFragment(keyedChildren.slice(7)),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Render impact: optimized patchFlags vs BAIL ----
|
||||
|
||||
describe('patch — optimized vs BAIL patchFlag', () => {
|
||||
bench('patch with TEXT patchFlag', () => {
|
||||
const container = document.createElement('div');
|
||||
const initial = h('div', null, [
|
||||
createVNode('span', null, 'a', PatchFlags.TEXT),
|
||||
createVNode('span', null, 'b', PatchFlags.TEXT),
|
||||
createVNode('span', null, 'c', PatchFlags.TEXT),
|
||||
]);
|
||||
const updated = h('div', null, [
|
||||
createVNode('span', null, 'x', PatchFlags.TEXT),
|
||||
createVNode('span', null, 'y', PatchFlags.TEXT),
|
||||
createVNode('span', null, 'z', PatchFlags.TEXT),
|
||||
]);
|
||||
render(initial, container);
|
||||
render(updated, container);
|
||||
});
|
||||
|
||||
bench('patch with BAIL patchFlag', () => {
|
||||
const container = document.createElement('div');
|
||||
const initial = h('div', null, [
|
||||
createVNode('span', null, 'a', PatchFlags.BAIL),
|
||||
createVNode('span', null, 'b', PatchFlags.BAIL),
|
||||
createVNode('span', null, 'c', PatchFlags.BAIL),
|
||||
]);
|
||||
const updated = h('div', null, [
|
||||
createVNode('span', null, 'x', PatchFlags.BAIL),
|
||||
createVNode('span', null, 'y', PatchFlags.BAIL),
|
||||
createVNode('span', null, 'z', PatchFlags.BAIL),
|
||||
]);
|
||||
render(initial, container);
|
||||
render(updated, container);
|
||||
});
|
||||
|
||||
bench('patch with CLASS patchFlag', () => {
|
||||
const container = document.createElement('div');
|
||||
const initial = h('div', null, [
|
||||
createVNode('span', { class: 'a' }, null, PatchFlags.CLASS),
|
||||
createVNode('span', { class: 'b' }, null, PatchFlags.CLASS),
|
||||
createVNode('span', { class: 'c' }, null, PatchFlags.CLASS),
|
||||
]);
|
||||
const updated = h('div', null, [
|
||||
createVNode('span', { class: 'x' }, null, PatchFlags.CLASS),
|
||||
createVNode('span', { class: 'y' }, null, PatchFlags.CLASS),
|
||||
createVNode('span', { class: 'z' }, null, PatchFlags.CLASS),
|
||||
]);
|
||||
render(initial, container);
|
||||
render(updated, container);
|
||||
});
|
||||
|
||||
bench('patch with CLASS→BAIL patchFlag', () => {
|
||||
const container = document.createElement('div');
|
||||
const initial = h('div', null, [
|
||||
createVNode('span', { class: 'a' }, null, PatchFlags.BAIL),
|
||||
createVNode('span', { class: 'b' }, null, PatchFlags.BAIL),
|
||||
createVNode('span', { class: 'c' }, null, PatchFlags.BAIL),
|
||||
]);
|
||||
const updated = h('div', null, [
|
||||
createVNode('span', { class: 'x' }, null, PatchFlags.BAIL),
|
||||
createVNode('span', { class: 'y' }, null, PatchFlags.BAIL),
|
||||
createVNode('span', { class: 'z' }, null, PatchFlags.BAIL),
|
||||
]);
|
||||
render(initial, container);
|
||||
render(updated, container);
|
||||
});
|
||||
});
|
||||
63
vue/primitives/src/utils/__test__/getRawChildren.test.ts
Normal file
63
vue/primitives/src/utils/__test__/getRawChildren.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createVNode, Comment, Fragment, h } from 'vue';
|
||||
import { getRawChildren } from '../getRawChildren';
|
||||
|
||||
describe(getRawChildren, () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(getRawChildren([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns element vnodes as-is', () => {
|
||||
const div = h('div');
|
||||
const span = h('span');
|
||||
|
||||
const result = getRawChildren([div, span]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.type).toBe('div');
|
||||
expect(result[1]!.type).toBe('span');
|
||||
});
|
||||
|
||||
it('filters out Comment vnodes', () => {
|
||||
const div = h('div');
|
||||
const comment = createVNode(Comment, null, 'comment');
|
||||
|
||||
const result = getRawChildren([comment, div, comment]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.type).toBe('div');
|
||||
});
|
||||
|
||||
it('flattens Fragment children', () => {
|
||||
const fragment = createVNode(Fragment, null, [h('a'), h('b')]);
|
||||
|
||||
const result = getRawChildren([fragment]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.type).toBe('a');
|
||||
expect(result[1]!.type).toBe('b');
|
||||
});
|
||||
|
||||
it('recursively flattens nested Fragment children', () => {
|
||||
const innerFragment = createVNode(Fragment, null, [h('span')]);
|
||||
const outerFragment = createVNode(Fragment, null, [innerFragment, h('div')]);
|
||||
|
||||
const result = getRawChildren([outerFragment]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.type).toBe('span');
|
||||
expect(result[1]!.type).toBe('div');
|
||||
});
|
||||
|
||||
it('filters comments inside fragments', () => {
|
||||
const fragment = createVNode(Fragment, null, [
|
||||
createVNode(Comment, null, 'skip'),
|
||||
h('p'),
|
||||
]);
|
||||
|
||||
const result = getRawChildren([fragment]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.type).toBe('p');
|
||||
});
|
||||
});
|
||||
40
vue/primitives/src/utils/getRawChildren.ts
Normal file
40
vue/primitives/src/utils/getRawChildren.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { VNode } from 'vue';
|
||||
import { Comment, Fragment } from 'vue';
|
||||
import { PatchFlags } from '@vue/shared';
|
||||
|
||||
/**
|
||||
* Recursively extracts and flattens VNodes from potentially nested Fragments
|
||||
* while filtering out Comment nodes.
|
||||
*
|
||||
* @param children - Array of VNodes to process
|
||||
* @returns Flattened array of non-Comment VNodes
|
||||
*/
|
||||
export function getRawChildren(children: VNode[]): VNode[] {
|
||||
const result: VNode[] = [];
|
||||
flatten(children, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function flatten(children: VNode[], result: VNode[]): void {
|
||||
let keyedFragmentCount = 0;
|
||||
const startIdx = result.length;
|
||||
|
||||
for (const child of children) {
|
||||
if (child.type === Fragment) {
|
||||
if (child.patchFlag & PatchFlags.KEYED_FRAGMENT) {
|
||||
keyedFragmentCount++;
|
||||
}
|
||||
|
||||
flatten(child.children as VNode[], result);
|
||||
}
|
||||
else if (child.type !== Comment) {
|
||||
result.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
if (keyedFragmentCount > 1) {
|
||||
for (let i = startIdx; i < result.length; i++) {
|
||||
result[i]!.patchFlag = PatchFlags.BAIL;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
vue/primitives/tsconfig.json
Normal file
10
vue/primitives/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
23
vue/primitives/tsdown.config.ts
Normal file
23
vue/primitives/tsdown.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: ['src/index.ts'],
|
||||
deps: {
|
||||
neverBundle: ['vue'],
|
||||
alwaysBundle: [/^@robonen\//, '@vue/shared'],
|
||||
},
|
||||
inputOptions: {
|
||||
resolve: {
|
||||
alias: {
|
||||
// We need to alias @vue/shared to its ESM build to avoid issues
|
||||
// with tree-shaking and module resolution in Rolldown
|
||||
'@vue/shared': '@vue/shared/dist/shared.esm-bundler.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__DEV__: 'false',
|
||||
},
|
||||
});
|
||||
16
vue/primitives/vitest.config.ts
Normal file
16
vue/primitives/vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__DEV__: 'true',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
});
|
||||
28
vue/toolkit/README.md
Normal file
28
vue/toolkit/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# @robonen/vue
|
||||
|
||||
Collection of composables and utilities for Vue 3.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @robonen/vue
|
||||
```
|
||||
|
||||
## Composables
|
||||
|
||||
| Category | Composables |
|
||||
| -------------- | ------------------------------------------------------------------ |
|
||||
| **browser** | `useEventListener`, `useFocusGuard`, `useSupported` |
|
||||
| **component** | `unrefElement`, `useRenderCount`, `useRenderInfo` |
|
||||
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
|
||||
| **math** | `useClamp` |
|
||||
| **reactivity** | `broadcastedRef`, `useCached`, `useLastChanged`, `useSyncRefs` |
|
||||
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useInjectionStore`, `useToggle` |
|
||||
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
|
||||
| **utilities** | `useOffsetPagination` |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { useToggle, useEventListener } from '@robonen/vue';
|
||||
```
|
||||
7
vue/toolkit/jsr.json
Normal file
7
vue/toolkit/jsr.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/vue",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.0.7",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
4
vue/toolkit/oxlint.config.ts
Normal file
4
vue/toolkit/oxlint.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, vue, vitest, imports, stylistic } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, vue, vitest, imports, stylistic));
|
||||
55
vue/toolkit/package.json
Normal file
55
vue/toolkit/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@robonen/vue",
|
||||
"version": "0.0.13",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Collection of powerful tools for Vue",
|
||||
"keywords": [
|
||||
"vue",
|
||||
"tools",
|
||||
"ui",
|
||||
"utilities",
|
||||
"composables"
|
||||
],
|
||||
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "vue/toolkit"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint:check": "oxlint -c oxlint.config.ts",
|
||||
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"@stylistic/eslint-plugin": "catalog:",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@robonen/platform": "workspace:*",
|
||||
"@robonen/stdlib": "workspace:*",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
7
vue/toolkit/src/composables/browser/index.ts
Normal file
7
vue/toolkit/src/composables/browser/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './useEventListener';
|
||||
export * from './useFocusGuard';
|
||||
export * from './useFps';
|
||||
export * from './useIntervalFn';
|
||||
export * from './useRafFn';
|
||||
export * from './useSupported';
|
||||
export * from './useTabLeader';
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useEventListener } from '.';
|
||||
|
||||
const mountWithEventListener = (
|
||||
setup: () => Record<string, any> | void,
|
||||
) => {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup,
|
||||
template: '<div></div>',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe(useEventListener, () => {
|
||||
let component: ReturnType<typeof mountWithEventListener>;
|
||||
|
||||
afterEach(() => {
|
||||
component?.unmount();
|
||||
});
|
||||
|
||||
it('register and trigger a listener on an explicit target', async () => {
|
||||
const listener = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
target.dispatchEvent(new Event('click'));
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('remove listener when stop is called', async () => {
|
||||
const listener = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
let stop: () => void;
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
stop = useEventListener(target, 'click', listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
stop!();
|
||||
target.dispatchEvent(new Event('click'));
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('remove listener when component is unmounted', async () => {
|
||||
const listener = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
component.unmount();
|
||||
target.dispatchEvent(new Event('click'));
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('register multiple events at once', async () => {
|
||||
const listener = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, ['click', 'focus'], listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
target.dispatchEvent(new Event('click'));
|
||||
target.dispatchEvent(new Event('focus'));
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('register multiple listeners at once', async () => {
|
||||
const listener1 = vi.fn();
|
||||
const listener2 = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', [listener1, listener2]);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
target.dispatchEvent(new Event('click'));
|
||||
|
||||
expect(listener1).toHaveBeenCalledOnce();
|
||||
expect(listener2).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('register multiple events and multiple listeners', async () => {
|
||||
const listener1 = vi.fn();
|
||||
const listener2 = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, ['click', 'focus'], [listener1, listener2]);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
target.dispatchEvent(new Event('click'));
|
||||
target.dispatchEvent(new Event('focus'));
|
||||
|
||||
expect(listener1).toHaveBeenCalledTimes(2);
|
||||
expect(listener2).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('react to a reactive target change', async () => {
|
||||
const listener = vi.fn();
|
||||
const el1 = document.createElement('div');
|
||||
const el2 = document.createElement('div');
|
||||
const target = ref<HTMLElement>(el1);
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
el1.dispatchEvent(new Event('click'));
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
target.value = el2;
|
||||
await nextTick();
|
||||
|
||||
// Old target should no longer trigger listener
|
||||
el1.dispatchEvent(new Event('click'));
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
// New target should trigger listener
|
||||
el2.dispatchEvent(new Event('click'));
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('cleanup when reactive target becomes null', async () => {
|
||||
const listener = vi.fn();
|
||||
const el = document.createElement('div');
|
||||
const target = ref<HTMLElement | null>(el);
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
el.dispatchEvent(new Event('click'));
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
target.value = null;
|
||||
await nextTick();
|
||||
|
||||
el.dispatchEvent(new Event('click'));
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('return noop when target is undefined', () => {
|
||||
const listener = vi.fn();
|
||||
const stop = useEventListener(undefined as any, 'click', listener);
|
||||
|
||||
expect(stop).toBeTypeOf('function');
|
||||
stop(); // should not throw
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pass options to addEventListener', async () => {
|
||||
const target = document.createElement('div');
|
||||
const addSpy = vi.spyOn(target, 'addEventListener');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', () => {}, { capture: true });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), { capture: true });
|
||||
});
|
||||
|
||||
it('use window as default target when event string is passed directly', async () => {
|
||||
const listener = vi.fn();
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
const removeSpy = vi.spyOn(globalThis, 'removeEventListener');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener('click', listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
|
||||
|
||||
component.unmount();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
|
||||
|
||||
addSpy.mockRestore();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('use window as default target when event array is passed directly', async () => {
|
||||
const listener = vi.fn();
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(['click', 'keydown'], listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
|
||||
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), undefined);
|
||||
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('work with document target', async () => {
|
||||
const listener = vi.fn();
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(document, 'click', listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
document.dispatchEvent(new Event('click'));
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('auto cleanup when effectScope is disposed', async () => {
|
||||
const listener = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useEventListener(target, 'click', listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
target.dispatchEvent(new Event('click'));
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
|
||||
target.dispatchEvent(new Event('click'));
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-register when reactive options change', async () => {
|
||||
const target = document.createElement('div');
|
||||
const listener = vi.fn();
|
||||
const options = ref<boolean | AddEventListenerOptions>(false);
|
||||
const addSpy = vi.spyOn(target, 'addEventListener');
|
||||
const removeSpy = vi.spyOn(target, 'removeEventListener');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', listener, options);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(addSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addSpy).toHaveBeenLastCalledWith('click', listener, false);
|
||||
|
||||
options.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addSpy).toHaveBeenCalledTimes(2);
|
||||
expect(addSpy).toHaveBeenLastCalledWith('click', listener, true);
|
||||
});
|
||||
|
||||
it('pass correct arguments to removeEventListener on stop', async () => {
|
||||
const listener = vi.fn();
|
||||
const options = { capture: true };
|
||||
const target = document.createElement('div');
|
||||
const removeSpy = vi.spyOn(target, 'removeEventListener');
|
||||
let stop: () => void;
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
stop = useEventListener(target, 'click', listener, options);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
stop!();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('click', listener, { capture: true });
|
||||
});
|
||||
|
||||
it('remove all listeners for all events on stop', async () => {
|
||||
const listener1 = vi.fn();
|
||||
const listener2 = vi.fn();
|
||||
const events = ['click', 'scroll', 'blur'];
|
||||
const options = { capture: true };
|
||||
const target = document.createElement('div');
|
||||
const removeSpy = vi.spyOn(target, 'removeEventListener');
|
||||
let stop: () => void;
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
stop = useEventListener(target, events, [listener1, listener2], options);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
stop!();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledTimes(events.length * 2);
|
||||
|
||||
for (const event of events) {
|
||||
expect(removeSpy).toHaveBeenCalledWith(event, listener1, { capture: true });
|
||||
expect(removeSpy).toHaveBeenCalledWith(event, listener2, { capture: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('clone object options to prevent reactive mutation issues', async () => {
|
||||
const target = document.createElement('div');
|
||||
const listener = vi.fn();
|
||||
const options = ref<AddEventListenerOptions>({ capture: true });
|
||||
const addSpy = vi.spyOn(target, 'addEventListener');
|
||||
const removeSpy = vi.spyOn(target, 'removeEventListener');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', listener, options);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('click', listener, { capture: true });
|
||||
|
||||
// Change options reactively — old removal should use the snapshotted options
|
||||
options.value = { capture: false };
|
||||
await nextTick();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('click', listener, { capture: true });
|
||||
expect(addSpy).toHaveBeenLastCalledWith('click', listener, { capture: false });
|
||||
});
|
||||
|
||||
it('not listen when reactive target starts as null', async () => {
|
||||
const listener = vi.fn();
|
||||
const target = ref<HTMLElement | null>(null);
|
||||
const el = document.createElement('div');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', listener);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
el.dispatchEvent(new Event('click'));
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
// Set target later
|
||||
target.value = el;
|
||||
await nextTick();
|
||||
|
||||
el.dispatchEvent(new Event('click'));
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('register listener synchronously for static target', () => {
|
||||
const listener = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener(target, 'click', listener);
|
||||
});
|
||||
|
||||
// No nextTick needed — listener is registered synchronously for static targets
|
||||
target.dispatchEvent(new Event('click'));
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('register listener synchronously for default window target', () => {
|
||||
const listener = vi.fn();
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
|
||||
component = mountWithEventListener(() => {
|
||||
useEventListener('click', listener);
|
||||
});
|
||||
|
||||
// No nextTick needed — registered synchronously
|
||||
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
|
||||
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
180
vue/toolkit/src/composables/browser/useEventListener/index.ts
Normal file
180
vue/toolkit/src/composables/browser/useEventListener/index.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { Arrayable, VoidFunction } from '@robonen/stdlib';
|
||||
import { first, isArray, isFunction, isObject, isString, noop } from '@robonen/stdlib';
|
||||
import { isRef, toValue, watch } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
interface InferEventTarget<Events> {
|
||||
addEventListener: (event: Events, listener?: any, options?: any) => any;
|
||||
removeEventListener: (event: Events, listener?: any, options?: any) => any;
|
||||
}
|
||||
|
||||
export type GeneralEventListener<E = Event> = (evt: E) => void;
|
||||
|
||||
export type WindowEventName = keyof WindowEventMap;
|
||||
export type DocumentEventName = keyof DocumentEventMap;
|
||||
export type ElementEventName = keyof HTMLElementEventMap;
|
||||
|
||||
type ListenerOptions = boolean | AddEventListenerOptions;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Browser
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 1: Omitted window target
|
||||
*/
|
||||
export function useEventListener<E extends WindowEventName>(
|
||||
event: Arrayable<E>,
|
||||
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
|
||||
): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Browser
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 2: Explicit window target
|
||||
*/
|
||||
export function useEventListener<E extends WindowEventName>(
|
||||
target: Window,
|
||||
event: Arrayable<E>,
|
||||
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
|
||||
): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Browser
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 3: Explicit document target
|
||||
*/
|
||||
export function useEventListener<E extends DocumentEventName>(
|
||||
target: Document,
|
||||
event: Arrayable<E>,
|
||||
listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
|
||||
): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Browser
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 4: Explicit HTMLElement target
|
||||
*/
|
||||
export function useEventListener<E extends ElementEventName>(
|
||||
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
|
||||
event: Arrayable<E>,
|
||||
listener: Arrayable<(this: HTMLElement, ev: HTMLElementEventMap[E]) => any>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
|
||||
): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Browser
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 5: Custom target with inferred event type
|
||||
*/
|
||||
export function useEventListener<Names extends string, EventType = Event>(
|
||||
target: MaybeRefOrGetter<InferEventTarget<Names> | null | undefined>,
|
||||
event: Arrayable<Names>,
|
||||
listener: Arrayable<GeneralEventListener<EventType>>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
|
||||
): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Browser
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 6: Custom event target fallback
|
||||
*/
|
||||
export function useEventListener<EventType = Event>(
|
||||
target: MaybeRefOrGetter<EventTarget | null | undefined>,
|
||||
event: Arrayable<string>,
|
||||
listener: Arrayable<GeneralEventListener<EventType>>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
|
||||
): VoidFunction;
|
||||
|
||||
export function useEventListener(...args: any[]) {
|
||||
let target: MaybeRefOrGetter<EventTarget> | undefined = defaultWindow;
|
||||
let _events: Arrayable<string>;
|
||||
let _listeners: Arrayable<EventListener>;
|
||||
let _options: MaybeRefOrGetter<ListenerOptions> | undefined;
|
||||
|
||||
if (isString(first(args)) || isArray(first(args))) {
|
||||
[_events, _listeners, _options] = args;
|
||||
}
|
||||
else {
|
||||
[target, _events, _listeners, _options] = args;
|
||||
}
|
||||
|
||||
if (!target)
|
||||
return noop;
|
||||
|
||||
const events = isArray(_events) ? _events : [_events];
|
||||
const listeners = isArray(_listeners) ? _listeners : [_listeners];
|
||||
|
||||
const cleanups: VoidFunction[] = [];
|
||||
|
||||
const _cleanup = () => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups.length = 0;
|
||||
};
|
||||
|
||||
const _register = (el: EventTarget, event: string, listener: EventListener, options: ListenerOptions | undefined) => {
|
||||
el.addEventListener(event, listener, options);
|
||||
return () => el.removeEventListener(event, listener, options);
|
||||
};
|
||||
|
||||
const _registerAll = (el: EventTarget, options: ListenerOptions | undefined) => {
|
||||
// Clone object options to avoid reactive mutation between add/remove
|
||||
const optionsClone = isObject(options) ? { ...options } : options;
|
||||
|
||||
for (const event of events) {
|
||||
for (const listener of listeners) {
|
||||
cleanups.push(_register(el, event, listener, optionsClone));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isTargetReactive = isRef(target) || isFunction(target);
|
||||
const isOptionsReactive = isRef(_options) || isFunction(_options);
|
||||
|
||||
// Reactive path: use watch for ref/getter targets (e.g., template refs)
|
||||
if (isTargetReactive || isOptionsReactive) {
|
||||
const stopWatch = watch(
|
||||
() => [toValue(target), toValue(_options)] as const,
|
||||
([el, options]) => {
|
||||
_cleanup();
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
_registerAll(el, options);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
const stop = () => {
|
||||
stopWatch();
|
||||
_cleanup();
|
||||
};
|
||||
|
||||
tryOnScopeDispose(stop);
|
||||
|
||||
return stop;
|
||||
}
|
||||
|
||||
// Fast path: static target — register synchronously, no watch overhead
|
||||
_registerAll(target as EventTarget, _options as ListenerOptions);
|
||||
|
||||
tryOnScopeDispose(_cleanup);
|
||||
|
||||
return _cleanup;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, nextTick } from 'vue';
|
||||
import { useFocusGuard } from '.';
|
||||
|
||||
const setupFocusGuard = (namespace?: string) => {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
useFocusGuard(namespace);
|
||||
},
|
||||
template: '<div></div>',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getFocusGuards = (namespace: string) =>
|
||||
document.querySelectorAll(`[data-${namespace}]`);
|
||||
|
||||
describe(useFocusGuard, () => {
|
||||
let component: ReturnType<typeof setupFocusGuard>;
|
||||
const namespace = 'test-guard';
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
component.unmount();
|
||||
});
|
||||
|
||||
it('create focus guards when mounted', async () => {
|
||||
component = setupFocusGuard(namespace);
|
||||
|
||||
const guards = getFocusGuards(namespace);
|
||||
expect(guards).toHaveLength(2);
|
||||
|
||||
guards.forEach((guard) => {
|
||||
expect(guard.getAttribute('tabindex')).toBe('0');
|
||||
expect(guard.getAttribute('style')).toContain('opacity: 0');
|
||||
});
|
||||
});
|
||||
|
||||
it('remove focus guards when unmounted', () => {
|
||||
component = setupFocusGuard(namespace);
|
||||
|
||||
component.unmount();
|
||||
|
||||
expect(getFocusGuards(namespace)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('correctly manage multiple instances with the same namespace', () => {
|
||||
const wrapper1 = setupFocusGuard(namespace);
|
||||
const wrapper2 = setupFocusGuard(namespace);
|
||||
|
||||
// Guards should not be duplicated
|
||||
expect(getFocusGuards(namespace)).toHaveLength(2);
|
||||
|
||||
wrapper1.unmount();
|
||||
|
||||
// Second instance still keeps the guards
|
||||
expect(getFocusGuards(namespace)).toHaveLength(2);
|
||||
|
||||
wrapper2.unmount();
|
||||
|
||||
// No guards left after all instances are unmounted
|
||||
expect(getFocusGuards(namespace)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
40
vue/toolkit/src/composables/browser/useFocusGuard/index.ts
Normal file
40
vue/toolkit/src/composables/browser/useFocusGuard/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { focusGuard } from '@robonen/platform/browsers';
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
// Global counter to drop the focus guards when the last instance is unmounted
|
||||
let counter = 0;
|
||||
|
||||
/**
|
||||
* @name useFocusGuard
|
||||
* @category Browser
|
||||
* @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior
|
||||
*
|
||||
* @param {string} [namespace] - A namespace to group the focus guards
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* useFocusGuard();
|
||||
*
|
||||
* @example
|
||||
* useFocusGuard('my-namespace');
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export function useFocusGuard(namespace?: string) {
|
||||
const manager = focusGuard(namespace);
|
||||
|
||||
const createGuard = () => {
|
||||
manager.createGuard();
|
||||
counter++;
|
||||
};
|
||||
|
||||
const removeGuard = () => {
|
||||
if (counter <= 1)
|
||||
manager.removeGuard();
|
||||
|
||||
counter = Math.max(0, counter - 1);
|
||||
};
|
||||
|
||||
onMounted(createGuard);
|
||||
onUnmounted(removeGuard);
|
||||
}
|
||||
46
vue/toolkit/src/composables/browser/useFps/demo.vue
Normal file
46
vue/toolkit/src/composables/browser/useFps/demo.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">import { useFps } from './index';
|
||||
|
||||
const { fps, min, max, isActive, reset, toggle } = useFps({ every: 10 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end gap-8">
|
||||
<div>
|
||||
<div class="text-4xl font-mono font-bold tabular-nums text-(--color-text)">{{ fps }}</div>
|
||||
<div class="text-xs text-(--color-text-mute) mt-1">FPS</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ min === Infinity ? '—' : min }}</div>
|
||||
<div class="text-xs text-(--color-text-mute) mt-1">Min</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ max || '—' }}</div>
|
||||
<div class="text-xs text-(--color-text-mute) mt-1">Max</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-2 rounded-full border border-(--color-border) overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="fps >= 50 ? 'bg-emerald-500' : fps >= 30 ? 'bg-amber-500' : 'bg-red-500'"
|
||||
:style="{ width: `${Math.min(fps / 60 * 100, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ isActive ? 'Pause' : 'Resume' }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
|
||||
@click="reset"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
244
vue/toolkit/src/composables/browser/useFps/index.test.ts
Normal file
244
vue/toolkit/src/composables/browser/useFps/index.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import { useFps } from '.';
|
||||
|
||||
let rafCallbacks: Array<(time: number) => void> = [];
|
||||
let rafIdCounter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = [];
|
||||
rafIdCounter = 0;
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
|
||||
const id = ++rafIdCounter;
|
||||
rafCallbacks.push(cb);
|
||||
return id;
|
||||
});
|
||||
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function triggerFrame(time: number) {
|
||||
const cbs = [...rafCallbacks];
|
||||
rafCallbacks = [];
|
||||
cbs.forEach(cb => cb(time));
|
||||
}
|
||||
|
||||
function triggerFrames(startTime: number, interval: number, count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
triggerFrame(startTime + i * interval);
|
||||
}
|
||||
}
|
||||
|
||||
describe(useFps, () => {
|
||||
it('starts at 0 fps', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps();
|
||||
});
|
||||
|
||||
expect(result!.fps.value).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports fps after "every" frames', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 5 });
|
||||
});
|
||||
|
||||
// ~60fps = 16.67ms per frame
|
||||
// First frame has delta=0, skipped by useFps. Need 5 real-delta frames.
|
||||
triggerFrame(100); // delta=0, skipped
|
||||
triggerFrame(116.67); // delta=16.67
|
||||
triggerFrame(133.33); // delta=16.66
|
||||
triggerFrame(150); // delta=16.67
|
||||
triggerFrame(166.67); // delta=16.67
|
||||
triggerFrame(183.33); // delta=16.66 → 5 deltas collected, update
|
||||
|
||||
expect(result!.fps.value).toBe(60);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not update fps before collecting enough frames', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 10 });
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
triggerFrame(116.67);
|
||||
triggerFrame(133.33);
|
||||
|
||||
expect(result!.fps.value).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks min and max fps', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 3 });
|
||||
});
|
||||
|
||||
// First batch: ~60fps (16.67ms intervals)
|
||||
triggerFrame(100); // delta=0, skipped
|
||||
triggerFrame(116.67); // delta=16.67
|
||||
triggerFrame(133.33); // delta=16.66
|
||||
triggerFrame(150); // delta=16.67 → 3 deltas, update
|
||||
|
||||
const firstFps = result!.fps.value;
|
||||
expect(firstFps).toBe(60);
|
||||
|
||||
// Second batch: ~30fps (33.33ms intervals)
|
||||
triggerFrame(183.33); // delta=33.33
|
||||
triggerFrame(216.67); // delta=33.34
|
||||
triggerFrame(250); // delta=33.33 → 3 deltas, update
|
||||
|
||||
const secondFps = result!.fps.value;
|
||||
expect(secondFps).toBe(30);
|
||||
|
||||
expect(result!.max.value).toBe(60);
|
||||
expect(result!.min.value).toBe(30);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resets min, max, and fps', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 3 });
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
triggerFrame(116.67);
|
||||
triggerFrame(133.33);
|
||||
triggerFrame(150);
|
||||
|
||||
expect(result!.fps.value).toBe(60);
|
||||
|
||||
result!.reset();
|
||||
|
||||
expect(result!.fps.value).toBe(0);
|
||||
expect(result!.min.value).toBe(Infinity);
|
||||
expect(result!.max.value).toBe(0);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useFps();
|
||||
});
|
||||
|
||||
// Should not throw on stop
|
||||
scope.stop();
|
||||
|
||||
// No more raf callbacks should be registered after stop
|
||||
triggerFrame(100);
|
||||
expect(rafCallbacks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does nothing when window is undefined (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ window: undefined as any });
|
||||
});
|
||||
|
||||
expect(result!.fps.value).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is active by default', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps();
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not start when immediate is false', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ immediate: false });
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pauses and resumes fps tracking', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 3 });
|
||||
});
|
||||
|
||||
// Collect one batch
|
||||
triggerFrame(100);
|
||||
triggerFrame(116.67);
|
||||
triggerFrame(133.33);
|
||||
triggerFrame(150);
|
||||
|
||||
expect(result!.fps.value).toBe(60);
|
||||
|
||||
result!.pause();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
// Frames while paused should not update
|
||||
triggerFrame(200);
|
||||
triggerFrame(300);
|
||||
triggerFrame(400);
|
||||
triggerFrame(500);
|
||||
|
||||
expect(result!.fps.value).toBe(60);
|
||||
|
||||
result!.resume();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('toggles fps tracking', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps();
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
result!.toggle();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
result!.toggle();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
109
vue/toolkit/src/composables/browser/useFps/index.ts
Normal file
109
vue/toolkit/src/composables/browser/useFps/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
|
||||
import type { UseRafFnCallbackArgs } from '@/composables/browser/useRafFn';
|
||||
import type { Ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useRafFn } from '@/composables/browser/useRafFn';
|
||||
|
||||
export interface UseFpsOptions extends ResumableOptions, ConfigurableWindow {
|
||||
/**
|
||||
* Number of frames to average over for a smoother reading.
|
||||
*
|
||||
* @default 10
|
||||
*/
|
||||
every?: number;
|
||||
}
|
||||
|
||||
export interface UseFpsReturn extends ResumableActions {
|
||||
/**
|
||||
* Current frames per second (averaged over the last `every` frames)
|
||||
*/
|
||||
fps: Readonly<Ref<number>>;
|
||||
|
||||
/**
|
||||
* Minimum FPS recorded since the composable was created or last reset
|
||||
*/
|
||||
min: Readonly<Ref<number>>;
|
||||
|
||||
/**
|
||||
* Maximum FPS recorded since the composable was created or last reset
|
||||
*/
|
||||
max: Readonly<Ref<number>>;
|
||||
|
||||
/**
|
||||
* Whether the FPS counter is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Reset min/max tracking
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive FPS counter based on `requestAnimationFrame`.
|
||||
* Reports a smoothed FPS value averaged over a configurable number of frames,
|
||||
* and tracks min/max values.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { fps, min, max, reset } = useFps();
|
||||
* ```
|
||||
*/
|
||||
export function useFps(options: UseFpsOptions = {}): UseFpsReturn {
|
||||
const { every = 10, ...rafOptions } = options;
|
||||
|
||||
const fps = ref(0);
|
||||
const min = ref(Infinity);
|
||||
const max = ref(0);
|
||||
|
||||
let deltaSum = 0;
|
||||
let frameCount = 0;
|
||||
|
||||
function update({ delta }: UseRafFnCallbackArgs) {
|
||||
if (!delta)
|
||||
return;
|
||||
|
||||
deltaSum += delta;
|
||||
frameCount++;
|
||||
|
||||
if (frameCount < every)
|
||||
return;
|
||||
|
||||
const currentFps = Math.round(1000 / (deltaSum / frameCount));
|
||||
|
||||
fps.value = currentFps;
|
||||
|
||||
if (currentFps < min.value)
|
||||
min.value = currentFps;
|
||||
|
||||
if (currentFps > max.value)
|
||||
max.value = currentFps;
|
||||
|
||||
deltaSum = 0;
|
||||
frameCount = 0;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
min.value = Infinity;
|
||||
max.value = 0;
|
||||
fps.value = 0;
|
||||
deltaSum = 0;
|
||||
frameCount = 0;
|
||||
}
|
||||
|
||||
const { isActive, pause, resume, toggle } = useRafFn(update, rafOptions);
|
||||
|
||||
return {
|
||||
fps,
|
||||
min,
|
||||
max,
|
||||
isActive,
|
||||
reset,
|
||||
pause,
|
||||
resume,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
259
vue/toolkit/src/composables/browser/useIntervalFn/index.test.ts
Normal file
259
vue/toolkit/src/composables/browser/useIntervalFn/index.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useIntervalFn } from '.';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
interval: {
|
||||
type: Number,
|
||||
default: 1000,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const result = useIntervalFn(props.callback as () => void, props.interval, props.options);
|
||||
return { ...result };
|
||||
},
|
||||
template: '<div>{{ isActive }}</div>',
|
||||
});
|
||||
|
||||
describe(useIntervalFn, () => {
|
||||
it('starts immediately by default', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('does not start when immediate is false', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
options: { immediate: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls callback on each interval', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: { callback, interval: 500 },
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(callback).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('calls callback immediately when immediateCallback is true', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
interval: 1000,
|
||||
options: { immediateCallback: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('pauses and resumes', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback, interval: 100 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
wrapper.vm.pause();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('false');
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
wrapper.vm.resume();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(callback).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('toggles the interval', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('false');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('supports reactive interval', async () => {
|
||||
const callback = vi.fn();
|
||||
const interval = ref(1000);
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useIntervalFn(callback, interval);
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change interval to 200ms — watcher triggers async
|
||||
interval.value = 200;
|
||||
await nextTick();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not fire with interval <= 0', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const { isActive } = useIntervalFn(callback, 0);
|
||||
expect(isActive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useIntervalFn(callback, 100);
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
scope.stop();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('cleans up on component unmount', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback, interval: 100 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('resume is idempotent when already active', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useIntervalFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useIntervalFn(callback, 100);
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
result!.resume();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
// Should still tick normally — no double interval
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pause is idempotent when already paused', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useIntervalFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useIntervalFn(callback, 100, { immediate: false });
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
result!.pause();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses default interval of 1000ms', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useIntervalFn(callback);
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
112
vue/toolkit/src/composables/browser/useIntervalFn/index.ts
Normal file
112
vue/toolkit/src/composables/browser/useIntervalFn/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { readonly, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import type { ResumableActions, ResumableOptions } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseIntervalFnOptions extends ResumableOptions {
|
||||
/**
|
||||
* Whether to invoke the callback immediately on start.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
immediateCallback?: boolean;
|
||||
}
|
||||
|
||||
export interface UseIntervalFnReturn extends ResumableActions {
|
||||
/**
|
||||
* Whether the interval is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a function on every interval. Supports reactive interval duration,
|
||||
* pause/resume, and automatic cleanup on scope dispose.
|
||||
*
|
||||
* @param callback - Function to call on every interval tick
|
||||
* @param interval - Interval duration in milliseconds (can be reactive)
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { pause, resume, isActive } = useIntervalFn(() => {
|
||||
* console.log('tick');
|
||||
* }, 1000);
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Reactive interval
|
||||
* const delay = ref(1000);
|
||||
* useIntervalFn(() => console.log('tick'), delay);
|
||||
* delay.value = 500; // interval restarts with new duration
|
||||
* ```
|
||||
*/
|
||||
export function useIntervalFn(
|
||||
callback: () => void,
|
||||
interval: MaybeRefOrGetter<number> = 1000,
|
||||
options: UseIntervalFnOptions = {},
|
||||
): UseIntervalFnReturn {
|
||||
const {
|
||||
immediate = true,
|
||||
immediateCallback = false,
|
||||
} = options;
|
||||
|
||||
const isActive = ref(false);
|
||||
|
||||
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function clean() {
|
||||
if (timerId !== null) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resume() {
|
||||
const ms = toValue(interval);
|
||||
|
||||
if (ms <= 0)
|
||||
return;
|
||||
|
||||
isActive.value = true;
|
||||
|
||||
if (immediateCallback)
|
||||
callback();
|
||||
|
||||
clean();
|
||||
timerId = setInterval(callback, ms);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isActive.value = false;
|
||||
clean();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isActive.value)
|
||||
pause();
|
||||
else
|
||||
resume();
|
||||
}
|
||||
|
||||
// Re-start when interval changes reactively
|
||||
watch(() => toValue(interval), () => {
|
||||
if (isActive.value) {
|
||||
clean();
|
||||
resume();
|
||||
}
|
||||
});
|
||||
|
||||
if (immediate)
|
||||
resume();
|
||||
|
||||
tryOnScopeDispose(pause);
|
||||
|
||||
return {
|
||||
isActive: readonly(isActive),
|
||||
pause,
|
||||
resume,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
252
vue/toolkit/src/composables/browser/useRafFn/index.test.ts
Normal file
252
vue/toolkit/src/composables/browser/useRafFn/index.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useRafFn } from '.';
|
||||
|
||||
let rafCallbacks: Array<(time: number) => void> = [];
|
||||
let rafIdCounter = 0;
|
||||
let currentTime = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = [];
|
||||
rafIdCounter = 0;
|
||||
currentTime = 0;
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
|
||||
const id = ++rafIdCounter;
|
||||
rafCallbacks.push(cb);
|
||||
return id;
|
||||
});
|
||||
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function triggerFrame(time: number) {
|
||||
currentTime = time;
|
||||
const cbs = [...rafCallbacks];
|
||||
rafCallbacks = [];
|
||||
cbs.forEach(cb => cb(currentTime));
|
||||
}
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const result = useRafFn(props.callback as any, props.options);
|
||||
return { ...result };
|
||||
},
|
||||
template: '<div>{{ isActive }}</div>',
|
||||
});
|
||||
|
||||
describe(useRafFn, () => {
|
||||
it('starts immediately by default', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('does not start when immediate is false', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
options: { immediate: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('false');
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the callback on animation frame with delta and timestamp', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledWith({ delta: 0, timestamp: 100 });
|
||||
});
|
||||
|
||||
it('provides correct delta between frames', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
triggerFrame(116.67);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback.mock.calls[1]![0]!.delta).toBeCloseTo(16.67, 1);
|
||||
});
|
||||
|
||||
it('pauses and resumes the loop', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.vm.pause();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('false');
|
||||
triggerFrame(200);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.vm.resume();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
triggerFrame(300);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resets delta after resume', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
wrapper.vm.pause();
|
||||
|
||||
wrapper.vm.resume();
|
||||
triggerFrame(500);
|
||||
|
||||
// After resume, first frame delta resets to 0
|
||||
const lastCall = callback.mock.calls[callback.mock.calls.length - 1]![0]!;
|
||||
expect(lastCall.delta).toBe(0);
|
||||
expect(lastCall.timestamp).toBe(500);
|
||||
});
|
||||
|
||||
it('toggles the loop', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('false');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('limits frame rate with fpsLimit', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
options: { fpsLimit: 30 },
|
||||
},
|
||||
});
|
||||
|
||||
// First frame always fires (delta is 0)
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 30fps = ~33.33ms per frame — too soon, skipped
|
||||
triggerFrame(110);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Enough time passed (~40ms > 33.33ms)
|
||||
triggerFrame(140);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useRafFn(callback);
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
triggerFrame(200);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up on component unmount', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.unmount();
|
||||
triggerFrame(200);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when window is undefined (SSR)', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const { isActive } = useRafFn(callback, { window: undefined as any });
|
||||
expect(isActive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resume is idempotent when already active', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useRafFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useRafFn(vi.fn());
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
result!.resume();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pause is idempotent when already paused', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useRafFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useRafFn(vi.fn(), { immediate: false });
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
result!.pause();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
120
vue/toolkit/src/composables/browser/useRafFn/index.ts
Normal file
120
vue/toolkit/src/composables/browser/useRafFn/index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseRafFnCallbackArgs {
|
||||
/**
|
||||
* Time elapsed since the last frame in milliseconds
|
||||
*/
|
||||
delta: number;
|
||||
|
||||
/**
|
||||
* `DOMHighResTimeStamp` passed by `requestAnimationFrame`
|
||||
*/
|
||||
timestamp: DOMHighResTimeStamp;
|
||||
}
|
||||
|
||||
export interface UseRafFnOptions extends ResumableOptions, ConfigurableWindow {
|
||||
/**
|
||||
* Maximum frames per second. Set to `0` or `undefined` to disable the limit.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
fpsLimit?: number;
|
||||
}
|
||||
|
||||
export interface UseRafFnReturn extends ResumableActions {
|
||||
/**
|
||||
* Whether the RAF loop is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a function on every `requestAnimationFrame` with delta time tracking.
|
||||
* Automatically cleans up when the component scope is disposed.
|
||||
*
|
||||
* @param callback - Function to call on every animation frame
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { pause, resume, isActive } = useRafFn(({ delta, timestamp }) => {
|
||||
* console.log(`${delta}ms since last frame`);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useRafFn(
|
||||
callback: (args: UseRafFnCallbackArgs) => void,
|
||||
options: UseRafFnOptions = {},
|
||||
): UseRafFnReturn {
|
||||
const {
|
||||
immediate = true,
|
||||
fpsLimit,
|
||||
} = options;
|
||||
|
||||
const window = 'window' in options ? options.window : defaultWindow;
|
||||
|
||||
const isActive = ref(false);
|
||||
const intervalLimit = fpsLimit ? 1000 / fpsLimit : null;
|
||||
|
||||
let previousFrameTimestamp = 0;
|
||||
let rafId: number | null = null;
|
||||
|
||||
function loop(timestamp: DOMHighResTimeStamp) {
|
||||
if (!isActive.value || !window)
|
||||
return;
|
||||
|
||||
if (!previousFrameTimestamp)
|
||||
previousFrameTimestamp = timestamp;
|
||||
|
||||
const delta = timestamp - previousFrameTimestamp;
|
||||
|
||||
if (intervalLimit && delta && delta < intervalLimit) {
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
return;
|
||||
}
|
||||
|
||||
previousFrameTimestamp = timestamp;
|
||||
callback({ delta, timestamp });
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (!isActive.value && window) {
|
||||
isActive.value = true;
|
||||
previousFrameTimestamp = 0;
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isActive.value = false;
|
||||
|
||||
if (rafId !== null && window) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isActive.value)
|
||||
pause();
|
||||
else
|
||||
resume();
|
||||
}
|
||||
|
||||
if (immediate)
|
||||
resume();
|
||||
|
||||
tryOnScopeDispose(pause);
|
||||
|
||||
return {
|
||||
isActive: readonly(isActive),
|
||||
pause,
|
||||
resume,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useSupported } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
location: {
|
||||
type: String,
|
||||
default: 'location',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const isSupported = useSupported(() => props.location in globalThis);
|
||||
|
||||
return { isSupported };
|
||||
},
|
||||
template: `<div>{{ isSupported }}</div>`,
|
||||
});
|
||||
|
||||
describe(useSupported, () => {
|
||||
it('return whether the feature is supported', async () => {
|
||||
const component = mount(ComponentStub);
|
||||
|
||||
expect(component.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('return whether the feature is not supported', async () => {
|
||||
const component = mount(ComponentStub, {
|
||||
props: {
|
||||
location: 'unsupported',
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.text()).toBe('false');
|
||||
});
|
||||
});
|
||||
30
vue/toolkit/src/composables/browser/useSupported/index.ts
Normal file
30
vue/toolkit/src/composables/browser/useSupported/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMounted } from '@/composables/lifecycle/useMounted';
|
||||
|
||||
/**
|
||||
* @name useSupported
|
||||
* @category Browser
|
||||
* @description SSR-friendly way to check if a feature is supported
|
||||
*
|
||||
* @param {Function} feature The feature to check for support
|
||||
* @returns {ComputedRef<boolean>} Whether the feature is supported
|
||||
*
|
||||
* @example
|
||||
* const isSupported = useSupported(() => 'IntersectionObserver' in window);
|
||||
*
|
||||
* @example
|
||||
* const isSupported = useSupported(() => 'ResizeObserver' in window);
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useSupported(feature: () => unknown) {
|
||||
const isMounted = useMounted();
|
||||
|
||||
return computed(() => {
|
||||
// add reactive dependency on isMounted
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
isMounted.value;
|
||||
|
||||
return Boolean(feature());
|
||||
});
|
||||
}
|
||||
54
vue/toolkit/src/composables/browser/useTabLeader/demo.vue
Normal file
54
vue/toolkit/src/composables/browser/useTabLeader/demo.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">import { useTabLeader } from './index';
|
||||
|
||||
const { isLeader, isSupported, acquire, release } = useTabLeader('docs-demo-leader');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-(--color-text-soft)">Web Locks API:</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
|
||||
:class="isSupported ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700' : 'border-red-500/30 bg-red-500/10 text-red-700'"
|
||||
>
|
||||
{{ isSupported ? 'Supported' : 'Not supported' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-(--color-text-soft)">Leader status:</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
|
||||
:class="isLeader ? 'border-brand-500/30 bg-brand-500/10 text-brand-600' : 'border-(--color-border) bg-(--color-bg-mute) text-(--color-text-soft)'"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="isLeader ? 'bg-brand-500 animate-pulse' : 'bg-(--color-text-mute)'"
|
||||
/>
|
||||
{{ isLeader ? 'Leader' : 'Follower' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--color-text-mute)">
|
||||
Open this page in multiple tabs — only one will be the leader.
|
||||
Close the leader tab and another will take over automatically.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="!isSupported || isLeader"
|
||||
@click="acquire"
|
||||
>
|
||||
Acquire
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="!isSupported || !isLeader"
|
||||
@click="release"
|
||||
>
|
||||
Release
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
222
vue/toolkit/src/composables/browser/useTabLeader/index.test.ts
Normal file
222
vue/toolkit/src/composables/browser/useTabLeader/index.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useTabLeader } from '.';
|
||||
|
||||
type LockGrantedCallback = (lock: unknown) => Promise<void>;
|
||||
interface MockLockRequest {
|
||||
key: string;
|
||||
callback: LockGrantedCallback;
|
||||
resolve: () => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const pendingRequests: MockLockRequest[] = [];
|
||||
let heldLocks: Set<string>;
|
||||
|
||||
function setupLocksMock() {
|
||||
heldLocks = new Set();
|
||||
|
||||
const mockLocks = {
|
||||
request: vi.fn(async (key: string, options: { signal?: AbortSignal }, callback: LockGrantedCallback) => {
|
||||
if (options.signal?.aborted) {
|
||||
throw new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
|
||||
if (heldLocks.has(key)) {
|
||||
// Queue the request — lock is held
|
||||
return new Promise<void>((resolve) => {
|
||||
const request: MockLockRequest = { key, callback, resolve, signal: options.signal };
|
||||
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
const index = pendingRequests.indexOf(request);
|
||||
if (index > -1) pendingRequests.splice(index, 1);
|
||||
resolve();
|
||||
});
|
||||
|
||||
pendingRequests.push(request);
|
||||
});
|
||||
}
|
||||
|
||||
heldLocks.add(key);
|
||||
const result = callback({} as unknown);
|
||||
|
||||
// When the callback promise resolves (lock released), grant to next waiter
|
||||
result.then(() => {
|
||||
heldLocks.delete(key);
|
||||
grantNextLock(key);
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: mockLocks,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function grantNextLock(key: string) {
|
||||
const index = pendingRequests.findIndex(r => r.key === key);
|
||||
if (index === -1) return;
|
||||
|
||||
const [request] = pendingRequests.splice(index, 1);
|
||||
if (!request) return;
|
||||
|
||||
heldLocks.add(key);
|
||||
|
||||
const result = request.callback({} as unknown);
|
||||
result.then(() => {
|
||||
heldLocks.delete(key);
|
||||
request.resolve();
|
||||
grantNextLock(key);
|
||||
});
|
||||
}
|
||||
|
||||
const mountWithComposable = (setup: () => Record<string, any> | void) => {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup,
|
||||
template: '<div></div>',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe(useTabLeader, () => {
|
||||
let component: ReturnType<typeof mountWithComposable>;
|
||||
|
||||
beforeEach(() => {
|
||||
pendingRequests.length = 0;
|
||||
setupLocksMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
component?.unmount();
|
||||
});
|
||||
|
||||
it('acquire leadership when lock is available', async () => {
|
||||
component = mountWithComposable(() => {
|
||||
const { isLeader, isSupported } = useTabLeader('test-leader');
|
||||
return { isLeader, isSupported };
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(component.vm.isSupported).toBeTruthy();
|
||||
expect(component.vm.isLeader).toBeTruthy();
|
||||
});
|
||||
|
||||
it('not grant leadership when another tab holds the lock', async () => {
|
||||
const scope1 = effectScope();
|
||||
let leader1: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope1.run(() => {
|
||||
leader1 = useTabLeader('exclusive');
|
||||
});
|
||||
|
||||
const scope2 = effectScope();
|
||||
let leader2: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope2.run(() => {
|
||||
leader2 = useTabLeader('exclusive');
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(leader1!.isLeader.value).toBeTruthy();
|
||||
expect(leader2!.isLeader.value).toBeFalsy();
|
||||
|
||||
scope1.stop();
|
||||
scope2.stop();
|
||||
});
|
||||
|
||||
it('transfer leadership when the leader releases the lock', async () => {
|
||||
const scope1 = effectScope();
|
||||
let leader1: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope1.run(() => {
|
||||
leader1 = useTabLeader('transfer');
|
||||
});
|
||||
|
||||
const scope2 = effectScope();
|
||||
let leader2: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope2.run(() => {
|
||||
leader2 = useTabLeader('transfer');
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(leader1!.isLeader.value).toBeTruthy();
|
||||
expect(leader2!.isLeader.value).toBeFalsy();
|
||||
|
||||
// Leader 1 releases (e.g., tab closes)
|
||||
scope1.stop();
|
||||
await nextTick();
|
||||
|
||||
expect(leader1!.isLeader.value).toBeFalsy();
|
||||
expect(leader2!.isLeader.value).toBeTruthy();
|
||||
|
||||
scope2.stop();
|
||||
});
|
||||
|
||||
it('manually release and re-acquire leadership', async () => {
|
||||
const scope = effectScope();
|
||||
let leader: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope.run(() => {
|
||||
leader = useTabLeader('manual');
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBeTruthy();
|
||||
|
||||
leader!.release();
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBeFalsy();
|
||||
|
||||
leader!.acquire();
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('not acquire when immediate is false', async () => {
|
||||
const scope = effectScope();
|
||||
let leader: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope.run(() => {
|
||||
leader = useTabLeader('deferred', { immediate: false });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBeFalsy();
|
||||
expect(navigator.locks.request).not.toHaveBeenCalled();
|
||||
|
||||
leader!.acquire();
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('fallback to isLeader always false when locks API is not supported', async () => {
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
component = mountWithComposable(() => {
|
||||
const { isLeader, isSupported } = useTabLeader('unsupported');
|
||||
return { isLeader, isSupported };
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(component.vm.isSupported).toBeFalsy();
|
||||
expect(component.vm.isLeader).toBeFalsy();
|
||||
});
|
||||
});
|
||||
115
vue/toolkit/src/composables/browser/useTabLeader/index.ts
Normal file
115
vue/toolkit/src/composables/browser/useTabLeader/index.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { ref, readonly } from 'vue';
|
||||
import type { Ref, DeepReadonly, ComputedRef } from 'vue';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseTabLeaderOptions {
|
||||
/**
|
||||
* Immediately attempt to acquire leadership on creation
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
export interface UseTabLeaderReturn {
|
||||
/**
|
||||
* Whether the current tab is the leader
|
||||
*/
|
||||
isLeader: DeepReadonly<Ref<boolean>>;
|
||||
/**
|
||||
* Whether the Web Locks API is supported
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
/**
|
||||
* Manually acquire leadership
|
||||
*/
|
||||
acquire: () => void;
|
||||
/**
|
||||
* Manually release leadership
|
||||
*/
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useTabLeader
|
||||
* @category Browser
|
||||
* @description Elects a single leader tab using the Web Locks API.
|
||||
* Only one tab at a time holds the lock for a given key.
|
||||
* When the leader tab closes or the scope is disposed, another tab automatically becomes the leader.
|
||||
*
|
||||
* @param {string} key A unique lock name identifying the leader group
|
||||
* @param {UseTabLeaderOptions} [options={}] Options
|
||||
* @returns {UseTabLeaderReturn} Leader state and controls
|
||||
*
|
||||
* @example
|
||||
* const { isLeader } = useTabLeader('payment-polling');
|
||||
*
|
||||
* watchEffect(() => {
|
||||
* if (isLeader.value) {
|
||||
* // Only this tab performs polling
|
||||
* startPolling();
|
||||
* } else {
|
||||
* stopPolling();
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* @since 0.0.13
|
||||
*/
|
||||
export function useTabLeader(key: string, options: UseTabLeaderOptions = {}): UseTabLeaderReturn {
|
||||
const { immediate = true } = options;
|
||||
|
||||
const isLeader = ref(false);
|
||||
const isSupported = useSupported(() => navigator?.locks);
|
||||
|
||||
let releaseResolve: (() => void) | null = null;
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
function acquire() {
|
||||
if (!isSupported.value || abortController) return;
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
navigator.locks.request(
|
||||
key,
|
||||
{ signal: abortController.signal },
|
||||
() => {
|
||||
isLeader.value = true;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
releaseResolve = resolve;
|
||||
});
|
||||
},
|
||||
).catch((error: unknown) => {
|
||||
// AbortError is expected when release() is called before lock is acquired
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function release() {
|
||||
isLeader.value = false;
|
||||
|
||||
if (releaseResolve) {
|
||||
releaseResolve();
|
||||
releaseResolve = null;
|
||||
}
|
||||
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
acquire();
|
||||
}
|
||||
|
||||
tryOnScopeDispose(release);
|
||||
|
||||
return {
|
||||
isLeader: readonly(isLeader),
|
||||
isSupported,
|
||||
acquire,
|
||||
release,
|
||||
};
|
||||
}
|
||||
3
vue/toolkit/src/composables/component/index.ts
Normal file
3
vue/toolkit/src/composables/component/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './unrefElement';
|
||||
export * from './useForwardExpose';
|
||||
export * from './useTemplateRefsList';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computed, defineComponent, nextTick, ref, shallowRef } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { unrefElement } from '.';
|
||||
|
||||
describe(unrefElement, () => {
|
||||
it('returns a plain element when passed a raw element', () => {
|
||||
const htmlEl = document.createElement('div');
|
||||
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
|
||||
expect(unrefElement(htmlEl)).toBe(htmlEl);
|
||||
expect(unrefElement(svgEl)).toBe(svgEl);
|
||||
});
|
||||
|
||||
it('returns element when passed a ref or shallowRef to an element', () => {
|
||||
const el = document.createElement('div');
|
||||
const elRef = ref<HTMLElement | null>(el);
|
||||
const shallowElRef = shallowRef<HTMLElement | null>(el);
|
||||
|
||||
expect(unrefElement(elRef)).toBe(el);
|
||||
expect(unrefElement(shallowElRef)).toBe(el);
|
||||
});
|
||||
|
||||
it('returns element when passed a computed ref or getter function', () => {
|
||||
const el = document.createElement('div');
|
||||
const computedElRef = computed(() => el);
|
||||
const elGetter = () => el;
|
||||
|
||||
expect(unrefElement(computedElRef)).toBe(el);
|
||||
expect(unrefElement(elGetter)).toBe(el);
|
||||
});
|
||||
|
||||
it('returns component $el when passed a component instance', async () => {
|
||||
const Child = defineComponent({
|
||||
template: `<span class="child-el">child</span>`,
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
template: `<Child ref="childRef" />`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
await nextTick();
|
||||
|
||||
const childInstance = (wrapper.vm as any).$refs.childRef;
|
||||
const result = unrefElement(childInstance);
|
||||
|
||||
expect(result).toBe(childInstance.$el);
|
||||
expect((result as HTMLElement).classList.contains('child-el')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles null and undefined values', () => {
|
||||
expect(unrefElement(undefined)).toBe(undefined);
|
||||
expect(unrefElement(null)).toBe(null);
|
||||
expect(unrefElement(ref<null>(null))).toBe(null);
|
||||
expect(unrefElement(ref<undefined>(undefined))).toBe(undefined);
|
||||
expect(unrefElement(() => null)).toBe(null);
|
||||
expect(unrefElement(() => undefined)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
33
vue/toolkit/src/composables/component/unrefElement/index.ts
Normal file
33
vue/toolkit/src/composables/component/unrefElement/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter } from 'vue';
|
||||
import { toValue } from 'vue';
|
||||
|
||||
export type VueInstance = ComponentPublicInstance;
|
||||
export type MaybeElement = HTMLElement | SVGElement | VueInstance | undefined | null;
|
||||
|
||||
export type MaybeElementRef<El extends MaybeElement = MaybeElement> = MaybeRef<El>;
|
||||
export type MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;
|
||||
|
||||
export type UnRefElementReturn<T extends MaybeElement = MaybeElement> = T extends VueInstance ? Exclude<MaybeElement, VueInstance> : T | undefined;
|
||||
|
||||
/**
|
||||
* @name unrefElement
|
||||
* @category Component
|
||||
* @description Unwraps a Vue element reference to get the underlying instance or DOM element.
|
||||
*
|
||||
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.
|
||||
* @returns {UnRefElementReturn<El>} - The unwrapped element or undefined.
|
||||
*
|
||||
* @example
|
||||
* const element = useTemplateRef<HTMLElement>('element');
|
||||
* const result = unrefElement(element); // result is the element instance
|
||||
*
|
||||
* @example
|
||||
* const component = useTemplateRef<Component>('component');
|
||||
* const result = unrefElement(component); // result is the component instance
|
||||
*
|
||||
* @since 0.0.11
|
||||
*/
|
||||
export function unrefElement<El extends MaybeElement>(elRef: MaybeComputedElementRef<El>): UnRefElementReturn<El> {
|
||||
const plain = toValue(elRef);
|
||||
return (plain as VueInstance)?.$el ?? plain;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { UseForwardExposeReturn } from '.';
|
||||
import { defineComponent, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useForwardExpose } from '.';
|
||||
|
||||
describe(useForwardExpose, () => {
|
||||
it('returns forwardRef, currentRef, and currentElement', () => {
|
||||
let result!: UseForwardExposeReturn<any>;
|
||||
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
result = useForwardExpose();
|
||||
return {};
|
||||
},
|
||||
template: `<div>test</div>`,
|
||||
});
|
||||
|
||||
mount(Component);
|
||||
|
||||
expect(result.forwardRef).toBeTypeOf('function');
|
||||
expect(result.currentRef).toBeDefined();
|
||||
expect(result.currentElement).toBeDefined();
|
||||
});
|
||||
|
||||
it('exposes parent props on instance.exposed', () => {
|
||||
const Component = defineComponent({
|
||||
props: {
|
||||
label: { type: String, default: 'hello' },
|
||||
},
|
||||
setup() {
|
||||
useForwardExpose();
|
||||
return {};
|
||||
},
|
||||
template: `<div>{{ label }}</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component, { props: { label: 'world' } });
|
||||
|
||||
expect(wrapper.vm.$.exposed).toBeDefined();
|
||||
expect(wrapper.vm.$.exposed!.label).toBe('world');
|
||||
});
|
||||
|
||||
it('exposes $el on instance.exposed', () => {
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
useForwardExpose();
|
||||
return {};
|
||||
},
|
||||
template: `<div class="root">content</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
|
||||
expect(wrapper.vm.$.exposed).toBeDefined();
|
||||
expect(wrapper.vm.$.exposed!.$el).toBeInstanceOf(HTMLDivElement);
|
||||
expect(wrapper.vm.$.exposed!.$el.classList.contains('root')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('forwardRef with a DOM element updates $el', async () => {
|
||||
let result!: UseForwardExposeReturn<any>;
|
||||
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
result = useForwardExpose();
|
||||
return {};
|
||||
},
|
||||
template: `<div><span class="inner">inner</span></div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
const innerSpan = wrapper.find('.inner').element;
|
||||
|
||||
result.forwardRef(innerSpan as Element);
|
||||
await nextTick();
|
||||
|
||||
expect(result.currentRef.value).toBe(innerSpan);
|
||||
expect(wrapper.vm.$.exposed!.$el).toBe(innerSpan);
|
||||
});
|
||||
|
||||
it('forwardRef with a child component instance copies child exposed', async () => {
|
||||
const Child = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
const childValue = ref('from-child');
|
||||
expose({ childValue });
|
||||
return { childValue };
|
||||
},
|
||||
template: `<span class="child">child</span>`,
|
||||
});
|
||||
|
||||
let result!: UseForwardExposeReturn<any>;
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
setup() {
|
||||
result = useForwardExpose();
|
||||
return { forwardRef: result.forwardRef };
|
||||
},
|
||||
template: `<Child :ref="forwardRef" />`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
await nextTick();
|
||||
|
||||
// The parent's exposed should contain the child's exposed ref
|
||||
expect(wrapper.vm.$.exposed).toBeDefined();
|
||||
expect(wrapper.vm.$.exposed!.childValue).toEqual(ref('from-child'));
|
||||
});
|
||||
|
||||
it('forwardRef with null clears currentRef without error', async () => {
|
||||
let result!: UseForwardExposeReturn<any>;
|
||||
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
result = useForwardExpose();
|
||||
return {};
|
||||
},
|
||||
template: `<div>test</div>`,
|
||||
});
|
||||
|
||||
mount(Component);
|
||||
|
||||
expect(() => result.forwardRef(null)).not.toThrow();
|
||||
expect(result.currentRef.value).toBeNull();
|
||||
});
|
||||
|
||||
it('merges prior expose bindings', () => {
|
||||
const Component = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
const custom = ref(42);
|
||||
expose({ custom });
|
||||
useForwardExpose();
|
||||
return {};
|
||||
},
|
||||
template: `<div>test</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
|
||||
expect(wrapper.vm.$.exposed).toBeDefined();
|
||||
expect(wrapper.vm.$.exposed!.custom).toEqual(ref(42));
|
||||
expect(wrapper.vm.$.exposed!.$el).toBeDefined();
|
||||
});
|
||||
|
||||
it('currentElement resolves to HTMLElement for element ref', async () => {
|
||||
let result!: UseForwardExposeReturn<any>;
|
||||
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
result = useForwardExpose();
|
||||
return {};
|
||||
},
|
||||
template: `<div class="resolved">content</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
const el = wrapper.find('.resolved').element;
|
||||
|
||||
result.forwardRef(el as Element);
|
||||
await nextTick();
|
||||
|
||||
expect(result.currentElement.value).toBe(el);
|
||||
});
|
||||
|
||||
it('switching child components updates exposed correctly', async () => {
|
||||
const ChildA = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
const a = ref('value-a');
|
||||
expose({ a });
|
||||
return { a };
|
||||
},
|
||||
template: `<span class="a">A</span>`,
|
||||
});
|
||||
|
||||
const ChildB = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
const b = ref('value-b');
|
||||
expose({ b });
|
||||
return { b };
|
||||
},
|
||||
template: `<span class="b">B</span>`,
|
||||
});
|
||||
|
||||
let result!: UseForwardExposeReturn<any>;
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { ChildA, ChildB },
|
||||
setup() {
|
||||
const showA = ref(true);
|
||||
result = useForwardExpose();
|
||||
return { showA, forwardRef: result.forwardRef };
|
||||
},
|
||||
template: `
|
||||
<ChildA v-if="showA" :ref="forwardRef" />
|
||||
<ChildB v-else :ref="forwardRef" />
|
||||
`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
await nextTick();
|
||||
|
||||
// Initially ChildA is rendered
|
||||
expect(wrapper.vm.$.exposed!.a).toEqual(ref('value-a'));
|
||||
|
||||
// Switch to ChildB
|
||||
wrapper.vm.showA = false;
|
||||
await nextTick();
|
||||
|
||||
// ChildB's exposed should be available
|
||||
expect(wrapper.vm.$.exposed!.b).toEqual(ref('value-b'));
|
||||
});
|
||||
|
||||
it('$el remains correct after switching from component to element', async () => {
|
||||
const Child = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({ test: ref(1) });
|
||||
return {};
|
||||
},
|
||||
template: `<span class="child-el">child</span>`,
|
||||
});
|
||||
|
||||
let result!: UseForwardExposeReturn<any>;
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
setup() {
|
||||
result = useForwardExpose();
|
||||
return { forwardRef: result.forwardRef };
|
||||
},
|
||||
template: `<Child :ref="forwardRef" />`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.$.exposed!.test).toEqual(ref(1));
|
||||
|
||||
// Now forward to a plain DOM element — $el must update on instance.exposed
|
||||
const div = document.createElement('div');
|
||||
result.forwardRef(div);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.$.exposed!.$el).toBe(div);
|
||||
});
|
||||
|
||||
it('parent props remain accessible after child forwarding', async () => {
|
||||
const Child = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({ childProp: ref('child') });
|
||||
return {};
|
||||
},
|
||||
template: `<span>child</span>`,
|
||||
});
|
||||
|
||||
let result!: UseForwardExposeReturn<any>;
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
props: {
|
||||
parentLabel: { type: String, default: 'parent' },
|
||||
},
|
||||
setup() {
|
||||
result = useForwardExpose();
|
||||
return { forwardRef: result.forwardRef };
|
||||
},
|
||||
template: `<Child :ref="forwardRef" />`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Parent, { props: { parentLabel: 'test' } });
|
||||
await nextTick();
|
||||
|
||||
// Both parent props and child exposed should be accessible
|
||||
expect(wrapper.vm.$.exposed!.parentLabel).toBe('test');
|
||||
expect(wrapper.vm.$.exposed!.childProp).toEqual(ref('child'));
|
||||
});
|
||||
});
|
||||
128
vue/toolkit/src/composables/component/useForwardExpose/index.ts
Normal file
128
vue/toolkit/src/composables/component/useForwardExpose/index.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { ComponentPublicInstance, Ref } from 'vue';
|
||||
import { computed, getCurrentInstance, shallowRef } from 'vue';
|
||||
import type { MaybeElement } from '../unrefElement';
|
||||
import { unrefElement } from '../unrefElement';
|
||||
|
||||
/** Set of non-element node names that should be skipped when resolving `$el` */
|
||||
const NON_ELEMENT_NODES = new Set(['#text', '#comment']);
|
||||
|
||||
export interface UseForwardExposeReturn<T extends ComponentPublicInstance> {
|
||||
/** Callback to set as `:ref` — forwards child's exposed API and `$el` through the parent */
|
||||
forwardRef: (ref: T | MaybeElement) => void;
|
||||
/** Reactive reference to the forwarded element or component instance */
|
||||
currentRef: Ref<T | MaybeElement>;
|
||||
/** Computed property resolving to the underlying `HTMLElement`, skipping text/comment nodes */
|
||||
currentElement: Readonly<Ref<HTMLElement | undefined>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useForwardExpose
|
||||
* @category Component
|
||||
* @description Forwards a child component's exposed API and DOM element (`$el`) through
|
||||
* the parent component. Useful for wrapper / headless components that need to transparently
|
||||
* proxy the inner component's ref to the consumer.
|
||||
*
|
||||
* Merges the parent's own props and any prior `expose()` bindings onto `instance.exposed`,
|
||||
* then updates them when `forwardRef` is called with a child element or component instance.
|
||||
*
|
||||
* @returns {UseForwardExposeReturn<T>} An object with `forwardRef`, `currentRef`, and `currentElement`
|
||||
*
|
||||
* @example
|
||||
* const { forwardRef, currentElement } = useForwardExpose();
|
||||
* // Template: <ChildComponent :ref="forwardRef" />
|
||||
*
|
||||
* @example
|
||||
* const { forwardRef, currentRef } = useForwardExpose<InstanceType<typeof MyInput>>();
|
||||
* // Template: <MyInput :ref="forwardRef" />
|
||||
* // currentRef.value exposes MyInput's public API
|
||||
*
|
||||
* @since 0.0.14
|
||||
*/
|
||||
export function useForwardExpose<T extends ComponentPublicInstance>(): UseForwardExposeReturn<T> {
|
||||
const instance = getCurrentInstance()!;
|
||||
|
||||
const currentRef = shallowRef<T | MaybeElement>();
|
||||
const currentElement = computed<HTMLElement | undefined>(() => {
|
||||
// @ts-expect-error — $el exists on component instances but not on HTMLElement/SVGElement
|
||||
const el = currentRef.value?.$el;
|
||||
|
||||
return NON_ELEMENT_NODES.has(el?.nodeName)
|
||||
? (el.nextElementSibling as HTMLElement | undefined) ?? undefined
|
||||
: (unrefElement(currentRef) as HTMLElement | undefined);
|
||||
});
|
||||
|
||||
// localExpose should only be assigned once else will create infinite loop
|
||||
const localExpose = instance.exposed;
|
||||
const ret: Record<string, any> = {};
|
||||
|
||||
// Collect all property descriptors in a single pass
|
||||
const descriptors: PropertyDescriptorMap = {};
|
||||
|
||||
for (const key in instance.props) {
|
||||
descriptors[key] = {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => instance.props[key],
|
||||
};
|
||||
}
|
||||
|
||||
if (localExpose && Object.keys(localExpose).length > 0) {
|
||||
for (const key in localExpose) {
|
||||
descriptors[key] = {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => localExpose[key],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
descriptors['$el'] = {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => instance.vnode.el,
|
||||
};
|
||||
|
||||
Object.defineProperties(ret, descriptors);
|
||||
instance.exposed = ret;
|
||||
|
||||
function forwardRef(ref: T | MaybeElement) {
|
||||
currentRef.value = ref;
|
||||
if (!ref) return;
|
||||
|
||||
const $elDescriptor: PropertyDescriptor = {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (ref instanceof Element ? ref : ref.$el),
|
||||
};
|
||||
|
||||
// Keep ret in sync — it's the source of descriptors for future rebuilds
|
||||
Object.defineProperty(ret, '$el', $elDescriptor);
|
||||
|
||||
// Also update current instance.exposed if it has diverged from ret
|
||||
if (instance.exposed && instance.exposed !== ret) {
|
||||
Object.defineProperty(instance.exposed, '$el', $elDescriptor);
|
||||
}
|
||||
|
||||
if (!(ref instanceof Element) && !Object.prototype.hasOwnProperty.call(ref, '$el')) {
|
||||
const childExposed = ref.$.exposed;
|
||||
|
||||
if (childExposed) {
|
||||
// Copy descriptors from ret (includes props, prior expose, $el)
|
||||
const allDescriptors = Object.getOwnPropertyDescriptors(ret);
|
||||
allDescriptors['$el'] = $elDescriptor;
|
||||
|
||||
for (const key in childExposed) {
|
||||
allDescriptors[key] = {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => childExposed[key],
|
||||
};
|
||||
}
|
||||
|
||||
instance.exposed = Object.defineProperties({}, allDescriptors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { forwardRef, currentRef, currentElement };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useTemplateRefsList } from '.';
|
||||
|
||||
describe(useTemplateRefsList, () => {
|
||||
it('collects elements rendered with v-for', async () => {
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
const items = ref([1, 2, 3]);
|
||||
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
|
||||
return { items, refs, set };
|
||||
},
|
||||
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.refs).toHaveLength(3);
|
||||
expect(wrapper.vm.refs[0]).toBeInstanceOf(HTMLDivElement);
|
||||
expect(wrapper.vm.refs[1]).toBeInstanceOf(HTMLDivElement);
|
||||
expect(wrapper.vm.refs[2]).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it('updates refs when items are added', async () => {
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
const items = ref([1, 2]);
|
||||
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
|
||||
return { items, refs, set };
|
||||
},
|
||||
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
await nextTick();
|
||||
expect(wrapper.vm.refs).toHaveLength(2);
|
||||
|
||||
wrapper.vm.items.push(3);
|
||||
await nextTick();
|
||||
expect(wrapper.vm.refs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('updates refs when items are removed', async () => {
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
const items = ref([1, 2, 3]);
|
||||
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
|
||||
return { items, refs, set };
|
||||
},
|
||||
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
await nextTick();
|
||||
expect(wrapper.vm.refs).toHaveLength(3);
|
||||
|
||||
wrapper.vm.items.splice(0, 1);
|
||||
await nextTick();
|
||||
expect(wrapper.vm.refs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns empty array when no elements are rendered', async () => {
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
const items = ref<number[]>([]);
|
||||
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
|
||||
return { items, refs, set };
|
||||
},
|
||||
template: `<div><span v-for="item in items" :key="item" :ref="set">{{ item }}</span></div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
await nextTick();
|
||||
expect(wrapper.vm.refs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unwraps component instances to their root elements', async () => {
|
||||
const Child = defineComponent({
|
||||
template: `<span class="child">child</span>`,
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
setup() {
|
||||
const items = ref([1, 2]);
|
||||
const { refs, set } = useTemplateRefsList<HTMLSpanElement>();
|
||||
return { items, refs, set };
|
||||
},
|
||||
template: `<div><Child v-for="item in items" :key="item" :ref="set" /></div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.refs).toHaveLength(2);
|
||||
expect(wrapper.vm.refs[0]).toBeInstanceOf(HTMLSpanElement);
|
||||
expect(wrapper.vm.refs[0]!.classList.contains('child')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves element order matching v-for order', async () => {
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
const items = ref(['a', 'b', 'c']);
|
||||
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
|
||||
return { items, refs, set };
|
||||
},
|
||||
template: `<div v-for="item in items" :key="item" :ref="set" :data-item="item">{{ item }}</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.refs[0]!.dataset.item).toBe('a');
|
||||
expect(wrapper.vm.refs[1]!.dataset.item).toBe('b');
|
||||
expect(wrapper.vm.refs[2]!.dataset.item).toBe('c');
|
||||
});
|
||||
|
||||
it('handles complete list replacement', async () => {
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
const items = ref([1, 2, 3]);
|
||||
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
|
||||
return { items, refs, set };
|
||||
},
|
||||
template: `<div v-for="item in items" :key="item" :ref="set" :data-item="item">{{ item }}</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
await nextTick();
|
||||
expect(wrapper.vm.refs).toHaveLength(3);
|
||||
|
||||
wrapper.vm.items = [4, 5];
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.refs).toHaveLength(2);
|
||||
expect(wrapper.vm.refs[0]!.dataset.item).toBe('4');
|
||||
expect(wrapper.vm.refs[1]!.dataset.item).toBe('5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { onBeforeUpdate, onMounted, onUpdated, readonly, shallowRef } from 'vue';
|
||||
import type { DeepReadonly, ShallowRef } from 'vue';
|
||||
import type { MaybeElement } from '../unrefElement';
|
||||
import { unrefElement } from '../unrefElement';
|
||||
|
||||
export interface UseTemplateRefsListReturn<El extends Element> {
|
||||
/** Reactive readonly array of collected template refs */
|
||||
refs: DeepReadonly<ShallowRef<El[]>>;
|
||||
/** Ref setter function — bind via `:ref="set"` in templates */
|
||||
set: (el: MaybeElement) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useTemplateRefsList
|
||||
* @category Component
|
||||
* @description Collects a dynamic list of template refs for use with `v-for`.
|
||||
* Automatically clears the list before each component update and repopulates it
|
||||
* with fresh element references. Handles both plain DOM elements and Vue component
|
||||
* instances (unwraps `$el`).
|
||||
*
|
||||
* Uses a non-reactive buffer internally to collect refs during the render cycle,
|
||||
* then flushes to a `shallowRef` in `onMounted`/`onUpdated` to avoid triggering
|
||||
* recursive update loops.
|
||||
*
|
||||
* @returns {UseTemplateRefsListReturn<El>} An object with a reactive `refs` array and a `set` function
|
||||
*
|
||||
* @example
|
||||
* const { refs, set } = useTemplateRefsList<HTMLDivElement>();
|
||||
* // Template: <div v-for="item in items" :key="item.id" :ref="set" />
|
||||
* // refs.value contains all rendered div elements
|
||||
*
|
||||
* @since 0.0.14
|
||||
*/
|
||||
export function useTemplateRefsList<El extends Element = Element>(): UseTemplateRefsListReturn<El> {
|
||||
const refs = shallowRef<El[]>([]);
|
||||
let buffer: El[] = [];
|
||||
|
||||
const set = (el: MaybeElement) => {
|
||||
const plain = unrefElement(el);
|
||||
|
||||
if (plain)
|
||||
buffer.push(plain as unknown as El);
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
buffer.sort(documentPositionComparator);
|
||||
refs.value = buffer;
|
||||
};
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
buffer = [];
|
||||
});
|
||||
|
||||
onMounted(flush);
|
||||
onUpdated(flush);
|
||||
|
||||
return {
|
||||
refs: readonly(refs) as DeepReadonly<ShallowRef<El[]>>,
|
||||
set,
|
||||
};
|
||||
}
|
||||
|
||||
function documentPositionComparator(a: Element, b: Element): number {
|
||||
if (a === b) return 0;
|
||||
|
||||
const position = a.compareDocumentPosition(b);
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
|
||||
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
2
vue/toolkit/src/composables/debug/index.ts
Normal file
2
vue/toolkit/src/composables/debug/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useRenderCount';
|
||||
export * from './useRenderInfo';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useRenderCount } from '.';
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
setup() {
|
||||
const count = useRenderCount();
|
||||
const visibleCount = ref(0);
|
||||
const hiddenCount = ref(0);
|
||||
|
||||
return { count, visibleCount, hiddenCount };
|
||||
},
|
||||
template: `<div>{{ visibleCount }}</div>`,
|
||||
});
|
||||
|
||||
describe(useRenderCount, () => {
|
||||
it('return the number of times the component has been rendered', async () => {
|
||||
const component = mount(ComponentStub);
|
||||
|
||||
// Initial render
|
||||
expect(component.vm.count).toBe(1);
|
||||
|
||||
component.vm.hiddenCount = 1;
|
||||
await nextTick();
|
||||
|
||||
// Will not trigger a render
|
||||
expect(component.vm.count).toBe(1);
|
||||
expect(component.text()).toBe('0');
|
||||
|
||||
component.vm.visibleCount++;
|
||||
await nextTick();
|
||||
|
||||
// Will trigger a render
|
||||
expect(component.vm.count).toBe(2);
|
||||
expect(component.text()).toBe('1');
|
||||
|
||||
component.vm.visibleCount++;
|
||||
component.vm.visibleCount++;
|
||||
await nextTick();
|
||||
|
||||
// Will trigger a single render for both updates
|
||||
expect(component.vm.count).toBe(3);
|
||||
expect(component.text()).toBe('3');
|
||||
});
|
||||
|
||||
it('can be used with a specific component instance', async () => {
|
||||
const component = mount(ComponentStub);
|
||||
const instance = component.vm.$;
|
||||
|
||||
const count = useRenderCount(instance);
|
||||
|
||||
// Initial render (should be zero because the component has already rendered on mount)
|
||||
expect(count.value).toBe(0);
|
||||
|
||||
component.vm.hiddenCount = 1;
|
||||
await nextTick();
|
||||
|
||||
// Will not trigger a render
|
||||
expect(count.value).toBe(0);
|
||||
|
||||
component.vm.visibleCount++;
|
||||
await nextTick();
|
||||
|
||||
// Will trigger a render
|
||||
expect(count.value).toBe(1);
|
||||
|
||||
component.vm.visibleCount++;
|
||||
component.vm.visibleCount++;
|
||||
await nextTick();
|
||||
|
||||
// Will trigger a single render for both updates
|
||||
expect(count.value).toBe(2);
|
||||
});
|
||||
});
|
||||
30
vue/toolkit/src/composables/debug/useRenderCount/index.ts
Normal file
30
vue/toolkit/src/composables/debug/useRenderCount/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { onMounted, onUpdated, readonly } from 'vue';
|
||||
import type { ComponentInternalInstance } from 'vue';
|
||||
import { useCounter } from '@/composables/state/useCounter';
|
||||
import { getLifeCycleTarger } from '@/utils';
|
||||
|
||||
/**
|
||||
* @name useRenderCount
|
||||
* @category Component
|
||||
* @description Returns the number of times the component has been rendered into the DOM
|
||||
*
|
||||
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for
|
||||
* @returns {Readonly<Ref<number>>} The number of times the component has been rendered
|
||||
*
|
||||
* @example
|
||||
* const count = useRenderCount();
|
||||
*
|
||||
* @example
|
||||
* const count = useRenderCount(getCurrentInstance());
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useRenderCount(instance?: ComponentInternalInstance) {
|
||||
const { count, increment } = useCounter(0);
|
||||
const target = getLifeCycleTarger(instance);
|
||||
|
||||
onMounted(increment, target);
|
||||
onUpdated(increment, target);
|
||||
|
||||
return readonly(count);
|
||||
}
|
||||
100
vue/toolkit/src/composables/debug/useRenderInfo/index.test.ts
Normal file
100
vue/toolkit/src/composables/debug/useRenderInfo/index.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useRenderInfo } from '.';
|
||||
import { defineComponent, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const NamedComponentStub = defineComponent({
|
||||
name: 'ComponentStub',
|
||||
setup() {
|
||||
const info = useRenderInfo();
|
||||
const visibleCount = ref(0);
|
||||
const hiddenCount = ref(0);
|
||||
|
||||
return { info, visibleCount, hiddenCount };
|
||||
},
|
||||
template: `<div>{{ visibleCount }}</div>`,
|
||||
});
|
||||
|
||||
const UnnamedComponentStub = defineComponent({
|
||||
setup() {
|
||||
const info = useRenderInfo();
|
||||
const visibleCount = ref(0);
|
||||
const hiddenCount = ref(0);
|
||||
|
||||
return { info, visibleCount, hiddenCount };
|
||||
},
|
||||
template: `<div>{{ visibleCount }}</div>`,
|
||||
});
|
||||
|
||||
describe(useRenderInfo, () => {
|
||||
it('return uid if component name is not available', async () => {
|
||||
const wrapper = mount(UnnamedComponentStub);
|
||||
|
||||
expect(wrapper.vm.info.component).toBe(wrapper.vm.$.uid);
|
||||
});
|
||||
|
||||
it('return render info for the given instance', async () => {
|
||||
const wrapper = mount(NamedComponentStub);
|
||||
|
||||
// Initial render
|
||||
expect(wrapper.vm.info.component).toBe('ComponentStub');
|
||||
expect(wrapper.vm.info.count.value).toBe(1);
|
||||
expect(wrapper.vm.info.duration.value).toBeGreaterThan(0);
|
||||
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
|
||||
|
||||
const lastRendered = wrapper.vm.info.lastRendered;
|
||||
const duration = wrapper.vm.info.duration.value;
|
||||
|
||||
// Will not trigger a render
|
||||
wrapper.vm.hiddenCount++;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.info.component).toBe('ComponentStub');
|
||||
expect(wrapper.vm.info.count.value).toBe(1);
|
||||
expect(wrapper.vm.info.duration.value).toBe(duration);
|
||||
expect(wrapper.vm.info.lastRendered).toBe(lastRendered);
|
||||
|
||||
// Will trigger a render
|
||||
wrapper.vm.visibleCount++;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.info.component).toBe('ComponentStub');
|
||||
expect(wrapper.vm.info.count.value).toBe(2);
|
||||
expect(wrapper.vm.info.duration.value).not.toBe(duration);
|
||||
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('can be used with a specific component instance', async () => {
|
||||
const wrapper = mount(NamedComponentStub);
|
||||
const instance = wrapper.vm.$;
|
||||
|
||||
const info = useRenderInfo(instance);
|
||||
|
||||
// Initial render (should be zero because the component has already rendered on mount)
|
||||
expect(info.component).toBe('ComponentStub');
|
||||
expect(info.count.value).toBe(0);
|
||||
expect(info.duration.value).toBe(0);
|
||||
expect(info.lastRendered).toBeGreaterThan(0);
|
||||
|
||||
const lastRendered = info.lastRendered;
|
||||
const duration = info.duration.value;
|
||||
|
||||
// Will not trigger a render
|
||||
wrapper.vm.hiddenCount++;
|
||||
await nextTick();
|
||||
|
||||
expect(info.component).toBe('ComponentStub');
|
||||
expect(info.count.value).toBe(0);
|
||||
expect(info.duration.value).toBe(duration);
|
||||
expect(info.lastRendered).toBe(lastRendered);
|
||||
|
||||
// Will trigger a render
|
||||
wrapper.vm.visibleCount++;
|
||||
await nextTick();
|
||||
|
||||
expect(info.component).toBe('ComponentStub');
|
||||
expect(info.count.value).toBe(1);
|
||||
expect(info.duration.value).not.toBe(duration);
|
||||
expect(info.lastRendered).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
48
vue/toolkit/src/composables/debug/useRenderInfo/index.ts
Normal file
48
vue/toolkit/src/composables/debug/useRenderInfo/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref } from 'vue';
|
||||
import type { ComponentInternalInstance } from 'vue';
|
||||
import { useRenderCount } from '../useRenderCount';
|
||||
import { getLifeCycleTarger } from '@/utils';
|
||||
|
||||
/**
|
||||
* @name useRenderInfo
|
||||
* @category Component
|
||||
* @description Returns information about the component's render count and the last time it was rendered
|
||||
*
|
||||
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const { component, count, duration, lastRendered } = useRenderInfo();
|
||||
*
|
||||
* @example
|
||||
* const { component, count, duration, lastRendered } = useRenderInfo(getCurrentInstance());
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useRenderInfo(instance?: ComponentInternalInstance) {
|
||||
const target = getLifeCycleTarger(instance);
|
||||
const duration = ref(0);
|
||||
let renderStartTime = 0;
|
||||
|
||||
const startMark = () => {
|
||||
renderStartTime = performance.now();
|
||||
};
|
||||
const endMark = () => {
|
||||
duration.value = Math.max(performance.now() - renderStartTime, 0);
|
||||
renderStartTime = 0;
|
||||
};
|
||||
|
||||
onBeforeMount(startMark, target);
|
||||
onMounted(endMark, target);
|
||||
|
||||
onBeforeUpdate(startMark, target);
|
||||
onUpdated(endMark, target);
|
||||
|
||||
return {
|
||||
component: target?.type.name ?? target?.uid,
|
||||
count: useRenderCount(instance),
|
||||
duration: readonly(duration),
|
||||
lastRendered: timestamp(),
|
||||
};
|
||||
}
|
||||
9
vue/toolkit/src/composables/index.ts
Normal file
9
vue/toolkit/src/composables/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './browser';
|
||||
export * from './component';
|
||||
export * from './debug';
|
||||
export * from './lifecycle';
|
||||
export * from './math';
|
||||
export * from './reactivity';
|
||||
export * from './state';
|
||||
export * from './storage';
|
||||
export * from './utilities';
|
||||
4
vue/toolkit/src/composables/lifecycle/index.ts
Normal file
4
vue/toolkit/src/composables/lifecycle/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './tryOnBeforeMount';
|
||||
export * from './tryOnMounted';
|
||||
export * from './tryOnScopeDispose';
|
||||
export * from './useMounted';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { onBeforeMount, nextTick } from 'vue';
|
||||
import type { ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '@/utils';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
// TODO: test
|
||||
|
||||
export interface TryOnBeforeMountOptions {
|
||||
sync?: boolean;
|
||||
target?: ComponentInternalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name tryOnBeforeMount
|
||||
* @category Lifecycle
|
||||
* @description Call onBeforeMount if it's inside a component lifecycle hook, otherwise just calls it
|
||||
*
|
||||
* @param {VoidFunction} fn - The function to run on before mount.
|
||||
* @param {TryOnBeforeMountOptions} options - The options for the function.
|
||||
* @param {boolean} [options.sync=true] - If true, the function will run synchronously, otherwise it will run asynchronously.
|
||||
* @param {ComponentInternalInstance} [options.target] - The target component instance to run the function on.
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* tryOnBeforeMount(() => console.log('Before mount'));
|
||||
*
|
||||
* @example
|
||||
* tryOnBeforeMount(() => console.log('Before mount async'), { sync: false });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function tryOnBeforeMount(fn: VoidFunction, options: TryOnBeforeMountOptions = {}) {
|
||||
const {
|
||||
sync = true,
|
||||
target,
|
||||
} = options;
|
||||
|
||||
const instance = getLifeCycleTarger(target);
|
||||
|
||||
if (instance)
|
||||
onBeforeMount(fn, instance);
|
||||
else if (sync)
|
||||
fn();
|
||||
else
|
||||
nextTick(fn);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, vi, expect } from 'vitest';
|
||||
import { defineComponent, nextTick } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { tryOnMounted } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function as PropType<VoidFunction>,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
if (props.callback) {
|
||||
tryOnMounted(props.callback);
|
||||
}
|
||||
},
|
||||
template: `<div></div>`,
|
||||
});
|
||||
|
||||
describe(tryOnMounted, () => {
|
||||
it('run the callback when mounted', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('run the callback outside of a component lifecycle', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
tryOnMounted(callback);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('run the callback asynchronously', async () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
tryOnMounted(callback, { sync: false });
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
await nextTick();
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip('run the callback with a specific target', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
const component = mount(ComponentStub);
|
||||
|
||||
tryOnMounted(callback, { target: component.vm.$ });
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
46
vue/toolkit/src/composables/lifecycle/tryOnMounted/index.ts
Normal file
46
vue/toolkit/src/composables/lifecycle/tryOnMounted/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { onMounted, nextTick } from 'vue';
|
||||
import type { ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '@/utils';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
// TODO: tests
|
||||
|
||||
export interface TryOnMountedOptions {
|
||||
sync?: boolean;
|
||||
target?: ComponentInternalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name tryOnMounted
|
||||
* @category Lifecycle
|
||||
* @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it
|
||||
*
|
||||
* @param {VoidFunction} fn The function to call
|
||||
* @param {TryOnMountedOptions} options The options to use
|
||||
* @param {boolean} [options.sync=true] If the function should be called synchronously
|
||||
* @param {ComponentInternalInstance} [options.target] The target instance to use
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* tryOnMounted(() => console.log('Mounted!'));
|
||||
*
|
||||
* @example
|
||||
* tryOnMounted(() => console.log('Mounted!'), { sync: false });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function tryOnMounted(fn: VoidFunction, options: TryOnMountedOptions = {}) {
|
||||
const {
|
||||
sync = true,
|
||||
target,
|
||||
} = options;
|
||||
|
||||
const instance = getLifeCycleTarger(target);
|
||||
|
||||
if (instance)
|
||||
onMounted(fn, instance);
|
||||
else if (sync)
|
||||
fn();
|
||||
else
|
||||
nextTick(fn);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, effectScope } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { tryOnScopeDispose } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function as PropType<VoidFunction>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
tryOnScopeDispose(props.callback);
|
||||
},
|
||||
template: '<div></div>',
|
||||
});
|
||||
|
||||
describe(tryOnScopeDispose, () => {
|
||||
it('returns false when the scope is not active', () => {
|
||||
const callback = vi.fn();
|
||||
const detectedScope = tryOnScopeDispose(callback);
|
||||
|
||||
expect(detectedScope).toBeFalsy();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('run the callback when the scope is disposed', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
let detectedScope: boolean | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
detectedScope = tryOnScopeDispose(callback);
|
||||
});
|
||||
|
||||
expect(detectedScope).toBeTruthy();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('run callback when the component is unmounted', () => {
|
||||
const callback = vi.fn();
|
||||
const component = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
component.unmount();
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { getCurrentScope, onScopeDispose } from 'vue';
|
||||
|
||||
/**
|
||||
* @name tryOnScopeDispose
|
||||
* @category Lifecycle
|
||||
* @description A composable that will run a callback when the scope is disposed or do nothing if the scope isn't available.
|
||||
*
|
||||
* @param {VoidFunction} callback - The callback to run when the scope is disposed.
|
||||
* @returns {boolean} - Returns true if the callback was run, otherwise false.
|
||||
*
|
||||
* @example
|
||||
* tryOnScopeDispose(() => console.log('Scope disposed'));
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function tryOnScopeDispose(callback: VoidFunction) {
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(callback);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useMounted } from '.';
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
setup() {
|
||||
const isMounted = useMounted();
|
||||
|
||||
return { isMounted };
|
||||
},
|
||||
template: `<div>{{ isMounted }}</div>`,
|
||||
});
|
||||
|
||||
describe(useMounted, () => {
|
||||
it('return the mounted state of the component', async () => {
|
||||
const component = mount(ComponentStub);
|
||||
|
||||
// Initial render
|
||||
expect(component.text()).toBe('false');
|
||||
|
||||
await nextTick();
|
||||
|
||||
// Will trigger a render
|
||||
expect(component.text()).toBe('true');
|
||||
});
|
||||
});
|
||||
30
vue/toolkit/src/composables/lifecycle/useMounted/index.ts
Normal file
30
vue/toolkit/src/composables/lifecycle/useMounted/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { onMounted, readonly, ref } from 'vue';
|
||||
import type { ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '@/utils';
|
||||
|
||||
/**
|
||||
* @name useMounted
|
||||
* @category Lifecycle
|
||||
* @description Returns a ref that tracks the mounted state of the component (doesn't track the unmounted state)
|
||||
*
|
||||
* @param {ComponentInternalInstance} [instance] The component instance to track the mounted state for
|
||||
* @returns {Readonly<Ref<boolean>>} The mounted state of the component
|
||||
*
|
||||
* @example
|
||||
* const isMounted = useMounted();
|
||||
*
|
||||
* @example
|
||||
* const isMounted = useMounted(getCurrentInstance());
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useMounted(instance?: ComponentInternalInstance) {
|
||||
const isMounted = ref(false);
|
||||
const targetInstance = getLifeCycleTarger(instance);
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
}, targetInstance);
|
||||
|
||||
return readonly(isMounted);
|
||||
}
|
||||
1
vue/toolkit/src/composables/math/index.ts
Normal file
1
vue/toolkit/src/composables/math/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './useClamp';
|
||||
60
vue/toolkit/src/composables/math/useClamp/index.test.ts
Normal file
60
vue/toolkit/src/composables/math/useClamp/index.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ref, readonly, computed } from 'vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useClamp } from '.';
|
||||
|
||||
describe(useClamp, () => {
|
||||
it('non-reactive values should be clamped', () => {
|
||||
const clampedValue = useClamp(10, 0, 5);
|
||||
|
||||
expect(clampedValue.value).toBe(5);
|
||||
});
|
||||
|
||||
it('clamp the value within the given range', () => {
|
||||
const value = ref(10);
|
||||
const clampedValue = useClamp(value, 0, 5);
|
||||
|
||||
expect(clampedValue.value).toBe(5);
|
||||
});
|
||||
|
||||
it('clamp the value within the given range using functions', () => {
|
||||
const value = ref(10);
|
||||
const clampedValue = useClamp(value, () => 0, () => 5);
|
||||
|
||||
expect(clampedValue.value).toBe(5);
|
||||
});
|
||||
|
||||
it('clamp readonly values', () => {
|
||||
const computedValue = computed(() => 10);
|
||||
const readonlyValue = readonly(ref(10));
|
||||
const clampedValue1 = useClamp(computedValue, 0, 5);
|
||||
const clampedValue2 = useClamp(readonlyValue, 0, 5);
|
||||
|
||||
expect(clampedValue1.value).toBe(5);
|
||||
expect(clampedValue2.value).toBe(5);
|
||||
});
|
||||
|
||||
it('update the clamped value when the original value changes', () => {
|
||||
const value = ref(10);
|
||||
const clampedValue = useClamp(value, 0, 5);
|
||||
value.value = 3;
|
||||
|
||||
expect(clampedValue.value).toBe(3);
|
||||
});
|
||||
|
||||
it('update the clamped value when the min or max changes', () => {
|
||||
const value = ref(10);
|
||||
const min = ref(0);
|
||||
const max = ref(5);
|
||||
const clampedValue = useClamp(value, min, max);
|
||||
|
||||
expect(clampedValue.value).toBe(5);
|
||||
|
||||
max.value = 15;
|
||||
|
||||
expect(clampedValue.value).toBe(10);
|
||||
|
||||
min.value = 11;
|
||||
|
||||
expect(clampedValue.value).toBe(11);
|
||||
});
|
||||
});
|
||||
40
vue/toolkit/src/composables/math/useClamp/index.ts
Normal file
40
vue/toolkit/src/composables/math/useClamp/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { clamp, isFunction } from '@robonen/stdlib';
|
||||
import { computed, isReadonly, ref, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, WritableComputedRef } from 'vue';
|
||||
|
||||
/**
|
||||
* @name useClamp
|
||||
* @category Math
|
||||
* @description Clamps a value between a minimum and maximum value
|
||||
*
|
||||
* @param {MaybeRefOrGetter<number>} value The value to clamp
|
||||
* @param {MaybeRefOrGetter<number>} min The minimum value
|
||||
* @param {MaybeRefOrGetter<number>} max The maximum value
|
||||
* @returns {ComputedRef<number>} The clamped value
|
||||
*
|
||||
* @example
|
||||
* const value = ref(10);
|
||||
* const clampedValue = useClamp(value, 0, 5);
|
||||
*
|
||||
* @example
|
||||
* const value = ref(10);
|
||||
* const clampedValue = useClamp(value, () => 0, () => 5);
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useClamp(value: MaybeRef<number>, min: MaybeRefOrGetter<number>, max: MaybeRefOrGetter<number>): WritableComputedRef<number>;
|
||||
export function useClamp(value: MaybeRefOrGetter<number>, min: MaybeRefOrGetter<number>, max: MaybeRefOrGetter<number>): ComputedRef<number> {
|
||||
if (isFunction(value) || isReadonly(value))
|
||||
return computed(() => clamp(toValue(value), toValue(min), toValue(max)));
|
||||
|
||||
const _value = ref(value);
|
||||
|
||||
return computed<number>({
|
||||
get() {
|
||||
return clamp(_value.value, toValue(min), toValue(max));
|
||||
},
|
||||
set(newValue) {
|
||||
_value.value = clamp(newValue, toValue(min), toValue(max));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick, watch } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { broadcastedRef } from '.';
|
||||
|
||||
type MessageHandler = ((event: MessageEvent) => void) | null;
|
||||
|
||||
class MockBroadcastChannel {
|
||||
static instances: MockBroadcastChannel[] = [];
|
||||
|
||||
name: string;
|
||||
onmessage: MessageHandler = null;
|
||||
closed = false;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
MockBroadcastChannel.instances.push(this);
|
||||
}
|
||||
|
||||
postMessage(data: unknown) {
|
||||
if (this.closed) return;
|
||||
|
||||
for (const instance of MockBroadcastChannel.instances) {
|
||||
if (instance !== this && instance.name === this.name && !instance.closed && instance.onmessage) {
|
||||
instance.onmessage(new MessageEvent('message', { data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
const index = MockBroadcastChannel.instances.indexOf(this);
|
||||
if (index > -1) MockBroadcastChannel.instances.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const mountWithRef = (setup: () => Record<string, any> | void) => {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup,
|
||||
template: '<div></div>',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe(broadcastedRef, () => {
|
||||
let component: ReturnType<typeof mountWithRef>;
|
||||
|
||||
beforeEach(() => {
|
||||
MockBroadcastChannel.instances = [];
|
||||
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
component?.unmount();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('create a ref with the initial value', () => {
|
||||
component = mountWithRef(() => {
|
||||
const count = broadcastedRef('test-key', 42);
|
||||
expect(count.value).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcast value changes to other channels with the same key', () => {
|
||||
const ref1 = broadcastedRef('shared', 0);
|
||||
const ref2 = broadcastedRef('shared', 0);
|
||||
|
||||
ref1.value = 100;
|
||||
|
||||
expect(ref2.value).toBe(100);
|
||||
});
|
||||
|
||||
it('not broadcast to channels with a different key', () => {
|
||||
const ref1 = broadcastedRef('key-a', 0);
|
||||
const ref2 = broadcastedRef('key-b', 0);
|
||||
|
||||
ref1.value = 100;
|
||||
|
||||
expect(ref2.value).toBe(0);
|
||||
});
|
||||
|
||||
it('receive values from other channels and trigger reactivity', async () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
component = mountWithRef(() => {
|
||||
const data = broadcastedRef('reactive-test', 'initial');
|
||||
watch(data, callback, { flush: 'sync' });
|
||||
});
|
||||
|
||||
const sender = broadcastedRef('reactive-test', '');
|
||||
sender.value = 'updated';
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith('updated', 'initial', expect.anything());
|
||||
});
|
||||
|
||||
it('not broadcast initial value by default', () => {
|
||||
const ref1 = broadcastedRef('no-immediate', 'first');
|
||||
const ref2 = broadcastedRef('no-immediate', 'second');
|
||||
|
||||
expect(ref1.value).toBe('first');
|
||||
expect(ref2.value).toBe('second');
|
||||
});
|
||||
|
||||
it('broadcast initial value when immediate is true', () => {
|
||||
const ref1 = broadcastedRef('immediate-test', 'existing');
|
||||
broadcastedRef('immediate-test', 'new-value', { immediate: true });
|
||||
|
||||
expect(ref1.value).toBe('new-value');
|
||||
});
|
||||
|
||||
it('close channel on scope dispose', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
broadcastedRef('dispose-test', 0);
|
||||
});
|
||||
|
||||
expect(MockBroadcastChannel.instances).toHaveLength(1);
|
||||
|
||||
scope.stop();
|
||||
|
||||
expect(MockBroadcastChannel.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handle complex object values via structured clone', () => {
|
||||
const ref1 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
|
||||
const ref2 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
|
||||
|
||||
ref1.value = { status: 'paid', amount: 99.99 };
|
||||
|
||||
expect(ref2.value).toEqual({ status: 'paid', amount: 99.99 });
|
||||
});
|
||||
|
||||
it('fallback to a regular ref when BroadcastChannel is not available', () => {
|
||||
vi.stubGlobal('BroadcastChannel', undefined);
|
||||
|
||||
const data = broadcastedRef('fallback', 'value');
|
||||
|
||||
expect(data.value).toBe('value');
|
||||
|
||||
data.value = 'updated';
|
||||
expect(data.value).toBe('updated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { customRef, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface BroadcastedRefOptions {
|
||||
/**
|
||||
* Immediately broadcast the initial value to other tabs on creation
|
||||
* @default false
|
||||
*/
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name broadcastedRef
|
||||
* @category Reactivity
|
||||
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
|
||||
*
|
||||
* @param {string} key The channel key to use for broadcasting
|
||||
* @param {T} initialValue The initial value of the ref
|
||||
* @param {BroadcastedRefOptions} [options={}] Options
|
||||
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
|
||||
*
|
||||
* @example
|
||||
* const count = broadcastedRef('counter', 0);
|
||||
*
|
||||
* @example
|
||||
* const state = broadcastedRef('payment-status', { status: 'pending' });
|
||||
*
|
||||
* @since 0.0.13
|
||||
*/
|
||||
export function broadcastedRef<T>(key: string, initialValue: T, options: BroadcastedRefOptions = {}): Ref<T> {
|
||||
const { immediate = false } = options;
|
||||
|
||||
if (!defaultWindow || typeof BroadcastChannel === 'undefined') {
|
||||
return ref(initialValue) as Ref<T>;
|
||||
}
|
||||
|
||||
const channel = new BroadcastChannel(key);
|
||||
let value = initialValue;
|
||||
|
||||
const data = customRef<T>((track, trigger) => {
|
||||
channel.onmessage = (event: MessageEvent<T>) => {
|
||||
value = event.data;
|
||||
trigger();
|
||||
};
|
||||
|
||||
return {
|
||||
get() {
|
||||
track();
|
||||
return value;
|
||||
},
|
||||
set(newValue: T) {
|
||||
value = newValue;
|
||||
channel.postMessage(newValue);
|
||||
trigger();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (immediate) {
|
||||
channel.postMessage(initialValue);
|
||||
}
|
||||
|
||||
tryOnScopeDispose(() => channel.close());
|
||||
|
||||
return data;
|
||||
}
|
||||
4
vue/toolkit/src/composables/reactivity/index.ts
Normal file
4
vue/toolkit/src/composables/reactivity/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './broadcastedRef';
|
||||
export * from './useCached';
|
||||
export * from './useLastChanged';
|
||||
export * from './useSyncRefs';
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref, nextTick, reactive } from 'vue';
|
||||
import { useCached } from '.';
|
||||
|
||||
const arrayEquals = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
|
||||
describe(useCached, () => {
|
||||
it('default comparator', async () => {
|
||||
const externalValue = ref(0);
|
||||
const cachedValue = useCached(externalValue);
|
||||
|
||||
expect(cachedValue.value).toBe(0);
|
||||
|
||||
externalValue.value = 1;
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toBe(1);
|
||||
});
|
||||
|
||||
it('custom array comparator', async () => {
|
||||
const externalValue = ref([1]);
|
||||
const initialValue = externalValue.value;
|
||||
|
||||
const cachedValue = useCached(externalValue, arrayEquals);
|
||||
|
||||
expect(cachedValue.value).toEqual(initialValue);
|
||||
|
||||
externalValue.value = initialValue;
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toEqual(initialValue);
|
||||
|
||||
externalValue.value = [1];
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toEqual(initialValue);
|
||||
|
||||
externalValue.value = [2];
|
||||
await nextTick();
|
||||
expect(cachedValue.value).not.toEqual(initialValue);
|
||||
expect(cachedValue.value).toEqual([2]);
|
||||
});
|
||||
|
||||
it('getter source', async () => {
|
||||
const externalValue = reactive({ value: 0 });
|
||||
const cachedValue = useCached(() => externalValue.value);
|
||||
|
||||
expect(cachedValue.value).toBe(0);
|
||||
|
||||
externalValue.value = 1;
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toBe(1);
|
||||
});
|
||||
});
|
||||
39
vue/toolkit/src/composables/reactivity/useCached/index.ts
Normal file
39
vue/toolkit/src/composables/reactivity/useCached/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ref, watch, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue';
|
||||
|
||||
export type Comparator<Value> = (a: Value, b: Value) => boolean;
|
||||
|
||||
/**
|
||||
* @name useCached
|
||||
* @category Reactivity
|
||||
* @description Caches the value of an external ref and updates it only when the value changes
|
||||
*
|
||||
* @param {Ref<T>} externalValue Ref to cache
|
||||
* @param {Comparator<T>} comparator Comparator function to compare the values
|
||||
* @param {WatchOptions} watchOptions Watch options
|
||||
* @returns {Ref<T>} Cached ref
|
||||
*
|
||||
* @example
|
||||
* const externalValue = ref(0);
|
||||
* const cachedValue = useCached(externalValue);
|
||||
*
|
||||
* @example
|
||||
* const externalValue = ref(0);
|
||||
* const cachedValue = useCached(externalValue, (a, b) => a === b, { immediate: true });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useCached<Value = unknown>(
|
||||
externalValue: MaybeRefOrGetter<Value>,
|
||||
comparator: Comparator<Value> = (a, b) => a === b,
|
||||
watchOptions?: WatchOptions,
|
||||
): Ref<Value> {
|
||||
const cached = ref(toValue(externalValue)) as Ref<Value>;
|
||||
|
||||
watch(() => toValue(externalValue), (value) => {
|
||||
if (!comparator(value, cached.value))
|
||||
cached.value = value;
|
||||
}, watchOptions);
|
||||
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useLastChanged } from '.';
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
|
||||
describe(useLastChanged, () => {
|
||||
it('initialize with null if no initialValue is provided', () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source);
|
||||
|
||||
expect(lastChanged.value).toBeNull();
|
||||
});
|
||||
|
||||
it('initialize with the provided initialValue', () => {
|
||||
const source = ref(0);
|
||||
const initialValue = 123456789;
|
||||
const lastChanged = useLastChanged(source, { initialValue });
|
||||
|
||||
expect(lastChanged.value).toBe(initialValue);
|
||||
});
|
||||
|
||||
it('update the timestamp when the source changes', async () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source);
|
||||
|
||||
const initialTimestamp = lastChanged.value;
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
|
||||
expect(lastChanged.value).not.toBe(initialTimestamp);
|
||||
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
|
||||
});
|
||||
|
||||
it('update the timestamp immediately if immediate option is true', async () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source, { immediate: true });
|
||||
|
||||
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
|
||||
});
|
||||
|
||||
it('not update the timestamp if the source does not change', async () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source);
|
||||
|
||||
const initialTimestamp = lastChanged.value;
|
||||
await nextTick();
|
||||
|
||||
expect(lastChanged.value).toBe(initialTimestamp);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { WatchSource, WatchOptions, Ref } from 'vue';
|
||||
|
||||
export interface UseLastChangedOptions<
|
||||
Immediate extends boolean,
|
||||
InitialValue extends number | null | undefined = undefined,
|
||||
> extends WatchOptions<Immediate> {
|
||||
initialValue?: InitialValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useLastChanged
|
||||
* @category Reactivity
|
||||
* @description Records the last time a value changed
|
||||
*
|
||||
* @param {WatchSource} source The value to track
|
||||
* @param {UseLastChangedOptions} [options={}] The options for the last changed tracker
|
||||
* @returns {Ref<number | null>} The timestamp of the last change
|
||||
*
|
||||
* @example
|
||||
* const value = ref(0);
|
||||
* const lastChanged = useLastChanged(value);
|
||||
*
|
||||
* @example
|
||||
* const value = ref(0);
|
||||
* const lastChanged = useLastChanged(value, { immediate: true });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useLastChanged(source: WatchSource, options?: UseLastChangedOptions<false>): Ref<number | null>;
|
||||
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<true> | UseLastChangedOptions<boolean, number>): Ref<number>;
|
||||
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<boolean, any> = {}): Ref<number | null> | Ref<number> {
|
||||
const lastChanged = ref<number | null>(options.initialValue ?? null);
|
||||
|
||||
watch(source, () => {
|
||||
lastChanged.value = timestamp();
|
||||
}, options);
|
||||
|
||||
return lastChanged;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useSyncRefs } from '.';
|
||||
|
||||
describe(useSyncRefs, () => {
|
||||
it('sync the value of a source ref with multiple target refs', () => {
|
||||
const source = ref(0);
|
||||
const target1 = ref(0);
|
||||
const target2 = ref(0);
|
||||
useSyncRefs(source, [target1, target2]);
|
||||
|
||||
source.value = 10;
|
||||
|
||||
expect(target1.value).toBe(10);
|
||||
expect(target2.value).toBe(10);
|
||||
});
|
||||
|
||||
it('sync the value of a source ref with a single target ref', () => {
|
||||
const source = ref(0);
|
||||
const target = ref(0);
|
||||
useSyncRefs(source, target);
|
||||
|
||||
source.value = 20;
|
||||
|
||||
expect(target.value).toBe(20);
|
||||
});
|
||||
|
||||
it('stop watching when the stop handle is called', () => {
|
||||
const source = ref(0);
|
||||
const target = ref(0);
|
||||
const stop = useSyncRefs(source, target);
|
||||
|
||||
source.value = 30;
|
||||
stop();
|
||||
source.value = 40;
|
||||
|
||||
expect(target.value).toBe(30);
|
||||
});
|
||||
});
|
||||
47
vue/toolkit/src/composables/reactivity/useSyncRefs/index.ts
Normal file
47
vue/toolkit/src/composables/reactivity/useSyncRefs/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { watch } from 'vue';
|
||||
import type { Ref, WatchOptions, WatchSource } from 'vue';
|
||||
import { isArray } from '@robonen/stdlib';
|
||||
|
||||
/**
|
||||
* @name useSyncRefs
|
||||
* @category Reactivity
|
||||
* @description Syncs the value of a source ref with multiple target refs
|
||||
*
|
||||
* @param {WatchSource<T>} source Source ref to sync
|
||||
* @param {Ref<T> | Ref<T>[]} targets Target refs to sync
|
||||
* @param {WatchOptions} watchOptions Watch options
|
||||
* @returns {WatchStopHandle} Watch stop handle
|
||||
*
|
||||
* @example
|
||||
* const source = ref(0);
|
||||
* const target1 = ref(0);
|
||||
* const target2 = ref(0);
|
||||
* useSyncRefs(source, [target1, target2]);
|
||||
*
|
||||
* @example
|
||||
* const source = ref(0);
|
||||
* const target1 = ref(0);
|
||||
* useSyncRefs(source, target1, { immediate: true });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useSyncRefs<T = unknown>(
|
||||
source: WatchSource<T>,
|
||||
targets: Ref<T> | Ref<T>[],
|
||||
watchOptions: WatchOptions = {},
|
||||
) {
|
||||
const {
|
||||
flush = 'sync',
|
||||
deep = false,
|
||||
immediate = true,
|
||||
} = watchOptions;
|
||||
|
||||
if (!isArray(targets))
|
||||
targets = [targets];
|
||||
|
||||
return watch(
|
||||
source,
|
||||
value => targets.forEach((target) => { target.value = value; }),
|
||||
{ flush, deep, immediate },
|
||||
);
|
||||
}
|
||||
6
vue/toolkit/src/composables/state/index.ts
Normal file
6
vue/toolkit/src/composables/state/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './useAppSharedState';
|
||||
export * from './useAsyncState';
|
||||
export * from './useContextFactory';
|
||||
export * from './useCounter';
|
||||
export * from './useInjectionStore';
|
||||
export * from './useToggle';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, vi, expect } from 'vitest';
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useAppSharedState } from '.';
|
||||
|
||||
describe(useAppSharedState, () => {
|
||||
it('initialize state only once', () => {
|
||||
const stateFactory = (initValue?: number) => {
|
||||
const count = ref(initValue ?? 0);
|
||||
return { count };
|
||||
};
|
||||
|
||||
const useSharedState = useAppSharedState(stateFactory);
|
||||
|
||||
const state1 = useSharedState(1);
|
||||
const state2 = useSharedState(2);
|
||||
|
||||
expect(state1.count.value).toBe(1);
|
||||
expect(state2.count.value).toBe(1);
|
||||
expect(state1).toBe(state2);
|
||||
});
|
||||
|
||||
it('return the same state object across different calls', () => {
|
||||
const stateFactory = () => {
|
||||
const state = reactive({ count: 0 });
|
||||
const increment = () => state.count++;
|
||||
return { state, increment };
|
||||
};
|
||||
|
||||
const useSharedState = useAppSharedState(stateFactory);
|
||||
|
||||
const sharedState1 = useSharedState();
|
||||
const sharedState2 = useSharedState();
|
||||
|
||||
expect(sharedState1.state.count).toBe(0);
|
||||
sharedState1.increment();
|
||||
expect(sharedState1.state.count).toBe(1);
|
||||
expect(sharedState2.state.count).toBe(1);
|
||||
expect(sharedState1).toBe(sharedState2);
|
||||
});
|
||||
});
|
||||
42
vue/toolkit/src/composables/state/useAppSharedState/index.ts
Normal file
42
vue/toolkit/src/composables/state/useAppSharedState/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { AnyFunction } from '@robonen/stdlib';
|
||||
import { effectScope } from 'vue';
|
||||
|
||||
// TODO: maybe we should control subscriptions and dispose them when the child scope is disposed
|
||||
|
||||
/**
|
||||
* @name useAppSharedState
|
||||
* @category State
|
||||
* @description Provides a shared state object for use across Vue instances
|
||||
*
|
||||
* @param {Function} stateFactory A factory function that returns the shared state object
|
||||
* @returns {Function} A function that returns the shared state object
|
||||
*
|
||||
* @example
|
||||
* const useSharedState = useAppSharedState((initValue?: number) => {
|
||||
* const count = ref(initValue ?? 0);
|
||||
* return { count };
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* const useSharedState = useAppSharedState(() => {
|
||||
* const state = reactive({ count: 0 });
|
||||
* const increment = () => state.count++;
|
||||
* return { state, increment };
|
||||
* });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useAppSharedState<Fn extends AnyFunction>(stateFactory: Fn) {
|
||||
let initialized = false;
|
||||
let state: ReturnType<Fn>;
|
||||
const scope = effectScope(true);
|
||||
|
||||
return (...args: Parameters<Fn>) => {
|
||||
if (!initialized) {
|
||||
state = scope.run(() => stateFactory(...args));
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
}
|
||||
209
vue/toolkit/src/composables/state/useAsyncState/index.test.ts
Normal file
209
vue/toolkit/src/composables/state/useAsyncState/index.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { isShallow, nextTick, ref } from 'vue';
|
||||
import { it, expect, describe, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useAsyncState } from '.';
|
||||
|
||||
describe(useAsyncState, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('works with a promise', async () => {
|
||||
const { state, isReady, isLoading, error } = useAsyncState(
|
||||
Promise.resolve('data'),
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
it('works with a function returning a promise', async () => {
|
||||
const { state, isReady, isLoading, error } = useAsyncState(
|
||||
() => Promise.resolve('data'),
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
it('handles errors', async () => {
|
||||
const { state, isReady, isLoading, error } = useAsyncState(
|
||||
Promise.reject(new Error('test-error')),
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
expect(error.value).toEqual(new Error('test-error'));
|
||||
});
|
||||
|
||||
it('calls onSuccess callback', async () => {
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
useAsyncState(
|
||||
Promise.resolve('data'),
|
||||
'initial',
|
||||
{ onSuccess },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('data');
|
||||
});
|
||||
|
||||
it('calls onError callback', async () => {
|
||||
const onError = vi.fn();
|
||||
const error = new Error('test-error');
|
||||
|
||||
useAsyncState(
|
||||
Promise.reject(error),
|
||||
'initial',
|
||||
{ onError },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('throws error if throwError is true', async () => {
|
||||
const error = new Error('test-error');
|
||||
|
||||
const { executeImmediately } = useAsyncState(
|
||||
Promise.reject(error),
|
||||
'initial',
|
||||
{ immediate: false, throwError: true },
|
||||
);
|
||||
|
||||
await expect(() => executeImmediately()).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it('resets state on execute if resetOnExecute is true', async () => {
|
||||
const { state, executeImmediately } = useAsyncState(
|
||||
(data: string) => Promise.resolve(data),
|
||||
'initial',
|
||||
{ immediate: false, resetOnExecute: true },
|
||||
);
|
||||
|
||||
await executeImmediately('new data');
|
||||
expect(state.value).toBe('new data');
|
||||
|
||||
executeImmediately('another data');
|
||||
expect(state.value).toBe('initial');
|
||||
});
|
||||
|
||||
it('delays execution with default delay', async () => {
|
||||
const { isLoading, execute } = useAsyncState(
|
||||
() => Promise.resolve('data'),
|
||||
'initial',
|
||||
{ delay: 100, immediate: false },
|
||||
);
|
||||
|
||||
const promise = execute();
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await promise;
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('is awaitable', async () => {
|
||||
const { state } = await useAsyncState(
|
||||
Promise.resolve('data'),
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
});
|
||||
|
||||
it('works with executeImmediately', async () => {
|
||||
const { state, isReady, isLoading, error, executeImmediately } = useAsyncState(
|
||||
() => Promise.resolve('data'),
|
||||
'initial',
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
executeImmediately();
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isLoading.value).toBeTruthy();
|
||||
expect(isReady.value).toBeFalsy();
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
expect(isLoading.value).toBeFalsy();
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
it('passes params to the function', async () => {
|
||||
const promiseFn = vi.fn((...args: any[]) => Promise.resolve(args.join(' ')));
|
||||
|
||||
const { executeImmediately } = useAsyncState(
|
||||
promiseFn,
|
||||
'initial',
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
await executeImmediately('hello', 'world');
|
||||
|
||||
expect(promiseFn).toHaveBeenCalledWith('hello', 'world');
|
||||
});
|
||||
|
||||
it('uses shallowRef by default', async () => {
|
||||
const { state } = await useAsyncState(
|
||||
Promise.resolve({ a: 1 }),
|
||||
{ a: 0 },
|
||||
);
|
||||
|
||||
expect(state.value.a).toBe(1);
|
||||
expect(isShallow(state)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses ref when shallow is false', async () => {
|
||||
const { state } = await useAsyncState(
|
||||
Promise.resolve({ a: ref(1) }),
|
||||
{ a: ref(0) },
|
||||
{ shallow: false },
|
||||
);
|
||||
|
||||
expect(state.value.a).toBe(1);
|
||||
expect(isShallow(state)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
130
vue/toolkit/src/composables/state/useAsyncState/index.ts
Normal file
130
vue/toolkit/src/composables/state/useAsyncState/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { ref, shallowRef, watch } from 'vue';
|
||||
import type { Ref, ShallowRef, UnwrapRef } from 'vue';
|
||||
import { isFunction, sleep } from '@robonen/stdlib';
|
||||
|
||||
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
|
||||
delay?: number;
|
||||
shallow?: Shallow;
|
||||
immediate?: boolean;
|
||||
resetOnExecute?: boolean;
|
||||
throwError?: boolean;
|
||||
onError?: (error: unknown) => void;
|
||||
onSuccess?: (data: Data) => void;
|
||||
}
|
||||
|
||||
export interface UseAsyncStateReturnBase<Data, Params extends any[], Shallow extends boolean> {
|
||||
state: Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>;
|
||||
isLoading: Ref<boolean>;
|
||||
isReady: Ref<boolean>;
|
||||
error: Ref<unknown | null>;
|
||||
execute: (delay?: number, ...params: Params) => Promise<Data>;
|
||||
executeImmediately: (...params: Params) => Promise<Data>;
|
||||
}
|
||||
|
||||
export type UseAsyncStateReturn<Data, Params extends any[], Shallow extends boolean>
|
||||
= & UseAsyncStateReturnBase<Data, Params, Shallow>
|
||||
& PromiseLike<UseAsyncStateReturnBase<Data, Params, Shallow>>;
|
||||
|
||||
/**
|
||||
* @name useAsyncState
|
||||
* @category State
|
||||
* @description A composable that provides a state for async operations without setup blocking
|
||||
*/
|
||||
export function useAsyncState<Data, Params extends any[] = [], Shallow extends boolean = true>(
|
||||
maybePromise: Promise<Data> | ((...args: Params) => Promise<Data>),
|
||||
initialState: Data,
|
||||
options?: UseAsyncStateOptions<Shallow, Data>,
|
||||
): UseAsyncStateReturn<Data, Params, Shallow> {
|
||||
const {
|
||||
delay = 0,
|
||||
shallow = true,
|
||||
immediate = true,
|
||||
resetOnExecute = false,
|
||||
throwError = false,
|
||||
onError,
|
||||
onSuccess,
|
||||
} = options ?? {};
|
||||
|
||||
const state = shallow ? shallowRef(initialState) : ref(initialState);
|
||||
const error = ref<unknown | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const isReady = ref(false);
|
||||
|
||||
const execute = async (actualDelay = delay, ...params: any[]) => {
|
||||
if (resetOnExecute)
|
||||
state.value = initialState;
|
||||
|
||||
isLoading.value = true;
|
||||
isReady.value = false;
|
||||
error.value = null;
|
||||
|
||||
if (actualDelay > 0)
|
||||
await sleep(actualDelay);
|
||||
|
||||
const promise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
|
||||
|
||||
try {
|
||||
const data = await promise;
|
||||
state.value = data;
|
||||
isReady.value = true;
|
||||
onSuccess?.(data);
|
||||
}
|
||||
catch (e: unknown) {
|
||||
error.value = e;
|
||||
onError?.(e);
|
||||
|
||||
if (throwError)
|
||||
throw e;
|
||||
}
|
||||
finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
return state.value as Data;
|
||||
};
|
||||
|
||||
const executeImmediately = (...params: Params) => {
|
||||
return execute(0, ...params);
|
||||
};
|
||||
|
||||
if (immediate)
|
||||
execute();
|
||||
|
||||
const shell = {
|
||||
state: state as Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>,
|
||||
isLoading,
|
||||
isReady,
|
||||
error,
|
||||
execute,
|
||||
executeImmediately,
|
||||
};
|
||||
|
||||
function waitResolve() {
|
||||
return new Promise<UseAsyncStateReturnBase<Data, Params, Shallow>>((resolve, reject) => {
|
||||
watch(
|
||||
isLoading,
|
||||
(loading) => {
|
||||
if (loading === false) {
|
||||
if (error.value)
|
||||
reject(error.value);
|
||||
else
|
||||
resolve(shell);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
once: true,
|
||||
flush: 'sync',
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...shell,
|
||||
// eslint-disable-next-line unicorn/no-thenable
|
||||
then(onFulfilled, onRejected) {
|
||||
return waitResolve().then(onFulfilled, onRejected);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defineComponent } from 'vue';
|
||||
import { useContextFactory } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { VueToolsError } from '@/utils';
|
||||
|
||||
function testFactory<Data>(
|
||||
data: Data,
|
||||
context: ReturnType<typeof useContextFactory<Data>>,
|
||||
fallback?: Data,
|
||||
) {
|
||||
const { inject, provide } = context;
|
||||
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
const value = inject(fallback);
|
||||
return { value };
|
||||
},
|
||||
template: `{{ value }}`,
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
setup() {
|
||||
provide(data);
|
||||
},
|
||||
template: `<Child />`,
|
||||
});
|
||||
|
||||
return {
|
||||
Parent,
|
||||
Child,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: maybe replace template with passing mock functions to setup
|
||||
|
||||
describe(useContextFactory, () => {
|
||||
it('provide and inject context correctly', () => {
|
||||
const { Parent } = testFactory('test', useContextFactory('TestContext'));
|
||||
|
||||
const component = mount(Parent);
|
||||
|
||||
expect(component.text()).toBe('test');
|
||||
});
|
||||
|
||||
it('throw an error when context is not provided', () => {
|
||||
const { Child } = testFactory('test', useContextFactory('TestContext'));
|
||||
|
||||
expect(() => mount(Child)).toThrow(VueToolsError);
|
||||
});
|
||||
|
||||
it('inject a fallback value when context is not provided', () => {
|
||||
const { Child } = testFactory('test', useContextFactory('TestContext'), 'fallback');
|
||||
|
||||
const component = mount(Child);
|
||||
|
||||
expect(component.text()).toBe('fallback');
|
||||
});
|
||||
|
||||
it('correctly handle null values', () => {
|
||||
const { Parent } = testFactory(null, useContextFactory('TestContext'));
|
||||
|
||||
const component = mount(Parent);
|
||||
|
||||
expect(component.text()).toBe('');
|
||||
});
|
||||
|
||||
it('provide context globally with app', () => {
|
||||
const context = useContextFactory('TestContext');
|
||||
const { Child } = testFactory(null, context);
|
||||
|
||||
const childComponent = mount(Child, {
|
||||
global: {
|
||||
plugins: [app => context.appProvide(app)('test')],
|
||||
},
|
||||
});
|
||||
|
||||
expect(childComponent.text()).toBe('test');
|
||||
});
|
||||
});
|
||||
63
vue/toolkit/src/composables/state/useContextFactory/index.ts
Normal file
63
vue/toolkit/src/composables/state/useContextFactory/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { inject as vueInject, provide as vueProvide } from 'vue';
|
||||
import type { InjectionKey, App } from 'vue';
|
||||
import { VueToolsError } from '@/utils';
|
||||
|
||||
/**
|
||||
* @name useContextFactory
|
||||
* @category State
|
||||
* @description A composable that provides a factory for creating context with unique key
|
||||
*
|
||||
* @param {string} name The name of the context
|
||||
* @returns {Object} An object with `inject`, `provide`, `appProvide` and `key` properties
|
||||
* @throws {VueToolsError} when the context is not provided
|
||||
*
|
||||
* @example
|
||||
* const { inject, provide } = useContextFactory('MyContext');
|
||||
*
|
||||
* provide('Hello World');
|
||||
* const value = inject();
|
||||
*
|
||||
* @example
|
||||
* const { inject: injectContext, appProvide } = useContextFactory('MyContext');
|
||||
*
|
||||
* // In a plugin
|
||||
* {
|
||||
* install(app) {
|
||||
* appProvide(app)('Hello World');
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // In a component
|
||||
* const value = injectContext();
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useContextFactory<ContextValue>(name: string) {
|
||||
const injectionKey: InjectionKey<ContextValue> = Symbol(name);
|
||||
|
||||
const inject = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
|
||||
const context = vueInject(injectionKey, fallback);
|
||||
|
||||
if (context !== undefined)
|
||||
return context;
|
||||
|
||||
throw new VueToolsError(`useContextFactory: '${name}' context is not provided`);
|
||||
};
|
||||
|
||||
const provide = (context: ContextValue) => {
|
||||
vueProvide(injectionKey, context);
|
||||
return context;
|
||||
};
|
||||
|
||||
const appProvide = (app: App) => (context: ContextValue) => {
|
||||
app.provide(injectionKey, context);
|
||||
return context;
|
||||
};
|
||||
|
||||
return {
|
||||
inject,
|
||||
provide,
|
||||
appProvide,
|
||||
key: injectionKey,
|
||||
};
|
||||
}
|
||||
6
vue/toolkit/src/composables/state/useCounter/demo.vue
Normal file
6
vue/toolkit/src/composables/state/useCounter/demo.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
</div>
|
||||
</template>
|
||||
81
vue/toolkit/src/composables/state/useCounter/index.test.ts
Normal file
81
vue/toolkit/src/composables/state/useCounter/index.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { it, expect, describe } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useCounter } from '.';
|
||||
|
||||
describe(useCounter, () => {
|
||||
it('initialize count with the provided initial value', () => {
|
||||
const { count } = useCounter(5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('initialize count with the provided initial value from a ref', () => {
|
||||
const { count } = useCounter(ref(5));
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('initialize count with the provided initial value from a getter', () => {
|
||||
const { count } = useCounter(() => 5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('increment count by 1 by default', () => {
|
||||
const { count, increment } = useCounter(0);
|
||||
increment();
|
||||
expect(count.value).toBe(1);
|
||||
});
|
||||
|
||||
it('increment count by the specified delta', () => {
|
||||
const { count, increment } = useCounter(0);
|
||||
increment(5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('decrement count by 1 by default', () => {
|
||||
const { count, decrement } = useCounter(5);
|
||||
decrement();
|
||||
expect(count.value).toBe(4);
|
||||
});
|
||||
|
||||
it('decrement count by the specified delta', () => {
|
||||
const { count, decrement } = useCounter(10);
|
||||
decrement(5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('set count to the specified value', () => {
|
||||
const { count, set } = useCounter(0);
|
||||
set(10);
|
||||
expect(count.value).toBe(10);
|
||||
});
|
||||
|
||||
it('get the current count value', () => {
|
||||
const { get } = useCounter(5);
|
||||
expect(get()).toBe(5);
|
||||
});
|
||||
|
||||
it('reset count to the initial value', () => {
|
||||
const { count, reset } = useCounter(10);
|
||||
count.value = 5;
|
||||
reset();
|
||||
expect(count.value).toBe(10);
|
||||
});
|
||||
|
||||
it('reset count to the specified value', () => {
|
||||
const { count, reset } = useCounter(10);
|
||||
count.value = 5;
|
||||
reset(20);
|
||||
expect(count.value).toBe(20);
|
||||
});
|
||||
|
||||
it('clamp count to the minimum value', () => {
|
||||
const { count, decrement } = useCounter(Number.MIN_SAFE_INTEGER);
|
||||
decrement();
|
||||
expect(count.value).toBe(Number.MIN_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
it('clamp count to the maximum value', () => {
|
||||
const { count, increment } = useCounter(Number.MAX_SAFE_INTEGER);
|
||||
increment();
|
||||
expect(count.value).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
});
|
||||
77
vue/toolkit/src/composables/state/useCounter/index.ts
Normal file
77
vue/toolkit/src/composables/state/useCounter/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ref, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
|
||||
export interface UseCounterOptions {
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface UseConterReturn {
|
||||
count: Ref<number>;
|
||||
increment: (delta?: number) => void;
|
||||
decrement: (delta?: number) => void;
|
||||
set: (value: number) => void;
|
||||
get: () => number;
|
||||
reset: (value?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useCounter
|
||||
* @category State
|
||||
* @description A composable that provides a counter with increment, decrement, set, get, and reset functions
|
||||
*
|
||||
* @param {MaybeRef<number>} [initialValue=0] The initial value of the counter
|
||||
* @param {UseCounterOptions} [options={}] The options for the counter
|
||||
* @param {number} [options.min=Number.MIN_SAFE_INTEGER] The minimum value of the counter
|
||||
* @param {number} [options.max=Number.MAX_SAFE_INTEGER] The maximum value of the counter
|
||||
* @returns {UseConterReturn} The counter object
|
||||
*
|
||||
* @example
|
||||
* const { count, increment } = useCounter(0);
|
||||
*
|
||||
* @example
|
||||
* const { count, increment, decrement, set, get, reset } = useCounter(0, { min: 0, max: 10 });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useCounter(
|
||||
initialValue: MaybeRefOrGetter<number> = 0,
|
||||
options: UseCounterOptions = {},
|
||||
): UseConterReturn {
|
||||
let _initialValue = toValue(initialValue);
|
||||
const count = ref(_initialValue);
|
||||
|
||||
const {
|
||||
min = Number.MIN_SAFE_INTEGER,
|
||||
max = Number.MAX_SAFE_INTEGER,
|
||||
} = options;
|
||||
|
||||
const increment = (delta = 1) => {
|
||||
count.value = clamp(count.value + delta, min, max);
|
||||
};
|
||||
|
||||
const decrement = (delta = 1) => {
|
||||
count.value = clamp(count.value - delta, min, max);
|
||||
};
|
||||
|
||||
const set = (value: number) => {
|
||||
count.value = clamp(value, min, max);
|
||||
};
|
||||
|
||||
const get = () => count.value;
|
||||
|
||||
const reset = (value = _initialValue) => {
|
||||
_initialValue = value;
|
||||
return set(value);
|
||||
};
|
||||
|
||||
return {
|
||||
count,
|
||||
increment,
|
||||
decrement,
|
||||
set,
|
||||
get,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { useInjectionStore } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
function testFactory<Args, Return>(
|
||||
store: ReturnType<typeof useInjectionStore<Args[], Return>>,
|
||||
) {
|
||||
const { useProvidingState, useInjectedState } = store;
|
||||
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
const state = useInjectedState();
|
||||
return { state };
|
||||
},
|
||||
template: `{{ state }}`,
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
setup() {
|
||||
const state = useProvidingState();
|
||||
return { state };
|
||||
},
|
||||
template: `<Child />`,
|
||||
});
|
||||
|
||||
return {
|
||||
Parent,
|
||||
Child,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useInjectionState', () => {
|
||||
it('provides and injects state correctly', () => {
|
||||
const { Parent } = testFactory(
|
||||
useInjectionStore(() => ref('base')),
|
||||
);
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
expect(wrapper.text()).toBe('base');
|
||||
});
|
||||
|
||||
it('injects default value when state is not provided', () => {
|
||||
const { Child } = testFactory(
|
||||
useInjectionStore(() => ref('without provider'), {
|
||||
defaultValue: ref('default'),
|
||||
injectionKey: 'testKey',
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = mount(Child);
|
||||
expect(wrapper.text()).toBe('default');
|
||||
});
|
||||
|
||||
it('provides state at app level', () => {
|
||||
const injectionStore = useInjectionStore(() => ref('app level'));
|
||||
const { Child } = testFactory(injectionStore);
|
||||
|
||||
const wrapper = mount(Child, {
|
||||
global: {
|
||||
plugins: [
|
||||
(app) => {
|
||||
const state = injectionStore.useAppProvidingState(app)();
|
||||
expect(state.value).toBe('app level');
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('app level');
|
||||
});
|
||||
|
||||
it('works with custom injection key', () => {
|
||||
const { Parent } = testFactory(
|
||||
useInjectionStore(() => ref('custom key'), {
|
||||
injectionKey: Symbol('customKey'),
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
expect(wrapper.text()).toBe('custom key');
|
||||
});
|
||||
|
||||
it('handles state factory with arguments', () => {
|
||||
const injectionStore = useInjectionStore((arg: string) => arg);
|
||||
const { Child } = testFactory(injectionStore);
|
||||
|
||||
const wrapper = mount(Child, {
|
||||
global: {
|
||||
plugins: [
|
||||
app => injectionStore.useAppProvidingState(app)('with args'),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('with args');
|
||||
});
|
||||
});
|
||||
73
vue/toolkit/src/composables/state/useInjectionStore/index.ts
Normal file
73
vue/toolkit/src/composables/state/useInjectionStore/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useContextFactory } from '../useContextFactory';
|
||||
import type { App } from 'vue';
|
||||
|
||||
export interface useInjectionStoreOptions<Return> {
|
||||
injectionName?: string;
|
||||
defaultValue?: Return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useInjectionStore
|
||||
* @category State
|
||||
* @description Create a global state that can be injected into components
|
||||
*
|
||||
* @param {Function} stateFactory A factory function that creates the state
|
||||
* @param {useInjectionStoreOptions} options An object with the following properties
|
||||
* @param {string | InjectionKey} options.injectionKey The key to use for the injection
|
||||
* @param {any} options.defaultValue The default value to use when the state is not provided
|
||||
* @returns {Object} An object with `useProvidingState`, `useAppProvidingState`, and `useInjectedState` functions
|
||||
*
|
||||
* @example
|
||||
* const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World'));
|
||||
*
|
||||
* // In a parent component
|
||||
* const state = useProvidingState();
|
||||
*
|
||||
* // In a child component
|
||||
* const state = useInjectedState();
|
||||
*
|
||||
* @example
|
||||
* const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World'), {
|
||||
* injectionKey: 'MyState',
|
||||
* defaultValue: 'Default Value'
|
||||
* });
|
||||
*
|
||||
* // In a plugin
|
||||
* {
|
||||
* install(app) {
|
||||
* const state = useAppProvidingState(app)();
|
||||
* state.value = 'Hello World';
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // In a component
|
||||
* const state = useInjectedState();
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function useInjectionStore<Args extends any[], Return>(
|
||||
stateFactory: (...args: Args) => Return,
|
||||
options?: useInjectionStoreOptions<Return>,
|
||||
) {
|
||||
const ctx = useContextFactory<Return>(options?.injectionName ?? stateFactory.name ?? 'InjectionStore');
|
||||
|
||||
const useProvidingState = (...args: Args) => {
|
||||
const state = stateFactory(...args);
|
||||
ctx.provide(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const useAppProvidingState = (app: App) => (...args: Args) => {
|
||||
const state = stateFactory(...args);
|
||||
ctx.appProvide(app)(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const useInjectedState = () => ctx.inject(options?.defaultValue);
|
||||
|
||||
return {
|
||||
useProvidingState,
|
||||
useAppProvidingState,
|
||||
useInjectedState,
|
||||
};
|
||||
}
|
||||
110
vue/toolkit/src/composables/state/useToggle/index.test.ts
Normal file
110
vue/toolkit/src/composables/state/useToggle/index.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { it, expect, describe } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useToggle } from '.';
|
||||
|
||||
describe(useToggle, () => {
|
||||
it('initialize with false by default', () => {
|
||||
const { value } = useToggle();
|
||||
expect(value.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('initialize with the provided initial value', () => {
|
||||
const { value } = useToggle(true);
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('initialize with the provided initial value from a ref', () => {
|
||||
const { value } = useToggle(ref(true));
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggle from false to true', () => {
|
||||
const { value, toggle } = useToggle(false);
|
||||
toggle();
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggle from true to false', () => {
|
||||
const { value, toggle } = useToggle(true);
|
||||
toggle();
|
||||
expect(value.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('toggle multiple times', () => {
|
||||
const { value, toggle } = useToggle(false);
|
||||
toggle();
|
||||
expect(value.value).toBeTruthy();
|
||||
toggle();
|
||||
expect(value.value).toBeFalsy();
|
||||
toggle();
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggle returns the new value', () => {
|
||||
const { toggle } = useToggle(false);
|
||||
expect(toggle()).toBeTruthy();
|
||||
expect(toggle()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('set a specific value via toggle', () => {
|
||||
const { value, toggle } = useToggle(false);
|
||||
toggle(true);
|
||||
expect(value.value).toBeTruthy();
|
||||
toggle(true);
|
||||
expect(value.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('use custom truthy and falsy values', () => {
|
||||
const { value, toggle } = useToggle('off', {
|
||||
truthyValue: 'on',
|
||||
falsyValue: 'off',
|
||||
});
|
||||
|
||||
expect(value.value).toBe('off');
|
||||
toggle();
|
||||
expect(value.value).toBe('on');
|
||||
toggle();
|
||||
expect(value.value).toBe('off');
|
||||
});
|
||||
|
||||
it('set a specific custom value via toggle', () => {
|
||||
const { value, toggle } = useToggle('off', {
|
||||
truthyValue: 'on',
|
||||
falsyValue: 'off',
|
||||
});
|
||||
|
||||
toggle('on');
|
||||
expect(value.value).toBe('on');
|
||||
toggle('on');
|
||||
expect(value.value).toBe('on');
|
||||
});
|
||||
|
||||
it('use ref-based truthy and falsy values', () => {
|
||||
const truthy = ref('yes');
|
||||
const falsy = ref('no');
|
||||
|
||||
const { value, toggle } = useToggle('no', {
|
||||
truthyValue: truthy,
|
||||
falsyValue: falsy,
|
||||
});
|
||||
|
||||
expect(value.value).toBe('no');
|
||||
toggle();
|
||||
expect(value.value).toBe('yes');
|
||||
toggle();
|
||||
expect(value.value).toBe('no');
|
||||
});
|
||||
|
||||
it('use getter-based truthy and falsy values', () => {
|
||||
const { value, toggle } = useToggle(0, {
|
||||
truthyValue: () => 1,
|
||||
falsyValue: () => 0,
|
||||
});
|
||||
|
||||
expect(value.value).toBe(0);
|
||||
toggle();
|
||||
expect(value.value).toBe(1);
|
||||
toggle();
|
||||
expect(value.value).toBe(0);
|
||||
});
|
||||
});
|
||||
57
vue/toolkit/src/composables/state/useToggle/index.ts
Normal file
57
vue/toolkit/src/composables/state/useToggle/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ref, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, MaybeRef, Ref } from 'vue';
|
||||
|
||||
export interface UseToggleOptions<Truthy, Falsy> {
|
||||
truthyValue?: MaybeRefOrGetter<Truthy>;
|
||||
falsyValue?: MaybeRefOrGetter<Falsy>;
|
||||
}
|
||||
|
||||
export interface UseToggleReturn<Truthy, Falsy> {
|
||||
value: Ref<Truthy | Falsy>;
|
||||
toggle: (value?: Truthy | Falsy) => Truthy | Falsy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useToggle
|
||||
* @category State
|
||||
* @description A composable that provides a boolean toggle with customizable truthy/falsy values
|
||||
*
|
||||
* @param {MaybeRef<Truthy | Falsy>} [initialValue=false] The initial value
|
||||
* @param {UseToggleOptions<Truthy, Falsy>} [options={}] Options for custom truthy/falsy values
|
||||
* @returns {UseToggleReturn<Truthy, Falsy>} The toggle state and function
|
||||
*
|
||||
* @example
|
||||
* const { value, toggle } = useToggle();
|
||||
*
|
||||
* @example
|
||||
* const { value, toggle } = useToggle(false, { truthyValue: 'on', falsyValue: 'off' });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useToggle<Truthy = true, Falsy = false>(
|
||||
initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy,
|
||||
options: UseToggleOptions<Truthy, Falsy> = {},
|
||||
): UseToggleReturn<Truthy, Falsy> {
|
||||
const {
|
||||
truthyValue = true as Truthy,
|
||||
falsyValue = false as Falsy,
|
||||
} = options;
|
||||
|
||||
const value = ref(initialValue) as Ref<Truthy | Falsy>;
|
||||
|
||||
const toggle = (newValue?: Truthy | Falsy) => {
|
||||
if (newValue !== undefined) {
|
||||
value.value = newValue;
|
||||
return value.value;
|
||||
}
|
||||
|
||||
const truthy = toValue(truthyValue);
|
||||
const falsy = toValue(falsyValue);
|
||||
|
||||
value.value = value.value === truthy ? falsy : truthy;
|
||||
|
||||
return value.value;
|
||||
};
|
||||
|
||||
return { value, toggle };
|
||||
}
|
||||
4
vue/toolkit/src/composables/storage/index.ts
Normal file
4
vue/toolkit/src/composables/storage/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './useLocalStorage';
|
||||
export * from './useSessionStorage';
|
||||
export * from './useStorage';
|
||||
export * from './useStorageAsync';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import { useLocalStorage } from '.';
|
||||
|
||||
describe(useLocalStorage, () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('stores and reads a string via localStorage', async () => {
|
||||
const state = useLocalStorage<string>('ls-string', 'hello');
|
||||
|
||||
expect(state.value).toBe('hello');
|
||||
expect(localStorage.getItem('ls-string')).toBe('hello');
|
||||
|
||||
state.value = 'world';
|
||||
await nextTick();
|
||||
|
||||
expect(localStorage.getItem('ls-string')).toBe('world');
|
||||
});
|
||||
|
||||
it('stores and reads a number', async () => {
|
||||
const state = useLocalStorage<number>('ls-number', 42);
|
||||
|
||||
expect(state.value).toBe(42);
|
||||
|
||||
state.value = 100;
|
||||
await nextTick();
|
||||
|
||||
expect(localStorage.getItem('ls-number')).toBe('100');
|
||||
});
|
||||
|
||||
it('stores and reads an object', async () => {
|
||||
const state = useLocalStorage('ls-obj', { a: 1 });
|
||||
|
||||
expect(state.value).toEqual({ a: 1 });
|
||||
|
||||
state.value = { a: 2 };
|
||||
await nextTick();
|
||||
|
||||
expect(JSON.parse(localStorage.getItem('ls-obj')!)).toEqual({ a: 2 });
|
||||
});
|
||||
|
||||
it('reads existing value from localStorage on init', () => {
|
||||
localStorage.setItem('ls-existing', '"stored"');
|
||||
|
||||
const state = useLocalStorage('ls-existing', 'default', {
|
||||
serializer: { read: v => JSON.parse(v), write: v => JSON.stringify(v) },
|
||||
});
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
});
|
||||
|
||||
it('removes from localStorage when set to null', async () => {
|
||||
const state = useLocalStorage<string | null>('ls-null', 'value');
|
||||
expect(localStorage.getItem('ls-null')).toBe('value');
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
|
||||
expect(localStorage.getItem('ls-null')).toBeNull();
|
||||
});
|
||||
|
||||
it('passes options through to useStorage', () => {
|
||||
const state = useLocalStorage<string>('ls-no-write', 'default', {
|
||||
writeDefaults: false,
|
||||
});
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
expect(localStorage.getItem('ls-no-write')).toBeNull();
|
||||
});
|
||||
});
|
||||
45
vue/toolkit/src/composables/storage/useLocalStorage/index.ts
Normal file
45
vue/toolkit/src/composables/storage/useLocalStorage/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ref, shallowRef, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import type { RemovableRef } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { useStorage } from '../useStorage';
|
||||
import type { UseStorageOptions } from '../useStorage';
|
||||
|
||||
/**
|
||||
* @name useLocalStorage
|
||||
* @category Storage
|
||||
* @description Reactive localStorage binding — creates a ref synced with `window.localStorage`
|
||||
*
|
||||
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
|
||||
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||
* @param {UseStorageOptions<T>} [options={}] Options
|
||||
* @returns {RemovableRef<T>} A reactive ref synced with localStorage
|
||||
*
|
||||
* @example
|
||||
* const count = useLocalStorage('my-count', 0);
|
||||
*
|
||||
* @example
|
||||
* const state = useLocalStorage('my-state', { hello: 'world' });
|
||||
*
|
||||
* @since 0.0.12
|
||||
*/
|
||||
export function useLocalStorage<T extends string>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useLocalStorage<T extends number>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useLocalStorage<T extends boolean>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useLocalStorage<T>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useLocalStorage<T = unknown>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useLocalStorage<T>(
|
||||
key: MaybeRefOrGetter<string>,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
options: UseStorageOptions<T> = {},
|
||||
): RemovableRef<T> {
|
||||
const window = options.window ?? defaultWindow;
|
||||
const storage = window?.localStorage;
|
||||
|
||||
if (!storage) {
|
||||
// SSR / non-browser environment: return an in-memory ref
|
||||
return (options.shallow !== false ? shallowRef : ref)(toValue(initialValue)) as RemovableRef<T>;
|
||||
}
|
||||
|
||||
return useStorage(key, initialValue, storage, { ...options, window });
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import { useSessionStorage } from '.';
|
||||
|
||||
describe(useSessionStorage, () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('stores and reads a string via sessionStorage', async () => {
|
||||
const state = useSessionStorage<string>('ss-string', 'hello');
|
||||
|
||||
expect(state.value).toBe('hello');
|
||||
expect(sessionStorage.getItem('ss-string')).toBe('hello');
|
||||
|
||||
state.value = 'world';
|
||||
await nextTick();
|
||||
|
||||
expect(sessionStorage.getItem('ss-string')).toBe('world');
|
||||
});
|
||||
|
||||
it('stores and reads a number', async () => {
|
||||
const state = useSessionStorage<number>('ss-number', 42);
|
||||
|
||||
expect(state.value).toBe(42);
|
||||
|
||||
state.value = 100;
|
||||
await nextTick();
|
||||
|
||||
expect(sessionStorage.getItem('ss-number')).toBe('100');
|
||||
});
|
||||
|
||||
it('stores and reads an object', async () => {
|
||||
const state = useSessionStorage('ss-obj', { a: 1 });
|
||||
|
||||
expect(state.value).toEqual({ a: 1 });
|
||||
|
||||
state.value = { a: 2 };
|
||||
await nextTick();
|
||||
|
||||
expect(JSON.parse(sessionStorage.getItem('ss-obj')!)).toEqual({ a: 2 });
|
||||
});
|
||||
|
||||
it('reads existing value from sessionStorage on init', () => {
|
||||
sessionStorage.setItem('ss-existing', '"stored"');
|
||||
|
||||
const state = useSessionStorage('ss-existing', 'default', {
|
||||
serializer: { read: v => JSON.parse(v), write: v => JSON.stringify(v) },
|
||||
});
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
});
|
||||
|
||||
it('removes from sessionStorage when set to null', async () => {
|
||||
const state = useSessionStorage<string | null>('ss-null', 'value');
|
||||
expect(sessionStorage.getItem('ss-null')).toBe('value');
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
|
||||
expect(sessionStorage.getItem('ss-null')).toBeNull();
|
||||
});
|
||||
|
||||
it('passes options through to useStorage', () => {
|
||||
const state = useSessionStorage<string>('ss-no-write', 'default', {
|
||||
writeDefaults: false,
|
||||
});
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
expect(sessionStorage.getItem('ss-no-write')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ref, shallowRef, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import type { RemovableRef } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { useStorage } from '../useStorage';
|
||||
import type { UseStorageOptions } from '../useStorage';
|
||||
|
||||
/**
|
||||
* @name useSessionStorage
|
||||
* @category Storage
|
||||
* @description Reactive sessionStorage binding — creates a ref synced with `window.sessionStorage`
|
||||
*
|
||||
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
|
||||
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||
* @param {UseStorageOptions<T>} [options={}] Options
|
||||
* @returns {RemovableRef<T>} A reactive ref synced with sessionStorage
|
||||
*
|
||||
* @example
|
||||
* const count = useSessionStorage('my-count', 0);
|
||||
*
|
||||
* @example
|
||||
* const state = useSessionStorage('my-state', { hello: 'world' });
|
||||
*
|
||||
* @since 0.0.12
|
||||
*/
|
||||
export function useSessionStorage<T extends string>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useSessionStorage<T extends number>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useSessionStorage<T extends boolean>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useSessionStorage<T>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useSessionStorage<T = unknown>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useSessionStorage<T>(
|
||||
key: MaybeRefOrGetter<string>,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
options: UseStorageOptions<T> = {},
|
||||
): RemovableRef<T> {
|
||||
const window = options.window ?? defaultWindow;
|
||||
const storage = window?.sessionStorage;
|
||||
|
||||
if (!storage) {
|
||||
// SSR / non-browser environment: return an in-memory ref
|
||||
return (options.shallow !== false ? shallowRef : ref)(toValue(initialValue)) as RemovableRef<T>;
|
||||
}
|
||||
|
||||
return useStorage(key, initialValue, storage, { ...options, window });
|
||||
}
|
||||
490
vue/toolkit/src/composables/storage/useStorage/index.test.ts
Normal file
490
vue/toolkit/src/composables/storage/useStorage/index.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { useStorage, StorageSerializers, customStorageEventName } from '.';
|
||||
import type { StorageLike, StorageEventLike } from '.';
|
||||
|
||||
function createMockStorage(): StorageLike & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
store,
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
describe(useStorage, () => {
|
||||
// --- Basic types ---
|
||||
|
||||
it('stores and reads a string', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('test-string', 'hello', storage);
|
||||
|
||||
expect(state.value).toBe('hello');
|
||||
expect(storage.getItem('test-string')).toBe('hello');
|
||||
|
||||
state.value = 'world';
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-string')).toBe('world');
|
||||
});
|
||||
|
||||
it('stores and reads a number', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<number>('test-number', 42, storage);
|
||||
|
||||
expect(state.value).toBe(42);
|
||||
|
||||
state.value = 100;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-number')).toBe('100');
|
||||
});
|
||||
|
||||
it('stores and reads a boolean', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<boolean>('test-bool', true, storage);
|
||||
|
||||
expect(state.value).toBeTruthy();
|
||||
|
||||
state.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-bool')).toBe('false');
|
||||
});
|
||||
|
||||
it('stores and reads an object', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage('test-obj', { a: 1, b: 'two' }, storage);
|
||||
|
||||
expect(state.value).toEqual({ a: 1, b: 'two' });
|
||||
|
||||
state.value = { a: 2, b: 'three' };
|
||||
await nextTick();
|
||||
|
||||
expect(JSON.parse(storage.getItem('test-obj')!)).toEqual({ a: 2, b: 'three' });
|
||||
});
|
||||
|
||||
// --- Reads existing value from storage ---
|
||||
|
||||
it('reads existing value from storage on init', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('existing', '"stored-value"');
|
||||
|
||||
const state = useStorage('existing', 'default', storage, {
|
||||
serializer: StorageSerializers.object,
|
||||
});
|
||||
|
||||
expect(state.value).toBe('stored-value');
|
||||
});
|
||||
|
||||
// --- Removes item when set to null ---
|
||||
|
||||
it('removes from storage when value is set to null', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string | null>('test-null', 'value', storage);
|
||||
|
||||
await nextTick();
|
||||
expect(storage.getItem('test-null')).toBe('value');
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-null')).toBeNull();
|
||||
});
|
||||
|
||||
// --- Custom serializer ---
|
||||
|
||||
it('uses custom serializer', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const serializer = {
|
||||
read: (v: string) => v.split(',').map(Number),
|
||||
write: (v: number[]) => v.join(','),
|
||||
};
|
||||
|
||||
const state = useStorage('custom-ser', [1, 2, 3], storage, { serializer });
|
||||
|
||||
expect(state.value).toEqual([1, 2, 3]);
|
||||
|
||||
state.value = [4, 5, 6];
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('custom-ser')).toBe('4,5,6');
|
||||
});
|
||||
|
||||
// --- Merge defaults ---
|
||||
|
||||
it('merges defaults with stored value', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('merge-test', JSON.stringify({ hello: 'stored' }));
|
||||
|
||||
const state = useStorage(
|
||||
'merge-test',
|
||||
{ hello: 'default', greeting: 'hi' },
|
||||
storage,
|
||||
{ mergeDefaults: true },
|
||||
);
|
||||
|
||||
expect(state.value.hello).toBe('stored');
|
||||
expect(state.value.greeting).toBe('hi');
|
||||
});
|
||||
|
||||
it('uses custom merge function', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
|
||||
|
||||
const state = useStorage(
|
||||
'merge-fn',
|
||||
{ a: 0, b: 2 },
|
||||
storage,
|
||||
{
|
||||
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(state.value).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
// --- Map and Set ---
|
||||
|
||||
it('stores and reads a Map', async () => {
|
||||
const storage = createMockStorage();
|
||||
const initial = new Map([['key1', 'val1']]);
|
||||
const state = useStorage('test-map', initial, storage);
|
||||
|
||||
expect(state.value).toEqual(new Map([['key1', 'val1']]));
|
||||
|
||||
state.value = new Map([['key2', 'val2']]);
|
||||
await nextTick();
|
||||
|
||||
const raw = storage.getItem('test-map');
|
||||
expect(JSON.parse(raw!)).toEqual([['key2', 'val2']]);
|
||||
});
|
||||
|
||||
it('stores and reads a Set', async () => {
|
||||
const storage = createMockStorage();
|
||||
const initial = new Set([1, 2, 3]);
|
||||
const state = useStorage('test-set', initial, storage);
|
||||
|
||||
expect(state.value).toEqual(new Set([1, 2, 3]));
|
||||
|
||||
state.value = new Set([4, 5]);
|
||||
await nextTick();
|
||||
|
||||
const raw = storage.getItem('test-set');
|
||||
expect(JSON.parse(raw!)).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
// --- Date ---
|
||||
|
||||
it('stores and reads a Date', async () => {
|
||||
const storage = createMockStorage();
|
||||
const date = new Date('2026-02-14T00:00:00.000Z');
|
||||
const state = useStorage('test-date', date, storage);
|
||||
|
||||
expect(state.value).toEqual(date);
|
||||
|
||||
const newDate = new Date('2026-12-25T00:00:00.000Z');
|
||||
state.value = newDate;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('test-date')).toBe(newDate.toISOString());
|
||||
});
|
||||
|
||||
// --- Error handling ---
|
||||
|
||||
it('calls onError when read fails', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('bad-json', '{invalid');
|
||||
const onError = vi.fn();
|
||||
|
||||
const state = useStorage('bad-json', { fallback: true }, storage, { onError });
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(state.value).toEqual({ fallback: true });
|
||||
});
|
||||
|
||||
it('calls onError when write fails', async () => {
|
||||
const onError = vi.fn();
|
||||
const storage: StorageLike = {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error('quota exceeded'); },
|
||||
removeItem: () => {},
|
||||
};
|
||||
|
||||
const state = useStorage<string>('fail-write', 'init', storage, { onError });
|
||||
|
||||
// One error from initial persist of defaults
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
|
||||
state.value = 'new';
|
||||
await nextTick();
|
||||
|
||||
// Another error from the write triggered by value change
|
||||
expect(onError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// --- Persists defaults on init ---
|
||||
|
||||
it('persists default value to storage on init when key does not exist', () => {
|
||||
const storage = createMockStorage();
|
||||
useStorage('new-key', 'default-val', storage);
|
||||
|
||||
expect(storage.getItem('new-key')).toBe('default-val');
|
||||
});
|
||||
|
||||
it('does not overwrite existing storage value with defaults', () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('existing-key', 'existing-val');
|
||||
|
||||
useStorage('existing-key', 'default-val', storage);
|
||||
|
||||
expect(storage.getItem('existing-key')).toBe('existing-val');
|
||||
});
|
||||
|
||||
// --- writeDefaults: false ---
|
||||
|
||||
it('does not persist defaults when writeDefaults is false', () => {
|
||||
const storage = createMockStorage();
|
||||
useStorage('no-write', 'default-val', storage, { writeDefaults: false });
|
||||
|
||||
expect(storage.getItem('no-write')).toBeNull();
|
||||
});
|
||||
|
||||
// --- No infinite loop on init ---
|
||||
|
||||
it('calls setItem exactly once on init for writeDefaults', () => {
|
||||
const setItem = vi.fn();
|
||||
const storage: StorageLike = {
|
||||
getItem: () => null,
|
||||
setItem,
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
|
||||
useStorage<string>('init-key', 'value', storage);
|
||||
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
expect(setItem).toHaveBeenCalledWith('init-key', 'value');
|
||||
});
|
||||
|
||||
// --- No-op write when value unchanged ---
|
||||
|
||||
it('does not call setItem when value is unchanged', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('noop-key', 'same', storage);
|
||||
|
||||
const setItem = vi.spyOn(storage, 'setItem');
|
||||
setItem.mockClear();
|
||||
|
||||
// Re-assign the same value
|
||||
state.value = 'same';
|
||||
await nextTick();
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- shallow: false with deep mutation ---
|
||||
|
||||
it('writes to storage on deep object mutation with shallow: false', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage('deep-obj', { nested: { count: 0 } }, storage, { shallow: false });
|
||||
|
||||
expect(state.value.nested.count).toBe(0);
|
||||
|
||||
state.value.nested.count = 42;
|
||||
await nextTick();
|
||||
|
||||
expect(JSON.parse(storage.getItem('deep-obj')!)).toEqual({ nested: { count: 42 } });
|
||||
});
|
||||
|
||||
// --- Multiple rapid assignments ---
|
||||
|
||||
it('only writes last value when multiple assignments happen before flush', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('rapid-key', 'initial', storage);
|
||||
|
||||
const setItem = vi.spyOn(storage, 'setItem');
|
||||
setItem.mockClear();
|
||||
|
||||
state.value = 'first';
|
||||
state.value = 'second';
|
||||
state.value = 'third';
|
||||
await nextTick();
|
||||
|
||||
// Watcher fires once with the last value (pre flush batches)
|
||||
expect(storage.getItem('rapid-key')).toBe('third');
|
||||
});
|
||||
|
||||
// --- Cross-tab synchronization via custom storage event ---
|
||||
// Note: Mock storage is not `instanceof Storage`, so useStorage listens
|
||||
// for the custom event 'vuetools-storage' instead of the native 'storage' event.
|
||||
|
||||
it('updates ref when receiving a custom storage event with matching key', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('sync-key', 'initial', storage, {
|
||||
listenToStorageChanges: true,
|
||||
window: globalThis,
|
||||
});
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
|
||||
const detail: StorageEventLike = {
|
||||
key: 'sync-key',
|
||||
oldValue: 'initial',
|
||||
newValue: 'from-other-tab',
|
||||
storageArea: storage,
|
||||
};
|
||||
|
||||
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('from-other-tab');
|
||||
});
|
||||
|
||||
it('ignores storage events with a different key', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('my-key', 'initial', storage, {
|
||||
listenToStorageChanges: true,
|
||||
window: globalThis,
|
||||
});
|
||||
|
||||
const detail: StorageEventLike = {
|
||||
key: 'other-key',
|
||||
oldValue: null,
|
||||
newValue: 'other-value',
|
||||
storageArea: storage,
|
||||
};
|
||||
|
||||
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
});
|
||||
|
||||
it('resets to default when storage event has null key (clear)', async () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('clear-key', 'stored');
|
||||
|
||||
const state = useStorage<string>('clear-key', 'default', storage, {
|
||||
listenToStorageChanges: true,
|
||||
window: globalThis,
|
||||
});
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
|
||||
// null key means storage.clear() was called
|
||||
const detail: StorageEventLike = {
|
||||
key: null,
|
||||
oldValue: null,
|
||||
newValue: null,
|
||||
storageArea: storage,
|
||||
};
|
||||
|
||||
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
});
|
||||
|
||||
it('does not update ref on storage events when listenToStorageChanges is false', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('no-listen', 'initial', storage, {
|
||||
listenToStorageChanges: false,
|
||||
window: globalThis,
|
||||
});
|
||||
|
||||
const detail: StorageEventLike = {
|
||||
key: 'no-listen',
|
||||
oldValue: null,
|
||||
newValue: 'changed',
|
||||
storageArea: storage,
|
||||
};
|
||||
|
||||
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
});
|
||||
|
||||
// --- Custom storage event for non-native backends ---
|
||||
|
||||
it('responds to custom storage events for non-native backends', async () => {
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('custom-backend', 'initial', storage, {
|
||||
listenToStorageChanges: true,
|
||||
window: globalThis,
|
||||
});
|
||||
|
||||
const detail: StorageEventLike = {
|
||||
key: 'custom-backend',
|
||||
oldValue: 'initial',
|
||||
newValue: 'updated-via-custom',
|
||||
storageArea: storage,
|
||||
};
|
||||
|
||||
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('updated-via-custom');
|
||||
});
|
||||
|
||||
// --- Reactive key ---
|
||||
|
||||
it('re-reads from storage when reactive key changes', async () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('key-a', 'value-a');
|
||||
storage.store.set('key-b', 'value-b');
|
||||
|
||||
const keyRef = ref('key-a');
|
||||
const state = useStorage<string>(keyRef, 'default', storage);
|
||||
|
||||
expect(state.value).toBe('value-a');
|
||||
|
||||
keyRef.value = 'key-b';
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('value-b');
|
||||
});
|
||||
|
||||
// --- initOnMounted ---
|
||||
|
||||
it('defers reading from storage until mounted when initOnMounted is true', async () => {
|
||||
const storage = createMockStorage();
|
||||
storage.store.set('mounted-key', 'stored-value');
|
||||
|
||||
// Outside component context, tryOnMounted calls fn synchronously
|
||||
const state = useStorage<string>('mounted-key', 'default', storage, {
|
||||
initOnMounted: true,
|
||||
});
|
||||
|
||||
// tryOnMounted calls fn synchronously when outside component context
|
||||
expect(state.value).toBe('stored-value');
|
||||
});
|
||||
|
||||
// --- eventFilter ---
|
||||
|
||||
it('applies event filter to writes', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const storage = createMockStorage();
|
||||
const state = useStorage<string>('filtered-key', 'initial', storage, {
|
||||
eventFilter: invoke => setTimeout(invoke, 100),
|
||||
});
|
||||
|
||||
const setItem = vi.spyOn(storage, 'setItem');
|
||||
setItem.mockClear();
|
||||
|
||||
state.value = 'filtered';
|
||||
await nextTick();
|
||||
|
||||
// Not written yet — debounced
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(setItem).toHaveBeenCalledWith('filtered-key', 'filtered');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
361
vue/toolkit/src/composables/storage/useStorage/index.ts
Normal file
361
vue/toolkit/src/composables/storage/useStorage/index.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { nextTick, ref, shallowRef, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { isBoolean, isNumber, isString, isObject, isMap, isSet, isDate } from '@robonen/stdlib';
|
||||
import type { ConfigurableFlush, ConfigurableWindow, RemovableRef } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
|
||||
export interface StorageSerializer<T> {
|
||||
read: (raw: string) => T;
|
||||
write: (value: T) => string;
|
||||
}
|
||||
|
||||
export const StorageSerializers: { [K: string]: StorageSerializer<any> } & {
|
||||
boolean: StorageSerializer<boolean>;
|
||||
number: StorageSerializer<number>;
|
||||
string: StorageSerializer<string>;
|
||||
object: StorageSerializer<any>;
|
||||
map: StorageSerializer<Map<any, any>>;
|
||||
set: StorageSerializer<Set<any>>;
|
||||
date: StorageSerializer<Date>;
|
||||
} = {
|
||||
boolean: {
|
||||
read: (v: string) => v === 'true',
|
||||
write: (v: boolean) => String(v),
|
||||
},
|
||||
number: {
|
||||
read: (v: string) => Number.parseFloat(v),
|
||||
write: (v: number) => String(v),
|
||||
},
|
||||
string: {
|
||||
read: (v: string) => v,
|
||||
write: (v: string) => v,
|
||||
},
|
||||
object: {
|
||||
read: (v: string) => JSON.parse(v),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
map: {
|
||||
read: (v: string) => new Map(JSON.parse(v)),
|
||||
write: (v: Map<any, any>) => JSON.stringify([...v.entries()]),
|
||||
},
|
||||
set: {
|
||||
read: (v: string) => new Set(JSON.parse(v)),
|
||||
write: (v: Set<any>) => JSON.stringify([...v]),
|
||||
},
|
||||
date: {
|
||||
read: (v: string) => new Date(v),
|
||||
write: (v: Date) => v.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
export interface StorageLike {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
}
|
||||
|
||||
export const customStorageEventName = 'vuetools-storage';
|
||||
|
||||
export interface StorageEventLike {
|
||||
storageArea: StorageLike | null;
|
||||
key: StorageEvent['key'];
|
||||
oldValue: StorageEvent['oldValue'];
|
||||
newValue: StorageEvent['newValue'];
|
||||
}
|
||||
|
||||
export interface UseStorageOptions<T> extends ConfigurableFlush, ConfigurableWindow, ConfigurableEventFilter {
|
||||
/**
|
||||
* Use shallowRef instead of ref for the internal state
|
||||
* @default true
|
||||
*/
|
||||
shallow?: boolean;
|
||||
/**
|
||||
* Watch for deep changes
|
||||
* @default true
|
||||
*/
|
||||
deep?: boolean;
|
||||
/**
|
||||
* Listen to storage changes from other tabs/windows
|
||||
* @default true
|
||||
*/
|
||||
listenToStorageChanges?: boolean;
|
||||
/**
|
||||
* Write the default value to the storage when it does not exist
|
||||
* @default true
|
||||
*/
|
||||
writeDefaults?: boolean;
|
||||
/**
|
||||
* Merge the default value with the stored value
|
||||
* @default false
|
||||
*/
|
||||
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
|
||||
/**
|
||||
* Custom serializer for reading/writing storage values
|
||||
*/
|
||||
serializer?: StorageSerializer<T>;
|
||||
/**
|
||||
* Error handler for read/write failures
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
/**
|
||||
* Wait for the component to be mounted before reading the storage
|
||||
*
|
||||
* Useful for SSR hydration to prevent mismatch
|
||||
* @default false
|
||||
*/
|
||||
initOnMounted?: boolean;
|
||||
}
|
||||
|
||||
export type UseStorageReturn<T> = RemovableRef<T>;
|
||||
|
||||
export function guessSerializer<T>(value: T): StorageSerializer<T> {
|
||||
if (isBoolean(value)) return StorageSerializers.boolean as any;
|
||||
if (isNumber(value)) return StorageSerializers.number as any;
|
||||
if (isString(value)) return StorageSerializers.string as any;
|
||||
if (isMap(value)) return StorageSerializers.map as any;
|
||||
if (isSet(value)) return StorageSerializers.set as any;
|
||||
if (isDate(value)) return StorageSerializers.date as any;
|
||||
if (isObject(value)) return StorageSerializers.object as any;
|
||||
|
||||
return StorageSerializers.object as any;
|
||||
}
|
||||
|
||||
export function shallowMerge<T>(stored: T, defaults: T): T {
|
||||
if (isObject(stored) && isObject(defaults))
|
||||
return { ...defaults, ...stored };
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useStorage
|
||||
* @category Storage
|
||||
* @description Reactive Storage binding — creates a ref synced with a storage backend
|
||||
*
|
||||
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
|
||||
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||
* @param {StorageLike} storage The storage backend
|
||||
* @param {UseStorageOptions<T>} [options={}] Options
|
||||
* @returns {RemovableRef<T>} A reactive ref synced with storage
|
||||
*
|
||||
* @example
|
||||
* const count = useStorage('my-count', 0, storage);
|
||||
*
|
||||
* @example
|
||||
* const state = useStorage('my-state', { hello: 'world' }, storage);
|
||||
*
|
||||
* @example
|
||||
* const id = useStorage('my-id', 'default', storage, {
|
||||
* serializer: { read: (v) => v, write: (v) => v },
|
||||
* });
|
||||
*
|
||||
* @since 0.0.12
|
||||
*/
|
||||
export function useStorage<T extends string>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useStorage<T extends number>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useStorage<T extends boolean>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useStorage<T>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useStorage<T = unknown>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
|
||||
export function useStorage<T>(
|
||||
key: MaybeRefOrGetter<string>,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
storage: StorageLike,
|
||||
options: UseStorageOptions<T> = {},
|
||||
): RemovableRef<T> {
|
||||
const {
|
||||
shallow = true,
|
||||
deep = true,
|
||||
flush = 'pre',
|
||||
writeDefaults = true,
|
||||
mergeDefaults = false,
|
||||
listenToStorageChanges = true,
|
||||
window = defaultWindow,
|
||||
eventFilter,
|
||||
initOnMounted = false,
|
||||
onError = console.error, // eslint-disable-line no-console
|
||||
} = options;
|
||||
|
||||
const defaults = toValue(initialValue);
|
||||
const serializer = options.serializer ?? guessSerializer(defaults);
|
||||
|
||||
const data = (shallow ? shallowRef : ref)(defaults) as RemovableRef<T>;
|
||||
const resolvedKey = () => toValue(key);
|
||||
|
||||
function read(event?: StorageEventLike): T {
|
||||
const rawValue = event
|
||||
? event.newValue
|
||||
: storage.getItem(resolvedKey());
|
||||
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
if (writeDefaults && defaults !== undefined && defaults !== null) {
|
||||
try {
|
||||
storage.setItem(resolvedKey(), serializer.write(defaults));
|
||||
}
|
||||
catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!event && mergeDefaults) {
|
||||
const value = serializer.read(rawValue);
|
||||
|
||||
return typeof mergeDefaults === 'function'
|
||||
? mergeDefaults(value, defaults)
|
||||
: shallowMerge(value, defaults);
|
||||
}
|
||||
|
||||
return serializer.read(rawValue);
|
||||
}
|
||||
catch (e) {
|
||||
onError(e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchWriteEvent(oldValue: string | null, newValue: string | null) {
|
||||
if (!window)
|
||||
return;
|
||||
|
||||
const payload = {
|
||||
key: resolvedKey(),
|
||||
oldValue,
|
||||
newValue,
|
||||
storageArea: storage as Storage,
|
||||
};
|
||||
|
||||
// Use native StorageEvent for built-in Storage, CustomEvent for custom backends
|
||||
window.dispatchEvent(
|
||||
storage instanceof Storage
|
||||
? new StorageEvent('storage', payload)
|
||||
: new CustomEvent<StorageEventLike>(customStorageEventName, { detail: payload }),
|
||||
);
|
||||
}
|
||||
|
||||
function write(value: T) {
|
||||
try {
|
||||
const oldValue = storage.getItem(resolvedKey());
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
dispatchWriteEvent(oldValue, null);
|
||||
storage.removeItem(resolvedKey());
|
||||
}
|
||||
else {
|
||||
const serialized = serializer.write(value);
|
||||
|
||||
if (oldValue !== serialized) {
|
||||
storage.setItem(resolvedKey(), serialized);
|
||||
dispatchWriteEvent(oldValue, serialized);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Write-lock prevents the data watcher from writing back to storage
|
||||
// when data.value is being updated programmatically (from storage reads,
|
||||
// key changes, or cross-tab events). The lock is released via nextTick
|
||||
// so it persists through the pre-flush watcher cycle.
|
||||
let writeLock = false;
|
||||
|
||||
function lockWritesUntilFlush() {
|
||||
writeLock = true;
|
||||
nextTick(() => {
|
||||
writeLock = false;
|
||||
});
|
||||
}
|
||||
|
||||
function update(event: StorageEventLike) {
|
||||
if (event.storageArea !== storage)
|
||||
return;
|
||||
|
||||
if (event.key === null) {
|
||||
lockWritesUntilFlush();
|
||||
data.value = defaults;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== resolvedKey())
|
||||
return;
|
||||
|
||||
const currentSerialized = serializer.write(data.value);
|
||||
|
||||
if (event.newValue !== currentSerialized) {
|
||||
lockWritesUntilFlush();
|
||||
data.value = read(event);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFromCustomEvent(event: CustomEvent<StorageEventLike>) {
|
||||
update(event.detail);
|
||||
}
|
||||
|
||||
// Apply event filter if provided
|
||||
const writeWithFilter: (value: T) => void = eventFilter
|
||||
? (value: T) => (eventFilter as EventFilter)(() => write(value))
|
||||
: write;
|
||||
|
||||
// Initialize data from storage BEFORE creating watchers to avoid
|
||||
// Vue 3.5 pause/resume replay race conditions
|
||||
if (!initOnMounted) {
|
||||
data.value = read();
|
||||
}
|
||||
|
||||
// Data write watcher — skips writes when writeLock is active
|
||||
watch(data, (newValue) => {
|
||||
if (writeLock)
|
||||
return;
|
||||
|
||||
writeWithFilter(newValue);
|
||||
}, { flush, deep });
|
||||
|
||||
// Watch for reactive key changes
|
||||
if (typeof key !== 'string') {
|
||||
watch(resolvedKey, () => {
|
||||
lockWritesUntilFlush();
|
||||
data.value = read();
|
||||
});
|
||||
}
|
||||
|
||||
// Event listeners for cross-tab synchronization
|
||||
let firstMounted = false;
|
||||
|
||||
const onStorageEvent = (ev: StorageEvent): void => {
|
||||
if (initOnMounted && !firstMounted)
|
||||
return;
|
||||
|
||||
update(ev);
|
||||
};
|
||||
|
||||
const onStorageCustomEvent = (ev: CustomEvent<StorageEventLike>): void => {
|
||||
if (initOnMounted && !firstMounted)
|
||||
return;
|
||||
|
||||
updateFromCustomEvent(ev);
|
||||
};
|
||||
|
||||
if (window && listenToStorageChanges) {
|
||||
if (storage instanceof Storage)
|
||||
useEventListener(window, 'storage', onStorageEvent, { passive: true });
|
||||
else
|
||||
useEventListener(window as any, customStorageEventName as any, onStorageCustomEvent as any);
|
||||
}
|
||||
|
||||
if (initOnMounted) {
|
||||
tryOnMounted(() => {
|
||||
firstMounted = true;
|
||||
lockWritesUntilFlush();
|
||||
data.value = read();
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { useStorageAsync } from '.';
|
||||
import type { StorageLikeAsync } from '.';
|
||||
|
||||
function createMockAsyncStorage(): StorageLikeAsync & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
store,
|
||||
getItem: async (key: string) => store.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => { store.set(key, value); },
|
||||
removeItem: async (key: string) => { store.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
function createDelayedAsyncStorage(delay: number): StorageLikeAsync & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
store,
|
||||
getItem: (key: string) => new Promise(resolve => setTimeout(() => resolve(store.get(key) ?? null), delay)),
|
||||
setItem: (key: string, value: string) => new Promise(resolve => setTimeout(() => {
|
||||
store.set(key, value);
|
||||
resolve();
|
||||
}, delay)),
|
||||
removeItem: (key: string) => new Promise(resolve => setTimeout(() => {
|
||||
store.delete(key);
|
||||
resolve();
|
||||
}, delay)),
|
||||
};
|
||||
}
|
||||
|
||||
describe(useStorageAsync, () => {
|
||||
// --- Basic read/write ---
|
||||
|
||||
it('returns default value before storage is ready', () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const { state, isReady } = useStorageAsync('key', 'default', storage);
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
expect(isReady.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reads existing value from async storage', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('key', 'stored');
|
||||
|
||||
const { state, isReady } = await useStorageAsync('key', 'default', storage);
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('writes value to async storage on change', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const { state } = await useStorageAsync<string>('key', 'initial', storage);
|
||||
|
||||
state.value = 'updated';
|
||||
await nextTick();
|
||||
|
||||
// Allow async write to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('key')).toBe('updated');
|
||||
});
|
||||
|
||||
// --- Types ---
|
||||
|
||||
it('reads and writes a number', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('num', '42');
|
||||
|
||||
const { state } = await useStorageAsync<number>('num', 0, storage);
|
||||
|
||||
expect(state.value).toBe(42);
|
||||
|
||||
state.value = 100;
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('num')).toBe('100');
|
||||
});
|
||||
|
||||
it('reads and writes a boolean', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('flag', 'true');
|
||||
|
||||
const { state } = await useStorageAsync('flag', false, storage);
|
||||
|
||||
expect(state.value).toBeTruthy();
|
||||
|
||||
state.value = false;
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('flag')).toBe('false');
|
||||
});
|
||||
|
||||
it('reads and writes an object', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('obj', JSON.stringify({ a: 1 }));
|
||||
|
||||
const { state } = await useStorageAsync('obj', { a: 0, b: 2 }, storage);
|
||||
|
||||
expect(state.value).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
// --- Awaitable ---
|
||||
|
||||
it('is awaitable and resolves after initial read', async () => {
|
||||
const storage = createDelayedAsyncStorage(50);
|
||||
storage.store.set('delayed', 'loaded');
|
||||
|
||||
const { state, isReady } = await useStorageAsync('delayed', 'default', storage);
|
||||
|
||||
expect(state.value).toBe('loaded');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
});
|
||||
|
||||
// --- onReady callback ---
|
||||
|
||||
it('calls onReady callback after initial load', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('ready', 'ready-value');
|
||||
|
||||
const onReady = vi.fn();
|
||||
|
||||
await useStorageAsync('ready', 'default', storage, { onReady });
|
||||
|
||||
expect(onReady).toHaveBeenCalledOnce();
|
||||
expect(onReady).toHaveBeenCalledWith('ready-value');
|
||||
});
|
||||
|
||||
it('calls onReady with default when key not in storage', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const onReady = vi.fn();
|
||||
|
||||
await useStorageAsync('missing', 'fallback', storage, { onReady });
|
||||
|
||||
expect(onReady).toHaveBeenCalledWith('fallback');
|
||||
});
|
||||
|
||||
// --- Merge defaults ---
|
||||
|
||||
it('merges defaults with stored value', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('merge', JSON.stringify({ hello: 'stored' }));
|
||||
|
||||
const { state } = await useStorageAsync(
|
||||
'merge',
|
||||
{ hello: 'default', greeting: 'hi' },
|
||||
storage,
|
||||
{ mergeDefaults: true },
|
||||
);
|
||||
|
||||
expect(state.value.hello).toBe('stored');
|
||||
expect(state.value.greeting).toBe('hi');
|
||||
});
|
||||
|
||||
it('uses custom merge function', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
|
||||
|
||||
const { state } = await useStorageAsync(
|
||||
'merge-fn',
|
||||
{ a: 0, b: 2 },
|
||||
storage,
|
||||
{
|
||||
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(state.value).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
// --- Custom serializer ---
|
||||
|
||||
it('uses custom async serializer', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('custom', '1,2,3');
|
||||
|
||||
const serializer = {
|
||||
read: async (v: string) => v.split(',').map(Number),
|
||||
write: async (v: number[]) => v.join(','),
|
||||
};
|
||||
|
||||
const { state } = await useStorageAsync('custom', [0], storage, { serializer });
|
||||
|
||||
expect(state.value).toEqual([1, 2, 3]);
|
||||
|
||||
state.value = [4, 5, 6];
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('custom')).toBe('4,5,6');
|
||||
});
|
||||
|
||||
// --- Null / remove ---
|
||||
|
||||
it('removes from storage when value is set to null', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('nullable', 'exists');
|
||||
|
||||
const { state } = await useStorageAsync<string | null>('nullable', 'default', storage);
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.has('nullable')).toBeFalsy();
|
||||
});
|
||||
|
||||
// --- Error handling ---
|
||||
|
||||
it('calls onError when read fails', async () => {
|
||||
const onError = vi.fn();
|
||||
const storage: StorageLikeAsync = {
|
||||
getItem: async () => { throw new Error('read failure'); },
|
||||
setItem: async () => {},
|
||||
removeItem: async () => {},
|
||||
};
|
||||
|
||||
const { state } = await useStorageAsync('fail-read', 'fallback', storage, { onError });
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(state.value).toBe('fallback');
|
||||
});
|
||||
|
||||
it('calls onError when write fails', async () => {
|
||||
const onError = vi.fn();
|
||||
const storage: StorageLikeAsync = {
|
||||
getItem: async () => null,
|
||||
setItem: async () => { throw new Error('write failure'); },
|
||||
removeItem: async () => {},
|
||||
};
|
||||
|
||||
const { state } = await useStorageAsync<string>('fail-write', 'initial', storage, { onError });
|
||||
|
||||
// One error from writeDefaults persisting initial value
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
|
||||
state.value = 'new';
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// Another error from the write triggered by value change
|
||||
expect(onError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// --- No unnecessary write-back on initial read ---
|
||||
|
||||
it('does not write back to storage after initial read', async () => {
|
||||
const setItem = vi.fn(async () => {});
|
||||
const storage: StorageLikeAsync = {
|
||||
getItem: async () => 'existing',
|
||||
setItem,
|
||||
removeItem: async () => {},
|
||||
};
|
||||
|
||||
await useStorageAsync('key', 'default', storage);
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write back to storage when key is missing and writeDefaults is false', async () => {
|
||||
const setItem = vi.fn(async () => {});
|
||||
const storage: StorageLikeAsync = {
|
||||
getItem: async () => null,
|
||||
setItem,
|
||||
removeItem: async () => {},
|
||||
};
|
||||
|
||||
await useStorageAsync('key', 'default', storage, { writeDefaults: false });
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- shallow: false with deep mutation ---
|
||||
|
||||
it('writes to storage on deep object mutation with shallow: false', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const { state } = await useStorageAsync(
|
||||
'deep-obj',
|
||||
{ nested: { count: 0 } },
|
||||
storage,
|
||||
{ shallow: false },
|
||||
);
|
||||
|
||||
state.value.nested.count = 42;
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(JSON.parse(storage.store.get('deep-obj')!)).toEqual({ nested: { count: 42 } });
|
||||
});
|
||||
|
||||
// --- Multiple rapid assignments ---
|
||||
|
||||
it('handles multiple rapid assignments', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
const { state } = await useStorageAsync<string>('rapid', 'initial', storage);
|
||||
|
||||
state.value = 'first';
|
||||
state.value = 'second';
|
||||
state.value = 'third';
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(storage.store.get('rapid')).toBe('third');
|
||||
});
|
||||
|
||||
// --- writeDefaults ---
|
||||
|
||||
it('persists defaults to storage when key does not exist', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
|
||||
await useStorageAsync('new-key', 'default-val', storage);
|
||||
|
||||
expect(storage.store.get('new-key')).toBe('default-val');
|
||||
});
|
||||
|
||||
it('does not persist defaults when writeDefaults is false', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
|
||||
await useStorageAsync('new-key', 'default-val', storage, { writeDefaults: false });
|
||||
|
||||
expect(storage.store.has('new-key')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not overwrite existing value with defaults', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('existing', 'stored');
|
||||
|
||||
await useStorageAsync('existing', 'default', storage);
|
||||
|
||||
expect(storage.store.get('existing')).toBe('stored');
|
||||
});
|
||||
|
||||
// --- Reactive key ---
|
||||
|
||||
it('re-reads from async storage when reactive key changes', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('key-a', 'value-a');
|
||||
storage.store.set('key-b', 'value-b');
|
||||
|
||||
const keyRef = ref('key-a');
|
||||
const { state } = await useStorageAsync<string>(keyRef, 'default', storage);
|
||||
|
||||
expect(state.value).toBe('value-a');
|
||||
|
||||
keyRef.value = 'key-b';
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(state.value).toBe('value-b');
|
||||
});
|
||||
|
||||
// --- eventFilter ---
|
||||
|
||||
it('applies event filter to writes', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
let captured: (() => void) | undefined;
|
||||
|
||||
const { state } = await useStorageAsync<string>('filtered', 'initial', storage, {
|
||||
eventFilter: (invoke) => { captured = invoke; },
|
||||
});
|
||||
|
||||
const setItem = vi.spyOn(storage, 'setItem');
|
||||
(setItem as any).mockClear();
|
||||
|
||||
state.value = 'filtered-value';
|
||||
await nextTick();
|
||||
|
||||
// Not written yet — held by filter
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
expect(captured).toBeDefined();
|
||||
|
||||
// Release the filter
|
||||
captured!();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(setItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
266
vue/toolkit/src/composables/storage/useStorageAsync/index.ts
Normal file
266
vue/toolkit/src/composables/storage/useStorageAsync/index.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { computed, ref, shallowRef, watch, toValue } from 'vue';
|
||||
import type { Ref, ShallowRef, MaybeRefOrGetter, UnwrapRef } from 'vue';
|
||||
import type { ConfigurableFlush, ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { guessSerializer, shallowMerge } from '../useStorage';
|
||||
import type { StorageEventLike } from '../useStorage';
|
||||
|
||||
export interface StorageSerializerAsync<T> {
|
||||
read: (raw: string) => T | Promise<T>;
|
||||
write: (value: T) => string | Promise<string>;
|
||||
}
|
||||
|
||||
export interface StorageLikeAsync {
|
||||
getItem: (key: string) => string | null | Promise<string | null>;
|
||||
setItem: (key: string, value: string) => void | Promise<void>;
|
||||
removeItem: (key: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseStorageAsyncOptions<T, Shallow extends boolean = true> extends ConfigurableFlush, ConfigurableWindow, ConfigurableEventFilter {
|
||||
/**
|
||||
* Use shallowRef instead of ref for the internal state
|
||||
* @default true
|
||||
*/
|
||||
shallow?: Shallow;
|
||||
/**
|
||||
* Watch for deep changes
|
||||
* @default true
|
||||
*/
|
||||
deep?: boolean;
|
||||
/**
|
||||
* Listen to storage changes from other tabs/windows
|
||||
* @default true
|
||||
*/
|
||||
listenToStorageChanges?: boolean;
|
||||
/**
|
||||
* Write the default value to the storage when it does not exist
|
||||
* @default true
|
||||
*/
|
||||
writeDefaults?: boolean;
|
||||
/**
|
||||
* Custom serializer for reading/writing storage values
|
||||
*/
|
||||
serializer?: StorageSerializerAsync<T>;
|
||||
/**
|
||||
* Merge the default value with the stored value
|
||||
* @default false
|
||||
*/
|
||||
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
|
||||
/**
|
||||
* Called once when the initial value has been loaded from storage
|
||||
*/
|
||||
onReady?: (value: T) => void;
|
||||
/**
|
||||
* Error handler for read/write failures
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
/**
|
||||
* Wait for the component to be mounted before reading the storage
|
||||
*
|
||||
* Useful for SSR hydration to prevent mismatch
|
||||
* @default false
|
||||
*/
|
||||
initOnMounted?: boolean;
|
||||
}
|
||||
|
||||
export interface UseStorageAsyncReturnBase<T, Shallow extends boolean> {
|
||||
state: Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||
isReady: Ref<boolean>;
|
||||
}
|
||||
|
||||
export type UseStorageAsyncReturn<T, Shallow extends boolean>
|
||||
= & UseStorageAsyncReturnBase<T, Shallow>
|
||||
& PromiseLike<UseStorageAsyncReturnBase<T, Shallow>>;
|
||||
|
||||
/**
|
||||
* @name useStorageAsync
|
||||
* @category Storage
|
||||
* @description Reactive Storage binding with async support — creates a ref synced with an async storage backend
|
||||
*
|
||||
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
|
||||
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||
* @param {StorageLikeAsync} storage The async storage backend
|
||||
* @param {UseStorageAsyncOptions<T>} [options={}] Options
|
||||
* @returns {UseStorageAsyncReturn<T, Shallow>} An object with state ref and isReady flag, also awaitable
|
||||
*
|
||||
* @example
|
||||
* const { state } = useStorageAsync('access-token', '', asyncStorage);
|
||||
*
|
||||
* @example
|
||||
* const { state, isReady } = await useStorageAsync('settings', { theme: 'dark' }, asyncStorage);
|
||||
*
|
||||
* @example
|
||||
* const { state } = useStorageAsync('key', 'default', asyncStorage, {
|
||||
* onReady: (value) => console.log('Loaded:', value),
|
||||
* });
|
||||
*
|
||||
* @since 0.0.12
|
||||
*/
|
||||
export function useStorageAsync<T extends string, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T extends number, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T extends boolean, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T = unknown, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||
export function useStorageAsync<T, Shallow extends boolean = true>(
|
||||
key: MaybeRefOrGetter<string>,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
storage: StorageLikeAsync,
|
||||
options: UseStorageAsyncOptions<T, Shallow> = {},
|
||||
): UseStorageAsyncReturn<T, Shallow> {
|
||||
const {
|
||||
shallow = true,
|
||||
deep = true,
|
||||
flush = 'pre',
|
||||
writeDefaults = true,
|
||||
mergeDefaults = false,
|
||||
listenToStorageChanges = true,
|
||||
window = defaultWindow,
|
||||
eventFilter,
|
||||
initOnMounted = false,
|
||||
onReady,
|
||||
onError = console.error, // eslint-disable-line no-console
|
||||
} = options;
|
||||
|
||||
const defaults = toValue(initialValue);
|
||||
const serializer = options.serializer ?? guessSerializer(defaults);
|
||||
|
||||
const state = (shallow ? shallowRef : ref)(defaults) as Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||
const isReady = ref(false);
|
||||
const keyComputed = computed<string>(() => toValue(key));
|
||||
|
||||
async function read(event?: StorageEventLike): Promise<T> {
|
||||
try {
|
||||
const rawValue = event
|
||||
? event.newValue
|
||||
: await storage.getItem(keyComputed.value);
|
||||
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
if (writeDefaults && defaults !== undefined && defaults !== null) {
|
||||
try {
|
||||
await storage.setItem(keyComputed.value, await serializer.write(defaults));
|
||||
}
|
||||
catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
if (!event && mergeDefaults) {
|
||||
const value: T = await serializer.read(rawValue) as T;
|
||||
|
||||
return typeof mergeDefaults === 'function'
|
||||
? mergeDefaults(value, defaults)
|
||||
: shallowMerge(value, defaults);
|
||||
}
|
||||
|
||||
return await serializer.read(rawValue) as T;
|
||||
}
|
||||
catch (e) {
|
||||
onError(e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
async function write(value: T) {
|
||||
try {
|
||||
if (value === undefined || value === null) {
|
||||
await storage.removeItem(keyComputed.value);
|
||||
}
|
||||
else {
|
||||
const raw = await serializer.write(value);
|
||||
await storage.setItem(keyComputed.value, raw);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply event filter if provided
|
||||
const writeWithFilter: (value: T) => void = eventFilter
|
||||
? (value: T) => (eventFilter as EventFilter)(() => write(value))
|
||||
: (value: T) => { write(value); };
|
||||
|
||||
let stopWatch: (() => void) | null = null;
|
||||
let stopKeyWatch: (() => void) | null = null;
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
stopWatch?.();
|
||||
stopKeyWatch?.();
|
||||
});
|
||||
|
||||
// Event listeners for cross-tab synchronization
|
||||
let firstMounted = false;
|
||||
|
||||
if (window && listenToStorageChanges) {
|
||||
useEventListener(window, 'storage', (ev: StorageEvent) => {
|
||||
if (initOnMounted && !firstMounted)
|
||||
return;
|
||||
if (ev.key !== keyComputed.value)
|
||||
return;
|
||||
if (ev.storageArea !== storage)
|
||||
return;
|
||||
|
||||
Promise.resolve().then(() => read(ev)).then((value) => {
|
||||
(state as Ref).value = value;
|
||||
});
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
const shell: UseStorageAsyncReturnBase<T, Shallow> = {
|
||||
state,
|
||||
isReady,
|
||||
};
|
||||
|
||||
function performInit() {
|
||||
return read().then((value) => {
|
||||
(state as Ref).value = value;
|
||||
isReady.value = true;
|
||||
onReady?.(value);
|
||||
|
||||
// Set up watcher AFTER initial state is set — avoids write-back on init
|
||||
const stop = watch(state, (newValue) => {
|
||||
writeWithFilter(newValue as T);
|
||||
}, { flush, deep });
|
||||
|
||||
stopWatch = stop;
|
||||
|
||||
// Watch for key changes
|
||||
stopKeyWatch = watch(keyComputed, () => {
|
||||
read().then((v) => {
|
||||
(state as Ref).value = v;
|
||||
});
|
||||
}, { flush });
|
||||
|
||||
return shell;
|
||||
});
|
||||
}
|
||||
|
||||
let readyPromise: Promise<UseStorageAsyncReturnBase<T, Shallow>>;
|
||||
|
||||
if (initOnMounted) {
|
||||
readyPromise = new Promise<UseStorageAsyncReturnBase<T, Shallow>>((resolve) => {
|
||||
tryOnMounted(() => {
|
||||
firstMounted = true;
|
||||
performInit().then(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
else {
|
||||
readyPromise = performInit();
|
||||
}
|
||||
|
||||
return {
|
||||
...shell,
|
||||
// eslint-disable-next-line unicorn/no-thenable
|
||||
then(onFulfilled, onRejected) {
|
||||
return readyPromise.then(onFulfilled, onRejected);
|
||||
},
|
||||
};
|
||||
}
|
||||
1
vue/toolkit/src/composables/utilities/index.ts
Normal file
1
vue/toolkit/src/composables/utilities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './useOffsetPagination';
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { useOffsetPagination } from '.';
|
||||
|
||||
describe(useOffsetPagination, () => {
|
||||
it('initialize with default values without options', () => {
|
||||
const { currentPage, currentPageSize, totalPages, isFirstPage } = useOffsetPagination({});
|
||||
|
||||
expect(currentPage.value).toBe(1);
|
||||
expect(currentPageSize.value).toBe(10);
|
||||
expect(totalPages.value).toBe(Infinity);
|
||||
expect(isFirstPage.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calculate total pages correctly', () => {
|
||||
const { totalPages } = useOffsetPagination({ total: 100, pageSize: 10 });
|
||||
|
||||
expect(totalPages.value).toBe(10);
|
||||
});
|
||||
|
||||
it('update current page correctly', () => {
|
||||
const { currentPage, next, previous, select } = useOffsetPagination({ total: 100, pageSize: 10 });
|
||||
|
||||
next();
|
||||
expect(currentPage.value).toBe(2);
|
||||
|
||||
previous();
|
||||
expect(currentPage.value).toBe(1);
|
||||
|
||||
select(5);
|
||||
expect(currentPage.value).toBe(5);
|
||||
});
|
||||
|
||||
it('handle out of bounds increments correctly', () => {
|
||||
const { currentPage, next, previous } = useOffsetPagination({ total: 10, pageSize: 5 });
|
||||
|
||||
next();
|
||||
next();
|
||||
next();
|
||||
|
||||
expect(currentPage.value).toBe(2);
|
||||
|
||||
previous();
|
||||
previous();
|
||||
previous();
|
||||
|
||||
expect(currentPage.value).toBe(1);
|
||||
});
|
||||
|
||||
it('handle page boundaries correctly', () => {
|
||||
const { currentPage, isFirstPage, isLastPage } = useOffsetPagination({ total: 20, pageSize: 10 });
|
||||
|
||||
expect(currentPage.value).toBe(1);
|
||||
expect(isFirstPage.value).toBeTruthy();
|
||||
expect(isLastPage.value).toBeFalsy();
|
||||
|
||||
currentPage.value = 2;
|
||||
|
||||
expect(currentPage.value).toBe(2);
|
||||
expect(isFirstPage.value).toBeFalsy();
|
||||
expect(isLastPage.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('call onPageChange callback', async () => {
|
||||
const onPageChange = vi.fn();
|
||||
const { currentPage, next } = useOffsetPagination({ total: 100, pageSize: 10, onPageChange });
|
||||
|
||||
next();
|
||||
await nextTick();
|
||||
|
||||
expect(onPageChange).toHaveBeenCalledTimes(1);
|
||||
expect(onPageChange.mock.calls[0]![0]).toHaveProperty('currentPage', currentPage.value);
|
||||
});
|
||||
|
||||
it('call onPageSizeChange callback', async () => {
|
||||
const onPageSizeChange = vi.fn();
|
||||
const pageSize = ref(10);
|
||||
const { currentPageSize } = useOffsetPagination({ total: 100, pageSize, onPageSizeChange });
|
||||
|
||||
pageSize.value = 20;
|
||||
await nextTick();
|
||||
|
||||
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||
expect(onPageSizeChange.mock.calls[0]![0]).toHaveProperty('currentPageSize', currentPageSize.value);
|
||||
});
|
||||
|
||||
it('call onPageCountChange callback', async () => {
|
||||
const onTotalPagesChange = vi.fn();
|
||||
const total = ref(100);
|
||||
const { totalPages } = useOffsetPagination({ total, pageSize: 10, onTotalPagesChange });
|
||||
|
||||
total.value = 200;
|
||||
await nextTick();
|
||||
|
||||
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||
expect(onTotalPagesChange.mock.calls[0]![0]).toHaveProperty('totalPages', totalPages.value);
|
||||
});
|
||||
|
||||
it('handle complex reactive options', async () => {
|
||||
const total = ref(100);
|
||||
const pageSize = ref(10);
|
||||
const page = ref(1);
|
||||
|
||||
const onPageChange = vi.fn();
|
||||
const onPageSizeChange = vi.fn();
|
||||
const onTotalPagesChange = vi.fn();
|
||||
|
||||
const { currentPage, currentPageSize, totalPages } = useOffsetPagination({
|
||||
total,
|
||||
pageSize,
|
||||
page,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onTotalPagesChange,
|
||||
});
|
||||
|
||||
// Initial values
|
||||
expect(currentPage.value).toBe(1);
|
||||
expect(currentPageSize.value).toBe(10);
|
||||
expect(totalPages.value).toBe(10);
|
||||
expect(onPageChange).toHaveBeenCalledTimes(0);
|
||||
expect(onPageSizeChange).toHaveBeenCalledTimes(0);
|
||||
expect(onTotalPagesChange).toHaveBeenCalledTimes(0);
|
||||
|
||||
total.value = 300;
|
||||
pageSize.value = 15;
|
||||
page.value = 2;
|
||||
await nextTick();
|
||||
|
||||
// Valid values after changes
|
||||
expect(currentPage.value).toBe(2);
|
||||
expect(currentPageSize.value).toBe(15);
|
||||
expect(totalPages.value).toBe(20);
|
||||
expect(onPageChange).toHaveBeenCalledTimes(1);
|
||||
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
page.value = 21;
|
||||
await nextTick();
|
||||
|
||||
// Invalid values after changes
|
||||
expect(currentPage.value).toBe(20);
|
||||
expect(onPageChange).toHaveBeenCalledTimes(2);
|
||||
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { computed, reactive, toValue, watch } from 'vue';
|
||||
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, UnwrapNestedRefs, WritableComputedRef } from 'vue';
|
||||
import { useClamp } from '@/composables/math/useClamp';
|
||||
|
||||
// TODO: sync returned refs with passed refs
|
||||
|
||||
export interface UseOffsetPaginationOptions {
|
||||
total?: MaybeRefOrGetter<number>;
|
||||
pageSize?: MaybeRef<number>;
|
||||
page?: MaybeRef<number>;
|
||||
onPageChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
|
||||
onPageSizeChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
|
||||
onTotalPagesChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
|
||||
}
|
||||
|
||||
export interface UseOffsetPaginationReturn {
|
||||
currentPage: WritableComputedRef<number>;
|
||||
currentPageSize: WritableComputedRef<number>;
|
||||
totalPages: ComputedRef<number>;
|
||||
isFirstPage: ComputedRef<boolean>;
|
||||
isLastPage: ComputedRef<boolean>;
|
||||
next: VoidFunction;
|
||||
previous: VoidFunction;
|
||||
select: (page: number) => void;
|
||||
}
|
||||
|
||||
export type UseOffsetPaginationInfinityReturn = Omit<UseOffsetPaginationReturn, 'isLastPage'>;
|
||||
|
||||
/**
|
||||
* @name useOffsetPagination
|
||||
* @category Utilities
|
||||
* @description A composable function that provides pagination functionality for offset based pagination
|
||||
*
|
||||
* @param {UseOffsetPaginationOptions} options The options for the pagination
|
||||
* @param {MaybeRefOrGetter<number>} options.total The total number of items
|
||||
* @param {MaybeRef<number>} options.pageSize The number of items per page
|
||||
* @param {MaybeRef<number>} options.page The current page
|
||||
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageChange A callback that is called when the page changes
|
||||
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageSizeChange A callback that is called when the page size changes
|
||||
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onTotalPagesChange A callback that is called when the total number of pages changes
|
||||
* @returns {UseOffsetPaginationReturn} The pagination object
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* currentPage,
|
||||
* currentPageSize,
|
||||
* totalPages,
|
||||
* isFirstPage,
|
||||
* isLastPage,
|
||||
* next,
|
||||
* previous,
|
||||
* select,
|
||||
* } = useOffsetPagination({ total: 100, pageSize: 10, page: 1 });
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* currentPage,
|
||||
* } = useOffsetPagination({
|
||||
* total: 100,
|
||||
* pageSize: 10,
|
||||
* page: 1,
|
||||
* onPageChange: ({ currentPage }) => console.log(currentPage),
|
||||
* onPageSizeChange: ({ currentPageSize }) => console.log(currentPageSize),
|
||||
* onTotalPagesChange: ({ totalPages }) => console.log(totalPages),
|
||||
* });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useOffsetPagination(options: Omit<UseOffsetPaginationOptions, 'total'>): UseOffsetPaginationInfinityReturn;
|
||||
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn;
|
||||
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn {
|
||||
const {
|
||||
total = Number.POSITIVE_INFINITY,
|
||||
pageSize = 10,
|
||||
page = 1,
|
||||
} = options;
|
||||
|
||||
const currentPageSize = useClamp(pageSize, 1, Number.POSITIVE_INFINITY);
|
||||
|
||||
const totalPages = computed(() => Math.max(
|
||||
1,
|
||||
Math.ceil(toValue(total) / toValue(currentPageSize)),
|
||||
));
|
||||
|
||||
const currentPage = useClamp(page, 1, totalPages);
|
||||
|
||||
const isFirstPage = computed(() => currentPage.value === 1);
|
||||
const isLastPage = computed(() => currentPage.value === totalPages.value);
|
||||
|
||||
const next = () => currentPage.value++;
|
||||
const previous = () => currentPage.value--;
|
||||
const select = (page: number) => {
|
||||
currentPage.value = page;
|
||||
};
|
||||
|
||||
const returnValue = {
|
||||
currentPage,
|
||||
currentPageSize,
|
||||
totalPages,
|
||||
isFirstPage,
|
||||
isLastPage,
|
||||
next,
|
||||
previous,
|
||||
select,
|
||||
};
|
||||
|
||||
// NOTE: Don't forget to await nextTick() after calling next() or previous() to ensure the callback is called
|
||||
|
||||
if (options.onPageChange) {
|
||||
watch(currentPage, () => {
|
||||
options.onPageChange!(reactive(returnValue));
|
||||
});
|
||||
}
|
||||
|
||||
if (options.onPageSizeChange) {
|
||||
watch(currentPageSize, () => {
|
||||
options.onPageSizeChange!(reactive(returnValue));
|
||||
});
|
||||
}
|
||||
|
||||
if (options.onTotalPagesChange) {
|
||||
watch(totalPages, () => {
|
||||
options.onTotalPagesChange!(reactive(returnValue));
|
||||
});
|
||||
}
|
||||
|
||||
return returnValue;
|
||||
}
|
||||
3
vue/toolkit/src/index.ts
Normal file
3
vue/toolkit/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './composables';
|
||||
export * from './utils';
|
||||
export * from './types';
|
||||
10
vue/toolkit/src/types/flush.ts
Normal file
10
vue/toolkit/src/types/flush.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { WatchOptions } from 'vue';
|
||||
|
||||
export interface ConfigurableFlush {
|
||||
/**
|
||||
* Timing for the watcher flush
|
||||
*
|
||||
* @default 'pre'
|
||||
*/
|
||||
flush?: WatchOptions['flush'];
|
||||
}
|
||||
4
vue/toolkit/src/types/index.ts
Normal file
4
vue/toolkit/src/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './flush';
|
||||
export * from './ref';
|
||||
export * from './resumable';
|
||||
export * from './window';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user