@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,257 @@
1
+ "use client";
2
+
3
+ import { createContext, forwardRef, useContext, useMemo } from "react";
4
+ import * as AvatarPrimitive from "@radix-ui/react-avatar";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ // Per docs/emara-ui-phase-1-components.md §7.
10
+
11
+ // ----------------------------------------------------------------------------
12
+ // Sizing — single source of truth shared by Avatar root and SkeletonCircle-
13
+ // style children.
14
+ // ----------------------------------------------------------------------------
15
+
16
+ type Size = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
17
+
18
+ const SIZE_TO_DIM: Record<Size, number> = {
19
+ xs: 24,
20
+ sm: 32,
21
+ md: 40,
22
+ lg: 48,
23
+ xl: 64,
24
+ "2xl": 96,
25
+ };
26
+
27
+ const avatarSizeClass: Record<Size, string> = {
28
+ xs: "size-6",
29
+ sm: "size-8",
30
+ md: "size-10",
31
+ lg: "size-12",
32
+ xl: "size-16",
33
+ "2xl": "size-24",
34
+ };
35
+
36
+ const statusDotSize: Record<Size, string> = {
37
+ xs: "size-1.5",
38
+ sm: "size-2",
39
+ md: "size-2.5",
40
+ lg: "size-3",
41
+ xl: "size-3.5",
42
+ "2xl": "size-4",
43
+ };
44
+
45
+ const fallbackTextSize: Record<Size, string> = {
46
+ xs: "text-[10px]",
47
+ sm: "text-xs",
48
+ md: "text-sm",
49
+ lg: "text-base",
50
+ xl: "text-lg",
51
+ "2xl": "text-2xl",
52
+ };
53
+
54
+ // ----------------------------------------------------------------------------
55
+ // AvatarGroup context — children inherit a default `size` from their parent
56
+ // group when no per-Avatar size is set.
57
+ // ----------------------------------------------------------------------------
58
+
59
+ interface AvatarGroupContextValue {
60
+ size: Size | undefined;
61
+ }
62
+ const AvatarGroupContext = createContext<AvatarGroupContextValue | null>(null);
63
+
64
+ function useAvatarGroupSize(): Size | undefined {
65
+ return useContext(AvatarGroupContext)?.size;
66
+ }
67
+
68
+ // ----------------------------------------------------------------------------
69
+ // Avatar (root)
70
+ // ----------------------------------------------------------------------------
71
+
72
+ const avatarVariants = cva(
73
+ "relative inline-flex shrink-0 overflow-hidden border-2 border-background bg-muted text-muted-foreground",
74
+ {
75
+ variants: {
76
+ shape: {
77
+ circle: "rounded-full",
78
+ rounded: "rounded-md",
79
+ },
80
+ },
81
+ defaultVariants: {
82
+ shape: "circle",
83
+ },
84
+ },
85
+ );
86
+
87
+ type Status = "online" | "offline" | "busy" | "away" | "none";
88
+
89
+ const statusLabel: Record<Exclude<Status, "none">, string> = {
90
+ online: "Online",
91
+ offline: "Offline",
92
+ busy: "Busy",
93
+ away: "Away",
94
+ };
95
+
96
+ const statusBgClass: Record<Exclude<Status, "none">, string> = {
97
+ online: "bg-success",
98
+ offline: "bg-muted-foreground",
99
+ busy: "bg-destructive",
100
+ away: "bg-warning",
101
+ };
102
+
103
+ type AvatarProps = Omit<
104
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>,
105
+ "asChild"
106
+ > &
107
+ VariantProps<typeof avatarVariants> & {
108
+ size?: Size;
109
+ status?: Status;
110
+ statusPosition?: "top-end" | "bottom-end";
111
+ };
112
+
113
+ const Avatar = forwardRef<
114
+ React.ElementRef<typeof AvatarPrimitive.Root>,
115
+ AvatarProps
116
+ >(function Avatar(
117
+ {
118
+ className,
119
+ size,
120
+ shape,
121
+ status = "none",
122
+ statusPosition = "bottom-end",
123
+ children,
124
+ ...props
125
+ },
126
+ ref,
127
+ ) {
128
+ const groupSize = useAvatarGroupSize();
129
+ const resolvedSize: Size = size ?? groupSize ?? "md";
130
+
131
+ return (
132
+ <AvatarPrimitive.Root
133
+ ref={ref}
134
+ className={cn(avatarVariants({ shape }), avatarSizeClass[resolvedSize], className)}
135
+ {...props}
136
+ >
137
+ {children}
138
+ {status !== "none" ? (
139
+ <span
140
+ aria-hidden="true"
141
+ className={cn(
142
+ "absolute block rounded-full ring-2 ring-background",
143
+ statusBgClass[status],
144
+ statusDotSize[resolvedSize],
145
+ statusPosition === "top-end" ? "top-0 end-0" : "bottom-0 end-0",
146
+ )}
147
+ />
148
+ ) : null}
149
+ {status !== "none" ? <span className="sr-only">{statusLabel[status]}</span> : null}
150
+ </AvatarPrimitive.Root>
151
+ );
152
+ });
153
+ Avatar.displayName = "Avatar";
154
+
155
+ // ----------------------------------------------------------------------------
156
+ // AvatarImage / AvatarFallback — thin Radix wrappers with default classes.
157
+ // ----------------------------------------------------------------------------
158
+
159
+ const AvatarImage = forwardRef<
160
+ React.ElementRef<typeof AvatarPrimitive.Image>,
161
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
162
+ >(function AvatarImage({ className, alt, ...props }, ref) {
163
+ return (
164
+ <AvatarPrimitive.Image
165
+ ref={ref}
166
+ alt={alt}
167
+ className={cn("aspect-square h-full w-full object-cover", className)}
168
+ {...props}
169
+ />
170
+ );
171
+ });
172
+ AvatarImage.displayName = "AvatarImage";
173
+
174
+ type AvatarFallbackProps = React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> & {
175
+ size?: Size;
176
+ };
177
+
178
+ const AvatarFallback = forwardRef<
179
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
180
+ AvatarFallbackProps
181
+ >(function AvatarFallback({ className, size, ...props }, ref) {
182
+ const groupSize = useAvatarGroupSize();
183
+ const resolved: Size = size ?? groupSize ?? "md";
184
+ return (
185
+ <AvatarPrimitive.Fallback
186
+ ref={ref}
187
+ className={cn(
188
+ "flex h-full w-full items-center justify-center bg-muted font-medium text-muted-foreground select-none",
189
+ fallbackTextSize[resolved],
190
+ className,
191
+ )}
192
+ {...props}
193
+ />
194
+ );
195
+ });
196
+ AvatarFallback.displayName = "AvatarFallback";
197
+
198
+ // ----------------------------------------------------------------------------
199
+ // AvatarGroup — overlaps children, optionally truncates with a "+N" pill.
200
+ // ----------------------------------------------------------------------------
201
+
202
+ type AvatarGroupProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
203
+ size?: Size;
204
+ spacing?: "tight" | "normal" | "loose";
205
+ max?: number;
206
+ children: React.ReactNode;
207
+ };
208
+
209
+ const spacingMargin: Record<"tight" | "normal" | "loose", string> = {
210
+ // Negative margin-inline-start — RTL flips automatically.
211
+ tight: "[&>*+*]:-ms-3",
212
+ normal: "[&>*+*]:-ms-2",
213
+ loose: "[&>*+*]:-ms-1",
214
+ };
215
+
216
+ const AvatarGroup = forwardRef<HTMLDivElement, AvatarGroupProps>(function AvatarGroup(
217
+ { className, size, spacing = "normal", max, children, ...props },
218
+ ref,
219
+ ) {
220
+ const ctx = useMemo<AvatarGroupContextValue>(() => ({ size }), [size]);
221
+
222
+ const items = (Array.isArray(children) ? children : [children]).filter(Boolean);
223
+ const visible = max !== undefined ? items.slice(0, max) : items;
224
+ const hidden = max !== undefined ? Math.max(0, items.length - max) : 0;
225
+ const dim = SIZE_TO_DIM[size ?? "md"];
226
+
227
+ return (
228
+ <AvatarGroupContext.Provider value={ctx}>
229
+ <div
230
+ ref={ref}
231
+ className={cn("inline-flex isolate", spacingMargin[spacing], className)}
232
+ {...props}
233
+ >
234
+ {visible}
235
+ {hidden > 0 ? (
236
+ <span
237
+ role="status"
238
+ aria-label={`${hidden} more`}
239
+ className={cn(
240
+ "relative inline-flex shrink-0 items-center justify-center rounded-full",
241
+ "border-2 border-background bg-muted text-muted-foreground font-medium",
242
+ avatarSizeClass[size ?? "md"],
243
+ fallbackTextSize[size ?? "md"],
244
+ )}
245
+ style={{ width: dim, height: dim }}
246
+ >
247
+ +{hidden}
248
+ </span>
249
+ ) : null}
250
+ </div>
251
+ </AvatarGroupContext.Provider>
252
+ );
253
+ });
254
+ AvatarGroup.displayName = "AvatarGroup";
255
+
256
+ export { Avatar, AvatarImage, AvatarFallback, AvatarGroup };
257
+ export type { AvatarProps, AvatarFallbackProps, AvatarGroupProps };
@@ -0,0 +1,127 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { RiCheckLine, RiStarLine } from "@remixicon/react";
3
+
4
+ import { Badge } from "./badge";
5
+
6
+ const meta: Meta<typeof Badge> = {
7
+ title: "Foundations/Badge",
8
+ component: Badge,
9
+ parameters: { layout: "centered" },
10
+ argTypes: {
11
+ variant: {
12
+ control: "select",
13
+ options: ["default", "secondary", "destructive", "outline", "success", "warning", "info"],
14
+ },
15
+ size: { control: "select", options: ["xs", "sm", "md", "lg"] },
16
+ dot: { control: "boolean" },
17
+ pulse: { control: "boolean" },
18
+ removable: { control: "boolean" },
19
+ },
20
+ args: { children: "Badge" },
21
+ };
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof Badge>;
25
+
26
+ export const Default: Story = {};
27
+
28
+ export const Variants: Story = {
29
+ render: () => (
30
+ <div className="flex flex-wrap items-center gap-2">
31
+ <Badge variant="default">Default</Badge>
32
+ <Badge variant="secondary">Secondary</Badge>
33
+ <Badge variant="destructive">Destructive</Badge>
34
+ <Badge variant="outline">Outline</Badge>
35
+ <Badge variant="success">Success</Badge>
36
+ <Badge variant="warning">Warning</Badge>
37
+ <Badge variant="info">Info</Badge>
38
+ </div>
39
+ ),
40
+ };
41
+
42
+ export const Sizes: Story = {
43
+ render: () => (
44
+ <div className="flex flex-wrap items-end gap-2">
45
+ <Badge size="xs">xs</Badge>
46
+ <Badge size="sm">sm</Badge>
47
+ <Badge size="md">md</Badge>
48
+ <Badge size="lg">lg</Badge>
49
+ </div>
50
+ ),
51
+ };
52
+
53
+ export const WithDot: Story = {
54
+ render: () => (
55
+ <div className="flex flex-wrap items-center gap-2">
56
+ <Badge dot variant="default">
57
+ Default
58
+ </Badge>
59
+ <Badge dot variant="secondary">
60
+ Secondary
61
+ </Badge>
62
+ <Badge dot variant="destructive">
63
+ Destructive
64
+ </Badge>
65
+ <Badge dot variant="outline">
66
+ Outline
67
+ </Badge>
68
+ <Badge dot variant="success">
69
+ Success
70
+ </Badge>
71
+ <Badge dot variant="warning">
72
+ Warning
73
+ </Badge>
74
+ <Badge dot variant="info">
75
+ Info
76
+ </Badge>
77
+ </div>
78
+ ),
79
+ };
80
+
81
+ export const PulsingDot: Story = {
82
+ render: () => (
83
+ <div className="flex flex-wrap items-center gap-2">
84
+ <Badge dot pulse variant="success">
85
+ Live
86
+ </Badge>
87
+ <Badge dot pulse variant="destructive">
88
+ Recording
89
+ </Badge>
90
+ <Badge dot pulse variant="info">
91
+ Streaming
92
+ </Badge>
93
+ </div>
94
+ ),
95
+ };
96
+
97
+ export const WithIcon: Story = {
98
+ render: () => (
99
+ <div className="flex flex-wrap items-center gap-2">
100
+ <Badge icon={<RiCheckLine />} variant="success">
101
+ Verified
102
+ </Badge>
103
+ <Badge icon={<RiStarLine />} variant="warning">
104
+ Featured
105
+ </Badge>
106
+ <Badge icon={<RiCheckLine />} variant="outline">
107
+ Approved
108
+ </Badge>
109
+ </div>
110
+ ),
111
+ };
112
+
113
+ export const Removable: Story = {
114
+ render: () => (
115
+ <div className="flex flex-wrap items-center gap-2">
116
+ <Badge removable onRemove={() => console.log("remove default")}>
117
+ Default
118
+ </Badge>
119
+ <Badge removable onRemove={() => console.log("remove tag")} variant="secondary">
120
+ TypeScript
121
+ </Badge>
122
+ <Badge removable onRemove={() => console.log("remove tag")} variant="outline">
123
+ Filter
124
+ </Badge>
125
+ </div>
126
+ ),
127
+ };
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { RiCloseLine } from "@remixicon/react";
6
+ import { cva, type VariantProps } from "class-variance-authority";
7
+
8
+ import { cn } from "@/lib/utils";
9
+
10
+ // Per docs/emara-ui-phase-1-components.md §6.
11
+
12
+ const badgeVariants = cva(
13
+ [
14
+ "inline-flex items-center gap-1 font-medium select-none",
15
+ "rounded-full border whitespace-nowrap",
16
+ "transition-colors",
17
+ ].join(" "),
18
+ {
19
+ variants: {
20
+ variant: {
21
+ default: "border-transparent bg-primary text-primary-foreground",
22
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
23
+ destructive: "border-transparent bg-destructive text-destructive-foreground",
24
+ outline: "border-border text-foreground",
25
+ success: "border-transparent bg-success text-primary-foreground",
26
+ warning: "border-transparent bg-warning text-primary-foreground",
27
+ info: "border-transparent bg-info text-primary-foreground",
28
+ },
29
+ size: {
30
+ xs: "h-4 px-1.5 text-[10px] leading-none",
31
+ sm: "h-5 px-2 text-xs leading-none",
32
+ md: "h-6 px-2.5 text-xs leading-none",
33
+ lg: "h-7 px-3 text-sm leading-none",
34
+ },
35
+ },
36
+ defaultVariants: { variant: "default", size: "md" },
37
+ },
38
+ );
39
+
40
+ // Per-variant dot color (the dot inherits the variant's foreground for
41
+ // outline; otherwise uses currentColor at full opacity).
42
+ const dotVariants = cva("inline-block size-1.5 rounded-full me-1", {
43
+ variants: {
44
+ variant: {
45
+ default: "bg-primary-foreground/90",
46
+ secondary: "bg-secondary-foreground/80",
47
+ destructive: "bg-destructive-foreground/90",
48
+ outline: "bg-foreground/80",
49
+ success: "bg-primary-foreground/90",
50
+ warning: "bg-primary-foreground/90",
51
+ info: "bg-primary-foreground/90",
52
+ },
53
+ size: {
54
+ xs: "size-1",
55
+ sm: "size-1.5",
56
+ md: "size-1.5",
57
+ lg: "size-2",
58
+ },
59
+ },
60
+ defaultVariants: { variant: "default", size: "md" },
61
+ });
62
+
63
+ type BadgeVariants = VariantProps<typeof badgeVariants>;
64
+
65
+ type BadgeProps = Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> &
66
+ BadgeVariants & {
67
+ asChild?: boolean;
68
+ dot?: boolean;
69
+ icon?: React.ReactNode;
70
+ removable?: boolean;
71
+ onRemove?: () => void;
72
+ pulse?: boolean;
73
+ children?: React.ReactNode;
74
+ };
75
+
76
+ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
77
+ {
78
+ className,
79
+ variant,
80
+ size,
81
+ asChild = false,
82
+ dot = false,
83
+ icon,
84
+ removable = false,
85
+ onRemove,
86
+ pulse = false,
87
+ children,
88
+ ...props
89
+ },
90
+ ref,
91
+ ) {
92
+ const Comp = asChild ? Slot : "span";
93
+
94
+ const innerContent = (
95
+ <>
96
+ {dot ? (
97
+ <span aria-hidden="true" className="relative inline-flex">
98
+ <span className={cn(dotVariants({ variant, size }))} />
99
+ {pulse ? (
100
+ <span
101
+ aria-hidden="true"
102
+ className={cn(
103
+ dotVariants({ variant, size }),
104
+ "absolute inset-0 me-0 animate-ping opacity-75",
105
+ )}
106
+ />
107
+ ) : null}
108
+ </span>
109
+ ) : null}
110
+ {icon ? (
111
+ <span aria-hidden="true" className="me-1 inline-flex [&_svg]:size-3.5 [&_svg]:shrink-0">
112
+ {icon}
113
+ </span>
114
+ ) : null}
115
+ <span>{children}</span>
116
+ {removable ? (
117
+ <button
118
+ type="button"
119
+ aria-label="Remove"
120
+ onClick={(e) => {
121
+ e.stopPropagation();
122
+ onRemove?.();
123
+ }}
124
+ className={cn(
125
+ "ms-1 inline-flex items-center justify-center rounded-full p-0.5",
126
+ "hover:bg-foreground/10",
127
+ "focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
128
+ "[&_svg]:size-3 [&_svg]:shrink-0",
129
+ )}
130
+ >
131
+ <RiCloseLine />
132
+ </button>
133
+ ) : null}
134
+ </>
135
+ );
136
+
137
+ return (
138
+ <Comp ref={ref} className={cn(badgeVariants({ variant, size }), className)} {...props}>
139
+ {asChild ? children : innerContent}
140
+ </Comp>
141
+ );
142
+ });
143
+ Badge.displayName = "Badge";
144
+
145
+ export { Badge, badgeVariants };
146
+ export type { BadgeProps };
@@ -0,0 +1,92 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { RiFolderLine, RiHome4Line, RiSettings4Line } from "@remixicon/react";
3
+
4
+ import {
5
+ Breadcrumb,
6
+ BreadcrumbEllipsis,
7
+ BreadcrumbItem,
8
+ BreadcrumbLink,
9
+ BreadcrumbList,
10
+ BreadcrumbPage,
11
+ BreadcrumbSeparator,
12
+ Breadcrumbs,
13
+ } from "./breadcrumb";
14
+
15
+ const meta: Meta<typeof Breadcrumbs> = {
16
+ title: "Layout/Breadcrumb",
17
+ component: Breadcrumbs,
18
+ parameters: { layout: "padded" },
19
+ argTypes: {
20
+ size: { control: "select", options: ["sm", "md", "lg"] },
21
+ maxItems: { control: "number" },
22
+ itemsBeforeCollapse: { control: "number" },
23
+ itemsAfterCollapse: { control: "number" },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof Breadcrumbs>;
29
+
30
+ const ITEMS = [
31
+ { label: "Home", href: "/", icon: <RiHome4Line /> },
32
+ { label: "Settings", href: "/settings", icon: <RiSettings4Line /> },
33
+ { label: "Account" },
34
+ ];
35
+
36
+ export const Default: Story = {
37
+ args: { items: ITEMS },
38
+ };
39
+
40
+ export const Sizes: Story = {
41
+ render: () => (
42
+ <div className="space-y-3">
43
+ <Breadcrumbs items={ITEMS} size="sm" />
44
+ <Breadcrumbs items={ITEMS} size="md" />
45
+ <Breadcrumbs items={ITEMS} size="lg" />
46
+ </div>
47
+ ),
48
+ };
49
+
50
+ const MANY = [
51
+ { label: "Home", href: "/", icon: <RiHome4Line /> },
52
+ { label: "Workspaces", href: "/workspaces" },
53
+ { label: "Acme", href: "/workspaces/acme" },
54
+ { label: "Projects", href: "/workspaces/acme/projects" },
55
+ { label: "Q3 launch", href: "/workspaces/acme/projects/q3", icon: <RiFolderLine /> },
56
+ { label: "Tasks" },
57
+ ];
58
+
59
+ export const Collapsing: Story = {
60
+ args: { items: MANY, maxItems: 4, itemsBeforeCollapse: 1, itemsAfterCollapse: 2 },
61
+ };
62
+
63
+ export const CustomSeparator: Story = {
64
+ args: { items: ITEMS, separator: <span className="text-muted-foreground/60">/</span> },
65
+ };
66
+
67
+ /** Manual primitives — when you need full layout control. */
68
+ export const Primitives: Story = {
69
+ render: () => (
70
+ <Breadcrumb>
71
+ <BreadcrumbList>
72
+ <BreadcrumbItem>
73
+ <BreadcrumbLink href="/">
74
+ <RiHome4Line /> Home
75
+ </BreadcrumbLink>
76
+ </BreadcrumbItem>
77
+ <BreadcrumbSeparator />
78
+ <BreadcrumbItem>
79
+ <BreadcrumbLink href="/library">Library</BreadcrumbLink>
80
+ </BreadcrumbItem>
81
+ <BreadcrumbSeparator />
82
+ <BreadcrumbItem>
83
+ <BreadcrumbEllipsis />
84
+ </BreadcrumbItem>
85
+ <BreadcrumbSeparator />
86
+ <BreadcrumbItem>
87
+ <BreadcrumbPage>Components</BreadcrumbPage>
88
+ </BreadcrumbItem>
89
+ </BreadcrumbList>
90
+ </Breadcrumb>
91
+ ),
92
+ };