@flux-ui/visuals 0.0.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/README.md +32 -0
- package/dist/component/FluxVisualAnimatedColors.vue.d.ts +10 -0
- package/dist/component/FluxVisualAttention.vue.d.ts +40 -0
- package/dist/component/FluxVisualBorderBeam.vue.d.ts +31 -0
- package/dist/component/FluxVisualBorderShine.vue.d.ts +53 -0
- package/dist/component/FluxVisualDotPattern.vue.d.ts +11 -0
- package/dist/component/FluxVisualFlickeringGrid.vue.d.ts +10 -0
- package/dist/component/FluxVisualGridPattern.vue.d.ts +10 -0
- package/dist/component/FluxVisualHighlighter.vue.d.ts +36 -0
- package/dist/component/FluxVisualHighlighterGroup.vue.d.ts +13 -0
- package/dist/component/FluxVisualNoise.vue.d.ts +9 -0
- package/dist/component/FluxVisualNumberFlow.vue.d.ts +14 -0
- package/dist/component/FluxVisualPing.vue.d.ts +8 -0
- package/dist/component/FluxVisualSlotText.vue.d.ts +41 -0
- package/dist/component/FluxVisualTextScramble.vue.d.ts +20 -0
- package/dist/component/FluxVisualTextShimmer.vue.d.ts +18 -0
- package/dist/component/index.d.ts +15 -0
- package/dist/composable/private/index.d.ts +2 -0
- package/dist/composable/private/useBorderBeamPulse.d.ts +21 -0
- package/dist/composable/private/useHighlighterGroup.d.ts +51 -0
- package/dist/index.css +1110 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +12320 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/package.json +71 -0
- package/src/component/FluxVisualAnimatedColors.vue +187 -0
- package/src/component/FluxVisualAttention.vue +86 -0
- package/src/component/FluxVisualBorderBeam.vue +171 -0
- package/src/component/FluxVisualBorderShine.vue +36 -0
- package/src/component/FluxVisualDotPattern.vue +119 -0
- package/src/component/FluxVisualFlickeringGrid.vue +159 -0
- package/src/component/FluxVisualGridPattern.vue +128 -0
- package/src/component/FluxVisualHighlighter.vue +217 -0
- package/src/component/FluxVisualHighlighterGroup.vue +20 -0
- package/src/component/FluxVisualNoise.vue +31 -0
- package/src/component/FluxVisualNumberFlow.vue +235 -0
- package/src/component/FluxVisualPing.vue +26 -0
- package/src/component/FluxVisualSlotText.vue +457 -0
- package/src/component/FluxVisualTextScramble.vue +168 -0
- package/src/component/FluxVisualTextShimmer.vue +34 -0
- package/src/component/index.ts +15 -0
- package/src/composable/private/index.ts +2 -0
- package/src/composable/private/useBorderBeamPulse.ts +203 -0
- package/src/composable/private/useHighlighterGroup.ts +162 -0
- package/src/css/component/Attention.module.scss +104 -0
- package/src/css/component/BorderBeam.module.scss +1178 -0
- package/src/css/component/Highlighter.module.scss +7 -0
- package/src/css/component/Noise.module.scss +80 -0
- package/src/css/component/NumberFlow.module.scss +5 -0
- package/src/css/component/PatternGlow.module.scss +19 -0
- package/src/css/component/Ping.module.scss +44 -0
- package/src/css/component/SlotText.module.scss +43 -0
- package/src/css/component/TextScramble.module.scss +5 -0
- package/src/css/component/TextShimmer.module.scss +54 -0
- package/src/css/component/Visual.module.scss +71 -0
- package/src/index.ts +1 -0
- package/src/types.d.ts +13 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
declare module '*.module.css' {
|
|
2
|
+
const content: Record<string, string>;
|
|
3
|
+
export default content;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module '*.module.scss' {
|
|
7
|
+
const content: Record<string, string>;
|
|
8
|
+
export default content;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module '*.svg' {
|
|
12
|
+
export = string;
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flux-ui/visuals",
|
|
3
|
+
"description": "Visual effect components for the Flux UI library.",
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"funding": "https://github.com/sponsors/basmilius",
|
|
8
|
+
"homepage": "https://flux-ui.dev",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/basmilius/flux.git",
|
|
12
|
+
"directory": "packages/visuals"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"ui library",
|
|
16
|
+
"component library",
|
|
17
|
+
"design system",
|
|
18
|
+
"vue",
|
|
19
|
+
"vue 3",
|
|
20
|
+
"ui",
|
|
21
|
+
"components",
|
|
22
|
+
"flux",
|
|
23
|
+
"visuals"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "vue-tsc && vite build",
|
|
27
|
+
"dev": "vite build --watch --mode development"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=23"
|
|
31
|
+
},
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./style.css": "./dist/index.css"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": false
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"src"
|
|
46
|
+
],
|
|
47
|
+
"main": "./dist/index.js",
|
|
48
|
+
"module": "./dist/index.js",
|
|
49
|
+
"types": "./dist/index.d.ts",
|
|
50
|
+
"sideEffects": false,
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@basmilius/common": "^3.45.0",
|
|
53
|
+
"@basmilius/utils": "^3.45.0",
|
|
54
|
+
"@flux-ui/internals": "workspace:*",
|
|
55
|
+
"clsx": "^2.1.1",
|
|
56
|
+
"rough-notation": "^0.5.1"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"vue": "^3.6.0-beta.17"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@basmilius/vite-preset": "^3.45.0",
|
|
63
|
+
"@types/node": "^26.0.1",
|
|
64
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
65
|
+
"@vue/tsconfig": "^0.9.1",
|
|
66
|
+
"sass-embedded": "^1.100.0",
|
|
67
|
+
"typescript": "^6.0.3",
|
|
68
|
+
"vite": "^8.1.0",
|
|
69
|
+
"vue-tsc": "^3.3.5"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<canvas
|
|
3
|
+
ref="canvas"
|
|
4
|
+
aria-hidden="true"
|
|
5
|
+
:class="$style.animatedColors"/>
|
|
6
|
+
</template>
|
|
7
|
+
|
|
8
|
+
<script
|
|
9
|
+
lang="ts"
|
|
10
|
+
setup>
|
|
11
|
+
import { useComponentId } from '@basmilius/common';
|
|
12
|
+
import { mulberry32 } from '@basmilius/utils';
|
|
13
|
+
import { prefersReducedMotion, useInView } from '@flux-ui/internals';
|
|
14
|
+
import { computed, onBeforeUnmount, ref, unref, useTemplateRef, watch } from 'vue';
|
|
15
|
+
import $style from '~flux/visuals/css/component/Visual.module.scss';
|
|
16
|
+
|
|
17
|
+
type Polygon = [number, number, string, PolygonPoint[]];
|
|
18
|
+
type PolygonPoint = [number, number, number];
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
colors,
|
|
22
|
+
incrementor = 1,
|
|
23
|
+
opacity = .5,
|
|
24
|
+
seed,
|
|
25
|
+
static: isStatic
|
|
26
|
+
} = defineProps<{
|
|
27
|
+
readonly colors?: string[];
|
|
28
|
+
readonly incrementor?: number;
|
|
29
|
+
readonly opacity?: number;
|
|
30
|
+
readonly seed?: number;
|
|
31
|
+
readonly static?: boolean;
|
|
32
|
+
}>();
|
|
33
|
+
|
|
34
|
+
const canvasRef = useTemplateRef('canvas');
|
|
35
|
+
const componentId = useComponentId();
|
|
36
|
+
const inView = useInView(canvasRef, {initial: true});
|
|
37
|
+
|
|
38
|
+
const contextRef = ref<CanvasRenderingContext2D>();
|
|
39
|
+
const animationFrame = ref(0);
|
|
40
|
+
const tick = ref(0);
|
|
41
|
+
const size = ref<{ width: number; height: number; } | null>(null);
|
|
42
|
+
|
|
43
|
+
const reducedMotion = prefersReducedMotion();
|
|
44
|
+
|
|
45
|
+
const polygons = computed(() => {
|
|
46
|
+
if (!colors || colors.length === 0) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const mulberry = mulberry32(seed ?? componentId.value);
|
|
51
|
+
const polygons: Polygon[] = [];
|
|
52
|
+
|
|
53
|
+
for (const color of colors) {
|
|
54
|
+
const localMulberry = mulberry.fork();
|
|
55
|
+
|
|
56
|
+
const x = colors.length === 1 ? .5 : localMulberry.next();
|
|
57
|
+
const y = colors.length === 1 ? .5 : localMulberry.next();
|
|
58
|
+
const count = Math.round(localMulberry.nextBetween(6, 9));
|
|
59
|
+
const points: PolygonPoint[] = [];
|
|
60
|
+
|
|
61
|
+
for (let p = 0; p < count; ++p) {
|
|
62
|
+
points.push([
|
|
63
|
+
localMulberry.next(),
|
|
64
|
+
localMulberry.next(),
|
|
65
|
+
localMulberry.next()
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
polygons.push([x, y, color, points]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return polygons;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
onBeforeUnmount(() => cancel());
|
|
76
|
+
|
|
77
|
+
function cancel(): void {
|
|
78
|
+
cancelAnimationFrame(animationFrame.value);
|
|
79
|
+
animationFrame.value = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function schedule(): void {
|
|
83
|
+
animationFrame.value = requestAnimationFrame(update);
|
|
84
|
+
tick.value += incrementor;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function update(): void {
|
|
88
|
+
render();
|
|
89
|
+
|
|
90
|
+
if (!isStatic && !reducedMotion && unref(inView)) {
|
|
91
|
+
schedule();
|
|
92
|
+
} else {
|
|
93
|
+
animationFrame.value = 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function render(): void {
|
|
98
|
+
const context = unref(contextRef);
|
|
99
|
+
const shapes = unref(polygons);
|
|
100
|
+
const dimensions = unref(size);
|
|
101
|
+
|
|
102
|
+
if (!context || shapes.length === 0 || !dimensions) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const {width, height} = dimensions;
|
|
107
|
+
const widthBasedOpacity = Math.min(1, Math.max(.15, 360 / width));
|
|
108
|
+
|
|
109
|
+
context.globalAlpha = opacity * widthBasedOpacity;
|
|
110
|
+
context.globalCompositeOperation = 'screen';
|
|
111
|
+
context.clearRect(0, 0, width, height);
|
|
112
|
+
|
|
113
|
+
for (const [tx, ty, color, shape] of shapes) {
|
|
114
|
+
context.save();
|
|
115
|
+
context.translate(tx * width, ty * height);
|
|
116
|
+
context.beginPath();
|
|
117
|
+
context.fillStyle = color;
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < shape.length; ++i) {
|
|
120
|
+
let [x, y, m] = shape[i];
|
|
121
|
+
|
|
122
|
+
x = Math.cos(x * Math.PI * 2 + tick.value / (m * 200 + 300)) * (width * .8);
|
|
123
|
+
y = Math.sin(y * Math.PI * 2 + tick.value / (m * 100 + 300)) * (height * .8);
|
|
124
|
+
|
|
125
|
+
if (i === 0) {
|
|
126
|
+
context.moveTo(x, y);
|
|
127
|
+
} else {
|
|
128
|
+
context.lineTo(x, y);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
context.closePath();
|
|
133
|
+
context.fill();
|
|
134
|
+
context.restore();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function restart(): void {
|
|
139
|
+
cancel();
|
|
140
|
+
|
|
141
|
+
if (isStatic || reducedMotion || !unref(inView)) {
|
|
142
|
+
render();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
schedule();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
watch(canvasRef, (canvas, _, onCleanup) => {
|
|
150
|
+
if (!canvas) {
|
|
151
|
+
contextRef.value = undefined;
|
|
152
|
+
size.value = null;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
contextRef.value = canvas.getContext('2d', {
|
|
157
|
+
alpha: true,
|
|
158
|
+
colorSpace: 'display-p3'
|
|
159
|
+
})!;
|
|
160
|
+
|
|
161
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
162
|
+
size.value = {width: canvas.offsetWidth, height: canvas.offsetHeight};
|
|
163
|
+
canvas.width = canvas.offsetWidth;
|
|
164
|
+
canvas.height = canvas.offsetHeight;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const observer = new ResizeObserver(() => {
|
|
169
|
+
const width = canvas.offsetWidth;
|
|
170
|
+
const height = canvas.offsetHeight;
|
|
171
|
+
|
|
172
|
+
if (!width || !height || (size.value?.width === width && size.value?.height === height)) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
canvas.width = width;
|
|
177
|
+
canvas.height = height;
|
|
178
|
+
size.value = {width, height};
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
observer.observe(canvas);
|
|
182
|
+
|
|
183
|
+
onCleanup(() => observer.disconnect());
|
|
184
|
+
}, {immediate: true});
|
|
185
|
+
|
|
186
|
+
watch([polygons, () => opacity, size, inView], () => restart());
|
|
187
|
+
</script>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { flattenVNodeTree, prefersReducedMotion } from '@flux-ui/internals';
|
|
3
|
+
import { clsx } from 'clsx';
|
|
4
|
+
import { cloneVNode, defineComponent, Fragment, h, onBeforeUnmount, ref, watch, type PropType } from 'vue';
|
|
5
|
+
import $style from '~flux/visuals/css/component/Attention.module.scss';
|
|
6
|
+
|
|
7
|
+
type AttentionEffect = 'pulse' | 'shake' | 'bounce' | 'tada';
|
|
8
|
+
|
|
9
|
+
export default defineComponent({
|
|
10
|
+
inheritAttrs: false,
|
|
11
|
+
props: {
|
|
12
|
+
duration: {default: 700, type: Number},
|
|
13
|
+
effect: {default: 'pulse', type: String as PropType<AttentionEffect>},
|
|
14
|
+
trigger: {default: undefined, type: null as unknown as PropType<unknown>}
|
|
15
|
+
},
|
|
16
|
+
emits: {
|
|
17
|
+
finished: () => true
|
|
18
|
+
},
|
|
19
|
+
setup(props, {attrs, emit, expose, slots}) {
|
|
20
|
+
const EFFECT_CLASSES: Record<AttentionEffect, string> = {
|
|
21
|
+
pulse: $style.pulse,
|
|
22
|
+
shake: $style.shake,
|
|
23
|
+
bounce: $style.bounce,
|
|
24
|
+
tada: $style.tada
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isPlaying = ref(false);
|
|
28
|
+
|
|
29
|
+
let restartFrame = 0;
|
|
30
|
+
|
|
31
|
+
// Drop the effect class and re-add it two frames later so the animation
|
|
32
|
+
// restarts cleanly, even when the same effect is played back to back.
|
|
33
|
+
function play(): void {
|
|
34
|
+
if (prefersReducedMotion()) {
|
|
35
|
+
emit('finished');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
cancelAnimationFrame(restartFrame);
|
|
40
|
+
isPlaying.value = false;
|
|
41
|
+
|
|
42
|
+
restartFrame = requestAnimationFrame(() => {
|
|
43
|
+
restartFrame = requestAnimationFrame(() => {
|
|
44
|
+
isPlaying.value = true;
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onAnimationEnd(event: AnimationEvent): void {
|
|
50
|
+
if (event.target !== event.currentTarget) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
isPlaying.value = false;
|
|
55
|
+
emit('finished');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
watch(() => props.trigger, () => {
|
|
59
|
+
play();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
onBeforeUnmount(() => {
|
|
63
|
+
cancelAnimationFrame(restartFrame);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expose({
|
|
67
|
+
play
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return () => h(
|
|
71
|
+
Fragment,
|
|
72
|
+
flattenVNodeTree(slots.default?.() ?? []).map(vnode => cloneVNode(vnode, {
|
|
73
|
+
...attrs,
|
|
74
|
+
class: clsx(
|
|
75
|
+
attrs.class as string,
|
|
76
|
+
isPlaying.value && EFFECT_CLASSES[props.effect]
|
|
77
|
+
),
|
|
78
|
+
style: {
|
|
79
|
+
'--attention-duration': props.duration
|
|
80
|
+
},
|
|
81
|
+
onAnimationend: onAnimationEnd
|
|
82
|
+
}))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
</script>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
ref="wrapper"
|
|
4
|
+
:class="clsx(
|
|
5
|
+
$style.borderBeam,
|
|
6
|
+
VARIANT_CLASSES[variant],
|
|
7
|
+
$style[colorVariant],
|
|
8
|
+
isActive && !isFading && $style.isActive,
|
|
9
|
+
isFading && $style.isFading,
|
|
10
|
+
isPaused && $style.isPaused,
|
|
11
|
+
isStatic && $style.isStatic
|
|
12
|
+
)"
|
|
13
|
+
:style="style"
|
|
14
|
+
@animationend="onAnimationEnd">
|
|
15
|
+
<slot/>
|
|
16
|
+
|
|
17
|
+
<div
|
|
18
|
+
aria-hidden="true"
|
|
19
|
+
:class="$style.bloom"/>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script
|
|
24
|
+
lang="ts"
|
|
25
|
+
setup>
|
|
26
|
+
import { useInView } from '@flux-ui/internals';
|
|
27
|
+
import { clsx } from 'clsx';
|
|
28
|
+
import { computed, ref, unref, useTemplateRef, watch } from 'vue';
|
|
29
|
+
import { useBorderBeamPulse, type BorderBeamVariant } from '~flux/visuals/composable/private';
|
|
30
|
+
import $style from '~flux/visuals/css/component/BorderBeam.module.scss';
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
activate: [];
|
|
34
|
+
deactivate: [];
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const {
|
|
38
|
+
active = true,
|
|
39
|
+
brightness,
|
|
40
|
+
colorVariant = 'colorful',
|
|
41
|
+
duration,
|
|
42
|
+
hueRange = 30,
|
|
43
|
+
radius,
|
|
44
|
+
saturation,
|
|
45
|
+
staticColors = false,
|
|
46
|
+
strength = 1,
|
|
47
|
+
variant = 'md'
|
|
48
|
+
} = defineProps<{
|
|
49
|
+
readonly active?: boolean;
|
|
50
|
+
readonly brightness?: number;
|
|
51
|
+
readonly colorVariant?: 'colorful' | 'mono' | 'ocean' | 'sunset';
|
|
52
|
+
readonly duration?: number;
|
|
53
|
+
readonly hueRange?: number;
|
|
54
|
+
readonly radius?: string | number;
|
|
55
|
+
readonly saturation?: number;
|
|
56
|
+
readonly staticColors?: boolean;
|
|
57
|
+
readonly strength?: number;
|
|
58
|
+
readonly variant?: BorderBeamVariant;
|
|
59
|
+
}>();
|
|
60
|
+
|
|
61
|
+
defineSlots<{
|
|
62
|
+
default(): any;
|
|
63
|
+
}>();
|
|
64
|
+
|
|
65
|
+
const VARIANT_CLASSES: Record<BorderBeamVariant, string> = {
|
|
66
|
+
'sm': $style.sm,
|
|
67
|
+
'md': $style.md,
|
|
68
|
+
'line': $style.line,
|
|
69
|
+
'pulse-inner': $style.pulseInner,
|
|
70
|
+
'pulse-outside': $style.pulseOutside
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const wrapperRef = useTemplateRef('wrapper');
|
|
74
|
+
const inView = useInView(wrapperRef, {initial: true, rootMargin: '256px'});
|
|
75
|
+
|
|
76
|
+
const isActive = ref(active);
|
|
77
|
+
const isFading = ref(false);
|
|
78
|
+
const glowScale = ref<{ x: number; y: number; } | null>(null);
|
|
79
|
+
|
|
80
|
+
const isPulse = computed(() => variant === 'pulse-inner' || variant === 'pulse-outside');
|
|
81
|
+
const isStatic = computed(() => staticColors || colorVariant === 'mono');
|
|
82
|
+
const isPaused = computed(() => isActive.value && !isFading.value && !inView.value);
|
|
83
|
+
const resolvedDuration = computed(() => duration ?? (variant === 'line' ? 3.1 : isPulse.value ? 2.3 : 1.96));
|
|
84
|
+
|
|
85
|
+
const style = computed(() => ({
|
|
86
|
+
'--beam-brightness': brightness,
|
|
87
|
+
'--beam-duration': resolvedDuration.value,
|
|
88
|
+
'--beam-glow-sx': glowScale.value?.x,
|
|
89
|
+
'--beam-glow-sy': glowScale.value?.y,
|
|
90
|
+
'--beam-hue-range': variant === 'line' ? Math.min(hueRange, 13) : hueRange,
|
|
91
|
+
'--beam-radius': typeof radius === 'number' ? `${radius}px` : radius,
|
|
92
|
+
'--beam-saturation': saturation,
|
|
93
|
+
'--beam-strength': Math.max(0, Math.min(1, strength))
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
useBorderBeamPulse({
|
|
97
|
+
duration: resolvedDuration,
|
|
98
|
+
elementRef: wrapperRef,
|
|
99
|
+
enabled: computed(() => (isActive.value || isFading.value) && inView.value),
|
|
100
|
+
staticColors: isStatic,
|
|
101
|
+
variant: computed(() => variant)
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
watch(() => active, value => {
|
|
105
|
+
if (value) {
|
|
106
|
+
// Also covers re-activating while the fade-out is still running: cancel the
|
|
107
|
+
// fade, otherwise its animationend would turn the beam off for good.
|
|
108
|
+
isFading.value = false;
|
|
109
|
+
isActive.value = true;
|
|
110
|
+
} else if (isActive.value && !isFading.value) {
|
|
111
|
+
isFading.value = true;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// The pulse-outside glow geometry is authored in fixed pixels for a reference
|
|
116
|
+
// element of ~350x140; measure the wrapped element and scale the glow per-axis
|
|
117
|
+
// so the halo fits any component it's applied to.
|
|
118
|
+
watch([wrapperRef, () => variant], (_, __, onCleanup) => {
|
|
119
|
+
glowScale.value = null;
|
|
120
|
+
|
|
121
|
+
const wrapper = unref(wrapperRef);
|
|
122
|
+
|
|
123
|
+
if (!wrapper || variant !== 'pulse-outside' || typeof ResizeObserver === 'undefined') {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const child = wrapper.firstElementChild;
|
|
128
|
+
|
|
129
|
+
if (!child || !(child instanceof HTMLElement)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const clamp = (value: number): number => Math.max(.35, Math.min(4, value));
|
|
134
|
+
|
|
135
|
+
const measure = (): void => {
|
|
136
|
+
const rect = child.getBoundingClientRect();
|
|
137
|
+
|
|
138
|
+
if (!rect.width || !rect.height) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const x = +clamp(rect.width / 350).toFixed(3);
|
|
143
|
+
const y = +clamp(rect.height / 140).toFixed(3);
|
|
144
|
+
|
|
145
|
+
if (glowScale.value?.x !== x || glowScale.value?.y !== y) {
|
|
146
|
+
glowScale.value = {x, y};
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
measure();
|
|
151
|
+
|
|
152
|
+
const observer = new ResizeObserver(measure);
|
|
153
|
+
observer.observe(child);
|
|
154
|
+
|
|
155
|
+
onCleanup(() => observer.disconnect());
|
|
156
|
+
}, {immediate: true});
|
|
157
|
+
|
|
158
|
+
function onAnimationEnd(event: AnimationEvent): void {
|
|
159
|
+
if (event.target !== event.currentTarget) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (isFading.value) {
|
|
164
|
+
isActive.value = false;
|
|
165
|
+
isFading.value = false;
|
|
166
|
+
emit('deactivate');
|
|
167
|
+
} else if (isActive.value) {
|
|
168
|
+
emit('activate');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
</script>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { flattenVNodeTree, orange600, pink600, purple600 } from '@flux-ui/internals';
|
|
3
|
+
import { clsx } from 'clsx';
|
|
4
|
+
import { cloneVNode, defineComponent, Fragment, h, type PropType } from 'vue';
|
|
5
|
+
import $style from '~flux/visuals/css/component/Visual.module.scss';
|
|
6
|
+
|
|
7
|
+
export default defineComponent({
|
|
8
|
+
inheritAttrs: false,
|
|
9
|
+
props: {
|
|
10
|
+
colors: {default: [purple600, 'transparent', orange600, 'transparent', pink600, 'transparent', purple600], type: Array as PropType<string[]>},
|
|
11
|
+
duration: {default: 9, type: Number},
|
|
12
|
+
offset: {default: 1, type: Number},
|
|
13
|
+
radius: {default: undefined, type: [String, Number] as PropType<string | number>},
|
|
14
|
+
width: {default: 2, type: Number}
|
|
15
|
+
},
|
|
16
|
+
setup(props, {attrs, slots}) {
|
|
17
|
+
return () => h(
|
|
18
|
+
Fragment,
|
|
19
|
+
flattenVNodeTree(slots.default?.() ?? []).map(vnode => cloneVNode(vnode, {
|
|
20
|
+
...attrs,
|
|
21
|
+
class: clsx(
|
|
22
|
+
attrs.class as string,
|
|
23
|
+
$style.borderShine
|
|
24
|
+
),
|
|
25
|
+
style: {
|
|
26
|
+
'--shine-colors': props.colors.join(', '),
|
|
27
|
+
'--shine-duration': props.duration,
|
|
28
|
+
'--shine-offset': props.offset,
|
|
29
|
+
'--shine-radius': typeof props.radius === 'number' ? `${props.radius}px` : props.radius,
|
|
30
|
+
'--shine-width': props.width
|
|
31
|
+
}
|
|
32
|
+
}))
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<svg
|
|
3
|
+
ref="root"
|
|
4
|
+
aria-hidden="true"
|
|
5
|
+
:class="$style.dotPattern">
|
|
6
|
+
<defs>
|
|
7
|
+
<pattern
|
|
8
|
+
:id="id"
|
|
9
|
+
:width="width"
|
|
10
|
+
:height="height"
|
|
11
|
+
patternContentUnits="userSpaceOnUse"
|
|
12
|
+
patternUnits="userSpaceOnUse"
|
|
13
|
+
:x="-1"
|
|
14
|
+
:y="-1">
|
|
15
|
+
<circle
|
|
16
|
+
:r="cr"
|
|
17
|
+
:cx="width / 2 - cx"
|
|
18
|
+
:cy="height / 2 - cy"/>
|
|
19
|
+
</pattern>
|
|
20
|
+
|
|
21
|
+
<pattern
|
|
22
|
+
v-if="glow"
|
|
23
|
+
:id="glowId"
|
|
24
|
+
:width="width"
|
|
25
|
+
:height="height"
|
|
26
|
+
patternContentUnits="userSpaceOnUse"
|
|
27
|
+
patternUnits="userSpaceOnUse"
|
|
28
|
+
:x="-1"
|
|
29
|
+
:y="-1">
|
|
30
|
+
<circle
|
|
31
|
+
:class="$glow.glowDot"
|
|
32
|
+
:r="cr"
|
|
33
|
+
:cx="width / 2 - cx"
|
|
34
|
+
:cy="height / 2 - cy"/>
|
|
35
|
+
</pattern>
|
|
36
|
+
</defs>
|
|
37
|
+
|
|
38
|
+
<rect
|
|
39
|
+
width="100%"
|
|
40
|
+
height="100%"
|
|
41
|
+
stroke-width="0"
|
|
42
|
+
:fill="`url(#${id})`"/>
|
|
43
|
+
|
|
44
|
+
<rect
|
|
45
|
+
v-if="glow"
|
|
46
|
+
:class="[$glow.glowLayer, active && $glow.isActive]"
|
|
47
|
+
width="100%"
|
|
48
|
+
height="100%"
|
|
49
|
+
stroke-width="0"
|
|
50
|
+
:fill="`url(#${glowId})`"/>
|
|
51
|
+
</svg>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<script
|
|
55
|
+
lang="ts"
|
|
56
|
+
setup>
|
|
57
|
+
import { ref, unref, useId, useTemplateRef, watch } from 'vue';
|
|
58
|
+
import $glow from '~flux/visuals/css/component/PatternGlow.module.scss';
|
|
59
|
+
import $style from '~flux/visuals/css/component/Visual.module.scss';
|
|
60
|
+
|
|
61
|
+
const {
|
|
62
|
+
width = 16,
|
|
63
|
+
height = 16,
|
|
64
|
+
cr = 1,
|
|
65
|
+
cx = 1,
|
|
66
|
+
cy = 1,
|
|
67
|
+
glow = false
|
|
68
|
+
} = defineProps<{
|
|
69
|
+
readonly width?: number;
|
|
70
|
+
readonly height?: number;
|
|
71
|
+
readonly cr?: number;
|
|
72
|
+
readonly cx?: number;
|
|
73
|
+
readonly cy?: number;
|
|
74
|
+
readonly glow?: boolean;
|
|
75
|
+
}>();
|
|
76
|
+
|
|
77
|
+
const id = useId();
|
|
78
|
+
const glowId = `${id}-glow`;
|
|
79
|
+
const rootRef = useTemplateRef<SVGSVGElement>('root');
|
|
80
|
+
const active = ref(false);
|
|
81
|
+
|
|
82
|
+
// The pattern svg has pointer-events: none, so the cursor is tracked on the
|
|
83
|
+
// parent scroll/overflow container instead. The position is written straight
|
|
84
|
+
// to CSS custom properties to avoid a re-render on every pointermove.
|
|
85
|
+
watch([rootRef, () => glow], (_, __, onCleanup) => {
|
|
86
|
+
const root = unref(rootRef);
|
|
87
|
+
|
|
88
|
+
if (!glow || !root || !root.parentElement) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parent = root.parentElement;
|
|
93
|
+
|
|
94
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
95
|
+
const rect = parent.getBoundingClientRect();
|
|
96
|
+
root.style.setProperty('--pattern-glow-x', `${event.clientX - rect.left}px`);
|
|
97
|
+
root.style.setProperty('--pattern-glow-y', `${event.clientY - rect.top}px`);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const onPointerEnter = (): void => {
|
|
101
|
+
active.value = true;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const onPointerLeave = (): void => {
|
|
105
|
+
active.value = false;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
parent.addEventListener('pointermove', onPointerMove, {passive: true});
|
|
109
|
+
parent.addEventListener('pointerenter', onPointerEnter);
|
|
110
|
+
parent.addEventListener('pointerleave', onPointerLeave);
|
|
111
|
+
|
|
112
|
+
onCleanup(() => {
|
|
113
|
+
active.value = false;
|
|
114
|
+
parent.removeEventListener('pointermove', onPointerMove);
|
|
115
|
+
parent.removeEventListener('pointerenter', onPointerEnter);
|
|
116
|
+
parent.removeEventListener('pointerleave', onPointerLeave);
|
|
117
|
+
});
|
|
118
|
+
}, {immediate: true});
|
|
119
|
+
</script>
|