@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
@@ -1,135 +1,262 @@
1
+ "use client"
2
+
1
3
  import * as React from "react"
2
- import { cva, type VariantProps } from "class-variance-authority"
4
+ import { cva } from "class-variance-authority"
5
+ import { UserRoundIcon } from "lucide-react"
3
6
  import { cn } from "../../../src/lib/utils"
7
+ import { StatusIndicator, type StatusIndicatorProps } from "../status-indicator/status-indicator"
4
8
 
9
+ /**
10
+ * Built against the Figma `Avatar` set (323:321): Direction × Radius × Style ×
11
+ * Border × Size, 72 variants, plus the `Status Indicator` boolean. Every colour
12
+ * and radius below is the token the set binds.
13
+ *
14
+ * `variant` is Figma's Style. `image`, the default, shows the photo when `src`
15
+ * loads, and while it loads or if it fails falls back to initials from `name`,
16
+ * then to the user icon when there is no name either — so the avatar is never
17
+ * empty. `initials` and `icon` show that content whatever else is passed.
18
+ *
19
+ * Direction is not a prop either. The dot sits on the logical end corner, so
20
+ * `dir="rtl"` moves it to the bottom-left with no RTL-specific class, and the
21
+ * type ramp re-leads the initials to Cairo's line on its own.
22
+ */
5
23
  const avatarVariants = cva(
6
- "relative flex shrink-0 overflow-hidden rounded-full",
24
+ "relative flex size-full items-center justify-center overflow-hidden text-foreground",
7
25
  {
8
26
  variants: {
9
27
  size: {
10
- xs: "h-6 w-6 text-xs",
11
- sm: "h-8 w-8 text-sm",
12
- default: "h-10 w-10 text-sm",
13
- lg: "h-12 w-12 text-base",
14
- xl: "h-16 w-16 text-lg",
28
+ // Figma's initials: Base 16px at 32, XS 12px at 24, XXS 10px at 12.
29
+ "32": "text-base",
30
+ "24": "text-xs",
31
+ "12": "text-xxs",
32
+ },
33
+ radius: {
34
+ standard: "",
35
+ rounded: "rounded-full",
36
+ },
37
+ // Figma's stroke is `ring`, INSIDE the edge, and draws over the photo.
38
+ // An inset box-shadow on the frame itself would paint under the <img>,
39
+ // so the ring lives on a pseudo-element laid on top.
40
+ border: {
41
+ true: "after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-inset after:ring-ring after:content-['']",
42
+ false: "",
15
43
  },
16
44
  },
45
+ compoundVariants: [
46
+ // Figma `border radius/md` at 32 and 24, `border radius/xs` at 12. No
47
+ // Tailwind class is either value in both apps.
48
+ { radius: "standard", size: "32", class: "rounded-[var(--radius-md)]" },
49
+ { radius: "standard", size: "24", class: "rounded-[var(--radius-md)]" },
50
+ { radius: "standard", size: "12", class: "rounded-[var(--radius-xs)]" },
51
+ { border: true, size: "32", class: "after:ring-2" },
52
+ { border: true, size: "24", class: "after:ring-1" },
53
+ { border: true, size: "12", class: "after:ring-1" },
54
+ ],
17
55
  defaultVariants: {
18
- size: "default",
56
+ size: "32",
57
+ radius: "standard",
58
+ border: false,
19
59
  },
20
60
  }
21
61
  )
22
62
 
