@smicolon/fasla-ui 0.2.4 → 0.4.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 +44 -20
- package/dist/index.cjs +319 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +92 -24
- package/dist/lib/utils.cjs +11 -0
- package/dist/lib/utils.d.cts +9 -0
- package/dist/motion/index.cjs +61 -0
- package/dist/motion/index.d.cts +33 -0
- package/dist/registry/index.cjs +179 -0
- package/dist/registry/index.d.cts +17 -0
- package/dist/registry/index.js +32 -3
- package/dist/tokens/index.cjs +77 -0
- package/dist/tokens/index.d.cts +124 -0
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +17 -7
- 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 +23 -0
- package/registry/blocks/empty-state/EmptyState.tsx +32 -33
- 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.test.tsx +39 -0
- package/registry/blocks/sidebar/Sidebar.tsx +36 -26
- package/registry/blocks/stats-card/StatsCard.tsx +12 -29
- package/registry/effects/animated-gradient/animated-gradient.tsx +19 -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 +70 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
- package/registry/next14-tsconfig.test.ts +43 -0
- 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.test.tsx +23 -0
- package/registry/ui/checkbox/checkbox.tsx +16 -27
- package/registry/ui/combobox/combobox.test.tsx +72 -0
- package/registry/ui/combobox/combobox.tsx +34 -59
- package/registry/ui/input/input.test.tsx +3 -3
- package/registry/ui/input/input.tsx +5 -5
- 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/index.ts +1 -0
- package/registry/ui/skeleton/skeleton.test.tsx +113 -29
- package/registry/ui/skeleton/skeleton.tsx +101 -41
- 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 +79 -16
- package/tailwind-preset.ts +143 -0
|
@@ -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>
|
|
@@ -8,7 +8,7 @@ export interface ShimmerButtonProps
|
|
|
8
8
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
9
|
/** Shimmer color */
|
|
10
10
|
shimmerColor?: string
|
|
11
|
-
/**
|
|
11
|
+
/** Width of the sheen, as any CSS length or percentage of the button */
|
|
12
12
|
shimmerSize?: string
|
|
13
13
|
/** Border radius */
|
|
14
14
|
borderRadius?: string
|
|
@@ -18,6 +18,26 @@ export interface ShimmerButtonProps
|
|
|
18
18
|
background?: string
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** The value, or the fallback when it is missing or blank (a cleared control, say). */
|
|
22
|
+
const filled = (value: string | undefined, fallback: string) =>
|
|
23
|
+
value && value.trim() ? value : fallback
|
|
24
|
+
|
|
25
|
+
const DEFAULTS = {
|
|
26
|
+
// primary-foreground, not primary: a primary sheen on the primary background is invisible
|
|
27
|
+
shimmerColor: "color-mix(in oklch, var(--primary-foreground) 35%, transparent)",
|
|
28
|
+
shimmerSize: "100%",
|
|
29
|
+
shimmerDuration: "2s",
|
|
30
|
+
borderRadius: "0.5rem",
|
|
31
|
+
background: "var(--primary)",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SHIMMER_KEYFRAMES = `
|
|
35
|
+
@keyframes fasla-shimmer {
|
|
36
|
+
from { transform: translateX(-100%); }
|
|
37
|
+
to { transform: translateX(100%); }
|
|
38
|
+
}
|
|
39
|
+
`
|
|
40
|
+
|
|
21
41
|
/**
|
|
22
42
|
* A button with an animated shimmer effect.
|
|
23
43
|
* The shimmer respects prefers-reduced-motion.
|
|
@@ -28,11 +48,11 @@ export const ShimmerButton = React.forwardRef<
|
|
|
28
48
|
>(
|
|
29
49
|
(
|
|
30
50
|
{
|
|
31
|
-
shimmerColor
|
|
32
|
-
shimmerSize
|
|
33
|
-
shimmerDuration
|
|
34
|
-
borderRadius
|
|
35
|
-
background
|
|
51
|
+
shimmerColor,
|
|
52
|
+
shimmerSize,
|
|
53
|
+
shimmerDuration,
|
|
54
|
+
borderRadius,
|
|
55
|
+
background,
|
|
36
56
|
className,
|
|
37
57
|
children,
|
|
38
58
|
...props
|
|
@@ -45,40 +65,59 @@ export const ShimmerButton = React.forwardRef<
|
|
|
45
65
|
<button
|
|
46
66
|
ref={ref}
|
|
47
67
|
className={cn(
|
|
48
|
-
"group relative inline-flex h-10 items-center justify-center overflow-hidden whitespace-nowrap px-6 py-2 text-sm font-medium text-primary-foreground transition-all
|
|
68
|
+
"group relative inline-flex h-10 items-center justify-center overflow-hidden whitespace-nowrap px-6 py-2 text-sm font-medium text-primary-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
69
|
+
// Plain hover, gated in JS rather than with a stacked motion-safe
|
|
70
|
+
// hover variant: Storybook's pseudo-states addon rewrites that
|
|
71
|
+
// stacked rule into invalid CSS and throws. Tailwind reads comments
|
|
72
|
+
// too, so the stacked class name must not appear here either.
|
|
73
|
+
!prefersReducedMotion && "hover:scale-105",
|
|
49
74
|
className
|
|
50
75
|
)}
|
|
51
76
|
style={
|
|
52
77
|
{
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"--
|
|
57
|
-
"--
|
|
78
|
+
// A blank value (a cleared control, say) falls back to the default
|
|
79
|
+
// rather than emptying the variable, which would hide the sheen,
|
|
80
|
+
// stop it, square the corners or clear the background.
|
|
81
|
+
"--shimmer-color": filled(shimmerColor, DEFAULTS.shimmerColor),
|
|
82
|
+
"--shimmer-size": filled(shimmerSize, DEFAULTS.shimmerSize),
|
|
83
|
+
"--shimmer-duration": filled(shimmerDuration, DEFAULTS.shimmerDuration),
|
|
84
|
+
"--border-radius": filled(borderRadius, DEFAULTS.borderRadius),
|
|
85
|
+
"--background": filled(background, DEFAULTS.background),
|
|
58
86
|
borderRadius: "var(--border-radius)",
|
|
59
87
|
background: "var(--background)",
|
|
60
88
|
} as React.CSSProperties
|
|
61
89
|
}
|
|
62
90
|
{...props}
|
|
63
91
|
>
|
|
64
|
-
{/* Shimmer effect */}
|
|
92
|
+
{/* Shimmer effect: mirrored in RTL so it sweeps in the reading direction */}
|
|
65
93
|
{!prefersReducedMotion && (
|
|
66
94
|
<div
|
|
67
|
-
|
|
95
|
+
aria-hidden="true"
|
|
96
|
+
className="pointer-events-none absolute inset-0 overflow-hidden rtl:-scale-x-100"
|
|
68
97
|
style={{ borderRadius: "var(--border-radius)" }}
|
|
69
98
|
>
|
|
99
|
+
{/*
|
|
100
|
+
The keyframes ship with the component, so it animates without any
|
|
101
|
+
Tailwind config. The track is the button's width and travels from
|
|
102
|
+
fully before it to fully after it; the sheen sits at its centre.
|
|
103
|
+
*/}
|
|
104
|
+
<style dangerouslySetInnerHTML={{ __html: SHIMMER_KEYFRAMES }} />
|
|
70
105
|
<div
|
|
71
|
-
className="
|
|
106
|
+
className="absolute inset-0 flex justify-center"
|
|
72
107
|
style={{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
transparent 0%,
|
|
76
|
-
var(--shimmer-color) 50%,
|
|
77
|
-
transparent 100%
|
|
78
|
-
)`,
|
|
79
|
-
animationDuration: "var(--shimmer-duration)",
|
|
108
|
+
transform: "translateX(-100%)",
|
|
109
|
+
animation: "fasla-shimmer var(--shimmer-duration) ease-in-out infinite",
|
|
80
110
|
}}
|
|
81
|
-
|
|
111
|
+
>
|
|
112
|
+
<div
|
|
113
|
+
className="h-full shrink-0"
|
|
114
|
+
style={{
|
|
115
|
+
width: "var(--shimmer-size)",
|
|
116
|
+
background:
|
|
117
|
+
"linear-gradient(90deg, transparent 0%, var(--shimmer-color) 50%, transparent 100%)",
|
|
118
|
+
}}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
82
121
|
</div>
|
|
83
122
|
)}
|
|
84
123
|
|