fancy-ui-svelte 0.12.0 → 0.14.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/dist/fancy-ui/button/Button.svelte +196 -9
- package/dist/fancy-ui/button/Button.test.js +117 -3
- package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte +32 -0
- package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte.d.ts +3 -0
- package/dist/fancy-ui/button/README.md +13 -2
- package/dist/fancy-ui/compare/Compare.svelte +404 -47
- package/dist/fancy-ui/compare/Compare.svelte.d.ts +26 -0
- package/dist/fancy-ui/compare/Compare.test.js +82 -2
- package/dist/fancy-ui/compare/README.md +9 -2
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.js +24 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte +479 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte.d.ts +69 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.js +250 -0
- package/dist/fancy-ui/datamosh-transition/README.md +87 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.d.ts +180 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.js +462 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.js +309 -0
- package/dist/fancy-ui/datamosh-transition/index.d.ts +3 -0
- package/dist/fancy-ui/datamosh-transition/index.js +3 -0
- package/dist/fancy-ui/flip-card/FlipCard.svelte +373 -32
- package/dist/fancy-ui/flip-card/FlipCard.svelte.d.ts +39 -2
- package/dist/fancy-ui/flip-card/FlipCard.test.js +125 -25
- package/dist/fancy-ui/flip-card/README.md +41 -14
- package/dist/fancy-ui/flip-card/index.d.ts +1 -1
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +97 -11
- package/dist/fancy-ui/fluid-cursor/README.md +3 -1
- package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +18 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.js +22 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +17 -1
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +6 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +32 -9
- package/dist/fancy-ui/glow-border/GlowBorder.svelte +256 -33
- package/dist/fancy-ui/glow-border/GlowBorder.svelte.d.ts +41 -2
- package/dist/fancy-ui/glow-border/GlowBorder.test.js +45 -1
- package/dist/fancy-ui/glow-border/README.md +25 -33
- package/dist/fancy-ui/glow-border/index.d.ts +1 -1
- package/dist/fancy-ui/glow-border/index.js +1 -1
- package/dist/fancy-ui/gradient-button/GradientButton.svelte +123 -42
- package/dist/fancy-ui/gradient-button/GradientButton.svelte.d.ts +10 -5
- package/dist/fancy-ui/gradient-button/GradientButton.test.js +22 -5
- package/dist/fancy-ui/gradient-button/README.md +14 -6
- package/dist/fancy-ui/index.d.ts +1 -0
- package/dist/fancy-ui/index.js +1 -0
- package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.svelte +166 -23
- package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.test.js +18 -68
- package/dist/fancy-ui/interactive-hover-button/README.md +21 -15
- package/dist/fancy-ui/meteors/Meteors.svelte +179 -18
- package/dist/fancy-ui/meteors/Meteors.svelte.d.ts +32 -2
- package/dist/fancy-ui/meteors/Meteors.test.js +70 -22
- package/dist/fancy-ui/meteors/README.md +21 -6
- package/dist/fancy-ui/neon-border/NeonBorder.svelte +187 -58
- package/dist/fancy-ui/neon-border/NeonBorder.svelte.d.ts +24 -5
- package/dist/fancy-ui/neon-border/NeonBorder.test.js +51 -19
- package/dist/fancy-ui/neon-border/README.md +21 -11
- package/dist/fancy-ui/pagination/Pagination.svelte +144 -31
- package/dist/fancy-ui/pagination/Pagination.test.js +64 -8
- package/dist/fancy-ui/pagination/README.md +32 -22
- package/dist/fancy-ui/rainbow-button/README.md +3 -2
- package/dist/fancy-ui/rainbow-button/RainbowButton.svelte +108 -0
- package/dist/fancy-ui/rainbow-button/RainbowButton.test.js +12 -0
- package/dist/fancy-ui/registry.js +256 -41
- package/dist/fancy-ui/ripple-button/README.md +10 -7
- package/dist/fancy-ui/ripple-button/RippleButton.svelte +172 -19
- package/dist/fancy-ui/ripple-button/RippleButton.svelte.d.ts +20 -1
- package/dist/fancy-ui/ripple-button/RippleButton.test.js +47 -1
- package/dist/fancy-ui/shimmer-button/README.md +14 -6
- package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte +137 -54
- package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte.d.ts +3 -3
- package/dist/fancy-ui/shimmer-button/ShimmerButton.test.js +21 -7
- package/package.json +1 -1
- package/dist/fancy-ui/themes.d.ts +0 -38
- package/dist/fancy-ui/themes.js +0 -356
|
@@ -1,7 +1,68 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* Compare - a before/after slider split by a blade of light
|
|
4
|
+
*
|
|
5
|
+
* The seam is a beam: a white-hot core with a chromatic fringe, a glow
|
|
6
|
+
* that spills onto both sides, and pulses of light running along it. Moving
|
|
7
|
+
* the seam leaves a trail behind it that stretches with the speed of the
|
|
8
|
+
* gesture and settles when it stops.
|
|
9
|
+
*/
|
|
10
|
+
export const COMPARE_BEAM_COLORS = ["#22d3ee", "#818cf8", "#f472b6"];
|
|
11
|
+
|
|
12
|
+
/** Three colours for the beam (left fringe, centre, right fringe), from one to many given. */
|
|
13
|
+
export function beamPalette(colors: string[] | undefined): [string, string, string] {
|
|
14
|
+
const list = (colors ?? []).filter(Boolean);
|
|
15
|
+
if (list.length === 0) return COMPARE_BEAM_COLORS as [string, string, string];
|
|
16
|
+
if (list.length === 1) return [list[0], list[0], list[0]];
|
|
17
|
+
if (list.length === 2) return [list[0], `color-mix(in srgb, ${list[0]}, ${list[1]})`, list[1]];
|
|
18
|
+
return [list[0], list[Math.floor((list.length - 1) / 2)], list[list.length - 1]];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One frame of the beam's motion: the trail follows the seam's velocity
|
|
23
|
+
* (px per frame, signed) and decays; the energy eases toward its target
|
|
24
|
+
* and is kicked up by speed.
|
|
25
|
+
*/
|
|
26
|
+
export function beamStep(
|
|
27
|
+
trail: number,
|
|
28
|
+
energy: number,
|
|
29
|
+
deltaPx: number,
|
|
30
|
+
target: number
|
|
31
|
+
): { trail: number; energy: number } {
|
|
32
|
+
const nextTrail = Math.max(-140, Math.min(140, trail * 0.86 + deltaPx * 1.6));
|
|
33
|
+
const kick = Math.min(1, Math.abs(deltaPx) / 12);
|
|
34
|
+
const nextEnergy = Math.min(1, energy + (Math.max(target, kick) - energy) * 0.12);
|
|
35
|
+
return { trail: Math.abs(nextTrail) < 0.25 ? 0 : nextTrail, energy: nextEnergy };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Keyboard step for the slider, in %: arrows move 2, Shift+arrows 10, Home/End jump to the ends. */
|
|
39
|
+
export function keyStep(key: string, shift: boolean, current: number): number | null {
|
|
40
|
+
const step = shift ? 10 : 2;
|
|
41
|
+
switch (key) {
|
|
42
|
+
case "ArrowLeft":
|
|
43
|
+
case "ArrowDown":
|
|
44
|
+
return Math.max(0, current - step);
|
|
45
|
+
case "ArrowRight":
|
|
46
|
+
case "ArrowUp":
|
|
47
|
+
return Math.min(100, current + step);
|
|
48
|
+
case "PageDown":
|
|
49
|
+
return Math.max(0, current - 10);
|
|
50
|
+
case "PageUp":
|
|
51
|
+
return Math.min(100, current + 10);
|
|
52
|
+
case "Home":
|
|
53
|
+
return 0;
|
|
54
|
+
case "End":
|
|
55
|
+
return 100;
|
|
56
|
+
default:
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
1
62
|
<script lang="ts">
|
|
2
|
-
import { onMount } from "svelte";
|
|
63
|
+
import { onMount, untrack } from "svelte";
|
|
3
64
|
import { cn } from "../../utils";
|
|
4
|
-
import
|
|
65
|
+
import { createReducedMotion } from "../_internals/motion/media-query.svelte.js";
|
|
5
66
|
|
|
6
67
|
interface Props {
|
|
7
68
|
firstImage?: string;
|
|
@@ -16,6 +77,10 @@
|
|
|
16
77
|
showHandlebar?: boolean;
|
|
17
78
|
autoplay?: boolean;
|
|
18
79
|
autoplayDuration?: number;
|
|
80
|
+
/** Beam colours: left fringe, centre, right fringe (one or more colours) */
|
|
81
|
+
beamColors?: string[];
|
|
82
|
+
/** Accessible name of the slider */
|
|
83
|
+
label?: string;
|
|
19
84
|
onpercentagechange?: (percentage: number) => void;
|
|
20
85
|
ondragstart?: () => void;
|
|
21
86
|
ondragend?: () => void;
|
|
@@ -39,6 +104,8 @@
|
|
|
39
104
|
showHandlebar = true,
|
|
40
105
|
autoplay = false,
|
|
41
106
|
autoplayDuration = 5000,
|
|
107
|
+
beamColors,
|
|
108
|
+
label = "Comparison slider",
|
|
42
109
|
onpercentagechange,
|
|
43
110
|
ondragstart,
|
|
44
111
|
ondragend,
|
|
@@ -49,12 +116,30 @@
|
|
|
49
116
|
handle,
|
|
50
117
|
}: Props = $props();
|
|
51
118
|
|
|
119
|
+
const reducedMotion = createReducedMotion();
|
|
120
|
+
$effect(() => reducedMotion.start());
|
|
121
|
+
|
|
52
122
|
let sliderRef: HTMLDivElement;
|
|
53
|
-
let sliderXPercent = $state(initialSliderPercentage);
|
|
123
|
+
let sliderXPercent = $state(untrack(() => initialSliderPercentage));
|
|
54
124
|
let isDragging = $state(false);
|
|
55
125
|
let isMouseOver = $state(false);
|
|
56
126
|
let isInteracting = $state(false);
|
|
57
127
|
let autoplayRAF: number | null = null;
|
|
128
|
+
let returnRAF: number | null = null;
|
|
129
|
+
|
|
130
|
+
// the beam's motion: trail (px, signed) and energy (0 idle … 1 lit)
|
|
131
|
+
let trail = $state(0);
|
|
132
|
+
let energy = $state(0);
|
|
133
|
+
let beamRAF: number | null = null;
|
|
134
|
+
let lastPx: number | null = null;
|
|
135
|
+
|
|
136
|
+
const palette = $derived(beamPalette(beamColors));
|
|
137
|
+
|
|
138
|
+
function setPercent(p: number): void {
|
|
139
|
+
const next = Math.max(0, Math.min(100, p));
|
|
140
|
+
sliderXPercent = next;
|
|
141
|
+
onpercentagechange?.(next);
|
|
142
|
+
}
|
|
58
143
|
|
|
59
144
|
function startAutoplay(): void {
|
|
60
145
|
if (!autoplay || isMouseOver || isDragging) return;
|
|
@@ -68,10 +153,11 @@
|
|
|
68
153
|
|
|
69
154
|
const elapsedTime = Date.now() - startTime;
|
|
70
155
|
const progress = (elapsedTime % (autoplayDuration * 2)) / autoplayDuration;
|
|
71
|
-
|
|
156
|
+
// eased back-and-forth: slow at the ends, quick through the middle
|
|
157
|
+
const linear = progress <= 1 ? progress : 2 - progress;
|
|
158
|
+
const eased = linear < 0.5 ? 4 * linear ** 3 : 1 - (-2 * linear + 2) ** 3 / 2;
|
|
72
159
|
|
|
73
|
-
|
|
74
|
-
onpercentagechange?.(percentage);
|
|
160
|
+
setPercent(eased * 100);
|
|
75
161
|
autoplayRAF = requestAnimationFrame(animate);
|
|
76
162
|
}
|
|
77
163
|
|
|
@@ -85,8 +171,35 @@
|
|
|
85
171
|
}
|
|
86
172
|
}
|
|
87
173
|
|
|
174
|
+
function stopReturn(): void {
|
|
175
|
+
if (returnRAF) {
|
|
176
|
+
cancelAnimationFrame(returnRAF);
|
|
177
|
+
returnRAF = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Glide the seam back to its resting place instead of snapping. */
|
|
182
|
+
function glideTo(target: number): void {
|
|
183
|
+
stopReturn();
|
|
184
|
+
if (reducedMotion.current) {
|
|
185
|
+
setPercent(target);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const from = sliderXPercent;
|
|
189
|
+
const start = performance.now();
|
|
190
|
+
const duration = 520;
|
|
191
|
+
const step = (now: number) => {
|
|
192
|
+
const k = Math.min(1, (now - start) / duration);
|
|
193
|
+
const e = 1 - (1 - k) ** 3;
|
|
194
|
+
setPercent(from + (target - from) * e);
|
|
195
|
+
returnRAF = k < 1 ? requestAnimationFrame(step) : null;
|
|
196
|
+
};
|
|
197
|
+
returnRAF = requestAnimationFrame(step);
|
|
198
|
+
}
|
|
199
|
+
|
|
88
200
|
function mouseEnterHandler(): void {
|
|
89
201
|
isMouseOver = true;
|
|
202
|
+
stopReturn();
|
|
90
203
|
onhoverenter?.();
|
|
91
204
|
if (autoplay) {
|
|
92
205
|
stopAutoplay();
|
|
@@ -99,14 +212,14 @@
|
|
|
99
212
|
onhoverleave?.();
|
|
100
213
|
|
|
101
214
|
if (slideMode === "hover") {
|
|
102
|
-
|
|
103
|
-
onpercentagechange?.(initialSliderPercentage);
|
|
215
|
+
glideTo(initialSliderPercentage);
|
|
104
216
|
}
|
|
105
217
|
if (slideMode === "drag") {
|
|
106
218
|
isDragging = false;
|
|
107
219
|
}
|
|
108
220
|
|
|
109
221
|
if (autoplay) {
|
|
222
|
+
stopReturn();
|
|
110
223
|
startAutoplay();
|
|
111
224
|
}
|
|
112
225
|
}
|
|
@@ -137,16 +250,13 @@
|
|
|
137
250
|
if (slideMode === "hover" || (slideMode === "drag" && isDragging)) {
|
|
138
251
|
isInteracting = true;
|
|
139
252
|
stopAutoplay();
|
|
253
|
+
stopReturn();
|
|
140
254
|
|
|
141
255
|
const rect = sliderRef.getBoundingClientRect();
|
|
142
256
|
const x = clientX - rect.left;
|
|
143
257
|
const percent = (x / rect.width) * 100;
|
|
144
258
|
|
|
145
|
-
requestAnimationFrame(() =>
|
|
146
|
-
const newPercent = Math.max(0, Math.min(100, percent));
|
|
147
|
-
sliderXPercent = newPercent;
|
|
148
|
-
onpercentagechange?.(newPercent);
|
|
149
|
-
});
|
|
259
|
+
requestAnimationFrame(() => setPercent(percent));
|
|
150
260
|
}
|
|
151
261
|
}
|
|
152
262
|
|
|
@@ -170,6 +280,41 @@
|
|
|
170
280
|
if (!autoplay) handleMove(e.touches[0].clientX);
|
|
171
281
|
}
|
|
172
282
|
|
|
283
|
+
function handleKeydown(e: KeyboardEvent): void {
|
|
284
|
+
const next = keyStep(e.key, e.shiftKey, sliderXPercent);
|
|
285
|
+
if (next === null) return;
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
stopAutoplay();
|
|
288
|
+
stopReturn();
|
|
289
|
+
setPercent(next);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Run the beam's motion until it has settled. */
|
|
293
|
+
function wakeBeam(): void {
|
|
294
|
+
if (beamRAF !== null || reducedMotion.current) return;
|
|
295
|
+
const tick = () => {
|
|
296
|
+
const width = sliderRef?.clientWidth ?? 0;
|
|
297
|
+
const px = (sliderXPercent / 100) * width;
|
|
298
|
+
const delta = lastPx === null ? 0 : px - lastPx;
|
|
299
|
+
lastPx = px;
|
|
300
|
+
const target = isInteracting || isDragging ? 1 : isMouseOver ? 0.7 : 0;
|
|
301
|
+
const next = beamStep(trail, energy, delta, target);
|
|
302
|
+
trail = next.trail;
|
|
303
|
+
energy = next.energy;
|
|
304
|
+
const settled = trail === 0 && delta === 0 && Math.abs(energy - target) < 0.01;
|
|
305
|
+
beamRAF = settled ? null : requestAnimationFrame(tick);
|
|
306
|
+
};
|
|
307
|
+
beamRAF = requestAnimationFrame(tick);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// any movement of the seam (pointer, keys, autoplay, glide) wakes the beam
|
|
311
|
+
$effect(() => {
|
|
312
|
+
void sliderXPercent;
|
|
313
|
+
void isInteracting;
|
|
314
|
+
void isMouseOver;
|
|
315
|
+
untrack(wakeBeam);
|
|
316
|
+
});
|
|
317
|
+
|
|
173
318
|
// Watch for initialSliderPercentage changes
|
|
174
319
|
$effect(() => {
|
|
175
320
|
sliderXPercent = initialSliderPercentage;
|
|
@@ -188,15 +333,23 @@
|
|
|
188
333
|
startAutoplay();
|
|
189
334
|
return () => {
|
|
190
335
|
stopAutoplay();
|
|
336
|
+
stopReturn();
|
|
337
|
+
if (beamRAF !== null) cancelAnimationFrame(beamRAF);
|
|
191
338
|
};
|
|
192
339
|
});
|
|
193
340
|
</script>
|
|
194
341
|
|
|
195
342
|
<div
|
|
196
343
|
bind:this={sliderRef}
|
|
197
|
-
class={cn("h-[400px] w-[400px] overflow-hidden", className)}
|
|
344
|
+
class={cn("compare h-[400px] w-[400px] overflow-hidden", className)}
|
|
198
345
|
style:position="relative"
|
|
199
|
-
style:cursor={slideMode === "drag" ? "grab" : "col-resize"}
|
|
346
|
+
style:cursor={slideMode === "drag" ? (isDragging ? "grabbing" : "grab") : "col-resize"}
|
|
347
|
+
style:--cmp-c1={palette[0]}
|
|
348
|
+
style:--cmp-c2={palette[1]}
|
|
349
|
+
style:--cmp-c3={palette[2]}
|
|
350
|
+
style:--cmp-energy={energy.toFixed(3)}
|
|
351
|
+
style:--cmp-trail={trail.toFixed(1)}
|
|
352
|
+
data-active={isInteracting || isDragging ? "" : undefined}
|
|
200
353
|
onmousemove={handleMouseMove}
|
|
201
354
|
onmouseleave={mouseLeaveHandler}
|
|
202
355
|
onmouseenter={mouseEnterHandler}
|
|
@@ -205,50 +358,42 @@
|
|
|
205
358
|
ontouchstart={handleTouchStart}
|
|
206
359
|
ontouchend={handleTouchEnd}
|
|
207
360
|
ontouchmove={handleTouchMove}
|
|
361
|
+
onkeydown={handleKeydown}
|
|
208
362
|
role="slider"
|
|
209
|
-
aria-
|
|
363
|
+
aria-label={label}
|
|
364
|
+
aria-valuenow={Math.round(sliderXPercent)}
|
|
365
|
+
aria-valuetext="{Math.round(sliderXPercent)}% {firstImageAlt}"
|
|
210
366
|
aria-valuemin={0}
|
|
211
367
|
aria-valuemax={100}
|
|
368
|
+
aria-orientation="horizontal"
|
|
212
369
|
tabindex="0"
|
|
213
370
|
>
|
|
214
|
-
<!--
|
|
371
|
+
<!-- The seam: a blade of light, screened onto both sides -->
|
|
215
372
|
<div
|
|
216
|
-
class="pointer-events-none absolute top-0 z-40
|
|
373
|
+
class="compare-beam pointer-events-none absolute top-0 z-40 h-full w-px"
|
|
217
374
|
style:left="{sliderXPercent}%"
|
|
375
|
+
aria-hidden="true"
|
|
218
376
|
>
|
|
219
|
-
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
></
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
<div
|
|
227
|
-
class="absolute top-1/2 -right-10 h-3/4 w-10 -translate-y-1/2 [mask-image:radial-gradient(100px_at_left,white,transparent)]"
|
|
228
|
-
>
|
|
229
|
-
<StarField starsCount={120} class="size-full" />
|
|
230
|
-
</div>
|
|
377
|
+
<span class="compare-beam__glow"></span>
|
|
378
|
+
<span class="compare-beam__trail"></span>
|
|
379
|
+
<span class="compare-beam__fringe"></span>
|
|
380
|
+
<span class="compare-beam__core"></span>
|
|
381
|
+
<span class="compare-beam__pulse"></span>
|
|
382
|
+
<span class="compare-beam__pulse compare-beam__pulse--late"></span>
|
|
383
|
+
</div>
|
|
231
384
|
|
|
232
|
-
|
|
385
|
+
<!-- Handle, above the beam and not blended with it -->
|
|
386
|
+
<div
|
|
387
|
+
class="compare-seam pointer-events-none absolute top-0 z-[41] h-full w-px"
|
|
388
|
+
style:left="{sliderXPercent}%"
|
|
389
|
+
>
|
|
233
390
|
{#if handle}
|
|
234
391
|
{@render handle()}
|
|
235
392
|
{:else if showHandlebar}
|
|
236
|
-
<div
|
|
237
|
-
class="
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
241
|
-
fill="none"
|
|
242
|
-
viewBox="0 0 24 24"
|
|
243
|
-
stroke-width="1.5"
|
|
244
|
-
stroke="currentColor"
|
|
245
|
-
class="size-4 text-black"
|
|
246
|
-
>
|
|
247
|
-
<path
|
|
248
|
-
stroke-linecap="round"
|
|
249
|
-
stroke-linejoin="round"
|
|
250
|
-
d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z"
|
|
251
|
-
/>
|
|
393
|
+
<div class="compare-handle pointer-events-auto" aria-hidden="true">
|
|
394
|
+
<span class="compare-handle__ring"></span>
|
|
395
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="size-4">
|
|
396
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7 4 12l5 5M15 7l5 5-5 5" />
|
|
252
397
|
</svg>
|
|
253
398
|
</div>
|
|
254
399
|
{/if}
|
|
@@ -302,3 +447,215 @@
|
|
|
302
447
|
{/if}
|
|
303
448
|
</div>
|
|
304
449
|
</div>
|
|
450
|
+
|
|
451
|
+
<style>
|
|
452
|
+
@property --cmp-angle {
|
|
453
|
+
syntax: "<angle>";
|
|
454
|
+
inherits: false;
|
|
455
|
+
initial-value: 0deg;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.compare:focus-visible {
|
|
459
|
+
outline: 2px solid var(--cmp-c2);
|
|
460
|
+
outline-offset: 3px;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* the whole beam screens onto the pictures: light adds, never darkens */
|
|
464
|
+
.compare-beam {
|
|
465
|
+
mix-blend-mode: screen;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.compare-beam > span {
|
|
469
|
+
position: absolute;
|
|
470
|
+
pointer-events: none;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/* The light the beam spills on both sides: chromatic, brighter when active. */
|
|
474
|
+
.compare-beam__glow {
|
|
475
|
+
top: 0;
|
|
476
|
+
bottom: 0;
|
|
477
|
+
left: -70px;
|
|
478
|
+
width: 140px;
|
|
479
|
+
background: linear-gradient(
|
|
480
|
+
to right,
|
|
481
|
+
transparent,
|
|
482
|
+
color-mix(in srgb, var(--cmp-c1) 55%, transparent) 38%,
|
|
483
|
+
var(--cmp-c2) 50%,
|
|
484
|
+
color-mix(in srgb, var(--cmp-c3) 55%, transparent) 62%,
|
|
485
|
+
transparent
|
|
486
|
+
);
|
|
487
|
+
-webkit-mask: linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
|
|
488
|
+
mask: linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
|
|
489
|
+
filter: blur(18px);
|
|
490
|
+
opacity: calc(0.35 + var(--cmp-energy) * 0.55);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/* The trail: stretched out behind the seam by its speed (the sign picks the side). */
|
|
494
|
+
.compare-beam__trail {
|
|
495
|
+
top: 12%;
|
|
496
|
+
bottom: 12%;
|
|
497
|
+
left: 0;
|
|
498
|
+
width: 100px;
|
|
499
|
+
transform-origin: left center;
|
|
500
|
+
transform: scaleX(calc(var(--cmp-trail) / -100));
|
|
501
|
+
background: linear-gradient(
|
|
502
|
+
to right,
|
|
503
|
+
#fff,
|
|
504
|
+
var(--cmp-c2) 18%,
|
|
505
|
+
color-mix(in srgb, var(--cmp-c3) 50%, transparent) 55%,
|
|
506
|
+
transparent
|
|
507
|
+
);
|
|
508
|
+
-webkit-mask: linear-gradient(to bottom, transparent, #000 30%, #000 70%, transparent);
|
|
509
|
+
mask: linear-gradient(to bottom, transparent, #000 30%, #000 70%, transparent);
|
|
510
|
+
filter: blur(6px);
|
|
511
|
+
opacity: 0.75;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/* A chromatic fringe either side of the core. */
|
|
515
|
+
.compare-beam__fringe {
|
|
516
|
+
top: 0;
|
|
517
|
+
bottom: 0;
|
|
518
|
+
left: -4px;
|
|
519
|
+
width: 9px;
|
|
520
|
+
background: linear-gradient(
|
|
521
|
+
to right,
|
|
522
|
+
var(--cmp-c1),
|
|
523
|
+
transparent 45%,
|
|
524
|
+
transparent 55%,
|
|
525
|
+
var(--cmp-c3)
|
|
526
|
+
);
|
|
527
|
+
-webkit-mask: linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent);
|
|
528
|
+
mask: linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent);
|
|
529
|
+
filter: blur(1.5px);
|
|
530
|
+
opacity: calc(0.55 + var(--cmp-energy) * 0.45);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/* The white-hot core. */
|
|
534
|
+
.compare-beam__core {
|
|
535
|
+
top: 0;
|
|
536
|
+
bottom: 0;
|
|
537
|
+
left: -0.75px;
|
|
538
|
+
width: 1.5px;
|
|
539
|
+
background: linear-gradient(
|
|
540
|
+
to bottom,
|
|
541
|
+
transparent,
|
|
542
|
+
color-mix(in srgb, var(--cmp-c2) 40%, #fff) 6%,
|
|
543
|
+
#fff 30%,
|
|
544
|
+
#fff 70%,
|
|
545
|
+
color-mix(in srgb, var(--cmp-c2) 40%, #fff) 94%,
|
|
546
|
+
transparent
|
|
547
|
+
);
|
|
548
|
+
box-shadow: 0 0 6px 0.5px color-mix(in srgb, var(--cmp-c2) 80%, transparent);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* Pulses of light running down the seam. */
|
|
552
|
+
.compare-beam__pulse {
|
|
553
|
+
top: 0;
|
|
554
|
+
left: -1.5px;
|
|
555
|
+
width: 3px;
|
|
556
|
+
height: 26%;
|
|
557
|
+
border-radius: 999px;
|
|
558
|
+
background: linear-gradient(to bottom, transparent, #fff 50%, transparent);
|
|
559
|
+
filter: drop-shadow(0 0 4px var(--cmp-c2)) drop-shadow(0 0 10px var(--cmp-c1));
|
|
560
|
+
opacity: 0;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
564
|
+
.compare-beam__pulse {
|
|
565
|
+
animation: cmp-pulse 3.2s cubic-bezier(0.45, 0, 0.55, 1) infinite;
|
|
566
|
+
}
|
|
567
|
+
.compare-beam__pulse--late {
|
|
568
|
+
animation-delay: -1.6s;
|
|
569
|
+
animation-duration: 3.2s;
|
|
570
|
+
}
|
|
571
|
+
.compare[data-active] .compare-beam__pulse {
|
|
572
|
+
animation-duration: 1.6s;
|
|
573
|
+
}
|
|
574
|
+
.compare[data-active] .compare-beam__pulse--late {
|
|
575
|
+
animation-delay: -0.8s;
|
|
576
|
+
}
|
|
577
|
+
.compare-handle__ring {
|
|
578
|
+
animation: cmp-spin 4s linear infinite;
|
|
579
|
+
}
|
|
580
|
+
.compare[data-active] .compare-handle__ring {
|
|
581
|
+
animation-duration: 1.4s;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
@keyframes cmp-pulse {
|
|
586
|
+
0% {
|
|
587
|
+
transform: translateY(-100%);
|
|
588
|
+
opacity: 0;
|
|
589
|
+
}
|
|
590
|
+
15% {
|
|
591
|
+
opacity: 1;
|
|
592
|
+
}
|
|
593
|
+
85% {
|
|
594
|
+
opacity: 1;
|
|
595
|
+
}
|
|
596
|
+
100% {
|
|
597
|
+
transform: translateY(385%);
|
|
598
|
+
opacity: 0;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/* The handle: a glass bead on the beam with a ring of light round it. */
|
|
603
|
+
.compare-handle {
|
|
604
|
+
position: absolute;
|
|
605
|
+
top: 50%;
|
|
606
|
+
left: 0;
|
|
607
|
+
z-index: 30;
|
|
608
|
+
display: flex;
|
|
609
|
+
align-items: center;
|
|
610
|
+
justify-content: center;
|
|
611
|
+
width: 34px;
|
|
612
|
+
height: 34px;
|
|
613
|
+
border-radius: 999px;
|
|
614
|
+
transform: translate(-50%, -50%) scale(calc(1 + var(--cmp-energy) * 0.08));
|
|
615
|
+
color: #fff;
|
|
616
|
+
background: rgb(255 255 255 / 0.12);
|
|
617
|
+
backdrop-filter: blur(10px) saturate(1.4);
|
|
618
|
+
-webkit-backdrop-filter: blur(10px) saturate(1.4);
|
|
619
|
+
box-shadow:
|
|
620
|
+
inset 0 1px 0 rgb(255 255 255 / 0.35),
|
|
621
|
+
inset 0 0 0 1px rgb(255 255 255 / 0.18),
|
|
622
|
+
0 4px 16px rgb(0 0 0 / 0.35),
|
|
623
|
+
0 0 calc(8px + var(--cmp-energy) * 18px) color-mix(in srgb, var(--cmp-c2) 70%, transparent);
|
|
624
|
+
cursor: inherit;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
.compare-handle svg {
|
|
628
|
+
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.45));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.compare-handle__ring {
|
|
632
|
+
position: absolute;
|
|
633
|
+
inset: -1.5px;
|
|
634
|
+
border-radius: inherit;
|
|
635
|
+
padding: 1.5px;
|
|
636
|
+
background: conic-gradient(
|
|
637
|
+
from var(--cmp-angle),
|
|
638
|
+
transparent 0%,
|
|
639
|
+
var(--cmp-c1) 18%,
|
|
640
|
+
#fff 30%,
|
|
641
|
+
var(--cmp-c3) 42%,
|
|
642
|
+
transparent 58%,
|
|
643
|
+
transparent 100%
|
|
644
|
+
);
|
|
645
|
+
-webkit-mask:
|
|
646
|
+
linear-gradient(#000 0 0) content-box,
|
|
647
|
+
linear-gradient(#000 0 0);
|
|
648
|
+
-webkit-mask-composite: xor;
|
|
649
|
+
mask:
|
|
650
|
+
linear-gradient(#000 0 0) content-box,
|
|
651
|
+
linear-gradient(#000 0 0);
|
|
652
|
+
mask-composite: exclude;
|
|
653
|
+
opacity: calc(0.45 + var(--cmp-energy) * 0.55);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
@keyframes cmp-spin {
|
|
657
|
+
to {
|
|
658
|
+
--cmp-angle: 360deg;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
</style>
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare - a before/after slider split by a blade of light
|
|
3
|
+
*
|
|
4
|
+
* The seam is a beam: a white-hot core with a chromatic fringe, a glow
|
|
5
|
+
* that spills onto both sides, and pulses of light running along it. Moving
|
|
6
|
+
* the seam leaves a trail behind it that stretches with the speed of the
|
|
7
|
+
* gesture and settles when it stops.
|
|
8
|
+
*/
|
|
9
|
+
export declare const COMPARE_BEAM_COLORS: string[];
|
|
10
|
+
/** Three colours for the beam (left fringe, centre, right fringe), from one to many given. */
|
|
11
|
+
export declare function beamPalette(colors: string[] | undefined): [string, string, string];
|
|
12
|
+
/**
|
|
13
|
+
* One frame of the beam's motion: the trail follows the seam's velocity
|
|
14
|
+
* (px per frame, signed) and decays; the energy eases toward its target
|
|
15
|
+
* and is kicked up by speed.
|
|
16
|
+
*/
|
|
17
|
+
export declare function beamStep(trail: number, energy: number, deltaPx: number, target: number): {
|
|
18
|
+
trail: number;
|
|
19
|
+
energy: number;
|
|
20
|
+
};
|
|
21
|
+
/** Keyboard step for the slider, in %: arrows move 2, Shift+arrows 10, Home/End jump to the ends. */
|
|
22
|
+
export declare function keyStep(key: string, shift: boolean, current: number): number | null;
|
|
1
23
|
interface Props {
|
|
2
24
|
firstImage?: string;
|
|
3
25
|
secondImage?: string;
|
|
@@ -11,6 +33,10 @@ interface Props {
|
|
|
11
33
|
showHandlebar?: boolean;
|
|
12
34
|
autoplay?: boolean;
|
|
13
35
|
autoplayDuration?: number;
|
|
36
|
+
/** Beam colours: left fringe, centre, right fringe (one or more colours) */
|
|
37
|
+
beamColors?: string[];
|
|
38
|
+
/** Accessible name of the slider */
|
|
39
|
+
label?: string;
|
|
14
40
|
onpercentagechange?: (percentage: number) => void;
|
|
15
41
|
ondragstart?: () => void;
|
|
16
42
|
ondragend?: () => void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { render, cleanup } from "@testing-library/svelte";
|
|
1
|
+
import { render, cleanup, fireEvent } from "@testing-library/svelte";
|
|
2
2
|
import { afterEach, describe, it, expect } from "vitest";
|
|
3
|
-
import Compare from "./Compare.svelte";
|
|
3
|
+
import Compare, { beamPalette, beamStep, keyStep, COMPARE_BEAM_COLORS } from "./Compare.svelte";
|
|
4
4
|
describe("Compare", () => {
|
|
5
5
|
afterEach(cleanup);
|
|
6
6
|
it('renders a container with role="slider"', () => {
|
|
@@ -55,3 +55,83 @@ describe("Compare", () => {
|
|
|
55
55
|
expect(slider).toHaveAttribute("aria-valuemax", "100");
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
describe("Compare beam", () => {
|
|
59
|
+
afterEach(cleanup);
|
|
60
|
+
it("renders the beam layers and a glass handle", () => {
|
|
61
|
+
const { container } = render(Compare);
|
|
62
|
+
for (const part of ["glow", "trail", "fringe", "core", "pulse"]) {
|
|
63
|
+
expect(container.querySelector(`.compare-beam__${part}`)).toBeInTheDocument();
|
|
64
|
+
}
|
|
65
|
+
expect(container.querySelector(".compare-handle")).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
it("hides the handle when showHandlebar is false", () => {
|
|
68
|
+
const { container } = render(Compare, { props: { showHandlebar: false } });
|
|
69
|
+
expect(container.querySelector(".compare-handle")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
it("sets the beam colours as CSS variables", () => {
|
|
72
|
+
const { container } = render(Compare, { props: { beamColors: ["red", "lime", "blue"] } });
|
|
73
|
+
const root = container.querySelector('[role="slider"]');
|
|
74
|
+
expect(root.style.getPropertyValue("--cmp-c1")).toBe("red");
|
|
75
|
+
expect(root.style.getPropertyValue("--cmp-c2")).toBe("lime");
|
|
76
|
+
expect(root.style.getPropertyValue("--cmp-c3")).toBe("blue");
|
|
77
|
+
});
|
|
78
|
+
it("has an accessible name", () => {
|
|
79
|
+
const { container } = render(Compare, { props: { label: "Before and after" } });
|
|
80
|
+
expect(container.querySelector('[role="slider"]')).toHaveAttribute("aria-label", "Before and after");
|
|
81
|
+
});
|
|
82
|
+
it("moves with the arrow keys, Shift for big steps, Home/End for the ends", async () => {
|
|
83
|
+
const seen = [];
|
|
84
|
+
const { container } = render(Compare, {
|
|
85
|
+
props: { onpercentagechange: (p) => seen.push(p) },
|
|
86
|
+
});
|
|
87
|
+
const slider = container.querySelector('[role="slider"]');
|
|
88
|
+
await fireEvent.keyDown(slider, { key: "ArrowRight" });
|
|
89
|
+
expect(slider).toHaveAttribute("aria-valuenow", "52");
|
|
90
|
+
await fireEvent.keyDown(slider, { key: "ArrowLeft", shiftKey: true });
|
|
91
|
+
expect(slider).toHaveAttribute("aria-valuenow", "42");
|
|
92
|
+
await fireEvent.keyDown(slider, { key: "End" });
|
|
93
|
+
expect(slider).toHaveAttribute("aria-valuenow", "100");
|
|
94
|
+
await fireEvent.keyDown(slider, { key: "Home" });
|
|
95
|
+
expect(slider).toHaveAttribute("aria-valuenow", "0");
|
|
96
|
+
expect(seen).toEqual([52, 42, 100, 0]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe("beamPalette", () => {
|
|
100
|
+
it("falls back to the default colours", () => {
|
|
101
|
+
expect(beamPalette(undefined)).toEqual(COMPARE_BEAM_COLORS);
|
|
102
|
+
expect(beamPalette([])).toEqual(COMPARE_BEAM_COLORS);
|
|
103
|
+
});
|
|
104
|
+
it("spreads one, two or many colours over three slots", () => {
|
|
105
|
+
expect(beamPalette(["red"])).toEqual(["red", "red", "red"]);
|
|
106
|
+
expect(beamPalette(["red", "blue"])).toEqual(["red", "color-mix(in srgb, red, blue)", "blue"]);
|
|
107
|
+
expect(beamPalette(["a", "b", "c", "d", "e"])).toEqual(["a", "c", "e"]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe("beamStep", () => {
|
|
111
|
+
it("stretches the trail with speed, on the side of the motion", () => {
|
|
112
|
+
expect(beamStep(0, 0, 10, 0).trail).toBeGreaterThan(0);
|
|
113
|
+
expect(beamStep(0, 0, -10, 0).trail).toBeLessThan(0);
|
|
114
|
+
});
|
|
115
|
+
it("caps the trail and lets it settle to zero", () => {
|
|
116
|
+
expect(beamStep(0, 0, 1000, 0).trail).toBe(140);
|
|
117
|
+
let s = { trail: 60, energy: 0 };
|
|
118
|
+
for (let i = 0; i < 200; i++)
|
|
119
|
+
s = beamStep(s.trail, s.energy, 0, 0);
|
|
120
|
+
expect(s.trail).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
it("eases the energy toward its target, kicked up by speed", () => {
|
|
123
|
+
let s = { trail: 0, energy: 0 };
|
|
124
|
+
for (let i = 0; i < 200; i++)
|
|
125
|
+
s = beamStep(s.trail, s.energy, 0, 1);
|
|
126
|
+
expect(s.energy).toBeCloseTo(1, 2);
|
|
127
|
+
expect(beamStep(0, 0, 20, 0).energy).toBeGreaterThan(0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe("keyStep", () => {
|
|
131
|
+
it("maps keys to positions and clamps", () => {
|
|
132
|
+
expect(keyStep("ArrowRight", false, 99)).toBe(100);
|
|
133
|
+
expect(keyStep("ArrowLeft", true, 5)).toBe(0);
|
|
134
|
+
expect(keyStep("PageUp", false, 50)).toBe(60);
|
|
135
|
+
expect(keyStep("Enter", false, 50)).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|