@elyndra/vue-animated 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@elyndra/vue-animated",
3
+ "version": "1.2.0",
4
+ "sideEffects": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "Elyndra animated components for Vue",
9
+ "keywords": [
10
+ "elyndra",
11
+ "ui",
12
+ "front-end",
13
+ "framework",
14
+ "scifi",
15
+ "sci-fi",
16
+ "science-fiction",
17
+ "vue"
18
+ ],
19
+ "homepage": "https://elyndra.dev",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Gabox301/elyndra.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Gabox301/elyndra/issues"
26
+ },
27
+ "funding": "https://github.com/sponsors/romelperez",
28
+ "license": "MIT",
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "exports": {
33
+ ".": "./src/index.ts"
34
+ },
35
+ "types": "./src/index.ts",
36
+ "module": "./src/index.ts",
37
+ "main": "./src/index.ts",
38
+ "peerDependencies": {
39
+ "vue": "3"
40
+ },
41
+ "dependencies": {
42
+ "@elyndra/animated": "^1.2.0",
43
+ "@elyndra/animator": "^1.2.0",
44
+ "@elyndra/tools": "^1.2.0",
45
+ "@elyndra/vue-animator": "^1.2.0",
46
+ "@elyndra/vue-tools": "^1.2.0",
47
+ "tslib": "2.8.1"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc --noEmit -p tsconfig.json && vue-tsc --noEmit -p tsconfig.json"
51
+ }
52
+ }
@@ -0,0 +1,24 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { expect, test } from 'vitest';
3
+ import { Animated } from './index.js';
4
+
5
+ test('Should render element with children and default "div" element', () => {
6
+ const wrapper = mount(Animated, {
7
+ props: { animated: ['fade'] },
8
+ slots: { default: () => 'hello' },
9
+ });
10
+ const element = wrapper.element as HTMLElement;
11
+ expect(element.tagName).toBe('DIV');
12
+ expect(element.textContent).toBe('hello');
13
+ wrapper.unmount();
14
+ });
15
+
16
+ test('Should allow to set custom element', () => {
17
+ const wrapper = mount(Animated, {
18
+ props: { as: 'section', animated: ['fade'] },
19
+ slots: { default: () => 'hello' },
20
+ });
21
+ const element = wrapper.element as HTMLElement;
22
+ expect(element.tagName).toBe('SECTION');
23
+ wrapper.unmount();
24
+ });
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts" generic="E extends HTMLElement | SVGElement = HTMLDivElement">
2
+ import { formatAnimatedCSSProps } from '@elyndra/animated';
3
+ import { useAnimator } from '@elyndra/vue-animator';
4
+ import { mergeRefs } from '@elyndra/vue-tools';
5
+ import { computed, type CSSProperties } from 'vue';
6
+ import { useAnimated } from '../useAnimated/index.js';
7
+ import type { AnimatedProps } from '../types.js';
8
+
9
+ const props = withDefaults(defineProps<AnimatedProps<E>>(), {
10
+ // Vue boolean-casts absent props with a Boolean type to `false`.
11
+ // `hideOnExited` defaults to `true`, so it gets an `undefined` default
12
+ // which skips the cast. (`hideOnEntered` absent also means falsy, but an
13
+ // explicit default keeps both intentions readable.)
14
+ hideOnExited: undefined,
15
+ hideOnEntered: undefined,
16
+ });
17
+
18
+ const getAnimator = useAnimator();
19
+ // Snapshot visibility like React/Preact/Solid render: the node state is
20
+ // vanilla (not reactive), live show/hide during transitions is owned by the
21
+ // animation above.
22
+ const animator = getAnimator?.();
23
+ const as = computed(() => props.as ?? 'div');
24
+ const elementBox: { current: E | null } = { current: null };
25
+
26
+ useAnimated(elementBox, () => props.animated, {
27
+ renderInitials: false,
28
+ hideOnExited: props.hideOnExited ?? true,
29
+ hideOnEntered: props.hideOnEntered,
30
+ onTransition: props.onTransition,
31
+ });
32
+
33
+ const animatedSettingsList = computed(() => {
34
+ const animatedSettingsListReceived = Array.isArray(props.animated) ? props.animated : [props.animated];
35
+ return animatedSettingsListReceived
36
+ .map((item) => (typeof item === 'string' || Array.isArray(item) ? undefined : item))
37
+ .filter(Boolean);
38
+ });
39
+
40
+ const initialAttributes = computed<Record<string, string> | undefined>(() => {
41
+ if (!animator) {
42
+ return undefined;
43
+ }
44
+ return animatedSettingsList.value
45
+ .map((item) => (item ? item.initialAttributes : undefined))
46
+ .reduce<Record<string, string>>((total, item) => ({ ...total, ...item }), {});
47
+ });
48
+
49
+ const dynamicStyles = computed<CSSProperties | undefined>(() => {
50
+ if (!animator) {
51
+ return undefined;
52
+ }
53
+ return animatedSettingsList.value
54
+ .map((item) => (item ? item.initialStyle : undefined))
55
+ .filter((style): style is NonNullable<typeof style> => Boolean(style))
56
+ .map((styles) => formatAnimatedCSSProps(styles) as CSSProperties)
57
+ .reduce((total, item) => ({ ...total, ...item }), {});
58
+ });
59
+
60
+ const hideOnExited = computed(() => props.hideOnExited ?? true);
61
+
62
+ const setElementRef = (value: E | null): void => {
63
+ elementBox.current = value;
64
+ mergeRefs(props.elementRef)(value);
65
+ };
66
+ </script>
67
+
68
+ <template>
69
+ <component
70
+ :is="as"
71
+ v-bind="initialAttributes"
72
+ :ref="setElementRef"
73
+ :class="props.className"
74
+ :style="{
75
+ ...props.style,
76
+ visibility:
77
+ animator &&
78
+ ((hideOnExited && animator.node.state === 'exited') ||
79
+ (props.hideOnEntered && animator.node.state === 'entered'))
80
+ ? 'hidden'
81
+ : '',
82
+ ...dynamicStyles,
83
+ }"
84
+ >
85
+ <slot />
86
+ </component>
87
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Animated } from './Animated.vue';
@@ -0,0 +1,24 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { expect, test } from 'vitest';
3
+ import { AnimatedX } from './index.js';
4
+
5
+ test('Should render element with children and default "div" element', () => {
6
+ const wrapper = mount(AnimatedX, {
7
+ props: { state: null },
8
+ slots: { default: () => 'hello' },
9
+ });
10
+ const element = wrapper.element as HTMLElement;
11
+ expect(element.tagName).toBe('DIV');
12
+ expect(element.textContent).toBe('hello');
13
+ wrapper.unmount();
14
+ });
15
+
16
+ test('Should allow to set custom element', () => {
17
+ const wrapper = mount(AnimatedX, {
18
+ props: { as: 'section', state: 'on' },
19
+ slots: { default: () => 'hello' },
20
+ });
21
+ const element = wrapper.element as HTMLElement;
22
+ expect(element.tagName).toBe('SECTION');
23
+ wrapper.unmount();
24
+ });
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts" generic="S extends string, E extends HTMLElement | SVGElement = HTMLDivElement">
2
+ import { type AnimatedSettings, formatAnimatedCSSProps } from '@elyndra/animated';
3
+ import { mergeRefs } from '@elyndra/vue-tools';
4
+ import { computed, type CSSProperties } from 'vue';
5
+ import { useAnimatedX } from '../useAnimatedX/index.js';
6
+ import type { AnimatedXProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<AnimatedXProps<S, E>>(), {
9
+ hideOnStates: undefined,
10
+ });
11
+
12
+ const hasState = computed(() => props.state !== undefined && props.state !== null);
13
+ const as = computed(() => props.as ?? 'div');
14
+ const elementBox: { current: E | null } = { current: null };
15
+
16
+ useAnimatedX<S, E>(() => props.state, elementBox, () => props.animated, {
17
+ renderInitials: false,
18
+ hideOnStates: props.hideOnStates ?? [],
19
+ });
20
+
21
+ const animatedSettingsList = computed(() => {
22
+ const animatedSettingsListReceived = Array.isArray(props.animated) ? props.animated : [props.animated];
23
+ return animatedSettingsListReceived.filter(Boolean) as AnimatedSettings[];
24
+ });
25
+
26
+ const initialAttributes = computed<Record<string, string> | undefined>(() => {
27
+ if (!hasState.value) {
28
+ return undefined;
29
+ }
30
+ return animatedSettingsList.value
31
+ .map((item) => item?.initialAttributes)
32
+ .reduce<Record<string, string>>((total, item) => ({ ...total, ...item }), {});
33
+ });
34
+
35
+ const dynamicStyles = computed<CSSProperties | undefined>(() => {
36
+ if (!hasState.value) {
37
+ return undefined;
38
+ }
39
+ return animatedSettingsList.value
40
+ .map((item) => item.initialStyle)
41
+ .filter((style): style is NonNullable<typeof style> => Boolean(style))
42
+ .map((styles) => formatAnimatedCSSProps(styles) as CSSProperties)
43
+ .reduce((total, item) => ({ ...total, ...item }), {});
44
+ });
45
+
46
+ const setElementRef = (value: E | null): void => {
47
+ elementBox.current = value;
48
+ mergeRefs(props.elementRef)(value);
49
+ };
50
+ </script>
51
+
52
+ <template>
53
+ <component
54
+ :is="as"
55
+ v-bind="initialAttributes"
56
+ :ref="setElementRef"
57
+ :class="props.className"
58
+ :style="{
59
+ ...props.style,
60
+ visibility: hasState && (props.hideOnStates ?? []).includes(props.state as S) ? 'hidden' : '',
61
+ ...dynamicStyles,
62
+ }"
63
+ >
64
+ <slot />
65
+ </component>
66
+ </template>
@@ -0,0 +1 @@
1
+ export { default as AnimatedX } from './AnimatedX.vue';
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './Animated/index.js';
2
+ export * from './AnimatedX/index.js';
3
+ export * from './useAnimated/index.js';
4
+ export * from './useAnimatedX/index.js';
5
+ export type * from './types.js';
package/src/types.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { CSSProperties } from 'vue';
2
+ import type { AnimatedProp, AnimatedXProp } from '@elyndra/animated';
3
+ import type { AnimatorNode } from '@elyndra/animator';
4
+ import type { ElementRef } from '@elyndra/vue-tools';
5
+
6
+ interface AnimatedProps<E extends HTMLElement | SVGElement = HTMLDivElement> {
7
+ elementRef?: ElementRef<E>;
8
+ className?: string;
9
+ style?: CSSProperties;
10
+ animated?: AnimatedProp;
11
+ hideOnExited?: boolean;
12
+ hideOnEntered?: boolean;
13
+ onTransition?: (element: E, node: AnimatorNode) => void;
14
+ as?: keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap;
15
+ }
16
+
17
+ interface AnimatedXProps<S extends string, E extends HTMLElement | SVGElement = HTMLDivElement> {
18
+ elementRef?: ElementRef<E>;
19
+ className?: string;
20
+ style?: CSSProperties;
21
+ state: S | undefined | null;
22
+ hideOnStates?: S[];
23
+ animated?: AnimatedXProp<S>;
24
+ as?: keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap;
25
+ }
26
+
27
+ export type { AnimatedProps, AnimatedXProps, ElementRef };
@@ -0,0 +1 @@
1
+ export * from './useAnimated.js';
@@ -0,0 +1,50 @@
1
+ import { flushPromises, mount } from '@vue/test-utils';
2
+ import { defineComponent, h } from 'vue';
3
+ import { expect, test } from 'vitest';
4
+ import { Animator } from '@elyndra/vue-animator';
5
+ import { useAnimated } from './index.js';
6
+
7
+ const flush = async (): Promise<void> => {
8
+ await flushPromises();
9
+ await new Promise((resolve) => setTimeout(resolve, 0));
10
+ await flushPromises();
11
+ };
12
+
13
+ test('Should apply initial attributes through the live settings ref', async () => {
14
+ const element = document.createElement('div');
15
+ const elementRef = { current: element as HTMLDivElement | null };
16
+
17
+ const Harness = defineComponent({
18
+ setup() {
19
+ useAnimated(elementRef, () => [{ initialAttributes: { 'data-animated': 'ready' } }]);
20
+ return () => h('div');
21
+ },
22
+ });
23
+
24
+ const wrapper = mount(Animator, {
25
+ props: { reducedMotion: true },
26
+ slots: { default: () => h(Harness) },
27
+ });
28
+ await flush();
29
+
30
+ expect(element.getAttribute('data-animated')).toBe('ready');
31
+ wrapper.unmount();
32
+ });
33
+
34
+ test('Should do nothing without an animator or element', async () => {
35
+ const elementRef = { current: null as HTMLDivElement | null };
36
+
37
+ const Harness = defineComponent({
38
+ setup() {
39
+ useAnimated(elementRef, () => ['fade']);
40
+ return () => h('div');
41
+ },
42
+ });
43
+
44
+ // No animator above: the animated element is never created, no throw.
45
+ const wrapper = mount(Harness);
46
+ await flush();
47
+
48
+ expect(elementRef.current).toBeNull();
49
+ wrapper.unmount();
50
+ });
@@ -0,0 +1,56 @@
1
+ import { type AnimatedElementPropsSettings, type AnimatedProp, createAnimatedElement } from '@elyndra/animated';
2
+ import { useAnimator } from '@elyndra/vue-animator';
3
+ import { watch, watchEffect } from 'vue';
4
+
5
+ interface ElementSource<Element> {
6
+ current: Element | null;
7
+ }
8
+
9
+ /**
10
+ * Mechanical Vue port of the React/Preact/Solid `useAnimated` hook.
11
+ * Differences are Vue idiom only: `watch` + `watch` cleanup replace
12
+ * `useEffect`/`createEffect` + `onCleanup`, the animator is read through the
13
+ * `useAnimator` accessor, and `animated` is a source function so updates
14
+ * stay tracked (same convention as `Animator`'s `refreshOn`). Must be called
15
+ * inside `setup`.
16
+ */
17
+ const useAnimated = <Element extends HTMLElement | SVGElement = HTMLElement>(
18
+ elementRef: ElementSource<Element | null>,
19
+ animated: () => undefined | AnimatedProp,
20
+ settings?: undefined | Omit<AnimatedElementPropsSettings<Element>, 'animated'>,
21
+ ): void => {
22
+ const getAnimator = useAnimator();
23
+ const settingsRef: { current: AnimatedElementPropsSettings<Element> } = {
24
+ current: { animated: undefined } as AnimatedElementPropsSettings<Element>,
25
+ };
26
+
27
+ // The settings ref stays live so the vanilla animated element always reads
28
+ // the latest settings.
29
+ watchEffect(() => {
30
+ settingsRef.current = {
31
+ ...settings,
32
+ animated: animated(),
33
+ } as AnimatedElementPropsSettings<Element>;
34
+ });
35
+
36
+ watch(
37
+ // oxlint-disable-next-line react/refs -- L4 arquitectura intencional: latest-ref en watch source; mover a efecto cambia timing/identidad (mismo patron que Solid)
38
+ () => [elementRef.current, getAnimator?.()] as const,
39
+ ([element, animator], _previous, onCleanup) => {
40
+ if (!element || !animator) {
41
+ return;
42
+ }
43
+
44
+ const animatedElement = createAnimatedElement({
45
+ element,
46
+ animator: animator.node,
47
+ settingsRef,
48
+ });
49
+
50
+ onCleanup(() => animatedElement.cancel());
51
+ },
52
+ { immediate: true, flush: 'post' },
53
+ );
54
+ };
55
+
56
+ export { useAnimated };
@@ -0,0 +1 @@
1
+ export * from './useAnimatedX.js';
@@ -0,0 +1,54 @@
1
+ import { type AnimatedXElementPropsSettings, type AnimatedXProp, createAnimatedXElement } from '@elyndra/animated';
2
+ import { watch, watchEffect } from 'vue';
3
+
4
+ interface ElementSource<Element> {
5
+ current: Element | null;
6
+ }
7
+
8
+ /**
9
+ * Mechanical Vue port of the React/Preact/Solid `useAnimatedX` hook.
10
+ * `state` is a source function so state transitions stay tracked (same
11
+ * convention as `Animator`'s `refreshOn`); `watch` + `watch` cleanup replace
12
+ * `useEffect`/`createEffect` + `onCleanup`. Must be called inside `setup`.
13
+ */
14
+ const useAnimatedX = <States extends string, Element extends HTMLElement | SVGElement = HTMLDivElement>(
15
+ state: () => undefined | null | States,
16
+ elementRef: ElementSource<Element | null>,
17
+ animated: () => undefined | AnimatedXProp<States>,
18
+ settings?: Omit<AnimatedXElementPropsSettings<States>, 'state' | 'animated'>,
19
+ ): void => {
20
+ const settingsRef: { current: AnimatedXElementPropsSettings<States> } = {
21
+ current: {} as unknown as AnimatedXElementPropsSettings<States>,
22
+ };
23
+
24
+ watchEffect(() => {
25
+ const currentState = state();
26
+
27
+ // NOTE: `currentState as States` is load-bearing, not debt. The hook legitimately
28
+ // accepts null/undefined state, and the stored value is only consumed
29
+ // inside the creation watcher after an explicit null/undefined guard.
30
+ // Widening the settings type instead would weaken the contract for all
31
+ // consumers.
32
+ settingsRef.current = {
33
+ ...settings,
34
+ state: currentState as States,
35
+ animated: animated(),
36
+ };
37
+ });
38
+
39
+ watch(
40
+ () => [state(), elementRef.current] as const,
41
+ ([currentState, element], _previous, onCleanup) => {
42
+ if (currentState === undefined || currentState === null || !element) {
43
+ return;
44
+ }
45
+
46
+ const animatedXElement = createAnimatedXElement({ element, settingsRef });
47
+
48
+ onCleanup(() => animatedXElement.cancel());
49
+ },
50
+ { immediate: true, flush: 'post' },
51
+ );
52
+ };
53
+
54
+ export { useAnimatedX };
package/src/vue.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ // In-repo ambient types for `.vue` imports (tests only). The published
2
+ // package ships the `.vue` sources and consumers get full prop inference
3
+ // from their own Vue setup.
4
+ declare module '*.vue' {
5
+ import type { DefineComponent } from 'vue';
6
+ const Component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
7
+ export default Component;
8
+ }