@emara/ui 1.1.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 (218) hide show
  1. package/components/ui/.gitkeep +0 -0
  2. package/components/ui/accordion.stories.tsx +231 -0
  3. package/components/ui/accordion.tsx +250 -0
  4. package/components/ui/app-shell.stories.tsx +270 -0
  5. package/components/ui/app-shell.tsx +491 -0
  6. package/components/ui/avatar.stories.tsx +174 -0
  7. package/components/ui/avatar.tsx +257 -0
  8. package/components/ui/badge.stories.tsx +127 -0
  9. package/components/ui/badge.tsx +146 -0
  10. package/components/ui/breadcrumb.stories.tsx +92 -0
  11. package/components/ui/breadcrumb.tsx +302 -0
  12. package/components/ui/button.stories.tsx +186 -0
  13. package/components/ui/button.tsx +128 -0
  14. package/components/ui/card.stories.tsx +279 -0
  15. package/components/ui/card.tsx +250 -0
  16. package/components/ui/checkbox.stories.tsx +93 -0
  17. package/components/ui/checkbox.tsx +131 -0
  18. package/components/ui/combobox.stories.tsx +489 -0
  19. package/components/ui/combobox.tsx +874 -0
  20. package/components/ui/context-menu.stories.tsx +202 -0
  21. package/components/ui/context-menu.tsx +309 -0
  22. package/components/ui/data-table.stories.tsx +227 -0
  23. package/components/ui/data-table.tsx +539 -0
  24. package/components/ui/date-picker.stories.tsx +225 -0
  25. package/components/ui/date-picker.tsx +597 -0
  26. package/components/ui/dialog.stories.tsx +193 -0
  27. package/components/ui/dialog.tsx +262 -0
  28. package/components/ui/divider.stories.tsx +84 -0
  29. package/components/ui/divider.tsx +135 -0
  30. package/components/ui/drawer.stories.tsx +218 -0
  31. package/components/ui/drawer.tsx +329 -0
  32. package/components/ui/dropdown-menu.stories.tsx +270 -0
  33. package/components/ui/dropdown-menu.tsx +353 -0
  34. package/components/ui/empty-state.stories.tsx +121 -0
  35. package/components/ui/empty-state.tsx +289 -0
  36. package/components/ui/field-group.stories.tsx +201 -0
  37. package/components/ui/field-group.tsx +276 -0
  38. package/components/ui/form.stories.tsx +219 -0
  39. package/components/ui/form.tsx +542 -0
  40. package/components/ui/input.stories.tsx +154 -0
  41. package/components/ui/input.tsx +208 -0
  42. package/components/ui/label.stories.tsx +84 -0
  43. package/components/ui/label.tsx +98 -0
  44. package/components/ui/page-header.stories.tsx +136 -0
  45. package/components/ui/page-header.tsx +315 -0
  46. package/components/ui/pagination.stories.tsx +136 -0
  47. package/components/ui/pagination.tsx +427 -0
  48. package/components/ui/popover.stories.tsx +212 -0
  49. package/components/ui/popover.tsx +167 -0
  50. package/components/ui/radio-group.stories.tsx +96 -0
  51. package/components/ui/radio-group.tsx +250 -0
  52. package/components/ui/select.stories.tsx +203 -0
  53. package/components/ui/select.tsx +318 -0
  54. package/components/ui/sidebar.stories.tsx +186 -0
  55. package/components/ui/sidebar.tsx +623 -0
  56. package/components/ui/skeleton.stories.tsx +131 -0
  57. package/components/ui/skeleton.tsx +311 -0
  58. package/components/ui/switch.stories.tsx +74 -0
  59. package/components/ui/switch.tsx +186 -0
  60. package/components/ui/table.stories.tsx +107 -0
  61. package/components/ui/table.tsx +285 -0
  62. package/components/ui/tabs.stories.tsx +222 -0
  63. package/components/ui/tabs.tsx +287 -0
  64. package/components/ui/textarea.stories.tsx +96 -0
  65. package/components/ui/textarea.tsx +182 -0
  66. package/components/ui/toast.stories.tsx +169 -0
  67. package/components/ui/toast.tsx +250 -0
  68. package/components/ui/tooltip.stories.tsx +146 -0
  69. package/components/ui/tooltip.tsx +156 -0
  70. package/components/ui/top-bar.stories.tsx +182 -0
  71. package/components/ui/top-bar.tsx +155 -0
  72. package/dist/components/ui/accordion.d.ts +45 -0
  73. package/dist/components/ui/accordion.d.ts.map +1 -0
  74. package/dist/components/ui/accordion.js +99 -0
  75. package/dist/components/ui/accordion.js.map +1 -0
  76. package/dist/components/ui/app-shell.d.ts +70 -0
  77. package/dist/components/ui/app-shell.d.ts.map +1 -0
  78. package/dist/components/ui/app-shell.js +199 -0
  79. package/dist/components/ui/app-shell.js.map +1 -0
  80. package/dist/components/ui/avatar.d.ts +41 -0
  81. package/dist/components/ui/avatar.d.ts.map +1 -0
  82. package/dist/components/ui/avatar.js +104 -0
  83. package/dist/components/ui/avatar.js.map +1 -0
  84. package/dist/components/ui/badge.d.ts +27 -0
  85. package/dist/components/ui/badge.d.ts.map +1 -0
  86. package/dist/components/ui/badge.js +65 -0
  87. package/dist/components/ui/badge.js.map +1 -0
  88. package/dist/components/ui/breadcrumb.d.ts +35 -0
  89. package/dist/components/ui/breadcrumb.d.ts.map +1 -0
  90. package/dist/components/ui/breadcrumb.js +88 -0
  91. package/dist/components/ui/breadcrumb.js.map +1 -0
  92. package/dist/components/ui/button.d.ts +26 -0
  93. package/dist/components/ui/button.d.ts.map +1 -0
  94. package/dist/components/ui/button.js +73 -0
  95. package/dist/components/ui/button.js.map +1 -0
  96. package/dist/components/ui/card.d.ts +52 -0
  97. package/dist/components/ui/card.d.ts.map +1 -0
  98. package/dist/components/ui/card.js +96 -0
  99. package/dist/components/ui/card.js.map +1 -0
  100. package/dist/components/ui/checkbox.d.ts +18 -0
  101. package/dist/components/ui/checkbox.d.ts.map +1 -0
  102. package/dist/components/ui/checkbox.js +59 -0
  103. package/dist/components/ui/checkbox.js.map +1 -0
  104. package/dist/components/ui/combobox.d.ts +194 -0
  105. package/dist/components/ui/combobox.d.ts.map +1 -0
  106. package/dist/components/ui/combobox.js +361 -0
  107. package/dist/components/ui/combobox.js.map +1 -0
  108. package/dist/components/ui/context-menu.d.ts +46 -0
  109. package/dist/components/ui/context-menu.d.ts.map +1 -0
  110. package/dist/components/ui/context-menu.js +95 -0
  111. package/dist/components/ui/context-menu.js.map +1 -0
  112. package/dist/components/ui/data-table.d.ts +53 -0
  113. package/dist/components/ui/data-table.d.ts.map +1 -0
  114. package/dist/components/ui/data-table.js +163 -0
  115. package/dist/components/ui/data-table.js.map +1 -0
  116. package/dist/components/ui/date-picker.d.ts +103 -0
  117. package/dist/components/ui/date-picker.d.ts.map +1 -0
  118. package/dist/components/ui/date-picker.js +306 -0
  119. package/dist/components/ui/date-picker.js.map +1 -0
  120. package/dist/components/ui/dialog.d.ts +40 -0
  121. package/dist/components/ui/dialog.d.ts.map +1 -0
  122. package/dist/components/ui/dialog.js +110 -0
  123. package/dist/components/ui/dialog.js.map +1 -0
  124. package/dist/components/ui/divider.d.ts +30 -0
  125. package/dist/components/ui/divider.d.ts.map +1 -0
  126. package/dist/components/ui/divider.js +62 -0
  127. package/dist/components/ui/divider.js.map +1 -0
  128. package/dist/components/ui/drawer.d.ts +56 -0
  129. package/dist/components/ui/drawer.d.ts.map +1 -0
  130. package/dist/components/ui/drawer.js +147 -0
  131. package/dist/components/ui/drawer.js.map +1 -0
  132. package/dist/components/ui/dropdown-menu.d.ts +63 -0
  133. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  134. package/dist/components/ui/dropdown-menu.js +116 -0
  135. package/dist/components/ui/dropdown-menu.js.map +1 -0
  136. package/dist/components/ui/empty-state.d.ts +43 -0
  137. package/dist/components/ui/empty-state.d.ts.map +1 -0
  138. package/dist/components/ui/empty-state.js +128 -0
  139. package/dist/components/ui/empty-state.js.map +1 -0
  140. package/dist/components/ui/field-group.d.ts +38 -0
  141. package/dist/components/ui/field-group.d.ts.map +1 -0
  142. package/dist/components/ui/field-group.js +107 -0
  143. package/dist/components/ui/field-group.js.map +1 -0
  144. package/dist/components/ui/form.d.ts +67 -0
  145. package/dist/components/ui/form.d.ts.map +1 -0
  146. package/dist/components/ui/form.js +286 -0
  147. package/dist/components/ui/form.js.map +1 -0
  148. package/dist/components/ui/input.d.ts +36 -0
  149. package/dist/components/ui/input.d.ts.map +1 -0
  150. package/dist/components/ui/input.js +99 -0
  151. package/dist/components/ui/input.js.map +1 -0
  152. package/dist/components/ui/label.d.ts +37 -0
  153. package/dist/components/ui/label.d.ts.map +1 -0
  154. package/dist/components/ui/label.js +34 -0
  155. package/dist/components/ui/label.js.map +1 -0
  156. package/dist/components/ui/page-header.d.ts +65 -0
  157. package/dist/components/ui/page-header.d.ts.map +1 -0
  158. package/dist/components/ui/page-header.js +140 -0
  159. package/dist/components/ui/page-header.js.map +1 -0
  160. package/dist/components/ui/pagination.d.ts +67 -0
  161. package/dist/components/ui/pagination.d.ts.map +1 -0
  162. package/dist/components/ui/pagination.js +109 -0
  163. package/dist/components/ui/pagination.js.map +1 -0
  164. package/dist/components/ui/popover.d.ts +28 -0
  165. package/dist/components/ui/popover.d.ts.map +1 -0
  166. package/dist/components/ui/popover.js +85 -0
  167. package/dist/components/ui/popover.js.map +1 -0
  168. package/dist/components/ui/radio-group.d.ts +35 -0
  169. package/dist/components/ui/radio-group.d.ts.map +1 -0
  170. package/dist/components/ui/radio-group.js +103 -0
  171. package/dist/components/ui/radio-group.js.map +1 -0
  172. package/dist/components/ui/select.d.ts +42 -0
  173. package/dist/components/ui/select.d.ts.map +1 -0
  174. package/dist/components/ui/select.js +86 -0
  175. package/dist/components/ui/select.js.map +1 -0
  176. package/dist/components/ui/sidebar.d.ts +59 -0
  177. package/dist/components/ui/sidebar.d.ts.map +1 -0
  178. package/dist/components/ui/sidebar.js +189 -0
  179. package/dist/components/ui/sidebar.js.map +1 -0
  180. package/dist/components/ui/skeleton.d.ts +77 -0
  181. package/dist/components/ui/skeleton.d.ts.map +1 -0
  182. package/dist/components/ui/skeleton.js +115 -0
  183. package/dist/components/ui/skeleton.js.map +1 -0
  184. package/dist/components/ui/switch.d.ts +26 -0
  185. package/dist/components/ui/switch.d.ts.map +1 -0
  186. package/dist/components/ui/switch.js +84 -0
  187. package/dist/components/ui/switch.js.map +1 -0
  188. package/dist/components/ui/table.d.ts +52 -0
  189. package/dist/components/ui/table.d.ts.map +1 -0
  190. package/dist/components/ui/table.js +109 -0
  191. package/dist/components/ui/table.js.map +1 -0
  192. package/dist/components/ui/tabs.d.ts +42 -0
  193. package/dist/components/ui/tabs.d.ts.map +1 -0
  194. package/dist/components/ui/tabs.js +163 -0
  195. package/dist/components/ui/tabs.js.map +1 -0
  196. package/dist/components/ui/textarea.d.ts +26 -0
  197. package/dist/components/ui/textarea.d.ts.map +1 -0
  198. package/dist/components/ui/textarea.js +96 -0
  199. package/dist/components/ui/textarea.js.map +1 -0
  200. package/dist/components/ui/toast.d.ts +77 -0
  201. package/dist/components/ui/toast.d.ts.map +1 -0
  202. package/dist/components/ui/toast.js +141 -0
  203. package/dist/components/ui/toast.js.map +1 -0
  204. package/dist/components/ui/tooltip.d.ts +31 -0
  205. package/dist/components/ui/tooltip.d.ts.map +1 -0
  206. package/dist/components/ui/tooltip.js +71 -0
  207. package/dist/components/ui/tooltip.js.map +1 -0
  208. package/dist/components/ui/top-bar.d.ts +30 -0
  209. package/dist/components/ui/top-bar.d.ts.map +1 -0
  210. package/dist/components/ui/top-bar.js +64 -0
  211. package/dist/components/ui/top-bar.js.map +1 -0
  212. package/dist/lib/utils.d.ts +3 -0
  213. package/dist/lib/utils.d.ts.map +1 -0
  214. package/dist/lib/utils.js +6 -0
  215. package/dist/lib/utils.js.map +1 -0
  216. package/lib/utils.ts +6 -0
  217. package/package.json +112 -0
  218. package/styles/globals.css +685 -0
