@elyndra/svelte-text 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,53 @@
1
+ {
2
+ "name": "@elyndra/svelte-text",
3
+ "version": "1.2.0",
4
+ "sideEffects": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "Elyndra text 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/text": "^1.2.0",
47
+ "@elyndra/tools": "^1.2.0",
48
+ "tslib": "2.8.1"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc --noEmit -p tsconfig.json && svelte-check --tsconfig tsconfig.json --output machine"
52
+ }
53
+ }
@@ -0,0 +1,145 @@
1
+ <script lang="ts">
2
+ import type { Animation } from '@elyndra/animated';
3
+ import { useAnimator } from '@elyndra/svelte-animator';
4
+ import { animateTextDecipher, animateTextSequence, getAnimationTextDuration } from '@elyndra/text';
5
+ import { cx } from '@elyndra/tools';
6
+ import { toStyleText } from '../internal/style.js';
7
+ import type { TextProps } from './types.js';
8
+
9
+ // No destructured rest: only direct `props.*` reads stay reactive.
10
+ const props: TextProps = $props();
11
+
12
+ const getAnimator = useAnimator();
13
+ const animator = $derived(getAnimator?.());
14
+
15
+ let rootElement: HTMLElement | null = $state(null);
16
+ let contentElement: HTMLSpanElement | null = $state(null);
17
+ let childrenText = $state('');
18
+ let transitionControl: Animation | null = null;
19
+
20
+ $effect(() => {
21
+ // Track children so the text snapshot follows content updates.
22
+ void props.children;
23
+ childrenText = contentElement?.textContent ?? '';
24
+ });
25
+
26
+ $effect(() => {
27
+ const currentAnimator = animator;
28
+ const text = childrenText;
29
+ const root = rootElement;
30
+ const content = contentElement;
31
+
32
+ if (!currentAnimator || !text.length || !root || !content) {
33
+ return;
34
+ }
35
+
36
+ if (!props.fixed) {
37
+ const { settings } = currentAnimator.node;
38
+ const durationEnter = getAnimationTextDuration({
39
+ length: text.length,
40
+ maxDuration: settings.duration.enter,
41
+ });
42
+ const durationExit = getAnimationTextDuration({
43
+ length: text.length,
44
+ maxDuration: settings.duration.exit,
45
+ });
46
+
47
+ currentAnimator.node.control.setSettings({
48
+ duration: { enter: durationEnter, exit: durationExit },
49
+ });
50
+ }
51
+
52
+ const transition = (duration: number, isEntering: boolean): void => {
53
+ const baseOptions = {
54
+ rootElement: root,
55
+ contentElement: content,
56
+ duration,
57
+ isEntering,
58
+ easing: props.easing,
59
+ hideOnExited: props.hideOnExited,
60
+ hideOnEntered: props.hideOnEntered,
61
+ };
62
+
63
+ transitionControl?.cancel();
64
+ if (props.manager === 'decipher') {
65
+ transitionControl = animateTextDecipher({ ...baseOptions, characters: props.characters });
66
+ } else {
67
+ transitionControl = animateTextSequence({
68
+ ...baseOptions,
69
+ blink: props.blink,
70
+ blinkDuration: props.blinkDuration,
71
+ });
72
+ }
73
+ };
74
+
75
+ const unsubscribe = currentAnimator.node.subscribe((node) => {
76
+ switch (node.state) {
77
+ case 'entering': {
78
+ transition(node.settings.duration.enter, true);
79
+ break;
80
+ }
81
+ case 'entered': {
82
+ // If the node is subscribed when the state is entered right away,
83
+ // then there was not an initial animation of entering, so run animations.
84
+ if (!transitionControl) {
85
+ transition(node.settings.duration.enter, true);
86
+ }
87
+ break;
88
+ }
89
+ case 'exiting': {
90
+ transition(node.settings.duration.exit, false);
91
+ break;
92
+ }
93
+ }
94
+ });
95
+
96
+ return () => {
97
+ unsubscribe();
98
+ transitionControl?.cancel();
99
+ transitionControl = null;
100
+ };
101
+ });
102
+
103
+ $effect(() => {
104
+ const target = rootElement;
105
+ const ref = props.elementRef;
106
+
107
+ if (typeof ref === 'function') {
108
+ ref(target);
109
+ } else if (ref) {
110
+ ref.current = target;
111
+ }
112
+ });
113
+
114
+ // Snapshot visibility like React/Preact render: the node state is vanilla
115
+ // (not a signal), live show/hide during transitions is owned by the
116
+ // animation above.
117
+ const contentVisibility = $derived(
118
+ animator
119
+ ? ((animator.node.state === 'exited' && (props.hideOnExited ?? true)) ||
120
+ (animator.node.state === 'entered' && props.hideOnEntered)
121
+ ? 'hidden'
122
+ : undefined)
123
+ : undefined,
124
+ );
125
+ const styleText = $derived(toStyleText({ position: 'relative' }, props.style));
126
+ const contentStyleText = $derived(
127
+ toStyleText({ position: 'relative' }, contentVisibility ? { visibility: contentVisibility } : undefined, props.contentStyle),
128
+ );
129
+ </script>
130
+
131
+ <svelte:element
132
+ this={props.as ?? 'p'}
133
+ bind:this={rootElement}
134
+ id={props.id}
135
+ class={cx('elyndra-text-text', props.className)}
136
+ style={styleText}
137
+ >
138
+ <span
139
+ bind:this={contentElement}
140
+ class={cx('elyndra-text-text__content', props.contentClassName)}
141
+ style={contentStyleText}
142
+ >
143
+ {@render props.children?.()}
144
+ </span>
145
+ </svelte:element>
@@ -0,0 +1,25 @@
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 Text from './Text.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 textComponent = Text as Component;
14
+
15
+ test('Should render component classes and content with default "p" element', () => {
16
+ const { container } = render(textComponent, {
17
+ children: text('<span>Furutistic <b>Sci-Fi</b> UI Web Framework</span>'),
18
+ });
19
+ const element = container.querySelector('p') as HTMLElement;
20
+ expect(element.classList.contains('elyndra-text-text')).toBe(true);
21
+ expect(element.tagName).toBe('P');
22
+ const contentEl = element.querySelector('.elyndra-text-text__content') as HTMLElement;
23
+ expect(contentEl.classList.contains('elyndra-text-text__content')).toBe(true);
24
+ expect(contentEl.textContent).toContain('Furutistic');
25
+ });
@@ -0,0 +1,2 @@
1
+ export { default as Text } from './Text.svelte';
2
+ export type * from './types.js';
@@ -0,0 +1,34 @@
1
+ import type { Easing } from '@elyndra/animated';
2
+ import type { AnimateTextManager } from '@elyndra/text';
3
+ import type { Snippet } from 'svelte';
4
+
5
+ type ElementRef<E> = ((element: E | null) => void) | { current: E | null };
6
+
7
+ interface TextProps {
8
+ as?: string;
9
+ className?: string;
10
+ style?: string | Record<string, string | number | undefined>;
11
+ contentClassName?: string;
12
+ contentStyle?: string | Record<string, string | number | undefined>;
13
+ elementRef?: ElementRef<HTMLElement>;
14
+ manager?: AnimateTextManager;
15
+ easing?: Easing;
16
+ /**
17
+ * If the duration of the animation should be fixed by the parent Animator
18
+ * or dynamic according to its children.
19
+ */
20
+ fixed?: boolean;
21
+ /**
22
+ * In manager sequence, add a blinking element at the end of the currently
23
+ * displayed text.
24
+ */
25
+ blink?: boolean;
26
+ blinkDuration?: number;
27
+ characters?: string;
28
+ hideOnEntered?: boolean;
29
+ hideOnExited?: boolean;
30
+ id?: string;
31
+ children?: Snippet;
32
+ }
33
+
34
+ export type { TextProps };
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './Text/index.js';
@@ -0,0 +1,51 @@
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.
23
+ */
24
+ const toStyleText = (...styles: StyleInput[]): string | undefined => {
25
+ const parts: string[] = [];
26
+
27
+ for (const style of styles) {
28
+ if (!style) {
29
+ continue;
30
+ }
31
+
32
+ if (typeof style === 'string') {
33
+ const trimmed = style.trim().replace(/;$/, '');
34
+
35
+ if (trimmed) {
36
+ parts.push(trimmed);
37
+ }
38
+ } else {
39
+ const text = stringifyStyleObject(style);
40
+
41
+ if (text) {
42
+ parts.push(text);
43
+ }
44
+ }
45
+ }
46
+
47
+ return parts.length ? parts.join('; ') : undefined;
48
+ };
49
+
50
+ export { toStyleText };
51
+ 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
+ }