@smicolon/fasla-ui 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -20
- package/dist/index.cjs +319 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +92 -24
- package/dist/lib/utils.cjs +11 -0
- package/dist/lib/utils.d.cts +9 -0
- package/dist/motion/index.cjs +61 -0
- package/dist/motion/index.d.cts +33 -0
- package/dist/registry/index.cjs +179 -0
- package/dist/registry/index.d.cts +17 -0
- package/dist/registry/index.js +32 -3
- package/dist/tokens/index.cjs +77 -0
- package/dist/tokens/index.d.cts +124 -0
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +17 -7
- package/registry/blocks/app-shell/AppShell.tsx +1 -1
- package/registry/blocks/data-table/DataTable.test.tsx +61 -0
- package/registry/blocks/data-table/DataTable.tsx +53 -16
- package/registry/blocks/data-table/index.ts +1 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +23 -0
- package/registry/blocks/empty-state/EmptyState.tsx +32 -33
- package/registry/blocks/form-section/FormSection.tsx +1 -1
- package/registry/blocks/navbar/Navbar.test.tsx +12 -0
- package/registry/blocks/navbar/Navbar.tsx +8 -23
- package/registry/blocks/page-header/PageHeader.tsx +5 -2
- package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
- package/registry/blocks/sidebar/Sidebar.tsx +36 -26
- package/registry/blocks/stats-card/StatsCard.tsx +12 -29
- package/registry/effects/animated-gradient/animated-gradient.tsx +19 -8
- package/registry/effects/border-beam/border-beam.tsx +77 -53
- package/registry/effects/effects.test.tsx +266 -0
- package/registry/effects/glow-card/glow-card.tsx +68 -43
- package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
- package/registry/effects/spotlight/spotlight.tsx +73 -29
- package/registry/effects/text-reveal/index.ts +1 -0
- package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
- package/registry/effects/text-reveal/text-reveal.tsx +70 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
- package/registry/next14-tsconfig.test.ts +43 -0
- package/registry/text-props-undefined.test.tsx +73 -0
- package/registry/ui/avatar/avatar.test.tsx +316 -0
- package/registry/ui/avatar/avatar.tsx +235 -108
- package/registry/ui/avatar/index.ts +1 -1
- package/registry/ui/badge/badge.test.tsx +267 -32
- package/registry/ui/badge/badge.tsx +233 -21
- package/registry/ui/button/button.test.tsx +6 -0
- package/registry/ui/button/button.tsx +6 -23
- package/registry/ui/card/card.tsx +1 -1
- package/registry/ui/checkbox/checkbox.test.tsx +23 -0
- package/registry/ui/checkbox/checkbox.tsx +16 -27
- package/registry/ui/combobox/combobox.test.tsx +72 -0
- package/registry/ui/combobox/combobox.tsx +34 -59
- package/registry/ui/input/input.test.tsx +3 -3
- package/registry/ui/input/input.tsx +5 -5
- package/registry/ui/radio/index.ts +1 -0
- package/registry/ui/radio/radio.test.tsx +248 -0
- package/registry/ui/radio/radio.tsx +208 -0
- package/registry/ui/select/select.tsx +4 -14
- package/registry/ui/skeleton/index.ts +1 -0
- package/registry/ui/skeleton/skeleton.test.tsx +113 -29
- package/registry/ui/skeleton/skeleton.tsx +101 -41
- package/registry/ui/status-indicator/index.ts +6 -0
- package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
- package/registry/ui/status-indicator/status-indicator.tsx +87 -0
- package/registry/ui/switch/index.ts +1 -1
- package/registry/ui/switch/switch.test.tsx +323 -0
- package/registry/ui/switch/switch.tsx +254 -57
- package/registry/ui/textarea/textarea.tsx +1 -1
- package/registry.json +79 -16
- package/tailwind-preset.ts +143 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva } from "class-variance-authority"
|
|
3
|
+
import { cn } from "../../../src/lib/utils"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The height of one line of label text, in whichever script is rendering.
|
|
7
|
+
*
|
|
8
|
+
* The type ramp publishes `--leading-sm` as a *unitless ratio* (`calc(20 / 14)`),
|
|
9
|
+
* not a length, so the line box is ratio × font-size. Writing it this way makes
|
|
10
|
+
* the control direction-aware for free: under `[dir="rtl"]` the ramp re-points
|
|
11
|
+
* `--leading-sm` at Cairo's 24px leading and the control re-centres itself with
|
|
12
|
+
* no RTL-specific class. The fallback, English's 20 / 14, is for a project that
|
|
13
|
+
* does not load the Fasla preset, where `--leading-sm` does not exist.
|
|
14
|
+
*
|
|
15
|
+
* The label deliberately has no fixed height. Figma pins its label boxes to
|
|
16
|
+
* 16/20/24px, which is a defect there — at `sm` a 16px box holds 20px English
|
|
17
|
+
* and 24px Arabic text — and it must not be mirrored here. The ramp sets the
|
|
18
|
+
* leading; nothing else does.
|
|
19
|
+
*/
|
|
20
|
+
const LINE_BOX = "h-[calc(var(--leading-sm,calc(20/14))*0.875rem)]"
|
|
21
|
+
|
|
22
|
+
const radioVariants = cva(
|
|
23
|
+
"group relative inline-flex cursor-pointer items-start gap-3 has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50",
|
|
24
|
+
{
|
|
25
|
+
variants: {
|
|
26
|
+
variant: {
|
|
27
|
+
default: "",
|
|
28
|
+
// In Layout the whole card is the target, so hover fills it and focus
|
|
29
|
+
// rings it. The card ships with no fill, so hover *adds* one.
|
|
30
|
+
layout:
|
|
31
|
+
"rounded-md border p-2 transition-colors motion-reduce:transition-none hover:bg-accent",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: {
|
|
35
|
+
variant: "default",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Control, dot and hover halo per size. The dot is always the control less 8px;
|
|
42
|
+
* the halo is always the control plus 8px.
|
|
43
|
+
*/
|
|
44
|
+
const sizeClasses = {
|
|
45
|
+
sm: { control: "size-4", dot: "size-2", halo: "size-6" },
|
|
46
|
+
md: { control: "size-5", dot: "size-3", halo: "size-7" },
|
|
47
|
+
lg: { control: "size-6", dot: "size-4", halo: "size-8" },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RadioProps
|
|
51
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
|
|
52
|
+
/**
|
|
53
|
+
* Visual treatment. `layout` wraps the control and text in a bordered card.
|
|
54
|
+
* Declared here rather than inherited from `VariantProps`, which widens every
|
|
55
|
+
* variant with `| null` and shows a meaningless third option in Storybook.
|
|
56
|
+
*/
|
|
57
|
+
variant?: "default" | "layout"
|
|
58
|
+
/** Label text. Without it (or `description`) pass `aria-label` for an accessible name. */
|
|
59
|
+
label?: string
|
|
60
|
+
/** Secondary line below the label */
|
|
61
|
+
description?: string
|
|
62
|
+
/** Size variant */
|
|
63
|
+
size?: "sm" | "md" | "lg"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
|
|
67
|
+
(
|
|
68
|
+
{
|
|
69
|
+
className,
|
|
70
|
+
variant = "default",
|
|
71
|
+
size = "md",
|
|
72
|
+
label,
|
|
73
|
+
description,
|
|
74
|
+
id,
|
|
75
|
+
"aria-describedby": ariaDescribedBy,
|
|
76
|
+
...props
|
|
77
|
+
},
|
|
78
|
+
ref
|
|
79
|
+
) => {
|
|
80
|
+
const generatedId = React.useId()
|
|
81
|
+
const inputId = id || generatedId
|
|
82
|
+
const descriptionId = `${inputId}-description`
|
|
83
|
+
const sizes = sizeClasses[size]
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<label
|
|
87
|
+
htmlFor={inputId}
|
|
88
|
+
className={cn(radioVariants({ variant }), className)}
|
|
89
|
+
>
|
|
90
|
+
<input
|
|
91
|
+
type="radio"
|
|
92
|
+
ref={ref}
|
|
93
|
+
id={inputId}
|
|
94
|
+
className="peer sr-only"
|
|
95
|
+
aria-describedby={
|
|
96
|
+
description ? cn(descriptionId, ariaDescribedBy) : ariaDescribedBy
|
|
97
|
+
}
|
|
98
|
+
{...props}
|
|
99
|
+
/>
|
|
100
|
+
{/*
|
|
101
|
+
* The control sits in a band exactly one line tall and centres itself
|
|
102
|
+
* in it, so it aligns optically with the *first* line of the label
|
|
103
|
+
* rather than drifting to the middle of a wrapped block. At `lg` the
|
|
104
|
+
* 24px control is taller than a 20px English line and overflows the
|
|
105
|
+
* band by 2px a side — deliberate, and well inside the card's 8px
|
|
106
|
+
* padding.
|
|
107
|
+
*/}
|
|
108
|
+
{/*
|
|
109
|
+
* Focus rings are real elements, exactly as Figma models them — an
|
|
110
|
+
* ellipse at −2,−2 for Default and a rounded rectangle at −1,−1 for
|
|
111
|
+
* Layout, rather than a property of the control or the card.
|
|
112
|
+
*
|
|
113
|
+
* Each is a *direct sibling of the input*, so a plain
|
|
114
|
+
* `peer-focus-visible:opacity-100` drives it. That matters beyond
|
|
115
|
+
* tidiness: `:has()` and arbitrary-variant selectors both resolve in a
|
|
116
|
+
* browser but are invisible to the pseudo-state renderer the Storybook
|
|
117
|
+
* states grid uses, so the grid would quietly stop matching the
|
|
118
|
+
* component. A simple class on a sibling is the one form that survives.
|
|
119
|
+
*/}
|
|
120
|
+
{variant === "layout" && (
|
|
121
|
+
<span
|
|
122
|
+
aria-hidden="true"
|
|
123
|
+
// −2px, not −1px: an absolutely positioned child is offset from the
|
|
124
|
+
// *padding* box, which already sits 1px inside the 1px border. −1
|
|
125
|
+
// would land the ring exactly on the border with no gap at all.
|
|
126
|
+
className="pointer-events-none absolute -inset-[2px] rounded-[calc(var(--radius)-1px)] opacity-0 ring-2 ring-ring peer-focus-visible:opacity-100"
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
{variant === "default" && (
|
|
130
|
+
<span
|
|
131
|
+
aria-hidden="true"
|
|
132
|
+
className={cn(
|
|
133
|
+
"pointer-events-none absolute start-0 top-0 flex items-center opacity-0 peer-focus-visible:opacity-100",
|
|
134
|
+
LINE_BOX
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
<span
|
|
138
|
+
className={cn(
|
|
139
|
+
"rounded-full ring-2 ring-ring ring-offset-2",
|
|
140
|
+
sizes.control
|
|
141
|
+
)}
|
|
142
|
+
/>
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
<span
|
|
146
|
+
aria-hidden="true"
|
|
147
|
+
className={cn("relative flex shrink-0 items-center", LINE_BOX)}
|
|
148
|
+
>
|
|
149
|
+
{/*
|
|
150
|
+
* Hover halo — Default only; in Layout the card fills instead. Kept
|
|
151
|
+
* absolute so it can never affect layout, which is the constraint the
|
|
152
|
+
* Figma set is built on: every Interaction variant has identical
|
|
153
|
+
* frame sizes.
|
|
154
|
+
*/}
|
|
155
|
+
{variant === "default" && (
|
|
156
|
+
<span
|
|
157
|
+
className={cn(
|
|
158
|
+
"pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent opacity-0 transition-opacity motion-reduce:transition-none group-hover:opacity-100",
|
|
159
|
+
sizes.halo
|
|
160
|
+
)}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
<span
|
|
164
|
+
data-slot="control"
|
|
165
|
+
className={cn(
|
|
166
|
+
"relative flex shrink-0 items-center justify-center rounded-full border border-input",
|
|
167
|
+
sizes.control
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
<span
|
|
171
|
+
className={cn(
|
|
172
|
+
// Figma paints the dot with theme/primary — the strong neutral
|
|
173
|
+
// (#0a0a0a light, #fafafa dark). Both apps now declare exactly
|
|
174
|
+
// that, so `bg-primary` is the honest token again. This read
|
|
175
|
+
// `bg-foreground` until the token sync, as a workaround for the
|
|
176
|
+
// docs app having repurposed `--primary` as the brand red.
|
|
177
|
+
// Figma gives primary and foreground the same value in both
|
|
178
|
+
// modes, so this is a rename, not a visual change.
|
|
179
|
+
"scale-0 rounded-full bg-primary transition-transform motion-reduce:transition-none group-has-[:checked]:scale-100",
|
|
180
|
+
sizes.dot
|
|
181
|
+
)}
|
|
182
|
+
/>
|
|
183
|
+
</span>
|
|
184
|
+
</span>
|
|
185
|
+
{(label || description) && (
|
|
186
|
+
<span className="flex min-w-0 flex-col gap-2">
|
|
187
|
+
{label && (
|
|
188
|
+
<span className="text-sm font-medium text-foreground">
|
|
189
|
+
{label}
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
{description && (
|
|
193
|
+
<span
|
|
194
|
+
id={descriptionId}
|
|
195
|
+
className="text-sm text-muted-foreground"
|
|
196
|
+
>
|
|
197
|
+
{description}
|
|
198
|
+
</span>
|
|
199
|
+
)}
|
|
200
|
+
</span>
|
|
201
|
+
)}
|
|
202
|
+
</label>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
Radio.displayName = "Radio"
|
|
207
|
+
|
|
208
|
+
export { Radio, radioVariants }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
+
import { ChevronDownIcon } from "lucide-react"
|
|
4
5
|
import { cn } from "../../../src/lib/utils"
|
|
5
6
|
|
|
6
7
|
export interface SelectOption {
|
|
@@ -51,7 +52,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|
|
51
52
|
? "border-destructive focus-visible:ring-destructive"
|
|
52
53
|
: "border-input",
|
|
53
54
|
sizeClasses[selectSize],
|
|
54
|
-
"
|
|
55
|
+
"pe-10",
|
|
55
56
|
className
|
|
56
57
|
)}
|
|
57
58
|
{...props}
|
|
@@ -71,19 +72,8 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|
|
71
72
|
</option>
|
|
72
73
|
))}
|
|
73
74
|
</select>
|
|
74
|
-
<div className="pointer-events-none absolute inset-y-0
|
|
75
|
-
<
|
|
76
|
-
width="16"
|
|
77
|
-
height="16"
|
|
78
|
-
viewBox="0 0 24 24"
|
|
79
|
-
fill="none"
|
|
80
|
-
stroke="currentColor"
|
|
81
|
-
strokeWidth="2"
|
|
82
|
-
strokeLinecap="round"
|
|
83
|
-
strokeLinejoin="round"
|
|
84
|
-
>
|
|
85
|
-
<polyline points="6 9 12 15 18 9" />
|
|
86
|
-
</svg>
|
|
75
|
+
<div className="pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3 text-muted-foreground">
|
|
76
|
+
<ChevronDownIcon size={16} strokeWidth={1.5} />
|
|
87
77
|
</div>
|
|
88
78
|
</div>
|
|
89
79
|
)
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import * as React from "react"
|
|
1
2
|
import { describe, it, expect } from "vitest"
|
|
2
3
|
import { render, screen } from "@testing-library/react"
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
Skeleton,
|
|
6
|
+
SkeletonText,
|
|
7
|
+
SkeletonAvatar,
|
|
8
|
+
SkeletonListItem,
|
|
9
|
+
SkeletonCard,
|
|
10
|
+
} from "./skeleton"
|
|
4
11
|
|
|
5
12
|
describe("Skeleton", () => {
|
|
6
13
|
it("renders with default props", () => {
|
|
@@ -8,19 +15,26 @@ describe("Skeleton", () => {
|
|
|
8
15
|
expect(screen.getByTestId("skeleton")).toBeInTheDocument()
|
|
9
16
|
})
|
|
10
17
|
|
|
11
|
-
it("
|
|
18
|
+
it("uses the secondary token, as Figma does", () => {
|
|
12
19
|
render(<Skeleton data-testid="skeleton" />)
|
|
13
|
-
expect(screen.getByTestId("skeleton")).toHaveClass("
|
|
20
|
+
expect(screen.getByTestId("skeleton")).toHaveClass("bg-secondary")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("pulses by default, only when motion is allowed", () => {
|
|
24
|
+
render(<Skeleton data-testid="skeleton" />)
|
|
25
|
+
const skeleton = screen.getByTestId("skeleton")
|
|
26
|
+
expect(skeleton).toHaveClass("motion-safe:animate-pulse")
|
|
27
|
+
expect(skeleton).not.toHaveClass("animate-pulse")
|
|
14
28
|
})
|
|
15
29
|
|
|
16
30
|
it("can disable animation", () => {
|
|
17
31
|
render(<Skeleton animate={false} data-testid="skeleton" />)
|
|
18
|
-
expect(screen.getByTestId("skeleton")).not.toHaveClass("animate-pulse")
|
|
32
|
+
expect(screen.getByTestId("skeleton")).not.toHaveClass("motion-safe:animate-pulse")
|
|
19
33
|
})
|
|
20
34
|
|
|
21
|
-
it("renders with default variant (
|
|
35
|
+
it("renders with default variant (4px radius)", () => {
|
|
22
36
|
render(<Skeleton data-testid="skeleton" />)
|
|
23
|
-
expect(screen.getByTestId("skeleton")).toHaveClass("rounded
|
|
37
|
+
expect(screen.getByTestId("skeleton")).toHaveClass("rounded")
|
|
24
38
|
})
|
|
25
39
|
|
|
26
40
|
it("renders with circular variant", () => {
|
|
@@ -37,13 +51,31 @@ describe("Skeleton", () => {
|
|
|
37
51
|
render(<Skeleton className="h-10 w-20" data-testid="skeleton" />)
|
|
38
52
|
expect(screen.getByTestId("skeleton")).toHaveClass("h-10", "w-20")
|
|
39
53
|
})
|
|
54
|
+
|
|
55
|
+
it("lets className override the radius", () => {
|
|
56
|
+
render(<Skeleton className="rounded-lg" data-testid="skeleton" />)
|
|
57
|
+
const skeleton = screen.getByTestId("skeleton")
|
|
58
|
+
expect(skeleton).toHaveClass("rounded-lg")
|
|
59
|
+
expect(skeleton).not.toHaveClass("rounded")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("is hidden from assistive technology", () => {
|
|
63
|
+
render(<Skeleton data-testid="skeleton" />)
|
|
64
|
+
expect(screen.getByTestId("skeleton")).toHaveAttribute("aria-hidden", "true")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("forwards a ref to the element", () => {
|
|
68
|
+
const ref = React.createRef<HTMLDivElement>()
|
|
69
|
+
render(<Skeleton ref={ref} data-testid="skeleton" />)
|
|
70
|
+
expect(ref.current).toBe(screen.getByTestId("skeleton"))
|
|
71
|
+
})
|
|
40
72
|
})
|
|
41
73
|
|
|
42
74
|
describe("SkeletonText", () => {
|
|
43
|
-
it("renders
|
|
75
|
+
it("renders 2 lines by default, as Figma does", () => {
|
|
44
76
|
render(<SkeletonText data-testid="skeleton-text" />)
|
|
45
77
|
const container = screen.getByTestId("skeleton-text")
|
|
46
|
-
expect(container.children).toHaveLength(
|
|
78
|
+
expect(container.children).toHaveLength(2)
|
|
47
79
|
})
|
|
48
80
|
|
|
49
81
|
it("renders with specified number of lines", () => {
|
|
@@ -52,18 +84,39 @@ describe("SkeletonText", () => {
|
|
|
52
84
|
expect(container.children).toHaveLength(5)
|
|
53
85
|
})
|
|
54
86
|
|
|
55
|
-
it("
|
|
87
|
+
it("makes every line a full-width 16px bar", () => {
|
|
56
88
|
render(<SkeletonText lines={3} data-testid="skeleton-text" />)
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
89
|
+
const lines = Array.from(screen.getByTestId("skeleton-text").children)
|
|
90
|
+
for (const line of lines) expect(line).toHaveClass("h-4", "w-full")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("stacks lines 8px apart", () => {
|
|
94
|
+
render(<SkeletonText data-testid="skeleton-text" />)
|
|
95
|
+
expect(screen.getByTestId("skeleton-text")).toHaveClass("flex", "flex-col", "gap-2")
|
|
60
96
|
})
|
|
61
97
|
|
|
62
|
-
it("
|
|
63
|
-
render(<SkeletonText
|
|
98
|
+
it("passes animate to every line", () => {
|
|
99
|
+
render(<SkeletonText animate={false} data-testid="skeleton-text" />)
|
|
100
|
+
const lines = Array.from(screen.getByTestId("skeleton-text").children)
|
|
101
|
+
for (const line of lines) expect(line).not.toHaveClass("motion-safe:animate-pulse")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("keeps animate and variant off the DOM", () => {
|
|
105
|
+
render(<SkeletonText animate variant="circular" data-testid="skeleton-text" />)
|
|
64
106
|
const container = screen.getByTestId("skeleton-text")
|
|
65
|
-
|
|
66
|
-
expect(
|
|
107
|
+
expect(container).not.toHaveAttribute("animate")
|
|
108
|
+
expect(container).not.toHaveAttribute("variant")
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("keeps variant off the DOM in the list item and card too", () => {
|
|
112
|
+
render(
|
|
113
|
+
<>
|
|
114
|
+
<SkeletonListItem variant="circular" data-testid="list-item" />
|
|
115
|
+
<SkeletonCard variant="circular" data-testid="card" />
|
|
116
|
+
</>
|
|
117
|
+
)
|
|
118
|
+
expect(screen.getByTestId("list-item")).not.toHaveAttribute("variant")
|
|
119
|
+
expect(screen.getByTestId("card")).not.toHaveAttribute("variant")
|
|
67
120
|
})
|
|
68
121
|
})
|
|
69
122
|
|
|
@@ -73,32 +126,63 @@ describe("SkeletonAvatar", () => {
|
|
|
73
126
|
expect(screen.getByTestId("skeleton-avatar")).toHaveClass("rounded-full")
|
|
74
127
|
})
|
|
75
128
|
|
|
76
|
-
it("renders
|
|
129
|
+
it("renders at 48px by default", () => {
|
|
77
130
|
render(<SkeletonAvatar data-testid="skeleton-avatar" />)
|
|
78
|
-
expect(screen.getByTestId("skeleton-avatar")).toHaveClass("h-
|
|
131
|
+
expect(screen.getByTestId("skeleton-avatar")).toHaveClass("h-12", "w-12")
|
|
79
132
|
})
|
|
80
133
|
|
|
81
134
|
it("accepts custom className to override size", () => {
|
|
82
135
|
render(<SkeletonAvatar className="h-16 w-16" data-testid="skeleton-avatar" />)
|
|
83
|
-
|
|
136
|
+
const avatar = screen.getByTestId("skeleton-avatar")
|
|
137
|
+
expect(avatar).toHaveClass("h-16", "w-16")
|
|
138
|
+
expect(avatar).not.toHaveClass("h-12", "w-12")
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe("SkeletonListItem", () => {
|
|
143
|
+
it("puts the avatar first, then the lines, 16px apart", () => {
|
|
144
|
+
render(<SkeletonListItem data-testid="skeleton-list-item" />)
|
|
145
|
+
const row = screen.getByTestId("skeleton-list-item")
|
|
146
|
+
expect(row).toHaveClass("flex", "items-center", "gap-4")
|
|
147
|
+
expect(row.children[0]).toHaveClass("rounded-full", "h-12", "w-12")
|
|
148
|
+
expect(row.children[1]!.children).toHaveLength(2)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("accepts lines", () => {
|
|
152
|
+
render(<SkeletonListItem lines={3} data-testid="skeleton-list-item" />)
|
|
153
|
+
expect(screen.getByTestId("skeleton-list-item").children[1]!.children).toHaveLength(3)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("passes animate to every block", () => {
|
|
157
|
+
const { container } = render(<SkeletonListItem animate={false} />)
|
|
158
|
+
expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(3)
|
|
159
|
+
expect(container.querySelectorAll(".motion-safe\\:animate-pulse")).toHaveLength(0)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("is hidden from assistive technology", () => {
|
|
163
|
+
render(<SkeletonListItem data-testid="skeleton-list-item" />)
|
|
164
|
+
expect(screen.getByTestId("skeleton-list-item")).toHaveAttribute("aria-hidden", "true")
|
|
84
165
|
})
|
|
85
166
|
})
|
|
86
167
|
|
|
87
168
|
describe("SkeletonCard", () => {
|
|
88
|
-
it("
|
|
169
|
+
it("has no card surface of its own", () => {
|
|
89
170
|
render(<SkeletonCard data-testid="skeleton-card" />)
|
|
90
|
-
|
|
171
|
+
const card = screen.getByTestId("skeleton-card")
|
|
172
|
+
expect(card).toHaveClass("flex", "flex-col", "gap-4")
|
|
173
|
+
expect(card).not.toHaveClass("border", "bg-card", "shadow")
|
|
91
174
|
})
|
|
92
175
|
|
|
93
|
-
it("
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
expect(
|
|
176
|
+
it("puts a 122px media block above 2 lines", () => {
|
|
177
|
+
render(<SkeletonCard data-testid="skeleton-card" />)
|
|
178
|
+
const card = screen.getByTestId("skeleton-card")
|
|
179
|
+
expect(card.children[0]).toHaveClass("h-[122px]", "w-full", "rounded")
|
|
180
|
+
expect(card.children[1]!.children).toHaveLength(2)
|
|
97
181
|
})
|
|
98
182
|
|
|
99
|
-
it("
|
|
100
|
-
const { container } = render(<SkeletonCard />)
|
|
101
|
-
|
|
102
|
-
expect(
|
|
183
|
+
it("passes animate to every block", () => {
|
|
184
|
+
const { container } = render(<SkeletonCard animate={false} />)
|
|
185
|
+
expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(3)
|
|
186
|
+
expect(container.querySelectorAll(".motion-safe\\:animate-pulse")).toHaveLength(0)
|
|
103
187
|
})
|
|
104
188
|
})
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
2
|
import { cn } from "../../../src/lib/utils"
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Skeleton — a loading placeholder that stands in for content until it arrives.
|
|
6
|
+
*
|
|
7
|
+
* Matches the Figma set (Skeleton, `15120:8056`):
|
|
8
|
+
* - every block is `secondary`, the token Figma binds (`theme/secondary`),
|
|
9
|
+
* solid rather than a tint, so it reads the same on any surface;
|
|
10
|
+
* - bars are 16px tall with a 4px radius, stacked 8px apart;
|
|
11
|
+
* - the list item puts a 48px circle 16px before its lines;
|
|
12
|
+
* - the card puts a 122px media block 16px above its lines.
|
|
13
|
+
*
|
|
14
|
+
* Blocks are decorative, so each is `aria-hidden`. Announce loading on the
|
|
15
|
+
* region that is loading instead, with `aria-busy="true"`.
|
|
16
|
+
*
|
|
17
|
+
* The pulse is Tailwind's built-in `animate-pulse`, behind `motion-safe:`, so
|
|
18
|
+
* it stops when the user has asked for reduced motion.
|
|
19
|
+
*
|
|
20
|
+
* Every composition lays out with flex and logical order only, so in RTL the
|
|
21
|
+
* circle moves to the inline start (the right) with no `rtl:` overrides.
|
|
22
|
+
*/
|
|
23
|
+
|
|
4
24
|
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
5
25
|
/** The shape of the skeleton */
|
|
6
26
|
variant?: "default" | "circular" | "rectangular"
|
|
@@ -8,18 +28,20 @@ export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
8
28
|
animate?: boolean
|
|
9
29
|
}
|
|
10
30
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
return (
|
|
31
|
+
/**
|
|
32
|
+
* A single block. A drop-in replacement for shadcn's Skeleton: size it with
|
|
33
|
+
* `className`, such as `h-4 w-48`.
|
|
34
|
+
*/
|
|
35
|
+
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
|
|
36
|
+
({ className, variant = "default", animate = true, ...props }, ref) => (
|
|
18
37
|
<div
|
|
38
|
+
ref={ref}
|
|
39
|
+
data-slot="skeleton"
|
|
40
|
+
aria-hidden="true"
|
|
19
41
|
className={cn(
|
|
20
|
-
"bg-
|
|
21
|
-
animate && "animate-pulse",
|
|
22
|
-
variant === "default" && "rounded
|
|
42
|
+
"bg-secondary",
|
|
43
|
+
animate && "motion-safe:animate-pulse",
|
|
44
|
+
variant === "default" && "rounded",
|
|
23
45
|
variant === "circular" && "rounded-full",
|
|
24
46
|
variant === "rectangular" && "rounded-none",
|
|
25
47
|
className
|
|
@@ -27,62 +49,100 @@ function Skeleton({
|
|
|
27
49
|
{...props}
|
|
28
50
|
/>
|
|
29
51
|
)
|
|
52
|
+
)
|
|
53
|
+
Skeleton.displayName = "Skeleton"
|
|
54
|
+
|
|
55
|
+
/** Props for the compositions: `animate` reaches every block inside. */
|
|
56
|
+
type SkeletonGroupProps = SkeletonProps & {
|
|
57
|
+
/** Number of text lines */
|
|
58
|
+
lines?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A group has no shape of its own, so `variant` is taken out of its props and
|
|
63
|
+
* never reaches the DOM. The parameter's rest pattern makes the copy.
|
|
64
|
+
*/
|
|
65
|
+
function withoutVariant({ ...props }: SkeletonProps): React.HTMLAttributes<HTMLDivElement> {
|
|
66
|
+
delete props.variant
|
|
67
|
+
return props
|
|
30
68
|
}
|
|
31
69
|
|
|
32
|
-
/** Pre-built skeleton for text lines */
|
|
70
|
+
/** Pre-built skeleton for text lines. Every line is full width, as in Figma. */
|
|
33
71
|
function SkeletonText({
|
|
34
72
|
className,
|
|
35
|
-
lines =
|
|
73
|
+
lines = 2,
|
|
74
|
+
animate = true,
|
|
36
75
|
...props
|
|
37
|
-
}:
|
|
76
|
+
}: SkeletonGroupProps) {
|
|
38
77
|
return (
|
|
39
|
-
<div
|
|
78
|
+
<div
|
|
79
|
+
data-slot="skeleton-text"
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
className={cn("flex flex-col gap-2", className)}
|
|
82
|
+
{...withoutVariant(props)}
|
|
83
|
+
>
|
|
40
84
|
{Array.from({ length: lines }).map((_, i) => (
|
|
41
|
-
<Skeleton
|
|
42
|
-
key={i}
|
|
43
|
-
className={cn(
|
|
44
|
-
"h-4",
|
|
45
|
-
i === lines - 1 && lines > 1 ? "w-4/5" : "w-full"
|
|
46
|
-
)}
|
|
47
|
-
/>
|
|
85
|
+
<Skeleton key={i} animate={animate} className="h-4 w-full" />
|
|
48
86
|
))}
|
|
49
87
|
</div>
|
|
50
88
|
)
|
|
51
89
|
}
|
|
52
90
|
|
|
53
|
-
/** Pre-built skeleton for avatars */
|
|
91
|
+
/** Pre-built skeleton for avatars: a 48px circle. */
|
|
54
92
|
function SkeletonAvatar({ className, ...props }: SkeletonProps) {
|
|
55
93
|
return (
|
|
56
94
|
<Skeleton
|
|
57
95
|
variant="circular"
|
|
58
|
-
className={cn("h-
|
|
96
|
+
className={cn("h-12 w-12 shrink-0", className)}
|
|
59
97
|
{...props}
|
|
60
98
|
/>
|
|
61
99
|
)
|
|
62
100
|
}
|
|
63
101
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Pre-built skeleton for a list row: an avatar at the inline start, then text
|
|
104
|
+
* lines filling the rest. For lists, comments and profile rows.
|
|
105
|
+
*/
|
|
106
|
+
function SkeletonListItem({
|
|
107
|
+
className,
|
|
108
|
+
lines = 2,
|
|
109
|
+
animate = true,
|
|
110
|
+
...props
|
|
111
|
+
}: SkeletonGroupProps) {
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
data-slot="skeleton-list-item"
|
|
115
|
+
aria-hidden="true"
|
|
116
|
+
className={cn("flex items-center gap-4", className)}
|
|
117
|
+
{...withoutVariant(props)}
|
|
118
|
+
>
|
|
119
|
+
<SkeletonAvatar animate={animate} />
|
|
120
|
+
<SkeletonText lines={lines} animate={animate} className="min-w-0 flex-1" />
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Pre-built skeleton for cards: a media block above text lines. It has no
|
|
127
|
+
* border or background of its own, so it can sit inside a real Card.
|
|
128
|
+
*/
|
|
129
|
+
function SkeletonCard({
|
|
130
|
+
className,
|
|
131
|
+
lines = 2,
|
|
132
|
+
animate = true,
|
|
133
|
+
...props
|
|
134
|
+
}: SkeletonGroupProps) {
|
|
66
135
|
return (
|
|
67
136
|
<div
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
)}
|
|
72
|
-
{...props}
|
|
137
|
+
data-slot="skeleton-card"
|
|
138
|
+
aria-hidden="true"
|
|
139
|
+
className={cn("flex flex-col gap-4", className)}
|
|
140
|
+
{...withoutVariant(props)}
|
|
73
141
|
>
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
<div className="space-y-2 flex-1">
|
|
77
|
-
<Skeleton className="h-4 w-1/2" />
|
|
78
|
-
<Skeleton className="h-3 w-1/4" />
|
|
79
|
-
</div>
|
|
80
|
-
</div>
|
|
81
|
-
<div className="mt-4">
|
|
82
|
-
<SkeletonText lines={3} />
|
|
83
|
-
</div>
|
|
142
|
+
<Skeleton animate={animate} className="h-[122px] w-full" />
|
|
143
|
+
<SkeletonText lines={lines} animate={animate} />
|
|
84
144
|
</div>
|
|
85
145
|
)
|
|
86
146
|
}
|
|
87
147
|
|
|
88
|
-
export { Skeleton, SkeletonText, SkeletonAvatar, SkeletonCard }
|
|
148
|
+
export { Skeleton, SkeletonText, SkeletonAvatar, SkeletonListItem, SkeletonCard }
|