@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.
Files changed (58) hide show
  1. package/README.md +32 -0
  2. package/dist/component/FluxVisualAnimatedColors.vue.d.ts +10 -0
  3. package/dist/component/FluxVisualAttention.vue.d.ts +40 -0
  4. package/dist/component/FluxVisualBorderBeam.vue.d.ts +31 -0
  5. package/dist/component/FluxVisualBorderShine.vue.d.ts +53 -0
  6. package/dist/component/FluxVisualDotPattern.vue.d.ts +11 -0
  7. package/dist/component/FluxVisualFlickeringGrid.vue.d.ts +10 -0
  8. package/dist/component/FluxVisualGridPattern.vue.d.ts +10 -0
  9. package/dist/component/FluxVisualHighlighter.vue.d.ts +36 -0
  10. package/dist/component/FluxVisualHighlighterGroup.vue.d.ts +13 -0
  11. package/dist/component/FluxVisualNoise.vue.d.ts +9 -0
  12. package/dist/component/FluxVisualNumberFlow.vue.d.ts +14 -0
  13. package/dist/component/FluxVisualPing.vue.d.ts +8 -0
  14. package/dist/component/FluxVisualSlotText.vue.d.ts +41 -0
  15. package/dist/component/FluxVisualTextScramble.vue.d.ts +20 -0
  16. package/dist/component/FluxVisualTextShimmer.vue.d.ts +18 -0
  17. package/dist/component/index.d.ts +15 -0
  18. package/dist/composable/private/index.d.ts +2 -0
  19. package/dist/composable/private/useBorderBeamPulse.d.ts +21 -0
  20. package/dist/composable/private/useHighlighterGroup.d.ts +51 -0
  21. package/dist/index.css +1110 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +12320 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/types.d.ts +13 -0
  26. package/package.json +71 -0
  27. package/src/component/FluxVisualAnimatedColors.vue +187 -0
  28. package/src/component/FluxVisualAttention.vue +86 -0
  29. package/src/component/FluxVisualBorderBeam.vue +171 -0
  30. package/src/component/FluxVisualBorderShine.vue +36 -0
  31. package/src/component/FluxVisualDotPattern.vue +119 -0
  32. package/src/component/FluxVisualFlickeringGrid.vue +159 -0
  33. package/src/component/FluxVisualGridPattern.vue +128 -0
  34. package/src/component/FluxVisualHighlighter.vue +217 -0
  35. package/src/component/FluxVisualHighlighterGroup.vue +20 -0
  36. package/src/component/FluxVisualNoise.vue +31 -0
  37. package/src/component/FluxVisualNumberFlow.vue +235 -0
  38. package/src/component/FluxVisualPing.vue +26 -0
  39. package/src/component/FluxVisualSlotText.vue +457 -0
  40. package/src/component/FluxVisualTextScramble.vue +168 -0
  41. package/src/component/FluxVisualTextShimmer.vue +34 -0
  42. package/src/component/index.ts +15 -0
  43. package/src/composable/private/index.ts +2 -0
  44. package/src/composable/private/useBorderBeamPulse.ts +203 -0
  45. package/src/composable/private/useHighlighterGroup.ts +162 -0
  46. package/src/css/component/Attention.module.scss +104 -0
  47. package/src/css/component/BorderBeam.module.scss +1178 -0
  48. package/src/css/component/Highlighter.module.scss +7 -0
  49. package/src/css/component/Noise.module.scss +80 -0
  50. package/src/css/component/NumberFlow.module.scss +5 -0
  51. package/src/css/component/PatternGlow.module.scss +19 -0
  52. package/src/css/component/Ping.module.scss +44 -0
  53. package/src/css/component/SlotText.module.scss +43 -0
  54. package/src/css/component/TextScramble.module.scss +5 -0
  55. package/src/css/component/TextShimmer.module.scss +54 -0
  56. package/src/css/component/Visual.module.scss +71 -0
  57. package/src/index.ts +1 -0
  58. package/src/types.d.ts +13 -0
@@ -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>