diff --git a/package.json b/package.json index 2594c2715..03f954f37 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@oku-ui/collapsible": "workspace:^", "@oku-ui/label": "workspace:^", "@oku-ui/popper": "workspace:^", + "@oku-ui/presence": "workspace:^", "@oku-ui/primitive": "workspace:^", "@oku-ui/progress": "workspace:^", "@oku-ui/provide": "workspace:^", diff --git a/packages/components/presence/README.md b/packages/components/presence/README.md new file mode 100644 index 000000000..76b7c1e81 --- /dev/null +++ b/packages/components/presence/README.md @@ -0,0 +1,11 @@ +# `presence` + +## Installation + +```sh +$ pnpm add @oku-ui/presence +``` + +## Usage + +soon docs \ No newline at end of file diff --git a/packages/components/presence/build.config.ts b/packages/components/presence/build.config.ts new file mode 100644 index 000000000..b972b9a78 --- /dev/null +++ b/packages/components/presence/build.config.ts @@ -0,0 +1,12 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + entries: [ + { + builder: 'mkdist', + input: './src/', + pattern: ['**/!(*.test|*.stories).ts'], + }, + ], + declaration: true, +}) diff --git a/packages/components/presence/package.json b/packages/components/presence/package.json new file mode 100644 index 000000000..630852ace --- /dev/null +++ b/packages/components/presence/package.json @@ -0,0 +1,49 @@ +{ + "name": "@oku-ui/presence", + "type": "module", + "version": "0.1.0", + "license": "MIT", + "source": "src/index.ts", + "funding": "https://github.com/sponsors/productdevbook", + "homepage": "https://oku-ui.com/primitives", + "repository": { + "type": "git", + "url": "git+https://github.com/oku-ui/primitives.git", + "directory": "packages/components/presence" + }, + "bugs": { + "url": "https://github.com/oku-ui/primitives/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs" + } + }, + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch" + }, + "peerDependencies": { + "vue": "^3.3.0" + }, + "dependencies": { + "@oku-ui/primitive": "latest", + "@oku-ui/use-composable": "latest", + "@oku-ui/utils": "latest" + }, + "devDependencies": { + "tsconfig": "workspace:^" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/components/presence/src/index.ts b/packages/components/presence/src/index.ts new file mode 100644 index 000000000..f8bda0578 --- /dev/null +++ b/packages/components/presence/src/index.ts @@ -0,0 +1,3 @@ +export { + OkuPresence, +} from './presence' diff --git a/packages/components/presence/src/presence.test.ts b/packages/components/presence/src/presence.test.ts new file mode 100644 index 000000000..43de0e96b --- /dev/null +++ b/packages/components/presence/src/presence.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { type Component, ref } from 'vue' +import { mount } from '@vue/test-utils' +import { OkuPresence } from './presence' + +describe('presence', async () => { + it('close content', async () => { + const component = { + components: { + OkuPresence, + }, + template: ` +
+ + +
+ content +
+
+
+ `, + setup() { + const open = ref(false) + const toggle = () => { + open.value = !open.value + } + return { + open, + toggle, + } + }, + } as Component + const wrapper = mount(component, {}) + expect(wrapper.html()).toContain(`
+ +
`) + }) + + it('open content', async () => { + const component = { + components: { + OkuPresence, + }, + template: ` +
+ + +
+ content +
+
+
+ `, + setup() { + const open = ref(false) + const toggle = () => { + open.value = !open.value + } + return { + open, + toggle, + } + }, + } as Component + const wrapper = mount(component, {}) + await wrapper.find('button').trigger('click') + expect(wrapper.html()).toContain(`
+
content
+
`) + }) + + it('open content', async () => { + const component = { + components: { + OkuPresence, + }, + template: ` +
+ + +
+ content +
+
+
+ `, + setup() { + const open = ref(false) + const toggle = () => { + open.value = !open.value + } + return { + open, + toggle, + } + }, + } as Component + const wrapper = mount(component, {}) + expect(wrapper.html()).toContain(`
+ +
`) + + await wrapper.find('button').trigger('click') + + expect(wrapper.html()).toContain(`
+
content
+
`) + }) + + // TODO: add transition test +}) diff --git a/packages/components/presence/src/presence.ts b/packages/components/presence/src/presence.ts new file mode 100644 index 000000000..d42a12e1f --- /dev/null +++ b/packages/components/presence/src/presence.ts @@ -0,0 +1,74 @@ +import type { Directive } from 'vue' +import { defineComponent, h, ref, toRefs, withDirectives } from 'vue' +import { syncRef } from '@oku-ui/use-composable' +import { usePresence } from './usePresence' + +interface PresenceProps { + present: boolean +} + +const NAME = 'OkuPresence' + +const presence = defineComponent({ + name: NAME, + inheritAttrs: false, + props: { + present: { + type: Boolean, + default: false, + }, + }, + setup(props, { slots, attrs }) { + const { present } = toRefs(props) + const elementRef = ref() + + const element: Directive = { + created(el) { + const { isPresent } = usePresence(present, el) + syncRef(isPresent, elementRef, { direction: 'ltr' }) + }, + } + + return () => { + const children = slots.default?.() + + if (children?.length === 1) { + const [firstChild] = children || [] + + const directVNodeChildren = withDirectives( + h( + firstChild, + { + present: present.value, + ...attrs, + }, + ), + [ + [element], + ]) + + return present.value ? directVNodeChildren : null + } + else { + throw new Error( + [ + `Now you can only pass one child to \`${NAME}\`.`, + '', + 'Note: All components accepting `Presence` expect only one direct child of valid VNode type.', + 'You can apply a few solutions:', + [ + 'Provide a single child element so that we can forward the props onto that element.', + 'Ensure the first child is an actual element instead of a raw text node or comment node.', + ] + .map(line => ` - ${line}`) + .join('\n'), + ].join('\n'), + ) + } + } + }, +}) + +const OkuPresence = presence as typeof presence & (new () => { $props: PresenceProps }) + +export { OkuPresence } diff --git a/packages/components/presence/src/stories/PresenceDemo.vue b/packages/components/presence/src/stories/PresenceDemo.vue new file mode 100644 index 000000000..d121a9328 --- /dev/null +++ b/packages/components/presence/src/stories/PresenceDemo.vue @@ -0,0 +1,108 @@ + + + + + + diff --git a/packages/components/presence/src/stories/presence.stories.ts b/packages/components/presence/src/stories/presence.stories.ts new file mode 100644 index 000000000..1e3fd2f75 --- /dev/null +++ b/packages/components/presence/src/stories/presence.stories.ts @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import type { OkuPresenceProps } from './PresenceDemo.vue' +import OkuPresenceComponent from './PresenceDemo.vue' + +interface StoryProps extends OkuPresenceProps { +} + +const meta = { + title: 'Utilities/Presence', + component: OkuPresenceComponent, + args: { + template: '#1', + }, + argTypes: { + template: { + control: 'text', + }, + }, + tags: ['autodocs'], +} satisfies Meta & { + args: StoryProps +} + +export default meta +type Story = StoryObj & { + args: StoryProps +} + +export const Styled: Story = { + args: { + template: '#1', + }, + render: (args: any) => ({ + components: { OkuPresenceComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const Transition: Story = { + args: { + template: '#2', + }, + render: (args: any) => ({ + components: { OkuPresenceComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const WithDeferredMountAnimation: Story = { + args: { + template: '#3', + }, + render: (args: any) => ({ + components: { OkuPresenceComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} diff --git a/packages/components/presence/src/usePresence.ts b/packages/components/presence/src/usePresence.ts new file mode 100644 index 000000000..f941051a9 --- /dev/null +++ b/packages/components/presence/src/usePresence.ts @@ -0,0 +1,125 @@ +import { type Ref, computed, nextTick, ref, watch } from 'vue' +import { useStateMachine } from './useStateMachine' + +function getAnimationName(styles?: CSSStyleDeclaration) { + return styles?.animationName || 'none' +} + +export function usePresence(present: Ref, el: HTMLElement) { + const stylesRef = ref({} as any) + const prevPresentRef = ref(present.value) + const prevAnimationNameRef = ref('none') + const initialState = present.value ? 'mounted' : 'unmounted' + + const { state, dispatch: send } = useStateMachine(initialState, { + mounted: { + UNMOUNT: 'unmounted', + ANIMATION_OUT: 'unmountSuspended', + }, + unmountSuspended: { + MOUNT: 'mounted', + ANIMATION_END: 'unmounted', + }, + unmounted: { + MOUNT: 'mounted', + }, + }) + + watch(state, () => { + const currentAnimationName = getAnimationName(stylesRef.value) + prevAnimationNameRef.value = state.value === 'mounted' ? currentAnimationName : 'none' + }) + + watch([present], async () => { + const styles = stylesRef.value + const wasPresent = prevPresentRef.value + const hasPresentChanged = wasPresent !== present.value + + await nextTick() + + if (hasPresentChanged) { + const prevAnimationName = prevAnimationNameRef.value + const currentAnimationName = getAnimationName(styles) + + if (present.value) { + send('MOUNT') + } + else if (currentAnimationName === 'none' + || styles?.display === 'none' + ) { + // If there is no exit animation or the element is hidden, animations won't run + // so we unmount instantly + send('UNMOUNT') + } + else { + /** + * When `present` changes to `false`, we check changes to animation-name to + * determine whether an animation has started. We chose this approach (reading + * computed styles) because there is no `animationrun` event and `animationstart` + * fires after `animation-delay` has expired which would be too late. + */ + const isAnimating = prevAnimationName !== currentAnimationName + if (wasPresent && isAnimating) + send('ANIMATION_OUT') + else + send('UNMOUNT') + } + + prevPresentRef.value = present.value + } + }) + + watch(() => el, () => { + if (el) { + /** + * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel` + * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we + * make sure we only trigger ANIMATION_END for the currently active animation. + */ + const handleAnimationEnd = async (event: AnimationEvent) => { + const currentAnimationName = getAnimationName(stylesRef.value) + const isCurrentAnimation = currentAnimationName.includes( + event.animationName, + ) + if (event.target === el && isCurrentAnimation) { + // With React 18 concurrency this update is applied + // a frame after the animation ends, creating a flash of visible content. + // By manually flushing we ensure they sync within a frame, removing the flash. + send('ANIMATION_END') + } + } + const handleAnimationStart = (event: AnimationEvent) => { + if (event.target === el) + // if animation occurred, store its name as the previous animation. + prevAnimationNameRef.value = getAnimationName(stylesRef.value) + } + el.addEventListener('animationstart', handleAnimationStart) + el.addEventListener('animationcancel', handleAnimationEnd) + el.addEventListener('animationend', handleAnimationEnd) + + return () => { + if (el) { + el.removeEventListener('animationstart', handleAnimationStart) + el.removeEventListener('animationcancel', handleAnimationEnd) + el.removeEventListener('animationend', handleAnimationEnd) + } + } + } + else { + // Transition to the unmounted state if the el is removed prematurely. + // We avoid doing so during cleanup as the el may change but still exist. + send('ANIMATION_END') + } + }) + + const isPresent = computed(() => + ['mounted', 'unmountSuspended'].includes(state.value), + ) + + if (el) + stylesRef.value = getComputedStyle(el) + + return { + isPresent, + } +} diff --git a/packages/components/presence/src/useStateMachine.ts b/packages/components/presence/src/useStateMachine.ts new file mode 100644 index 000000000..a2879e738 --- /dev/null +++ b/packages/components/presence/src/useStateMachine.ts @@ -0,0 +1,36 @@ +import { ref } from 'vue' + +type Machine = { [k: string]: { [k: string]: S } } +type MachineState = keyof T +type MachineEvent = keyof UnionToIntersection + +// 🤯 https://fettblog.eu/typescript-union-to-intersection/ +type UnionToIntersection = (T extends any ? (x: T) => any : never) extends ( + x: infer R +) => any + ? R + : never + +export function useStateMachine( + initialState: MachineState, + machine: M & Machine>, +) { + const state = ref(initialState) + + const reducer = (event: MachineEvent) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const nextState = machine[state.value][event] + + return nextState ?? state.value + } + + const dispatch = (event: MachineEvent) => { + state.value = reducer(event) + } + + return { + state, + dispatch, + } +} diff --git a/packages/components/presence/tsconfig.json b/packages/components/presence/tsconfig.json new file mode 100644 index 000000000..b8dfa9041 --- /dev/null +++ b/packages/components/presence/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "tsconfig/node16.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src" + ] +} diff --git a/packages/components/presence/tsup.config.ts b/packages/components/presence/tsup.config.ts new file mode 100644 index 000000000..a2f7a0d8b --- /dev/null +++ b/packages/components/presence/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsup' +import pkg from './package.json' + +const external = [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), +] + +export default defineConfig((options) => { + return [ + { + ...options, + entryPoints: ['src/index.ts'], + external, + dts: true, + clean: true, + target: 'node16', + format: ['esm'], + outExtension: () => ({ js: '.mjs' }), + }, + ] +}) diff --git a/packages/core/use-composable/src/index.ts b/packages/core/use-composable/src/index.ts index 8959420b8..5c993ec0f 100644 --- a/packages/core/use-composable/src/index.ts +++ b/packages/core/use-composable/src/index.ts @@ -1,4 +1,4 @@ -import { computedEager } from '@vueuse/core' +import { computedEager, syncRef } from '@vueuse/core' export { useControllable } from './useControllable' export { useCallbackRef } from './useCallbackRef' @@ -9,4 +9,4 @@ export { unrefElement } from './unrefElement' export { useId } from './useId' export type { MaybeComputedElementRef } from './unrefElement' -export { computedEager } +export { computedEager, syncRef } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52f821fb0..97dc3640a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: '@oku-ui/popper': specifier: workspace:^ version: link:packages/components/popper + '@oku-ui/presence': + specifier: workspace:^ + version: link:packages/components/presence '@oku-ui/primitive': specifier: workspace:^ version: link:packages/core/primitive @@ -324,6 +327,25 @@ importers: specifier: workspace:^ version: link:../../tsconfig + packages/components/presence: + dependencies: + '@oku-ui/primitive': + specifier: latest + version: link:../../core/primitive + '@oku-ui/use-composable': + specifier: latest + version: link:../../core/use-composable + '@oku-ui/utils': + specifier: latest + version: link:../../core/utils + vue: + specifier: ^3.3.0 + version: 3.3.4 + devDependencies: + tsconfig: + specifier: workspace:^ + version: link:../../tsconfig + packages/components/progress: dependencies: '@oku-ui/primitive':