@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,51 +1,286 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { dirname, resolve } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { beforeAll, describe, it, expect, vi } from "vitest"
|
|
6
|
+
import { render, screen, fireEvent } from "@testing-library/react"
|
|
7
|
+
import { Badge, type BadgeProps } from "./badge"
|
|
8
|
+
|
|
9
|
+
const SOURCE = readFileSync(
|
|
10
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "badge.tsx"),
|
|
11
|
+
"utf8"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const Star = () => (
|
|
15
|
+
<svg data-testid="icon" viewBox="0 0 24 24">
|
|
16
|
+
<path d="M12 2 15 9 22 9 16 14 18 21 12 17 6 21 8 14 2 9 9 9Z" />
|
|
17
|
+
</svg>
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
function badge(props: Partial<BadgeProps> = {}) {
|
|
21
|
+
render(
|
|
22
|
+
<Badge data-testid="badge" {...props}>
|
|
23
|
+
{props.children ?? "Badge"}
|
|
24
|
+
</Badge>
|
|
25
|
+
)
|
|
26
|
+
return screen.getByTestId("badge")
|
|
27
|
+
}
|
|
4
28
|
|
|
5
29
|
describe("Badge", () => {
|
|
6
|
-
it("renders with
|
|
7
|
-
|
|
8
|
-
expect(
|
|
30
|
+
it("renders a span with Figma's defaults: Solid, Primary, sm, Rounded", () => {
|
|
31
|
+
const el = badge()
|
|
32
|
+
expect(el.tagName).toBe("SPAN")
|
|
33
|
+
expect(el).toHaveClass("bg-primary", "text-primary-foreground", "rounded-full")
|
|
34
|
+
expect(el).toHaveClass("min-h-5", "px-1.5", "py-0.5", "gap-0.5", "text-xs", "font-medium")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe("variant × tone", () => {
|
|
38
|
+
const cases: Array<[BadgeProps["variant"], BadgeProps["tone"], string[]]> = [
|
|
39
|
+
["solid", "primary", ["bg-primary", "text-primary-foreground"]],
|
|
40
|
+
["solid", "secondary", ["bg-secondary", "text-secondary-foreground"]],
|
|
41
|
+
["solid", "info", ["bg-info", "text-info-foreground"]],
|
|
42
|
+
["solid", "success", ["bg-success", "text-success-foreground"]],
|
|
43
|
+
["solid", "warning", ["bg-warning", "text-warning-foreground"]],
|
|
44
|
+
["solid", "destructive", ["bg-destructive", "text-destructive-foreground"]],
|
|
45
|
+
["soft", "primary", ["from-soft-primary", "to-soft-primary", "text-primary"]],
|
|
46
|
+
// Figma binds Soft Secondary's text to `foreground`, unlike Outline Secondary.
|
|
47
|
+
["soft", "secondary", ["from-soft-secondary", "to-soft-secondary", "text-foreground"]],
|
|
48
|
+
["soft", "info", ["from-soft-info", "to-soft-info", "text-info"]],
|
|
49
|
+
["soft", "success", ["from-soft-success", "to-soft-success", "text-success"]],
|
|
50
|
+
["soft", "warning", ["from-soft-warning", "to-soft-warning", "text-warning"]],
|
|
51
|
+
["soft", "destructive", ["from-soft-destructive", "to-soft-destructive", "text-destructive"]],
|
|
52
|
+
["outline", "primary", ["ring-primary", "text-primary"]],
|
|
53
|
+
["outline", "secondary", ["ring-secondary", "text-secondary-foreground"]],
|
|
54
|
+
["outline", "info", ["ring-info", "text-info"]],
|
|
55
|
+
["outline", "success", ["ring-success", "text-success"]],
|
|
56
|
+
["outline", "warning", ["ring-warning", "text-warning"]],
|
|
57
|
+
["outline", "destructive", ["ring-destructive", "text-destructive"]],
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
it.each(cases)("%s / %s binds the Figma tokens", (variant, tone, classes) => {
|
|
61
|
+
expect(badge({ variant, tone })).toHaveClass(...classes)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("lays the Soft tint over card, as Figma stacks the two fills", () => {
|
|
65
|
+
expect(badge({ variant: "soft" })).toHaveClass("bg-card", "bg-gradient-to-r")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("draws Outline's stroke inside, so it is the same size as Solid", () => {
|
|
69
|
+
const el = badge({ variant: "outline" })
|
|
70
|
+
expect(el).toHaveClass("ring-1", "ring-inset")
|
|
71
|
+
expect(el.className).not.toMatch(/\bborder\b/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("uses no raw colours", () => {
|
|
75
|
+
expect(SOURCE).not.toMatch(/#[0-9a-f]{3,8}\b/i)
|
|
76
|
+
expect(SOURCE).not.toMatch(/-(green|yellow|red|blue|sky|amber)-\d{2,3}/)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe("size", () => {
|
|
81
|
+
it.each([
|
|
82
|
+
["sm", ["min-h-5", "px-1.5", "py-0.5", "gap-0.5"]],
|
|
83
|
+
["md", ["min-h-[1.375rem]", "px-2", "py-0.5", "gap-1"]],
|
|
84
|
+
["lg", ["min-h-[1.625rem]", "px-3", "py-1", "gap-1.5"]],
|
|
85
|
+
] as const)("%s matches Figma's padding and gap", (size, classes) => {
|
|
86
|
+
expect(badge({ size })).toHaveClass(...classes)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("sets minimum heights, not fixed ones, so Arabic can grow", () => {
|
|
90
|
+
expect(SOURCE).not.toMatch(/(?<![\w-])h-(5|\[1\.375rem\]|\[1\.625rem\])/)
|
|
91
|
+
})
|
|
9
92
|
})
|
|
10
93
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
94
|
+
describe("radius", () => {
|
|
95
|
+
it("Rounded is a pill", () => {
|
|
96
|
+
expect(badge({ radius: "rounded" })).toHaveClass("rounded-full")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("Standard binds border radius/sm, never a literal 6px", () => {
|
|
100
|
+
expect(badge({ radius: "standard" })).toHaveClass("rounded-[var(--radius-sm)]")
|
|
101
|
+
expect(SOURCE).not.toMatch(/\[6px\]|:\s*6px/)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe("icon and avatar", () => {
|
|
106
|
+
it("wraps the icon at 12px and hides it from assistive technology", () => {
|
|
107
|
+
badge({ icon: <Star /> })
|
|
108
|
+
const wrapper = screen.getByTestId("icon").parentElement!
|
|
109
|
+
expect(wrapper).toHaveAttribute("aria-hidden", "true")
|
|
110
|
+
expect(wrapper).toHaveClass("size-3", "[&>svg]:size-3")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("clips the avatar to a 12px circle and keeps it accessible", () => {
|
|
114
|
+
badge({ avatar: <img src="/a.png" alt="Layla" /> })
|
|
115
|
+
const img = screen.getByAltText("Layla")
|
|
116
|
+
expect(img.parentElement).toHaveClass("size-3", "rounded-full", "overflow-hidden")
|
|
117
|
+
expect(img.parentElement).not.toHaveAttribute("aria-hidden")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("allows both at once, as Figma's two independent booleans do", () => {
|
|
121
|
+
badge({ icon: <Star />, avatar: <img src="/a.png" alt="Layla" /> })
|
|
122
|
+
expect(screen.getByTestId("icon")).toBeInTheDocument()
|
|
123
|
+
expect(screen.getByAltText("Layla")).toBeInTheDocument()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("renders icon, avatar, label, close in DOM order, which dir mirrors", () => {
|
|
127
|
+
const el = badge({
|
|
128
|
+
icon: <Star />,
|
|
129
|
+
avatar: <img src="/a.png" alt="Layla" />,
|
|
130
|
+
onClose: () => {},
|
|
131
|
+
})
|
|
132
|
+
const kids = Array.from(el.children)
|
|
133
|
+
expect(kids[0]).toContainElement(screen.getByTestId("icon"))
|
|
134
|
+
expect(kids[1]).toContainElement(screen.getByAltText("Layla"))
|
|
135
|
+
expect(kids[2]).toHaveTextContent("Badge")
|
|
136
|
+
expect(kids[3]?.tagName).toBe("BUTTON")
|
|
137
|
+
})
|
|
14
138
|
})
|
|
15
139
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
140
|
+
describe("close", () => {
|
|
141
|
+
/*
|
|
142
|
+
* The default name is two hidden runs, and CSS keeps the one matching the
|
|
143
|
+
* nearest `lang`. jsdom loads no Tailwind, so inject exactly the compiled
|
|
144
|
+
* rules the name depends on; the accessible-name computation then drops
|
|
145
|
+
* the `display: none` run, as a browser does.
|
|
146
|
+
*/
|
|
147
|
+
beforeAll(() => {
|
|
148
|
+
const style = document.createElement("style")
|
|
149
|
+
style.textContent = [
|
|
150
|
+
".hidden { display: none }",
|
|
151
|
+
".\\[\\&\\:lang\\(ar\\)\\]\\:hidden:lang(ar) { display: none }",
|
|
152
|
+
".\\[\\&\\:lang\\(ar\\)\\]\\:inline:lang(ar) { display: inline }",
|
|
153
|
+
].join("\n")
|
|
154
|
+
document.head.append(style)
|
|
155
|
+
})
|
|
19
156
|
|
|
20
|
-
|
|
21
|
-
|
|
157
|
+
it("renders no button without onClose", () => {
|
|
158
|
+
badge()
|
|
159
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument()
|
|
160
|
+
})
|
|
22
161
|
|
|
23
|
-
|
|
24
|
-
|
|
162
|
+
it("is a real button that calls onClose", () => {
|
|
163
|
+
const onClose = vi.fn()
|
|
164
|
+
badge({ onClose, children: "Cairo" })
|
|
165
|
+
const button = screen.getByRole("button", { name: "Remove Cairo" })
|
|
166
|
+
expect(button).toHaveAttribute("type", "button")
|
|
167
|
+
fireEvent.click(button)
|
|
168
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
169
|
+
})
|
|
25
170
|
|
|
26
|
-
|
|
27
|
-
|
|
171
|
+
it("falls back to a bare verb when the label is not plain text", () => {
|
|
172
|
+
render(
|
|
173
|
+
<Badge onClose={() => {}}>
|
|
174
|
+
<strong>Cairo</strong>
|
|
175
|
+
</Badge>
|
|
176
|
+
)
|
|
177
|
+
expect(screen.getByRole("button", { name: "Remove" })).toBeInTheDocument()
|
|
178
|
+
})
|
|
28
179
|
|
|
29
|
-
|
|
30
|
-
|
|
180
|
+
it("names itself in Arabic when the page is Arabic", () => {
|
|
181
|
+
render(
|
|
182
|
+
<div lang="ar" dir="rtl">
|
|
183
|
+
<Badge onClose={() => {}}>القاهرة</Badge>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
expect(screen.getByRole("button", { name: "إزالة القاهرة" })).toBeInTheDocument()
|
|
187
|
+
})
|
|
31
188
|
|
|
32
|
-
|
|
33
|
-
|
|
189
|
+
it("follows the nearest lang, so an English island on an Arabic page stays English", () => {
|
|
190
|
+
render(
|
|
191
|
+
<div lang="ar">
|
|
192
|
+
<div lang="en">
|
|
193
|
+
<Badge onClose={() => {}}>Cairo</Badge>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
expect(screen.getByRole("button", { name: "Remove Cairo" })).toBeInTheDocument()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("follows a live language change without remounting", () => {
|
|
201
|
+
const { container } = render(
|
|
202
|
+
<div lang="en">
|
|
203
|
+
<Badge onClose={() => {}}>القاهرة</Badge>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
expect(screen.getByRole("button", { name: "Remove القاهرة" })).toBeInTheDocument()
|
|
207
|
+
container.firstElementChild!.setAttribute("lang", "ar")
|
|
208
|
+
expect(screen.getByRole("button", { name: "إزالة القاهرة" })).toBeInTheDocument()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("carries both names in the markup, so server HTML needs no script", () => {
|
|
212
|
+
badge({ onClose: () => {}, children: "Cairo" })
|
|
213
|
+
const runs = Array.from(screen.getByRole("button").querySelectorAll(".sr-only"))
|
|
214
|
+
expect(runs.map((r) => r.textContent)).toEqual(["Remove Cairo", "إزالة Cairo"])
|
|
215
|
+
expect(SOURCE).not.toMatch(/useEffect|useState|useLayoutEffect/)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it("takes an explicit label over the page language", () => {
|
|
219
|
+
render(
|
|
220
|
+
<div lang="ar">
|
|
221
|
+
<Badge onClose={() => {}} closeLabel="Remove filter: Cairo">
|
|
222
|
+
Cairo
|
|
223
|
+
</Badge>
|
|
224
|
+
</div>
|
|
225
|
+
)
|
|
226
|
+
const button = screen.getByRole("button", { name: "Remove filter: Cairo" })
|
|
227
|
+
expect(button.querySelector(".sr-only")).toBeNull()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("widens the hit area to 24px without changing the 12px glyph", () => {
|
|
231
|
+
badge({ onClose: () => {} })
|
|
232
|
+
expect(screen.getByRole("button")).toHaveClass("size-3", "after:absolute", "after:-inset-1.5")
|
|
233
|
+
})
|
|
34
234
|
})
|
|
35
235
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
236
|
+
describe("focus ring", () => {
|
|
237
|
+
it("exists only when there is something to focus", () => {
|
|
238
|
+
expect(badge().querySelector(".peer-focus-visible\\:opacity-100")).toBeNull()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("follows the close button as a peer, 2px out, on the badge's own radius", () => {
|
|
242
|
+
const el = badge({ onClose: () => {} })
|
|
243
|
+
const ring = screen.getByRole("button").nextElementSibling!
|
|
244
|
+
expect(ring.parentElement).toBe(el)
|
|
245
|
+
expect(ring).toHaveAttribute("aria-hidden", "true")
|
|
246
|
+
expect(ring).toHaveClass(
|
|
247
|
+
"absolute",
|
|
248
|
+
"-inset-0.5",
|
|
249
|
+
"rounded-[inherit]",
|
|
250
|
+
"ring-1",
|
|
251
|
+
"opacity-0",
|
|
252
|
+
"peer-focus-visible:opacity-100"
|
|
253
|
+
)
|
|
254
|
+
expect(screen.getByRole("button")).toHaveClass("peer")
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it.each([
|
|
258
|
+
["primary", "ring-ring/50"],
|
|
259
|
+
["secondary", "ring-ring/50"],
|
|
260
|
+
["info", "ring-info/20"],
|
|
261
|
+
["success", "ring-success/20"],
|
|
262
|
+
["warning", "ring-warning/20"],
|
|
263
|
+
["destructive", "ring-destructive/20"],
|
|
264
|
+
] as const)("%s uses Figma's ring colour and opacity", (tone, cls) => {
|
|
265
|
+
badge({ tone, onClose: () => {} })
|
|
266
|
+
expect(screen.getByRole("button").nextElementSibling).toHaveClass(cls)
|
|
267
|
+
})
|
|
39
268
|
})
|
|
40
269
|
|
|
41
|
-
it("
|
|
42
|
-
|
|
43
|
-
const badge = screen.getByTestId("badge")
|
|
44
|
-
expect(badge).toHaveClass("inline-flex", "items-center", "rounded-md", "border", "px-2.5", "py-0.5", "text-xs", "font-semibold")
|
|
270
|
+
it("is a client component, so a server component can render it", () => {
|
|
271
|
+
expect(SOURCE.startsWith('"use client"')).toBe(true)
|
|
45
272
|
})
|
|
46
273
|
|
|
47
|
-
it("
|
|
48
|
-
|
|
49
|
-
|
|
274
|
+
it("forwards its ref, merges className and passes props through", () => {
|
|
275
|
+
const ref = React.createRef<HTMLSpanElement>()
|
|
276
|
+
render(
|
|
277
|
+
<Badge ref={ref} className="custom" aria-label="status" data-testid="badge">
|
|
278
|
+
Status
|
|
279
|
+
</Badge>
|
|
280
|
+
)
|
|
281
|
+
const el = screen.getByTestId("badge")
|
|
282
|
+
expect(ref.current).toBe(el)
|
|
283
|
+
expect(el).toHaveClass("custom")
|
|
284
|
+
expect(el).toHaveAttribute("aria-label", "status")
|
|
50
285
|
})
|
|
51
286
|
})
|
|
@@ -1,39 +1,251 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
1
3
|
import * as React from "react"
|
|
2
|
-
import {
|
|
4
|
+
import { XIcon } from "lucide-react"
|
|
5
|
+
import { cva } from "class-variance-authority"
|
|
3
6
|
import { cn } from "../../../src/lib/utils"
|
|
4
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Built against the Figma `Badge` set (326:2134): Type × Tone × Size × Radius ×
|
|
10
|
+
* Direction × Stats, 432 variants. Every colour below is the token the set binds.
|
|
11
|
+
*
|
|
12
|
+
* Direction is not a prop. The row is a plain flex row in DOM order — icon,
|
|
13
|
+
* avatar, label, close — so `dir="rtl"` on the page mirrors it into Figma's RTL
|
|
14
|
+
* order (close, label, avatar, icon) with no RTL-specific class, and the type
|
|
15
|
+
* ramp re-leads `text-xs` to Cairo's 20px line on its own.
|
|
16
|
+
*
|
|
17
|
+
* Heights are minimums, not fixed. Figma pins md and lg to 22 and 26px, which
|
|
18
|
+
* holds English (a 16px line) but squeezes Arabic's 20px line into md's padding.
|
|
19
|
+
* Here RTL is allowed to grow: 24 / 24 / 28px against LTR's 20 / 22 / 26px.
|
|
20
|
+
*/
|
|
5
21
|
const badgeVariants = cva(
|
|
6
|
-
"inline-flex items-center
|
|
22
|
+
"relative inline-flex shrink-0 items-center whitespace-nowrap text-xs font-medium",
|
|
7
23
|
{
|
|
8
24
|
variants: {
|
|
9
25
|
variant: {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
solid: "",
|
|
27
|
+
// Figma stacks two fills: `card` underneath, the translucent tint on
|
|
28
|
+
// top. Doing the same keeps the chip opaque and identical on any
|
|
29
|
+
// surface, rather than letting whatever is behind it show through.
|
|
30
|
+
soft: "bg-card bg-gradient-to-r",
|
|
31
|
+
// Figma's stroke is 1px INSIDE, so the badge is the same size as Solid.
|
|
32
|
+
// An inset ring draws exactly that; a border would add 2px.
|
|
33
|
+
outline: "ring-1 ring-inset",
|
|
34
|
+
},
|
|
35
|
+
tone: {
|
|
36
|
+
primary: "",
|
|
37
|
+
secondary: "",
|
|
38
|
+
info: "",
|
|
39
|
+
success: "",
|
|
40
|
+
warning: "",
|
|
41
|
+
destructive: "",
|
|
42
|
+
},
|
|
43
|
+
size: {
|
|
44
|
+
sm: "min-h-5 gap-0.5 px-1.5 py-0.5",
|
|
45
|
+
// 22px and 26px have no Tailwind step; they are Figma's fixed heights.
|
|
46
|
+
md: "min-h-[1.375rem] gap-1 px-2 py-0.5",
|
|
47
|
+
lg: "min-h-[1.625rem] gap-1.5 px-3 py-1",
|
|
48
|
+
},
|
|
49
|
+
radius: {
|
|
50
|
+
rounded: "rounded-full",
|
|
51
|
+
// Figma `border radius/sm`. No class is 6px in both apps.
|
|
52
|
+
standard: "rounded-[var(--radius-sm)]",
|
|
21
53
|
},
|
|
22
54
|
},
|
|
55
|
+
compoundVariants: [
|
|
56
|
+
{ variant: "solid", tone: "primary", class: "bg-primary text-primary-foreground" },
|
|
57
|
+
{ variant: "solid", tone: "secondary", class: "bg-secondary text-secondary-foreground" },
|
|
58
|
+
{ variant: "solid", tone: "info", class: "bg-info text-info-foreground" },
|
|
59
|
+
{ variant: "solid", tone: "success", class: "bg-success text-success-foreground" },
|
|
60
|
+
{ variant: "solid", tone: "warning", class: "bg-warning text-warning-foreground" },
|
|
61
|
+
{
|
|
62
|
+
variant: "solid",
|
|
63
|
+
tone: "destructive",
|
|
64
|
+
class: "bg-destructive text-destructive-foreground",
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
{ variant: "soft", tone: "primary", class: "from-soft-primary to-soft-primary text-primary" },
|
|
68
|
+
// Figma sets Soft Secondary's text on `foreground`, not `secondary-foreground`.
|
|
69
|
+
{
|
|
70
|
+
variant: "soft",
|
|
71
|
+
tone: "secondary",
|
|
72
|
+
class: "from-soft-secondary to-soft-secondary text-foreground",
|
|
73
|
+
},
|
|
74
|
+
{ variant: "soft", tone: "info", class: "from-soft-info to-soft-info text-info" },
|
|
75
|
+
{ variant: "soft", tone: "success", class: "from-soft-success to-soft-success text-success" },
|
|
76
|
+
{ variant: "soft", tone: "warning", class: "from-soft-warning to-soft-warning text-warning" },
|
|
77
|
+
{
|
|
78
|
+
variant: "soft",
|
|
79
|
+
tone: "destructive",
|
|
80
|
+
class: "from-soft-destructive to-soft-destructive text-destructive",
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
{ variant: "outline", tone: "primary", class: "ring-primary text-primary" },
|
|
84
|
+
{ variant: "outline", tone: "secondary", class: "ring-secondary text-secondary-foreground" },
|
|
85
|
+
{ variant: "outline", tone: "info", class: "ring-info text-info" },
|
|
86
|
+
{ variant: "outline", tone: "success", class: "ring-success text-success" },
|
|
87
|
+
{ variant: "outline", tone: "warning", class: "ring-warning text-warning" },
|
|
88
|
+
{ variant: "outline", tone: "destructive", class: "ring-destructive text-destructive" },
|
|
89
|
+
],
|
|
23
90
|
defaultVariants: {
|
|
24
|
-
variant: "
|
|
91
|
+
variant: "solid",
|
|
92
|
+
tone: "primary",
|
|
93
|
+
size: "sm",
|
|
94
|
+
radius: "rounded",
|
|
25
95
|
},
|
|
26
96
|
}
|
|
27
97
|
)
|
|
28
98
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Figma's focus ring: its own node, 2px outside the badge on every side, a 1px
|
|
101
|
+
* line in the tone's colour at partial opacity — 50% on `ring` for the two
|
|
102
|
+
* neutral tones, 20% on the tone itself for the four status tones.
|
|
103
|
+
*/
|
|
104
|
+
const focusRingTone = {
|
|
105
|
+
primary: "ring-ring/50",
|
|
106
|
+
secondary: "ring-ring/50",
|
|
107
|
+
info: "ring-info/20",
|
|
108
|
+
success: "ring-success/20",
|
|
109
|
+
warning: "ring-warning/20",
|
|
110
|
+
destructive: "ring-destructive/20",
|
|
111
|
+
}
|
|
32
112
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
113
|
+
/** The close button's accessible name, per script, with the badge's label when it is plain text. */
|
|
114
|
+
const CLOSE_LABELS = {
|
|
115
|
+
en: (label?: string) => (label ? `Remove ${label}` : "Remove"),
|
|
116
|
+
ar: (label?: string) => (label ? `إزالة ${label}` : "إزالة"),
|
|
37
117
|
}
|
|
38
118
|
|
|
119
|
+
type Variant = "solid" | "soft" | "outline"
|
|
120
|
+
type Tone = keyof typeof focusRingTone
|
|
121
|
+
type Size = "sm" | "md" | "lg"
|
|
122
|
+
type Radius = "rounded" | "standard"
|
|
123
|
+
|
|
124
|
+
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
125
|
+
/**
|
|
126
|
+
* Visual weight — Figma `Type`. Declared here rather than inherited from
|
|
127
|
+
* `VariantProps`, which widens every option with `| null`.
|
|
128
|
+
*/
|
|
129
|
+
variant?: Variant
|
|
130
|
+
/** Semantic colour — Figma `Tone`. */
|
|
131
|
+
tone?: Tone
|
|
132
|
+
/** Figma `Size`. */
|
|
133
|
+
size?: Size
|
|
134
|
+
/** Figma `Radius`: `rounded` is a pill, `standard` is `border radius/sm`. */
|
|
135
|
+
radius?: Radius
|
|
136
|
+
/**
|
|
137
|
+
* Leading icon. Sized to 12px and coloured with `currentColor`, so pass any
|
|
138
|
+
* SVG icon without size or colour of its own. Decorative: hidden from
|
|
139
|
+
* assistive technology.
|
|
140
|
+
*/
|
|
141
|
+
icon?: React.ReactNode
|
|
142
|
+
/**
|
|
143
|
+
* Leading avatar — a single image. The badge owns its 12px size and circular
|
|
144
|
+
* clip; the child only supplies the picture, and its own `alt`.
|
|
145
|
+
*/
|
|
146
|
+
avatar?: React.ReactNode
|
|
147
|
+
/** Renders a close button at the trailing edge, and is called when it is pressed. */
|
|
148
|
+
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void
|
|
149
|
+
/**
|
|
150
|
+
* Accessible name for the close button — read by screen readers, never shown.
|
|
151
|
+
* Defaults to "Remove {label}" when the label is plain text, or "إزالة {label}"
|
|
152
|
+
* when the page's `lang` is Arabic; "Remove" / "إزالة" otherwise.
|
|
153
|
+
*/
|
|
154
|
+
closeLabel?: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
|
158
|
+
(
|
|
159
|
+
{
|
|
160
|
+
className,
|
|
161
|
+
variant = "solid",
|
|
162
|
+
tone = "primary",
|
|
163
|
+
size = "sm",
|
|
164
|
+
radius = "rounded",
|
|
165
|
+
icon,
|
|
166
|
+
avatar,
|
|
167
|
+
onClose,
|
|
168
|
+
closeLabel,
|
|
169
|
+
children,
|
|
170
|
+
...props
|
|
171
|
+
},
|
|
172
|
+
ref
|
|
173
|
+
) => {
|
|
174
|
+
const label =
|
|
175
|
+
typeof children === "string" || typeof children === "number"
|
|
176
|
+
? String(children).trim() || undefined
|
|
177
|
+
: undefined
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<span
|
|
181
|
+
ref={ref}
|
|
182
|
+
className={cn(badgeVariants({ variant, tone, size, radius }), className)}
|
|
183
|
+
{...props}
|
|
184
|
+
>
|
|
185
|
+
{icon && (
|
|
186
|
+
<span
|
|
187
|
+
aria-hidden="true"
|
|
188
|
+
className="inline-flex size-3 shrink-0 items-center justify-center [&>svg]:size-3"
|
|
189
|
+
>
|
|
190
|
+
{icon}
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
{avatar && (
|
|
194
|
+
<span className="inline-flex size-3 shrink-0 overflow-hidden rounded-full [&>img]:size-full [&>img]:object-cover">
|
|
195
|
+
{avatar}
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
<span>{children}</span>
|
|
199
|
+
{onClose && (
|
|
200
|
+
<>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
aria-label={closeLabel}
|
|
204
|
+
onClick={onClose}
|
|
205
|
+
// The ring below is the badge's focus indicator, so the button
|
|
206
|
+
// draws none of its own. `after:` widens the hit area to 24×24px
|
|
207
|
+
// (WCAG 2.5.8) without moving anything: the glyph stays Figma's
|
|
208
|
+
// 12px, and an absolute pseudo-element can't change the badge's
|
|
209
|
+
// height even where it reaches past sm's 20px.
|
|
210
|
+
className="peer relative inline-flex size-3 shrink-0 cursor-pointer items-center justify-center rounded-full outline-none after:absolute after:-inset-1.5 after:content-['']"
|
|
211
|
+
>
|
|
212
|
+
{/*
|
|
213
|
+
* Without `closeLabel`, the name comes from these two hidden
|
|
214
|
+
* runs, and CSS keeps the one that matches the nearest `lang` —
|
|
215
|
+
* the same way `dir` is inherited. That is right in the server
|
|
216
|
+
* HTML before any script runs, and it follows a live language
|
|
217
|
+
* change; `display: none` takes the other out of the name.
|
|
218
|
+
*/}
|
|
219
|
+
{!closeLabel && (
|
|
220
|
+
<>
|
|
221
|
+
<span className="sr-only [&:lang(ar)]:hidden">{CLOSE_LABELS.en(label)}</span>
|
|
222
|
+
<span className="sr-only hidden [&:lang(ar)]:inline">
|
|
223
|
+
{CLOSE_LABELS.ar(label)}
|
|
224
|
+
</span>
|
|
225
|
+
</>
|
|
226
|
+
)}
|
|
227
|
+
<XIcon aria-hidden="true" size={12} strokeWidth={1.5} />
|
|
228
|
+
</button>
|
|
229
|
+
{/*
|
|
230
|
+
* A sibling of the button, after it, so a plain
|
|
231
|
+
* `peer-focus-visible:` drives it — the one form the Storybook
|
|
232
|
+
* pseudo-state renderer can show. `:has()` would work in a browser
|
|
233
|
+
* but not in the states grid. `rounded-[inherit]` keeps the ring on
|
|
234
|
+
* the badge's own radius, as Figma binds the same token to both.
|
|
235
|
+
*/}
|
|
236
|
+
<span
|
|
237
|
+
aria-hidden="true"
|
|
238
|
+
className={cn(
|
|
239
|
+
"pointer-events-none absolute -inset-0.5 rounded-[inherit] opacity-0 ring-1 peer-focus-visible:opacity-100",
|
|
240
|
+
focusRingTone[tone]
|
|
241
|
+
)}
|
|
242
|
+
/>
|
|
243
|
+
</>
|
|
244
|
+
)}
|
|
245
|
+
</span>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
Badge.displayName = "Badge"
|
|
250
|
+
|
|
39
251
|
export { Badge, badgeVariants }
|
|
@@ -51,6 +51,12 @@ describe("Button", () => {
|
|
|
51
51
|
expect(screen.getByText("Loading")).toBeInTheDocument()
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
+
it("announces the loading text it is given", () => {
|
|
55
|
+
render(<Button loading loadingLabel="جارٍ الحفظ">حفظ</Button>)
|
|
56
|
+
expect(screen.getByText("جارٍ الحفظ")).toBeInTheDocument()
|
|
57
|
+
expect(screen.queryByText("Loading")).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
54
60
|
it("is disabled when disabled prop is true", () => {
|
|
55
61
|
render(<Button disabled>Disabled</Button>)
|
|
56
62
|
expect(screen.getByRole("button")).toBeDisabled()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
|
+
import { LoaderCircleIcon } from "lucide-react"
|
|
2
3
|
import { Slot } from "@radix-ui/react-slot"
|
|
3
4
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
5
|
import { cn } from "../../../src/lib/utils"
|
|
@@ -38,10 +39,12 @@ export interface ButtonProps
|
|
|
38
39
|
VariantProps<typeof buttonVariants> {
|
|
39
40
|
asChild?: boolean
|
|
40
41
|
loading?: boolean
|
|
42
|
+
/** Screen-reader text announced while loading */
|
|
43
|
+
loadingLabel?: string
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
44
|
-
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
|
|
47
|
+
({ className, variant, size, asChild = false, loading, loadingLabel = "Loading", children, disabled, ...props }, ref) => {
|
|
45
48
|
const Comp = asChild ? Slot : "button"
|
|
46
49
|
return (
|
|
47
50
|
<Comp
|
|
@@ -52,28 +55,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
52
55
|
>
|
|
53
56
|
{loading ? (
|
|
54
57
|
<>
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
58
|
-
fill="none"
|
|
59
|
-
viewBox="0 0 24 24"
|
|
60
|
-
aria-hidden="true"
|
|
61
|
-
>
|
|
62
|
-
<circle
|
|
63
|
-
className="opacity-25"
|
|
64
|
-
cx="12"
|
|
65
|
-
cy="12"
|
|
66
|
-
r="10"
|
|
67
|
-
stroke="currentColor"
|
|
68
|
-
strokeWidth="4"
|
|
69
|
-
/>
|
|
70
|
-
<path
|
|
71
|
-
className="opacity-75"
|
|
72
|
-
fill="currentColor"
|
|
73
|
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
74
|
-
/>
|
|
75
|
-
</svg>
|
|
76
|
-
<span className="sr-only">Loading</span>
|
|
58
|
+
<LoaderCircleIcon aria-hidden="true" className="animate-spin motion-reduce:animate-none" strokeWidth={1.5} />
|
|
59
|
+
<span className="sr-only">{loadingLabel}</span>
|
|
77
60
|
{children}
|
|
78
61
|
</>
|
|
79
62
|
) : (
|