@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,159 @@
1
+ <template>
2
+ <canvas
3
+ ref="canvas"
4
+ aria-hidden="true"
5
+ :class="$style.flickeringGrid"/>
6
+ </template>
7
+
8
+ <script
9
+ lang="ts"
10
+ setup>
11
+ import { mulberry32 } from '@basmilius/utils';
12
+ import { prefersReducedMotion, useInView } from '@flux-ui/internals';
13
+ import { computed, unref, useTemplateRef, watch } from 'vue';
14
+ import $style from '~flux/visuals/css/component/Visual.module.scss';
15
+
16
+ const {
17
+ color = '#1d4ed8',
18
+ flickerChance = 0.15,
19
+ gap = 6,
20
+ maxOpacity = 0.3,
21
+ size = 3
22
+ } = defineProps<{
23
+ readonly color?: string;
24
+ readonly flickerChance?: number;
25
+ readonly gap?: number;
26
+ readonly maxOpacity?: number;
27
+ readonly size?: number;
28
+ }>();
29
+
30
+ const canvasRef = useTemplateRef('canvas');
31
+
32
+ const inView = useInView(canvasRef);
33
+
34
+ const mulberry = mulberry32(13);
35
+
36
+ const rgb = computed(() => {
37
+ const canvas = document.createElement('canvas');
38
+ canvas.width = canvas.height = 1;
39
+
40
+ const context = canvas.getContext('2d');
41
+
42
+ if (!context) {
43
+ return [0, 0, 0];
44
+ }
45
+
46
+ context.fillStyle = color;
47
+ context.fillRect(0, 0, 1, 1);
48
+
49
+ return context.getImageData(0, 0, 1, 1).data;
50
+ });
51
+
52
+ function draw(context: CanvasRenderingContext2D, width: number, height: number, columns: number, rows: number, squares: Float32Array, dpr: number): void {
53
+ context.clearRect(0, 0, width * dpr, height * dpr);
54
+
55
+ const [r, g, b] = unref(rgb);
56
+
57
+ for (let i = 0; i < columns; ++i) {
58
+ for (let j = 0; j < rows; ++j) {
59
+ const opacity = squares[i * rows + j];
60
+ context.fillStyle = `rgb(${r} ${g} ${b} / ${opacity})`;
61
+ context.fillRect(
62
+ (i * (size + gap) + width / 2 - (columns / 2 * (size + gap) - gap / 2)) * dpr,
63
+ (j * (size + gap) + height / 2 - (rows / 2 * (size + gap) - gap / 2)) * dpr,
64
+ size * dpr,
65
+ size * dpr
66
+ );
67
+ }
68
+ }
69
+ }
70
+
71
+ function setup(canvas: HTMLCanvasElement) {
72
+ const width = canvas.clientWidth;
73
+ const height = canvas.clientHeight;
74
+ const dpr = window.devicePixelRatio || 1;
75
+ canvas.width = width * dpr;
76
+ canvas.height = height * dpr;
77
+ canvas.style.width = `${width}px`;
78
+ canvas.style.height = `${height}px`;
79
+
80
+ const columns = Math.ceil(width / (size + gap));
81
+ const rows = Math.ceil(height / (size + gap));
82
+ const squares = new Float32Array(columns * rows);
83
+
84
+ for (let i = 0; i < squares.length; ++i) {
85
+ squares[i] = mulberry.next() * maxOpacity;
86
+ }
87
+
88
+ return {
89
+ width,
90
+ height,
91
+ columns,
92
+ rows,
93
+ squares,
94
+ dpr
95
+ };
96
+ }
97
+
98
+ function tick(squares: Float32Array, delta: number): void {
99
+ for (let i = 0; i < squares.length; ++i) {
100
+ if (mulberry.next() < flickerChance * delta) {
101
+ squares[i] = mulberry.next() * maxOpacity;
102
+ }
103
+ }
104
+ }
105
+
106
+ watch([canvasRef, inView], ([canvas, inView], _, onCleanup) => {
107
+ if (!canvas || !inView) {
108
+ return;
109
+ }
110
+
111
+ const context = canvas.getContext('2d');
112
+
113
+ if (!context) {
114
+ return;
115
+ }
116
+
117
+ let frame = 0;
118
+ let lastTime = 0;
119
+ let {width, height, columns, rows, squares, dpr} = setup(canvas);
120
+
121
+ const reducedMotion = prefersReducedMotion();
122
+
123
+ const onResize = () => {
124
+ ({width, height, columns, rows, squares, dpr} = setup(canvas));
125
+
126
+ if (reducedMotion) {
127
+ draw(context, width, height, columns, rows, squares, dpr);
128
+ }
129
+ };
130
+
131
+ window.addEventListener('resize', onResize, {passive: true});
132
+
133
+ if (reducedMotion) {
134
+ draw(context, width, height, columns, rows, squares, dpr);
135
+
136
+ onCleanup(() => {
137
+ window.removeEventListener('resize', onResize);
138
+ });
139
+
140
+ return;
141
+ }
142
+
143
+ const animate = (time: number): void => {
144
+ const delta = lastTime > 0 ? (time - lastTime) / 1000 : 0;
145
+ lastTime = time;
146
+
147
+ tick(squares, delta);
148
+ draw(context, width, height, columns, rows, squares, dpr);
149
+ frame = requestAnimationFrame(animate);
150
+ };
151
+
152
+ frame = requestAnimationFrame(animate);
153
+
154
+ onCleanup(() => {
155
+ window.removeEventListener('resize', onResize);
156
+ cancelAnimationFrame(frame);
157
+ });
158
+ }, {immediate: true});
159
+ </script>
@@ -0,0 +1,128 @@
1
+ <template>
2
+ <svg
3
+ ref="root"
4
+ aria-hidden="true"
5
+ :class="$style.gridPattern">
6
+ <defs>
7
+ <pattern
8
+ :id="id"
9
+ :width="width"
10
+ :height="height"
11
+ patternUnits="userSpaceOnUse"
12
+ :x="-1"
13
+ :y="-1">
14
+ <path
15
+ :d="`M.5 ${height}V.5H${width}`"
16
+ fill="none"
17
+ :stroke-dasharray="strokeDasharray"/>
18
+ </pattern>
19
+
20
+ <pattern
21
+ v-if="glow"
22
+ :id="glowId"
23
+ :width="width"
24
+ :height="height"
25
+ patternUnits="userSpaceOnUse"
26
+ :x="-1"
27
+ :y="-1">
28
+ <path
29
+ :class="$glow.glowLine"
30
+ :d="`M.5 ${height}V.5H${width}`"
31
+ fill="none"
32
+ :stroke-dasharray="strokeDasharray"/>
33
+ </pattern>
34
+ </defs>
35
+
36
+ <rect
37
+ width="100%"
38
+ height="100%"
39
+ stroke-width="0"
40
+ :fill="`url(#${id})`"/>
41
+
42
+ <svg
43
+ v-if="squares?.length"
44
+ style="overflow: visible;">
45
+ <rect
46
+ v-for="[x, y] of squares"
47
+ :key="`${x}-${y}`"
48
+ :width="width - 1"
49
+ :height="height - 1"
50
+ :x="x * width"
51
+ :y="y * height"
52
+ stroke-width="0"/>
53
+ </svg>
54
+
55
+ <rect
56
+ v-if="glow"
57
+ :class="[$glow.glowLayer, active && $glow.isActive]"
58
+ width="100%"
59
+ height="100%"
60
+ stroke-width="0"
61
+ :fill="`url(#${glowId})`"/>
62
+ </svg>
63
+ </template>
64
+
65
+ <script
66
+ lang="ts"
67
+ setup>
68
+ import { ref, unref, useId, useTemplateRef, watch } from 'vue';
69
+ import $glow from '~flux/visuals/css/component/PatternGlow.module.scss';
70
+ import $style from '~flux/visuals/css/component/Visual.module.scss';
71
+
72
+ const {
73
+ width = 42,
74
+ height = 42,
75
+ strokeDasharray = 0,
76
+ squares,
77
+ glow = false
78
+ } = defineProps<{
79
+ readonly width?: number;
80
+ readonly height?: number;
81
+ readonly strokeDasharray?: number | string;
82
+ readonly squares?: Array<[x: number, y: number]>;
83
+ readonly glow?: boolean;
84
+ }>();
85
+
86
+ const id = useId();
87
+ const glowId = `${id}-glow`;
88
+ const rootRef = useTemplateRef<SVGSVGElement>('root');
89
+ const active = ref(false);
90
+
91
+ // The pattern svg has pointer-events: none, so the cursor is tracked on the
92
+ // parent scroll/overflow container instead. The position is written straight
93
+ // to CSS custom properties to avoid a re-render on every pointermove.
94
+ watch([rootRef, () => glow], (_, __, onCleanup) => {
95
+ const root = unref(rootRef);
96
+
97
+ if (!glow || !root || !root.parentElement) {
98
+ return;
99
+ }
100
+
101
+ const parent = root.parentElement;
102
+
103
+ const onPointerMove = (event: PointerEvent): void => {
104
+ const rect = parent.getBoundingClientRect();
105
+ root.style.setProperty('--pattern-glow-x', `${event.clientX - rect.left}px`);
106
+ root.style.setProperty('--pattern-glow-y', `${event.clientY - rect.top}px`);
107
+ };
108
+
109
+ const onPointerEnter = (): void => {
110
+ active.value = true;
111
+ };
112
+
113
+ const onPointerLeave = (): void => {
114
+ active.value = false;
115
+ };
116
+
117
+ parent.addEventListener('pointermove', onPointerMove, {passive: true});
118
+ parent.addEventListener('pointerenter', onPointerEnter);
119
+ parent.addEventListener('pointerleave', onPointerLeave);
120
+
121
+ onCleanup(() => {
122
+ active.value = false;
123
+ parent.removeEventListener('pointermove', onPointerMove);
124
+ parent.removeEventListener('pointerenter', onPointerEnter);
125
+ parent.removeEventListener('pointerleave', onPointerLeave);
126
+ });
127
+ }, {immediate: true});
128
+ </script>
@@ -0,0 +1,217 @@
1
+ <template>
2
+ <span
3
+ ref="target"
4
+ :class="$style.highlighter">
5
+ <slot/>
6
+ </span>
7
+ </template>
8
+
9
+ <script
10
+ lang="ts"
11
+ setup>
12
+ import { prefersReducedMotion, useInView } from '@flux-ui/internals';
13
+ import { annotate } from 'rough-notation';
14
+ import { computed, onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue';
15
+ import { useHighlighterGroupInjection, type HighlighterGroupEntry, type HighlighterVariant } from '~flux/visuals/composable/private';
16
+ import $style from '~flux/visuals/css/component/Highlighter.module.scss';
17
+
18
+ const emit = defineEmits<{
19
+ hidden: [];
20
+ shown: [];
21
+ }>();
22
+
23
+ // The annotation props deliberately have no destructure defaults: an unset
24
+ // prop must stay distinguishable from an explicit one, so an enclosing group
25
+ // can supply the value instead. The effective* computeds resolve the chain.
26
+ const {
27
+ variant,
28
+ color,
29
+ strokeWidth,
30
+ animationDuration,
31
+ iterations,
32
+ padding,
33
+ multiline,
34
+ whenInView = false
35
+ } = defineProps<{
36
+ readonly variant?: HighlighterVariant;
37
+ readonly color?: string;
38
+ readonly strokeWidth?: number;
39
+ readonly animationDuration?: number;
40
+ readonly iterations?: number;
41
+ readonly padding?: number;
42
+ readonly multiline?: boolean;
43
+ readonly whenInView?: boolean;
44
+ }>();
45
+
46
+ defineSlots<{
47
+ default(): any;
48
+ }>();
49
+
50
+ const group = useHighlighterGroupInjection();
51
+
52
+ const targetRef = useTemplateRef('target');
53
+
54
+ // Standalone in-view tracking. In a group the parent owns the reveal timing,
55
+ // so no observer is set up at all.
56
+ const inView = group ? shallowRef(true) : useInView(targetRef, {initial: !whenInView});
57
+
58
+ const effectiveVariant = computed(() => variant ?? group?.defaults.variant ?? 'highlight');
59
+ const effectiveColor = computed(() => color ?? group?.defaults.color ?? 'var(--warning-200)');
60
+ const effectiveStrokeWidth = computed(() => strokeWidth ?? group?.defaults.strokeWidth ?? 1.5);
61
+ const effectiveAnimationDuration = computed(() => animationDuration ?? group?.defaults.animationDuration ?? 500);
62
+ const effectiveIterations = computed(() => iterations ?? group?.defaults.iterations ?? 2);
63
+ const effectivePadding = computed(() => padding ?? group?.defaults.padding ?? 2);
64
+ const effectiveMultiline = computed(() => multiline ?? group?.defaults.multiline ?? true);
65
+
66
+ let annotation: ReturnType<typeof annotate> | null = null;
67
+ let entry: HighlighterGroupEntry | null = null;
68
+ let observer: ResizeObserver | null = null;
69
+ let settleTimer: number | undefined;
70
+ let shownTimer: number | undefined;
71
+ let revealed = false;
72
+
73
+ // rough-notation has no completion callback, so shown is emitted after the
74
+ // draw animation's duration, or immediately when it draws without animation.
75
+ function emitShown(): void {
76
+ window.clearTimeout(shownTimer);
77
+
78
+ if (prefersReducedMotion()) {
79
+ emit('shown');
80
+ return;
81
+ }
82
+
83
+ shownTimer = window.setTimeout(() => emit('shown'), effectiveAnimationDuration.value);
84
+ }
85
+
86
+ function show(): void {
87
+ if (!annotation) {
88
+ return;
89
+ }
90
+
91
+ revealed = true;
92
+ stopSettleWatch();
93
+ annotation.show();
94
+ emitShown();
95
+ }
96
+
97
+ function hide(): void {
98
+ if (!annotation) {
99
+ return;
100
+ }
101
+
102
+ window.clearTimeout(shownTimer);
103
+ annotation.hide();
104
+ emit('hidden');
105
+ }
106
+
107
+ function replay(): void {
108
+ if (!annotation) {
109
+ return;
110
+ }
111
+
112
+ annotation.hide();
113
+ annotation.show();
114
+ emitShown();
115
+ }
116
+
117
+ // Standalone: draw once, with the intro animation, on the final geometry.
118
+ // rough-notation keeps the drawn annotation aligned on later resizes itself.
119
+ function reveal(): void {
120
+ if (revealed || !annotation || !inView.value) {
121
+ return;
122
+ }
123
+
124
+ show();
125
+ }
126
+
127
+ function stopSettleWatch(): void {
128
+ window.clearTimeout(settleTimer);
129
+ observer?.disconnect();
130
+ observer = null;
131
+ }
132
+
133
+ function build(): void {
134
+ annotation?.remove();
135
+ annotation = null;
136
+ stopSettleWatch();
137
+ revealed = false;
138
+
139
+ const element = targetRef.value;
140
+
141
+ if (!element) {
142
+ return;
143
+ }
144
+
145
+ annotation = annotate(element, {
146
+ type: effectiveVariant.value,
147
+ color: effectiveColor.value,
148
+ strokeWidth: effectiveStrokeWidth.value,
149
+ animationDuration: effectiveAnimationDuration.value,
150
+ iterations: effectiveIterations.value,
151
+ padding: effectivePadding.value,
152
+ multiline: effectiveMultiline.value,
153
+ animate: !prefersReducedMotion()
154
+ });
155
+
156
+ // In a group the parent collects the annotations and drives the cascade.
157
+ if (group) {
158
+ group.notify();
159
+ return;
160
+ }
161
+
162
+ if (typeof ResizeObserver === 'undefined') {
163
+ reveal();
164
+ return;
165
+ }
166
+
167
+ // The surrounding chrome (preview panels, web-font swaps) can reflow right
168
+ // after mount, which would otherwise play the intro animation where the
169
+ // text sat *before* it settled. Debounce until the layout is quiet, then
170
+ // draw on the final geometry.
171
+ observer = new ResizeObserver(() => {
172
+ window.clearTimeout(settleTimer);
173
+ settleTimer = window.setTimeout(() => reveal(), 80);
174
+ });
175
+ observer.observe(element);
176
+ observer.observe(document.body);
177
+ }
178
+
179
+ onMounted(() => {
180
+ const element = targetRef.value;
181
+
182
+ if (group && element) {
183
+ entry = {element, getAnnotation: () => annotation};
184
+ group.add(entry);
185
+ }
186
+
187
+ build();
188
+ });
189
+
190
+ // The annotation type is immutable, so any prop change rebuilds it from scratch.
191
+ watch([effectiveVariant, effectiveColor, effectiveStrokeWidth, effectiveAnimationDuration, effectiveIterations, effectivePadding, effectiveMultiline], () => build());
192
+
193
+ // Standalone reveal once the element scrolls into view (whenInView).
194
+ watch(inView, () => {
195
+ if (!group) {
196
+ reveal();
197
+ }
198
+ });
199
+
200
+ onBeforeUnmount(() => {
201
+ if (group && entry) {
202
+ group.remove(entry);
203
+ entry = null;
204
+ }
205
+
206
+ window.clearTimeout(shownTimer);
207
+ stopSettleWatch();
208
+ annotation?.remove();
209
+ annotation = null;
210
+ });
211
+
212
+ defineExpose({
213
+ hide,
214
+ replay,
215
+ show
216
+ });
217
+ </script>
@@ -0,0 +1,20 @@
1
+ <template>
2
+ <span :class="$style.highlighterGroup">
3
+ <slot/>
4
+ </span>
5
+ </template>
6
+
7
+ <script
8
+ lang="ts"
9
+ setup>
10
+ import { useHighlighterGroup, type HighlighterGroupProps } from '~flux/visuals/composable/private';
11
+ import $style from '~flux/visuals/css/component/Highlighter.module.scss';
12
+
13
+ const props = defineProps<HighlighterGroupProps>();
14
+
15
+ defineSlots<{
16
+ default(): any;
17
+ }>();
18
+
19
+ useHighlighterGroup(props);
20
+ </script>
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <div
3
+ aria-hidden="true"
4
+ :class="clsx($style.noise, animated && $style.animated)"
5
+ :style="style"/>
6
+ </template>
7
+
8
+ <script
9
+ lang="ts"
10
+ setup>
11
+ import { clsx } from 'clsx';
12
+ import { computed } from 'vue';
13
+ import $style from '~flux/visuals/css/component/Noise.module.scss';
14
+
15
+ type NoiseBlendMode = 'normal' | 'multiply' | 'screen' | 'overlay' | 'soft-light' | 'plus-lighter';
16
+
17
+ const {
18
+ animated = false,
19
+ blend = 'overlay',
20
+ opacity = 0.05
21
+ } = defineProps<{
22
+ readonly animated?: boolean;
23
+ readonly blend?: NoiseBlendMode;
24
+ readonly opacity?: number;
25
+ }>();
26
+
27
+ const style = computed(() => ({
28
+ '--noise-blend': blend,
29
+ '--noise-opacity': opacity
30
+ }));
31
+ </script>