1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 02:44:45 +00:00

feat: update package.json exports to support new module formats and types

This commit is contained in:
2026-03-08 08:19:01 +07:00
parent bcc9cb2915
commit a996eb74b9
22 changed files with 1022 additions and 38 deletions

View File

@@ -25,9 +25,14 @@
],
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"scripts": {
@@ -51,6 +56,7 @@
"vue-tsc": "^3.2.5"
},
"dependencies": {
"@robonen/platform": "workspace:*",
"@robonen/vue": "workspace:*",
"@vue/shared": "catalog:",
"vue": "catalog:"

View File

@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import {
provideConfig,
provideAppConfig,
useConfig,
} from '..';
// --- useConfig ---
describe('useConfig', () => {
it('returns default config when no provider exists', () => {
const wrapper = mount(
defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', {
'data-dir': this.config.dir.value,
'data-target': this.config.teleportTarget.value,
});
},
}),
);
expect(wrapper.find('div').attributes('data-dir')).toBe('ltr');
expect(wrapper.find('div').attributes('data-target')).toBe('body');
wrapper.unmount();
});
it('returns custom config from provideConfig', () => {
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', {
'data-dir': this.config.dir.value,
'data-target': this.config.teleportTarget.value,
'data-nonce': this.config.nonce.value,
});
},
});
const Parent = defineComponent({
setup() {
provideConfig({
dir: 'rtl',
teleportTarget: '#app',
nonce: 'abc123',
});
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
expect(wrapper.find('div').attributes('data-target')).toBe('#app');
expect(wrapper.find('div').attributes('data-nonce')).toBe('abc123');
wrapper.unmount();
});
it('exposes mutable refs for runtime updates', async () => {
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', { 'data-dir': this.config.dir.value });
},
});
const Parent = defineComponent({
setup() {
const config = provideConfig({ dir: 'ltr' });
return { config };
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-dir')).toBe('ltr');
wrapper.vm.config.dir.value = 'rtl';
await wrapper.vm.$nextTick();
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
wrapper.unmount();
});
});
// --- provideAppConfig ---
describe('provideAppConfig', () => {
it('provides config at app level', () => {
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', {
'data-dir': this.config.dir.value,
});
},
});
const wrapper = mount(Child, {
global: {
plugins: [
app => provideAppConfig(app, { dir: 'rtl' }),
],
},
});
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
wrapper.unmount();
});
});

View File