@@ -0,0 +1,279 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { RiArrowRightLine, RiCheckLine, RiMoreLine } from "@remixicon/react";
3
+
4
+ import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
5
+ import { Badge } from "./badge";
6
+ import { Button } from "./button";
7
+ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
8
+
9
+ const meta: Meta<typeof Card> = {
10
+ title: "Foundations/Card",
11
+ component: Card,
12
+ parameters: { layout: "centered" },
13
+ argTypes: {
14
+ variant: {
15
+ control: "select",
16
+ options: ["default", "outline", "elevated", "filled", "ghost"],
17
+ },
18
+ padding: { control: "select", options: ["none", "sm", "md", "lg"] },
19
+ interactive: { control: "boolean" },
20
+ selected: { control: "boolean" },
21
+ loading: { control: "boolean" },
22
+ },
23
+ };
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof Card>;
27
+
28
+ /** Standard Card with title, description, body, and footer actions. */
29
+ export const Default: Story = {
30
+ render: (args) => (
31
+ <Card {...args} className="w-96">
32
+ <CardHeader>
33
+ <CardTitle>Email notifications</CardTitle>
34
+ <CardDescription>Choose which messages get delivered to your inbox.</CardDescription>
35
+ </CardHeader>
36
+ <CardContent>
37
+ <p className="text-sm leading-relaxed">
38
+ We&apos;ll only email you about the things you&apos;ve opted in to. You can change this at
39
+ any time from your account settings.
40
+ </p>
41
+ </CardContent>
42
+ <CardFooter className="justify-end gap-2">
43
+ <Button variant="ghost">Cancel</Button>
44
+ <Button>Save changes</Button>
45
+ </CardFooter>
46
+ </Card>
47
+ ),
48
+ };
49
+
50
+ /** Side-by-side comparison of the five spec variants. */
51
+ export const Variants: Story = {
52
+ parameters: { layout: "padded" },
53
+ render: () => (
54
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
55
+ {(["default", "outline", "elevated", "filled", "ghost"] as const).map((v) => (
56
+ <Card key={v} variant={v} className="w-72">
57
+ <CardHeader>
58
+ <CardTitle>{v}</CardTitle>
59
+ <CardDescription>variant=&quot;{v}&quot;</CardDescription>
60
+ </CardHeader>
61
+ <CardContent>
62
+ <p className="text-sm">
63
+ {v === "default"
64
+ ? "No border — assumes the host theme tints --card vs --background."
65
+ : v === "outline"
66
+ ? "Border + card background. The safe default for most apps."
67
+ : v === "elevated"
68
+ ? "Shadow-md, no border. Reads as floating above the page."
69
+ : v === "filled"
70
+ ? "--muted background. Use for nested cards inside another Card."
71
+ : "Transparent. Pure spacing wrapper for headings + body."}
72
+ </p>
73
+ </CardContent>
74
+ </Card>
75
+ ))}
76
+ </div>
77
+ ),
78
+ };
79
+
80
+ export const PaddingScale: Story = {
81
+ parameters: { layout: "padded" },
82
+ render: () => (
83
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
84
+ {(["none", "sm", "md", "lg"] as const).map((p) => (
85
+ <Card key={p} padding={p} className="w-56">
86
+ <CardTitle>padding={p}</CardTitle>
87
+ </Card>
88
+ ))}
89
+ </div>
90
+ ),
91
+ };
92
+
93
+ /** Whole card is a real `<button>` via `asChild`. Hover + focus styles applied. */
94
+ export const Interactive: Story = {
95
+ render: () => (
96
+ <Card asChild interactive className="w-80">
97
+ <button type="button" onClick={() => console.log("card clicked")} className="text-start">
98
+ <CardHeader>
99
+ <CardTitle>Click anywhere on this card</CardTitle>
100
+ <CardDescription>The whole surface is a real button.</CardDescription>
101
+ </CardHeader>
102
+ <CardContent className="flex items-center justify-between">
103
+ <span className="text-muted-foreground text-sm">View details</span>
104
+ <RiArrowRightLine className="rtl-mirror text-muted-foreground size-4" />
105
+ </CardContent>
106
+ </button>
107
+ </Card>
108
+ ),
109
+ };
110
+
111
+ /** Selected card — used for grid selection states (filter chips, plan picker, etc.). */
112
+ export const Selected: Story = {
113
+ render: () => (
114
+ <div className="flex gap-4">
115
+ <Card className="w-56">
116
+ <CardHeader>
117
+ <CardTitle>Starter</CardTitle>
118
+ <CardDescription>$0 / month</CardDescription>
119
+ </CardHeader>
120
+ </Card>
121
+ <Card selected className="w-56">
122
+ <CardHeader>
123
+ <CardTitle>Pro</CardTitle>
124
+ <CardDescription>$12 / month</CardDescription>
125
+ </CardHeader>
126
+ </Card>
127
+ <Card className="w-56">
128
+ <CardHeader>
129
+ <CardTitle>Team</CardTitle>
130
+ <CardDescription>$48 / month</CardDescription>
131
+ </CardHeader>
132
+ </Card>
133
+ </div>
134
+ ),
135
+ };
136
+
137
+ /** Skeleton placeholder while data loads. Carries `aria-busy` / `aria-live`. */
138
+ export const Loading: Story = {
139
+ args: { loading: true },
140
+ render: (args) => <Card {...args} className="w-96" />,
141
+ };
142
+
143
+ /** `CardTitle as="h2"` keeps the document outline intact when a Card sits
144
+ * directly under a `PageHeader` whose title is an `<h1>`. */
145
+ export const TitleAsH2: Story = {
146
+ render: () => (
147
+ <Card className="w-96">
148
+ <CardHeader>
149
+ <CardTitle as="h2">Title rendered as &lt;h2&gt;</CardTitle>
150
+ <CardDescription>
151
+ Use <code>as=&quot;h2&quot;</code> when the Card sits directly under a PageHeader.
152
+ </CardDescription>
153
+ </CardHeader>
154
+ </Card>
155
+ ),
156
+ };
157
+
158
+ // ----------------------------------------------------------------------------
159
+ // Real-world composition examples
160
+ // ----------------------------------------------------------------------------
161
+
162
+ /** Profile-style card composing Avatar, Badge, and CardTitle in a header row. */
163
+ export const ProfileCard: Story = {
164
+ render: () => (
165
+ <Card className="w-96">
166
+ <CardHeader className="flex flex-row items-center gap-3 space-y-0">
167
+ <Avatar size="lg">
168
+ <AvatarImage src="https://i.pravatar.cc/96?img=12" alt="" />
169
+ <AvatarFallback>AB</AvatarFallback>
170
+ </Avatar>
171
+ <div className="flex-1">
172
+ <CardTitle>Alice Bouchaib</CardTitle>
173
+ <CardDescription>alice@example.com</CardDescription>
174
+ </div>
175
+ <Badge dot variant="success">
176
+ Active
177
+ </Badge>
178
+ </CardHeader>
179
+ <CardContent>
180
+ <dl className="grid grid-cols-2 gap-3 text-sm">
181
+ <div>
182
+ <dt className="text-muted-foreground">Role</dt>
183
+ <dd className="font-medium">Designer</dd>
184
+ </div>
185
+ <div>
186
+ <dt className="text-muted-foreground">Joined</dt>
187
+ <dd className="font-medium">2024-03-12</dd>
188
+ </div>
189
+ </dl>
190
+ </CardContent>
191
+ <CardFooter className="justify-between">
192
+ <Button variant="ghost" size="sm">
193
+ Message
194
+ </Button>
195
+ <Button size="sm">View profile</Button>
196
+ </CardFooter>
197
+ </Card>
198
+ ),
199
+ };
200
+
201
+ /** Stat-style card with a primary metric, secondary detail, and a trend badge. */
202
+ export const StatCard: Story = {
203
+ render: () => (
204
+ <Card className="w-72">
205
+ <CardHeader className="flex flex-row items-start justify-between space-y-0">
206
+ <div>
207
+ <CardDescription>Monthly recurring revenue</CardDescription>
208
+ <CardTitle className="mt-1 text-3xl tabular-nums">$24,580</CardTitle>
209
+ </div>
210
+ <Badge variant="success" icon={<RiCheckLine />}>
211
+ +12.3%
212
+ </Badge>
213
+ </CardHeader>
214
+ <CardContent>
215
+ <p className="text-muted-foreground text-xs">vs. $21,890 last month</p>
216
+ </CardContent>
217
+ </Card>
218
+ ),
219
+ };
220
+
221
+ /** Header with a leading icon-button action — common for cards that get
222
+ * dismissed, pinned, or expanded individually. */
223
+ export const WithHeaderAction: Story = {
224
+ render: () => (
225
+ <Card className="w-96">
226
+ <CardHeader className="flex flex-row items-start justify-between space-y-0">
227
+ <div className="space-y-1">
228
+ <CardTitle>API keys</CardTitle>
229
+ <CardDescription>Manage authentication tokens for this workspace.</CardDescription>
230
+ </div>
231
+ <Button variant="ghost" size="icon-sm" aria-label="More actions">
232
+ <RiMoreLine />
233
+ </Button>
234
+ </CardHeader>
235
+ <CardContent>
236
+ <p className="text-muted-foreground text-sm">3 active keys, 1 revoked.</p>
237
+ </CardContent>
238
+ </Card>
239
+ ),
240
+ };
241
+
242
+ /** Grid of selectable plan cards — demonstrates the selected state in a real
243
+ * picker pattern with `asChild` + `<button>` for keyboard accessibility. */
244
+ export const PlanPicker: Story = {
245
+ parameters: { layout: "padded" },
246
+ render: () => {
247
+ const plans = [
248
+ { id: "starter", name: "Starter", price: "$0", perks: "Up to 3 projects" },
249
+ { id: "pro", name: "Pro", price: "$12", perks: "Unlimited projects", selected: true },
250
+ { id: "team", name: "Team", price: "$48", perks: "Seats + roles" },
251
+ ];
252
+ return (
253
+ <div className="grid grid-cols-3 gap-4">
254
+ {plans.map((plan) => (
255
+ <Card
256
+ key={plan.id}
257
+ asChild
258
+ interactive
259
+ selected={plan.selected}
260
+ className="w-full text-start"
261
+ >
262
+ <button type="button">
263
+ <CardHeader>
264
+ <CardTitle>{plan.name}</CardTitle>
265
+ <CardDescription>{plan.perks}</CardDescription>
266
+ </CardHeader>
267
+ <CardContent>
268
+ <p className="text-2xl font-semibold tabular-nums">
269
+ {plan.price}
270
+ <span className="text-muted-foreground ms-1 text-sm font-normal">/mo</span>
271
+ </p>
272
+ </CardContent>
273
+ </button>
274
+ </Card>
275
+ ))}
276
+ </div>
277
+ );
278
+ },
279
+ };
@@ -0,0 +1,250 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ import { Skeleton } from "./skeleton";
10
+
11
+ // Per docs/emara-ui-phase-1-components.md §5.
12
+
13
+ // ----------------------------------------------------------------------------
14
+ // Card root
15
+ // ----------------------------------------------------------------------------
16
+
17
+ const cardVariants = cva(
18
+ "rounded-lg transition-colors transition-shadow",
19
+ {
20
+ variants: {
21
+ // Spec-faithful per docs/emara-ui-phase-1-components.md §5. Note that
22
+ // `default` carries no border because the spec assumes `--card` differs
23
+ // from `--background` in the host theme. Emara's stock palette has both
24
+ // as white in light mode, so `defaultVariants.variant` (below) is set
25
+ // to `outline` — a bare `<Card>` should still look like a card.
26
+ variant: {
27
+ default: "bg-card text-card-foreground",
28
+ outline: "bg-card text-card-foreground border border-border",
29
+ elevated: "bg-card text-card-foreground shadow-md",
30
+ filled: "bg-muted text-foreground",
31
+ ghost: "bg-transparent text-foreground",
32
+ },
33
+ padding: {
34
+ none: "p-0",
35
+ sm: "p-3",
36
+ md: "p-4",
37
+ lg: "p-6",
38
+ },
39
+ interactive: {
40
+ true: [
41
+ "cursor-pointer",
42
+ // Subtle hover — only the surface tone shifts, text stays put.
43
+ // For the default/outline variants the border also gets slightly
44
+ // stronger so the lift reads even without a background change.
45
+ "hover:bg-accent/40 hover:border-foreground/20",
46
+ "active:bg-accent/60",
47
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
48
+ ].join(" "),
49
+ false: "",
50
+ },
51
+ selected: {
52
+ true: "ring-2 ring-primary ring-offset-2 ring-offset-background",
53
+ false: "",
54
+ },
55
+ },
56
+ defaultVariants: {
57
+ variant: "outline",
58
+ padding: "md",
59
+ interactive: false,
60
+ selected: false,
61
+ },
62
+ },
63
+ );
64
+
65
+ type CardVariants = VariantProps<typeof cardVariants>;
66
+
67
+ type CardProps = React.HTMLAttributes<HTMLDivElement> &
68
+ CardVariants & {
69
+ asChild?: boolean;
70
+ loading?: boolean;
71
+ };
72
+
73
+ const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
74
+ {
75
+ className,
76
+ variant,
77
+ padding,
78
+ interactive,
79
+ selected,
80
+ asChild = false,
81
+ loading = false,
82
+ children,
83
+ ...props
84
+ },
85
+ ref,
86
+ ) {
87
+ const Comp = asChild ? Slot : "div";
88
+
89
+ // Loading state mirrors a typical Card: header (title + description), body
90
+ // (3 lines), and a footer action. The container carries `aria-busy` /
91
+ // `aria-live` so assistive tech announces the loading region.
92
+ const loadingContent = (
93
+ <div className="space-y-4" aria-busy="true" aria-live="polite">
94
+ <div className="space-y-2">
95
+ <Skeleton className="h-5 w-2/5" />
96
+ <Skeleton className="h-4 w-3/5" />
97
+ </div>
98
+ <div className="space-y-2">
99
+ <Skeleton className="h-4 w-full" />
100
+ <Skeleton className="h-4 w-10/12" />
101
+ <Skeleton className="h-4 w-3/5" />
102
+ </div>
103
+ <div className="flex justify-end">
104
+ <Skeleton className="h-9 w-24" />
105
+ </div>
106
+ </div>
107
+ );
108
+
109
+ return (
110
+ <Comp
111
+ ref={ref}
112
+ data-selected={selected ? "true" : undefined}
113
+ className={cn(cardVariants({ variant, padding, interactive, selected }), className)}
114
+ {...props}
115
+ >
116
+ {loading ? loadingContent : children}
117
+ </Comp>
118
+ );
119
+ });
120
+ Card.displayName = "Card";
121
+
122
+ // ----------------------------------------------------------------------------
123
+ // CardHeader
124
+ // ----------------------------------------------------------------------------
125
+
126
+ type CardHeaderProps = React.HTMLAttributes<HTMLDivElement> & {
127
+ asChild?: boolean;
128
+ };
129
+
130
+ const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(function CardHeader(
131
+ { className, asChild = false, ...props },
132
+ ref,
133
+ ) {
134
+ const Comp = asChild ? Slot : "div";
135
+ // Block layout with vertical rhythm. `space-y-1` is 4px — tight, but the
136
+ // CardTitle now carries its own `leading-snug` so visually there's already
137
+ // breathing room between title and description.
138
+ return <Comp ref={ref} className={cn("space-y-1", className)} {...props} />;
139
+ });
140
+ CardHeader.displayName = "CardHeader";
141
+
142
+ // ----------------------------------------------------------------------------
143
+ // CardTitle — polymorphic via `as` prop (h2..h6, default h3).
144
+ // ----------------------------------------------------------------------------
145
+
146
+ type CardTitleAs = "h2" | "h3" | "h4" | "h5" | "h6";
147
+
148
+ type CardTitleProps = React.HTMLAttributes<HTMLHeadingElement> & {
149
+ as?: CardTitleAs;
150
+ asChild?: boolean;
151
+ };
152
+
153
+ const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(function CardTitle(
154
+ { className, as = "h3", asChild = false, ...props },
155
+ ref,
156
+ ) {
157
+ const Comp = asChild ? Slot : as;
158
+ // 18px / leading-snug / tight letter-spacing — establishes a clear visual
159
+ // step above the 14px CardDescription and 16px body text.
160
+ return (
161
+ <Comp
162
+ ref={ref}
163
+ className={cn("text-lg font-semibold leading-snug tracking-tight", className)}
164
+ {...props}
165
+ />
166
+ );
167
+ });
168
+ CardTitle.displayName = "CardTitle";
169
+
170
+ // ----------------------------------------------------------------------------
171
+ // CardDescription
172
+ // ----------------------------------------------------------------------------
173
+
174
+ type CardDescriptionProps = React.HTMLAttributes<HTMLParagraphElement> & {
175
+ asChild?: boolean;
176
+ };
177
+
178
+ const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(function CardDescription(
179
+ { className, asChild = false, ...props },
180
+ ref,
181
+ ) {
182
+ const Comp = asChild ? Slot : "p";
183
+ return (
184
+ <Comp
185
+ ref={ref}
186
+ className={cn("text-sm leading-snug text-muted-foreground", className)}
187
+ {...props}
188
+ />
189
+ );
190
+ });
191
+ CardDescription.displayName = "CardDescription";
192
+
193
+ // ----------------------------------------------------------------------------
194
+ // CardContent — `first:pt-0` so a Card with content-only renders flush.
195
+ // ----------------------------------------------------------------------------
196
+
197
+ type CardContentProps = React.HTMLAttributes<HTMLDivElement> & {
198
+ asChild?: boolean;
199
+ };
200
+
201
+ const CardContent = forwardRef<HTMLDivElement, CardContentProps>(function CardContent(
202
+ { className, asChild = false, ...props },
203
+ ref,
204
+ ) {
205
+ const Comp = asChild ? Slot : "div";
206
+ return <Comp ref={ref} className={cn("pt-4 first:pt-0", className)} {...props} />;
207
+ });
208
+ CardContent.displayName = "CardContent";
209
+
210
+ // ----------------------------------------------------------------------------
211
+ // CardFooter — same `first:pt-0` guard so a footer-only Card doesn't get a
212
+ // stray top gap.
213
+ // ----------------------------------------------------------------------------
214
+
215
+ type CardFooterProps = React.HTMLAttributes<HTMLDivElement> & {
216
+ asChild?: boolean;
217
+ };
218
+
219
+ const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(function CardFooter(
220
+ { className, asChild = false, ...props },
221
+ ref,
222
+ ) {
223
+ const Comp = asChild ? Slot : "div";
224
+ return (
225
+ <Comp
226
+ ref={ref}
227
+ className={cn("flex items-center pt-4 first:pt-0", className)}
228
+ {...props}
229
+ />
230
+ );
231
+ });
232
+ CardFooter.displayName = "CardFooter";
233
+
234
+ export {
235
+ Card,
236
+ CardHeader,
237
+ CardTitle,
238
+ CardDescription,
239
+ CardContent,
240
+ CardFooter,
241
+ cardVariants,
242
+ };
243
+ export type {
244
+ CardProps,
245
+ CardHeaderProps,
246
+ CardTitleProps,
247
+ CardDescriptionProps,
248
+ CardContentProps,
249
+ CardFooterProps,
250
+ };
@@ -0,0 +1,93 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+
4
+ import { Checkbox } from "./checkbox";
5
+
6
+ const meta: Meta<typeof Checkbox> = {
7
+ title: "Forms/Checkbox",
8
+ component: Checkbox,
9
+ parameters: { layout: "centered" },
10
+ argTypes: {
11
+ size: { control: "select", options: ["sm", "md", "lg"] },
12
+ invalid: { control: "boolean" },
13
+ disabled: { control: "boolean" },
14
+ },
15
+ };
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof Checkbox>;
19
+
20
+ export const Default: Story = {};
21
+
22
+ export const Sizes: Story = {
23
+ render: () => (
24
+ <div className="flex items-center gap-6">
25
+ <Checkbox size="sm" defaultChecked label="Small" />
26
+ <Checkbox size="md" defaultChecked label="Medium" />
27
+ <Checkbox size="lg" defaultChecked label="Large" />
28
+ </div>
29
+ ),
30
+ };
31
+
32
+ export const WithLabel: Story = {
33
+ args: { label: "Accept the terms and conditions" },
34
+ };
35
+
36
+ export const WithDescription: Story = {
37
+ args: {
38
+ label: "Email notifications",
39
+ description: "Receive weekly digest emails about your account activity.",
40
+ },
41
+ };
42
+
43
+ export const Checked: Story = {
44
+ args: { defaultChecked: true, label: "Default checked" },
45
+ };
46
+
47
+ export const Indeterminate: Story = {
48
+ render: () => {
49
+ const Wrapper = () => {
50
+ const [state, setState] = useState<"indeterminate" | boolean>("indeterminate");
51
+ return (
52
+ <Checkbox
53
+ checked={state}
54
+ onCheckedChange={(v) => setState(v)}
55
+ label="Select all"
56
+ description={`Current state: ${String(state)}`}
57
+ />
58
+ );
59
+ };
60
+ return <Wrapper />;
61
+ },
62
+ };
63
+
64
+ export const Invalid: Story = {
65
+ args: {
66
+ invalid: true,
67
+ label: "You must accept to continue",
68
+ description: "This field is required.",
69
+ },
70
+ };
71
+
72
+ export const Disabled: Story = {
73
+ render: () => (
74
+ <div className="space-y-2">
75
+ <Checkbox disabled label="Disabled unchecked" />
76
+ <Checkbox disabled defaultChecked label="Disabled checked" />
77
+ </div>
78
+ ),
79
+ };
80
+
81
+ export const ListGroup: Story = {
82
+ render: () => (
83
+ <div className="w-72 space-y-3">
84
+ <Checkbox label="Marketing emails" description="Product updates and offers." />
85
+ <Checkbox
86
+ label="Security alerts"
87
+ description="Sign-in attempts and password changes."
88
+ defaultChecked
89
+ />
90
+ <Checkbox label="Newsletter" description="Monthly company updates." />
91
+ </div>
92
+ ),
93
+ };