@smicolon/fasla-ui 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +44 -20
  2. package/dist/index.cjs +319 -0
  3. package/dist/index.d.cts +5 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.js +92 -24
  6. package/dist/lib/utils.cjs +11 -0
  7. package/dist/lib/utils.d.cts +9 -0
  8. package/dist/motion/index.cjs +61 -0
  9. package/dist/motion/index.d.cts +33 -0
  10. package/dist/registry/index.cjs +179 -0
  11. package/dist/registry/index.d.cts +17 -0
  12. package/dist/registry/index.js +32 -3
  13. package/dist/tokens/index.cjs +77 -0
  14. package/dist/tokens/index.d.cts +124 -0
  15. package/dist/tokens/index.d.ts +75 -6
  16. package/dist/tokens/index.js +59 -20
  17. package/package.json +17 -7
  18. package/registry/blocks/app-shell/AppShell.tsx +1 -1
  19. package/registry/blocks/data-table/DataTable.test.tsx +61 -0
  20. package/registry/blocks/data-table/DataTable.tsx +53 -16
  21. package/registry/blocks/data-table/index.ts +1 -0
  22. package/registry/blocks/empty-state/EmptyState.test.tsx +23 -0
  23. package/registry/blocks/empty-state/EmptyState.tsx +32 -33
  24. package/registry/blocks/form-section/FormSection.tsx +1 -1
  25. package/registry/blocks/navbar/Navbar.test.tsx +12 -0
  26. package/registry/blocks/navbar/Navbar.tsx +8 -23
  27. package/registry/blocks/page-header/PageHeader.tsx +5 -2
  28. package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
  29. package/registry/blocks/sidebar/Sidebar.tsx +36 -26
  30. package/registry/blocks/stats-card/StatsCard.tsx +12 -29
  31. package/registry/effects/animated-gradient/animated-gradient.tsx +19 -8
  32. package/registry/effects/border-beam/border-beam.tsx +77 -53
  33. package/registry/effects/effects.test.tsx +266 -0
  34. package/registry/effects/glow-card/glow-card.tsx +68 -43
  35. package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
  36. package/registry/effects/spotlight/spotlight.tsx +73 -29
  37. package/registry/effects/text-reveal/index.ts +1 -0
  38. package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
  39. package/registry/effects/text-reveal/text-reveal.tsx +70 -21
  40. package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
  41. package/registry/next14-tsconfig.test.ts +43 -0
  42. package/registry/text-props-undefined.test.tsx +73 -0
  43. package/registry/ui/avatar/avatar.test.tsx +316 -0
  44. package/registry/ui/avatar/avatar.tsx +235 -108
  45. package/registry/ui/avatar/index.ts +1 -1
  46. package/registry/ui/badge/badge.test.tsx +267 -32
  47. package/registry/ui/badge/badge.tsx +233 -21
  48. package/registry/ui/button/button.test.tsx +6 -0
  49. package/registry/ui/button/button.tsx +6 -23
  50. package/registry/ui/card/card.tsx +1 -1
  51. package/registry/ui/checkbox/checkbox.test.tsx +23 -0
  52. package/registry/ui/checkbox/checkbox.tsx +16 -27
  53. package/registry/ui/combobox/combobox.test.tsx +72 -0
  54. package/registry/ui/combobox/combobox.tsx +34 -59
  55. package/registry/ui/input/input.test.tsx +3 -3
  56. package/registry/ui/input/input.tsx +5 -5
  57. package/registry/ui/radio/index.ts +1 -0
  58. package/registry/ui/radio/radio.test.tsx +248 -0
  59. package/registry/ui/radio/radio.tsx +208 -0
  60. package/registry/ui/select/select.tsx +4 -14
  61. package/registry/ui/skeleton/index.ts +1 -0
  62. package/registry/ui/skeleton/skeleton.test.tsx +113 -29
  63. package/registry/ui/skeleton/skeleton.tsx +101 -41
  64. package/registry/ui/status-indicator/index.ts +6 -0
  65. package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
  66. package/registry/ui/status-indicator/status-indicator.tsx +87 -0
  67. package/registry/ui/switch/index.ts +1 -1
  68. package/registry/ui/switch/switch.test.tsx +323 -0
  69. package/registry/ui/switch/switch.tsx +254 -57
  70. package/registry/ui/textarea/textarea.tsx +1 -1
  71. package/registry.json +79 -16
  72. package/tailwind-preset.ts +143 -0