23
- export interface AvatarProps
24
- extends React.HTMLAttributes<HTMLDivElement>,
25
- VariantProps<typeof avatarVariants> {
26
- /** Image source URL */
27
- src?: string
28
- /** Alt text for the image */
29
- alt?: string
30
- /** Fallback initials or content when image fails to load */
31
- fallback?: React.ReactNode
63
+ const ROOT_SIZE = { "32": "size-8", "24": "size-6", "12": "size-3" } as const
64
+
65
+ /**
66
+ * The dot, 8px at 32 and 24 and 4px at 12. On Standard 32 and 24 it hangs 2px
67
+ * past the end and bottom edges; on Rounded, and at 12, it sits flush in the
68
+ * corner of the bounding box.
69
+ */
70
+ function dotPosition(size: Size, radius: Radius) {
71
+ return radius === "standard" && size !== "12" ? "-bottom-0.5 -end-0.5" : "bottom-0 end-0"
32
72
  }
33
73
 
34
- export function Avatar({
35
- className,
36
- size,
37
- src,
38
- alt,
39
- fallback,
40
- ...props
41
- }: AvatarProps) {
42
- const [imageError, setImageError] = React.useState(false)
74
+ const ARABIC = /[؀-ۿ]/
75
+ /** U+200C keeps two Arabic initials in their separate forms rather than joined. */
76
+ const ZWNJ = "‌"
43
77
 
44
- const showImage = src && !imageError
78
+ /**
79
+ * Up to two initials — the first letters of the first and last words — or one
80
+ * at size 12, as Figma shows. Latin is upper-cased; Arabic has no case.
81
+ */
82
+ function initialsFrom(name: string, count: 1 | 2) {
83
+ const words = name.trim().split(/\s+/).filter(Boolean)
84
+ if (words.length === 0) return ""
85
+ const first = Array.from(words[0]!)[0]!
86
+ const last = words.length > 1 ? Array.from(words[words.length - 1]!)[0]! : ""
87
+ const letters = count === 1 || !last ? [first] : [first, last]
88
+ const joined = letters.join(letters.some((l) => ARABIC.test(l)) ? ZWNJ : "")
89
+ return joined.toLocaleUpperCase()
90
+ }
45
91
 
46
- // Generate initials from alt text if no fallback provided
47
- const initials = React.useMemo(() => {
48
- if (fallback) return null
49
- if (!alt) return null
50
- return alt
51
- .split(" ")
52
- .map((word) => word[0])
53
- .slice(0, 2)
54
- .join("")
55
- .toUpperCase()
56
- }, [alt, fallback])
92
+ type Size = "32" | "24" | "12"
93
+ type Radius = "standard" | "rounded"
94
+ type Status = NonNullable<StatusIndicatorProps["status"]>
95
+ type Variant = "image" | "initials" | "icon"
96
+ type ImgAttributes = React.ImgHTMLAttributes<HTMLImageElement>
57
97
 
58
- return (
59
- <div className={cn(avatarVariants({ size }), className)} {...props}>
60
- {showImage ? (
61
- <img
62
- src={src}
63
- alt={alt || "Avatar"}
64
- onError={() => setImageError(true)}
65
- className="aspect-square h-full w-full object-cover"
66
- />
67
- ) : (
68
- <div className="flex h-full w-full items-center justify-center bg-muted font-medium text-muted-foreground">
69
- {fallback || initials || (
70
- <svg
71
- width="50%"
72
- height="50%"
73
- viewBox="0 0 24 24"
74
- fill="none"
75
- stroke="currentColor"
76
- strokeWidth="2"
77
- >
78
- <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
79
- <circle cx="12" cy="7" r="4" />
80
- </svg>
81
- )}
82
- </div>
83
- )}
84
- </div>
85
- )
98
+ export interface AvatarProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
99
+ /**
100
+ * Figma `Style` — named `variant`, as Badge names Figma's Type, so React's
101
+ * inline `style` stays available. `image` shows the photo, falling back to
102
+ * initials, then the icon. `initials` always shows the initials (the icon if
103
+ * there is no name); `icon` always shows the icon.
104
+ */
105
+ variant?: Variant
106
+ /** Figma `Size`, in px. */
107
+ size?: Size
108
+ /** Figma `Radius`: `standard` is `border radius/md` (`xs` at 12), `rounded` a circle. */
109
+ radius?: Radius
110
+ /** Figma `Border`: a `ring` stroke inside the edge. */
111
+ border?: boolean
112
+ /** The photo, for `variant="image"`. While it loads, or if it fails, the avatar shows its fallback. */
113
+ src?: string
114
+ /** Passed to the `<img>`: responsive sources for sharper photos on dense screens. */
115
+ srcSet?: ImgAttributes["srcSet"]
116
+ /** Passed to the `<img>`, alongside `srcSet`. */
117
+ sizes?: ImgAttributes["sizes"]
118
+ /** Passed to the `<img>`. `"lazy"` defers photos below the fold, as in a long list. */
119
+ loading?: ImgAttributes["loading"]
120
+ /** Passed to the `<img>`. */
121
+ decoding?: ImgAttributes["decoding"]
122
+ /** Passed to the `<img>`, for photos served from another origin. */
123
+ crossOrigin?: ImgAttributes["crossOrigin"]
124
+ /** Passed to the `<img>`. */
125
+ referrerPolicy?: ImgAttributes["referrerPolicy"]
126
+ /**
127
+ * The person's name. It is the photo's `alt`, the source of the initials —
128
+ * the first letters of the first and last words, one letter at size 12 — and
129
+ * the accessible name of every variant. Pass `""` when a label beside the
130
+ * avatar already names the person.
131
+ */
132
+ name?: string
133
+ /**
134
+ * Shows the status dot. Off unless set: a dot on every avatar would claim
135
+ * presence nobody measured. Its name — "Online" / "متصل" and so on — follows
136
+ * the page language.
137
+ */
138
+ status?: Status
139
+ /** Overrides the dot's accessible name. Pass `""` to silence it, as `name=""` silences the avatar. */
140
+ statusLabel?: string
86
141
  }
87
142
 
88
- export interface AvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
89
- /** Maximum number of avatars to show */
90
- max?: number
91
- /** Size of avatars */
92
- size?: "xs" | "sm" | "default" | "lg" | "xl"
93
- children: React.ReactNode
94
- }
143
+ type ImageState = "loading" | "loaded" | "error"
144
+ type Photo = { src: string; srcSet?: string }
145
+ /** Identifies a photo by its sources; "" when there is none. */
146
+ const photoKey = (photo?: Photo | null) => (photo ? `${photo.src}\n${photo.srcSet ?? ""}` : "")
95
147
 
