@smicolon/fasla-ui 0.2.3 → 0.3.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 +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +91 -23
- package/dist/registry/index.js +31 -2
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +4 -1
- 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 +12 -0
- package/registry/blocks/empty-state/EmptyState.tsx +22 -31
- 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.tsx +9 -16
- package/registry/blocks/stats-card/StatsCard.tsx +9 -27
- package/registry/effects/animated-gradient/animated-gradient.tsx +23 -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 +68 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +41 -22
- 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.tsx +13 -26
- package/registry/ui/combobox/combobox.test.tsx +43 -0
- package/registry/ui/combobox/combobox.tsx +27 -55
- package/registry/ui/input/input.test.tsx +2 -2
- package/registry/ui/input/input.tsx +4 -4
- 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/skeleton.tsx +1 -1
- 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 +435 -78
- package/tailwind-preset.ts +143 -0
|
@@ -1,135 +1,262 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
1
3
|
import * as React from "react"
|
|
2
|
-
import { cva
|
|
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
|
|
24
|
+
"relative flex size-full items-center justify-center overflow-hidden text-foreground",
|
|
7
25
|
{
|
|
8
26
|
variants: {
|
|
9
27
|
size: {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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: "
|
|
56
|
+
size: "32",
|
|
57
|
+
radius: "standard",
|
|
58
|
+
border: false,
|
|
19
59
|
},
|
|
20
60
|
}
|
|
21
61
|
)
|
|
22
62
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
))
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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,
|
|
1
|
+
export { Avatar, avatarVariants, type AvatarProps } from "./avatar"
|