@@ -0,0 +1,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
- "pr-10",
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 right-0 flex items-center pr-3 text-muted-foreground">
75
- <svg
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
  )
@@ -2,6 +2,7 @@ export {
2
2
  Skeleton,
3
3
  SkeletonText,
4
4
  SkeletonAvatar,
5
+ SkeletonListItem,
5
6
  SkeletonCard,
6
7
  type SkeletonProps,
7
8
  } from "./skeleton"
@@ -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 { Skeleton, SkeletonText, SkeletonAvatar, SkeletonCard } from "./skeleton"
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("renders with animation by default", () => {
18
+ it("uses the secondary token, as Figma does", () => {
12
19
  render(<Skeleton data-testid="skeleton" />)
13
- expect(screen.getByTestId("skeleton")).toHaveClass("animate-pulse")
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 (rounded-md)", () => {
35
+ it("renders with default variant (4px radius)", () => {
22
36
  render(<Skeleton data-testid="skeleton" />)
23
- expect(screen.getByTestId("skeleton")).toHaveClass("rounded-md")
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 with default 3 lines", () => {
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(3)
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("last line is shorter when multiple lines", () => {
87
+ it("makes every line a full-width 16px bar", () => {
56
88
  render(<SkeletonText lines={3} data-testid="skeleton-text" />)
57
- const container = screen.getByTestId("skeleton-text")
58
- const lastLine = container.children[2]
59
- expect(lastLine).toHaveClass("w-4/5")
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("single line is full width", () => {
63
- render(<SkeletonText lines={1} data-testid="skeleton-text" />)
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
- const line = container.children[0]
66
- expect(line).toHaveClass("w-full")
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 with default size", () => {
129
+ it("renders at 48px by default", () => {
77
130
  render(<SkeletonAvatar data-testid="skeleton-avatar" />)
78
- expect(screen.getByTestId("skeleton-avatar")).toHaveClass("h-10", "w-10")
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
- expect(screen.getByTestId("skeleton-avatar")).toHaveClass("h-16", "w-16")
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("renders a card container", () => {
169
+ it("has no card surface of its own", () => {
89
170
  render(<SkeletonCard data-testid="skeleton-card" />)
90
- expect(screen.getByTestId("skeleton-card")).toHaveClass("rounded-xl", "border", "bg-card", "shadow")
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("contains an avatar skeleton", () => {
94
- const { container } = render(<SkeletonCard />)
95
- const avatars = container.querySelectorAll(".rounded-full")
96
- expect(avatars.length).toBeGreaterThan(0)
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("contains text skeletons", () => {
100
- const { container } = render(<SkeletonCard />)
101
- const skeletons = container.querySelectorAll(".animate-pulse")
102
- expect(skeletons.length).toBeGreaterThan(0)
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
- function Skeleton({
12
- className,
13
- variant = "default",
14
- animate = true,
15
- ...props
16
- }: SkeletonProps) {
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-primary/10",
21
- animate && "animate-pulse",
22
- variant === "default" && "rounded-md",
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 = 3,
73
+ lines = 2,
74
+ animate = true,
36
75
  ...props
37
- }: SkeletonProps & { lines?: number }) {
76
+ }: SkeletonGroupProps) {
38
77
  return (
39
- <div className={cn("space-y-2", className)} {...props}>
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-10 w-10", className)}
96
+ className={cn("h-12 w-12 shrink-0", className)}
59
97
  {...props}
60
98
  />
61
99
  )
62
100
  }
63
101
 
64
- /** Pre-built skeleton for cards */
65
- function SkeletonCard({ className, ...props }: SkeletonProps) {
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
- className={cn(
69
- "rounded-xl border bg-card p-6 shadow",
70
- className
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
- <div className="flex items-center space-x-4">
75
- <SkeletonAvatar />
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 }
@@ -0,0 +1,6 @@
1
+ export {
2
+ StatusIndicator,
3
+ statusIndicatorVariants,
4
+ STATUS_LABELS,
5
+ type StatusIndicatorProps,
6
+ } from "./status-indicator"