@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 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,2 @@
1
+ export { default as Animated } from './Animated.svelte';
2
+ export type * from './types.js';
@@ -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,2 @@
1
+ export { default as AnimatedX } from './AnimatedX.svelte';
2
+ export type * from './types.js';
@@ -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,4 @@
1
+ export * from './Animated/index.js';
2
+ export * from './AnimatedX/index.js';
3
+ export * from './useAnimated/index.js';
4
+ export * from './useAnimatedX/index.js';
@@ -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 };
@@ -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 };