@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,121 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { RiAddLine, RiInboxLine, RiRocketLine, RiSearchLine } from "@remixicon/react";
3
+
4
+ import { Button } from "./button";
5
+ import {
6
+ EmptyState,
7
+ EmptyStateActions,
8
+ EmptyStateDescription,
9
+ EmptyStateIllustration,
10
+ EmptyStateTitle,
11
+ } from "./empty-state";
12
+
13
+ const meta: Meta<typeof EmptyState> = {
14
+ title: "Data/EmptyState",
15
+ component: EmptyState,
16
+ parameters: { layout: "padded" },
17
+ argTypes: {
18
+ variant: { control: "select", options: ["default", "compact", "splash"] },
19
+ size: { control: "select", options: ["sm", "md", "lg"] },
20
+ },
21
+ };
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof EmptyState>;
25
+
26
+ export const Default: Story = {
27
+ args: {
28
+ variant: "default",
29
+ icon: <RiInboxLine />,
30
+ title: "No charges yet",
31
+ description: "When customers pay, their charges will appear here.",
32
+ actions: <Button leftIcon={<RiAddLine />}>Create charge</Button>,
33
+ },
34
+ render: (args) => (
35
+ <div className="border-border w-xl rounded-md border">
36
+ <EmptyState {...args} />
37
+ </div>
38
+ ),
39
+ };
40
+
41
+ export const Sizes: Story = {
42
+ render: () => (
43
+ <div className="w-xl space-y-4">
44
+ {(["sm", "md", "lg"] as const).map((size) => (
45
+ <div key={size} className="border-border rounded-md border">
46
+ <EmptyState
47
+ size={size}
48
+ icon={<RiInboxLine />}
49
+ title={`Size: ${size}`}
50
+ description="The illustration, title, and description all scale with size."
51
+ />
52
+ </div>
53
+ ))}
54
+ </div>
55
+ ),
56
+ };
57
+
58
+ export const Compact: Story = {
59
+ args: {
60
+ variant: "compact",
61
+ icon: <RiSearchLine />,
62
+ title: "No matches",
63
+ description: "Try a different search term.",
64
+ actions: (
65
+ <Button size="sm" variant="ghost">
66
+ Clear
67
+ </Button>
68
+ ),
69
+ },
70
+ render: (args) => (
71
+ <div className="border-border w-lg rounded-md border">
72
+ <EmptyState {...args} />
73
+ </div>
74
+ ),
75
+ };
76
+
77
+ export const Splash: Story = {
78
+ args: {
79
+ variant: "splash",
80
+ icon: <RiRocketLine />,
81
+ title: "Welcome to Emara",
82
+ description: "Let's get you set up with the tools you need.",
83
+ actions: (
84
+ <>
85
+ <Button size="lg">Get started</Button>
86
+ <Button size="lg" variant="outline">
87
+ View docs
88
+ </Button>
89
+ </>
90
+ ),
91
+ },
92
+ render: (args) => (
93
+ <div className="border-border w-full max-w-3xl rounded-md border">
94
+ <EmptyState {...args} />
95
+ </div>
96
+ ),
97
+ };
98
+
99
+ /**
100
+ * Composition with sub-components instead of convenience props.
101
+ * Use this when you need more layout control.
102
+ */
103
+ export const Composed: Story = {
104
+ render: () => (
105
+ <div className="border-border w-xl rounded-md border">
106
+ <EmptyState variant="default" size="md">
107
+ <EmptyStateIllustration>
108
+ <RiInboxLine />
109
+ </EmptyStateIllustration>
110
+ <EmptyStateTitle as="h2">Inbox empty</EmptyStateTitle>
111
+ <EmptyStateDescription>
112
+ Compose your first message — you&apos;ll see it here when sent.
113
+ </EmptyStateDescription>
114
+ <EmptyStateActions>
115
+ <Button leftIcon={<RiAddLine />}>Compose</Button>
116
+ <Button variant="outline">Learn more</Button>
117
+ </EmptyStateActions>
118
+ </EmptyState>
119
+ </div>
120
+ ),
121
+ };
@@ -0,0 +1,289 @@
1
+ "use client";
2
+
3
+ import { createContext, forwardRef, useContext, useMemo } 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
+ // Per docs/emara-ui-phase-4-components.md §5.
10
+
11
+ type EmptyStateVariant = "default" | "compact" | "splash";
12
+ type EmptyStateSize = "sm" | "md" | "lg";
13
+
14
+ interface EmptyStateContextValue {
15
+ variant: EmptyStateVariant;
16
+ size: EmptyStateSize;
17
+ }
18
+
19
+ const EmptyStateContext = createContext<EmptyStateContextValue | null>(null);
20
+
21
+ function useEmptyStateContext(): EmptyStateContextValue {
22
+ const ctx = useContext(EmptyStateContext);
23
+ if (!ctx) throw new Error("EmptyState subcomponents must be used inside <EmptyState>");
24
+ return ctx;
25
+ }
26
+
27
+ // --- Root ------------------------------------------------------------------
28
+
29
+ const emptyStateVariants = cva("text-foreground", {
30
+ variants: {
31
+ variant: {
32
+ default: "flex w-full flex-col items-center text-center",
33
+ compact: "flex w-full items-start gap-3 text-start",
34
+ splash: "flex min-h-[60vh] w-full flex-col items-center justify-center text-center",
35
+ },
36
+ size: {
37
+ sm: "",
38
+ md: "",
39
+ lg: "",
40
+ },
41
+ },
42
+ compoundVariants: [
43
+ { variant: "default", size: "sm", class: "py-6 gap-2" },
44
+ { variant: "default", size: "md", class: "py-10 gap-3" },
45
+ { variant: "default", size: "lg", class: "py-16 gap-4" },
46
+ { variant: "compact", size: "sm", class: "p-3" },
47
+ { variant: "compact", size: "md", class: "p-4" },
48
+ { variant: "compact", size: "lg", class: "p-6" },
49
+ { variant: "splash", size: "sm", class: "gap-4" },
50
+ { variant: "splash", size: "md", class: "gap-6" },
51
+ { variant: "splash", size: "lg", class: "gap-8" },
52
+ ],
53
+ defaultVariants: { variant: "default", size: "md" },
54
+ });
55
+
56
+ type EmptyStateRootVariants = VariantProps<typeof emptyStateVariants>;
57
+
58
+ type EmptyStateProps = React.HTMLAttributes<HTMLDivElement> &
59
+ EmptyStateRootVariants & {
60
+ asChild?: boolean;
61
+ /** Convenience: provided icon is rendered inside Illustration. */
62
+ icon?: React.ReactNode;
63
+ /** Convenience: provided string is rendered as the Title. */
64
+ title?: React.ReactNode;
65
+ /** Convenience: provided node is rendered as the Description. */
66
+ description?: React.ReactNode;
67
+ /** Convenience: provided node is rendered inside Actions. */
68
+ actions?: React.ReactNode;
69
+ };
70
+
71
+ const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(
72
+ {
73
+ className,
74
+ variant = "default",
75
+ size = "md",
76
+ asChild = false,
77
+ icon,
78
+ title,
79
+ description,
80
+ actions,
81
+ children,
82
+ ...props
83
+ },
84
+ ref,
85
+ ) {
86
+ const Comp = asChild ? Slot : "div";
87
+ const ctx = useMemo(
88
+ () => ({ variant: variant ?? "default", size: size ?? "md" }),
89
+ [variant, size],
90
+ );
91
+
92
+ // When convenience props are used, render the composed structure
93
+ // automatically. Otherwise just render children.
94
+ const usingConvenience =
95
+ icon !== undefined ||
96
+ title !== undefined ||
97
+ description !== undefined ||
98
+ actions !== undefined;
99
+
100
+ return (
101
+ <EmptyStateContext.Provider value={ctx}>
102
+ <Comp
103
+ ref={ref}
104
+ role="status"
105
+ className={cn(emptyStateVariants({ variant, size }), className)}
106
+ {...props}
107
+ >
108
+ {usingConvenience ? (
109
+ variant === "compact" ? (
110
+ <>
111
+ {icon ? <EmptyStateIllustration>{icon}</EmptyStateIllustration> : null}
112
+ <div className="flex-1 min-w-0">
113
+ {title ? <EmptyStateTitle>{title}</EmptyStateTitle> : null}
114
+ {description ? (
115
+ <EmptyStateDescription>{description}</EmptyStateDescription>
116
+ ) : null}
117
+ </div>
118
+ {actions ? <EmptyStateActions>{actions}</EmptyStateActions> : null}
119
+ </>
120
+ ) : (
121
+ <>
122
+ {icon ? <EmptyStateIllustration>{icon}</EmptyStateIllustration> : null}
123
+ {title ? <EmptyStateTitle>{title}</EmptyStateTitle> : null}
124
+ {description ? (
125
+ <EmptyStateDescription>{description}</EmptyStateDescription>
126
+ ) : null}
127
+ {actions ? <EmptyStateActions>{actions}</EmptyStateActions> : null}
128
+ </>
129
+ )
130
+ ) : (
131
+ children
132
+ )}
133
+ </Comp>
134
+ </EmptyStateContext.Provider>
135
+ );
136
+ });
137
+ EmptyState.displayName = "EmptyState";
138
+
139
+ // --- Illustration ----------------------------------------------------------
140
+
141
+ const illustrationVariants = cva(
142
+ "text-muted-foreground [&_svg]:shrink-0",
143
+ {
144
+ variants: {
145
+ variant: {
146
+ default: "[&_svg]:size-12",
147
+ compact: "[&_svg]:size-5 mt-0.5",
148
+ splash: "[&_svg]:size-20",
149
+ },
150
+ size: {
151
+ sm: "",
152
+ md: "",
153
+ lg: "",
154
+ },
155
+ },
156
+ compoundVariants: [
157
+ { variant: "default", size: "sm", class: "[&_svg]:size-10" },
158
+ { variant: "default", size: "lg", class: "[&_svg]:size-16" },
159
+ { variant: "splash", size: "sm", class: "[&_svg]:size-16" },
160
+ { variant: "splash", size: "lg", class: "[&_svg]:size-24" },
161
+ ],
162
+ defaultVariants: { variant: "default", size: "md" },
163
+ },
164
+ );
165
+
166
+ type EmptyStateIllustrationProps = React.HTMLAttributes<HTMLDivElement>;
167
+
168
+ const EmptyStateIllustration = forwardRef<HTMLDivElement, EmptyStateIllustrationProps>(
169
+ function EmptyStateIllustration({ className, ...props }, ref) {
170
+ const { variant, size } = useEmptyStateContext();
171
+ return (
172
+ <div
173
+ ref={ref}
174
+ aria-hidden="true"
175
+ className={cn(illustrationVariants({ variant, size }), className)}
176
+ {...props}
177
+ />
178
+ );
179
+ },
180
+ );
181
+ EmptyStateIllustration.displayName = "EmptyStateIllustration";
182
+
183
+ // --- Title ------------------------------------------------------------------
184
+
185
+ const titleVariants = cva("font-semibold text-foreground", {
186
+ variants: {
187
+ variant: {
188
+ default: "",
189
+ compact: "",
190
+ splash: "",
191
+ },
192
+ size: {
193
+ sm: "text-sm",
194
+ md: "text-base",
195
+ lg: "text-lg",
196
+ },
197
+ },
198
+ compoundVariants: [
199
+ { variant: "splash", size: "sm", class: "text-lg" },
200
+ { variant: "splash", size: "md", class: "text-2xl" },
201
+ { variant: "splash", size: "lg", class: "text-3xl" },
202
+ ],
203
+ defaultVariants: { variant: "default", size: "md" },
204
+ });
205
+
206
+ type EmptyStateTitleProps = React.HTMLAttributes<HTMLHeadingElement> & {
207
+ as?: "h2" | "h3" | "h4" | "h5" | "h6";
208
+ };
209
+
210
+ const EmptyStateTitle = forwardRef<HTMLHeadingElement, EmptyStateTitleProps>(
211
+ function EmptyStateTitle({ as: Heading = "h3", className, ...props }, ref) {
212
+ const { variant, size } = useEmptyStateContext();
213
+ return (
214
+ <Heading
215
+ ref={ref}
216
+ className={cn(titleVariants({ variant, size }), className)}
217
+ {...props}
218
+ />
219
+ );
220
+ },
221
+ );
222
+ EmptyStateTitle.displayName = "EmptyStateTitle";
223
+
224
+ // --- Description ------------------------------------------------------------
225
+
226
+ const descriptionVariants = cva("text-muted-foreground", {
227
+ variants: {
228
+ size: {
229
+ sm: "text-xs",
230
+ md: "text-sm",
231
+ lg: "text-base",
232
+ },
233
+ },
234
+ defaultVariants: { size: "md" },
235
+ });
236
+
237
+ type EmptyStateDescriptionProps = React.HTMLAttributes<HTMLParagraphElement>;
238
+
239
+ const EmptyStateDescription = forwardRef<HTMLParagraphElement, EmptyStateDescriptionProps>(
240
+ function EmptyStateDescription({ className, ...props }, ref) {
241
+ const { size } = useEmptyStateContext();
242
+ return (
243
+ <p
244
+ ref={ref}
245
+ className={cn(descriptionVariants({ size }), "max-w-prose", className)}
246
+ {...props}
247
+ />
248
+ );
249
+ },
250
+ );
251
+ EmptyStateDescription.displayName = "EmptyStateDescription";
252
+
253
+ // --- Actions ----------------------------------------------------------------
254
+
255
+ type EmptyStateActionsProps = React.HTMLAttributes<HTMLDivElement>;
256
+
257
+ const EmptyStateActions = forwardRef<HTMLDivElement, EmptyStateActionsProps>(
258
+ function EmptyStateActions({ className, ...props }, ref) {
259
+ const { variant } = useEmptyStateContext();
260
+ return (
261
+ <div
262
+ ref={ref}
263
+ className={cn(
264
+ "flex flex-wrap items-center gap-2",
265
+ variant === "compact" ? "ms-auto" : "justify-center mt-2",
266
+ className,
267
+ )}
268
+ {...props}
269
+ />
270
+ );
271
+ },
272
+ );
273
+ EmptyStateActions.displayName = "EmptyStateActions";
274
+
275
+ export {
276
+ EmptyState,
277
+ EmptyStateIllustration,
278
+ EmptyStateTitle,
279
+ EmptyStateDescription,
280
+ EmptyStateActions,
281
+ emptyStateVariants,
282
+ };
283
+ export type {
284
+ EmptyStateProps,
285
+ EmptyStateIllustrationProps,
286
+ EmptyStateTitleProps,
287
+ EmptyStateDescriptionProps,
288
+ EmptyStateActionsProps,
289
+ };
@@ -0,0 +1,201 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+
4
+ import { Checkbox } from "./checkbox";
5
+ import { FieldGroup } from "./field-group";
6
+ import { Input } from "./input";
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "./select";
14
+ import { Textarea } from "./textarea";
15
+ import { TooltipProvider } from "./tooltip";
16
+
17
+ const meta: Meta<typeof FieldGroup> = {
18
+ title: "Forms/FieldGroup",
19
+ component: FieldGroup,
20
+ parameters: { layout: "centered" },
21
+ decorators: [
22
+ (Story) => (
23
+ <TooltipProvider delayDuration={200}>
24
+ <div className="w-96">
25
+ <Story />
26
+ </div>
27
+ </TooltipProvider>
28
+ ),
29
+ ],
30
+ argTypes: {
31
+ required: { control: "boolean" },
32
+ optional: { control: "boolean" },
33
+ invalid: { control: "boolean" },
34
+ disabled: { control: "boolean" },
35
+ orientation: { control: "select", options: ["vertical", "horizontal"] },
36
+ },
37
+ };
38
+
39
+ export default meta;
40
+ type Story = StoryObj<typeof FieldGroup>;
41
+
42
+ export const Default: Story = {
43
+ render: (args) => (
44
+ <FieldGroup {...args}>
45
+ <FieldGroup.Label>Full name</FieldGroup.Label>
46
+ <FieldGroup.Control>
47
+ <Input placeholder="Alice Bouchaib" />
48
+ </FieldGroup.Control>
49
+ <FieldGroup.HelpText>As it appears on your ID.</FieldGroup.HelpText>
50
+ </FieldGroup>
51
+ ),
52
+ };
53
+
54
+ export const Required: Story = {
55
+ args: { required: true },
56
+ render: (args) => (
57
+ <FieldGroup {...args}>
58
+ <FieldGroup.Label>Email address</FieldGroup.Label>
59
+ <FieldGroup.Control>
60
+ <Input type="email" placeholder="alice@example.com" />
61
+ </FieldGroup.Control>
62
+ </FieldGroup>
63
+ ),
64
+ };
65
+
66
+ export const Optional: Story = {
67
+ args: { optional: true },
68
+ render: (args) => (
69
+ <FieldGroup {...args}>
70
+ <FieldGroup.Label>Phone number</FieldGroup.Label>
71
+ <FieldGroup.Control>
72
+ <Input type="tel" placeholder="+212 6 12 34 56 78" />
73
+ </FieldGroup.Control>
74
+ <FieldGroup.HelpText>We&apos;ll only use this for security alerts.</FieldGroup.HelpText>
75
+ </FieldGroup>
76
+ ),
77
+ };
78
+
79
+ export const Invalid: Story = {
80
+ args: { invalid: true, required: true },
81
+ render: (args) => (
82
+ <FieldGroup {...args}>
83
+ <FieldGroup.Label>Email address</FieldGroup.Label>
84
+ <FieldGroup.Control>
85
+ <Input type="email" defaultValue="not-an-email" />
86
+ </FieldGroup.Control>
87
+ <FieldGroup.HelpText>Enter the address you signed up with.</FieldGroup.HelpText>
88
+ <FieldGroup.ErrorText>That doesn&apos;t look like a valid email.</FieldGroup.ErrorText>
89
+ </FieldGroup>
90
+ ),
91
+ };
92
+
93
+ export const Disabled: Story = {
94
+ args: { disabled: true },
95
+ render: (args) => (
96
+ <FieldGroup {...args}>
97
+ <FieldGroup.Label>Workspace slug</FieldGroup.Label>
98
+ <FieldGroup.Control>
99
+ <Input defaultValue="emara-ui" />
100
+ </FieldGroup.Control>
101
+ <FieldGroup.HelpText>Contact support to change this.</FieldGroup.HelpText>
102
+ </FieldGroup>
103
+ ),
104
+ };
105
+
106
+ export const HorizontalLayout: Story = {
107
+ args: { orientation: "horizontal" },
108
+ render: (args) => (
109
+ <FieldGroup {...args}>
110
+ <FieldGroup.Label>Username</FieldGroup.Label>
111
+ <FieldGroup.Control>
112
+ <Input placeholder="alice" />
113
+ </FieldGroup.Control>
114
+ </FieldGroup>
115
+ ),
116
+ };
117
+
118
+ export const WithTextarea: Story = {
119
+ args: { required: true },
120
+ render: (args) => (
121
+ <FieldGroup {...args}>
122
+ <FieldGroup.Label>Bio</FieldGroup.Label>
123
+ <FieldGroup.Control>
124
+ <Textarea placeholder="Tell us a bit about yourself…" />
125
+ </FieldGroup.Control>
126
+ <FieldGroup.HelpText>Markdown is supported.</FieldGroup.HelpText>
127
+ </FieldGroup>
128
+ ),
129
+ };
130
+
131
+ export const WithSelect: Story = {
132
+ render: () => (
133
+ <FieldGroup required>
134
+ <FieldGroup.Label>Country</FieldGroup.Label>
135
+ <FieldGroup.Control>
136
+ <Select>
137
+ <SelectTrigger className="w-full">
138
+ <SelectValue placeholder="Pick a country" />
139
+ </SelectTrigger>
140
+ <SelectContent>
141
+ <SelectItem value="ma">Morocco</SelectItem>
142
+ <SelectItem value="fr">France</SelectItem>
143
+ <SelectItem value="de">Germany</SelectItem>
144
+ </SelectContent>
145
+ </Select>
146
+ </FieldGroup.Control>
147
+ </FieldGroup>
148
+ ),
149
+ };
150
+
151
+ export const WithCheckbox: Story = {
152
+ render: () => (
153
+ <FieldGroup invalid>
154
+ <FieldGroup.Control>
155
+ <Checkbox label="I accept the terms and conditions" />
156
+ </FieldGroup.Control>
157
+ <FieldGroup.ErrorText>You must accept the terms to continue.</FieldGroup.ErrorText>
158
+ </FieldGroup>
159
+ ),
160
+ };
161
+
162
+ /** Real-world form composition: 4 fields, one with a validation error. */
163
+ export const Form: Story = {
164
+ render: () => {
165
+ const Wrapper = () => {
166
+ const [email, setEmail] = useState("not-an-email");
167
+ const invalidEmail = !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
168
+ return (
169
+ <form className="space-y-4">
170
+ <FieldGroup required>
171
+ <FieldGroup.Label>Full name</FieldGroup.Label>
172
+ <FieldGroup.Control>
173
+ <Input placeholder="Alice Bouchaib" />
174
+ </FieldGroup.Control>
175
+ </FieldGroup>
176
+
177
+ <FieldGroup required invalid={invalidEmail}>
178
+ <FieldGroup.Label>Email</FieldGroup.Label>
179
+ <FieldGroup.Control>
180
+ <Input
181
+ type="email"
182
+ value={email}
183
+ onChange={(e) => setEmail(e.target.value)}
184
+ />
185
+ </FieldGroup.Control>
186
+ <FieldGroup.HelpText>We&apos;ll never share your email.</FieldGroup.HelpText>
187
+ <FieldGroup.ErrorText>Enter a valid email address.</FieldGroup.ErrorText>
188
+ </FieldGroup>
189
+
190
+ <FieldGroup optional>
191
+ <FieldGroup.Label>Bio</FieldGroup.Label>
192
+ <FieldGroup.Control>
193
+ <Textarea placeholder="Tell us a bit about yourself…" />
194
+ </FieldGroup.Control>
195
+ </FieldGroup>
196
+ </form>
197
+ );
198
+ };
199
+ return <Wrapper />;
200
+ },
201
+ };