@smicolon/fasla-ui 0.2.4 → 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.
Files changed (57) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +91 -23
  4. package/dist/registry/index.js +31 -2
  5. package/dist/tokens/index.d.ts +75 -6
  6. package/dist/tokens/index.js +59 -20
  7. package/package.json +4 -1
  8. package/registry/blocks/app-shell/AppShell.tsx +1 -1
  9. package/registry/blocks/data-table/DataTable.test.tsx +61 -0
  10. package/registry/blocks/data-table/DataTable.tsx +53 -16
  11. package/registry/blocks/data-table/index.ts +1 -0
  12. package/registry/blocks/empty-state/EmptyState.test.tsx +12 -0
  13. package/registry/blocks/empty-state/EmptyState.tsx +22 -31
  14. package/registry/blocks/form-section/FormSection.tsx +1 -1
  15. package/registry/blocks/navbar/Navbar.test.tsx +12 -0
  16. package/registry/blocks/navbar/Navbar.tsx +8 -23
  17. package/registry/blocks/page-header/PageHeader.tsx +5 -2
  18. package/registry/blocks/sidebar/Sidebar.tsx +9 -16
  19. package/registry/blocks/stats-card/StatsCard.tsx +9 -27
  20. package/registry/effects/animated-gradient/animated-gradient.tsx +23 -8
  21. package/registry/effects/border-beam/border-beam.tsx +77 -53
  22. package/registry/effects/effects.test.tsx +266 -0
  23. package/registry/effects/glow-card/glow-card.tsx +68 -43
  24. package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
  25. package/registry/effects/spotlight/spotlight.tsx +73 -29
  26. package/registry/effects/text-reveal/index.ts +1 -0
  27. package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
  28. package/registry/effects/text-reveal/text-reveal.tsx +68 -21
  29. package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
  30. package/registry/text-props-undefined.test.tsx +73 -0
  31. package/registry/ui/avatar/avatar.test.tsx +316 -0
  32. package/registry/ui/avatar/avatar.tsx +235 -108
  33. package/registry/ui/avatar/index.ts +1 -1
  34. package/registry/ui/badge/badge.test.tsx +267 -32
  35. package/registry/ui/badge/badge.tsx +233 -21
  36. package/registry/ui/button/button.test.tsx +6 -0
  37. package/registry/ui/button/button.tsx +6 -23
  38. package/registry/ui/card/card.tsx +1 -1
  39. package/registry/ui/checkbox/checkbox.tsx +13 -26
  40. package/registry/ui/combobox/combobox.test.tsx +43 -0
  41. package/registry/ui/combobox/combobox.tsx +27 -55
  42. package/registry/ui/input/input.test.tsx +2 -2
  43. package/registry/ui/input/input.tsx +4 -4
  44. package/registry/ui/radio/index.ts +1 -0
  45. package/registry/ui/radio/radio.test.tsx +248 -0
  46. package/registry/ui/radio/radio.tsx +208 -0
  47. package/registry/ui/select/select.tsx +4 -14
  48. package/registry/ui/skeleton/skeleton.tsx +1 -1
  49. package/registry/ui/status-indicator/index.ts +6 -0
  50. package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
  51. package/registry/ui/status-indicator/status-indicator.tsx +87 -0
  52. package/registry/ui/switch/index.ts +1 -1
  53. package/registry/ui/switch/switch.test.tsx +323 -0
  54. package/registry/ui/switch/switch.tsx +254 -57
  55. package/registry/ui/textarea/textarea.tsx +1 -1
  56. package/registry.json +78 -15
  57. 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 = "hsl(var(--primary))",
28
- colorTo = "transparent",
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
- {/* Border beam */}
42
- {!prefersReducedMotion && (
43
- <motion.div
44
- className="pointer-events-none absolute inset-0 rounded-[inherit]"
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
- {/* Static border fallback for reduced motion */}
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
- className="pointer-events-none absolute inset-0 rounded-[inherit]"
68
- style={{
69
- padding: borderWidth,
70
- background: colorFrom,
71
- mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
72
- maskComposite: "exclude",
73
- WebkitMaskComposite: "xor",
74
- opacity: 0.5,
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: "0 0 10px 2px",
98
- md: "0 0 20px 4px",
99
- lg: "0 0 30px 6px",
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 effect.
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 = "hsl(var(--primary) / 0.5)",
107
- borderRadius = "0.5rem",
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
- {/* Glow */}
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
- className="absolute inset-0 -z-10"
146
+ aria-hidden="true"
147
+ className="pointer-events-none absolute inset-0 z-10"
124
148
  style={{
125
149
  borderRadius,
126
- boxShadow: `${intensityValues[intensity]} ${glowColor}`,
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 = "hsl(var(--primary))",
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
- const [mousePosition, setMousePosition] = React.useState({ x: 50, y: 50 })
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
- // Check for reduced motion preference
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
- const x = ((e.clientX - rect.left) / rect.width) * 100
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
- {/* Glow effect */}
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 -inset-px rounded-xl opacity-0 transition-opacity duration-300",
89
+ "pointer-events-none absolute opacity-0 transition-opacity duration-300",
64
90
  showGlow && "opacity-100"
65
91
  )}
66
92
  style={{
67
- background: followMouse
68
- ? `radial-gradient(circle at ${mousePosition.x}% ${mousePosition.y}%, ${glowColor}, transparent 70%)`
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
- {/* Border glow */}
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-xl opacity-0 transition-opacity duration-300",
78
- showGlow && "opacity-50"
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: followMouse
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 = "hsl(var(--primary))",
131
+ glowColor: glowColorProp,
110
132
  duration = 3,
111
133
  className,
112
134
  children,
113
135
  ...props
114
136
  }: GlowContainerProps) {
115
- const prefersReducedMotion = React.useMemo(() => {
116
- if (typeof window === "undefined") return false
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>