@elyndra/svelte-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.svelte +98 -0
- package/src/Animated/Animated.test.ts +35 -0
- package/src/Animated/index.ts +2 -0
- package/src/Animated/types.ts +20 -0
- package/src/AnimatedX/AnimatedX.svelte +82 -0
- package/src/AnimatedX/AnimatedX.test.ts +35 -0
- package/src/AnimatedX/index.ts +2 -0
- package/src/AnimatedX/types.ts +18 -0
- package/src/index.ts +4 -0
- package/src/internal/style.ts +52 -0
- package/src/svelte.d.ts +7 -0
- package/src/useAnimated/index.ts +1 -0
- package/src/useAnimated/useAnimated.svelte.ts +51 -0
- package/src/useAnimatedX/index.ts +1 -0
- package/src/useAnimatedX/useAnimatedX.svelte.ts +45 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elyndra/svelte-animated",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"sideEffects": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"description": "Elyndra animated components for Svelte",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"elyndra",
|
|
11
|
+
"ui",
|
|
12
|
+
"front-end",
|
|
13
|
+
"framework",
|
|
14
|
+
"scifi",
|
|
15
|
+
"sci-fi",
|
|
16
|
+
"science-fiction",
|
|
17
|
+
"svelte"
|
|
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
|
+
"svelte": "5.57.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@elyndra/animated": "^1.2.0",
|
|
43
|
+
"@elyndra/animator": "^1.2.0",
|
|
44
|
+
"@elyndra/svelte-animator": "^1.2.0",
|
|
45
|
+
"@elyndra/svelte-tools": "^1.2.0",
|
|
46
|
+
"@elyndra/tools": "^1.2.0",
|
|
47
|
+
"tslib": "2.8.1"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc --noEmit -p tsconfig.json && svelte-check --tsconfig tsconfig.json --output machine"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatAnimatedCSSProps } from '@elyndra/animated';
|
|
3
|
+
import { useAnimator } from '@elyndra/svelte-animator';
|
|
4
|
+
import { useAnimated } from '../useAnimated/index.js';
|
|
5
|
+
import { toStyleText } from '../internal/style.js';
|
|
6
|
+
import type { AnimatedProps } from './types.js';
|
|
7
|
+
|
|
8
|
+
// No destructured rest: only direct `props.*` reads stay reactive.
|
|
9
|
+
const props: AnimatedProps<HTMLElement | SVGElement> = $props();
|
|
10
|
+
|
|
11
|
+
const getAnimator = useAnimator();
|
|
12
|
+
const animator = $derived(getAnimator?.());
|
|
13
|
+
|
|
14
|
+
let el: HTMLElement | SVGElement | null = $state(null);
|
|
15
|
+
const elementRef: { current: HTMLElement | SVGElement | null } = $state({ current: null });
|
|
16
|
+
|
|
17
|
+
// Keep the vanilla ref object live: `$state` makes `.current` reads
|
|
18
|
+
// tracked, so the `useAnimated` creation effect re-runs once the element
|
|
19
|
+
// is bound after mount.
|
|
20
|
+
$effect(() => {
|
|
21
|
+
elementRef.current = el;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Forward to the consumer ref (callback or object).
|
|
25
|
+
$effect(() => {
|
|
26
|
+
const target = el;
|
|
27
|
+
const ref = props.elementRef;
|
|
28
|
+
|
|
29
|
+
if (typeof ref === 'function') {
|
|
30
|
+
ref(target as HTMLElement & SVGElement);
|
|
31
|
+
} else if (ref) {
|
|
32
|
+
ref.current = target as HTMLElement & SVGElement;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Settings below intentionally capture the initial render values, same as
|
|
37
|
+
// the Solid adapter (vanilla `settingsRef` only tracks `animated`
|
|
38
|
+
// afterwards; visibility stays reactive via the template deriveds).
|
|
39
|
+
// svelte-ignore state_referenced_locally
|
|
40
|
+
useAnimated<HTMLElement | SVGElement>(elementRef, () => props.animated, {
|
|
41
|
+
renderInitials: false,
|
|
42
|
+
// svelte-ignore state_referenced_locally
|
|
43
|
+
hideOnExited: props.hideOnExited ?? true,
|
|
44
|
+
// svelte-ignore state_referenced_locally
|
|
45
|
+
hideOnEntered: props.hideOnEntered,
|
|
46
|
+
// svelte-ignore state_referenced_locally
|
|
47
|
+
onTransition: props.onTransition as (
|
|
48
|
+
element: HTMLElement | SVGElement,
|
|
49
|
+
node: Parameters<NonNullable<AnimatedProps<HTMLElement | SVGElement>['onTransition']>>[1],
|
|
50
|
+
) => void,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const animatedSettingsList = $derived.by(() => {
|
|
54
|
+
const received = Array.isArray(props.animated) ? props.animated : [props.animated];
|
|
55
|
+
return received
|
|
56
|
+
.map((item) => (typeof item === 'string' || Array.isArray(item) ? undefined : item))
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const initialAttributes = $derived.by(() => {
|
|
61
|
+
if (!animator) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return animatedSettingsList
|
|
65
|
+
.map((item) => (item ? item.initialAttributes : undefined))
|
|
66
|
+
.reduce<Record<string, string>>((total, item) => ({ ...total, ...item }), {});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const dynamicStyles = $derived.by(() => {
|
|
70
|
+
if (!animator) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return animatedSettingsList
|
|
74
|
+
.map((item) => (item ? item.initialStyle : undefined))
|
|
75
|
+
.filter((style): style is NonNullable<typeof style> => Boolean(style))
|
|
76
|
+
.map((styles) => formatAnimatedCSSProps(styles) as Record<string, string>)
|
|
77
|
+
.reduce<Record<string, string>>((total, item) => ({ ...total, ...item }), {});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const hideOnExited = $derived(props.hideOnExited ?? true);
|
|
81
|
+
const visibility = $derived(
|
|
82
|
+
animator && ((hideOnExited && animator.node.state === 'exited') || (props.hideOnEntered && animator.node.state === 'entered'))
|
|
83
|
+
? 'hidden'
|
|
84
|
+
: undefined,
|
|
85
|
+
);
|
|
86
|
+
const styleText = $derived(toStyleText(props.style, visibility ? { visibility } : undefined, dynamicStyles));
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<svelte:element
|
|
90
|
+
this={props.as ?? 'div'}
|
|
91
|
+
bind:this={el}
|
|
92
|
+
{...initialAttributes ?? {}}
|
|
93
|
+
id={props.id}
|
|
94
|
+
class={props.className}
|
|
95
|
+
style={styleText}
|
|
96
|
+
>
|
|
97
|
+
{@render props.children?.()}
|
|
98
|
+
</svelte:element>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { render } from '@testing-library/svelte';
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
import { createRawSnippet, type Snippet } from 'svelte';
|
|
4
|
+
import { expect, test } from 'vitest';
|
|
5
|
+
import Animated from './Animated.svelte';
|
|
6
|
+
|
|
7
|
+
const text = (html: string): Snippet =>
|
|
8
|
+
createRawSnippet(() => ({
|
|
9
|
+
render: () => html,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// `.svelte` components are `unknown` in-repo (ambient decl, tests only).
|
|
13
|
+
const animated = Animated as Component;
|
|
14
|
+
|
|
15
|
+
test('Should render element with children and default "div" element', () => {
|
|
16
|
+
const { container } = render(animated, {
|
|
17
|
+
animated: 'fade',
|
|
18
|
+
children: text('<span>hello</span>'),
|
|
19
|
+
});
|
|
20
|
+
// Svelte renders `<!---->` anchors, so query the element instead of
|
|
21
|
+
// relying on `firstChild` like the Solid adapter does.
|
|
22
|
+
const element = container.querySelector('div') as HTMLElement;
|
|
23
|
+
expect(element.tagName).toBe('DIV');
|
|
24
|
+
expect(element.textContent).toBe('hello');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('Should allow to set custom element', () => {
|
|
28
|
+
const { container } = render(animated, {
|
|
29
|
+
as: 'section',
|
|
30
|
+
animated: 'fade',
|
|
31
|
+
children: text('<span>hello</span>'),
|
|
32
|
+
});
|
|
33
|
+
const element = container.querySelector('section') as HTMLElement;
|
|
34
|
+
expect(element.tagName).toBe('SECTION');
|
|
35
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AnimatedProp } from '@elyndra/animated';
|
|
2
|
+
import type { AnimatorNode } from '@elyndra/animator';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
|
|
5
|
+
type ElementRef<E> = ((element: E | null) => void) | { current: E | null };
|
|
6
|
+
|
|
7
|
+
interface AnimatedProps<E extends HTMLElement | SVGElement = HTMLDivElement> {
|
|
8
|
+
elementRef?: ElementRef<E>;
|
|
9
|
+
className?: string;
|
|
10
|
+
style?: string | Record<string, string | number | undefined>;
|
|
11
|
+
animated?: AnimatedProp;
|
|
12
|
+
hideOnExited?: boolean;
|
|
13
|
+
hideOnEntered?: boolean;
|
|
14
|
+
onTransition?: (element: E, node: AnimatorNode) => void;
|
|
15
|
+
as?: string;
|
|
16
|
+
id?: string;
|
|
17
|
+
children?: Snippet;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type { AnimatedProps };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatAnimatedCSSProps, type AnimatedSettings } from '@elyndra/animated';
|
|
3
|
+
import { useAnimatedX } from '../useAnimatedX/index.js';
|
|
4
|
+
import { toStyleText } from '../internal/style.js';
|
|
5
|
+
import type { AnimatedXProps } from './types.js';
|
|
6
|
+
|
|
7
|
+
// No destructured rest: only direct `props.*` reads stay reactive.
|
|
8
|
+
const props: AnimatedXProps<string, HTMLElement | SVGElement> = $props();
|
|
9
|
+
|
|
10
|
+
let el: HTMLElement | SVGElement | null = $state(null);
|
|
11
|
+
const elementRef: { current: HTMLElement | SVGElement | null } = $state({ current: null });
|
|
12
|
+
|
|
13
|
+
$effect(() => {
|
|
14
|
+
elementRef.current = el;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
$effect(() => {
|
|
18
|
+
const target = el;
|
|
19
|
+
const ref = props.elementRef;
|
|
20
|
+
|
|
21
|
+
if (typeof ref === 'function') {
|
|
22
|
+
ref(target as HTMLElement & SVGElement);
|
|
23
|
+
} else if (ref) {
|
|
24
|
+
ref.current = target as HTMLElement & SVGElement;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const hasState = $derived(props.state !== undefined && props.state !== null);
|
|
29
|
+
|
|
30
|
+
// Settings intentionally capture the initial render values, same as the
|
|
31
|
+
// Solid adapter (vanilla `settingsRef` only tracks `state`/`animated`
|
|
32
|
+
// afterwards).
|
|
33
|
+
// svelte-ignore state_referenced_locally
|
|
34
|
+
useAnimatedX<string, HTMLElement | SVGElement>(
|
|
35
|
+
() => props.state,
|
|
36
|
+
elementRef,
|
|
37
|
+
() => props.animated,
|
|
38
|
+
{
|
|
39
|
+
renderInitials: false,
|
|
40
|
+
hideOnStates: (props.hideOnStates ?? []) as string[],
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const animatedSettingsList = $derived.by(() => {
|
|
45
|
+
const received = Array.isArray(props.animated) ? props.animated : [props.animated];
|
|
46
|
+
return received.filter(Boolean) as AnimatedSettings[];
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const initialAttributes = $derived.by(() => {
|
|
50
|
+
if (!hasState) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return animatedSettingsList
|
|
54
|
+
.map((item) => item?.initialAttributes)
|
|
55
|
+
.reduce<Record<string, string>>((total, item) => ({ ...total, ...item }), {});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const dynamicStyles = $derived.by(() => {
|
|
59
|
+
if (!hasState) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return animatedSettingsList
|
|
63
|
+
.map((item) => item.initialStyle)
|
|
64
|
+
.filter((style): style is NonNullable<typeof style> => Boolean(style))
|
|
65
|
+
.map((styles) => formatAnimatedCSSProps(styles) as Record<string, string>)
|
|
66
|
+
.reduce<Record<string, string>>((total, item) => ({ ...total, ...item }), {});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const visibility = $derived(hasState && (props.hideOnStates ?? []).includes(props.state as string) ? 'hidden' : undefined);
|
|
70
|
+
const styleText = $derived(toStyleText(props.style, visibility ? { visibility } : undefined, dynamicStyles));
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<svelte:element
|
|
74
|
+
this={props.as ?? 'div'}
|
|
75
|
+
bind:this={el}
|
|
76
|
+
{...initialAttributes ?? {}}
|
|
77
|
+
id={props.id}
|
|
78
|
+
class={props.className}
|
|
79
|
+
style={styleText}
|
|
80
|
+
>
|
|
81
|
+
{@render props.children?.()}
|
|
82
|
+
</svelte:element>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { render } from '@testing-library/svelte';
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
import { createRawSnippet, type Snippet } from 'svelte';
|
|
4
|
+
import { expect, test } from 'vitest';
|
|
5
|
+
import AnimatedX from './AnimatedX.svelte';
|
|
6
|
+
|
|
7
|
+
const text = (html: string): Snippet =>
|
|
8
|
+
createRawSnippet(() => ({
|
|
9
|
+
render: () => html,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// `.svelte` components are `unknown` in-repo (ambient decl, tests only).
|
|
13
|
+
const animatedX = AnimatedX as Component;
|
|
14
|
+
|
|
15
|
+
test('Should render element with children and default "div" element', () => {
|
|
16
|
+
const { container } = render(animatedX, {
|
|
17
|
+
state: 'on',
|
|
18
|
+
children: text('<span>hello</span>'),
|
|
19
|
+
});
|
|
20
|
+
// Svelte renders `<!---->` anchors, so query the element instead of
|
|
21
|
+
// relying on `firstChild` like the Solid adapter does.
|
|
22
|
+
const element = container.querySelector('div') as HTMLElement;
|
|
23
|
+
expect(element.tagName).toBe('DIV');
|
|
24
|
+
expect(element.textContent).toBe('hello');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('Should allow to set custom element', () => {
|
|
28
|
+
const { container } = render(animatedX, {
|
|
29
|
+
as: 'section',
|
|
30
|
+
state: 'on',
|
|
31
|
+
children: text('<span>hello</span>'),
|
|
32
|
+
});
|
|
33
|
+
const element = container.querySelector('section') as HTMLElement;
|
|
34
|
+
expect(element.tagName).toBe('SECTION');
|
|
35
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AnimatedXProp } from '@elyndra/animated';
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
type ElementRef<E> = ((element: E | null) => void) | { current: E | null };
|
|
5
|
+
|
|
6
|
+
interface AnimatedXProps<S extends string, E extends HTMLElement | SVGElement = HTMLDivElement> {
|
|
7
|
+
elementRef?: ElementRef<E>;
|
|
8
|
+
className?: string;
|
|
9
|
+
style?: string | Record<string, string | number | undefined>;
|
|
10
|
+
state: S | undefined | null;
|
|
11
|
+
hideOnStates?: S[];
|
|
12
|
+
animated?: AnimatedXProp<S>;
|
|
13
|
+
as?: string;
|
|
14
|
+
id?: string;
|
|
15
|
+
children?: Snippet;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type { AnimatedXProps };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
type StyleValue = string | number | undefined;
|
|
2
|
+
type StyleInput = string | Record<string, StyleValue> | undefined;
|
|
3
|
+
|
|
4
|
+
const toKebabCase = (key: string): string => key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
5
|
+
|
|
6
|
+
const stringifyStyleObject = (style: Record<string, StyleValue>): string => {
|
|
7
|
+
const parts: string[] = [];
|
|
8
|
+
|
|
9
|
+
for (const [key, value] of Object.entries(style)) {
|
|
10
|
+
if (value === undefined || value === null || value === '') {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
parts.push(`${toKebabCase(key)}: ${value}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return parts.join('; ');
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Serializes Svelte `style` props to CSS text. Accepts the React-style
|
|
22
|
+
* object (camelCase keys) for API parity plus plain CSS strings, so vanilla
|
|
23
|
+
* `initialStyle` objects and user styles merge declaratively.
|
|
24
|
+
*/
|
|
25
|
+
const toStyleText = (...styles: StyleInput[]): string | undefined => {
|
|
26
|
+
const parts: string[] = [];
|
|
27
|
+
|
|
28
|
+
for (const style of styles) {
|
|
29
|
+
if (!style) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof style === 'string') {
|
|
34
|
+
const trimmed = style.trim().replace(/;$/, '');
|
|
35
|
+
|
|
36
|
+
if (trimmed) {
|
|
37
|
+
parts.push(trimmed);
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
const text = stringifyStyleObject(style);
|
|
41
|
+
|
|
42
|
+
if (text) {
|
|
43
|
+
parts.push(text);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return parts.length ? parts.join('; ') : undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export { toStyleText };
|
|
52
|
+
export type { StyleInput };
|
package/src/svelte.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// In-repo ambient types for `.svelte` imports (tests only). The published
|
|
2
|
+
// package ships the `.svelte` sources and consumers get full prop inference
|
|
3
|
+
// from the `$props()` interfaces via their own Svelte setup.
|
|
4
|
+
declare module '*.svelte' {
|
|
5
|
+
const Component: unknown;
|
|
6
|
+
export default Component;
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './useAnimated.svelte.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type AnimatedElementPropsSettings, type AnimatedProp, createAnimatedElement } from '@elyndra/animated';
|
|
2
|
+
import { useAnimator } from '@elyndra/svelte-animator';
|
|
3
|
+
|
|
4
|
+
interface ElementRef<Element> {
|
|
5
|
+
current: Element | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mechanical Svelte port of the React/Preact `useAnimated` hook. Differences
|
|
10
|
+
* are Svelte idiom only: `$effect` + `$effect` cleanup replace `useEffect`,
|
|
11
|
+
* the animator is read through the `useAnimator` accessor, and `animated`
|
|
12
|
+
* is an accessor so updates stay tracked (same convention as `Animator`'s
|
|
13
|
+
* `refreshOn`). Must be called during component init.
|
|
14
|
+
*/
|
|
15
|
+
const useAnimated = <Element extends HTMLElement | SVGElement = HTMLElement>(
|
|
16
|
+
elementRef: ElementRef<Element | null>,
|
|
17
|
+
animated: () => undefined | AnimatedProp,
|
|
18
|
+
settings?: undefined | Omit<AnimatedElementPropsSettings<Element>, 'animated'>,
|
|
19
|
+
): void => {
|
|
20
|
+
const getAnimator = useAnimator();
|
|
21
|
+
const settingsRef: { current: AnimatedElementPropsSettings<Element> } = {
|
|
22
|
+
current: { animated: undefined } as AnimatedElementPropsSettings<Element>,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
$effect(() => {
|
|
26
|
+
settingsRef.current = {
|
|
27
|
+
...settings,
|
|
28
|
+
animated: animated(),
|
|
29
|
+
} as AnimatedElementPropsSettings<Element>;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// oxlint-disable-next-line react/refs -- L4 arquitectura intencional: latest-ref/mergeRefs/ref-callback en render; mover a efecto cambia timing/identidad
|
|
33
|
+
$effect(() => {
|
|
34
|
+
const animator = getAnimator?.();
|
|
35
|
+
const element = elementRef.current;
|
|
36
|
+
|
|
37
|
+
if (!element || !animator) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const animatedElement = createAnimatedElement({
|
|
42
|
+
element,
|
|
43
|
+
animator: animator.node,
|
|
44
|
+
settingsRef,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return () => animatedElement.cancel();
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export { useAnimated };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './useAnimatedX.svelte.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type AnimatedXElementPropsSettings, type AnimatedXProp, createAnimatedXElement } from '@elyndra/animated';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mechanical Svelte port of the React/Preact `useAnimatedX` hook. `state`
|
|
5
|
+
* is an accessor so state transitions stay tracked (same convention as
|
|
6
|
+
* `Animator`'s `refreshOn`); `$effect` + cleanup replace `useEffect`.
|
|
7
|
+
* Must be called during component init.
|
|
8
|
+
*/
|
|
9
|
+
const useAnimatedX = <States extends string, Element extends HTMLElement | SVGElement = HTMLDivElement>(
|
|
10
|
+
state: () => undefined | null | States,
|
|
11
|
+
elementRef: { current: Element | null },
|
|
12
|
+
animated: () => undefined | AnimatedXProp<States>,
|
|
13
|
+
settings?: Omit<AnimatedXElementPropsSettings<States>, 'state' | 'animated'>,
|
|
14
|
+
): void => {
|
|
15
|
+
const settingsRef: { current: AnimatedXElementPropsSettings<States> } = {
|
|
16
|
+
current: {} as unknown as AnimatedXElementPropsSettings<States>,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
$effect(() => {
|
|
20
|
+
const currentState = state();
|
|
21
|
+
|
|
22
|
+
// NOTE: `currentState as States` is load-bearing, not debt. The hook legitimately
|
|
23
|
+
// accepts null/undefined state, and the stored value is only consumed
|
|
24
|
+
// inside the creation effect after an explicit null/undefined guard.
|
|
25
|
+
// Widening the settings type instead would weaken the contract for all
|
|
26
|
+
// consumers.
|
|
27
|
+
settingsRef.current = {
|
|
28
|
+
...settings,
|
|
29
|
+
state: currentState as States,
|
|
30
|
+
animated: animated(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const element = elementRef.current;
|
|
34
|
+
|
|
35
|
+
if (currentState === undefined || currentState === null || !element) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const animatedXElement = createAnimatedXElement({ element, settingsRef });
|
|
40
|
+
|
|
41
|
+
return () => animatedXElement.cancel();
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export { useAnimatedX };
|