@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
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span
|
|
3
|
+
ref="label"
|
|
4
|
+
:aria-label="text">{{ initialText }}</span>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script
|
|
8
|
+
lang="ts"
|
|
9
|
+
setup>
|
|
10
|
+
import { prefersReducedMotion } from '@flux-ui/internals';
|
|
11
|
+
import { onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue';
|
|
12
|
+
import $style from '~flux/visuals/css/component/SlotText.module.scss';
|
|
13
|
+
|
|
14
|
+
type SlotTextColor = string | ((index: number, total: number) => string);
|
|
15
|
+
type SlotTextDirection = 'up' | 'down';
|
|
16
|
+
|
|
17
|
+
type AnimateOptions = {
|
|
18
|
+
direction?: SlotTextDirection;
|
|
19
|
+
stagger?: number;
|
|
20
|
+
duration?: number;
|
|
21
|
+
exitOffset?: number;
|
|
22
|
+
easing?: string;
|
|
23
|
+
bounce?: number;
|
|
24
|
+
color?: SlotTextColor;
|
|
25
|
+
colorFade?: number;
|
|
26
|
+
skipUnchanged?: boolean;
|
|
27
|
+
interrupt?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type FlashOptions = {
|
|
31
|
+
revertAfter?: number;
|
|
32
|
+
enter?: AnimateOptions;
|
|
33
|
+
exit?: AnimateOptions;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type SlotState = {
|
|
37
|
+
timers: number[];
|
|
38
|
+
target: string;
|
|
39
|
+
pending?: { text: string; options: AnimateOptions; };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
text,
|
|
44
|
+
bounce = .6,
|
|
45
|
+
chromatic = false,
|
|
46
|
+
color,
|
|
47
|
+
colorFade = 280,
|
|
48
|
+
direction = 'down',
|
|
49
|
+
duration = 300,
|
|
50
|
+
easing = 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
51
|
+
exitOffset = 50,
|
|
52
|
+
interrupt = true,
|
|
53
|
+
skipUnchanged = true,
|
|
54
|
+
stagger = 45
|
|
55
|
+
} = defineProps<{
|
|
56
|
+
readonly text: string;
|
|
57
|
+
readonly bounce?: number;
|
|
58
|
+
readonly chromatic?: boolean;
|
|
59
|
+
readonly color?: string;
|
|
60
|
+
readonly colorFade?: number;
|
|
61
|
+
readonly direction?: SlotTextDirection;
|
|
62
|
+
readonly duration?: number;
|
|
63
|
+
readonly easing?: string;
|
|
64
|
+
readonly exitOffset?: number;
|
|
65
|
+
readonly interrupt?: boolean;
|
|
66
|
+
readonly skipUnchanged?: boolean;
|
|
67
|
+
readonly stagger?: number;
|
|
68
|
+
}>();
|
|
69
|
+
|
|
70
|
+
const NBSP = '\u00A0';
|
|
71
|
+
|
|
72
|
+
// Rendered once for SSR / first paint only. The engine owns the span's DOM
|
|
73
|
+
// after mount, so this must NOT be reactive - a reactive {{ text }} would make
|
|
74
|
+
// Vue re-run setElementText on every change and wipe the animated glyph cells.
|
|
75
|
+
// The accessible name stays current through the reactive :aria-label binding.
|
|
76
|
+
const initialText = text;
|
|
77
|
+
|
|
78
|
+
const labelRef = useTemplateRef('label');
|
|
79
|
+
|
|
80
|
+
// Per-instance record of the in-flight roll, so a new roll can interrupt it.
|
|
81
|
+
let state: SlotState | null = null;
|
|
82
|
+
let revertTimeout: number | undefined;
|
|
83
|
+
let restingText: string | undefined;
|
|
84
|
+
|
|
85
|
+
const glyph = (char: string): string => char === ' ' ? NBSP : char;
|
|
86
|
+
|
|
87
|
+
// Sweep the hue across the line so the roll lands as a chromatic spectrum.
|
|
88
|
+
const chromaticColor = (index: number, total: number): string => {
|
|
89
|
+
const t = total <= 1 ? 0 : index / (total - 1);
|
|
90
|
+
return `hsl(${(t * 320) % 360} 92% 60%)`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function baseOptions(): AnimateOptions {
|
|
94
|
+
return {
|
|
95
|
+
direction,
|
|
96
|
+
stagger,
|
|
97
|
+
duration,
|
|
98
|
+
exitOffset,
|
|
99
|
+
easing,
|
|
100
|
+
bounce,
|
|
101
|
+
color: chromatic ? chromaticColor : color,
|
|
102
|
+
colorFade,
|
|
103
|
+
skipUnchanged,
|
|
104
|
+
interrupt
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeFace(char: string): HTMLSpanElement {
|
|
109
|
+
const face = document.createElement('span');
|
|
110
|
+
face.className = $style.charFace;
|
|
111
|
+
face.textContent = glyph(char);
|
|
112
|
+
return face;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildSlot(char: string): HTMLSpanElement {
|
|
116
|
+
const slot = document.createElement('span');
|
|
117
|
+
slot.className = $style.charSlot;
|
|
118
|
+
slot.dataset.char = char;
|
|
119
|
+
|
|
120
|
+
// Invisible sizer keeps the cell exactly the width/height of its glyph,
|
|
121
|
+
// so the absolutely-positioned animating faces never reflow the line.
|
|
122
|
+
const sizer = document.createElement('span');
|
|
123
|
+
sizer.className = $style.charSizer;
|
|
124
|
+
sizer.textContent = glyph(char);
|
|
125
|
+
|
|
126
|
+
slot.append(sizer, makeFace(char));
|
|
127
|
+
return slot;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildSlotText(container: HTMLElement, value: string): void {
|
|
131
|
+
container.classList.add($style.slotText);
|
|
132
|
+
container.replaceChildren(...Array.from(value, buildSlot));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Cancel any running roll on the container and snap it to its target text.
|
|
136
|
+
function settle(container: HTMLElement): void {
|
|
137
|
+
if (!state) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
state.timers.forEach(timer => window.clearTimeout(timer));
|
|
142
|
+
|
|
143
|
+
// Rebuild a pristine DOM at the text the interrupted roll was heading
|
|
144
|
+
// toward, so the next animation starts from a clean baseline.
|
|
145
|
+
const target = state.target;
|
|
146
|
+
state = null;
|
|
147
|
+
buildSlotText(container, target);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function animateSlotText(container: HTMLElement, toText: string, options: AnimateOptions = {}): void {
|
|
151
|
+
const {
|
|
152
|
+
direction = 'down',
|
|
153
|
+
stagger = 45,
|
|
154
|
+
duration = 300,
|
|
155
|
+
exitOffset = 50,
|
|
156
|
+
easing = 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
157
|
+
bounce = .6,
|
|
158
|
+
color,
|
|
159
|
+
colorFade = 280,
|
|
160
|
+
skipUnchanged = true,
|
|
161
|
+
interrupt = true
|
|
162
|
+
} = options;
|
|
163
|
+
|
|
164
|
+
// Reduced motion: swap to the new text without rolling.
|
|
165
|
+
if (prefersReducedMotion()) {
|
|
166
|
+
buildSlotText(container, toText);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Non-interrupting mode: if a roll is already in flight, let it finish
|
|
171
|
+
// and remember this request instead. Only the latest request survives,
|
|
172
|
+
// so spam taps coalesce into a single follow-up roll once it lands.
|
|
173
|
+
if (state && !interrupt) {
|
|
174
|
+
if (toText !== state.target) {
|
|
175
|
+
state.pending = {text: toText, options};
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Interrupt: fast-forward any previous roll to its target and tear down
|
|
181
|
+
// its timers before we start fresh.
|
|
182
|
+
settle(container);
|
|
183
|
+
|
|
184
|
+
// First run / empty container → just build it.
|
|
185
|
+
if (!container.querySelector(`.${$style.charSlot}`)) {
|
|
186
|
+
buildSlotText(container, toText);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const slots = Array.from(container.querySelectorAll<HTMLElement>(`.${$style.charSlot}`));
|
|
191
|
+
const fromText = slots.map(slot => slot.dataset.char ?? '').join('');
|
|
192
|
+
|
|
193
|
+
// Non-interrupting mode drops rolls to the text already on screen, so
|
|
194
|
+
// repeated triggers do not visibly re-roll an unchanged label.
|
|
195
|
+
if (!interrupt && fromText === toText) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const maxLen = Math.max(fromText.length, toText.length);
|
|
200
|
+
|
|
201
|
+
// Whole-pixel slide distance = one cell height, so glyphs clip cleanly.
|
|
202
|
+
// Ceil, not round: half a pixel short leaves a sliver of the outgoing
|
|
203
|
+
// glyph visible at the clip edge.
|
|
204
|
+
const sample = slots.find(slot => (slot.dataset.char ?? '') !== '') ?? slots[0];
|
|
205
|
+
const cs = getComputedStyle(container);
|
|
206
|
+
const H = Math.ceil(
|
|
207
|
+
sample?.getBoundingClientRect().height
|
|
208
|
+
|| sample?.offsetHeight
|
|
209
|
+
|| container.getBoundingClientRect().height
|
|
210
|
+
|| parseFloat(cs.lineHeight)
|
|
211
|
+
|| 0
|
|
212
|
+
) || Math.ceil(parseFloat(cs.fontSize) * 1.3) || 18;
|
|
213
|
+
|
|
214
|
+
// Resting color to settle the chromatic flash back to.
|
|
215
|
+
const restColor = color ? cs.color : '';
|
|
216
|
+
|
|
217
|
+
// Pre-create any extra cells up front so the row never reflows mid-roll.
|
|
218
|
+
for (let i = slots.length; i < maxLen; i++) {
|
|
219
|
+
const slot = buildSlot('');
|
|
220
|
+
container.appendChild(slot);
|
|
221
|
+
slots.push(slot);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const timers: number[] = [];
|
|
225
|
+
state = {timers, target: toText};
|
|
226
|
+
|
|
227
|
+
// down: new enters from above (-H to 0), old exits below (0 to +H)
|
|
228
|
+
// up: new enters from below (+H to 0), old exits above (0 to -H)
|
|
229
|
+
const outY = direction === 'down' ? H : -H;
|
|
230
|
+
const inStart = direction === 'down' ? -H : H;
|
|
231
|
+
|
|
232
|
+
// A tiny deterministic jitter in [-1, 1] per character. Scaled by
|
|
233
|
+
// `bounce` it gives each glyph its own speed and a little tilt-wobble,
|
|
234
|
+
// so the line does not land as one rigid block.
|
|
235
|
+
const wobble = (index: number, salt: number): number => {
|
|
236
|
+
const n = Math.sin((index + 1) * 12.9898 + salt * 78.233) * 43758.5453;
|
|
237
|
+
return (n - Math.floor(n)) * 2 - 1;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Track the slowest letter so the safety-net snap waits for everyone.
|
|
241
|
+
let maxEnd = 0;
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < maxLen; i++) {
|
|
244
|
+
const fromChar = fromText[i] || '';
|
|
245
|
+
const toChar = toText[i] || '';
|
|
246
|
+
|
|
247
|
+
if (fromChar === toChar && (skipUnchanged || fromChar === '')) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const slot = slots[i];
|
|
252
|
+
const sizer = slot.querySelector<HTMLElement>(`.${$style.charSizer}`)!;
|
|
253
|
+
const oldFace = slot.querySelector<HTMLElement>(`.${$style.charFace}`);
|
|
254
|
+
|
|
255
|
+
// Resize the cell to the new glyph — but ease the width instead of
|
|
256
|
+
// snapping it, so a wide outgoing glyph is never cropped by a
|
|
257
|
+
// suddenly-narrow cell and neighbours glide rather than jump.
|
|
258
|
+
const oldW = slot.getBoundingClientRect().width;
|
|
259
|
+
sizer.textContent = glyph(toChar);
|
|
260
|
+
const newW = sizer.getBoundingClientRect().width;
|
|
261
|
+
const widthChanges = Math.abs(newW - oldW) > .5;
|
|
262
|
+
|
|
263
|
+
if (widthChanges) {
|
|
264
|
+
slot.style.width = `${oldW}px`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A cell growing from or collapsing to empty changes width
|
|
268
|
+
// drastically — clip it horizontally while it resizes so its glyph
|
|
269
|
+
// wipes in/out with the cell instead of stacking onto the neighbours.
|
|
270
|
+
if (fromChar === '' || toChar === '') {
|
|
271
|
+
slot.classList.add($style.isResizing);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const tint = typeof color === 'function' ? color(i, maxLen) : color;
|
|
275
|
+
|
|
276
|
+
// Per-letter personality: vary the speed, the stagger and a starting
|
|
277
|
+
// tilt that springs back to upright as the glyph settles. Tail cells
|
|
278
|
+
// (rolling out to nothing) join the same wave instead of queuing
|
|
279
|
+
// behind it, so nothing trails.
|
|
280
|
+
const isTail = toChar === '';
|
|
281
|
+
const d = Math.round(duration * (isTail ? .75 : 1) * (1 + bounce * .45 * wobble(i, 1)));
|
|
282
|
+
const staggerIndex = isTail ? toText.length * .5 + (i - toText.length) * .25 : i;
|
|
283
|
+
const base = Math.round(staggerIndex * stagger * (1 + bounce * .25 * wobble(i, 2)));
|
|
284
|
+
const tilt = (bounce * 5 * wobble(i, 3)).toFixed(2);
|
|
285
|
+
|
|
286
|
+
const rollTrans = `transform ${d}ms ${easing}`;
|
|
287
|
+
const trans = color ? `${rollTrans}, color ${colorFade}ms linear ${d}ms` : rollTrans;
|
|
288
|
+
|
|
289
|
+
const newFace = makeFace(toChar);
|
|
290
|
+
newFace.style.transformOrigin = '50% 50%';
|
|
291
|
+
newFace.style.transform = `translateY(${inStart}px) rotate(${tilt}deg)`;
|
|
292
|
+
|
|
293
|
+
if (tint) {
|
|
294
|
+
newFace.style.color = tint;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
slot.appendChild(newFace);
|
|
298
|
+
|
|
299
|
+
void slot.offsetWidth; // commit start transforms
|
|
300
|
+
|
|
301
|
+
// Glide the cell to its new width with a clean ease-out (no
|
|
302
|
+
// overshoot) so it never pinches narrower than either glyph. Timing
|
|
303
|
+
// depends on the kind of change:
|
|
304
|
+
// - glyph → glyph: resize alongside the roll.
|
|
305
|
+
// - glyph → empty: roll out at full width first, then snap closed.
|
|
306
|
+
// - empty → glyph: open the cell quickly before the glyph rolls in.
|
|
307
|
+
if (widthChanges) {
|
|
308
|
+
let wDelay = base;
|
|
309
|
+
let wDur = d;
|
|
310
|
+
|
|
311
|
+
if (isTail) {
|
|
312
|
+
wDelay = base + Math.round(d * .55);
|
|
313
|
+
wDur = Math.max(140, Math.round(d * .6));
|
|
314
|
+
} else if (fromChar === '') {
|
|
315
|
+
wDur = Math.max(140, Math.round(d * .45));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
timers.push(window.setTimeout(() => {
|
|
319
|
+
slot.style.transition = `width ${wDur}ms cubic-bezier(0.2, 0, 0, 1)`;
|
|
320
|
+
slot.style.width = `${newW}px`;
|
|
321
|
+
}, wDelay));
|
|
322
|
+
|
|
323
|
+
maxEnd = Math.max(maxEnd, wDelay + wDur);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
maxEnd = Math.max(maxEnd, base + exitOffset + d + (color ? colorFade : 0));
|
|
327
|
+
|
|
328
|
+
// Outgoing glyph slides away first (with its own little counter-tilt).
|
|
329
|
+
if (oldFace) {
|
|
330
|
+
timers.push(window.setTimeout(() => {
|
|
331
|
+
oldFace.style.transition = rollTrans;
|
|
332
|
+
oldFace.style.transform = `translateY(${outY}px) rotate(${-Number(tilt)}deg)`;
|
|
333
|
+
}, base));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Incoming glyph chases it in (and, if tinted, fades to rest after).
|
|
337
|
+
timers.push(window.setTimeout(() => {
|
|
338
|
+
newFace.style.transition = trans;
|
|
339
|
+
newFace.style.transform = 'translateY(0) rotate(0deg)';
|
|
340
|
+
|
|
341
|
+
if (color) {
|
|
342
|
+
newFace.style.color = restColor;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const done = (event: TransitionEvent): void => {
|
|
346
|
+
if (event.propertyName !== 'transform') {
|
|
347
|
+
return; // ignore the colour fade
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
newFace.removeEventListener('transitionend', done);
|
|
351
|
+
slot.dataset.char = toChar;
|
|
352
|
+
// Hand sizing back to the sizer (same px, nothing moves).
|
|
353
|
+
slot.style.removeProperty('transition');
|
|
354
|
+
slot.style.removeProperty('width');
|
|
355
|
+
slot.classList.remove($style.isResizing);
|
|
356
|
+
slot.querySelectorAll(`.${$style.charFace}`).forEach(face => {
|
|
357
|
+
if (face !== newFace) {
|
|
358
|
+
face.remove();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
newFace.addEventListener('transitionend', done);
|
|
364
|
+
}, base + exitOffset));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Safety net: snap to a pristine DOM once the slowest letter settles. If
|
|
368
|
+
// a non-interrupting call was deferred mid-roll, replay it now as a fresh
|
|
369
|
+
// roll from this clean baseline.
|
|
370
|
+
const total = maxEnd + 80;
|
|
371
|
+
timers.push(window.setTimeout(() => {
|
|
372
|
+
const pending = state?.pending;
|
|
373
|
+
state = null;
|
|
374
|
+
buildSlotText(container, toText);
|
|
375
|
+
|
|
376
|
+
if (pending) {
|
|
377
|
+
animateSlotText(container, pending.text, pending.options);
|
|
378
|
+
}
|
|
379
|
+
}, total));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function clearSlotText(container: HTMLElement, value = ''): void {
|
|
383
|
+
settle(container);
|
|
384
|
+
container.classList.remove($style.slotText);
|
|
385
|
+
container.textContent = value;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Roll to new text. Cancels any pending flash revert.
|
|
389
|
+
function set(toText: string, options: AnimateOptions = {}): void {
|
|
390
|
+
const element = labelRef.value;
|
|
391
|
+
|
|
392
|
+
if (!element) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
window.clearTimeout(revertTimeout);
|
|
397
|
+
restingText = undefined;
|
|
398
|
+
animateSlotText(element, toText, {...baseOptions(), ...options});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Roll to temporary text, then roll back automatically — the classic
|
|
402
|
+
// Copy → Copied → Copy in one call. Spam-safe: repeat flashes restart the
|
|
403
|
+
// revert timer instead of queuing extra rolls.
|
|
404
|
+
function flash(toText: string, {revertAfter = 1400, enter, exit}: FlashOptions = {}): void {
|
|
405
|
+
const element = labelRef.value;
|
|
406
|
+
|
|
407
|
+
if (!element) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Capture the resting text only on the first flash of a burst, so a
|
|
412
|
+
// flash-during-flash still reverts to the original label.
|
|
413
|
+
if (restingText === undefined) {
|
|
414
|
+
restingText = text;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
animateSlotText(element, toText, {...baseOptions(), interrupt: false, ...enter});
|
|
418
|
+
|
|
419
|
+
window.clearTimeout(revertTimeout);
|
|
420
|
+
revertTimeout = window.setTimeout(() => {
|
|
421
|
+
const back = restingText!;
|
|
422
|
+
restingText = undefined;
|
|
423
|
+
revertTimeout = undefined;
|
|
424
|
+
|
|
425
|
+
const current = labelRef.value;
|
|
426
|
+
|
|
427
|
+
if (current) {
|
|
428
|
+
animateSlotText(current, back, {...baseOptions(), interrupt: false, ...exit});
|
|
429
|
+
}
|
|
430
|
+
}, revertAfter);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
onMounted(() => {
|
|
434
|
+
const element = labelRef.value;
|
|
435
|
+
|
|
436
|
+
if (element) {
|
|
437
|
+
buildSlotText(element, text);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
watch(() => text, value => set(value));
|
|
442
|
+
|
|
443
|
+
onBeforeUnmount(() => {
|
|
444
|
+
window.clearTimeout(revertTimeout);
|
|
445
|
+
|
|
446
|
+
const element = labelRef.value;
|
|
447
|
+
|
|
448
|
+
if (element) {
|
|
449
|
+
clearSlotText(element, text);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
defineExpose({
|
|
454
|
+
flash,
|
|
455
|
+
set
|
|
456
|
+
});
|
|
457
|
+
</script>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span
|
|
3
|
+
ref="label"
|
|
4
|
+
:aria-label="text"
|
|
5
|
+
:class="$style.textScramble">{{ initialText }}</span>
|
|
6
|
+
</template>
|
|
7
|
+
|
|
8
|
+
<script
|
|
9
|
+
lang="ts"
|
|
10
|
+
setup>
|
|
11
|
+
import { prefersReducedMotion } from '@flux-ui/internals';
|
|
12
|
+
import { onBeforeUnmount, useTemplateRef, watch } from 'vue';
|
|
13
|
+
import $style from '~flux/visuals/css/component/TextScramble.module.scss';
|
|
14
|
+
|
|
15
|
+
type ScrambleCell = {
|
|
16
|
+
from: string;
|
|
17
|
+
to: string;
|
|
18
|
+
start: number;
|
|
19
|
+
end: number;
|
|
20
|
+
char: string;
|
|
21
|
+
lastSwap: number;
|
|
22
|
+
fixed: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
text,
|
|
27
|
+
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
|
|
28
|
+
duration = 900,
|
|
29
|
+
skipUnchanged = true,
|
|
30
|
+
speed = 45,
|
|
31
|
+
stagger = 0.5
|
|
32
|
+
} = defineProps<{
|
|
33
|
+
readonly text: string;
|
|
34
|
+
readonly characters?: string;
|
|
35
|
+
readonly duration?: number;
|
|
36
|
+
readonly skipUnchanged?: boolean;
|
|
37
|
+
readonly speed?: number;
|
|
38
|
+
readonly stagger?: number;
|
|
39
|
+
}>();
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{
|
|
42
|
+
finished: [];
|
|
43
|
+
}>();
|
|
44
|
+
|
|
45
|
+
// Rendered once for SSR / first paint only. The engine owns the span's text
|
|
46
|
+
// after mount, so this must NOT be reactive - a reactive {{ text }} would make
|
|
47
|
+
// Vue re-patch the text node every frame and wipe the scramble. The accessible
|
|
48
|
+
// name stays current through the reactive :aria-label binding.
|
|
49
|
+
const initialText = text;
|
|
50
|
+
|
|
51
|
+
const labelRef = useTemplateRef('label');
|
|
52
|
+
|
|
53
|
+
let frame = 0;
|
|
54
|
+
let currentText = text;
|
|
55
|
+
|
|
56
|
+
function cancel(): void {
|
|
57
|
+
if (frame) {
|
|
58
|
+
cancelAnimationFrame(frame);
|
|
59
|
+
frame = 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function randomChar(): string {
|
|
64
|
+
return characters.charAt(Math.floor(Math.random() * characters.length));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Decode `toText` character by character. Each cell holds its old glyph until
|
|
68
|
+
// its staggered start, cycles through random glyphs, then settles on its
|
|
69
|
+
// final glyph. `force` re-scrambles even unchanged cells, for replay().
|
|
70
|
+
function scramble(element: HTMLElement, fromText: string, toText: string, force = false): void {
|
|
71
|
+
cancel();
|
|
72
|
+
|
|
73
|
+
// Reduced motion or no duration: swap straight to the final text.
|
|
74
|
+
if (duration <= 0 || prefersReducedMotion()) {
|
|
75
|
+
element.textContent = toText;
|
|
76
|
+
emit('finished');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const maxLen = Math.max(fromText.length, toText.length);
|
|
81
|
+
const spread = Math.min(Math.max(stagger, 0), 1);
|
|
82
|
+
const revealWindow = duration * spread;
|
|
83
|
+
const scrambleFor = duration - revealWindow;
|
|
84
|
+
const cells: ScrambleCell[] = [];
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < maxLen; ++i) {
|
|
87
|
+
const from = fromText.charAt(i);
|
|
88
|
+
const to = toText.charAt(i);
|
|
89
|
+
|
|
90
|
+
if (!force && skipUnchanged && from !== '' && from === to) {
|
|
91
|
+
cells.push({from, to, start: 0, end: 0, char: to, lastSwap: 0, fixed: true});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const start = maxLen <= 1 ? 0 : (i / (maxLen - 1)) * revealWindow;
|
|
96
|
+
cells.push({from, to, start, end: start + scrambleFor, char: '', lastSwap: -Infinity, fixed: false});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const startTime = performance.now();
|
|
100
|
+
|
|
101
|
+
const step = (now: number): void => {
|
|
102
|
+
const elapsed = now - startTime;
|
|
103
|
+
let output = '';
|
|
104
|
+
let done = 0;
|
|
105
|
+
|
|
106
|
+
for (const cell of cells) {
|
|
107
|
+
if (cell.fixed || elapsed >= cell.end) {
|
|
108
|
+
output += cell.to;
|
|
109
|
+
++done;
|
|
110
|
+
} else if (elapsed >= cell.start) {
|
|
111
|
+
if (now - cell.lastSwap >= speed) {
|
|
112
|
+
cell.char = randomChar();
|
|
113
|
+
cell.lastSwap = now;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
output += cell.char;
|
|
117
|
+
} else {
|
|
118
|
+
output += cell.from;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
element.textContent = output;
|
|
123
|
+
|
|
124
|
+
if (done === cells.length) {
|
|
125
|
+
frame = 0;
|
|
126
|
+
emit('finished');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
frame = requestAnimationFrame(step);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
frame = requestAnimationFrame(step);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Decode toward new text permanently, scrambling from the text on screen.
|
|
137
|
+
function set(toText: string): void {
|
|
138
|
+
const element = labelRef.value;
|
|
139
|
+
|
|
140
|
+
if (!element) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fromText = currentText;
|
|
145
|
+
currentText = toText;
|
|
146
|
+
scramble(element, fromText, toText);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Re-run the decode on the current text without changing it.
|
|
150
|
+
function replay(): void {
|
|
151
|
+
const element = labelRef.value;
|
|
152
|
+
|
|
153
|
+
if (!element) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
scramble(element, currentText, currentText, true);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
watch(() => text, value => set(value));
|
|
161
|
+
|
|
162
|
+
onBeforeUnmount(cancel);
|
|
163
|
+
|
|
164
|
+
defineExpose({
|
|
165
|
+
replay,
|
|
166
|
+
set
|
|
167
|
+
});
|
|
168
|
+
</script>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span
|
|
3
|
+
:class="$style.textShimmer"
|
|
4
|
+
:style="{
|
|
5
|
+
'--shimmer-duration': `${duration}s`,
|
|
6
|
+
'--shimmer-spread': `${spread}`,
|
|
7
|
+
'--shimmer-base': color,
|
|
8
|
+
'--shimmer-color': shimmerColor
|
|
9
|
+
}">
|
|
10
|
+
<slot/>
|
|
11
|
+
</span>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script
|
|
15
|
+
lang="ts"
|
|
16
|
+
setup>
|
|
17
|
+
import $style from '~flux/visuals/css/component/TextShimmer.module.scss';
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
color,
|
|
21
|
+
duration = 2,
|
|
22
|
+
shimmerColor,
|
|
23
|
+
spread = 15
|
|
24
|
+
} = defineProps<{
|
|
25
|
+
readonly color?: string;
|
|
26
|
+
readonly duration?: number;
|
|
27
|
+
readonly shimmerColor?: string;
|
|
28
|
+
readonly spread?: number;
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
defineSlots<{
|
|
32
|
+
default(): any;
|
|
33
|
+
}>();
|
|
34
|
+
</script>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { default as FluxVisualAnimatedColors } from './FluxVisualAnimatedColors.vue';
|
|
2
|
+
export { default as FluxVisualAttention } from './FluxVisualAttention.vue';
|
|
3
|
+
export { default as FluxVisualBorderBeam } from './FluxVisualBorderBeam.vue';
|
|
4
|
+
export { default as FluxVisualBorderShine } from './FluxVisualBorderShine.vue';
|
|
5
|
+
export { default as FluxVisualDotPattern } from './FluxVisualDotPattern.vue';
|
|
6
|
+
export { default as FluxVisualFlickeringGrid } from './FluxVisualFlickeringGrid.vue';
|
|
7
|
+
export { default as FluxVisualGridPattern } from './FluxVisualGridPattern.vue';
|
|
8
|
+
export { default as FluxVisualHighlighter } from './FluxVisualHighlighter.vue';
|
|
9
|
+
export { default as FluxVisualHighlighterGroup } from './FluxVisualHighlighterGroup.vue';
|
|
10
|
+
export { default as FluxVisualNoise } from './FluxVisualNoise.vue';
|
|
11
|
+
export { default as FluxVisualNumberFlow } from './FluxVisualNumberFlow.vue';
|
|
12
|
+
export { default as FluxVisualPing } from './FluxVisualPing.vue';
|
|
13
|
+
export { default as FluxVisualSlotText } from './FluxVisualSlotText.vue';
|
|
14
|
+
export { default as FluxVisualTextScramble } from './FluxVisualTextScramble.vue';
|
|
15
|
+
export { default as FluxVisualTextShimmer } from './FluxVisualTextShimmer.vue';
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { default as useBorderBeamPulse, type BorderBeamVariant } from './useBorderBeamPulse';
|
|
2
|
+
export { default as useHighlighterGroup, useHighlighterGroupInjection, type HighlighterGroupContext, type HighlighterGroupEntry, type HighlighterGroupProps, type HighlighterVariant } from './useHighlighterGroup';
|