@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.
Files changed (57) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +91 -23
  4. package/dist/registry/index.js +31 -2
  5. package/dist/tokens/index.d.ts +75 -6
  6. package/dist/tokens/index.js +59 -20
  7. package/package.json +4 -1
  8. package/registry/blocks/app-shell/AppShell.tsx +1 -1
  9. package/registry/blocks/data-table/DataTable.test.tsx +61 -0
  10. package/registry/blocks/data-table/DataTable.tsx +53 -16
  11. package/registry/blocks/data-table/index.ts +1 -0
  12. package/registry/blocks/empty-state/EmptyState.test.tsx +12 -0
  13. package/registry/blocks/empty-state/EmptyState.tsx +22 -31
  14. package/registry/blocks/form-section/FormSection.tsx +1 -1
  15. package/registry/blocks/navbar/Navbar.test.tsx +12 -0
  16. package/registry/blocks/navbar/Navbar.tsx +8 -23
  17. package/registry/blocks/page-header/PageHeader.tsx +5 -2
  18. package/registry/blocks/sidebar/Sidebar.tsx +9 -16
  19. package/registry/blocks/stats-card/StatsCard.tsx +9 -27
  20. package/registry/effects/animated-gradient/animated-gradient.tsx +23 -8
  21. package/registry/effects/border-beam/border-beam.tsx +77 -53
  22. package/registry/effects/effects.test.tsx +266 -0
  23. package/registry/effects/glow-card/glow-card.tsx +68 -43
  24. package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
  25. package/registry/effects/spotlight/spotlight.tsx +73 -29
  26. package/registry/effects/text-reveal/index.ts +1 -0
  27. package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
  28. package/registry/effects/text-reveal/text-reveal.tsx +68 -21
  29. package/registry/effects/typewriter-text/typewriter-text.tsx +41 -22
  30. package/registry/text-props-undefined.test.tsx +73 -0
  31. package/registry/ui/avatar/avatar.test.tsx +316 -0
  32. package/registry/ui/avatar/avatar.tsx +235 -108
  33. package/registry/ui/avatar/index.ts +1 -1
  34. package/registry/ui/badge/badge.test.tsx +267 -32
  35. package/registry/ui/badge/badge.tsx +233 -21
  36. package/registry/ui/button/button.test.tsx +6 -0
  37. package/registry/ui/button/button.tsx +6 -23
  38. package/registry/ui/card/card.tsx +1 -1
  39. package/registry/ui/checkbox/checkbox.tsx +13 -26
  40. package/registry/ui/combobox/combobox.test.tsx +43 -0
  41. package/registry/ui/combobox/combobox.tsx +27 -55
  42. package/registry/ui/input/input.test.tsx +2 -2
  43. package/registry/ui/input/input.tsx +4 -4
  44. package/registry/ui/radio/index.ts +1 -0
  45. package/registry/ui/radio/radio.test.tsx +248 -0
  46. package/registry/ui/radio/radio.tsx +208 -0
  47. package/registry/ui/select/select.tsx +4 -14
  48. package/registry/ui/skeleton/skeleton.tsx +1 -1
  49. package/registry/ui/status-indicator/index.ts +6 -0
  50. package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
  51. package/registry/ui/status-indicator/status-indicator.tsx +87 -0
  52. package/registry/ui/switch/index.ts +1 -1
  53. package/registry/ui/switch/switch.test.tsx +323 -0
  54. package/registry/ui/switch/switch.tsx +254 -57
  55. package/registry/ui/textarea/textarea.tsx +1 -1
  56. package/registry.json +435 -78
  57. 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
  )
@@ -71,7 +71,7 @@ function SkeletonCard({ className, ...props }: SkeletonProps) {
71
71
  )}
72
72
  {...props}
73
73
  >
74
- <div className="flex items-center space-x-4">
74
+ <div className="flex items-center gap-4">
75
75
  <SkeletonAvatar />
76
76
  <div className="space-y-2 flex-1">
77
77
  <Skeleton className="h-4 w-1/2" />
