@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.
- package/README.md +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +91 -23
- package/dist/registry/index.js +31 -2
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +4 -1
- package/registry/blocks/app-shell/AppShell.tsx +1 -1
- package/registry/blocks/data-table/DataTable.test.tsx +61 -0
- package/registry/blocks/data-table/DataTable.tsx +53 -16
- package/registry/blocks/data-table/index.ts +1 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +12 -0
- package/registry/blocks/empty-state/EmptyState.tsx +22 -31
- package/registry/blocks/form-section/FormSection.tsx +1 -1
- package/registry/blocks/navbar/Navbar.test.tsx +12 -0
- package/registry/blocks/navbar/Navbar.tsx +8 -23
- package/registry/blocks/page-header/PageHeader.tsx +5 -2
- package/registry/blocks/sidebar/Sidebar.tsx +9 -16
- package/registry/blocks/stats-card/StatsCard.tsx +9 -27
- package/registry/effects/animated-gradient/animated-gradient.tsx +23 -8
- package/registry/effects/border-beam/border-beam.tsx +77 -53
- package/registry/effects/effects.test.tsx +266 -0
- package/registry/effects/glow-card/glow-card.tsx +68 -43
- package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
- package/registry/effects/spotlight/spotlight.tsx +73 -29
- package/registry/effects/text-reveal/index.ts +1 -0
- package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
- package/registry/effects/text-reveal/text-reveal.tsx +68 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
- package/registry/text-props-undefined.test.tsx +73 -0
- package/registry/ui/avatar/avatar.test.tsx +316 -0
- package/registry/ui/avatar/avatar.tsx +235 -108
- package/registry/ui/avatar/index.ts +1 -1
- package/registry/ui/badge/badge.test.tsx +267 -32
- package/registry/ui/badge/badge.tsx +233 -21
- package/registry/ui/button/button.test.tsx +6 -0
- package/registry/ui/button/button.tsx +6 -23
- package/registry/ui/card/card.tsx +1 -1
- package/registry/ui/checkbox/checkbox.tsx +13 -26
- package/registry/ui/combobox/combobox.test.tsx +43 -0
- package/registry/ui/combobox/combobox.tsx +27 -55
- package/registry/ui/input/input.test.tsx +2 -2
- package/registry/ui/input/input.tsx +4 -4
- package/registry/ui/radio/index.ts +1 -0
- package/registry/ui/radio/radio.test.tsx +248 -0
- package/registry/ui/radio/radio.tsx +208 -0
- package/registry/ui/select/select.tsx +4 -14
- package/registry/ui/skeleton/skeleton.tsx +1 -1
- package/registry/ui/status-indicator/index.ts +6 -0
- package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
- package/registry/ui/status-indicator/status-indicator.tsx +87 -0
- package/registry/ui/switch/index.ts +1 -1
- package/registry/ui/switch/switch.test.tsx +323 -0
- package/registry/ui/switch/switch.tsx +254 -57
- package/registry/ui/textarea/textarea.tsx +1 -1
- package/registry.json +78 -15
- package/tailwind-preset.ts +143 -0
|
@@ -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
|
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import { motion, useMotionValue, useSpring, useReducedMotion } from "framer-motion"
|
|
4
|
+
import { AnimatePresence, motion, useMotionValue, useSpring, useReducedMotion } from "framer-motion"
|
|
5
5
|
import { cn } from "../../../src/lib/utils"
|
|
6
6
|
|
|
7
|
+
/** A layout effect in the browser, a plain effect on the server (where neither runs). */
|
|
8
|
+
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect
|
|
9
|
+
|
|
10
|
+
/** The value, or the fallback when it is missing or blank (a cleared control, say). */
|
|
11
|
+
const filled = (value: string | undefined, fallback: string) =>
|
|
12
|
+
value && value.trim() ? value : fallback
|
|
13
|
+
|
|
7
14
|
export interface SpotlightProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
15
|
/** Spotlight color */
|
|
9
16
|
color?: string
|
|
@@ -20,7 +27,7 @@ export interface SpotlightProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
20
27
|
* Respects prefers-reduced-motion by disabling the effect.
|
|
21
28
|
*/
|
|
22
29
|
export function Spotlight({
|
|
23
|
-
color
|
|
30
|
+
color: colorProp,
|
|
24
31
|
size = 400,
|
|
25
32
|
blur = 80,
|
|
26
33
|
opacity = 1,
|
|
@@ -28,6 +35,7 @@ export function Spotlight({
|
|
|
28
35
|
children,
|
|
29
36
|
...props
|
|
30
37
|
}: SpotlightProps) {
|
|
38
|
+
const color = filled(colorProp, "color-mix(in oklch, var(--primary) 15%, transparent)")
|
|
31
39
|
const prefersReducedMotion = useReducedMotion()
|
|
32
40
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
33
41
|
|
|
@@ -38,18 +46,43 @@ export function Spotlight({
|
|
|
38
46
|
const springX = useSpring(mouseX, springConfig)
|
|
39
47
|
const springY = useSpring(mouseY, springConfig)
|
|
40
48
|
|
|
49
|
+
// The motion values hold the light's centre, and the light is offset by half
|
|
50
|
+
// its size through left and top, so a change of size keeps it centred on the
|
|
51
|
+
// same point instead of drifting away from the pointer.
|
|
52
|
+
//
|
|
53
|
+
// Rest at the container's centre, not its top-left corner, until the pointer
|
|
54
|
+
// moves, and follow that centre while the container resizes (a sidebar
|
|
55
|
+
// opening, say).
|
|
56
|
+
const pointerMoved = React.useRef(false)
|
|
57
|
+
useIsomorphicLayoutEffect(() => {
|
|
58
|
+
const el = containerRef.current
|
|
59
|
+
if (!el) return
|
|
60
|
+
const centre = () => {
|
|
61
|
+
if (pointerMoved.current) return
|
|
62
|
+
const x = el.offsetWidth / 2
|
|
63
|
+
const y = el.offsetHeight / 2
|
|
64
|
+
mouseX.jump(x)
|
|
65
|
+
mouseY.jump(y)
|
|
66
|
+
springX.jump(x)
|
|
67
|
+
springY.jump(y)
|
|
68
|
+
}
|
|
69
|
+
centre()
|
|
70
|
+
if (typeof ResizeObserver === "undefined") return
|
|
71
|
+
const observer = new ResizeObserver(centre)
|
|
72
|
+
observer.observe(el)
|
|
73
|
+
return () => observer.disconnect()
|
|
74
|
+
}, [mouseX, mouseY, springX, springY, prefersReducedMotion])
|
|
75
|
+
|
|
41
76
|
const handleMouseMove = React.useCallback(
|
|
42
77
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
43
78
|
if (!containerRef.current || prefersReducedMotion) return
|
|
44
79
|
|
|
80
|
+
pointerMoved.current = true
|
|
45
81
|
const rect = containerRef.current.getBoundingClientRect()
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
mouseX.set(x)
|
|
50
|
-
mouseY.set(y)
|
|
82
|
+
mouseX.set(e.clientX - rect.left)
|
|
83
|
+
mouseY.set(e.clientY - rect.top)
|
|
51
84
|
},
|
|
52
|
-
[mouseX, mouseY,
|
|
85
|
+
[mouseX, mouseY, prefersReducedMotion]
|
|
53
86
|
)
|
|
54
87
|
|
|
55
88
|
if (prefersReducedMotion) {
|
|
@@ -64,15 +97,19 @@ export function Spotlight({
|
|
|
64
97
|
<div
|
|
65
98
|
ref={containerRef}
|
|
66
99
|
onMouseMove={handleMouseMove}
|
|
67
|
-
className={cn("relative overflow-hidden", className)}
|
|
100
|
+
className={cn("relative isolate overflow-hidden", className)}
|
|
68
101
|
{...props}
|
|
69
102
|
>
|
|
70
|
-
{/* Spotlight
|
|
103
|
+
{/* Spotlight. Placed from the top-left corner (a flex parent that centres
|
|
104
|
+
its content would otherwise move the origin), then pulled back by half
|
|
105
|
+
its size so x and y are its centre. */}
|
|
71
106
|
<motion.div
|
|
72
107
|
className="pointer-events-none absolute -z-10 rounded-full"
|
|
73
108
|
style={{
|
|
74
109
|
x: springX,
|
|
75
110
|
y: springY,
|
|
111
|
+
left: -size / 2,
|
|
112
|
+
top: -size / 2,
|
|
76
113
|
width: size,
|
|
77
114
|
height: size,
|
|
78
115
|
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
|
|
@@ -98,12 +135,13 @@ export interface SpotlightCardProps extends React.HTMLAttributes<HTMLDivElement>
|
|
|
98
135
|
* Card with spotlight effect on hover.
|
|
99
136
|
*/
|
|
100
137
|
export function SpotlightCard({
|
|
101
|
-
spotlightColor
|
|
138
|
+
spotlightColor: spotlightColorProp,
|
|
102
139
|
spotlightSize = 300,
|
|
103
140
|
className,
|
|
104
141
|
children,
|
|
105
142
|
...props
|
|
106
143
|
}: SpotlightCardProps) {
|
|
144
|
+
const spotlightColor = filled(spotlightColorProp, "color-mix(in oklch, var(--primary) 10%, transparent)")
|
|
107
145
|
const prefersReducedMotion = useReducedMotion()
|
|
108
146
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
109
147
|
const [isHovered, setIsHovered] = React.useState(false)
|
|
@@ -132,28 +170,34 @@ export function SpotlightCard({
|
|
|
132
170
|
onMouseEnter={() => setIsHovered(true)}
|
|
133
171
|
onMouseLeave={() => setIsHovered(false)}
|
|
134
172
|
className={cn(
|
|
135
|
-
"relative overflow-hidden rounded-xl border bg-card p-6 shadow",
|
|
173
|
+
"relative isolate overflow-hidden rounded-xl border bg-card p-6 shadow",
|
|
136
174
|
className
|
|
137
175
|
)}
|
|
138
176
|
{...props}
|
|
139
177
|
>
|
|
140
|
-
{/*
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
178
|
+
{/*
|
|
179
|
+
Spotlight. Centred on the pointer by offsetting half its size: a CSS
|
|
180
|
+
translate(-50%, -50%) is overwritten by the x and y motion values.
|
|
181
|
+
*/}
|
|
182
|
+
<AnimatePresence>
|
|
183
|
+
{!prefersReducedMotion && isHovered && (
|
|
184
|
+
<motion.div
|
|
185
|
+
className="pointer-events-none absolute -z-10 rounded-full"
|
|
186
|
+
style={{
|
|
187
|
+
x: mouseX,
|
|
188
|
+
y: mouseY,
|
|
189
|
+
left: -spotlightSize / 2,
|
|
190
|
+
top: -spotlightSize / 2,
|
|
191
|
+
width: spotlightSize,
|
|
192
|
+
height: spotlightSize,
|
|
193
|
+
background: `radial-gradient(circle, ${spotlightColor} 0%, transparent 70%)`,
|
|
194
|
+
}}
|
|
195
|
+
initial={{ opacity: 0 }}
|
|
196
|
+
animate={{ opacity: 1 }}
|
|
197
|
+
exit={{ opacity: 0 }}
|
|
198
|
+
/>
|
|
199
|
+
)}
|
|
200
|
+
</AnimatePresence>
|
|
157
201
|
|
|
158
202
|
{/* Content */}
|
|
159
203
|
{children}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from "vitest"
|
|
2
|
+
import { render } from "@testing-library/react"
|
|
3
|
+
import { TextReveal, revealUnits } from "./text-reveal"
|
|
4
|
+
|
|
5
|
+
// framer-motion's useInView needs an observer, which jsdom does not provide.
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
globalThis.IntersectionObserver ??= class {
|
|
8
|
+
observe() {}
|
|
9
|
+
unobserve() {}
|
|
10
|
+
disconnect() {}
|
|
11
|
+
} as unknown as typeof IntersectionObserver
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe("TextReveal units", () => {
|
|
15
|
+
it("splits Latin text into characters", () => {
|
|
16
|
+
expect(revealUnits("Hi there")).toEqual(["H", "i", " ", "t", "h", "e", "r", "e"])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("keeps a combining mark with its letter", () => {
|
|
20
|
+
expect(revealUnits("été")).toEqual(["é", "t", "é"])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("splits Arabic into words, so the letters stay joined", () => {
|
|
24
|
+
expect(revealUnits("مكوّن جاهز")).toEqual(["مكوّن", " ", "جاهز"])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("renders one span per Arabic word", () => {
|
|
28
|
+
const { container } = render(<TextReveal text="أطلق متجرك اليوم" triggerOnView={false} />)
|
|
29
|
+
const spans = Array.from(container.querySelectorAll("span[aria-hidden]"), (span) => span.textContent)
|
|
30
|
+
expect(spans).toEqual(["أطلق", "متجرك", "اليوم"])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("gives assistive tech the whole text once", () => {
|
|
34
|
+
const { container } = render(<TextReveal text="Hello" triggerOnView={false} />)
|
|
35
|
+
expect(container.querySelector(".sr-only")?.textContent).toBe("Hello")
|
|
36
|
+
expect(container.querySelectorAll("span[aria-hidden]")).toHaveLength(5)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("keeps whitespace as plain text, so white-space rules apply as they do to the unanimated text", () => {
|
|
40
|
+
const text = "أطلق متجرك\nاليوم"
|
|
41
|
+
const { container } = render(<TextReveal text={text} triggerOnView={false} className="whitespace-pre-wrap" />)
|
|
42
|
+
const root = container.firstElementChild!
|
|
43
|
+
const visible = Array.from(root.childNodes)
|
|
44
|
+
.filter((node) => !(node instanceof HTMLElement && node.classList.contains("sr-only")))
|
|
45
|
+
.map((node) => node.textContent)
|
|
46
|
+
.join("")
|
|
47
|
+
expect(visible).toBe(text)
|
|
48
|
+
expect(root.querySelectorAll("span[aria-hidden]")).toHaveLength(3)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("keeps a combining mark with its letter when Intl.Segmenter is missing", () => {
|
|
52
|
+
const intl = Intl as unknown as { Segmenter?: unknown }
|
|
53
|
+
const saved = intl.Segmenter
|
|
54
|
+
delete intl.Segmenter
|
|
55
|
+
try {
|
|
56
|
+
expect(revealUnits("e\u0301te\u0300")).toEqual(["e\u0301", "t", "e\u0300"])
|
|
57
|
+
} finally {
|
|
58
|
+
intl.Segmenter = saved
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("is exported from the effect's entry point", async () => {
|
|
63
|
+
const entry = await import("./index")
|
|
64
|
+
expect(entry.revealUnits).toBe(revealUnits)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -16,7 +16,42 @@ export interface TextRevealProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Scripts whose letters join to their neighbours: Arabic (with its supplements
|
|
20
|
+
* and presentation forms), Syriac, N'Ko and Mandaic. A span per letter would
|
|
21
|
+
* break the joins, so these reveal by word instead. The range also takes in
|
|
22
|
+
* Thaana, which does not join; revealing it by word is harmless.
|
|
23
|
+
*/
|
|
24
|
+
const JOINED_SCRIPT = /[\u0600-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
|
|
25
|
+
|
|
26
|
+
/** The slice of Intl.Segmenter used here, typed locally: older TypeScript libs lack it. */
|
|
27
|
+
type GraphemeSegmenter = new (
|
|
28
|
+
locale: undefined,
|
|
29
|
+
options: { granularity: "grapheme" }
|
|
30
|
+
) => { segment(text: string): Iterable<{ segment: string }> }
|
|
31
|
+
|
|
32
|
+
/** Splits into user-perceived characters, so a mark stays with its letter. */
|
|
33
|
+
function splitGraphemes(text: string): string[] {
|
|
34
|
+
const Segmenter = (Intl as unknown as { Segmenter?: GraphemeSegmenter }).Segmenter
|
|
35
|
+
if (Segmenter) {
|
|
36
|
+
const segmenter = new Segmenter(undefined, { granularity: "grapheme" })
|
|
37
|
+
return Array.from(segmenter.segment(text), (part) => part.segment)
|
|
38
|
+
}
|
|
39
|
+
// Without Segmenter (Firefox before 125, for one), keep each combining mark
|
|
40
|
+
// with the character before it, so a letter and its accent animate together.
|
|
41
|
+
return text.match(/\P{M}\p{M}*|\p{M}+/gu) ?? []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Splits the text into the pieces TextReveal animates: characters, or words
|
|
46
|
+
* with their spaces for a joined script such as Arabic.
|
|
47
|
+
*/
|
|
48
|
+
export function revealUnits(text: string): string[] {
|
|
49
|
+
return JOINED_SCRIPT.test(text) ? text.split(/(\s+)/).filter(Boolean) : splitGraphemes(text)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Text reveal animation that reveals text character by character. Arabic and
|
|
54
|
+
* other joined scripts reveal word by word, so their letters stay connected.
|
|
20
55
|
* Respects prefers-reduced-motion by showing text immediately.
|
|
21
56
|
*/
|
|
22
57
|
export function TextReveal({
|
|
@@ -32,7 +67,7 @@ export function TextReveal({
|
|
|
32
67
|
const isInView = useInView(ref, { once: true, amount: 0.5 })
|
|
33
68
|
|
|
34
69
|
const shouldAnimate = triggerOnView ? isInView : true
|
|
35
|
-
const
|
|
70
|
+
const units = revealUnits(text)
|
|
36
71
|
|
|
37
72
|
if (prefersReducedMotion) {
|
|
38
73
|
return (
|
|
@@ -48,25 +83,35 @@ export function TextReveal({
|
|
|
48
83
|
className={cn("inline-block", className)}
|
|
49
84
|
{...props}
|
|
50
85
|
>
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
{/* Read once, whole; the animated pieces are hidden from assistive tech */}
|
|
87
|
+
<span className="sr-only">{text}</span>
|
|
88
|
+
{units.map((unit, i) =>
|
|
89
|
+
/^\s+$/.test(unit) ? (
|
|
90
|
+
// Whitespace stays plain text, so it follows the element's
|
|
91
|
+
// white-space like the unanimated text: collapsed normally, kept
|
|
92
|
+
// (with its line breaks) under whitespace-pre-wrap.
|
|
93
|
+
<React.Fragment key={`space-${i}`}>{unit}</React.Fragment>
|
|
94
|
+
) : (
|
|
95
|
+
<motion.span
|
|
96
|
+
aria-hidden="true"
|
|
97
|
+
key={`${unit}-${i}`}
|
|
98
|
+
className="inline-block"
|
|
99
|
+
initial={{ opacity: 0, y: 10 }}
|
|
100
|
+
animate={
|
|
101
|
+
shouldAnimate
|
|
102
|
+
? { opacity: 1, y: 0 }
|
|
103
|
+
: { opacity: 0, y: 10 }
|
|
104
|
+
}
|
|
105
|
+
transition={{
|
|
106
|
+
duration,
|
|
107
|
+
delay: i * delay,
|
|
108
|
+
ease: [0.2, 0.65, 0.3, 0.9],
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{unit}
|
|
112
|
+
</motion.span>
|
|
113
|
+
)
|
|
114
|
+
)}
|
|
70
115
|
</div>
|
|
71
116
|
)
|
|
72
117
|
}
|
|
@@ -114,8 +159,10 @@ export function WordReveal({
|
|
|
114
159
|
className={cn("inline-block", className)}
|
|
115
160
|
{...props}
|
|
116
161
|
>
|
|
162
|
+
<span className="sr-only">{text}</span>
|
|
117
163
|
{words.map((word, i) => (
|
|
118
164
|
<motion.span
|
|
165
|
+
aria-hidden="true"
|
|
119
166
|
key={`${word}-${i}`}
|
|
120
167
|
className="inline-block"
|
|
121
168
|
initial={{ opacity: 0, y: 20, filter: "blur(10px)" }}
|
|
@@ -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 TypewriterTextProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
7
28
|
/** Text to type out */
|
|
8
29
|
text: string
|
|
@@ -27,34 +48,35 @@ export function TypewriterText({
|
|
|
27
48
|
speed = 50,
|
|
28
49
|
delay = 0,
|
|
29
50
|
cursor = true,
|
|
30
|
-
cursorChar
|
|
51
|
+
cursorChar: cursorCharProp,
|
|
31
52
|
loop = false,
|
|
32
53
|
loopDelay = 2000,
|
|
33
54
|
onComplete,
|
|
34
55
|
className,
|
|
35
56
|
...props
|
|
36
57
|
}: TypewriterTextProps) {
|
|
58
|
+
const cursorChar = filled(cursorCharProp, "|")
|
|
37
59
|
const [displayText, setDisplayText] = React.useState("")
|
|
38
60
|
const [isTyping, setIsTyping] = React.useState(false)
|
|
39
|
-
const
|
|
61
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
62
|
+
const showCursor = cursor && !prefersReducedMotion
|
|
40
63
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}, [])
|
|
64
|
+
// Held in a ref: an inline callback is a new function every render, and as
|
|
65
|
+
// an effect dependency it restarted the typing each time the parent rendered.
|
|
66
|
+
const onCompleteRef = React.useRef(onComplete)
|
|
67
|
+
onCompleteRef.current = onComplete
|
|
46
68
|
|
|
47
69
|
React.useEffect(() => {
|
|
48
70
|
// If user prefers reduced motion, show full text immediately
|
|
49
71
|
if (prefersReducedMotion) {
|
|
50
72
|
setDisplayText(text)
|
|
51
|
-
|
|
52
|
-
onComplete?.()
|
|
73
|
+
onCompleteRef.current?.()
|
|
53
74
|
return
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
let timeoutId: ReturnType<typeof setTimeout>
|
|
57
78
|
let charIndex = 0
|
|
79
|
+
setDisplayText("")
|
|
58
80
|
setIsTyping(true)
|
|
59
81
|
|
|
60
82
|
const startTyping = () => {
|
|
@@ -65,7 +87,7 @@ export function TypewriterText({
|
|
|
65
87
|
timeoutId = setTimeout(typeChar, speed)
|
|
66
88
|
} else {
|
|
67
89
|
setIsTyping(false)
|
|
68
|
-
|
|
90
|
+
onCompleteRef.current?.()
|
|
69
91
|
|
|
70
92
|
if (loop) {
|
|
71
93
|
timeoutId = setTimeout(() => {
|
|
@@ -84,20 +106,20 @@ export function TypewriterText({
|
|
|
84
106
|
timeoutId = setTimeout(startTyping, delay)
|
|
85
107
|
|
|
86
108
|
return () => clearTimeout(timeoutId)
|
|
87
|
-
}, [text, speed, delay, loop, loopDelay,
|
|
109
|
+
}, [text, speed, delay, loop, loopDelay, prefersReducedMotion])
|
|
88
110
|
|
|
89
111
|
// Cursor blink effect
|
|
90
112
|
const [cursorVisible, setCursorVisible] = React.useState(true)
|
|
91
113
|
|
|
92
114
|
React.useEffect(() => {
|
|
93
|
-
if (!showCursor
|
|
115
|
+
if (!showCursor) return
|
|
94
116
|
|
|
95
117
|
const blinkInterval = setInterval(() => {
|
|
96
118
|
setCursorVisible((prev) => !prev)
|
|
97
119
|
}, 530)
|
|
98
120
|
|
|
99
121
|
return () => clearInterval(blinkInterval)
|
|
100
|
-
}, [showCursor
|
|
122
|
+
}, [showCursor])
|
|
101
123
|
|
|
102
124
|
return (
|
|
103
125
|
<span className={cn("inline", className)} {...props}>
|
|
@@ -105,7 +127,7 @@ export function TypewriterText({
|
|
|
105
127
|
{showCursor && (
|
|
106
128
|
<span
|
|
107
129
|
className={cn(
|
|
108
|
-
"
|
|
130
|
+
"ms-0.5 inline-block",
|
|
109
131
|
cursorVisible ? "opacity-100" : "opacity-0",
|
|
110
132
|
isTyping ? "" : "animate-pulse"
|
|
111
133
|
)}
|
|
@@ -144,10 +166,7 @@ export function TypewriterWords({
|
|
|
144
166
|
const [displayText, setDisplayText] = React.useState("")
|
|
145
167
|
const [isDeleting, setIsDeleting] = React.useState(false)
|
|
146
168
|
|
|
147
|
-
const prefersReducedMotion =
|
|
148
|
-
if (typeof window === "undefined") return false
|
|
149
|
-
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
150
|
-
}, [])
|
|
169
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
151
170
|
|
|
152
171
|
React.useEffect(() => {
|
|
153
172
|
if (prefersReducedMotion) {
|
|
@@ -204,10 +223,10 @@ export function TypewriterWords({
|
|
|
204
223
|
return (
|
|
205
224
|
<span className={cn("inline", className)} {...props}>
|
|
206
225
|
{displayText}
|
|
207
|
-
{cursor && (
|
|
226
|
+
{cursor && !prefersReducedMotion && (
|
|
208
227
|
<span
|
|
209
228
|
className={cn(
|
|
210
|
-
"
|
|
229
|
+
"ms-0.5 inline-block",
|
|
211
230
|
cursorVisible ? "opacity-100" : "opacity-0"
|
|
212
231
|
)}
|
|
213
232
|
aria-hidden="true"
|