@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.
Files changed (72) hide show
  1. package/README.md +44 -20
  2. package/dist/index.cjs +319 -0
  3. package/dist/index.d.cts +5 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.js +92 -24
  6. package/dist/lib/utils.cjs +11 -0
  7. package/dist/lib/utils.d.cts +9 -0
  8. package/dist/motion/index.cjs +61 -0
  9. package/dist/motion/index.d.cts +33 -0
  10. package/dist/registry/index.cjs +179 -0
  11. package/dist/registry/index.d.cts +17 -0
  12. package/dist/registry/index.js +32 -3
  13. package/dist/tokens/index.cjs +77 -0
  14. package/dist/tokens/index.d.cts +124 -0
  15. package/dist/tokens/index.d.ts +75 -6
  16. package/dist/tokens/index.js +59 -20
  17. package/package.json +17 -7
  18. package/registry/blocks/app-shell/AppShell.tsx +1 -1
  19. package/registry/blocks/data-table/DataTable.test.tsx +61 -0
  20. package/registry/blocks/data-table/DataTable.tsx +53 -16
  21. package/registry/blocks/data-table/index.ts +1 -0
  22. package/registry/blocks/empty-state/EmptyState.test.tsx +23 -0
  23. package/registry/blocks/empty-state/EmptyState.tsx +32 -33
  24. package/registry/blocks/form-section/FormSection.tsx +1 -1
  25. package/registry/blocks/navbar/Navbar.test.tsx +12 -0
  26. package/registry/blocks/navbar/Navbar.tsx +8 -23
  27. package/registry/blocks/page-header/PageHeader.tsx +5 -2
  28. package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
  29. package/registry/blocks/sidebar/Sidebar.tsx +36 -26
  30. package/registry/blocks/stats-card/StatsCard.tsx +12 -29
  31. package/registry/effects/animated-gradient/animated-gradient.tsx +19 -8
  32. package/registry/effects/border-beam/border-beam.tsx +77 -53
  33. package/registry/effects/effects.test.tsx +266 -0
  34. package/registry/effects/glow-card/glow-card.tsx +68 -43
  35. package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
  36. package/registry/effects/spotlight/spotlight.tsx +73 -29
  37. package/registry/effects/text-reveal/index.ts +1 -0
  38. package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
  39. package/registry/effects/text-reveal/text-reveal.tsx +70 -21
  40. package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
  41. package/registry/next14-tsconfig.test.ts +43 -0
  42. package/registry/text-props-undefined.test.tsx +73 -0
  43. package/registry/ui/avatar/avatar.test.tsx +316 -0
  44. package/registry/ui/avatar/avatar.tsx +235 -108
  45. package/registry/ui/avatar/index.ts +1 -1
  46. package/registry/ui/badge/badge.test.tsx +267 -32
  47. package/registry/ui/badge/badge.tsx +233 -21
  48. package/registry/ui/button/button.test.tsx +6 -0
  49. package/registry/ui/button/button.tsx +6 -23
  50. package/registry/ui/card/card.tsx +1 -1
  51. package/registry/ui/checkbox/checkbox.test.tsx +23 -0
  52. package/registry/ui/checkbox/checkbox.tsx +16 -27
  53. package/registry/ui/combobox/combobox.test.tsx +72 -0
  54. package/registry/ui/combobox/combobox.tsx +34 -59
  55. package/registry/ui/input/input.test.tsx +3 -3
  56. package/registry/ui/input/input.tsx +5 -5
  57. package/registry/ui/radio/index.ts +1 -0
  58. package/registry/ui/radio/radio.test.tsx +248 -0
  59. package/registry/ui/radio/radio.tsx +208 -0
  60. package/registry/ui/select/select.tsx +4 -14
  61. package/registry/ui/skeleton/index.ts +1 -0
  62. package/registry/ui/skeleton/skeleton.test.tsx +113 -29
  63. package/registry/ui/skeleton/skeleton.tsx +101 -41
  64. package/registry/ui/status-indicator/index.ts +6 -0
  65. package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
  66. package/registry/ui/status-indicator/status-indicator.tsx +87 -0
  67. package/registry/ui/switch/index.ts +1 -1
  68. package/registry/ui/switch/switch.test.tsx +323 -0
  69. package/registry/ui/switch/switch.tsx +254 -57
  70. package/registry/ui/textarea/textarea.tsx +1 -1
  71. package/registry.json +79 -16
  72. 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 = "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>
@@ -8,7 +8,7 @@ export interface ShimmerButtonProps
8
8
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9
9
  /** Shimmer color */
10
10
  shimmerColor?: string
11
- /** Shimmer size in pixels */
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 = "hsl(var(--primary) / 0.2)",
32
- shimmerSize = "0.1em",
33
- shimmerDuration = "2s",
34
- borderRadius = "0.5rem",
35
- background = "hsl(var(--primary))",
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 hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
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
- "--shimmer-color": shimmerColor,
54
- "--shimmer-size": shimmerSize,
55
- "--shimmer-duration": shimmerDuration,
56
- "--border-radius": borderRadius,
57
- "--background": background,
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
- className="absolute inset-0 overflow-hidden"
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="animate-shimmer absolute inset-0 -translate-x-full"
106
+ className="absolute inset-0 flex justify-center"
72
107
  style={{
73
- background: `linear-gradient(
74
- 90deg,
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