@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
|
@@ -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,44 @@ 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
|
+
// Built from a string: TypeScript rejects the u flag in a regex literal when
|
|
42
|
+
// the target is below ES2015, as it is in a Next.js 14 tsconfig.
|
|
43
|
+
return text.match(new RegExp("\\P{M}\\p{M}*|\\p{M}+", "gu")) ?? []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Splits the text into the pieces TextReveal animates: characters, or words
|
|
48
|
+
* with their spaces for a joined script such as Arabic.
|
|
49
|
+
*/
|
|
50
|
+
export function revealUnits(text: string): string[] {
|
|
51
|
+
return JOINED_SCRIPT.test(text) ? text.split(/(\s+)/).filter(Boolean) : splitGraphemes(text)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Text reveal animation that reveals text character by character. Arabic and
|
|
56
|
+
* other joined scripts reveal word by word, so their letters stay connected.
|
|
20
57
|
* Respects prefers-reduced-motion by showing text immediately.
|
|
21
58
|
*/
|
|
22
59
|
export function TextReveal({
|
|
@@ -32,7 +69,7 @@ export function TextReveal({
|
|
|
32
69
|
const isInView = useInView(ref, { once: true, amount: 0.5 })
|
|
33
70
|
|
|
34
71
|
const shouldAnimate = triggerOnView ? isInView : true
|
|
35
|
-
const
|
|
72
|
+
const units = revealUnits(text)
|
|
36
73
|
|
|
37
74
|
if (prefersReducedMotion) {
|
|
38
75
|
return (
|
|
@@ -48,25 +85,35 @@ export function TextReveal({
|
|
|
48
85
|
className={cn("inline-block", className)}
|
|
49
86
|
{...props}
|
|
50
87
|
>
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
{/* Read once, whole; the animated pieces are hidden from assistive tech */}
|
|
89
|
+
<span className="sr-only">{text}</span>
|
|
90
|
+
{units.map((unit, i) =>
|
|
91
|
+
/^\s+$/.test(unit) ? (
|
|
92
|
+
// Whitespace stays plain text, so it follows the element's
|
|
93
|
+
// white-space like the unanimated text: collapsed normally, kept
|
|
94
|
+
// (with its line breaks) under whitespace-pre-wrap.
|
|
95
|
+
<React.Fragment key={`space-${i}`}>{unit}</React.Fragment>
|
|
96
|
+
) : (
|
|
97
|
+
<motion.span
|
|
98
|
+
aria-hidden="true"
|
|
99
|
+
key={`${unit}-${i}`}
|
|
100
|
+
className="inline-block"
|
|
101
|
+
initial={{ opacity: 0, y: 10 }}
|
|
102
|
+
animate={
|
|
103
|
+
shouldAnimate
|
|
104
|
+
? { opacity: 1, y: 0 }
|
|
105
|
+
: { opacity: 0, y: 10 }
|
|
106
|
+
}
|
|
107
|
+
transition={{
|
|
108
|
+
duration,
|
|
109
|
+
delay: i * delay,
|
|
110
|
+
ease: [0.2, 0.65, 0.3, 0.9],
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{unit}
|
|
114
|
+
</motion.span>
|
|
115
|
+
)
|
|
116
|
+
)}
|
|
70
117
|
</div>
|
|
71
118
|
)
|
|
72
119
|
}
|
|
@@ -114,8 +161,10 @@ export function WordReveal({
|
|
|
114
161
|
className={cn("inline-block", className)}
|
|
115
162
|
{...props}
|
|
116
163
|
>
|
|
164
|
+
<span className="sr-only">{text}</span>
|
|
117
165
|
{words.map((word, i) => (
|
|
118
166
|
<motion.span
|
|
167
|
+
aria-hidden="true"
|
|
119
168
|
key={`${word}-${i}`}
|
|
120
169
|
className="inline-block"
|
|
121
170
|
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"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect } from "vitest"
|
|
3
|
+
import { readdirSync } from "node:fs"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import ts from "typescript"
|
|
6
|
+
|
|
7
|
+
const registry = __dirname
|
|
8
|
+
|
|
9
|
+
function sourceFiles(dir: string): string[] {
|
|
10
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
11
|
+
const path = join(dir, entry.name)
|
|
12
|
+
if (entry.isDirectory()) return sourceFiles(path)
|
|
13
|
+
return /\.tsx?$/.test(entry.name) && !/\.(test|stories)\.tsx?$/.test(entry.name) ? [path] : []
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The compiler options create-next-app@14 writes. It sets no target, so
|
|
18
|
+
// TypeScript falls back to ES5, and syntax that needs ES2015 or later (a u
|
|
19
|
+
// flag on a regex literal, for one) fails `next build`.
|
|
20
|
+
const next14 = {
|
|
21
|
+
lib: ["lib.dom.d.ts", "lib.dom.iterable.d.ts", "lib.esnext.d.ts"],
|
|
22
|
+
allowJs: true,
|
|
23
|
+
skipLibCheck: true,
|
|
24
|
+
strict: true,
|
|
25
|
+
noEmit: true,
|
|
26
|
+
esModuleInterop: true,
|
|
27
|
+
module: ts.ModuleKind.ESNext,
|
|
28
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
29
|
+
resolveJsonModule: true,
|
|
30
|
+
isolatedModules: true,
|
|
31
|
+
jsx: ts.JsxEmit.Preserve,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("registry under a Next.js 14 tsconfig", () => {
|
|
35
|
+
it("type-checks with no target set", () => {
|
|
36
|
+
const program = ts.createProgram(sourceFiles(registry), next14)
|
|
37
|
+
const errors = ts.getPreEmitDiagnostics(program).map((d) => {
|
|
38
|
+
const where = d.file ? `${d.file.fileName.replace(registry, "registry")}:${d.file.getLineAndCharacterOfPosition(d.start ?? 0).line + 1}` : ""
|
|
39
|
+
return `${where} TS${d.code} ${ts.flattenDiagnosticMessageText(d.messageText, " ")}`
|
|
40
|
+
})
|
|
41
|
+
expect(errors).toEqual([])
|
|
42
|
+
}, 60_000)
|
|
43
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import { Pagination } from "./blocks/data-table/DataTable"
|
|
4
|
+
import { EmptySearchResults, EmptyData } from "./blocks/empty-state"
|
|
5
|
+
import { PageHeader } from "./blocks/page-header/PageHeader"
|
|
6
|
+
import { Button } from "./ui/button"
|
|
7
|
+
import { Combobox } from "./ui/combobox"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every optional text prop added for translation falls back to its English
|
|
11
|
+
* default when a caller passes it explicitly as undefined, as a translation
|
|
12
|
+
* lookup that misses a key does.
|
|
13
|
+
*/
|
|
14
|
+
describe("text props passed as undefined keep their English default", () => {
|
|
15
|
+
it("Pagination", () => {
|
|
16
|
+
render(
|
|
17
|
+
<Pagination
|
|
18
|
+
page={2}
|
|
19
|
+
totalPages={5}
|
|
20
|
+
onPageChange={() => {}}
|
|
21
|
+
labels={{
|
|
22
|
+
pagination: undefined,
|
|
23
|
+
status: undefined,
|
|
24
|
+
previous: undefined,
|
|
25
|
+
next: undefined,
|
|
26
|
+
goToPrevious: undefined,
|
|
27
|
+
goToNext: undefined,
|
|
28
|
+
goToPage: undefined,
|
|
29
|
+
}}
|
|
30
|
+
/>
|
|
31
|
+
)
|
|
32
|
+
expect(screen.getByRole("navigation", { name: "Pagination" })).toBeTruthy()
|
|
33
|
+
expect(screen.getByText("Page 2 of 5")).toBeTruthy()
|
|
34
|
+
expect(screen.getByRole("button", { name: "Go to previous page" }).textContent).toBe("Previous")
|
|
35
|
+
expect(screen.getByRole("button", { name: "Go to page 3" })).toBeTruthy()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("EmptySearchResults", () => {
|
|
39
|
+
render(<EmptySearchResults query="linen" title={undefined} description={undefined} />)
|
|
40
|
+
expect(screen.getByText("No results found")).toBeTruthy()
|
|
41
|
+
expect(screen.getByText('No results for "linen". Try a different search term.')).toBeTruthy()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("EmptyData", () => {
|
|
45
|
+
render(<EmptyData resourceName="orders" title={undefined} description={undefined} />)
|
|
46
|
+
expect(screen.getByText("No orders yet")).toBeTruthy()
|
|
47
|
+
expect(screen.getByText("Get started by creating your first order.")).toBeTruthy()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("PageHeader", () => {
|
|
51
|
+
render(<PageHeader title="Orders" breadcrumb={<a href="/">Home</a>} breadcrumbLabel={undefined} />)
|
|
52
|
+
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeTruthy()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("Button", () => {
|
|
56
|
+
render(<Button loading loadingLabel={undefined}>Save</Button>)
|
|
57
|
+
expect(screen.getByText("Loading")).toBeTruthy()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("Combobox", () => {
|
|
61
|
+
render(
|
|
62
|
+
<Combobox
|
|
63
|
+
options={[]}
|
|
64
|
+
loading
|
|
65
|
+
loadingText={undefined}
|
|
66
|
+
openLabel={undefined}
|
|
67
|
+
closeLabel={undefined}
|
|
68
|
+
removeLabel={undefined}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
expect(screen.getByRole("button", { name: "Open" })).toBeTruthy()
|
|
72
|
+
})
|
|
73
|
+
})
|