From a996eb74b9dd27d0f96324bb7b600d15a4af89f3 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 8 Mar 2026 08:19:01 +0700 Subject: [PATCH] feat: update package.json exports to support new module formats and types --- configs/oxlint/package.json | 11 +- core/encoding/package.json | 11 +- core/platform/package.json | 22 +- .../browsers/animationLifecycle/index.test.ts | 139 +++++++ .../src/browsers/animationLifecycle/index.ts | 139 +++++++ core/platform/src/browsers/index.ts | 1 + core/stdlib/package.json | 11 +- pnpm-lock.yaml | 3 + vue/primitives/package.json | 12 +- .../__test__/config-provider.test.ts | 133 +++++++ vue/primitives/src/config-provider/context.ts | 45 +++ vue/primitives/src/config-provider/index.ts | 8 + vue/primitives/src/index.ts | 2 + vue/primitives/src/pagination/context.ts | 18 +- vue/primitives/src/presence/Presence.vue | 28 ++ .../src/presence/__test__/Presence.test.ts | 356 ++++++++++++++++++ vue/primitives/src/presence/index.ts | 5 + vue/primitives/src/presence/usePresence.ts | 84 +++++ vue/primitives/src/primitive/Primitive.ts | 14 +- vue/primitives/tsconfig.json | 5 +- vue/toolkit/package.json | 11 +- .../component/unrefElement/index.ts | 2 +- 22 files changed, 1022 insertions(+), 38 deletions(-) create mode 100644 core/platform/src/browsers/animationLifecycle/index.test.ts create mode 100644 core/platform/src/browsers/animationLifecycle/index.ts create mode 100644 vue/primitives/src/config-provider/__test__/config-provider.test.ts create mode 100644 vue/primitives/src/config-provider/context.ts create mode 100644 vue/primitives/src/config-provider/index.ts create mode 100644 vue/primitives/src/presence/Presence.vue create mode 100644 vue/primitives/src/presence/__test__/Presence.test.ts create mode 100644 vue/primitives/src/presence/index.ts create mode 100644 vue/primitives/src/presence/usePresence.ts diff --git a/configs/oxlint/package.json b/configs/oxlint/package.json index 258c8de..176d5e5 100644 --- a/configs/oxlint/package.json +++ b/configs/oxlint/package.json @@ -26,9 +26,14 @@ ], "exports": { ".": { - "types": "./dist/index.d.ts", - "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": { diff --git a/core/encoding/package.json b/core/encoding/package.json index 3ae2402..d3d070a 100644 --- a/core/encoding/package.json +++ b/core/encoding/package.json @@ -23,9 +23,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": { diff --git a/core/platform/package.json b/core/platform/package.json index 7876556..e78a9cf 100644 --- a/core/platform/package.json +++ b/core/platform/package.json @@ -28,14 +28,24 @@ ], "exports": { "./browsers": { - "types": "./dist/browsers.d.ts", - "import": "./dist/browsers.js", - "require": "./dist/browsers.cjs" + "import": { + "types": "./dist/browsers.d.mts", + "default": "./dist/browsers.mjs" + }, + "require": { + "types": "./dist/browsers.d.cts", + "default": "./dist/browsers.cjs" + } }, "./multi": { - "types": "./dist/multi.d.ts", - "import": "./dist/multi.js", - "require": "./dist/multi.cjs" + "import": { + "types": "./dist/multi.d.mts", + "default": "./dist/multi.mjs" + }, + "require": { + "types": "./dist/multi.d.cts", + "default": "./dist/multi.cjs" + } } }, "scripts": { diff --git a/core/platform/src/browsers/animationLifecycle/index.test.ts b/core/platform/src/browsers/animationLifecycle/index.test.ts new file mode 100644 index 0000000..ae6df45 --- /dev/null +++ b/core/platform/src/browsers/animationLifecycle/index.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + getAnimationName, + isAnimatable, + shouldSuspendUnmount, + dispatchAnimationEvent, + onAnimationSettle, +} from '.'; + +describe('getAnimationName', () => { + it('returns "none" for undefined element', () => { + expect(getAnimationName(undefined)).toBe('none'); + }); + + it('returns the animation name from inline style', () => { + const el = document.createElement('div'); + el.style.animationName = 'fadeIn'; + document.body.appendChild(el); + + expect(getAnimationName(el)).toBe('fadeIn'); + + document.body.removeChild(el); + }); +}); + +describe('isAnimatable', () => { + it('returns false for undefined element', () => { + expect(isAnimatable(undefined)).toBe(false); + }); + + it('returns false for element with no animation or transition', () => { + const el = document.createElement('div'); + document.body.appendChild(el); + + expect(isAnimatable(el)).toBe(false); + + document.body.removeChild(el); + }); +}); + +describe('shouldSuspendUnmount', () => { + it('returns false for undefined element', () => { + expect(shouldSuspendUnmount(undefined, 'none')).toBe(false); + }); + + it('returns false for element with no animation/transition', () => { + const el = document.createElement('div'); + document.body.appendChild(el); + + expect(shouldSuspendUnmount(el, 'none')).toBe(false); + + document.body.removeChild(el); + }); +}); + +describe('dispatchAnimationEvent', () => { + it('dispatches a custom event on the element', () => { + const el = document.createElement('div'); + const handler = vi.fn(); + + el.addEventListener('enter', handler); + dispatchAnimationEvent(el, 'enter'); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('does not throw for undefined element', () => { + expect(() => dispatchAnimationEvent(undefined, 'leave')).not.toThrow(); + }); + + it('dispatches non-bubbling event', () => { + const el = document.createElement('div'); + const parent = document.createElement('div'); + const handler = vi.fn(); + + parent.appendChild(el); + parent.addEventListener('enter', handler); + dispatchAnimationEvent(el, 'enter'); + + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe('onAnimationSettle', () => { + it('returns a cleanup function', () => { + const el = document.createElement('div'); + const cleanup = onAnimationSettle(el, { onSettle: vi.fn() }); + + expect(typeof cleanup).toBe('function'); + cleanup(); + }); + + it('calls onSettle callback on transitionend', () => { + const el = document.createElement('div'); + const callback = vi.fn(); + + onAnimationSettle(el, { onSettle: callback }); + el.dispatchEvent(new Event('transitionend')); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('calls onSettle callback on transitioncancel', () => { + const el = document.createElement('div'); + const callback = vi.fn(); + + onAnimationSettle(el, { onSettle: callback }); + el.dispatchEvent(new Event('transitioncancel')); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('calls onStart callback on animationstart', () => { + const el = document.createElement('div'); + const startCallback = vi.fn(); + + onAnimationSettle(el, { + onSettle: vi.fn(), + onStart: startCallback, + }); + + el.dispatchEvent(new Event('animationstart')); + + expect(startCallback).toHaveBeenCalledOnce(); + }); + + it('removes all listeners on cleanup', () => { + const el = document.createElement('div'); + const callback = vi.fn(); + + const cleanup = onAnimationSettle(el, { onSettle: callback }); + cleanup(); + + el.dispatchEvent(new Event('transitionend')); + el.dispatchEvent(new Event('transitioncancel')); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/core/platform/src/browsers/animationLifecycle/index.ts b/core/platform/src/browsers/animationLifecycle/index.ts new file mode 100644 index 0000000..9701621 --- /dev/null +++ b/core/platform/src/browsers/animationLifecycle/index.ts @@ -0,0 +1,139 @@ +export type AnimationLifecycleEvent = 'enter' | 'after-enter' | 'leave' | 'after-leave'; + +export interface AnimationSettleCallbacks { + onSettle: () => void; + onStart?: (animationName: string) => void; +} + +/** + * @name getAnimationName + * @category Browsers + * @description Returns the current CSS animation name(s) of an element + * + * @since 0.0.5 + */ +export function getAnimationName(el: HTMLElement | undefined): string { + return el ? getComputedStyle(el).animationName || 'none' : 'none'; +} + +/** + * @name isAnimatable + * @category Browsers + * @description Checks whether an element has a running CSS animation or transition + * + * @since 0.0.5 + */ +export function isAnimatable(el: HTMLElement | undefined): boolean { + if (!el) return false; + + const style = getComputedStyle(el); + const animationName = style.animationName || 'none'; + const transitionProperty = style.transitionProperty || 'none'; + + const hasAnimation = animationName !== 'none' && animationName !== ''; + const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all'; + + return hasAnimation || hasTransition; +} + +/** + * @name shouldSuspendUnmount + * @category Browsers + * @description Determines whether unmounting should be delayed due to a running animation/transition change + * + * @since 0.0.5 + */ +export function shouldSuspendUnmount(el: HTMLElement | undefined, prevAnimationName: string): boolean { + if (!el) return false; + + const style = getComputedStyle(el); + + if (style.display === 'none') return false; + + const animationName = style.animationName || 'none'; + const transitionProperty = style.transitionProperty || 'none'; + + const hasAnimation = animationName !== 'none' && animationName !== ''; + const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all'; + + if (!hasAnimation && !hasTransition) return false; + + return prevAnimationName !== animationName || hasTransition; +} + +/** + * @name dispatchAnimationEvent + * @category Browsers + * @description Dispatches a non-bubbling custom event on an element for animation lifecycle tracking + * + * @since 0.0.5 + */ +export function dispatchAnimationEvent(el: HTMLElement | undefined, name: AnimationLifecycleEvent): void { + el?.dispatchEvent(new CustomEvent(name, { bubbles: false, cancelable: false })); +} + +/** + * @name onAnimationSettle + * @category Browsers + * @description Attaches animation/transition end listeners to an element with fill-mode flash prevention. Returns a cleanup function. + * + * @since 0.0.5 + */ +export function onAnimationSettle(el: HTMLElement, callbacks: AnimationSettleCallbacks): () => void { + let fillModeTimeoutId: ReturnType | undefined; + + const handleAnimationEnd = (event: AnimationEvent) => { + const currentAnimationName = getAnimationName(el); + const isCurrentAnimation = currentAnimationName.includes(CSS.escape(event.animationName)); + + if (event.target === el && isCurrentAnimation) { + callbacks.onSettle(); + + if (fillModeTimeoutId !== undefined) { + clearTimeout(fillModeTimeoutId); + } + + const currentFillMode = el.style.animationFillMode; + el.style.animationFillMode = 'forwards'; + + fillModeTimeoutId = setTimeout(() => { + if (el.style.animationFillMode === 'forwards') { + el.style.animationFillMode = currentFillMode; + } + }); + } + else if (event.target === el && currentAnimationName === 'none') { + callbacks.onSettle(); + } + }; + + const handleAnimationStart = (event: AnimationEvent) => { + if (event.target === el) { + callbacks.onStart?.(getAnimationName(el)); + } + }; + + const handleTransitionEnd = (event: TransitionEvent) => { + if (event.target === el) { + callbacks.onSettle(); + } + }; + + el.addEventListener('animationstart', handleAnimationStart, { passive: true }); + el.addEventListener('animationcancel', handleAnimationEnd, { passive: true }); + el.addEventListener('animationend', handleAnimationEnd, { passive: true }); + el.addEventListener('transitioncancel', handleTransitionEnd, { passive: true }); + el.addEventListener('transitionend', handleTransitionEnd, { passive: true }); + + return () => { + el.removeEventListener('animationstart', handleAnimationStart); + el.removeEventListener('animationcancel', handleAnimationEnd); + el.removeEventListener('animationend', handleAnimationEnd); + el.removeEventListener('transitioncancel', handleTransitionEnd); + el.removeEventListener('transitionend', handleTransitionEnd); + + if (fillModeTimeoutId !== undefined) { + clearTimeout(fillModeTimeoutId); + } + }; +} diff --git a/core/platform/src/browsers/index.ts b/core/platform/src/browsers/index.ts index 568f902..0a9fd37 100644 --- a/core/platform/src/browsers/index.ts +++ b/core/platform/src/browsers/index.ts @@ -1 +1,2 @@ +export * from './animationLifecycle'; export * from './focusGuard'; diff --git a/core/stdlib/package.json b/core/stdlib/package.json index e451469..9e1ec98 100644 --- a/core/stdlib/package.json +++ b/core/stdlib/package.json @@ -28,9 +28,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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4803ba6..1f1aff1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: vue/primitives: dependencies: + '@robonen/platform': + specifier: workspace:* + version: link:../../core/platform '@robonen/vue': specifier: workspace:* version: link:../toolkit diff --git a/vue/primitives/package.json b/vue/primitives/package.json index 7f06b82..7853168 100644 --- a/vue/primitives/package.json +++ b/vue/primitives/package.json @@ -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:" diff --git a/vue/primitives/src/config-provider/__test__/config-provider.test.ts b/vue/primitives/src/config-provider/__test__/config-provider.test.ts new file mode 100644 index 0000000..6e212b6 --- /dev/null +++ b/vue/primitives/src/config-provider/__test__/config-provider.test.ts @@ -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(); + }); +}); diff --git a/vue/primitives/src/config-provider/context.ts b/vue/primitives/src/config-provider/context.ts new file mode 100644 index 0000000..24406c5 --- /dev/null +++ b/vue/primitives/src/config-provider/context.ts @@ -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; + nonce: Ref; + teleportTarget: ShallowRef; +} + +export interface ConfigOptions { + dir?: MaybeRefOrGetter; + nonce?: MaybeRefOrGetter; + teleportTarget?: MaybeRefOrGetter; +} + +const DEFAULT_CONFIG: UnwrapRef = { + dir: 'ltr', + nonce: undefined, + teleportTarget: 'body', +}; + +const ConfigCtx = useContextFactory('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()); +} diff --git a/vue/primitives/src/config-provider/index.ts b/vue/primitives/src/config-provider/index.ts new file mode 100644 index 0000000..92d2ddf --- /dev/null +++ b/vue/primitives/src/config-provider/index.ts @@ -0,0 +1,8 @@ +export { + provideConfig, + provideAppConfig, + useConfig, + type ConfigContext, + type ConfigOptions, + type Direction, +} from './context'; diff --git a/vue/primitives/src/index.ts b/vue/primitives/src/index.ts index 2c7a7b4..4b96ffe 100644 --- a/vue/primitives/src/index.ts +++ b/vue/primitives/src/index.ts @@ -1,2 +1,4 @@ +export * from './config-provider'; export * from './primitive'; +export * from './presence'; export * from './pagination'; diff --git a/vue/primitives/src/pagination/context.ts b/vue/primitives/src/pagination/context.ts index 329f8a4..6a36bfc 100644 --- a/vue/primitives/src/pagination/context.ts +++ b/vue/primitives/src/pagination/context.ts @@ -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; - totalPages: ComputedRef; - pageSize: Ref; - siblingCount: Ref; - showEdges: Ref; - disabled: Ref; - isFirstPage: ComputedRef; - isLastPage: ComputedRef; + currentPage: Readonly>; + totalPages: Readonly>; + pageSize: Readonly>; + siblingCount: Readonly>; + showEdges: Readonly>; + disabled: Readonly>; + isFirstPage: Readonly>; + isLastPage: Readonly>; onPageChange: (value: number) => void; next: () => void; prev: () => void; diff --git a/vue/primitives/src/presence/Presence.vue b/vue/primitives/src/presence/Presence.vue new file mode 100644 index 0000000..ada528f --- /dev/null +++ b/vue/primitives/src/presence/Presence.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/vue/primitives/src/presence/__test__/Presence.test.ts b/vue/primitives/src/presence/__test__/Presence.test.ts new file mode 100644 index 0000000..4bb6cda --- /dev/null +++ b/vue/primitives/src/presence/__test__/Presence.test.ts @@ -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) { + 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(); + }); +}); diff --git a/vue/primitives/src/presence/index.ts b/vue/primitives/src/presence/index.ts new file mode 100644 index 0000000..33a9ee9 --- /dev/null +++ b/vue/primitives/src/presence/index.ts @@ -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'; diff --git a/vue/primitives/src/presence/usePresence.ts b/vue/primitives/src/presence/usePresence.ts new file mode 100644 index 0000000..de1517e --- /dev/null +++ b/vue/primitives/src/presence/usePresence.ts @@ -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>; + setRef: (v: unknown) => void; +} + +export function usePresence( + present: MaybeRefOrGetter, +): UsePresenceReturn { + const node = shallowRef(); + 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, + }; +} diff --git a/vue/primitives/src/primitive/Primitive.ts b/vue/primitives/src/primitive/Primitive.ts index bf2aeaa..b070e1a 100644 --- a/vue/primitives/src/primitive/Primitive.ts +++ b/vue/primitives/src/primitive/Primitive.ts @@ -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; @@ -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, 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 = { diff --git a/vue/primitives/tsconfig.json b/vue/primitives/tsconfig.json index d8fecb6..3b66232 100644 --- a/vue/primitives/tsconfig.json +++ b/vue/primitives/tsconfig.json @@ -9,6 +9,9 @@ }, "vueCompilerOptions": { "strictTemplates": true, - "fallthroughAttributes": true + "fallthroughAttributes": true, + "inferTemplateDollarAttrs": true, + "inferTemplateDollarEl": true, + "inferTemplateDollarRefs": true } } \ No newline at end of file diff --git a/vue/toolkit/package.json b/vue/toolkit/package.json index 52e1a26..d8b493b 100644 --- a/vue/toolkit/package.json +++ b/vue/toolkit/package.json @@ -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": { diff --git a/vue/toolkit/src/composables/component/unrefElement/index.ts b/vue/toolkit/src/composables/component/unrefElement/index.ts index 379ff29..e98517e 100644 --- a/vue/toolkit/src/composables/component/unrefElement/index.ts +++ b/vue/toolkit/src/composables/component/unrefElement/index.ts @@ -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 = MaybeRef; export type MaybeComputedElementRef = MaybeRefOrGetter;