96
- export function AvatarGroup({
97
- children,
98
- max = 4,
99
- size = "default",
100
- className,
101
- ...props
102
- }: AvatarGroupProps) {
103
- const childArray = React.Children.toArray(children)
104
- const visibleChildren = childArray.slice(0, max)
105
- const remainingCount = childArray.length - max
148
+ const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
149
+ (
150
+ {
151
+ className,
152
+ variant = "image",
153
+ size = "32",
154
+ radius = "standard",
155
+ border = false,
156
+ src,
157
+ srcSet,
158
+ sizes,
159
+ loading,
160
+ decoding,
161
+ crossOrigin,
162
+ referrerPolicy,
163
+ name,
164
+ status,
165
+ statusLabel,
166
+ ...props
167
+ },
168
+ ref
169
+ ) => {
170
+ const requested = variant === "image" && src ? { src, srcSet } : undefined
171
+ const requestedKey = photoKey(requested)
172
+ // The loading state belongs to one source. Any change of `src` or `srcSet`
173
+ // starts again from "loading" in the same render — the fallback shows until
174
+ // the new photo loads, and a source that failed before is tried afresh.
175
+ // shadcn's and Radix's Avatar behave the same way.
176
+ const [state, setState] = React.useState<{ key: string; image: ImageState }>({
177
+ key: requestedKey,
178
+ image: "loading",
179
+ })
180
+ const image = state.key === requestedKey ? state.image : "loading"
181
+ const imgRef = React.useRef<HTMLImageElement>(null)
106
182
 
107
- return (
108
- <div className={cn("flex -space-x-2", className)} {...props}>
109
- {visibleChildren.map((child, index) => (
110
- <div
111
- key={index}
112
- className="ring-2 ring-background rounded-full"
113
- style={{ zIndex: visibleChildren.length - index }}
114
- >
115
- {React.isValidElement(child)
116
- ? React.cloneElement(child as React.ReactElement<AvatarProps>, { size })
117
- : child}
118
- </div>
119
- ))}
120
- {remainingCount > 0 && (
121
- <div
183
+ React.useEffect(() => {
184
+ // On mount and on every source change, read the <img> itself. A photo
185
+ // served from cache can finish before hydration attaches `onLoad` and
186
+ // `onError`, so its events never reach us: complete with a real size is
187
+ // loaded, complete but empty is a failure. Otherwise this source starts
188
+ // from "loading", so no earlier result — above all an earlier failure of
189
+ // the same source — carries over; if its own event already landed, keep it.
190
+ const img = imgRef.current
191
+ if (img?.complete) {
192
+ setState({ key: requestedKey, image: img.naturalWidth > 0 ? "loaded" : "error" })
193
+ return
194
+ }
195
+ setState((prev) => (prev.key === requestedKey ? prev : { key: requestedKey, image: "loading" }))
196
+ }, [requestedKey])
197
+
198
+ const showImage = requested !== undefined && image !== "error"
199
+ const loaded = showImage && image === "loaded"
200
+
201
+ const letters = variant !== "icon" && name ? initialsFrom(name, size === "12" ? 1 : 2) : ""
202
+
203
+ return (
204
+ <span
205
+ ref={ref}
206
+ className={cn("relative inline-flex shrink-0 align-middle", ROOT_SIZE[size], className)}
207
+ {...props}
208
+ >
209
+ <span
122
210
  className={cn(
123
- avatarVariants({ size }),
124
- "ring-2 ring-background flex items-center justify-center bg-muted text-muted-foreground"
211
+ avatarVariants({ size, radius, border }),
212
+ // Figma's Image variants have no fill; the fallback sits on `muted`.
213
+ !loaded && "bg-muted"
125
214
  )}
126
- style={{ zIndex: 0 }}
127
215
  >
128
- +{remainingCount}
129
- </div>
130
- )}
131
- </div>
132
- )
133
- }
216
+ {!loaded &&
217
+ (letters ? (
218
+ <span aria-hidden="true" className="select-none">
219
+ {letters}
220
+ </span>
221
+ ) : (
222
+ // Lucide `user-round` at Figma's 1.5 stroke, 75% of the avatar.
223
+ <UserRoundIcon aria-hidden="true" className="size-3/4" strokeWidth={1.5} />
224
+ ))}
225
+ {/* The fallback's name, when there is no photo to carry it. */}
226
+ {!loaded && name && <span className="sr-only">{name}</span>}
227
+ {showImage && requested && (
228
+ <img
229
+ // A new element per source: a fresh request, never the last photo.
230
+ key={requestedKey}
231
+ ref={imgRef}
232
+ src={requested.src}
233
+ srcSet={requested.srcSet}
234
+ sizes={sizes}
235
+ loading={loading}
236
+ decoding={decoding}
237
+ crossOrigin={crossOrigin}
238
+ referrerPolicy={referrerPolicy}
239
+ // Named only once it shows; while it loads, the fallback carries the name.
240
+ alt={loaded ? (name ?? "") : ""}
241
+ aria-hidden={loaded ? undefined : true}
242
+ onLoad={() => setState({ key: requestedKey, image: "loaded" })}
243
+ onError={() => setState({ key: requestedKey, image: "error" })}
244
+ className={cn("absolute inset-0 size-full object-cover", !loaded && "opacity-0")}
245
+ />
246
+ )}
247
+ </span>
248
+ {status && (
249
+ <StatusIndicator
250
+ status={status}
251
+ size={size === "12" ? "4" : "8"}
252
+ label={statusLabel}
253
+ className={cn("absolute", dotPosition(size, radius))}
254
+ />
255
+ )}
256
+ </span>
257
+ )
258
+ }
259
+ )
260
+ Avatar.displayName = "Avatar"
134
261
 
135
- export { avatarVariants }
262
+ export { Avatar, avatarVariants }
@@ -1 +1 @@
1
- export { Avatar, AvatarGroup, avatarVariants, type AvatarProps, type AvatarGroupProps } from "./avatar"
1
+ export { Avatar, avatarVariants, type AvatarProps } from "./avatar"