@elyndra/vue-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.test.ts +33 -0
- package/src/Text/Text.vue +173 -0
- package/src/Text/index.ts +1 -0
- package/src/index.ts +2 -0
- package/src/types.ts +31 -0
- package/src/vue.d.ts +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elyndra/vue-text",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"sideEffects": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"description": "Elyndra text 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/text": "^1.2.0",
|
|
45
|
+
"@elyndra/tools": "^1.2.0",
|
|
46
|
+
"@elyndra/vue-animator": "^1.2.0",
|
|
47
|
+
"@elyndra/vue-tools": "^1.2.0",
|
|
48
|
+
"tslib": "2.8.1"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc --noEmit -p tsconfig.json && vue-tsc --noEmit -p tsconfig.json"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { flushPromises, mount } from '@vue/test-utils';
|
|
2
|
+
import { h } from 'vue';
|
|
3
|
+
import { expect, test } from 'vitest';
|
|
4
|
+
import { Text } from './index.js';
|
|
5
|
+
|
|
6
|
+
test('Should render component classes and content with default "p" element', async () => {
|
|
7
|
+
const wrapper = mount(Text, {
|
|
8
|
+
slots: { default: () => ['Furutistic ', h('b', 'Sci-Fi'), ' UI Web Framework'] },
|
|
9
|
+
});
|
|
10
|
+
await flushPromises();
|
|
11
|
+
|
|
12
|
+
const element = wrapper.element as HTMLElement;
|
|
13
|
+
const contentElement = element.firstChild as HTMLElement;
|
|
14
|
+
expect(element.classList.contains('elyndra-text-text')).toBe(true);
|
|
15
|
+
expect(element.tagName).toBe('P');
|
|
16
|
+
expect(contentElement.classList.contains('elyndra-text-text__content')).toBe(true);
|
|
17
|
+
expect(contentElement.innerHTML).toBe('Furutistic <b>Sci-Fi</b> UI Web Framework');
|
|
18
|
+
wrapper.unmount();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('Should allow to set custom element', async () => {
|
|
22
|
+
const wrapper = mount(Text, {
|
|
23
|
+
props: { as: 'a', href: 'https://elyndra.dev', target: 'elyndra' },
|
|
24
|
+
slots: { default: () => 'Elyndra' },
|
|
25
|
+
});
|
|
26
|
+
await flushPromises();
|
|
27
|
+
|
|
28
|
+
const element = wrapper.element as HTMLElement;
|
|
29
|
+
expect(element.tagName).toBe('A');
|
|
30
|
+
expect(element.getAttribute('href')).toBe('https://elyndra.dev');
|
|
31
|
+
expect(element.getAttribute('target')).toBe('elyndra');
|
|
32
|
+
wrapper.unmount();
|
|
33
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<script setup lang="ts" generic="E extends HTMLElement = HTMLSpanElement">
|
|
2
|
+
import type { Animation } from '@elyndra/animated';
|
|
3
|
+
import { useAnimator } from '@elyndra/vue-animator';
|
|
4
|
+
import { mergeRefs } from '@elyndra/vue-tools';
|
|
5
|
+
import {
|
|
6
|
+
animateTextDecipher,
|
|
7
|
+
animateTextSequence,
|
|
8
|
+
getAnimationTextDuration,
|
|
9
|
+
} from '@elyndra/text';
|
|
10
|
+
import { cx } from '@elyndra/tools';
|
|
11
|
+
import { computed, onMounted, onUpdated, ref, watch, type CSSProperties } from 'vue';
|
|
12
|
+
import type { TextProps } from '../types.js';
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<TextProps<E>>(), {
|
|
15
|
+
// Vue boolean-casts absent props with a Boolean type to `false`. These
|
|
16
|
+
// flags distinguish "absent" from an explicit `false` (or are read with
|
|
17
|
+
// `??`), so each gets an `undefined` default which skips the cast.
|
|
18
|
+
fixed: undefined,
|
|
19
|
+
blink: undefined,
|
|
20
|
+
hideOnEntered: undefined,
|
|
21
|
+
hideOnExited: undefined,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const getAnimator = useAnimator();
|
|
25
|
+
// Snapshot like React/Preact/Solid render: the node state is vanilla (not
|
|
26
|
+
// reactive), live show/hide during transitions is owned by the animation.
|
|
27
|
+
const animator = getAnimator?.();
|
|
28
|
+
const as = computed(() => props.as ?? 'p');
|
|
29
|
+
const childrenText = ref('');
|
|
30
|
+
const rootBox: { current: E | null } = { current: null };
|
|
31
|
+
const contentBox: { current: HTMLSpanElement | null } = { current: null };
|
|
32
|
+
let transitionControl: Animation | null = null;
|
|
33
|
+
|
|
34
|
+
const setElementRef = (value: unknown): void => {
|
|
35
|
+
const element = value as E | null;
|
|
36
|
+
rootBox.current = element;
|
|
37
|
+
mergeRefs(props.elementRef)(element);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const setContentRef = (value: unknown): void => {
|
|
41
|
+
contentBox.current = value as HTMLSpanElement | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const readChildrenText = (): void => {
|
|
45
|
+
childrenText.value = contentBox.current?.textContent ?? '';
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const rootStyle = computed<CSSProperties>(() => ({
|
|
49
|
+
position: 'relative',
|
|
50
|
+
...props.style,
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const contentVisibility = computed<CSSProperties['visibility']>(() => {
|
|
54
|
+
if (!animator) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const hidden =
|
|
58
|
+
(animator.node.state === 'exited' && (props.hideOnExited ?? true)) ||
|
|
59
|
+
(animator.node.state === 'entered' && props.hideOnEntered);
|
|
60
|
+
// Empty string keeps the runtime contract identical to React/Preact/Solid.
|
|
61
|
+
return (hidden ? 'hidden' : '') as CSSProperties['visibility'];
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const contentStyle = computed<CSSProperties>(() => ({
|
|
65
|
+
position: 'relative',
|
|
66
|
+
visibility: contentVisibility.value,
|
|
67
|
+
...props.contentStyle,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// Slot content is only measurable after mount, and again after any update
|
|
71
|
+
// that may have changed it.
|
|
72
|
+
onMounted(readChildrenText);
|
|
73
|
+
onUpdated(readChildrenText);
|
|
74
|
+
|
|
75
|
+
watch(
|
|
76
|
+
() => [getAnimator?.(), childrenText.value] as const,
|
|
77
|
+
([currentAnimator, text], _previous, onCleanup) => {
|
|
78
|
+
const root = rootBox.current;
|
|
79
|
+
const content = contentBox.current;
|
|
80
|
+
|
|
81
|
+
if (!currentAnimator || !text.length || !root || !content) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!props.fixed) {
|
|
86
|
+
const { settings } = currentAnimator.node;
|
|
87
|
+
const durationEnter = getAnimationTextDuration({
|
|
88
|
+
length: text.length,
|
|
89
|
+
maxDuration: settings.duration.enter,
|
|
90
|
+
});
|
|
91
|
+
const durationExit = getAnimationTextDuration({
|
|
92
|
+
length: text.length,
|
|
93
|
+
maxDuration: settings.duration.exit,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
currentAnimator.node.control.setSettings({
|
|
97
|
+
duration: { enter: durationEnter, exit: durationExit },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const transition = (duration: number, isEntering: boolean): void => {
|
|
102
|
+
// Props read here are live at call time but untracked (same as Solid,
|
|
103
|
+
// where the subscription callback runs outside the reactive context).
|
|
104
|
+
// Only `fixed` above is tracked and re-subscribes.
|
|
105
|
+
const baseOptions = {
|
|
106
|
+
rootElement: root,
|
|
107
|
+
contentElement: content,
|
|
108
|
+
duration,
|
|
109
|
+
isEntering,
|
|
110
|
+
easing: props.easing,
|
|
111
|
+
hideOnExited: props.hideOnExited,
|
|
112
|
+
hideOnEntered: props.hideOnEntered,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
transitionControl?.cancel();
|
|
116
|
+
if (props.manager === 'decipher') {
|
|
117
|
+
transitionControl = animateTextDecipher({ ...baseOptions, characters: props.characters });
|
|
118
|
+
} else {
|
|
119
|
+
transitionControl = animateTextSequence({
|
|
120
|
+
...baseOptions,
|
|
121
|
+
blink: props.blink,
|
|
122
|
+
blinkDuration: props.blinkDuration,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const unsubscribe = currentAnimator.node.subscribe((node) => {
|
|
128
|
+
switch (node.state) {
|
|
129
|
+
case 'entering': {
|
|
130
|
+
transition(node.settings.duration.enter, true);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'entered': {
|
|
134
|
+
// If the node is subscribed when the state is entered right away,
|
|
135
|
+
// then there was not an initial animation of entering, so run animations.
|
|
136
|
+
if (!transitionControl) {
|
|
137
|
+
transition(node.settings.duration.enter, true);
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'exiting': {
|
|
142
|
+
transition(node.settings.duration.exit, false);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
onCleanup(() => {
|
|
149
|
+
unsubscribe();
|
|
150
|
+
transitionControl?.cancel();
|
|
151
|
+
transitionControl = null;
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
{ immediate: true, flush: 'post' },
|
|
155
|
+
);
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<template>
|
|
159
|
+
<component
|
|
160
|
+
:is="as"
|
|
161
|
+
:ref="setElementRef"
|
|
162
|
+
:class="cx('elyndra-text-text', props.className)"
|
|
163
|
+
:style="rootStyle"
|
|
164
|
+
>
|
|
165
|
+
<span
|
|
166
|
+
:ref="setContentRef"
|
|
167
|
+
:class="cx('elyndra-text-text__content', props.contentClassName)"
|
|
168
|
+
:style="contentStyle"
|
|
169
|
+
>
|
|
170
|
+
<slot />
|
|
171
|
+
</span>
|
|
172
|
+
</component>
|
|
173
|
+
</template>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Text } from './Text.vue';
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CSSProperties } from 'vue';
|
|
2
|
+
import type { Easing } from '@elyndra/animated';
|
|
3
|
+
import type { AnimateTextManager } from '@elyndra/text';
|
|
4
|
+
import type { ElementRef } from '@elyndra/vue-tools';
|
|
5
|
+
|
|
6
|
+
interface TextProps<E extends HTMLElement = HTMLSpanElement> {
|
|
7
|
+
as?: keyof HTMLElementTagNameMap;
|
|
8
|
+
className?: string;
|
|
9
|
+
style?: CSSProperties;
|
|
10
|
+
contentClassName?: string;
|
|
11
|
+
contentStyle?: CSSProperties;
|
|
12
|
+
elementRef?: ElementRef<E>;
|
|
13
|
+
manager?: AnimateTextManager;
|
|
14
|
+
easing?: Easing;
|
|
15
|
+
/**
|
|
16
|
+
* If the duration of the animation should be fixed by the parent Animator
|
|
17
|
+
* or dynamic according to its children.
|
|
18
|
+
*/
|
|
19
|
+
fixed?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* In manager sequence, add a blinking element at the end of the currently
|
|
22
|
+
* displayed text.
|
|
23
|
+
*/
|
|
24
|
+
blink?: boolean;
|
|
25
|
+
blinkDuration?: number;
|
|
26
|
+
characters?: string;
|
|
27
|
+
hideOnEntered?: boolean;
|
|
28
|
+
hideOnExited?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type { ElementRef, TextProps };
|
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
|
+
}
|