@smicolon/fasla-ui 0.3.0 → 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 +43 -20
- package/dist/index.cjs +319 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.js +1 -1
- 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 +1 -1
- package/dist/tokens/index.cjs +77 -0
- package/dist/tokens/index.d.cts +124 -0
- package/package.json +14 -7
- package/registry/blocks/empty-state/EmptyState.test.tsx +11 -0
- package/registry/blocks/empty-state/EmptyState.tsx +10 -2
- package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
- package/registry/blocks/sidebar/Sidebar.tsx +28 -11
- package/registry/blocks/stats-card/StatsCard.tsx +3 -2
- package/registry/effects/animated-gradient/animated-gradient.tsx +3 -7
- package/registry/effects/text-reveal/text-reveal.tsx +3 -1
- package/registry/next14-tsconfig.test.ts +43 -0
- package/registry/ui/checkbox/checkbox.test.tsx +23 -0
- package/registry/ui/checkbox/checkbox.tsx +3 -1
- package/registry/ui/combobox/combobox.test.tsx +30 -1
- package/registry/ui/combobox/combobox.tsx +7 -4
- package/registry/ui/input/input.test.tsx +1 -1
- package/registry/ui/input/input.tsx +1 -1
- 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.json +1 -1
|
@@ -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,23 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import { Checkbox } from "./checkbox"
|
|
4
|
+
|
|
5
|
+
describe("Checkbox id", () => {
|
|
6
|
+
it("uses the id it is given", () => {
|
|
7
|
+
render(<Checkbox id="terms" label="Accept the terms" />)
|
|
8
|
+
expect(screen.getByLabelText("Accept the terms").id).toBe("terms")
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("generates an id that ties the label to the input", () => {
|
|
12
|
+
render(<Checkbox label="Accept the terms" />)
|
|
13
|
+
expect(screen.getByLabelText("Accept the terms").id).not.toBe("")
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("keeps working when the id is added and removed between renders", () => {
|
|
17
|
+
const { rerender } = render(<Checkbox label="Accept the terms" />)
|
|
18
|
+
rerender(<Checkbox id="terms" label="Accept the terms" />)
|
|
19
|
+
expect(screen.getByLabelText("Accept the terms").id).toBe("terms")
|
|
20
|
+
rerender(<Checkbox label="Accept the terms" />)
|
|
21
|
+
expect(screen.getByLabelText("Accept the terms").id).not.toBe("terms")
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -26,7 +26,9 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
|
|
26
26
|
}
|
|
27
27
|
}, [indeterminate])
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Called on every render, so the hook order holds when id comes and goes
|
|
30
|
+
const generatedId = React.useId()
|
|
31
|
+
const inputId = id || generatedId
|
|
30
32
|
|
|
31
33
|
const checkbox = (
|
|
32
34
|
<div className="relative flex items-center">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
1
|
+
import { describe, it, expect, beforeAll } from "vitest"
|
|
2
2
|
import { render, screen, fireEvent } from "@testing-library/react"
|
|
3
3
|
import { Combobox } from "./combobox"
|
|
4
4
|
|
|
@@ -41,3 +41,32 @@ describe("Combobox text", () => {
|
|
|
41
41
|
expect(screen.getByText("جارٍ التحميل")).toBeTruthy()
|
|
42
42
|
})
|
|
43
43
|
})
|
|
44
|
+
|
|
45
|
+
describe("Combobox highlight", () => {
|
|
46
|
+
// The highlighted option is scrolled into view, which jsdom does not implement
|
|
47
|
+
beforeAll(() => {
|
|
48
|
+
Element.prototype.scrollIntoView ??= () => {}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("moves back to the first option when the search changes", () => {
|
|
52
|
+
const cities = [...options, { value: "dammam", label: "Dammam" }]
|
|
53
|
+
render(<Combobox options={cities} />)
|
|
54
|
+
const input = screen.getByRole("combobox")
|
|
55
|
+
|
|
56
|
+
fireEvent.focus(input)
|
|
57
|
+
fireEvent.keyDown(input, { key: "ArrowDown" })
|
|
58
|
+
fireEvent.keyDown(input, { key: "ArrowDown" })
|
|
59
|
+
expect(screen.getByRole("option", { name: "Dammam" })).toHaveAttribute("data-highlighted", "true")
|
|
60
|
+
|
|
61
|
+
fireEvent.change(input, { target: { value: "a" } })
|
|
62
|
+
expect(screen.getByRole("option", { name: "Riyadh" })).toHaveAttribute("data-highlighted", "true")
|
|
63
|
+
expect(screen.getByRole("option", { name: "Dammam" })).toHaveAttribute("data-highlighted", "false")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("quotes the search in the create option", () => {
|
|
67
|
+
render(<Combobox options={options} creatable onCreate={() => {}} />)
|
|
68
|
+
const input = screen.getByRole("combobox")
|
|
69
|
+
fireEvent.change(input, { target: { value: "Mecca" } })
|
|
70
|
+
expect(screen.getByRole("option").textContent).toBe('Create "Mecca"')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -189,10 +189,13 @@ export function Combobox({
|
|
|
189
189
|
}
|
|
190
190
|
}, [highlightedIndex, open])
|
|
191
191
|
|
|
192
|
-
// Reset highlighted index when search changes
|
|
193
|
-
|
|
192
|
+
// Reset highlighted index when search changes. Done while rendering rather
|
|
193
|
+
// than in an effect, so the list never paints with the old highlight.
|
|
194
|
+
const [highlightedSearch, setHighlightedSearch] = React.useState(search)
|
|
195
|
+
if (highlightedSearch !== search) {
|
|
196
|
+
setHighlightedSearch(search)
|
|
194
197
|
setHighlightedIndex(0)
|
|
195
|
-
}
|
|
198
|
+
}
|
|
196
199
|
|
|
197
200
|
// Close on outside click
|
|
198
201
|
React.useEffect(() => {
|
|
@@ -334,7 +337,7 @@ export function Combobox({
|
|
|
334
337
|
)}
|
|
335
338
|
>
|
|
336
339
|
<PlusIcon size={16} strokeWidth={1.5} className="me-2" />
|
|
337
|
-
{createText}
|
|
340
|
+
{createText} "{search}"
|
|
338
341
|
</li>
|
|
339
342
|
)}
|
|
340
343
|
</>
|
|
@@ -16,7 +16,7 @@ describe("Input", () => {
|
|
|
16
16
|
expect(screen.getByTestId("input")).toHaveClass("border-destructive")
|
|
17
17
|
|
|
18
18
|
rerender(<Input variant="success" data-testid="input" />)
|
|
19
|
-
expect(screen.getByTestId("input")).toHaveClass("border-
|
|
19
|
+
expect(screen.getByTestId("input")).toHaveClass("border-success")
|
|
20
20
|
})
|
|
21
21
|
|
|
22
22
|
it("renders with different sizes", () => {
|
|
@@ -9,7 +9,7 @@ const inputVariants = cva(
|
|
|
9
9
|
variant: {
|
|
10
10
|
default: "border-input shadow-sm",
|
|
11
11
|
error: "border-destructive focus-visible:ring-destructive",
|
|
12
|
-
success: "border-
|
|
12
|
+
success: "border-success focus-visible:ring-success",
|
|
13
13
|
},
|
|
14
14
|
inputSize: {
|
|
15
15
|
default: "h-9 px-3 py-1",
|
|
@@ -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 }
|
package/registry.json
CHANGED
|
@@ -421,7 +421,7 @@
|
|
|
421
421
|
"name": "skeleton",
|
|
422
422
|
"type": "registry:ui",
|
|
423
423
|
"title": "Skeleton",
|
|
424
|
-
"description": "Loading
|
|
424
|
+
"description": "Loading placeholders that replace shadcn's Skeleton: a plain block plus text, avatar, list item and card shapes that stay still under reduced motion.",
|
|
425
425
|
"dependencies": [],
|
|
426
426
|
"registryDependencies": [],
|
|
427
427
|
"files": [
|