@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 +52 -0
- package/src/Animated/Animated.test.ts +24 -0
- package/src/Animated/Animated.vue +87 -0
- package/src/Animated/index.ts +1 -0
- package/src/AnimatedX/AnimatedX.test.ts +24 -0
- package/src/AnimatedX/AnimatedX.vue +66 -0
- package/src/AnimatedX/index.ts +1 -0
- package/src/index.ts +5 -0
- package/src/types.ts +27 -0
- package/src/useAnimated/index.ts +1 -0
- package/src/useAnimated/useAnimated.test.ts +50 -0
- package/src/useAnimated/useAnimated.ts +56 -0
- package/src/useAnimatedX/index.ts +1 -0
- package/src/useAnimatedX/useAnimatedX.ts +54 -0
- package/src/vue.d.ts +8 -0
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
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
|
+
}
|