@@ -0,0 +1,6 @@
1
+ export {
2
+ StatusIndicator,
3
+ statusIndicatorVariants,
4
+ STATUS_LABELS,
5
+ type StatusIndicatorProps,
6
+ } from "./status-indicator"
@@ -0,0 +1,148 @@
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 } from "vitest"
6
+ import { render, screen } from "@testing-library/react"
7
+ import { StatusIndicator, type StatusIndicatorProps } from "./status-indicator"
8
+
9
+ const SOURCE = readFileSync(
10
+ resolve(dirname(fileURLToPath(import.meta.url)), "status-indicator.tsx"),
11
+ "utf8"
12
+ )
13
+
14
+ function dot(props: Partial<StatusIndicatorProps> = {}) {
15
+ render(<StatusIndicator data-testid="dot" {...props} />)
16
+ return screen.getByTestId("dot")
17
+ }
18
+
19
+ /** The name a screen reader would hear: the visible (not display: none) runs. */
20
+ function spokenName(el: HTMLElement) {
21
+ return Array.from(el.querySelectorAll<HTMLElement>(".sr-only"))
22
+ .filter((run) => getComputedStyle(run).display !== "none")
23
+ .map((run) => run.textContent)
24
+ .join("")
25
+ }
26
+
27
+ /*
28
+ * The default name is two hidden runs, and CSS keeps the one matching the
29
+ * nearest `lang`. jsdom loads no Tailwind, so inject exactly the compiled
30
+ * rules the name depends on.
31
+ */
32
+ beforeAll(() => {
33
+ const style = document.createElement("style")
34
+ style.textContent = [
35
+ ".hidden { display: none }",
36
+ ".\\[\\&\\:lang\\(ar\\)\\]\\:hidden:lang(ar) { display: none }",
37
+ ".\\[\\&\\:lang\\(ar\\)\\]\\:inline:lang(ar) { display: inline }",
38
+ ].join("\n")
39
+ document.head.append(style)
40
+ })
41
+
42
+ describe("StatusIndicator", () => {
43
+ it("renders Figma's defaults: Online, 8px, a 2px background ring", () => {
44
+ const el = dot()
45
+ expect(el.tagName).toBe("SPAN")
46
+ expect(el).toHaveClass("bg-success", "size-2", "ring-2", "ring-background", "rounded-full")
47
+ })
48
+
49
+ it.each([
50
+ ["online", "bg-success"],
51
+ ["away", "bg-warning"],
52
+ ["busy", "bg-destructive"],
53
+ ["offline", "bg-muted"],
54
+ ] as const)("%s binds the Figma token %s", (status, fill) => {
55
+ expect(dot({ status })).toHaveClass(fill)
56
+ })
57
+
58
+ it("draws the 4px dot with a 1px ring", () => {
59
+ const el = dot({ size: "4" })
60
+ expect(el).toHaveClass("size-1", "ring-1")
61
+ expect(el).not.toHaveClass("size-2", "ring-2")
62
+ })
63
+
64
+ it("keeps the ring outside the dot, so it never changes the footprint", () => {
65
+ // A border would add to the box; Figma's stroke is OUTSIDE.
66
+ expect(SOURCE).not.toMatch(/\bborder(-\d)?\b/)
67
+ })
68
+
69
+ it("uses tokens only — no raw colour or pixel values", () => {
70
+ expect(SOURCE).not.toMatch(/#[0-9a-f]{3,8}\b|\[\d+px\]|rgb\(/i)
71
+ })
72
+
73
+ it("merges a consumer className", () => {
74
+ expect(dot({ className: "absolute" })).toHaveClass("absolute", "bg-success")
75
+ })
76
+
77
+ describe("accessible name", () => {
78
+ it.each([
79
+ ["online", "Online", "متصل"],
80
+ ["away", "Away", "بعيد"],
81
+ ["busy", "Busy", "مشغول"],
82
+ ["offline", "Offline", "غير متصل"],
83
+ ] as const)("%s is %s in English and %s in Arabic", (status, en, ar) => {
84
+ render(
85
+ <>
86
+ <div lang="en">
87
+ <StatusIndicator data-testid="en" status={status} />
88
+ </div>
89
+ <div lang="ar" dir="rtl">
90
+ <StatusIndicator data-testid="ar" status={status} />
91
+ </div>
92
+ </>
93
+ )
94
+ expect(spokenName(screen.getByTestId("en"))).toBe(en)
95
+ expect(spokenName(screen.getByTestId("ar"))).toBe(ar)
96
+ })
97
+
98
+ it("follows the nearest lang, so an English island on an Arabic page stays English", () => {
99
+ render(
100
+ <div lang="ar">
101
+ <div lang="en">
102
+ <StatusIndicator data-testid="dot" status="busy" />
103
+ </div>
104
+ </div>
105
+ )
106
+ expect(spokenName(screen.getByTestId("dot"))).toBe("Busy")
107
+ })
108
+
109
+ it("follows a live language change without remounting", () => {
110
+ const { container } = render(
111
+ <div lang="en">
112
+ <StatusIndicator data-testid="dot" status="away" />
113
+ </div>
114
+ )
115
+ expect(spokenName(screen.getByTestId("dot"))).toBe("Away")
116
+ container.firstElementChild!.setAttribute("lang", "ar")
117
+ expect(spokenName(screen.getByTestId("dot"))).toBe("بعيد")
118
+ })
119
+
120
+ it("takes an explicit label over the page language", () => {
121
+ render(
122
+ <div lang="ar">
123
+ <StatusIndicator data-testid="dot" status="online" label="Layla is online" />
124
+ </div>
125
+ )
126
+ const el = screen.getByTestId("dot")
127
+ expect(spokenName(el)).toBe("Layla is online")
128
+ expect(el.querySelectorAll(".sr-only")).toHaveLength(1)
129
+ })
130
+
131
+ it("is silent with label=\"\", for when text beside it already says the status", () => {
132
+ render(
133
+ <div lang="ar">
134
+ <StatusIndicator data-testid="dot" status="busy" label="" />
135
+ </div>
136
+ )
137
+ const el = screen.getByTestId("dot")
138
+ expect(el.querySelectorAll(".sr-only")).toHaveLength(0)
139
+ expect(el.textContent).toBe("")
140
+ })
141
+
142
+ it("puts the name in text, not in colour alone", () => {
143
+ const el = dot({ status: "offline" })
144
+ expect(el.textContent).toContain("Offline")
145
+ expect(el).not.toHaveAttribute("aria-hidden")
146
+ })
147
+ })
148
+ })
@@ -0,0 +1,87 @@
1
+ import * as React from "react"
2
+ import { cva } from "class-variance-authority"
3
+ import { cn } from "../../../src/lib/utils"
4
+
5
+ /**
6
+ * Built against the Figma `Status Indicator` set (323:390): Type × Size, 8
7
+ * variants. Every colour below is the token the set binds.
8
+ *
9
+ * The dot has no Direction axis: it is a circle, so there is nothing to mirror.
10
+ * Where it sits is the parent's job — Avatar pins it to the end corner.
11
+ *
12
+ * Figma's ring is a `theme/background` stroke drawn OUTSIDE the dot, 2px on the
13
+ * 8px dot and 1px on the 4px one. `ring-*` is a box-shadow, so it draws there
14
+ * too and never changes the dot's footprint.
15
+ */
16
+ const statusIndicatorVariants = cva(
17
+ "relative inline-block shrink-0 rounded-full ring-background",
18
+ {
19
+ variants: {
20
+ status: {
21
+ online: "bg-success",
22
+ away: "bg-warning",
23
+ busy: "bg-destructive",
24
+ offline: "bg-muted",
25
+ },
26
+ size: {
27
+ "8": "size-2 ring-2",
28
+ "4": "size-1 ring-1",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ status: "online",
33
+ size: "8",
34
+ },
35
+ }
36
+ )
37
+
38
+ /** The accessible name of each status, per script. Colour alone can't carry it. */
39
+ const STATUS_LABELS = {
40
+ en: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
41
+ ar: { online: "متصل", away: "بعيد", busy: "مشغول", offline: "غير متصل" },
42
+ } as const
43
+
44
+ type Status = keyof typeof STATUS_LABELS.en
45
+ type Size = "8" | "4"
46
+
47
+ export interface StatusIndicatorProps extends React.HTMLAttributes<HTMLSpanElement> {
48
+ /** Figma `Type`. */
49
+ status?: Status
50
+ /** Figma `Size`, in px: `8`, or `4` for the smallest avatar. */
51
+ size?: Size
52
+ /**
53
+ * Accessible name — read by screen readers, never shown. Defaults to the
54
+ * status in the page's language: "Online" / "متصل", "Away" / "بعيد",
55
+ * "Busy" / "مشغول", "Offline" / "غير متصل". Pass `""` to silence the dot,
56
+ * for when text beside it already says the status.
57
+ */
58
+ label?: string
59
+ }
60
+
61
+ const StatusIndicator = React.forwardRef<HTMLSpanElement, StatusIndicatorProps>(
62
+ ({ className, status = "online", size = "8", label, ...props }, ref) => (
63
+ <span
64
+ ref={ref}
65
+ className={cn(statusIndicatorVariants({ status, size }), className)}
66
+ {...props}
67
+ >
68
+ {/*
69
+ * Without `label`, the name comes from these two hidden runs, and CSS
70
+ * keeps the one that matches the nearest `lang` — the same way Badge's
71
+ * close button names itself. It is right in the server HTML before any
72
+ * script runs, and follows a live language change.
73
+ */}
74
+ {label === "" ? null : label ? (
75
+ <span className="sr-only">{label}</span>
76
+ ) : (
77
+ <>
78
+ <span className="sr-only [&:lang(ar)]:hidden">{STATUS_LABELS.en[status]}</span>
79
+ <span className="sr-only hidden [&:lang(ar)]:inline">{STATUS_LABELS.ar[status]}</span>
80
+ </>
81
+ )}
82
+ </span>
83
+ )
84
+ )
85
+ StatusIndicator.displayName = "StatusIndicator"
86
+
87
+ export { StatusIndicator, statusIndicatorVariants, STATUS_LABELS }
@@ -1 +1 @@
1
- export { Switch, type SwitchProps } from "./switch"
1
+ export { Switch, switchVariants, type SwitchProps } from "./switch"