@@ -0,0 +1,45 @@
import { ref, shallowRef, toValue } from 'vue';
import type { App, MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type Direction = 'ltr' | 'rtl';
export interface ConfigContext {
dir: Ref<Direction>;
nonce: Ref<string | undefined>;
teleportTarget: ShallowRef<string | HTMLElement>;
}
export interface ConfigOptions {
dir?: MaybeRefOrGetter<Direction>;
nonce?: MaybeRefOrGetter<string | undefined>;
teleportTarget?: MaybeRefOrGetter<string | HTMLElement>;
}
const DEFAULT_CONFIG: UnwrapRef<ConfigContext> = {
dir: 'ltr',
nonce: undefined,
teleportTarget: 'body',
};
const ConfigCtx = useContextFactory<ConfigContext>('ConfigContext');
function resolveContext(options?: ConfigOptions): ConfigContext {
return {
dir: ref(toValue(options?.dir) ?? DEFAULT_CONFIG.dir),
nonce: ref(toValue(options?.nonce) ?? DEFAULT_CONFIG.nonce),
teleportTarget: shallowRef(toValue(options?.teleportTarget) ?? DEFAULT_CONFIG.teleportTarget),
};
}
export function provideConfig(options?: ConfigOptions): ConfigContext {
return ConfigCtx.provide(resolveContext(options));
}
export function provideAppConfig(app: App, options?: ConfigOptions): ConfigContext {
return ConfigCtx.appProvide(app)(resolveContext(options));
}
export function useConfig(): ConfigContext {
return ConfigCtx.inject(resolveContext());
}

View File

@@ -0,0 +1,8 @@
export {
provideConfig,
provideAppConfig,
useConfig,
type ConfigContext,
type ConfigOptions,
type Direction,
} from './context';

View File

@@ -1,2 +1,4 @@
export * from './config-provider';
export * from './primitive';
export * from './presence';
export * from './pagination';

View File

@@ -1,15 +1,15 @@
import type { ComputedRef, Ref } from 'vue';
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface PaginationContext {
currentPage: Ref<number>;
totalPages: ComputedRef<number>;
pageSize: Ref<number>;
siblingCount: Ref<number>;
showEdges: Ref<boolean>;
disabled: Ref<boolean>;
isFirstPage: ComputedRef<boolean>;
isLastPage: ComputedRef<boolean>;
currentPage: Readonly<Ref<number>>;
totalPages: Readonly<Ref<number>>;
pageSize: Readonly<Ref<number>>;
siblingCount: Readonly<Ref<number>>;
showEdges: Readonly<Ref<boolean>>;
disabled: Readonly<Ref<boolean>>;
isFirstPage: Readonly<Ref<boolean>>;
isLastPage: Readonly<Ref<boolean>>;
onPageChange: (value: number) => void;
next: () => void;
prev: () => void;

View File

@@ -0,0 +1,28 @@
<script lang="ts">
export interface PresenceProps {
present: boolean;
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { usePresence } from './usePresence';
const {
present,
forceMount = false,
} = defineProps<PresenceProps>();
defineSlots<{
default?: (props: { present: boolean }) => any;
}>();
const { isPresent, setRef } = usePresence(() => present);
defineExpose({ present: isPresent });
</script>
<template>
<!-- @vue-expect-error ref is forwarded to slot -->
<slot v-if="forceMount || present || isPresent" :ref="setRef" :present="isPresent" />
</template>

View File

@@ -0,0 +1,356 @@
import { beforeEach, describe, it, expect, vi } from 'vitest';
import type { Ref } from 'vue';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { usePresence } from '../usePresence';
import Presence from '../Presence.vue';
import {
getAnimationName,
shouldSuspendUnmount,
dispatchAnimationEvent,
onAnimationSettle,
} from '@robonen/platform/browsers';
vi.mock('@robonen/platform/browsers', () => ({
getAnimationName: vi.fn(() => 'none'),
shouldSuspendUnmount: vi.fn(() => false),
dispatchAnimationEvent: vi.fn((el, name) => {
el?.dispatchEvent(new CustomEvent(name, { bubbles: false, cancelable: false }));
}),
onAnimationSettle: vi.fn(() => vi.fn()),
}));
const mockGetAnimationName = vi.mocked(getAnimationName);
const mockShouldSuspend = vi.mocked(shouldSuspendUnmount);
const mockDispatchEvent = vi.mocked(dispatchAnimationEvent);
const mockOnSettle = vi.mocked(onAnimationSettle);
function mountUsePresence(initial: boolean) {
const present = ref(initial);
const wrapper = mount(defineComponent({
setup() {
const { isPresent } = usePresence(present);
return { isPresent };
},
render() {
return h('div', this.isPresent ? 'visible' : 'hidden');
},
}));
return { wrapper, present };
}
function mountPresenceWithAnimation(present: Ref<boolean>) {
return mount(defineComponent({
setup() {
const { isPresent, setRef } = usePresence(present);
return { isPresent, setRef };
},
render() {
if (!this.isPresent) return h('div', 'hidden');
return h('div', {
ref: (el: any) => this.setRef(el),
}, 'visible');
},
}));
}
function findDispatchCall(name: string) {
return mockDispatchEvent.mock.calls.find(([, n]) => n === name);
}
describe('usePresence', () => {
it('returns isPresent=true when present is true', () => {
const { wrapper } = mountUsePresence(true);
expect(wrapper.text()).toBe('visible');
wrapper.unmount();
});
it('returns isPresent=false when present is false', () => {
const { wrapper } = mountUsePresence(false);
expect(wrapper.text()).toBe('hidden');
wrapper.unmount();
});
it('transitions to unmounted immediately when no animation', async () => {
const { wrapper, present } = mountUsePresence(true);
expect(wrapper.text()).toBe('visible');
present.value = false;
await nextTick();
expect(wrapper.text()).toBe('hidden');
wrapper.unmount();
});
it('transitions to mounted when present becomes true', async () => {
const { wrapper, present } = mountUsePresence(false);
expect(wrapper.text()).toBe('hidden');
present.value = true;
await nextTick();
expect(wrapper.text()).toBe('visible');
wrapper.unmount();
});
});
describe('Presence', () => {
it('renders child when present is true', () => {
const wrapper = mount(Presence, {
props: { present: true },
slots: { default: () => h('div', 'content') },
});
expect(wrapper.html()).toContain('content');
expect(wrapper.find('div').exists()).toBe(true);
wrapper.unmount();
});
it('does not render child when present is false', () => {
const wrapper = mount(Presence, {
props: { present: false },
slots: { default: () => h('div', 'content') },
});
expect(wrapper.html()).not.toContain('content');
wrapper.unmount();
});
it('removes child when present becomes false (no animation)', async () => {
const present = ref(true);
const wrapper = mount(defineComponent({
setup() {
return () => h(Presence, { present: present.value }, {
default: () => h('span', 'hello'),
});
},
}));
expect(wrapper.find('span').exists()).toBe(true);
present.value = false;
await nextTick();
expect(wrapper.find('span').exists()).toBe(false);
wrapper.unmount();
});
it('adds child when present becomes true', async () => {
const present = ref(false);
const wrapper = mount(defineComponent({
setup() {
return () => h(Presence, { present: present.value }, {
default: () => h('span', 'hello'),
});
},
}));
expect(wrapper.find('span').exists()).toBe(false);
present.value = true;
await nextTick();
expect(wrapper.find('span').exists()).toBe(true);
wrapper.unmount();
});
it('always renders child when forceMount is true', () => {
const wrapper = mount(Presence, {
props: { present: false, forceMount: true },
slots: { default: () => h('span', 'always') },
});
expect(wrapper.find('span').exists()).toBe(true);
expect(wrapper.find('span').text()).toBe('always');
wrapper.unmount();
});
it('exposes present state via scoped slot', () => {
let slotPresent: boolean | undefined;
const wrapper = mount(Presence, {
props: { present: true },
slots: {
default: (props: { present: boolean }) => {
slotPresent = props.present;
return h('div', 'content');
},
},
});
expect(slotPresent).toBe(true);
wrapper.unmount();
});
it('exposes present=false via scoped slot when forceMount and not present', () => {
let slotPresent: boolean | undefined;
const wrapper = mount(Presence, {
props: { present: false, forceMount: true },
slots: {
default: (props: { present: boolean }) => {
slotPresent = props.present;
return h('div', 'content');
},
},
});
expect(slotPresent).toBe(false);
wrapper.unmount();
});
});
describe('usePresence (animation)', () => {
beforeEach(() => {
mockGetAnimationName.mockReturnValue('none');
mockShouldSuspend.mockReturnValue(false);
mockOnSettle.mockImplementation(() => vi.fn());
mockDispatchEvent.mockClear();
});
it('dispatches enter and after-enter when present becomes true (no animation)', async () => {
const present = ref(false);
const wrapper = mountPresenceWithAnimation(present);
mockDispatchEvent.mockClear();
present.value = true;
await nextTick();
expect(findDispatchCall('enter')).toBeTruthy();
expect(findDispatchCall('after-enter')).toBeTruthy();
wrapper.unmount();
});
it('dispatches leave and after-leave when no animation on leave', async () => {
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
mockDispatchEvent.mockClear();
present.value = false;
await nextTick();
expect(findDispatchCall('leave')).toBeTruthy();
expect(findDispatchCall('after-leave')).toBeTruthy();
expect(wrapper.text()).toBe('hidden');
wrapper.unmount();
});
it('suspends unmount when shouldSuspendUnmount returns true', async () => {
mockShouldSuspend.mockReturnValue(true);
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
mockDispatchEvent.mockClear();
present.value = false;
await nextTick();
expect(findDispatchCall('leave')).toBeTruthy();
expect(findDispatchCall('after-leave')).toBeUndefined();
expect(wrapper.text()).toBe('visible');
wrapper.unmount();
});
it('dispatches after-leave and unmounts when animation settles', async () => {
mockShouldSuspend.mockReturnValue(true);
let settleCallback: (() => void) | undefined;
mockOnSettle.mockImplementation((_el: any, callbacks: any) => {
settleCallback = callbacks.onSettle;
return vi.fn();
});
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
mockDispatchEvent.mockClear();
present.value = false;
await nextTick();
expect(wrapper.text()).toBe('visible');
settleCallback!();
await nextTick();
expect(findDispatchCall('after-leave')).toBeTruthy();
expect(wrapper.text()).toBe('hidden');
wrapper.unmount();
});
it('tracks animation name on start via onAnimationSettle', async () => {
let startCallback: ((name: string) => void) | undefined;
mockOnSettle.mockImplementation((_el: any, callbacks: any) => {
startCallback = callbacks.onStart;
return vi.fn();
});
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
expect(startCallback).toBeDefined();
wrapper.unmount();
});
it('calls cleanup returned by onAnimationSettle on unmount', async () => {
const cleanupFn = vi.fn();
mockOnSettle.mockReturnValue(cleanupFn);
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
wrapper.unmount();
});
it('setRef connects DOM element for animation tracking', async () => {
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
expect(wrapper.text()).toBe('visible');
expect(mockOnSettle).toHaveBeenCalled();
expect(mockOnSettle.mock.calls[0]![0]).toBeInstanceOf(HTMLElement);
wrapper.unmount();
});
it('resets isAnimating when node ref becomes undefined', async () => {
mockShouldSuspend.mockReturnValue(true);
mockOnSettle.mockImplementation(() => vi.fn());
const present = ref(true);
const showEl = ref(true);
const wrapper = mount(defineComponent({
setup() {
const { isPresent, setRef } = usePresence(present);
return { isPresent, setRef, showEl };
},
render() {
if (!showEl.value) {
this.setRef(undefined);
return h('div', 'no-el');
}
return h('div', {
ref: (el: any) => this.setRef(el),
}, this.isPresent ? 'visible' : 'hidden');
},
}));
await nextTick();
expect(wrapper.text()).toBe('visible');
showEl.value = false;
await nextTick();
expect(wrapper.text()).toBe('no-el');
wrapper.unmount();
});
});

View File

@@ -0,0 +1,5 @@
export { default as Presence } from './Presence.vue';
export { usePresence } from './usePresence';
export type { PresenceProps } from './Presence.vue';
export type { UsePresenceReturn } from './usePresence';

View File

@@ -0,0 +1,84 @@
import type { MaybeElement } from '@robonen/vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { computed, readonly, shallowRef, toValue, watch } from 'vue';
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
import {
dispatchAnimationEvent,
getAnimationName,
onAnimationSettle,
shouldSuspendUnmount,
} from '@robonen/platform/browsers';
export interface UsePresenceReturn {
isPresent: Readonly<Ref<boolean>>;
setRef: (v: unknown) => void;
}
export function usePresence(
present: MaybeRefOrGetter<boolean>,
): UsePresenceReturn {
const node = shallowRef<HTMLElement>();
const isAnimating = shallowRef(false);
let prevAnimationName = 'none';
const isPresent = computed(() => toValue(present) || isAnimating.value);
watch(isPresent, (current) => {
prevAnimationName = current ? getAnimationName(node.value) : 'none';
});
watch(() => toValue(present), (value, oldValue) => {
if (value === oldValue) return;
if (value) {
isAnimating.value = false;
dispatchAnimationEvent(node.value, 'enter');
if (getAnimationName(node.value) === 'none') {
dispatchAnimationEvent(node.value, 'after-enter');
}
}
else {
isAnimating.value = shouldSuspendUnmount(node.value, prevAnimationName);
dispatchAnimationEvent(node.value, 'leave');
if (!isAnimating.value) {
dispatchAnimationEvent(node.value, 'after-leave');
}
}
}, { flush: 'sync' });
watch(node, (el, _oldEl, onCleanup) => {
if (el) {
const cleanup = onAnimationSettle(el, {
onSettle: () => {
const direction = toValue(present) ? 'enter' : 'leave';
dispatchAnimationEvent(el, `after-${direction}`);
isAnimating.value = false;
},
onStart: (animationName) => {
prevAnimationName = animationName;
},
});
onCleanup(cleanup);
}
else {
isAnimating.value = false;
}
});
tryOnScopeDispose(() => {
isAnimating.value = false;
});
function setRef(v: unknown) {
const el = unrefElement(v as MaybeElement);
node.value = el instanceof HTMLElement ? el : undefined;
}
return {
isPresent: readonly(isPresent),
setRef,
};
}

View File

@@ -1,5 +1,5 @@
import type { Component, IntrinsicElementAttributes, SetupContext } from 'vue';
import { h } from 'vue';
import type { AllowedComponentProps, Component, IntrinsicElementAttributes, SetupContext, VNodeProps } from 'vue';
import { h, mergeProps } from 'vue';
import { Slot } from './Slot';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
@@ -8,10 +8,12 @@ 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);
export function Primitive(props: PrimitiveProps & VNodeProps & AllowedComponentProps & Record<string, unknown>, ctx: FunctionalComponentContext) {
const { as, ...delegatedProps } = props;
return as === 'template'
? h(Slot, mergeProps(ctx.attrs, delegatedProps), ctx.slots)
: h(as!, mergeProps(ctx.attrs, delegatedProps), ctx.slots);
}
Primitive.props = {

View File

@@ -9,6 +9,9 @@
},
"vueCompilerOptions": {
"strictTemplates": true,
"fallthroughAttributes": true
"fallthroughAttributes": true,
"inferTemplateDollarAttrs": true,
"inferTemplateDollarEl": true,
"inferTemplateDollarRefs": true
}
}

View File

@@ -26,9 +26,14 @@
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"scripts": {

View File

@@ -2,7 +2,7 @@ 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 MaybeElement = Element | VueInstance | undefined | null;
export type MaybeElementRef<El extends MaybeElement = MaybeElement> = MaybeRef<El>;
export type MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;