@elyndra/vue-frames 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,51 @@
1
+ {
2
+ "name": "@elyndra/vue-frames",
3
+ "version": "1.2.0",
4
+ "sideEffects": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "Elyndra frames 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/frames": "^1.2.0",
43
+ "@elyndra/tools": "^1.2.0",
44
+ "@elyndra/vue-animator": "^1.2.0",
45
+ "@elyndra/vue-tools": "^1.2.0",
46
+ "tslib": "2.8.1"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc --noEmit -p tsconfig.json && vue-tsc --noEmit -p tsconfig.json"
50
+ }
51
+ }
@@ -0,0 +1,31 @@
1
+ import { flushPromises, mount } from '@vue/test-utils';
2
+ import { afterEach, beforeEach, expect, test } from 'vitest';
3
+ import { createFrameOctagonSettings } from '@elyndra/frames';
4
+ import { FrameBase } from './index.js';
5
+
6
+ beforeEach(() => {
7
+ class ResizeObserver {
8
+ observe(): void {}
9
+ unobserve(): void {}
10
+ disconnect(): void {}
11
+ }
12
+ window.ResizeObserver = ResizeObserver as unknown as typeof window.ResizeObserver;
13
+ });
14
+
15
+ afterEach(() => {
16
+ window.ResizeObserver = undefined as unknown as typeof window.ResizeObserver;
17
+ });
18
+
19
+ test('Should render svg element with elyndra class and children', async () => {
20
+ const wrapper = mount(FrameBase, {
21
+ props: { settings: createFrameOctagonSettings({}) },
22
+ slots: { default: () => 'hello' },
23
+ });
24
+ await flushPromises();
25
+
26
+ const element = wrapper.element as HTMLElement;
27
+ expect(element.tagName).toBe('svg');
28
+ expect(element.classList.contains('elyndra-frames-frame')).toBe(true);
29
+ expect(element.textContent).toBe('hello');
30
+ wrapper.unmount();
31
+ });
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ import { type Frame, type FrameSettings, createFrame } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { useAnimator } from '@elyndra/vue-animator';
5
+ import { mergeRefs, useUpdateEffect } from '@elyndra/vue-tools';
6
+ import { computed, watch, watchEffect, type CSSProperties } from 'vue';
7
+ import { positionedStyle } from '../internal/styles.js';
8
+ import type { FrameBaseProps } from '../types.js';
9
+
10
+ const props = withDefaults(defineProps<FrameBaseProps>(), {
11
+ // Vue boolean-casts absent props with a Boolean type to `false`.
12
+ // `positioned` defaults to `true`, so it gets an `undefined` default
13
+ // which skips the cast.
14
+ positioned: undefined,
15
+ preserveAspectRatio: 'none',
16
+ });
17
+
18
+ const getAnimator = useAnimator();
19
+ const svgBox: { current: SVGSVGElement | null } = { current: null };
20
+ let frame: Frame | null = null;
21
+ const settingsRef: { current: FrameSettings } = { current: props.settings };
22
+
23
+ const setSvgRef = (value: unknown): void => {
24
+ const element = value as SVGSVGElement | null;
25
+ svgBox.current = element;
26
+ mergeRefs(props.elementRef)(element);
27
+ };
28
+
29
+ watchEffect(() => {
30
+ Object.assign(settingsRef.current, props.settings, { animator: getAnimator?.()?.node });
31
+ });
32
+
33
+ watch(
34
+ () => getAnimator?.(),
35
+ (animator, _previous, onCleanup) => {
36
+ // Rebuild the frame when the animator interface changes, full teardown +
37
+ // rebuild. Settings-only changes take the light path below, so animator
38
+ // changes must NOT join those deps or they would render() a
39
+ // just-removed frame.
40
+ void animator;
41
+
42
+ const svg = svgBox.current;
43
+
44
+ if (!svg) {
45
+ return;
46
+ }
47
+
48
+ frame = createFrame(svg, settingsRef.current);
49
+
50
+ onCleanup(() => frame?.remove());
51
+ },
52
+ { immediate: true, flush: 'post' },
53
+ );
54
+
55
+ useUpdateEffect(() => {
56
+ frame?.render();
57
+ }, [() => props.settings]);
58
+
59
+ const svgStyle = computed<CSSProperties>(() => ({
60
+ ...((props.positioned ?? true) ? positionedStyle : null),
61
+ ...props.style,
62
+ }));
63
+ </script>
64
+
65
+ <template>
66
+ <svg
67
+ role="presentation"
68
+ :ref="setSvgRef"
69
+ :class="cx('elyndra-frames-frame', props.className)"
70
+ :style="svgStyle"
71
+ xmlns="http://www.w3.org/2000/svg"
72
+ :preserveAspectRatio="props.preserveAspectRatio"
73
+ >
74
+ <slot />
75
+ </svg>
76
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameBase } from './FrameBase.vue';
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ import { createFrameCircleSettings } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { computed } from 'vue';
5
+ import { FrameBase } from '../FrameBase/index.js';
6
+ import type { FrameCircleProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<FrameCircleProps>(), {
9
+ // The vanilla settings default `styled`/`animated` to `true`; Vue
10
+ // boolean-casts absent Boolean props to `false`, so each gets an
11
+ // `undefined` default which skips the cast. Same for `positioned`.
12
+ styled: undefined,
13
+ animated: undefined,
14
+ positioned: undefined,
15
+ });
16
+
17
+ const settings = computed(() =>
18
+ createFrameCircleSettings({
19
+ styled: props.styled,
20
+ animated: props.animated,
21
+ padding: props.padding,
22
+ strokeWidth: props.strokeWidth,
23
+ separation: props.separation,
24
+ sideWidth: props.sideWidth,
25
+ }),
26
+ );
27
+ </script>
28
+
29
+ <template>
30
+ <FrameBase
31
+ :elementRef="props.elementRef"
32
+ :positioned="props.positioned"
33
+ :style="props.style"
34
+ :preserveAspectRatio="props.preserveAspectRatio"
35
+ :class="cx('elyndra-frames-framecircle', props.className)"
36
+ :settings="settings"
37
+ >
38
+ <slot />
39
+ </FrameBase>
40
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameCircle } from './FrameCircle.vue';
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { createFrameCornersSettings } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { computed } from 'vue';
5
+ import { FrameBase } from '../FrameBase/index.js';
6
+ import type { FrameCornersProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<FrameCornersProps>(), {
9
+ // The vanilla settings default `styled`/`animated` to `true`; Vue
10
+ // boolean-casts absent Boolean props to `false`, so each gets an
11
+ // `undefined` default which skips the cast. Same for `positioned`.
12
+ styled: undefined,
13
+ animated: undefined,
14
+ positioned: undefined,
15
+ });
16
+
17
+ const settings = computed(() =>
18
+ createFrameCornersSettings({
19
+ styled: props.styled,
20
+ animated: props.animated,
21
+ padding: props.padding,
22
+ strokeWidth: props.strokeWidth,
23
+ cornerLength: props.cornerLength,
24
+ }),
25
+ );
26
+ </script>
27
+
28
+ <template>
29
+ <FrameBase
30
+ :elementRef="props.elementRef"
31
+ :positioned="props.positioned"
32
+ :style="props.style"
33
+ :preserveAspectRatio="props.preserveAspectRatio"
34
+ :class="cx('elyndra-frames-framecorners', props.className)"
35
+ :settings="settings"
36
+ >
37
+ <slot />
38
+ </FrameBase>
39
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameCorners } from './FrameCorners.vue';
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ import { createFrameHeaderSettings } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { computed } from 'vue';
5
+ import { FrameBase } from '../FrameBase/index.js';
6
+ import type { FrameHeaderProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<FrameHeaderProps>(), {
9
+ // The vanilla settings default `styled`/`animated` to `true`; Vue
10
+ // boolean-casts absent Boolean props to `false`, so each gets an
11
+ // `undefined` default which skips the cast. Same for `positioned`.
12
+ styled: undefined,
13
+ animated: undefined,
14
+ positioned: undefined,
15
+ });
16
+
17
+ const settings = computed(() =>
18
+ createFrameHeaderSettings({
19
+ styled: props.styled,
20
+ animated: props.animated,
21
+ padding: props.padding,
22
+ strokeWidth: props.strokeWidth,
23
+ decoWidth: props.decoWidth,
24
+ direction: props.direction,
25
+ align: props.align,
26
+ contentLength: props.contentLength,
27
+ }),
28
+ );
29
+ </script>
30
+
31
+ <template>
32
+ <FrameBase
33
+ :elementRef="props.elementRef"
34
+ :positioned="props.positioned"
35
+ :style="props.style"
36
+ :preserveAspectRatio="props.preserveAspectRatio"
37
+ :class="cx('elyndra-frames-frameheader', props.className)"
38
+ :settings="settings"
39
+ >
40
+ <slot />
41
+ </FrameBase>
42
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameHeader } from './FrameHeader.vue';
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ import { createFrameKranoxSettings } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { computed } from 'vue';
5
+ import { FrameBase } from '../FrameBase/index.js';
6
+ import type { FrameKranoxProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<FrameKranoxProps>(), {
9
+ // The vanilla settings default `styled`/`animated` to `true`; Vue
10
+ // boolean-casts absent Boolean props to `false`, so each gets an
11
+ // `undefined` default which skips the cast. Same for `positioned`.
12
+ styled: undefined,
13
+ animated: undefined,
14
+ positioned: undefined,
15
+ });
16
+
17
+ const settings = computed(() =>
18
+ createFrameKranoxSettings({
19
+ styled: props.styled,
20
+ animated: props.animated,
21
+ padding: props.padding,
22
+ strokeWidth: props.strokeWidth,
23
+ bgStrokeWidth: props.bgStrokeWidth,
24
+ squareSize: props.squareSize,
25
+ smallLineLength: props.smallLineLength,
26
+ largeLineLength: props.largeLineLength,
27
+ }),
28
+ );
29
+ </script>
30
+
31
+ <template>
32
+ <FrameBase
33
+ :elementRef="props.elementRef"
34
+ :positioned="props.positioned"
35
+ :style="props.style"
36
+ :preserveAspectRatio="props.preserveAspectRatio"
37
+ :class="cx('elyndra-frames-framekranox', props.className)"
38
+ :settings="settings"
39
+ >
40
+ <slot />
41
+ </FrameBase>
42
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameKranox } from './FrameKranox.vue';
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ import { createFrameLinesSettings } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { computed } from 'vue';
5
+ import { FrameBase } from '../FrameBase/index.js';
6
+ import type { FrameLinesProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<FrameLinesProps>(), {
9
+ // The vanilla settings default `styled`/`animated` to `true`; Vue
10
+ // boolean-casts absent Boolean props to `false`, so each gets an
11
+ // `undefined` default which skips the cast. Same for `positioned`.
12
+ styled: undefined,
13
+ animated: undefined,
14
+ positioned: undefined,
15
+ });
16
+
17
+ const settings = computed(() =>
18
+ createFrameLinesSettings({
19
+ styled: props.styled,
20
+ animated: props.animated,
21
+ padding: props.padding,
22
+ largeLineWidth: props.largeLineWidth,
23
+ smallLineWidth: props.smallLineWidth,
24
+ smallLineLength: props.smallLineLength,
25
+ }),
26
+ );
27
+ </script>
28
+
29
+ <template>
30
+ <FrameBase
31
+ :elementRef="props.elementRef"
32
+ :positioned="props.positioned"
33
+ :style="props.style"
34
+ :preserveAspectRatio="props.preserveAspectRatio"
35
+ :class="cx('elyndra-frames-framelines', props.className)"
36
+ :settings="settings"
37
+ >
38
+ <slot />
39
+ </FrameBase>
40
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameLines } from './FrameLines.vue';
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import { createFrameNefrexSettings } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { computed } from 'vue';
5
+ import { FrameBase } from '../FrameBase/index.js';
6
+ import type { FrameNefrexProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<FrameNefrexProps>(), {
9
+ // The vanilla settings default `styled`/`animated` to `true`; Vue
10
+ // boolean-casts absent Boolean props to `false`, so each gets an
11
+ // `undefined` default which skips the cast. Same for `positioned`.
12
+ styled: undefined,
13
+ animated: undefined,
14
+ positioned: undefined,
15
+ });
16
+
17
+ const settings = computed(() =>
18
+ createFrameNefrexSettings({
19
+ styled: props.styled,
20
+ animated: props.animated,
21
+ padding: props.padding,
22
+ leftTop: props.leftTop,
23
+ leftBottom: props.leftBottom,
24
+ rightTop: props.rightTop,
25
+ rightBottom: props.rightBottom,
26
+ squareSize: props.squareSize,
27
+ strokeWidth: props.strokeWidth,
28
+ smallLineLength: props.smallLineLength,
29
+ largeLineLength: props.largeLineLength,
30
+ }),
31
+ );
32
+ </script>
33
+
34
+ <template>
35
+ <FrameBase
36
+ :elementRef="props.elementRef"
37
+ :positioned="props.positioned"
38
+ :style="props.style"
39
+ :preserveAspectRatio="props.preserveAspectRatio"
40
+ :class="cx('elyndra-frames-framenefrex', props.className)"
41
+ :settings="settings"
42
+ >
43
+ <slot />
44
+ </FrameBase>
45
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameNefrex } from './FrameNefrex.vue';
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { createFrameNeroSettings } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { computed } from 'vue';
5
+ import { FrameBase } from '../FrameBase/index.js';
6
+ import type { FrameNeroProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<FrameNeroProps>(), {
9
+ // The vanilla settings default `styled`/`animated` to `true`; Vue
10
+ // boolean-casts absent Boolean props to `false`, so each gets an
11
+ // `undefined` default which skips the cast. Same for `positioned`.
12
+ styled: undefined,
13
+ animated: undefined,
14
+ positioned: undefined,
15
+ });
16
+
17
+ const settings = computed(() =>
18
+ createFrameNeroSettings({
19
+ styled: props.styled,
20
+ animated: props.animated,
21
+ padding: props.padding,
22
+ cornerLength: props.cornerLength,
23
+ cornerWidth: props.cornerWidth,
24
+ }),
25
+ );
26
+ </script>
27
+
28
+ <template>
29
+ <FrameBase
30
+ :elementRef="props.elementRef"
31
+ :positioned="props.positioned"
32
+ :style="props.style"
33
+ :preserveAspectRatio="props.preserveAspectRatio"
34
+ :class="cx('elyndra-frames-framenero', props.className)"
35
+ :settings="settings"
36
+ >
37
+ <slot />
38
+ </FrameBase>
39
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameNero } from './FrameNero.vue';
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { createFrameOctagonSettings } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { computed } from 'vue';
5
+ import { FrameBase } from '../FrameBase/index.js';
6
+ import type { FrameOctagonProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<FrameOctagonProps>(), {
9
+ // The vanilla settings default `styled`/`animated` to `true`; Vue
10
+ // boolean-casts absent Boolean props to `false`, so each gets an
11
+ // `undefined` default which skips the cast. Same for `positioned`.
12
+ styled: undefined,
13
+ animated: undefined,
14
+ positioned: undefined,
15
+ });
16
+
17
+ const settings = computed(() =>
18
+ createFrameOctagonSettings({
19
+ styled: props.styled,
20
+ animated: props.animated,
21
+ padding: props.padding,
22
+ leftTop: props.leftTop,
23
+ rightTop: props.rightTop,
24
+ rightBottom: props.rightBottom,
25
+ leftBottom: props.leftBottom,
26
+ squareSize: props.squareSize,
27
+ strokeWidth: props.strokeWidth,
28
+ }),
29
+ );
30
+ </script>
31
+
32
+ <template>
33
+ <FrameBase
34
+ :elementRef="props.elementRef"
35
+ :positioned="props.positioned"
36
+ :style="props.style"
37
+ :preserveAspectRatio="props.preserveAspectRatio"
38
+ :class="cx('elyndra-frames-frameoctagon', props.className)"
39
+ :settings="settings"
40
+ >
41
+ <slot />
42
+ </FrameBase>
43
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameOctagon } from './FrameOctagon.vue';
@@ -0,0 +1,27 @@
1
+ import { flushPromises, mount } from '@vue/test-utils';
2
+ import { afterEach, beforeEach, expect, test } from 'vitest';
3
+ import { FrameUnderline } from './index.js';
4
+
5
+ beforeEach(() => {
6
+ class ResizeObserver {
7
+ observe(): void {}
8
+ unobserve(): void {}
9
+ disconnect(): void {}
10
+ }
11
+ window.ResizeObserver = ResizeObserver as unknown as typeof window.ResizeObserver;
12
+ });
13
+
14
+ afterEach(() => {
15
+ window.ResizeObserver = undefined as unknown as typeof window.ResizeObserver;
16
+ });
17
+
18
+ test('Should render svg element with elyndra classes', async () => {
19
+ const wrapper = mount(FrameUnderline);
20
+ await flushPromises();
21
+
22
+ const element = wrapper.element as HTMLElement;
23
+ expect(element.tagName).toBe('svg');
24
+ expect(element.classList.contains('elyndra-frames-frame')).toBe(true);
25
+ expect(element.classList.contains('elyndra-frames-frameunderline')).toBe(true);
26
+ wrapper.unmount();
27
+ });
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ import { createFrameUnderlineSettings } from '@elyndra/frames';
3
+ import { cx } from '@elyndra/tools';
4
+ import { computed } from 'vue';
5
+ import { FrameBase } from '../FrameBase/index.js';
6
+ import type { FrameUnderlineProps } from '../types.js';
7
+
8
+ const props = withDefaults(defineProps<FrameUnderlineProps>(), {
9
+ // The vanilla settings default `styled` to `true`; Vue boolean-casts
10
+ // absent Boolean props to `false`, so it gets an `undefined` default
11
+ // which skips the cast. Same for `positioned`. (`animated` is accepted
12
+ // by vanilla but intentionally not forwarded, same as React/Solid.)
13
+ styled: undefined,
14
+ positioned: undefined,
15
+ });
16
+
17
+ const settings = computed(() =>
18
+ createFrameUnderlineSettings({
19
+ styled: props.styled,
20
+ squareSize: props.squareSize,
21
+ strokeWidth: props.strokeWidth,
22
+ padding: props.padding,
23
+ }),
24
+ );
25
+ </script>
26
+
27
+ <template>
28
+ <FrameBase
29
+ :elementRef="props.elementRef"
30
+ :positioned="props.positioned"
31
+ :style="props.style"
32
+ :preserveAspectRatio="props.preserveAspectRatio"
33
+ :class="cx('elyndra-frames-frameunderline', props.className)"
34
+ :settings="settings"
35
+ >
36
+ <slot />
37
+ </FrameBase>
38
+ </template>
@@ -0,0 +1 @@
1
+ export { default as FrameUnderline } from './FrameUnderline.vue';
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export * from './FrameBase/index.js';
2
+ export * from './FrameCircle/index.js';
3
+ export * from './FrameCorners/index.js';
4
+ export * from './FrameHeader/index.js';
5
+ export * from './FrameKranox/index.js';
6
+ export * from './FrameLines/index.js';
7
+ export * from './FrameNefrex/index.js';
8
+ export * from './FrameNero/index.js';
9
+ export * from './FrameOctagon/index.js';
10
+ export * from './FrameUnderline/index.js';
11
+ export * from './useFrameAssembler/index.js';
12
+ export type * from './types.js';
@@ -0,0 +1,12 @@
1
+ import type { CSSProperties } from 'vue';
2
+
3
+ export const positionedStyle: CSSProperties = {
4
+ position: 'absolute',
5
+ inset: 0,
6
+ display: 'block',
7
+ border: 0,
8
+ margin: 0,
9
+ padding: 0,
10
+ width: '100%',
11
+ height: '100%',
12
+ };
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { CSSProperties } from 'vue';
2
+ import type {
3
+ CreateFrameCircleSettingsProps,
4
+ CreateFrameCornersSettingsProps,
5
+ CreateFrameHeaderSettingsProps,
6
+ CreateFrameKranoxSettingsProps,
7
+ CreateFrameLinesSettingsProps,
8
+ CreateFrameNefrexSettingsProps,
9
+ CreateFrameNeroSettingsProps,
10
+ CreateFrameOctagonSettingsProps,
11
+ CreateFrameUnderlineSettingsProps,
12
+ FrameSettings,
13
+ } from '@elyndra/frames';
14
+ import type { ElementRef } from '@elyndra/vue-tools';
15
+
16
+ type FrameBaseProps = {
17
+ elementRef?: ElementRef<SVGSVGElement>;
18
+ positioned?: boolean;
19
+ settings: FrameSettings;
20
+ className?: string;
21
+ style?: CSSProperties;
22
+ preserveAspectRatio?: string;
23
+ };
24
+
25
+ type FrameCircleProps = Omit<FrameBaseProps, 'settings'> & CreateFrameCircleSettingsProps;
26
+ type FrameCornersProps = Omit<FrameBaseProps, 'settings'> & CreateFrameCornersSettingsProps;
27
+ type FrameHeaderProps = Omit<FrameBaseProps, 'settings'> & CreateFrameHeaderSettingsProps;
28
+ type FrameKranoxProps = Omit<FrameBaseProps, 'settings'> & CreateFrameKranoxSettingsProps;
29
+ type FrameLinesProps = Omit<FrameBaseProps, 'settings'> & CreateFrameLinesSettingsProps;
30
+ type FrameNefrexProps = Omit<FrameBaseProps, 'settings'> & CreateFrameNefrexSettingsProps;
31
+ type FrameNeroProps = Omit<FrameBaseProps, 'settings'> & CreateFrameNeroSettingsProps;
32
+ type FrameOctagonProps = Omit<FrameBaseProps, 'settings'> & CreateFrameOctagonSettingsProps;
33
+ type FrameUnderlineProps = Omit<FrameBaseProps, 'settings'> & CreateFrameUnderlineSettingsProps;
34
+
35
+ export type {
36
+ ElementRef,
37
+ FrameBaseProps,
38
+ FrameCircleProps,
39
+ FrameCornersProps,
40
+ FrameHeaderProps,
41
+ FrameKranoxProps,
42
+ FrameLinesProps,
43
+ FrameNefrexProps,
44
+ FrameNeroProps,
45
+ FrameOctagonProps,
46
+ FrameUnderlineProps,
47
+ };
@@ -0,0 +1 @@
1
+ export * from './useFrameAssembler.js';
@@ -0,0 +1,40 @@
1
+ import { flushPromises, mount } from '@vue/test-utils';
2
+ import { defineComponent, h, ref } from 'vue';
3
+ import { expect, test } from 'vitest';
4
+ import { Animator } from '@elyndra/vue-animator';
5
+ import { useFrameAssembler } from './index.js';
6
+
7
+ const flush = async (): Promise<void> => {
8
+ await flushPromises();
9
+ await new Promise((resolve) => setTimeout(resolve, 0));
10
+ await flushPromises();
11
+ };
12
+
13
+ test('Should subscribe to the animator without throwing', async () => {
14
+ const svgBox = { current: null as SVGSVGElement | null };
15
+
16
+ const Harness = defineComponent({
17
+ setup() {
18
+ const svg = ref<SVGSVGElement | null>(null);
19
+ useFrameAssembler(() => svg.value);
20
+ return () =>
21
+ h('svg', {
22
+ ref: (value: unknown) => {
23
+ const element = value as SVGSVGElement | null;
24
+ svg.value = element;
25
+ svgBox.current = element;
26
+ },
27
+ });
28
+ },
29
+ });
30
+
31
+ const wrapper = mount(Animator, {
32
+ props: { reducedMotion: true },
33
+ slots: { default: () => h(Harness) },
34
+ });
35
+ await flush();
36
+
37
+ expect(svgBox.current).not.toBeNull();
38
+ expect(svgBox.current?.tagName).toBe('svg');
39
+ wrapper.unmount();
40
+ });
@@ -0,0 +1,57 @@
1
+ import { animateFrameAssembler } from '@elyndra/frames';
2
+ import { useAnimator } from '@elyndra/vue-animator';
3
+ import type { AnimationPlaybackControlsWithThen } from 'motion';
4
+ import { watch } from 'vue';
5
+
6
+ /**
7
+ * Mechanical Vue port of the React/Preact/Solid `useFrameAssembler` hook.
8
+ * The element is a source function so it stays tracked (same convention as
9
+ * `Animator`'s `refreshOn`); `watch` + `watch` cleanup replace
10
+ * `useEffect`/`createEffect` + `onCleanup`. Must be called inside `setup`.
11
+ */
12
+ const useFrameAssembler = (svg: () => SVGElement | HTMLElement | null | undefined): void => {
13
+ const getAnimator = useAnimator();
14
+
15
+ watch(
16
+ () => [getAnimator?.(), svg()] as const,
17
+ ([animator, container], _previous, onCleanup) => {
18
+ if (!animator || !container) {
19
+ return;
20
+ }
21
+
22
+ let animation: AnimationPlaybackControlsWithThen | undefined;
23
+
24
+ const unsubscribe = animator.node.subscribe((node) => {
25
+ switch (node.state) {
26
+ case 'entering': {
27
+ animation?.cancel();
28
+ animation = animateFrameAssembler({
29
+ element: container,
30
+ duration: node.settings.duration.enter,
31
+ isEntering: true,
32
+ });
33
+ break;
34
+ }
35
+
36
+ case 'exiting': {
37
+ animation?.cancel();
38
+ animation = animateFrameAssembler({
39
+ element: container,
40
+ duration: node.settings.duration.exit,
41
+ isEntering: false,
42
+ });
43
+ break;
44
+ }
45
+ }
46
+ });
47
+
48
+ onCleanup(() => {
49
+ animation?.cancel();
50
+ unsubscribe();
51
+ });
52
+ },
53
+ { immediate: true, flush: 'post' },
54
+ );
55
+ };
56
+
57
+ export { useFrameAssembler };
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
+ }