@smicolon/fasla-ui 0.2.3 → 0.3.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 +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +91 -23
- package/dist/registry/index.js +31 -2
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +4 -1
- package/registry/blocks/app-shell/AppShell.tsx +1 -1
- package/registry/blocks/data-table/DataTable.test.tsx +61 -0
- package/registry/blocks/data-table/DataTable.tsx +53 -16
- package/registry/blocks/data-table/index.ts +1 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +12 -0
- package/registry/blocks/empty-state/EmptyState.tsx +22 -31
- package/registry/blocks/form-section/FormSection.tsx +1 -1
- package/registry/blocks/navbar/Navbar.test.tsx +12 -0
- package/registry/blocks/navbar/Navbar.tsx +8 -23
- package/registry/blocks/page-header/PageHeader.tsx +5 -2
- package/registry/blocks/sidebar/Sidebar.tsx +9 -16
- package/registry/blocks/stats-card/StatsCard.tsx +9 -27
- package/registry/effects/animated-gradient/animated-gradient.tsx +23 -8
- package/registry/effects/border-beam/border-beam.tsx +77 -53
- package/registry/effects/effects.test.tsx +266 -0
- package/registry/effects/glow-card/glow-card.tsx +68 -43
- package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
- package/registry/effects/spotlight/spotlight.tsx +73 -29
- package/registry/effects/text-reveal/index.ts +1 -0
- package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
- package/registry/effects/text-reveal/text-reveal.tsx +68 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +41 -22
- package/registry/text-props-undefined.test.tsx +73 -0
- package/registry/ui/avatar/avatar.test.tsx +316 -0
- package/registry/ui/avatar/avatar.tsx +235 -108
- package/registry/ui/avatar/index.ts +1 -1
- package/registry/ui/badge/badge.test.tsx +267 -32
- package/registry/ui/badge/badge.tsx +233 -21
- package/registry/ui/button/button.test.tsx +6 -0
- package/registry/ui/button/button.tsx +6 -23
- package/registry/ui/card/card.tsx +1 -1
- package/registry/ui/checkbox/checkbox.tsx +13 -26
- package/registry/ui/combobox/combobox.test.tsx +43 -0
- package/registry/ui/combobox/combobox.tsx +27 -55
- package/registry/ui/input/input.test.tsx +2 -2
- package/registry/ui/input/input.tsx +4 -4
- package/registry/ui/radio/index.ts +1 -0
- package/registry/ui/radio/radio.test.tsx +248 -0
- package/registry/ui/radio/radio.tsx +208 -0
- package/registry/ui/select/select.tsx +4 -14
- package/registry/ui/skeleton/skeleton.tsx +1 -1
- package/registry/ui/status-indicator/index.ts +6 -0
- package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
- package/registry/ui/status-indicator/status-indicator.tsx +87 -0
- package/registry/ui/switch/index.ts +1 -1
- package/registry/ui/switch/switch.test.tsx +323 -0
- package/registry/ui/switch/switch.tsx +254 -57
- package/registry/ui/textarea/textarea.tsx +1 -1
- package/registry.json +435 -78
- package/tailwind-preset.ts +143 -0
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import { motion, useReducedMotion } from "framer-motion"
|
|
4
|
+
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "framer-motion"
|
|
5
5
|
import { cn } from "../../../src/lib/utils"
|
|
6
6
|
|
|
7
|
+
/** The value, or the fallback when it is missing or blank (a cleared control, say). */
|
|
8
|
+
const filled = (value: string | undefined, fallback: string) =>
|
|
9
|
+
value && value.trim() ? value : fallback
|
|
10
|
+
|
|
7
11
|
export interface BorderBeamProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
12
|
/** Duration of the animation in seconds */
|
|
9
13
|
duration?: number
|
|
@@ -24,62 +28,68 @@ export interface BorderBeamProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
24
28
|
export function BorderBeam({
|
|
25
29
|
duration = 4,
|
|
26
30
|
borderWidth = 2,
|
|
27
|
-
colorFrom
|
|
28
|
-
colorTo
|
|
31
|
+
colorFrom: colorFromProp,
|
|
32
|
+
colorTo: colorToProp,
|
|
29
33
|
delay = 0,
|
|
30
34
|
className,
|
|
31
35
|
children,
|
|
32
36
|
...props
|
|
33
37
|
}: BorderBeamProps) {
|
|
38
|
+
const colorFrom = filled(colorFromProp, "var(--primary)")
|
|
39
|
+
const colorTo = filled(colorToProp, "transparent")
|
|
34
40
|
const prefersReducedMotion = useReducedMotion()
|
|
35
41
|
|
|
42
|
+
// The beam is a conic gradient whose start angle turns, so it runs round
|
|
43
|
+
// the border of any shape; rotating a layer only works for a square.
|
|
44
|
+
const angle = useMotionValue(0)
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
if (prefersReducedMotion) return
|
|
47
|
+
const controls = animate(angle, 360, {
|
|
48
|
+
duration,
|
|
49
|
+
delay,
|
|
50
|
+
ease: "linear",
|
|
51
|
+
repeat: Infinity,
|
|
52
|
+
})
|
|
53
|
+
return () => controls.stop()
|
|
54
|
+
}, [angle, duration, delay, prefersReducedMotion])
|
|
55
|
+
const background = useTransform(
|
|
56
|
+
angle,
|
|
57
|
+
(a) => `conic-gradient(from ${a}deg, transparent 0deg 270deg, ${colorTo} 270deg, ${colorFrom} 360deg)`
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
/** Paints only a borderWidth ring: the content box is masked out. */
|
|
61
|
+
const ring: React.CSSProperties = {
|
|
62
|
+
padding: borderWidth,
|
|
63
|
+
mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
|
64
|
+
maskComposite: "exclude",
|
|
65
|
+
WebkitMask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
|
66
|
+
WebkitMaskComposite: "xor",
|
|
67
|
+
}
|
|
68
|
+
|
|
36
69
|
return (
|
|
37
70
|
<div
|
|
38
|
-
className={cn("relative overflow-hidden rounded-lg", className)}
|
|
71
|
+
className={cn("relative isolate overflow-hidden rounded-lg", className)}
|
|
39
72
|
{...props}
|
|
40
73
|
>
|
|
41
|
-
{/*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
style={{
|
|
46
|
-
padding: borderWidth,
|
|
47
|
-
background: `linear-gradient(90deg, ${colorFrom}, ${colorTo})`,
|
|
48
|
-
mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
|
49
|
-
maskComposite: "exclude",
|
|
50
|
-
WebkitMaskComposite: "xor",
|
|
51
|
-
}}
|
|
52
|
-
animate={{
|
|
53
|
-
rotate: [0, 360],
|
|
54
|
-
}}
|
|
55
|
-
transition={{
|
|
56
|
-
duration,
|
|
57
|
-
delay,
|
|
58
|
-
repeat: Infinity,
|
|
59
|
-
ease: "linear",
|
|
60
|
-
}}
|
|
61
|
-
/>
|
|
62
|
-
)}
|
|
74
|
+
{/* Content */}
|
|
75
|
+
<div className="relative rounded-[inherit] bg-background">
|
|
76
|
+
{children}
|
|
77
|
+
</div>
|
|
63
78
|
|
|
64
|
-
{/*
|
|
65
|
-
{prefersReducedMotion
|
|
79
|
+
{/* The ring sits above the content, over its outer edge, so the content cannot cover it */}
|
|
80
|
+
{prefersReducedMotion ? (
|
|
66
81
|
<div
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}}
|
|
82
|
+
aria-hidden="true"
|
|
83
|
+
className="pointer-events-none absolute inset-0 z-10 rounded-[inherit]"
|
|
84
|
+
style={{ ...ring, background: colorFrom, opacity: 0.5 }}
|
|
85
|
+
/>
|
|
86
|
+
) : (
|
|
87
|
+
<motion.div
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
className="pointer-events-none absolute inset-0 z-10 rounded-[inherit]"
|
|
90
|
+
style={{ ...ring, background }}
|
|
76
91
|
/>
|
|
77
92
|
)}
|
|
78
|
-
|
|
79
|
-
{/* Content */}
|
|
80
|
-
<div className="relative z-10 rounded-[inherit] bg-background">
|
|
81
|
-
{children}
|
|
82
|
-
</div>
|
|
83
93
|
</div>
|
|
84
94
|
)
|
|
85
95
|
}
|
|
@@ -93,40 +103,54 @@ export interface GlowingBorderProps extends React.HTMLAttributes<HTMLDivElement>
|
|
|
93
103
|
intensity?: "sm" | "md" | "lg"
|
|
94
104
|
}
|
|
95
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The ring's width and the blur of the glow that hugs it, in px. The glow has
|
|
108
|
+
* no spread, so it stays close to the edge instead of casting a shadow.
|
|
109
|
+
*/
|
|
96
110
|
const intensityValues = {
|
|
97
|
-
sm:
|
|
98
|
-
md:
|
|
99
|
-
lg:
|
|
111
|
+
sm: { ring: 1, glow: 4 },
|
|
112
|
+
md: { ring: 1, glow: 8 },
|
|
113
|
+
lg: { ring: 2, glow: 12 },
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
/**
|
|
103
|
-
* Static glowing border
|
|
117
|
+
* Static glowing border: a coloured ring on the element's edge with a soft,
|
|
118
|
+
* tight glow around it. The ring is the border, so the content needs none.
|
|
104
119
|
*/
|
|
105
120
|
export function GlowingBorder({
|
|
106
|
-
glowColor
|
|
107
|
-
borderRadius
|
|
121
|
+
glowColor: glowColorProp,
|
|
122
|
+
borderRadius: borderRadiusProp,
|
|
108
123
|
intensity = "md",
|
|
109
124
|
className,
|
|
110
125
|
children,
|
|
111
126
|
...props
|
|
112
127
|
}: GlowingBorderProps) {
|
|
128
|
+
const glowColor = filled(glowColorProp, "color-mix(in oklch, var(--primary) 50%, transparent)")
|
|
129
|
+
const borderRadius = filled(borderRadiusProp, "0.5rem")
|
|
130
|
+
const { ring, glow } = intensityValues[intensity]
|
|
113
131
|
return (
|
|
114
132
|
<div
|
|
115
|
-
className={cn("relative", className)}
|
|
133
|
+
className={cn("relative isolate", className)}
|
|
116
134
|
style={{
|
|
117
135
|
borderRadius,
|
|
118
136
|
}}
|
|
119
137
|
{...props}
|
|
120
138
|
>
|
|
121
|
-
{
|
|
139
|
+
{children}
|
|
140
|
+
{/*
|
|
141
|
+
Above the content, so a card's own background cannot cover it. The inset
|
|
142
|
+
shadow draws the ring just inside the edge, over any neutral border the
|
|
143
|
+
content has; the outer ones are the glow.
|
|
144
|
+
*/}
|
|
122
145
|
<div
|
|
123
|
-
|
|
146
|
+
aria-hidden="true"
|
|
147
|
+
className="pointer-events-none absolute inset-0 z-10"
|
|
124
148
|
style={{
|
|
125
149
|
borderRadius,
|
|
126
|
-
|
|
150
|
+
// Two stacked blurs, no spread: a denser glow at the edge that fades fast.
|
|
151
|
+
boxShadow: `inset 0 0 0 ${ring}px ${glowColor}, 0 0 ${glow / 2}px 0 ${glowColor}, 0 0 ${glow}px 0 ${glowColor}`,
|
|
127
152
|
}}
|
|
128
153
|
/>
|
|
129
|
-
{children}
|
|
130
154
|
</div>
|
|
131
155
|
)
|
|
132
156
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest"
|
|
2
|
+
import { act, fireEvent, render, waitFor } from "@testing-library/react"
|
|
3
|
+
import { ShimmerButton } from "./shimmer-button/shimmer-button"
|
|
4
|
+
import { AnimatedGradient } from "./animated-gradient/animated-gradient"
|
|
5
|
+
import { TypewriterText } from "./typewriter-text/typewriter-text"
|
|
6
|
+
import { Spotlight, SpotlightCard } from "./spotlight/spotlight"
|
|
7
|
+
import { GlowCard } from "./glow-card/glow-card"
|
|
8
|
+
import { BorderBeam, GlowingBorder } from "./border-beam/border-beam"
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe("ShimmerButton", () => {
|
|
15
|
+
it("draws the sheen in primary-foreground by default, so it shows on the primary background", () => {
|
|
16
|
+
const { getByRole } = render(<ShimmerButton>Shop</ShimmerButton>)
|
|
17
|
+
expect(getByRole("button").style.getPropertyValue("--shimmer-color")).toContain("--primary-foreground")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("sizes the sheen with shimmerSize, full width by default", () => {
|
|
21
|
+
const { getByRole, rerender } = render(<ShimmerButton>Shop</ShimmerButton>)
|
|
22
|
+
expect(getByRole("button").style.getPropertyValue("--shimmer-size")).toBe("100%")
|
|
23
|
+
rerender(<ShimmerButton shimmerSize="3rem">Shop</ShimmerButton>)
|
|
24
|
+
const button = getByRole("button")
|
|
25
|
+
expect(button.style.getPropertyValue("--shimmer-size")).toBe("3rem")
|
|
26
|
+
const sheen = Array.from(button.querySelectorAll<HTMLElement>("div")).find((el) => el.style.width)
|
|
27
|
+
expect(sheen?.style.width).toBe("var(--shimmer-size)")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("ships its own keyframes, so it needs no Tailwind config", () => {
|
|
31
|
+
const { container } = render(<ShimmerButton>Shop</ShimmerButton>)
|
|
32
|
+
expect(container.querySelector("style")?.textContent).toContain("@keyframes fasla-shimmer")
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe("TypewriterText", () => {
|
|
37
|
+
it("does not restart when the parent passes a new inline onComplete", () => {
|
|
38
|
+
vi.useFakeTimers()
|
|
39
|
+
const { container, rerender } = render(<TypewriterText text="Hello" speed={10} onComplete={() => {}} />)
|
|
40
|
+
act(() => {
|
|
41
|
+
vi.advanceTimersByTime(35)
|
|
42
|
+
})
|
|
43
|
+
const typed = container.textContent
|
|
44
|
+
rerender(<TypewriterText text="Hello" speed={10} onComplete={() => {}} />)
|
|
45
|
+
expect(container.textContent).toBe(typed)
|
|
46
|
+
act(() => {
|
|
47
|
+
vi.advanceTimersByTime(100)
|
|
48
|
+
})
|
|
49
|
+
expect(container.textContent).toBe("Hello|")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("calls the latest onComplete once the text is typed", () => {
|
|
53
|
+
vi.useFakeTimers()
|
|
54
|
+
const first = vi.fn()
|
|
55
|
+
const latest = vi.fn()
|
|
56
|
+
const { rerender } = render(<TypewriterText text="Hi" speed={10} onComplete={first} />)
|
|
57
|
+
rerender(<TypewriterText text="Hi" speed={10} onComplete={latest} />)
|
|
58
|
+
act(() => {
|
|
59
|
+
vi.advanceTimersByTime(100)
|
|
60
|
+
})
|
|
61
|
+
expect(first).not.toHaveBeenCalled()
|
|
62
|
+
expect(latest).toHaveBeenCalledTimes(1)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe("SpotlightCard", () => {
|
|
67
|
+
it("centres the light on the pointer", () => {
|
|
68
|
+
const { container } = render(<SpotlightCard spotlightSize={300}>Card</SpotlightCard>)
|
|
69
|
+
fireEvent.mouseEnter(container.firstElementChild!)
|
|
70
|
+
const light = container.querySelector<HTMLElement>(".rounded-full")!
|
|
71
|
+
expect(light.style.left).toBe("-150px")
|
|
72
|
+
expect(light.style.top).toBe("-150px")
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("GlowCard", () => {
|
|
77
|
+
it("puts the glow between the card surface and the content, not under an opaque layer", () => {
|
|
78
|
+
const { container, getByText } = render(<GlowCard hoverOnly={false}>Card</GlowCard>)
|
|
79
|
+
const root = container.firstElementChild as HTMLElement
|
|
80
|
+
expect(root.className).toContain("bg-card")
|
|
81
|
+
const content = getByText("Card")
|
|
82
|
+
expect(content.className).not.toContain("bg-")
|
|
83
|
+
expect(root.firstElementChild?.className).toContain("opacity-100")
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("BorderBeam", () => {
|
|
88
|
+
it("paints the beam above the content", () => {
|
|
89
|
+
const { container, getByText } = render(<BorderBeam>Card</BorderBeam>)
|
|
90
|
+
const root = container.firstElementChild as HTMLElement
|
|
91
|
+
const beam = root.lastElementChild as HTMLElement
|
|
92
|
+
expect(root.firstElementChild).toBe(getByText("Card"))
|
|
93
|
+
expect(beam.className).toContain("z-10")
|
|
94
|
+
expect(beam.getAttribute("aria-hidden")).toBe("true")
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("GlowingBorder", () => {
|
|
99
|
+
it("draws a coloured ring above the content with a tight glow and no spread", () => {
|
|
100
|
+
const { container, getByText } = render(
|
|
101
|
+
<GlowingBorder glowColor="red" intensity="lg">
|
|
102
|
+
Card
|
|
103
|
+
</GlowingBorder>
|
|
104
|
+
)
|
|
105
|
+
const root = container.firstElementChild as HTMLElement
|
|
106
|
+
const ring = root.lastElementChild as HTMLElement
|
|
107
|
+
expect(root.firstChild?.textContent).toBe(getByText("Card").textContent)
|
|
108
|
+
expect(ring.className).toContain("z-10")
|
|
109
|
+
const shadow = ring.style.boxShadow
|
|
110
|
+
expect(shadow).toContain("inset 0 0 0 2px red")
|
|
111
|
+
// Every outer layer is a blur with zero spread: "0 0 <blur>px 0".
|
|
112
|
+
for (const layer of shadow.split(/,(?![^(]*\))/).slice(1)) {
|
|
113
|
+
expect(layer.trim()).toMatch(/^0 0 \d+px 0 red$/)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe("Spotlight", () => {
|
|
119
|
+
it("keeps the resting light centred when the container resizes, until the pointer moves", async () => {
|
|
120
|
+
let resize: () => void = () => {}
|
|
121
|
+
const saved = globalThis.ResizeObserver
|
|
122
|
+
globalThis.ResizeObserver = class {
|
|
123
|
+
constructor(callback: () => void) {
|
|
124
|
+
resize = callback
|
|
125
|
+
}
|
|
126
|
+
observe() {}
|
|
127
|
+
disconnect() {}
|
|
128
|
+
} as unknown as typeof ResizeObserver
|
|
129
|
+
let width = 400
|
|
130
|
+
const widthSpy = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(() => width)
|
|
131
|
+
const heightSpy = vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(() => 200)
|
|
132
|
+
try {
|
|
133
|
+
const { container } = render(<Spotlight size={100}>Panel</Spotlight>)
|
|
134
|
+
const light = container.querySelector<HTMLElement>(".rounded-full")!
|
|
135
|
+
// framer writes translateX(<n>px); jsdom has no DOMMatrix to parse it.
|
|
136
|
+
const x = () => Number(/translateX\((-?[\d.]+)px\)/.exec(light.style.transform)?.[1] ?? 0)
|
|
137
|
+
// framer writes the transform on its next frame, hence waitFor.
|
|
138
|
+
await waitFor(() => expect(x()).toBe(200)) // centre of 400
|
|
139
|
+
width = 800
|
|
140
|
+
act(() => resize())
|
|
141
|
+
await waitFor(() => expect(x()).toBe(400)) // centre of 800
|
|
142
|
+
fireEvent.mouseMove(container.firstElementChild!, { clientX: 10, clientY: 10 })
|
|
143
|
+
width = 1000
|
|
144
|
+
act(() => resize())
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
146
|
+
expect(x()).not.toBe(500) // the pointer has taken over from the centre
|
|
147
|
+
} finally {
|
|
148
|
+
widthSpy.mockRestore()
|
|
149
|
+
heightSpy.mockRestore()
|
|
150
|
+
globalThis.ResizeObserver = saved
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A blank string prop (what a cleared Storybook control or an empty form field
|
|
157
|
+
* passes) falls back to the default instead of emptying the style it feeds.
|
|
158
|
+
*/
|
|
159
|
+
describe("blank string props fall back to their defaults", () => {
|
|
160
|
+
it("ShimmerButton: every string prop", () => {
|
|
161
|
+
const { getByRole } = render(
|
|
162
|
+
<ShimmerButton shimmerColor="" shimmerSize=" " shimmerDuration="" borderRadius="" background="">
|
|
163
|
+
Shop
|
|
164
|
+
</ShimmerButton>
|
|
165
|
+
)
|
|
166
|
+
const style = getByRole("button").style
|
|
167
|
+
expect(style.getPropertyValue("--shimmer-color")).toContain("--primary-foreground")
|
|
168
|
+
expect(style.getPropertyValue("--shimmer-size")).toBe("100%")
|
|
169
|
+
expect(style.getPropertyValue("--shimmer-duration")).toBe("2s")
|
|
170
|
+
expect(style.getPropertyValue("--border-radius")).toBe("0.5rem")
|
|
171
|
+
expect(style.getPropertyValue("--background")).toBe("var(--primary)")
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("ShimmerButton: no stacked motion-safe hover variant, anywhere in the source", async () => {
|
|
175
|
+
const { getByRole } = render(<ShimmerButton>Shop</ShimmerButton>)
|
|
176
|
+
expect(getByRole("button").className).toContain("hover:scale-105")
|
|
177
|
+
// Tailwind generates classes it finds in comments, so check the file itself.
|
|
178
|
+
const { readFileSync } = await import("node:fs")
|
|
179
|
+
const { resolve } = await import("node:path")
|
|
180
|
+
const source = readFileSync(resolve(__dirname, "shimmer-button/shimmer-button.tsx"), "utf8")
|
|
181
|
+
expect(source).not.toMatch(/motion-(safe|reduce):(hover|focus)/)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("BorderBeam and GlowingBorder", async () => {
|
|
185
|
+
const beam = render(<BorderBeam colorFrom="" colorTo="">Card</BorderBeam>)
|
|
186
|
+
const ring = beam.container.firstElementChild!.lastElementChild as HTMLElement
|
|
187
|
+
await waitFor(() => expect(ring.style.background).toContain("var(--primary) 360deg"))
|
|
188
|
+
expect(ring.style.background).toContain("transparent 270deg")
|
|
189
|
+
|
|
190
|
+
const glow = render(<GlowingBorder glowColor="" borderRadius="">Card</GlowingBorder>)
|
|
191
|
+
const root = glow.container.firstElementChild as HTMLElement
|
|
192
|
+
expect(root.style.borderRadius).toBe("0.5rem")
|
|
193
|
+
expect((root.lastElementChild as HTMLElement).style.boxShadow).toContain("var(--primary)")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("GlowCard and GlowContainer", () => {
|
|
197
|
+
const card = render(<GlowCard glowColor="" hoverOnly={false}>Card</GlowCard>)
|
|
198
|
+
const layer = card.container.firstElementChild!.firstElementChild as HTMLElement
|
|
199
|
+
expect(layer.style.background).toContain("var(--primary)")
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("Spotlight and SpotlightCard", () => {
|
|
203
|
+
const spot = render(<Spotlight color="">Panel</Spotlight>)
|
|
204
|
+
expect(spot.container.querySelector<HTMLElement>(".rounded-full")!.style.background).toContain("var(--primary)")
|
|
205
|
+
const card = render(<SpotlightCard spotlightColor="">Card</SpotlightCard>)
|
|
206
|
+
fireEvent.mouseEnter(card.container.firstElementChild!)
|
|
207
|
+
expect(card.container.querySelector<HTMLElement>(".rounded-full")!.style.background).toContain("var(--primary)")
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("AnimatedGradient: blank colours are dropped, an empty list uses the default", () => {
|
|
211
|
+
const empty = render(<AnimatedGradient colors={[]} />)
|
|
212
|
+
expect(empty.container.querySelectorAll(".rounded-full")).toHaveLength(4)
|
|
213
|
+
const some = render(<AnimatedGradient colors={["", "red", " "]} />)
|
|
214
|
+
const blobs = some.container.querySelectorAll<HTMLElement>(".rounded-full")
|
|
215
|
+
expect(blobs).toHaveLength(1)
|
|
216
|
+
expect(blobs[0]!.style.background).toBe("red")
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("TypewriterText: a blank cursorChar keeps the default cursor", () => {
|
|
220
|
+
const { container } = render(<TypewriterText text="Hi" cursorChar="" />)
|
|
221
|
+
expect(container.querySelector("[aria-hidden]")?.textContent).toBe("|")
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe("Spotlight size", () => {
|
|
226
|
+
// The light's centre is its translate plus its left offset plus half its size.
|
|
227
|
+
const centreOf = (light: HTMLElement) => {
|
|
228
|
+
const translate = Number(/translateX\((-?[\d.]+)px\)/.exec(light.style.transform)?.[1] ?? 0)
|
|
229
|
+
return translate + parseFloat(light.style.left) + light.offsetWidth / 2
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
it("keeps the light centred on the pointer when size changes after the pointer moved", async () => {
|
|
233
|
+
const widthSpy = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
234
|
+
return this.classList.contains("rounded-full") ? parseFloat(this.style.width) : 400
|
|
235
|
+
})
|
|
236
|
+
try {
|
|
237
|
+
const { container, rerender } = render(<Spotlight size={100}>Panel</Spotlight>)
|
|
238
|
+
const root = container.firstElementChild!
|
|
239
|
+
fireEvent.mouseMove(root, { clientX: 120, clientY: 60 })
|
|
240
|
+
const light = () => container.querySelector<HTMLElement>(".rounded-full")!
|
|
241
|
+
await waitFor(() => expect(Math.round(centreOf(light()))).toBe(120))
|
|
242
|
+
rerender(<Spotlight size={300}>Panel</Spotlight>)
|
|
243
|
+
await waitFor(() => expect(light().style.width).toBe("300px"))
|
|
244
|
+
expect(Math.round(centreOf(light()))).toBe(120)
|
|
245
|
+
expect(light().style.left).toBe("-150px")
|
|
246
|
+
} finally {
|
|
247
|
+
widthSpy.mockRestore()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("keeps the resting light at the container's centre when size changes", async () => {
|
|
252
|
+
const widthSpy = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
253
|
+
return this.classList.contains("rounded-full") ? parseFloat(this.style.width) : 400
|
|
254
|
+
})
|
|
255
|
+
try {
|
|
256
|
+
const { container, rerender } = render(<Spotlight size={100}>Panel</Spotlight>)
|
|
257
|
+
const light = () => container.querySelector<HTMLElement>(".rounded-full")!
|
|
258
|
+
await waitFor(() => expect(Math.round(centreOf(light()))).toBe(200))
|
|
259
|
+
rerender(<Spotlight size={300}>Panel</Spotlight>)
|
|
260
|
+
await waitFor(() => expect(light().style.width).toBe("300px"))
|
|
261
|
+
expect(Math.round(centreOf(light()))).toBe(200)
|
|
262
|
+
} finally {
|
|
263
|
+
widthSpy.mockRestore()
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
})
|
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { cn } from "../../../src/lib/utils"
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Whether the user asks for reduced motion. It starts false, so the server and
|
|
8
|
+
* the first client render agree, then follows the setting as it changes.
|
|
9
|
+
*/
|
|
10
|
+
function usePrefersReducedMotion() {
|
|
11
|
+
const [reduced, setReduced] = React.useState(false)
|
|
12
|
+
React.useEffect(() => {
|
|
13
|
+
if (typeof window.matchMedia !== "function") return
|
|
14
|
+
const query = window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
15
|
+
setReduced(query.matches)
|
|
16
|
+
const onChange = () => setReduced(query.matches)
|
|
17
|
+
query.addEventListener("change", onChange)
|
|
18
|
+
return () => query.removeEventListener("change", onChange)
|
|
19
|
+
}, [])
|
|
20
|
+
return reduced
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The value, or the fallback when it is missing or blank (a cleared control, say). */
|
|
24
|
+
const filled = (value: string | undefined, fallback: string) =>
|
|
25
|
+
value && value.trim() ? value : fallback
|
|
26
|
+
|
|
6
27
|
export interface GlowCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
28
|
/** Glow color (any CSS color) */
|
|
8
29
|
glowColor?: string
|
|
@@ -16,7 +37,7 @@ export interface GlowCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
16
37
|
}
|
|
17
38
|
|
|
18
39
|
export function GlowCard({
|
|
19
|
-
glowColor
|
|
40
|
+
glowColor: glowColorProp,
|
|
20
41
|
glowIntensity = 60,
|
|
21
42
|
followMouse = false,
|
|
22
43
|
hoverOnly = true,
|
|
@@ -24,29 +45,30 @@ export function GlowCard({
|
|
|
24
45
|
children,
|
|
25
46
|
...props
|
|
26
47
|
}: GlowCardProps) {
|
|
48
|
+
const glowColor = filled(glowColorProp, "var(--primary)")
|
|
27
49
|
const cardRef = React.useRef<HTMLDivElement>(null)
|
|
28
|
-
|
|
50
|
+
// Pointer position in px from the card's top-left; null keeps the glow centred.
|
|
51
|
+
const [mousePosition, setMousePosition] = React.useState<{ x: number; y: number } | null>(null)
|
|
29
52
|
const [isHovering, setIsHovering] = React.useState(false)
|
|
30
53
|
|
|
31
|
-
|
|
32
|
-
const prefersReducedMotion = React.useMemo(() => {
|
|
33
|
-
if (typeof window === "undefined") return false
|
|
34
|
-
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
35
|
-
}, [])
|
|
54
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
36
55
|
|
|
37
56
|
const handleMouseMove = React.useCallback(
|
|
38
57
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
39
58
|
if (!followMouse || !cardRef.current || prefersReducedMotion) return
|
|
40
59
|
|
|
41
60
|
const rect = cardRef.current.getBoundingClientRect()
|
|
42
|
-
|
|
43
|
-
const y = ((e.clientY - rect.top) / rect.height) * 100
|
|
44
|
-
setMousePosition({ x, y })
|
|
61
|
+
setMousePosition({ x: e.clientX - rect.left, y: e.clientY - rect.top })
|
|
45
62
|
},
|
|
46
63
|
[followMouse, prefersReducedMotion]
|
|
47
64
|
)
|
|
48
65
|
|
|
49
66
|
const showGlow = prefersReducedMotion ? false : hoverOnly ? isHovering : true
|
|
67
|
+
// The glow layer overhangs the card by the blur radius on every side, so the
|
|
68
|
+
// blur has colour to spread instead of fading into the card's clipped edge.
|
|
69
|
+
const overhang = glowIntensity
|
|
70
|
+
const glowAt = mousePosition ? `${mousePosition.x + overhang}px ${mousePosition.y + overhang}px` : "50% 50%"
|
|
71
|
+
const edgeAt = mousePosition ? `${mousePosition.x}px ${mousePosition.y}px` : "50% 50%"
|
|
50
72
|
|
|
51
73
|
return (
|
|
52
74
|
<div
|
|
@@ -54,45 +76,45 @@ export function GlowCard({
|
|
|
54
76
|
onMouseMove={handleMouseMove}
|
|
55
77
|
onMouseEnter={() => setIsHovering(true)}
|
|
56
78
|
onMouseLeave={() => setIsHovering(false)}
|
|
57
|
-
className={cn("relative overflow-hidden rounded-xl", className)}
|
|
79
|
+
className={cn("relative isolate overflow-hidden rounded-xl border bg-card", className)}
|
|
58
80
|
{...props}
|
|
59
81
|
>
|
|
60
|
-
{/*
|
|
82
|
+
{/*
|
|
83
|
+
Glow: above the card surface and below the content, so it shows through
|
|
84
|
+
it. The colour is softened so text on top keeps its contrast.
|
|
85
|
+
*/}
|
|
61
86
|
<div
|
|
87
|
+
aria-hidden="true"
|
|
62
88
|
className={cn(
|
|
63
|
-
"pointer-events-none absolute
|
|
89
|
+
"pointer-events-none absolute opacity-0 transition-opacity duration-300",
|
|
64
90
|
showGlow && "opacity-100"
|
|
65
91
|
)}
|
|
66
92
|
style={{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
: `radial-gradient(circle at 50% 50%, ${glowColor}, transparent 70%)`,
|
|
93
|
+
inset: -overhang,
|
|
94
|
+
background: `radial-gradient(circle at ${glowAt}, color-mix(in oklch, ${glowColor} 60%, transparent), transparent 40%)`,
|
|
70
95
|
filter: `blur(${glowIntensity}px)`,
|
|
71
96
|
}}
|
|
72
97
|
/>
|
|
73
98
|
|
|
74
|
-
{/*
|
|
99
|
+
{/* Card content */}
|
|
100
|
+
<div className="relative">{children}</div>
|
|
101
|
+
|
|
102
|
+
{/* Edge glow: a 1px ring above the content, just inside the border */}
|
|
75
103
|
<div
|
|
104
|
+
aria-hidden="true"
|
|
76
105
|
className={cn(
|
|
77
|
-
"pointer-events-none absolute inset-0 rounded-
|
|
78
|
-
showGlow && "opacity-
|
|
106
|
+
"pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-300",
|
|
107
|
+
showGlow && "opacity-100"
|
|
79
108
|
)}
|
|
80
109
|
style={{
|
|
81
|
-
background:
|
|
82
|
-
? `radial-gradient(circle at ${mousePosition.x}% ${mousePosition.y}%, ${glowColor}, transparent 50%)`
|
|
83
|
-
: undefined,
|
|
110
|
+
background: `radial-gradient(circle at ${edgeAt}, ${glowColor}, transparent 50%)`,
|
|
84
111
|
padding: "1px",
|
|
85
|
-
WebkitMask:
|
|
86
|
-
"linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
|
112
|
+
WebkitMask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
|
87
113
|
WebkitMaskComposite: "xor",
|
|
114
|
+
mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
|
88
115
|
maskComposite: "exclude",
|
|
89
116
|
}}
|
|
90
117
|
/>
|
|
91
|
-
|
|
92
|
-
{/* Card content */}
|
|
93
|
-
<div className="relative z-10 rounded-xl border bg-card">
|
|
94
|
-
{children}
|
|
95
|
-
</div>
|
|
96
118
|
</div>
|
|
97
119
|
)
|
|
98
120
|
}
|
|
@@ -106,29 +128,18 @@ export interface GlowContainerProps extends React.HTMLAttributes<HTMLDivElement>
|
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
export function GlowContainer({
|
|
109
|
-
glowColor
|
|
131
|
+
glowColor: glowColorProp,
|
|
110
132
|
duration = 3,
|
|
111
133
|
className,
|
|
112
134
|
children,
|
|
113
135
|
...props
|
|
114
136
|
}: GlowContainerProps) {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
118
|
-
}, [])
|
|
137
|
+
const glowColor = filled(glowColorProp, "var(--primary)")
|
|
138
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
119
139
|
|
|
120
140
|
return (
|
|
121
141
|
<div
|
|
122
142
|
className={cn("relative overflow-hidden rounded-xl p-px", className)}
|
|
123
|
-
style={
|
|
124
|
-
!prefersReducedMotion
|
|
125
|
-
? {
|
|
126
|
-
background: `linear-gradient(90deg, ${glowColor}, transparent, ${glowColor})`,
|
|
127
|
-
backgroundSize: "200% 100%",
|
|
128
|
-
animation: `glow-slide ${duration}s linear infinite`,
|
|
129
|
-
}
|
|
130
|
-
: { background: glowColor }
|
|
131
|
-
}
|
|
132
143
|
{...props}
|
|
133
144
|
>
|
|
134
145
|
<style dangerouslySetInnerHTML={{ __html: `
|
|
@@ -137,6 +148,20 @@ export function GlowContainer({
|
|
|
137
148
|
100% { background-position: -200% 0; }
|
|
138
149
|
}
|
|
139
150
|
` }} />
|
|
151
|
+
{/* The glow is its own layer so it can mirror in RTL and slide in the reading direction */}
|
|
152
|
+
<div
|
|
153
|
+
aria-hidden="true"
|
|
154
|
+
className="pointer-events-none absolute inset-0 rtl:-scale-x-100"
|
|
155
|
+
style={
|
|
156
|
+
!prefersReducedMotion
|
|
157
|
+
? {
|
|
158
|
+
background: `linear-gradient(90deg, ${glowColor}, transparent, ${glowColor})`,
|
|
159
|
+
backgroundSize: "200% 100%",
|
|
160
|
+
animation: `glow-slide ${duration}s linear infinite`,
|
|
161
|
+
}
|
|
162
|
+
: { background: glowColor }
|
|
163
|
+
}
|
|
164
|
+
/>
|
|
140
165
|
<div className="relative z-10 rounded-[11px] bg-background">
|
|
141
166
|
{children}
|
|
142
167
|
</div>
|