@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,623 @@
1
+ "use client";
2
+
3
+ import {
4
+ Children,
5
+ cloneElement,
6
+ createContext,
7
+ forwardRef,
8
+ isValidElement,
9
+ useContext,
10
+ useMemo,
11
+ useState,
12
+ type ReactElement,
13
+ type ReactNode,
14
+ } from "react";
15
+ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
16
+ import { Slot } from "@radix-ui/react-slot";
17
+ import { RiArrowDownSLine, RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react";
18
+ import { cva, type VariantProps } from "class-variance-authority";
19
+
20
+ import { cn } from "@/lib/utils";
21
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
22
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
23
+
24
+ // Per docs/emara-ui-phase-5-components.md §2.
25
+
26
+ // ---- Context ---------------------------------------------------------------
27
+
28
+ type SidebarVariant = "filled" | "outline" | "floating";
29
+
30
+ interface SidebarContextValue {
31
+ variant: SidebarVariant;
32
+ collapsed: boolean;
33
+ }
34
+
35
+ const SidebarContext = createContext<SidebarContextValue | null>(null);
36
+
37
+ function useSidebar(): SidebarContextValue {
38
+ const ctx = useContext(SidebarContext);
39
+ if (!ctx) throw new Error("Sidebar subcomponents must be used inside <Sidebar>");
40
+ return ctx;
41
+ }
42
+
43
+ // ---- Root ------------------------------------------------------------------
44
+
45
+ // Width is driven by tokens (--sidebar-w-{sm,md,lg,collapsed}) defined in
46
+ // design-tokens §11b.3. Collapsed width is fixed across sizes.
47
+ const sidebarVariants = cva(
48
+ "flex h-full flex-col text-foreground transition-[width] duration-normal",
49
+ {
50
+ variants: {
51
+ variant: {
52
+ filled: "bg-muted",
53
+ outline: "bg-background border-e border-border",
54
+ floating: "bg-card rounded-lg shadow-md m-2",
55
+ },
56
+ size: {
57
+ sm: "",
58
+ md: "",
59
+ lg: "",
60
+ },
61
+ collapsed: {
62
+ true: "w-sidebar-collapsed",
63
+ false: "",
64
+ },
65
+ },
66
+ compoundVariants: [
67
+ // Expanded width — driven by `size` only when not collapsed.
68
+ { collapsed: false, size: "sm", class: "w-sidebar-sm" },
69
+ { collapsed: false, size: "md", class: "w-sidebar-md" },
70
+ { collapsed: false, size: "lg", class: "w-sidebar-lg" },
71
+ ],
72
+ defaultVariants: {
73
+ variant: "filled",
74
+ size: "md",
75
+ collapsed: false,
76
+ },
77
+ },
78
+ );
79
+
80
+ type SidebarVariants = VariantProps<typeof sidebarVariants>;
81
+
82
+ type SidebarProps = React.HTMLAttributes<HTMLElement> &
83
+ SidebarVariants & {
84
+ asChild?: boolean;
85
+ };
86
+
87
+ const Sidebar = forwardRef<HTMLElement, SidebarProps>(function Sidebar(
88
+ { className, variant = "filled", size = "md", collapsed = false, asChild = false, ...props },
89
+ ref,
90
+ ) {
91
+ const Comp = (asChild ? Slot : "aside") as React.ElementType;
92
+ const ctx = useMemo<SidebarContextValue>(
93
+ () => ({
94
+ variant: variant ?? "filled",
95
+ collapsed: collapsed ?? false,
96
+ }),
97
+ [variant, collapsed],
98
+ );
99
+ return (
100
+ <SidebarContext.Provider value={ctx}>
101
+ <Comp
102
+ ref={ref}
103
+ data-collapsed={collapsed || undefined}
104
+ data-variant={variant}
105
+ data-size={size}
106
+ className={cn(sidebarVariants({ variant, size, collapsed }), className)}
107
+ {...props}
108
+ />
109
+ </SidebarContext.Provider>
110
+ );
111
+ });
112
+ Sidebar.displayName = "Sidebar";
113
+
114
+ // ---- Brand / Header / Body / Footer ---------------------------------------
115
+
116
+ const SidebarBrand = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
117
+ function SidebarBrand({ className, ...props }, ref) {
118
+ const { collapsed } = useSidebar();
119
+ return (
120
+ <div
121
+ ref={ref}
122
+ className={cn(
123
+ "text-foreground flex h-14 shrink-0 items-center gap-2 px-3 font-semibold",
124
+ "[&_svg]:size-6 [&_svg]:shrink-0",
125
+ collapsed && "justify-center",
126
+ className,
127
+ )}
128
+ {...props}
129
+ />
130
+ );
131
+ },
132
+ );
133
+ SidebarBrand.displayName = "SidebarBrand";
134
+
135
+ const SidebarHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
136
+ function SidebarHeader({ className, ...props }, ref) {
137
+ return <div ref={ref} className={cn("shrink-0 px-3 pb-2", className)} {...props} />;
138
+ },
139
+ );
140
+ SidebarHeader.displayName = "SidebarHeader";
141
+
142
+ const SidebarBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
143
+ function SidebarBody({ className, ...props }, ref) {
144
+ return (
145
+ <div
146
+ ref={ref}
147
+ className={cn("flex-1 space-y-3 overflow-x-hidden overflow-y-auto px-2 py-2", className)}
148
+ {...props}
149
+ />
150
+ );
151
+ },
152
+ );
153
+ SidebarBody.displayName = "SidebarBody";
154
+
155
+ const SidebarFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
156
+ function SidebarFooter({ className, ...props }, ref) {
157
+ return (
158
+ <div
159
+ ref={ref}
160
+ className={cn("border-border/60 shrink-0 border-t px-2 py-2", className)}
161
+ {...props}
162
+ />
163
+ );
164
+ },
165
+ );
166
+ SidebarFooter.displayName = "SidebarFooter";
167
+
168
+ // ---- Group -----------------------------------------------------------------
169
+
170
+ const SidebarGroup = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
171
+ function SidebarGroup({ className, ...props }, ref) {
172
+ return <div ref={ref} className={cn("space-y-1", className)} {...props} />;
173
+ },
174
+ );
175
+ SidebarGroup.displayName = "SidebarGroup";
176
+
177
+ const SidebarGroupLabel = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
178
+ function SidebarGroupLabel({ className, ...props }, ref) {
179
+ const { collapsed } = useSidebar();
180
+ if (collapsed) {
181
+ return <div ref={ref} aria-hidden="true" className="h-2" />;
182
+ }
183
+ return (
184
+ <div
185
+ ref={ref}
186
+ className={cn(
187
+ "text-muted-foreground px-3 pb-1 text-xs font-medium tracking-wider uppercase",
188
+ className,
189
+ )}
190
+ {...props}
191
+ />
192
+ );
193
+ },
194
+ );
195
+ SidebarGroupLabel.displayName = "SidebarGroupLabel";
196
+
197
+ // ---- Item visual ----------------------------------------------------------
198
+
199
+ const itemVariants = cva(
200
+ [
201
+ "group flex w-full items-center gap-2 rounded-md ps-3 pe-2 text-sm cursor-pointer",
202
+ "text-foreground transition-colors select-none",
203
+ "hover:bg-accent hover:text-accent-foreground",
204
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
205
+ "[&_svg]:size-4 [&_svg]:shrink-0",
206
+ "data-[active=true]:bg-accent data-[active=true]:text-accent-foreground data-[active=true]:font-medium",
207
+ "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
208
+ ].join(" "),
209
+ {
210
+ variants: {
211
+ collapsed: {
212
+ true: "justify-center px-0 h-10 w-10 mx-auto",
213
+ false: "h-9",
214
+ },
215
+ },
216
+ defaultVariants: { collapsed: false },
217
+ },
218
+ );
219
+
220
+ // Inner item content — used by both flat Items and the trigger of expandable Items
221
+ function ItemContent({
222
+ icon,
223
+ label,
224
+ badge,
225
+ kbd,
226
+ expandable,
227
+ expanded,
228
+ collapsed,
229
+ }: {
230
+ icon?: ReactNode;
231
+ label: ReactNode;
232
+ badge?: ReactNode;
233
+ kbd?: string[];
234
+ expandable?: boolean;
235
+ expanded?: boolean;
236
+ collapsed: boolean;
237
+ }) {
238
+ return (
239
+ <>
240
+ {icon ? <span className="shrink-0">{icon}</span> : null}
241
+ <span className={cn("flex-1 truncate text-start", collapsed && "sr-only")}>{label}</span>
242
+ {!collapsed && badge ? <span className="ms-1 shrink-0">{badge}</span> : null}
243
+ {!collapsed && kbd && kbd.length > 0 ? (
244
+ <kbd className="border-border bg-muted text-muted-foreground ms-1 inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[10px] font-medium">
245
+ {kbd.map((k, i) => (
246
+ <span key={i}>{k}</span>
247
+ ))}
248
+ </kbd>
249
+ ) : null}
250
+ {!collapsed && expandable ? (
251
+ <span
252
+ aria-hidden="true"
253
+ className={cn(
254
+ "text-muted-foreground ms-1 shrink-0 transition-transform",
255
+ expanded && "rotate-180",
256
+ )}
257
+ >
258
+ <RiArrowDownSLine />
259
+ </span>
260
+ ) : null}
261
+ </>
262
+ );
263
+ }
264
+
265
+ // ---- Item ------------------------------------------------------------------
266
+
267
+ type SidebarItemBaseProps = React.HTMLAttributes<HTMLElement> & {
268
+ icon?: ReactNode;
269
+ label?: ReactNode;
270
+ badge?: ReactNode;
271
+ kbd?: string[];
272
+ tooltip?: string;
273
+ active?: boolean;
274
+ disabled?: boolean;
275
+ asChild?: boolean;
276
+ };
277
+
278
+ type SidebarItemFlatProps = SidebarItemBaseProps & {
279
+ expandable?: false;
280
+ /** Required for expandable=true. Ignored when flat. */
281
+ defaultExpanded?: boolean;
282
+ children?: ReactNode;
283
+ };
284
+
285
+ type SidebarItemExpandableProps = SidebarItemBaseProps & {
286
+ expandable: true;
287
+ defaultExpanded?: boolean;
288
+ /** Nested SidebarSubItems. */
289
+ children?: ReactNode;
290
+ };
291
+
292
+ type SidebarItemProps = SidebarItemFlatProps | SidebarItemExpandableProps;
293
+
294
+ const SidebarItem = forwardRef<HTMLElement, SidebarItemProps>(function SidebarItem(
295
+ {
296
+ className,
297
+ icon,
298
+ label,
299
+ badge,
300
+ kbd,
301
+ tooltip,
302
+ active = false,
303
+ disabled = false,
304
+ asChild = false,
305
+ children,
306
+ ...rest
307
+ },
308
+ ref,
309
+ ) {
310
+ const expandable = "expandable" in rest && rest.expandable === true;
311
+ const defaultExpanded =
312
+ "defaultExpanded" in rest && rest.defaultExpanded ? Boolean(rest.defaultExpanded) : false;
313
+ // Strip discriminator keys before spreading onto DOM.
314
+ const domProps: Record<string, unknown> = { ...rest };
315
+ delete domProps.expandable;
316
+ delete domProps.defaultExpanded;
317
+
318
+ const { collapsed } = useSidebar();
319
+
320
+ // Use label as accessible tooltip text when none provided + collapsed mode.
321
+ const tooltipText = tooltip ?? (typeof label === "string" ? label : undefined);
322
+
323
+ // --- Expandable ----------------------------------------------------------
324
+ if (expandable) {
325
+ return (
326
+ <ExpandableItem
327
+ ref={ref as React.Ref<HTMLDivElement>}
328
+ defaultExpanded={defaultExpanded}
329
+ active={active}
330
+ disabled={disabled}
331
+ collapsed={collapsed}
332
+ {...(icon !== undefined ? { icon } : {})}
333
+ label={label}
334
+ {...(badge !== undefined ? { badge } : {})}
335
+ {...(kbd !== undefined ? { kbd } : {})}
336
+ {...(tooltipText !== undefined ? { tooltip: tooltipText } : {})}
337
+ {...(className !== undefined ? { className } : {})}
338
+ {...(domProps as React.HTMLAttributes<HTMLDivElement>)}
339
+ >
340
+ {children}
341
+ </ExpandableItem>
342
+ );
343
+ }
344
+
345
+ // --- Flat item -----------------------------------------------------------
346
+ const sharedProps = {
347
+ "data-active": active || undefined,
348
+ "data-disabled": disabled || undefined,
349
+ ...(disabled ? { "aria-disabled": true, tabIndex: -1 } : {}),
350
+ ...(active ? { "aria-current": "page" as const } : {}),
351
+ className: cn(itemVariants({ collapsed }), className),
352
+ ...domProps,
353
+ };
354
+ const itemContent = (
355
+ <ItemContent
356
+ {...(icon !== undefined ? { icon } : {})}
357
+ label={label}
358
+ {...(badge !== undefined ? { badge } : {})}
359
+ {...(kbd !== undefined ? { kbd } : {})}
360
+ collapsed={collapsed}
361
+ />
362
+ );
363
+
364
+ let triggerEl: ReactNode;
365
+ if (asChild) {
366
+ // Clone the consumer's single child (an `<a>`, Next `<Link>`, etc.),
367
+ // merge our props onto it, and inject ItemContent as its body. Any
368
+ // existing children on the consumer's element are preserved before
369
+ // ItemContent so consumers can compose if they want.
370
+ const child = Children.only(children) as ReactElement<{
371
+ className?: string;
372
+ ref?: React.Ref<unknown>;
373
+ children?: ReactNode;
374
+ }>;
375
+ if (!isValidElement(child)) {
376
+ throw new Error("SidebarItem with asChild expects a single valid React element.");
377
+ }
378
+ triggerEl = cloneElement(
379
+ child,
380
+ {
381
+ ...sharedProps,
382
+ className: cn(sharedProps.className, child.props.className),
383
+ ref,
384
+ },
385
+ child.props.children ?? itemContent,
386
+ );
387
+ } else {
388
+ triggerEl = (
389
+ <button ref={ref as React.Ref<HTMLButtonElement>} type="button" {...sharedProps}>
390
+ {itemContent}
391
+ </button>
392
+ );
393
+ }
394
+
395
+ if (collapsed && tooltipText) {
396
+ // `side="right"` is correct for a start-positioned sidebar in both
397
+ // LTR (toward content on the right) and RTL (Radix auto-flips to
398
+ // "left", which is toward content). For an end-positioned sidebar
399
+ // the tooltip would point off-screen — a known limitation tracked
400
+ // for v1.x once Sidebar can read its parent AppShell's position.
401
+ return (
402
+ <Tooltip>
403
+ <TooltipTrigger asChild>{triggerEl}</TooltipTrigger>
404
+ <TooltipContent side="right">{tooltipText}</TooltipContent>
405
+ </Tooltip>
406
+ );
407
+ }
408
+
409
+ return triggerEl;
410
+ });
411
+ SidebarItem.displayName = "SidebarItem";
412
+
413
+ // ---- ExpandableItem (internal) --------------------------------------------
414
+
415
+ const ExpandableItem = forwardRef<
416
+ HTMLDivElement,
417
+ Omit<SidebarItemBaseProps, "tooltip"> & {
418
+ defaultExpanded: boolean;
419
+ tooltip?: string;
420
+ collapsed: boolean;
421
+ children?: ReactNode;
422
+ }
423
+ >(function ExpandableItem(
424
+ {
425
+ defaultExpanded,
426
+ icon,
427
+ label,
428
+ badge,
429
+ kbd,
430
+ tooltip,
431
+ active,
432
+ disabled,
433
+ className,
434
+ collapsed,
435
+ children,
436
+ ...props
437
+ },
438
+ ref,
439
+ ) {
440
+ const [open, setOpen] = useState(defaultExpanded);
441
+
442
+ // Collapsed mode: open as a popover instead of inline collapsible.
443
+ if (collapsed) {
444
+ return (
445
+ <Popover>
446
+ <PopoverTrigger asChild>
447
+ <button
448
+ ref={ref as unknown as React.Ref<HTMLButtonElement>}
449
+ type="button"
450
+ data-active={active || undefined}
451
+ data-disabled={disabled || undefined}
452
+ {...(disabled ? { "aria-disabled": true, tabIndex: -1 } : {})}
453
+ className={cn(itemVariants({ collapsed: true }), className)}
454
+ >
455
+ <ItemContent
456
+ {...(icon !== undefined ? { icon } : {})}
457
+ label={label}
458
+ {...(badge !== undefined ? { badge } : {})}
459
+ {...(kbd !== undefined ? { kbd } : {})}
460
+ expandable
461
+ collapsed
462
+ />
463
+ </button>
464
+ </PopoverTrigger>
465
+ <PopoverContent side="right" align="start" className="w-56 p-1">
466
+ {tooltip ? (
467
+ <div className="text-muted-foreground px-2 py-1.5 text-xs font-medium">{tooltip}</div>
468
+ ) : null}
469
+ <div className="space-y-0.5">{children}</div>
470
+ </PopoverContent>
471
+ </Popover>
472
+ );
473
+ }
474
+
475
+ return (
476
+ <CollapsiblePrimitive.Root open={open} onOpenChange={setOpen} asChild>
477
+ <div ref={ref} className="contents">
478
+ <CollapsiblePrimitive.Trigger asChild>
479
+ <button
480
+ type="button"
481
+ data-active={active || undefined}
482
+ data-disabled={disabled || undefined}
483
+ {...(disabled ? { "aria-disabled": true, tabIndex: -1 } : {})}
484
+ className={cn(itemVariants({ collapsed: false }), className)}
485
+ {...props}
486
+ >
487
+ <ItemContent
488
+ {...(icon !== undefined ? { icon } : {})}
489
+ label={label}
490
+ {...(badge !== undefined ? { badge } : {})}
491
+ {...(kbd !== undefined ? { kbd } : {})}
492
+ expandable
493
+ expanded={open}
494
+ collapsed={false}
495
+ />
496
+ </button>
497
+ </CollapsiblePrimitive.Trigger>
498
+ <CollapsiblePrimitive.Content
499
+ className={cn(
500
+ "overflow-hidden",
501
+ "data-[state=open]:animate-[collapse-down_var(--duration-fast,150ms)_var(--ease-out,ease-out)]",
502
+ "data-[state=closed]:animate-[collapse-up_var(--duration-fast,150ms)_var(--ease-in,ease-in)]",
503
+ "[--collapsible-content-height:var(--radix-collapsible-content-height)]",
504
+ )}
505
+ >
506
+ <div className="border-border ms-3 mt-0.5 space-y-0.5 border-s ps-3">{children}</div>
507
+ </CollapsiblePrimitive.Content>
508
+ </div>
509
+ </CollapsiblePrimitive.Root>
510
+ );
511
+ });
512
+ ExpandableItem.displayName = "ExpandableItem";
513
+
514
+ // ---- SubItem ---------------------------------------------------------------
515
+
516
+ type SidebarSubItemProps = Omit<SidebarItemBaseProps, "tooltip"> & {
517
+ children?: ReactNode;
518
+ };
519
+
520
+ const SidebarSubItem = forwardRef<HTMLElement, SidebarSubItemProps>(function SidebarSubItem(
521
+ {
522
+ className,
523
+ icon,
524
+ label,
525
+ badge,
526
+ kbd,
527
+ active = false,
528
+ disabled = false,
529
+ asChild = false,
530
+ ...rest
531
+ },
532
+ ref,
533
+ ) {
534
+ const Comp = (asChild ? Slot : "button") as React.ElementType;
535
+ return (
536
+ <Comp
537
+ ref={ref}
538
+ data-active={active || undefined}
539
+ data-disabled={disabled || undefined}
540
+ {...(disabled ? { "aria-disabled": true, tabIndex: -1 } : {})}
541
+ {...(active ? { "aria-current": "page" } : {})}
542
+ {...(!asChild ? { type: "button" as const } : {})}
543
+ className={cn(
544
+ // Slightly smaller indent; reuses item variants for color logic.
545
+ "group flex h-8 w-full cursor-pointer items-center gap-2 rounded-md ps-3 pe-2 text-sm",
546
+ "text-muted-foreground transition-colors select-none",
547
+ "hover:bg-accent hover:text-accent-foreground",
548
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
549
+ "[&_svg]:size-4 [&_svg]:shrink-0",
550
+ "data-[active=true]:bg-accent data-[active=true]:text-accent-foreground data-[active=true]:font-medium",
551
+ "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
552
+ className,
553
+ )}
554
+ {...rest}
555
+ >
556
+ {icon ? <span className="shrink-0">{icon}</span> : null}
557
+ <span className="flex-1 truncate text-start">{label}</span>
558
+ {badge ? <span className="ms-1 shrink-0">{badge}</span> : null}
559
+ {kbd && kbd.length > 0 ? (
560
+ <kbd className="border-border bg-muted text-muted-foreground ms-1 inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[10px] font-medium">
561
+ {kbd.map((k, i) => (
562
+ <span key={i}>{k}</span>
563
+ ))}
564
+ </kbd>
565
+ ) : null}
566
+ </Comp>
567
+ );
568
+ });
569
+ SidebarSubItem.displayName = "SidebarSubItem";
570
+
571
+ // ---- CollapseToggle --------------------------------------------------------
572
+
573
+ interface SidebarCollapseToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
574
+ /** Override the auto-injected aria-label. */
575
+ expandLabel?: string;
576
+ collapseLabel?: string;
577
+ }
578
+
579
+ const SidebarCollapseToggle = forwardRef<HTMLButtonElement, SidebarCollapseToggleProps>(
580
+ function SidebarCollapseToggle(
581
+ { className, expandLabel = "Expand sidebar", collapseLabel = "Collapse sidebar", ...props },
582
+ ref,
583
+ ) {
584
+ const { collapsed } = useSidebar();
585
+ return (
586
+ <button
587
+ ref={ref}
588
+ type="button"
589
+ aria-label={collapsed ? expandLabel : collapseLabel}
590
+ className={cn(
591
+ "inline-flex h-8 w-full cursor-pointer items-center justify-center rounded-md",
592
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
593
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
594
+ "[&_svg]:size-4 [&_svg]:shrink-0",
595
+ className,
596
+ )}
597
+ {...props}
598
+ >
599
+ {collapsed ? (
600
+ <RiArrowRightSLine className="rtl-mirror" />
601
+ ) : (
602
+ <RiArrowLeftSLine className="rtl-mirror" />
603
+ )}
604
+ </button>
605
+ );
606
+ },
607
+ );
608
+ SidebarCollapseToggle.displayName = "SidebarCollapseToggle";
609
+
610
+ export {
611
+ Sidebar,
612
+ SidebarBrand,
613
+ SidebarHeader,
614
+ SidebarBody,
615
+ SidebarFooter,
616
+ SidebarGroup,
617
+ SidebarGroupLabel,
618
+ SidebarItem,
619
+ SidebarSubItem,
620
+ SidebarCollapseToggle,
621
+ sidebarVariants,
622
+ };
623
+ export type { SidebarProps, SidebarItemProps, SidebarSubItemProps, SidebarCollapseToggleProps };