@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 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
@@ -0,0 +1,2 @@
1
+ export * from './Text/index.js';
2
+ export type * from './types.js';
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
+ }