@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 +53 -0
- package/src/Text/Text.svelte +145 -0
- package/src/Text/Text.test.ts +25 -0
- package/src/Text/index.ts +2 -0
- package/src/Text/types.ts +34 -0
- package/src/index.ts +1 -0
- package/src/internal/style.ts +51 -0
- package/src/svelte.d.ts +7 -0
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,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 };
|
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
|
+
}
|