banhaten 0.3.0 → 0.4.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 (65) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -4
  3. package/docs/design-system/component-implementation-ownership-audit.md +2 -1
  4. package/docs/design-system/component-quality-audit-2026-08-20.md +4 -2
  5. package/docs/design-system/components/alert-dialog.md +1 -1
  6. package/docs/design-system/components/alert.md +2 -2
  7. package/docs/design-system/components/attribute.md +1 -1
  8. package/docs/design-system/components/avatar.md +2 -2
  9. package/docs/design-system/components/banner.md +2 -2
  10. package/docs/design-system/components/breadcrumbs.md +2 -2
  11. package/docs/design-system/components/button-group.md +2 -2
  12. package/docs/design-system/components/button.md +2 -2
  13. package/docs/design-system/components/date-picker.md +3 -1
  14. package/docs/design-system/components/file-upload.md +1 -1
  15. package/docs/design-system/components/input.md +8 -6
  16. package/docs/design-system/components/item.md +3 -1
  17. package/docs/design-system/components/kbd.md +11 -8
  18. package/docs/design-system/components/menu.md +2 -2
  19. package/docs/design-system/components/modal.md +1 -1
  20. package/docs/design-system/components/onboarding-step-list-item.md +2 -2
  21. package/docs/design-system/components/pagination.md +2 -2
  22. package/docs/design-system/components/radio-card.md +1 -1
  23. package/docs/design-system/components/select.md +1 -1
  24. package/docs/design-system/components/sidebar.md +59 -19
  25. package/docs/design-system/components/table.md +1 -1
  26. package/docs/design-system/components/tag.md +3 -3
  27. package/docs/design-system/components/toolbar.md +1 -1
  28. package/docs/design-system/density-and-elevation.md +16 -12
  29. package/docs/design-system/icons.md +6 -1
  30. package/docs/design-system/installation.md +1 -1
  31. package/docs/design-system/token-governance.md +7 -8
  32. package/package.json +1 -1
  33. package/registry/components/alert-dialog.tsx +1 -1
  34. package/registry/components/alert.tsx +3 -3
  35. package/registry/components/attribute.tsx +8 -2
  36. package/registry/components/avatar.tsx +21 -17
  37. package/registry/components/button-group.tsx +1 -1
  38. package/registry/components/button.tsx +1 -1
  39. package/registry/components/date-picker.tsx +16 -4
  40. package/registry/components/expanded/OnboardingStepListItem.tsx +6 -5
  41. package/registry/components/expanded/banner.css +10 -5
  42. package/registry/components/expanded/breadcrumbs.css +4 -4
  43. package/registry/components/expanded/fileUpload.css +3 -3
  44. package/registry/components/expanded/onboardingStepListItem.css +14 -5
  45. package/registry/components/expanded/table.css +5 -2
  46. package/registry/components/input.tsx +12 -5
  47. package/registry/components/item.tsx +1 -1
  48. package/registry/components/kbd.tsx +109 -15
  49. package/registry/components/menu.tsx +1 -1
  50. package/registry/components/modal.tsx +1 -1
  51. package/registry/components/pagination.tsx +12 -7
  52. package/registry/components/radio-card.tsx +1 -2
  53. package/registry/components/select.tsx +1 -1
  54. package/registry/components/sidebar.tsx +929 -57
  55. package/registry/components/tag.tsx +1 -1
  56. package/registry/components/toolbar.tsx +1 -1
  57. package/registry/examples/attribute-demo.tsx +1 -1
  58. package/registry/examples/command-palette-demo.tsx +0 -1
  59. package/registry/examples/kbd-demo.tsx +2 -1
  60. package/registry/examples/sidebar-demo.tsx +60 -16
  61. package/registry/guidance/USAGE.md +1 -1
  62. package/registry/index.json +233 -31
  63. package/registry/styles/globals.css +152 -118
  64. package/registry.json +4 -3
  65. package/tokens/banhaten.tokens.json +169 -49
@@ -2,8 +2,24 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import { Slot } from "@radix-ui/react-slot"
5
- import { PanelLeftIcon } from "lucide-react"
5
+ import {
6
+ Building2Icon,
7
+ ChevronDownIcon,
8
+ ChevronRightIcon,
9
+ ChevronsUpDownIcon,
10
+ FolderIcon,
11
+ PanelLeftIcon,
12
+ SearchIcon,
13
+ SparklesIcon,
14
+ UserIcon,
15
+ } from "lucide-react"
6
16
 
17
+ import {
18
+ Tooltip,
19
+ TooltipContent,
20
+ TooltipProvider,
21
+ TooltipTrigger,
22
+ } from "@/components/ui/tooltip"
7
23
  import { cn } from "@/lib/utils"
8
24
 
9
25
  type SidebarContextValue = {
@@ -23,13 +39,83 @@ type SidebarProps = React.ComponentProps<"aside"> & {
23
39
  side?: "start" | "end"
24
40
  }
25
41
  type SidebarSectionProps = React.ComponentProps<"div">
26
- type SidebarGroupLabelProps = React.ComponentProps<"div">
42
+ type SidebarHeaderActionProps = React.ComponentProps<"button"> & {
43
+ label?: string
44
+ }
45
+ type SidebarHeaderBrandProps = React.ComponentProps<"div"> & {
46
+ brand?: React.ReactNode
47
+ collapseAction?: React.ReactNode | false
48
+ logo?: React.ReactNode
49
+ onSearchClick?: React.MouseEventHandler<HTMLButtonElement>
50
+ searchAction?: React.ReactNode | false
51
+ searchLabel?: string
52
+ }
53
+ type SidebarWorkspaceVariant = "actions" | "card"
54
+ type SidebarHeaderWorkspaceSwitchProps = React.ComponentProps<"div"> & {
55
+ collapseAction?: React.ReactNode | false
56
+ image?: React.ReactNode
57
+ label?: React.ReactNode
58
+ onSearchClick?: React.MouseEventHandler<HTMLButtonElement>
59
+ onWorkspaceClick?: React.MouseEventHandler<HTMLButtonElement>
60
+ searchAction?: React.ReactNode | false
61
+ searchLabel?: string
62
+ supportText?: React.ReactNode
63
+ variant?: SidebarWorkspaceVariant
64
+ workspaceLabel?: string
65
+ }
66
+ type SidebarFooterUserProps = React.ComponentProps<"button"> & {
67
+ avatar?: React.ReactNode
68
+ name?: React.ReactNode
69
+ supportText?: React.ReactNode
70
+ trailingIcon?: React.ReactNode
71
+ variant?: "default" | "supporting-text"
72
+ }
73
+ type SidebarGroupLabelProps = React.ComponentProps<"div"> & {
74
+ actions?: React.ReactNode
75
+ expandIcon?: React.ReactNode
76
+ expandable?: boolean
77
+ }
78
+ type SidebarGroupActionProps = React.ComponentProps<"button"> & {
79
+ label?: string
80
+ }
27
81
  type SidebarMenuProps = React.ComponentProps<"ul">
28
82
  type SidebarMenuItemProps = React.ComponentProps<"li">
83
+ type SidebarInteractionState = "default" | "hover" | "active" | "focus"
84
+ type SidebarMenuButtonVariant =
85
+ | "default"
86
+ | "avatar"
87
+ | "level2"
88
+ | "level2-line"
29
89
  type SidebarMenuButtonProps = React.ComponentProps<"button"> & {
30
90
  active?: boolean
31
91
  asChild?: boolean
92
+ badge?: React.ReactNode
93
+ icon?: React.ReactNode
94
+ state?: SidebarInteractionState
95
+ supportText?: React.ReactNode
96
+ tooltip?: React.ReactNode
97
+ trailingIcon?: React.ReactNode
98
+ variant?: SidebarMenuButtonVariant
99
+ }
100
+ type SidebarMenuBadgeProps = React.ComponentProps<"span">
101
+ type SidebarMenuSubProps = React.ComponentProps<"ul">
102
+ type SidebarMenuSubButtonProps = Omit<SidebarMenuButtonProps, "variant"> & {
103
+ showLine?: boolean
104
+ }
105
+ type SidebarMenuTreeItemProps = React.ComponentProps<"button"> & {
106
+ active?: boolean
107
+ asChild?: boolean
108
+ expanded?: boolean
32
109
  icon?: React.ReactNode
110
+ level?: 1 | 2 | 3 | 4
111
+ showChevron?: boolean
112
+ state?: SidebarInteractionState
113
+ }
114
+ type SidebarTreeGroupProps = SidebarSectionProps & {
115
+ actions?: React.ReactNode
116
+ expandable?: boolean
117
+ expandIcon?: React.ReactNode
118
+ label?: React.ReactNode
33
119
  }
34
120
  type SidebarToggleProps = React.ComponentProps<"button">
35
121
  type SidebarTriggerProps = SidebarToggleProps
@@ -40,6 +126,7 @@ type SidebarInsetProps = React.ComponentProps<"div"> & {
40
126
 
41
127
  const SidebarContext = React.createContext<SidebarContextValue | null>(null)
42
128
 
129
+ /** Owns the controlled or uncontrolled collapsed state for a sidebar composition. */
43
130
  function SidebarProvider({
44
131
  children,
45
132
  className,
@@ -76,19 +163,22 @@ function SidebarProvider({
76
163
 
77
164
  return (
78
165
  <SidebarContext.Provider value={context}>
79
- <div
80
- data-collapsed={resolvedCollapsed ? "true" : undefined}
81
- data-slot="sidebar-provider"
82
- dir={dir}
83
- className={cn("min-w-0", className)}
84
- {...props}
85
- >
86
- {children}
87
- </div>
166
+ <TooltipProvider delayDuration={0}>
167
+ <div
168
+ data-collapsed={resolvedCollapsed ? "true" : "false"}
169
+ data-slot="sidebar-provider"
170
+ dir={dir}
171
+ className={cn("relative min-w-0", className)}
172
+ {...props}
173
+ >
174
+ {children}
175
+ </div>
176
+ </TooltipProvider>
88
177
  </SidebarContext.Provider>
89
178
  )
90
179
  }
91
180
 
181
+ /** The 280px navigation surface that contracts to the Figma 48px icon rail. */
92
182
  const Sidebar = React.forwardRef<HTMLElement, SidebarProps>(function Sidebar(
93
183
  { className, side = "start", ...props },
94
184
  ref
@@ -99,15 +189,16 @@ const Sidebar = React.forwardRef<HTMLElement, SidebarProps>(function Sidebar(
99
189
 
100
190
  return (
101
191
  <aside
102
- data-collapsed={collapsed ? "true" : undefined}
192
+ data-collapsed={collapsed ? "true" : "false"}
103
193
  data-side={side}
104
194
  data-slot="sidebar"
105
195
  dir={dir}
106
196
  ref={ref}
107
197
  className={cn(
108
- "group/sidebar flex h-full min-h-[var(--bh-space-19xl-384)] w-[var(--bh-sidebar-width)] max-w-full shrink-0 flex-col overflow-hidden bg-[var(--bh-sidebar-bg)] text-[var(--bh-content-default)] shadow-[var(--shadow-sidebar)] transition-[width,background-color]",
198
+ "group/sidebar flex h-full min-h-[var(--bh-space-19xl-384)] w-[var(--bh-sidebar-width)] max-w-full shrink-0 flex-col overflow-hidden bg-[var(--bh-sidebar-bg)] text-[var(--bh-content-default)] transition-[width,background-color]",
199
+ "border-e border-[var(--bh-sidebar-border)]",
109
200
  "data-[collapsed=true]:w-[var(--bh-sidebar-width-collapsed)] data-[collapsed=true]:bg-[var(--bh-sidebar-bg-collapsed)]",
110
- side === "end" && "ms-auto",
201
+ side === "end" && "ms-auto border-e-0 border-s",
111
202
  className
112
203
  )}
113
204
  {...props}
@@ -115,12 +206,14 @@ const Sidebar = React.forwardRef<HTMLElement, SidebarProps>(function Sidebar(
115
206
  )
116
207
  })
117
208
 
209
+ /** Header slot with the Figma 10px expanded and 4px/8px collapsed insets. */
118
210
  function SidebarHeader({ className, ...props }: SidebarSectionProps) {
119
211
  return (
120
212
  <div
121
213
  data-slot="sidebar-header"
122
214
  className={cn(
123
- "flex min-h-[var(--bh-sidebar-item-height)] shrink-0 items-center gap-[var(--bh-sidebar-item-gap)] px-[var(--bh-sidebar-padding)] py-[var(--bh-space-xl-12)]",
215
+ "flex shrink-0 flex-col items-start gap-[var(--bh-space-lg-10)] overflow-hidden bg-[var(--bh-sidebar-bg)] p-[var(--bh-sidebar-padding)]",
216
+ "group-data-[collapsed=true]/sidebar:items-center group-data-[collapsed=true]/sidebar:px-[var(--bh-space-xs-4)] group-data-[collapsed=true]/sidebar:py-[var(--bh-space-md-8)]",
124
217
  className
125
218
  )}
126
219
  {...props}
@@ -128,12 +221,262 @@ function SidebarHeader({ className, ...props }: SidebarSectionProps) {
128
221
  )
129
222
  }
130
223
 
224
+ /** Compact 28px ghost action used by Figma sidebar headers. */
225
+ const SidebarHeaderAction = React.forwardRef<
226
+ HTMLButtonElement,
227
+ SidebarHeaderActionProps
228
+ >(function SidebarHeaderAction(
229
+ { "aria-label": ariaLabel, children, className, label, type = "button", ...props },
230
+ ref
231
+ ) {
232
+ return (
233
+ <button
234
+ aria-label={ariaLabel ?? label ?? "Sidebar action"}
235
+ data-slot="sidebar-header-action"
236
+ ref={ref}
237
+ type={type}
238
+ className={cn(
239
+ "inline-flex size-[var(--bh-sidebar-header-action-size)] shrink-0 items-center justify-center rounded-[var(--bh-radius-full)] border border-transparent bg-[var(--bh-interactive-ghost-default)] text-[var(--bh-content-default)] outline-none transition-[background-color,border-color,box-shadow,color]",
240
+ "hover:bg-[var(--bh-interactive-ghost-hover)] focus-visible:border-[var(--bh-border-focus)] focus-visible:shadow-[var(--shadow-button-focus)]",
241
+ className
242
+ )}
243
+ {...props}
244
+ >
245
+ <span
246
+ aria-hidden="true"
247
+ data-slot="sidebar-header-action-icon"
248
+ className="inline-flex size-[var(--bh-sidebar-icon-slot-size)] items-center justify-center [&_svg]:size-[var(--bh-sidebar-icon-size)] [&_svg]:shrink-0"
249
+ >
250
+ {children}
251
+ </span>
252
+ </button>
253
+ )
254
+ })
255
+
256
+ /** Brand header with optional search and collapse actions; actions disappear on the icon rail. */
257
+ function SidebarHeaderBrand({
258
+ brand = "Banhaten",
259
+ className,
260
+ collapseAction,
261
+ logo,
262
+ onSearchClick,
263
+ searchAction,
264
+ searchLabel = "Search",
265
+ ...props
266
+ }: SidebarHeaderBrandProps) {
267
+ const { collapsed } = useSidebarContext()
268
+
269
+ return (
270
+ <div
271
+ data-collapsed={collapsed ? "true" : "false"}
272
+ data-slot="sidebar-header-brand"
273
+ className={cn(
274
+ "flex h-[var(--bh-sidebar-brand-height)] w-full items-center justify-between py-[var(--bh-space-xs-4)] ps-[var(--bh-space-sm-6)] pe-[var(--bh-space-xs-4)]",
275
+ collapsed &&
276
+ "size-[var(--bh-sidebar-compact-control-size)] justify-center gap-[var(--bh-space-md-8)] p-0",
277
+ className
278
+ )}
279
+ {...props}
280
+ >
281
+ <span
282
+ data-slot="sidebar-brand-content"
283
+ className="flex min-w-0 items-center gap-[var(--bh-space-md-8)]"
284
+ >
285
+ <span
286
+ data-slot="sidebar-brand-logo"
287
+ className="inline-flex size-[var(--bh-sidebar-header-action-size)] shrink-0 items-center justify-center overflow-hidden rounded-[var(--bh-radius-full)] [&>*]:size-full"
288
+ >
289
+ {logo ?? (
290
+ <span className="inline-flex size-full items-center justify-center bg-[var(--bh-bg-brand-default)] text-[var(--bh-content-on-brand)]">
291
+ <SparklesIcon
292
+ aria-hidden="true"
293
+ className="size-[var(--bh-sidebar-icon-size)]"
294
+ />
295
+ </span>
296
+ )}
297
+ </span>
298
+ {!collapsed ? (
299
+ <span
300
+ data-slot="sidebar-brand-name"
301
+ dir="auto"
302
+ className="truncate text-[length:var(--bh-text-body-xl-semibold-font-size)] font-[var(--bh-text-body-xl-semibold-font-weight)] leading-[var(--bh-text-body-xl-semibold-line-height)] tracking-[var(--bh-text-body-xl-semibold-letter-spacing)]"
303
+ >
304
+ {brand}
305
+ </span>
306
+ ) : null}
307
+ </span>
308
+
309
+ {!collapsed ? (
310
+ <span
311
+ data-slot="sidebar-brand-actions"
312
+ className="flex shrink-0 items-center gap-[var(--bh-space-sm-6)]"
313
+ >
314
+ {searchAction === false
315
+ ? null
316
+ : searchAction ?? (
317
+ <SidebarHeaderAction label={searchLabel} onClick={onSearchClick}>
318
+ <SearchIcon aria-hidden="true" />
319
+ </SidebarHeaderAction>
320
+ )}
321
+ {collapseAction === false ? null : collapseAction ?? <SidebarTrigger />}
322
+ </span>
323
+ ) : null}
324
+ </div>
325
+ )
326
+ }
327
+
328
+ /** Workspace switcher in either the header-actions or bordered card treatment. */
329
+ function SidebarHeaderWorkspaceSwitch({
330
+ className,
331
+ collapseAction,
332
+ image,
333
+ label = "Acme Inc",
334
+ onSearchClick,
335
+ onWorkspaceClick,
336
+ searchAction,
337
+ searchLabel = "Search",
338
+ supportText = "Pro plan",
339
+ variant = "actions",
340
+ workspaceLabel = "Switch workspace",
341
+ ...props
342
+ }: SidebarHeaderWorkspaceSwitchProps) {
343
+ const { collapsed } = useSidebarContext()
344
+ const card = variant === "card"
345
+
346
+ const workspaceButton = (
347
+ <button
348
+ aria-label={workspaceLabel}
349
+ data-slot="sidebar-workspace-trigger"
350
+ onClick={onWorkspaceClick}
351
+ type="button"
352
+ className={cn(
353
+ "group/workspace relative flex min-w-0 items-center text-start outline-none transition-[background-color,border-color,box-shadow]",
354
+ card
355
+ ? "h-[var(--bh-sidebar-workspace-card-height)] w-full gap-[var(--bh-space-md-8)] overflow-hidden rounded-[var(--bh-radius-lg-8)] border border-[var(--bh-border-subtle)] bg-[var(--bh-bg-default)] p-[var(--bh-space-sm-6)] hover:bg-[var(--bh-bg-default-hover)] focus-visible:border-[length:var(--bh-border-width-strong)] focus-visible:border-[var(--bh-border-focus)]"
356
+ : "h-[var(--bh-sidebar-compact-control-size)] w-[var(--bh-sidebar-workspace-trigger-width)] max-w-[var(--bh-sidebar-workspace-trigger-max-width)] gap-[var(--bh-space-md-8)] rounded-[var(--bh-radius-md-6)] p-[var(--bh-space-sm-6)] hover:bg-[var(--bh-interactive-ghost-hover)] focus-visible:border-[length:var(--bh-border-width-strong)] focus-visible:border-[var(--bh-border-focus)]",
357
+ collapsed && card &&
358
+ "size-[var(--bh-sidebar-compact-control-size)] justify-center gap-0 p-0",
359
+ collapsed && !card &&
360
+ "size-[var(--bh-sidebar-item-height-collapsed)] max-w-[var(--bh-sidebar-item-height-collapsed)] justify-center gap-0 p-0"
361
+ )}
362
+ >
363
+ <span
364
+ data-slot="sidebar-workspace-image"
365
+ className={cn(
366
+ "inline-flex shrink-0 items-center justify-center overflow-hidden [&>*]:size-full",
367
+ card
368
+ ? "size-[var(--bh-space-6xl-32)] rounded-[var(--bh-radius-md-6)]"
369
+ : "size-[var(--bh-space-5xl-24)] rounded-[var(--bh-radius-lg-8)]",
370
+ collapsed && !card &&
371
+ "size-[var(--bh-sidebar-header-action-size)] rounded-[var(--bh-radius-full)]"
372
+ )}
373
+ >
374
+ {image ?? (
375
+ <Building2Icon
376
+ aria-hidden="true"
377
+ className="size-[var(--bh-sidebar-icon-size)]"
378
+ />
379
+ )}
380
+ </span>
381
+
382
+ {!collapsed ? (
383
+ card ? (
384
+ <span className="flex min-w-0 flex-1 flex-col items-start justify-center">
385
+ <span
386
+ data-slot="sidebar-workspace-label"
387
+ dir="auto"
388
+ className="w-full truncate text-[length:var(--bh-text-body-sm-medium-font-size)] font-[var(--bh-text-body-sm-medium-font-weight)] leading-[var(--bh-text-body-sm-medium-line-height)] tracking-[var(--bh-text-body-sm-medium-letter-spacing)]"
389
+ >
390
+ {label}
391
+ </span>
392
+ <span
393
+ data-slot="sidebar-workspace-support"
394
+ dir="auto"
395
+ className="w-full truncate text-[length:var(--bh-text-body-2xs-regular-font-size)] font-[var(--bh-text-body-2xs-regular-font-weight)] leading-[var(--bh-text-body-2xs-regular-line-height)] tracking-[var(--bh-text-body-2xs-regular-letter-spacing)] text-[var(--bh-content-subtle)]"
396
+ >
397
+ {supportText}
398
+ </span>
399
+ </span>
400
+ ) : (
401
+ <span className="flex min-w-0 items-center gap-[var(--bh-space-xxs-2)]">
402
+ <span
403
+ data-slot="sidebar-workspace-label"
404
+ dir="auto"
405
+ className="truncate text-[length:var(--bh-text-body-sm-medium-font-size)] font-[var(--bh-text-body-sm-medium-font-weight)] leading-[var(--bh-text-body-sm-medium-line-height)] tracking-[var(--bh-text-body-sm-medium-letter-spacing)]"
406
+ >
407
+ {label}
408
+ </span>
409
+ <ChevronDownIcon
410
+ aria-hidden="true"
411
+ className="size-[var(--bh-space-3xl-16)] shrink-0"
412
+ data-rtl-flip="true"
413
+ />
414
+ </span>
415
+ )
416
+ ) : !card ? (
417
+ <span
418
+ aria-hidden="true"
419
+ data-slot="sidebar-workspace-caret"
420
+ className="absolute end-0 top-1/2 inline-flex size-[var(--bh-space-3xl-16)] -translate-y-1/2 items-center justify-center rounded-[var(--bh-radius-full)] border border-[var(--bh-border-subtle)] bg-[var(--bh-bg-default)]"
421
+ >
422
+ <ChevronDownIcon className="size-[var(--bh-space-xl-12)]" />
423
+ </span>
424
+ ) : null}
425
+
426
+ {card && !collapsed ? (
427
+ <ChevronsUpDownIcon
428
+ aria-hidden="true"
429
+ className="size-[var(--bh-space-3xl-16)] shrink-0 text-[var(--bh-content-muted)]"
430
+ />
431
+ ) : null}
432
+ </button>
433
+ )
434
+
435
+ return (
436
+ <div
437
+ data-collapsed={collapsed ? "true" : "false"}
438
+ data-slot="sidebar-header-workspace-switch"
439
+ data-variant={variant}
440
+ className={cn(
441
+ "flex w-full items-center",
442
+ card
443
+ ? "h-[var(--bh-sidebar-workspace-card-height)]"
444
+ : "h-[var(--bh-sidebar-compact-control-size)] justify-between pe-[var(--bh-space-md-8)]",
445
+ collapsed && card && "size-[var(--bh-sidebar-compact-control-size)]",
446
+ collapsed && !card &&
447
+ "size-[var(--bh-sidebar-item-height-collapsed)] justify-center p-0",
448
+ className
449
+ )}
450
+ {...props}
451
+ >
452
+ {workspaceButton}
453
+ {!card && !collapsed ? (
454
+ <span
455
+ data-slot="sidebar-workspace-actions"
456
+ className="flex shrink-0 items-center gap-[var(--bh-space-sm-6)]"
457
+ >
458
+ {searchAction === false
459
+ ? null
460
+ : searchAction ?? (
461
+ <SidebarHeaderAction label={searchLabel} onClick={onSearchClick}>
462
+ <SearchIcon aria-hidden="true" />
463
+ </SidebarHeaderAction>
464
+ )}
465
+ {collapseAction === false ? null : collapseAction ?? <SidebarTrigger />}
466
+ </span>
467
+ ) : null}
468
+ </div>
469
+ )
470
+ }
471
+
472
+ /** Scrollable content region with Figma's expanded and rail insets. */
131
473
  function SidebarBody({ className, ...props }: SidebarSectionProps) {
132
474
  return (
133
475
  <div
134
476
  data-slot="sidebar-body"
135
477
  className={cn(
136
- "min-h-0 flex-1 overflow-auto px-[var(--bh-sidebar-padding)] py-[var(--bh-space-xl-12)]",
478
+ "min-h-0 flex-1 overflow-auto px-[var(--bh-space-xl-12)] py-0",
479
+ "group-data-[collapsed=true]/sidebar:px-0",
137
480
  className
138
481
  )}
139
482
  {...props}
@@ -141,12 +484,14 @@ function SidebarBody({ className, ...props }: SidebarSectionProps) {
141
484
  )
142
485
  }
143
486
 
487
+ /** Bottom-pinned footer with the matching expanded and collapsed padding. */
144
488
  function SidebarFooter({ className, ...props }: SidebarSectionProps) {
145
489
  return (
146
490
  <div
147
491
  data-slot="sidebar-footer"
148
492
  className={cn(
149
- "shrink-0 border-t border-[var(--bh-sidebar-border)] px-[var(--bh-sidebar-padding)] py-[var(--bh-space-xl-12)]",
493
+ "mt-auto shrink-0 border-t border-[var(--bh-sidebar-border)] bg-[var(--bh-sidebar-bg)] px-[var(--bh-sidebar-padding)] py-[var(--bh-space-md-8)]",
494
+ "group-data-[collapsed=true]/sidebar:p-[var(--bh-space-xs-4)]",
150
495
  className
151
496
  )}
152
497
  {...props}
@@ -154,17 +499,132 @@ function SidebarFooter({ className, ...props }: SidebarSectionProps) {
154
499
  )
155
500
  }
156
501
 
502
+ /** User switcher used in the footer, with 48px supporting and 40px compact rows. */
503
+ const SidebarFooterUser = React.forwardRef<
504
+ HTMLButtonElement,
505
+ SidebarFooterUserProps
506
+ >(function SidebarFooterUser(
507
+ {
508
+ "aria-label": ariaLabel,
509
+ avatar,
510
+ className,
511
+ name = "Username",
512
+ supportText = "user@example.com",
513
+ trailingIcon,
514
+ type = "button",
515
+ variant = "supporting-text",
516
+ ...props
517
+ },
518
+ ref
519
+ ) {
520
+ const { collapsed } = useSidebarContext()
521
+ const supporting = variant === "supporting-text"
522
+ const resolvedLabel =
523
+ ariaLabel ?? (typeof name === "string" ? name : "Open user menu")
524
+
525
+ return (
526
+ <button
527
+ aria-label={resolvedLabel}
528
+ data-collapsed={collapsed ? "true" : "false"}
529
+ data-slot="sidebar-footer-user"
530
+ data-variant={variant}
531
+ ref={ref}
532
+ type={type}
533
+ className={cn(
534
+ "flex w-full items-center rounded-[var(--bh-radius-lg-8)] text-start outline-none transition-[background-color,border-color,box-shadow]",
535
+ supporting
536
+ ? "h-[var(--bh-space-8xl-48)] gap-[var(--bh-space-xs-4)] p-[var(--bh-space-sm-6)] hover:bg-[var(--bh-interactive-ghost-hover)]"
537
+ : "h-[var(--bh-sidebar-item-height-collapsed)] gap-[var(--bh-space-xs-4)] border border-[var(--bh-border-subtle)] bg-[var(--bh-bg-default)] px-[var(--bh-space-sm-6)] py-[var(--bh-space-md-8)] hover:bg-[var(--bh-bg-default-hover)]",
538
+ "focus-visible:border-[length:var(--bh-border-width-strong)] focus-visible:border-[var(--bh-border-focus)]",
539
+ collapsed &&
540
+ "mx-auto size-[var(--bh-sidebar-compact-control-size)] justify-center gap-0 rounded-[var(--bh-radius-full)] border-0 p-0 hover:bg-[var(--bh-interactive-ghost-hover)]",
541
+ className
542
+ )}
543
+ {...props}
544
+ >
545
+ <span
546
+ data-slot="sidebar-footer-user-content"
547
+ className={cn(
548
+ "flex min-w-0 flex-1 items-center gap-[var(--bh-space-md-8)] px-[var(--bh-space-xxs-2)]",
549
+ collapsed &&
550
+ "size-[var(--bh-sidebar-compact-control-size)] flex-none justify-center gap-0 p-0"
551
+ )}
552
+ >
553
+ <span
554
+ data-slot="sidebar-footer-user-avatar"
555
+ className={cn(
556
+ "inline-flex shrink-0 items-center justify-center overflow-hidden rounded-[var(--bh-radius-full)] [&>*]:size-full",
557
+ supporting ? "size-[var(--bh-space-6xl-32)]" : "size-[var(--bh-space-5xl-24)]",
558
+ collapsed && "size-[var(--bh-sidebar-compact-control-size)]"
559
+ )}
560
+ >
561
+ {avatar ?? (
562
+ <UserIcon
563
+ aria-hidden="true"
564
+ className="size-[var(--bh-sidebar-icon-size)]"
565
+ />
566
+ )}
567
+ </span>
568
+ {!collapsed ? (
569
+ <span
570
+ data-slot="sidebar-footer-user-text"
571
+ className="flex min-w-0 flex-1 flex-col items-start justify-center"
572
+ >
573
+ <span
574
+ data-slot="sidebar-footer-user-name"
575
+ dir="auto"
576
+ className="w-full truncate text-[length:var(--bh-text-body-sm-medium-font-size)] font-[var(--bh-text-body-sm-medium-font-weight)] leading-[var(--bh-text-body-sm-medium-line-height)] tracking-[var(--bh-text-body-sm-medium-letter-spacing)]"
577
+ >
578
+ {name}
579
+ </span>
580
+ {supporting ? (
581
+ <span
582
+ data-slot="sidebar-footer-user-support"
583
+ dir="auto"
584
+ className="w-full truncate text-[length:var(--bh-text-body-2xs-regular-font-size)] font-[var(--bh-text-body-2xs-regular-font-weight)] leading-[var(--bh-text-body-2xs-regular-line-height)] tracking-[var(--bh-text-body-2xs-regular-letter-spacing)] text-[var(--bh-content-subtle)]"
585
+ >
586
+ {supportText}
587
+ </span>
588
+ ) : null}
589
+ </span>
590
+ ) : null}
591
+ </span>
592
+ {!collapsed ? (
593
+ <span
594
+ aria-hidden="true"
595
+ data-slot="sidebar-footer-user-trailing"
596
+ className="inline-flex size-[var(--bh-space-4xl-20)] shrink-0 items-center justify-center text-[var(--bh-content-muted)] [&_svg]:size-[var(--bh-space-3xl-16)]"
597
+ >
598
+ {trailingIcon ?? <ChevronsUpDownIcon />}
599
+ </span>
600
+ ) : null}
601
+ </button>
602
+ )
603
+ })
604
+
605
+ /** A Figma sidebar group: 12px top padding, 6px bottom padding, and no menu gap. */
157
606
  function SidebarGroup({ className, ...props }: SidebarSectionProps) {
158
607
  return (
159
608
  <div
160
609
  data-slot="sidebar-group"
161
- className={cn("grid gap-[var(--bh-space-md-8)]", className)}
610
+ className={cn(
611
+ "flex w-full flex-col items-start pb-[var(--bh-space-sm-6)] pt-[var(--bh-space-xl-12)]",
612
+ className
613
+ )}
162
614
  {...props}
163
615
  />
164
616
  )
165
617
  }
166
618
 
167
- function SidebarGroupLabel({ className, ...props }: SidebarGroupLabelProps) {
619
+ /** Group label that reveals its disclosure and actions on hover or keyboard focus. */
620
+ function SidebarGroupLabel({
621
+ actions,
622
+ children,
623
+ className,
624
+ expandIcon,
625
+ expandable = false,
626
+ ...props
627
+ }: SidebarGroupLabelProps) {
168
628
  const { collapsed } = useSidebarContext()
169
629
 
170
630
  if (collapsed) return null
@@ -172,26 +632,84 @@ function SidebarGroupLabel({ className, ...props }: SidebarGroupLabelProps) {
172
632
  return (
173
633
  <div
174
634
  data-slot="sidebar-group-label"
175
- dir="auto"
176
635
  className={cn(
177
- "px-[var(--bh-sidebar-item-padding-x)] text-[length:var(--bh-text-body-xs-medium-font-size)] font-[var(--bh-text-body-xs-medium-font-weight)] uppercase leading-[var(--bh-text-body-xs-medium-line-height)] tracking-[var(--bh-text-body-xs-medium-letter-spacing)] text-[var(--bh-content-muted)]",
636
+ "group/label flex h-[var(--bh-space-5xl-24)] w-full items-center gap-[var(--bh-space-xs-4)] rounded-[var(--bh-radius-lg-8)] px-[var(--bh-space-sm-6)] pb-[var(--bh-space-xs-4)]",
178
637
  className
179
638
  )}
180
639
  {...props}
181
- />
640
+ >
641
+ <span
642
+ data-slot="sidebar-group-label-content"
643
+ className="flex min-w-0 flex-1 items-center gap-[var(--bh-space-xxxs-1)]"
644
+ >
645
+ <span
646
+ data-slot="sidebar-group-label-text"
647
+ dir="auto"
648
+ className="truncate text-[length:var(--bh-text-body-sm-medium-font-size)] font-[var(--bh-text-body-sm-medium-font-weight)] leading-[var(--bh-text-body-sm-medium-line-height)] tracking-[var(--bh-text-body-sm-medium-letter-spacing)] text-[var(--bh-content-muted)]"
649
+ >
650
+ {children}
651
+ </span>
652
+ {expandable ? (
653
+ <span
654
+ aria-hidden="true"
655
+ data-slot="sidebar-group-label-expand"
656
+ className="inline-flex size-[var(--bh-space-3xl-16)] shrink-0 items-center justify-center opacity-[var(--bh-opacity-0)] transition-opacity group-hover/label:opacity-[var(--bh-opacity-100)] group-focus-within/label:opacity-[var(--bh-opacity-100)] [&_svg]:size-full"
657
+ >
658
+ {expandIcon ?? <ChevronRightIcon data-rtl-flip="true" />}
659
+ </span>
660
+ ) : null}
661
+ </span>
662
+ {actions ? (
663
+ <span
664
+ data-slot="sidebar-group-label-actions"
665
+ className="flex shrink-0 items-center gap-[var(--bh-space-xs-4)] opacity-[var(--bh-opacity-0)] transition-opacity group-hover/label:opacity-[var(--bh-opacity-100)] group-focus-within/label:opacity-[var(--bh-opacity-100)]"
666
+ >
667
+ {actions}
668
+ </span>
669
+ ) : null}
670
+ </div>
182
671
  )
183
672
  }
184
673
 
674
+ /** 20px group-label icon slot matching the Default, Hover, and Focus variants. */
675
+ const SidebarGroupAction = React.forwardRef<
676
+ HTMLButtonElement,
677
+ SidebarGroupActionProps
678
+ >(function SidebarGroupAction(
679
+ { "aria-label": ariaLabel, children, className, label, type = "button", ...props },
680
+ ref
681
+ ) {
682
+ return (
683
+ <button
684
+ aria-label={ariaLabel ?? label ?? "Group action"}
685
+ data-slot="sidebar-group-action"
686
+ ref={ref}
687
+ type={type}
688
+ className={cn(
689
+ "inline-flex size-[var(--bh-sidebar-icon-slot-size)] items-center justify-center rounded-[var(--bh-control-sm)] border border-transparent bg-[var(--bh-interactive-ghost-default)] text-[var(--bh-content-muted)] outline-none transition-[background-color,border-color,color]",
690
+ "hover:bg-[var(--bh-interactive-ghost-hover)] hover:text-[var(--bh-content-default)] focus-visible:border-[var(--bh-border-focus)]",
691
+ "[&_svg]:size-[var(--bh-sidebar-icon-size)] [&_svg]:shrink-0",
692
+ className
693
+ )}
694
+ {...props}
695
+ >
696
+ {children}
697
+ </button>
698
+ )
699
+ })
700
+
701
+ /** List wrapper for contiguous 32px sidebar menu rows. */
185
702
  function SidebarMenu({ className, ...props }: SidebarMenuProps) {
186
703
  return (
187
704
  <ul
188
705
  data-slot="sidebar-menu"
189
- className={cn("m-0 grid list-none gap-[var(--bh-space-xs-4)] p-0", className)}
706
+ className={cn("m-0 flex w-full list-none flex-col p-0", className)}
190
707
  {...props}
191
708
  />
192
709
  )
193
710
  }
194
711
 
712
+ /** Semantic list item wrapper. */
195
713
  function SidebarMenuItem({ className, ...props }: SidebarMenuItemProps) {
196
714
  return (
197
715
  <li
@@ -202,50 +720,359 @@ function SidebarMenuItem({ className, ...props }: SidebarMenuItemProps) {
202
720
  )
203
721
  }
204
722
 
723
+ /** Native 24px neutral count badge for sidebar rows. */
724
+ function SidebarMenuBadge({ children, className, ...props }: SidebarMenuBadgeProps) {
725
+ return (
726
+ <span
727
+ data-slot="sidebar-menu-badge"
728
+ className={cn(
729
+ "inline-flex h-[var(--bh-space-5xl-24)] min-w-[var(--bh-space-5xl-24)] shrink-0 items-center justify-center rounded-[var(--bh-radius-full)] bg-[var(--bh-bg-neutral-subtle)] px-[var(--bh-space-xs-4)] text-center text-[length:var(--bh-text-body-3xs-semibold-font-size)] font-[var(--bh-text-body-3xs-semibold-font-weight)] leading-[var(--bh-text-body-3xs-semibold-line-height)] tracking-[var(--bh-text-body-3xs-semibold-letter-spacing)] text-[var(--bh-content-default)]",
730
+ className
731
+ )}
732
+ {...props}
733
+ >
734
+ {children}
735
+ </span>
736
+ )
737
+ }
738
+
739
+ /** Default, avatar, Level 2, and Level 2 line menu variants from the Figma set. */
205
740
  const SidebarMenuButton = React.forwardRef<
206
741
  HTMLButtonElement,
207
742
  SidebarMenuButtonProps
208
743
  >(function SidebarMenuButton(
209
- { active = false, asChild = false, children, className, icon, ...props },
744
+ {
745
+ "aria-label": ariaLabel,
746
+ active = false,
747
+ asChild = false,
748
+ badge,
749
+ children,
750
+ className,
751
+ icon,
752
+ state = "default",
753
+ supportText,
754
+ tooltip,
755
+ trailingIcon,
756
+ type = "button",
757
+ variant = "default",
758
+ ...props
759
+ },
210
760
  ref
211
761
  ) {
212
- const { collapsed } = useSidebarContext()
213
- const Comp = asChild ? Slot : "button"
762
+ const { collapsed, dir } = useSidebarContext()
763
+ const isLevel2 = variant === "level2" || variant === "level2-line"
764
+ const label = getSlottableLabel(children, asChild)
765
+ const resolvedLabel =
766
+ ariaLabel ?? (typeof label === "string" ? label : undefined)
767
+
768
+ if (collapsed && isLevel2) return null
769
+
770
+ const content = (
771
+ <>
772
+ {isLevel2 ? (
773
+ <span
774
+ aria-hidden="true"
775
+ data-slot="sidebar-menu-indent"
776
+ className="relative h-full w-[var(--bh-sidebar-level-2-indent-width)] shrink-0"
777
+ >
778
+ {variant === "level2-line" ? (
779
+ <span
780
+ data-slot="sidebar-menu-guide"
781
+ className={cn(
782
+ "absolute start-[var(--bh-space-md-8)]",
783
+ active
784
+ ? "top-1/2 h-[var(--bh-sidebar-active-guide-height)] -translate-y-1/2 border-e-[length:var(--bh-border-width-strong)] border-[var(--bh-bg-brand-default)]"
785
+ : "inset-y-0 border-e-[length:var(--bh-border-width-default)] border-[var(--bh-border-subtle)]"
786
+ )}
787
+ />
788
+ ) : null}
789
+ </span>
790
+ ) : null}
214
791
 
215
- return (
216
- <Comp
217
- aria-current={active ? "page" : undefined}
218
- data-active={active ? "true" : undefined}
219
- data-slot="sidebar-menu-button"
220
- ref={ref}
221
- className={cn(
222
- "flex h-[var(--bh-sidebar-item-height)] w-full min-w-0 items-center gap-[var(--bh-sidebar-item-gap)] rounded-[var(--bh-radius-lg-8)] px-[var(--bh-sidebar-item-padding-x)] text-start text-[length:var(--bh-text-body-sm-medium-font-size)] font-[var(--bh-text-body-sm-medium-font-weight)] leading-[var(--bh-text-body-sm-medium-line-height)] tracking-[var(--bh-text-body-sm-medium-letter-spacing)] text-[var(--bh-sidebar-item-content)] outline-none transition-[background-color,box-shadow,color]",
223
- "hover:bg-[var(--bh-sidebar-item-bg-hover)] focus-visible:shadow-[var(--shadow-button-focus)] data-[active=true]:bg-[var(--bh-sidebar-item-bg-active)] data-[active=true]:text-[var(--bh-sidebar-item-content-active)]",
224
- collapsed && "justify-center px-[var(--bh-space-md-8)]",
225
- className
226
- )}
227
- {...props}
228
- >
229
792
  {icon ? (
230
793
  <span
231
794
  aria-hidden="true"
232
795
  data-slot="sidebar-menu-icon"
233
- className="inline-flex size-[var(--bh-sidebar-icon-size)] shrink-0 items-center justify-center"
796
+ className={cn(
797
+ "inline-flex shrink-0 items-center justify-center overflow-hidden [&>*]:size-full [&_svg]:shrink-0",
798
+ variant === "avatar"
799
+ ? "size-[var(--bh-space-4xl-20)] rounded-[var(--bh-radius-md-6)]"
800
+ : "size-[var(--bh-sidebar-icon-slot-size)] [&_svg]:size-[var(--bh-sidebar-icon-size)]",
801
+ collapsed && variant === "avatar" &&
802
+ "size-[var(--bh-space-5xl-24)] rounded-[var(--bh-radius-lg-8)]",
803
+ collapsed && variant === "default" &&
804
+ "[&_svg]:size-[var(--bh-sidebar-icon-size-collapsed)]"
805
+ )}
234
806
  >
235
807
  {icon}
236
808
  </span>
237
809
  ) : null}
238
- {collapsed ? null : (
239
- <span data-slot="sidebar-menu-label" dir="auto" className="min-w-0 flex-1 truncate">
240
- {children}
810
+
811
+ {!collapsed ? (
812
+ <span
813
+ data-slot="sidebar-menu-label"
814
+ dir="auto"
815
+ className="min-w-0 flex-1 truncate ps-[var(--bh-space-xs-4)]"
816
+ >
817
+ {label}
241
818
  </span>
242
- )}
243
- </Comp>
819
+ ) : null}
820
+
821
+ {!collapsed && supportText ? (
822
+ <span
823
+ data-slot="sidebar-menu-support"
824
+ dir="auto"
825
+ className="shrink-0 whitespace-nowrap text-[length:var(--bh-text-body-xs-regular-font-size)] font-[var(--bh-text-body-xs-regular-font-weight)] leading-[var(--bh-text-body-xs-regular-line-height)] tracking-[var(--bh-text-body-xs-regular-letter-spacing)] text-[var(--bh-content-muted)]"
826
+ >
827
+ {supportText}
828
+ </span>
829
+ ) : null}
830
+
831
+ {!collapsed && badge ? (
832
+ <span data-slot="sidebar-menu-badge-slot" className="inline-flex shrink-0">
833
+ {badge}
834
+ </span>
835
+ ) : null}
836
+
837
+ {!collapsed && trailingIcon ? (
838
+ <span
839
+ aria-hidden="true"
840
+ data-slot="sidebar-menu-trailing"
841
+ className="inline-flex size-[var(--bh-sidebar-icon-slot-size)] shrink-0 items-center justify-center text-[var(--bh-content-muted)] [&_svg]:size-[var(--bh-sidebar-icon-size)] [&_svg]:shrink-0"
842
+ >
843
+ {trailingIcon}
844
+ </span>
845
+ ) : null}
846
+ </>
847
+ )
848
+
849
+ const buttonClassName = cn(
850
+ "group/menu-button relative flex w-full min-w-0 items-center gap-[var(--bh-space-sm-6)] rounded-[var(--bh-radius-lg-8)] border-[length:var(--bh-border-width-strong)] border-transparent px-[var(--bh-sidebar-item-padding-x)] text-start text-[length:var(--bh-text-body-sm-regular-font-size)] font-[var(--bh-text-body-sm-regular-font-weight)] leading-[var(--bh-text-body-sm-regular-line-height)] tracking-[var(--bh-text-body-sm-regular-letter-spacing)] text-[var(--bh-sidebar-item-content)] outline-none transition-[background-color,border-color,color]",
851
+ "hover:rounded-[var(--bh-radius-md-6)] hover:bg-[var(--bh-sidebar-item-bg-hover)]",
852
+ "focus-visible:rounded-[var(--bh-radius-md-6)] focus-visible:border-[var(--bh-border-focus)]",
853
+ "data-[state=hover]:rounded-[var(--bh-radius-md-6)] data-[state=hover]:bg-[var(--bh-sidebar-item-bg-hover)]",
854
+ "data-[state=focus]:rounded-[var(--bh-radius-md-6)] data-[state=focus]:border-[var(--bh-border-focus)]",
855
+ "disabled:pointer-events-none disabled:text-[var(--bh-content-disabled)]",
856
+ variant === "avatar"
857
+ ? "h-[var(--bh-sidebar-compact-control-size)]"
858
+ : "h-[var(--bh-sidebar-item-height)]",
859
+ (active || state === "active") &&
860
+ "rounded-[var(--bh-radius-md-6)] bg-[var(--bh-sidebar-item-bg-active)] font-[var(--bh-text-body-sm-medium-font-weight)] text-[var(--bh-sidebar-item-content-active)]",
861
+ collapsed &&
862
+ "mx-auto size-[var(--bh-sidebar-item-height-collapsed)] justify-center gap-0 rounded-[var(--bh-radius-lg-8)] p-0",
863
+ className
864
+ )
865
+
866
+ const commonProps = {
867
+ "aria-current": active ? ("page" as const) : undefined,
868
+ "aria-label": resolvedLabel,
869
+ "data-active": active ? "true" : "false",
870
+ "data-collapsed": collapsed ? "true" : "false",
871
+ "data-slot": "sidebar-menu-button",
872
+ "data-state": state,
873
+ "data-variant": variant,
874
+ className: buttonClassName,
875
+ }
876
+
877
+ const button = asChild ? (
878
+ <Slot {...commonProps} ref={ref} {...props}>
879
+ {cloneSlottableChild(children, content)}
880
+ </Slot>
881
+ ) : (
882
+ <button {...commonProps} ref={ref} type={type} {...props}>
883
+ {content}
884
+ </button>
885
+ )
886
+
887
+ const resolvedTooltip = tooltip ?? resolvedLabel
888
+ if (!collapsed || !resolvedTooltip) return button
889
+
890
+ return (
891
+ <Tooltip>
892
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
893
+ <TooltipContent
894
+ pointerPosition={dir === "rtl" ? "center-right" : "center-left"}
895
+ showShortcut={false}
896
+ side={dir === "rtl" ? "left" : "right"}
897
+ >
898
+ {resolvedTooltip}
899
+ </TooltipContent>
900
+ </Tooltip>
244
901
  )
245
902
  })
246
903
 
904
+ /** Nested menu list that unmounts when the parent sidebar is collapsed. */
905
+ function SidebarMenuSub({ className, ...props }: SidebarMenuSubProps) {
906
+ const { collapsed } = useSidebarContext()
907
+ if (collapsed) return null
908
+
909
+ return (
910
+ <ul
911
+ data-slot="sidebar-menu-sub"
912
+ className={cn("m-0 flex w-full list-none flex-col p-0", className)}
913
+ {...props}
914
+ />
915
+ )
916
+ }
917
+
918
+ /** Level 2 menu row with an optional vertical guide. */
919
+ const SidebarMenuSubButton = React.forwardRef<
920
+ HTMLButtonElement,
921
+ SidebarMenuSubButtonProps
922
+ >(function SidebarMenuSubButton({ showLine = false, ...props }, ref) {
923
+ return (
924
+ <SidebarMenuButton
925
+ ref={ref}
926
+ variant={showLine ? "level2-line" : "level2"}
927
+ {...props}
928
+ />
929
+ )
930
+ })
931
+
932
+ const treeLevelClasses: Record<NonNullable<SidebarMenuTreeItemProps["level"]>, string> = {
933
+ 1: "ps-[var(--bh-space-sm-6)]",
934
+ 2: "ps-[calc(var(--bh-space-sm-6)+var(--bh-sidebar-tree-indent-step))]",
935
+ 3: "ps-[calc(var(--bh-space-sm-6)+var(--bh-sidebar-tree-indent-step)+var(--bh-sidebar-tree-indent-step))]",
936
+ 4: "ps-[calc(var(--bh-space-sm-6)+var(--bh-sidebar-tree-indent-step)+var(--bh-sidebar-tree-indent-step)+var(--bh-sidebar-tree-indent-step))]",
937
+ }
938
+
939
+ /** 28px file-tree row with four Figma indentation levels. */
940
+ const SidebarMenuTreeItem = React.forwardRef<
941
+ HTMLButtonElement,
942
+ SidebarMenuTreeItemProps
943
+ >(function SidebarMenuTreeItem(
944
+ {
945
+ "aria-label": ariaLabel,
946
+ active = false,
947
+ asChild = false,
948
+ children,
949
+ className,
950
+ expanded = false,
951
+ icon,
952
+ level = 1,
953
+ showChevron = true,
954
+ state = "default",
955
+ type = "button",
956
+ ...props
957
+ },
958
+ ref
959
+ ) {
960
+ const { collapsed } = useSidebarContext()
961
+ if (collapsed) return null
962
+
963
+ const label = getSlottableLabel(children, asChild)
964
+ const resolvedLabel =
965
+ ariaLabel ?? (typeof label === "string" ? label : undefined)
966
+ const content = (
967
+ <>
968
+ {showChevron ? (
969
+ <span
970
+ aria-hidden="true"
971
+ data-slot="sidebar-tree-chevron"
972
+ className="inline-flex size-[var(--bh-space-3xl-16)] shrink-0 items-center justify-center [&_svg]:size-full"
973
+ >
974
+ {expanded ? (
975
+ <ChevronDownIcon />
976
+ ) : (
977
+ <ChevronRightIcon data-rtl-flip="true" />
978
+ )}
979
+ </span>
980
+ ) : null}
981
+ <span
982
+ aria-hidden="true"
983
+ data-slot="sidebar-tree-icon"
984
+ className="inline-flex size-[var(--bh-space-3xl-16)] shrink-0 items-center justify-center [&_svg]:size-full"
985
+ >
986
+ {icon ?? <FolderIcon />}
987
+ </span>
988
+ <span
989
+ data-slot="sidebar-tree-label"
990
+ dir="auto"
991
+ className="min-w-0 flex-1 truncate"
992
+ >
993
+ {label}
994
+ </span>
995
+ </>
996
+ )
997
+
998
+ const commonProps = {
999
+ "aria-current": active ? ("page" as const) : undefined,
1000
+ "aria-expanded": showChevron ? expanded : undefined,
1001
+ "aria-label": resolvedLabel,
1002
+ "data-active": active ? "true" : "false",
1003
+ "data-level": String(level),
1004
+ "data-slot": "sidebar-menu-tree-item",
1005
+ "data-state": state,
1006
+ className: cn(
1007
+ "flex h-[var(--bh-sidebar-tree-item-height)] w-full min-w-0 items-center gap-[var(--bh-space-sm-6)] rounded-[var(--bh-radius-lg-8)] border-[length:var(--bh-border-width-strong)] border-transparent pe-[var(--bh-space-sm-6)] text-start text-[length:var(--bh-text-body-xs-regular-font-size)] font-[var(--bh-text-body-xs-regular-font-weight)] leading-[var(--bh-text-body-xs-regular-line-height)] tracking-[var(--bh-text-body-xs-regular-letter-spacing)] text-[var(--bh-content-subtle)] outline-none transition-[background-color,border-color,color]",
1008
+ "hover:rounded-[var(--bh-radius-md-6)] hover:bg-[var(--bh-interactive-ghost-hover)] focus-visible:rounded-[var(--bh-radius-md-6)] focus-visible:border-[var(--bh-border-focus)]",
1009
+ "data-[state=hover]:rounded-[var(--bh-radius-md-6)] data-[state=hover]:bg-[var(--bh-interactive-ghost-hover)] data-[state=focus]:rounded-[var(--bh-radius-md-6)] data-[state=focus]:border-[var(--bh-border-focus)]",
1010
+ (active || state === "active") &&
1011
+ "rounded-[var(--bh-radius-md-6)] bg-[var(--bh-interactive-ghost-active)] font-[var(--bh-text-body-xs-semibold-font-weight)] text-[var(--bh-content-default)]",
1012
+ treeLevelClasses[level],
1013
+ className
1014
+ ),
1015
+ }
1016
+
1017
+ return asChild ? (
1018
+ <Slot {...commonProps} ref={ref} {...props}>
1019
+ {cloneSlottableChild(children, content)}
1020
+ </Slot>
1021
+ ) : (
1022
+ <button {...commonProps} ref={ref} type={type} {...props}>
1023
+ {content}
1024
+ </button>
1025
+ )
1026
+ })
1027
+
1028
+ /** Labeled tree group used for file-browser and hierarchy sidebars. */
1029
+ function SidebarTreeGroup({
1030
+ actions,
1031
+ children,
1032
+ className,
1033
+ expandable = false,
1034
+ expandIcon,
1035
+ label,
1036
+ ...props
1037
+ }: SidebarTreeGroupProps) {
1038
+ const { collapsed } = useSidebarContext()
1039
+ if (collapsed) return null
1040
+
1041
+ return (
1042
+ <div
1043
+ data-slot="sidebar-tree-group"
1044
+ className={cn(
1045
+ "flex w-full flex-col items-start gap-[var(--bh-space-sm-6)] pb-[var(--bh-space-sm-6)] pt-[var(--bh-space-xl-12)]",
1046
+ className
1047
+ )}
1048
+ {...props}
1049
+ >
1050
+ {label ? (
1051
+ <SidebarGroupLabel
1052
+ actions={actions}
1053
+ className={cn(
1054
+ expandable &&
1055
+ "[&_[data-slot=sidebar-group-label-expand]]:opacity-[var(--bh-opacity-100)]"
1056
+ )}
1057
+ expandable={expandable}
1058
+ expandIcon={expandIcon ?? (expandable ? <ChevronDownIcon /> : undefined)}
1059
+ >
1060
+ {label}
1061
+ </SidebarGroupLabel>
1062
+ ) : null}
1063
+ <div data-slot="sidebar-tree-menu" className="flex w-full flex-col">
1064
+ {children}
1065
+ </div>
1066
+ </div>
1067
+ )
1068
+ }
1069
+
1070
+ /** 28px collapse control with accessible pressed state. */
247
1071
  const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
248
- function SidebarToggle({ children, className, onClick, type = "button", ...props }, ref) {
1072
+ function SidebarToggle(
1073
+ { children, className, onClick, type = "button", ...props },
1074
+ ref
1075
+ ) {
249
1076
  const { collapsed, toggle } = useSidebarContext()
250
1077
 
251
1078
  return (
@@ -256,7 +1083,8 @@ const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
256
1083
  ref={ref}
257
1084
  type={type}
258
1085
  className={cn(
259
- "inline-flex size-[var(--bh-sidebar-item-height)] items-center justify-center rounded-[var(--bh-radius-lg-8)] text-[var(--bh-content-subtle)] outline-none transition-[background-color,box-shadow,color] hover:bg-[var(--bh-interactive-ghost-hover)] focus-visible:shadow-[var(--shadow-button-focus)]",
1086
+ "inline-flex size-[var(--bh-sidebar-header-action-size)] shrink-0 items-center justify-center rounded-[var(--bh-radius-full)] border border-transparent bg-[var(--bh-interactive-ghost-default)] text-[var(--bh-content-default)] outline-none transition-[background-color,border-color,box-shadow,color]",
1087
+ "hover:bg-[var(--bh-interactive-ghost-hover)] focus-visible:border-[var(--bh-border-focus)] focus-visible:shadow-[var(--shadow-button-focus)]",
260
1088
  className
261
1089
  )}
262
1090
  onClick={(event) => {
@@ -265,13 +1093,18 @@ const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
265
1093
  }}
266
1094
  {...props}
267
1095
  >
268
- {children ?? (
269
- <PanelLeftIcon
270
- aria-hidden="true"
271
- className="size-[var(--bh-space-5xl-24)] rtl:-scale-x-100"
272
- strokeWidth="var(--bh-icon-stroke-200)"
273
- />
274
- )}
1096
+ <span
1097
+ aria-hidden="true"
1098
+ data-slot="sidebar-toggle-icon"
1099
+ className="inline-flex size-[var(--bh-sidebar-icon-slot-size)] items-center justify-center [&_svg]:size-[var(--bh-sidebar-icon-size)]"
1100
+ >
1101
+ {children ?? (
1102
+ <PanelLeftIcon
1103
+ className="rtl:-scale-x-100"
1104
+ strokeWidth="var(--bh-icon-stroke-200)"
1105
+ />
1106
+ )}
1107
+ </span>
275
1108
  </button>
276
1109
  )
277
1110
  }
@@ -279,6 +1112,7 @@ const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
279
1112
 
280
1113
  const SidebarTrigger = SidebarToggle
281
1114
 
1115
+ /** Edge-hugging pointer target for toggling the sidebar from its border. */
282
1116
  const SidebarRail = React.forwardRef<HTMLButtonElement, SidebarRailProps>(
283
1117
  function SidebarRail({ className, onClick, type = "button", ...props }, ref) {
284
1118
  const { toggle } = useSidebarContext()
@@ -303,6 +1137,7 @@ const SidebarRail = React.forwardRef<HTMLButtonElement, SidebarRailProps>(
303
1137
  }
304
1138
  )
305
1139
 
1140
+ /** Main-content wrapper paired with a sidebar shell. */
306
1141
  const SidebarInset = React.forwardRef<HTMLDivElement, SidebarInsetProps>(
307
1142
  function SidebarInset({ asChild = false, className, ...props }, ref) {
308
1143
  const Comp = asChild ? Slot : "div"
@@ -318,6 +1153,20 @@ const SidebarInset = React.forwardRef<HTMLDivElement, SidebarInsetProps>(
318
1153
  }
319
1154
  )
320
1155
 
1156
+ function getSlottableLabel(children: React.ReactNode, asChild: boolean) {
1157
+ if (!asChild || !React.isValidElement<{ children?: React.ReactNode }>(children)) {
1158
+ return children
1159
+ }
1160
+ return children.props.children
1161
+ }
1162
+
1163
+ function cloneSlottableChild(children: React.ReactNode, content: React.ReactNode) {
1164
+ if (!React.isValidElement<{ children?: React.ReactNode }>(children)) {
1165
+ throw new Error("Sidebar asChild expects one valid React element")
1166
+ }
1167
+ return React.cloneElement(children, undefined, content)
1168
+ }
1169
+
321
1170
  function useSidebarContext() {
322
1171
  const context = React.useContext(SidebarContext)
323
1172
  if (!context) {
@@ -331,28 +1180,51 @@ export {
331
1180
  Sidebar,
332
1181
  SidebarBody,
333
1182
  SidebarFooter,
1183
+ SidebarFooterUser,
334
1184
  SidebarGroup,
1185
+ SidebarGroupAction,
335
1186
  SidebarGroupLabel,
336
1187
  SidebarHeader,
1188
+ SidebarHeaderAction,
1189
+ SidebarHeaderBrand,
1190
+ SidebarHeaderWorkspaceSwitch,
337
1191
  SidebarInset,
338
1192
  SidebarMenu,
1193
+ SidebarMenuBadge,
339
1194
  SidebarMenuButton,
340
1195
  SidebarMenuItem,
1196
+ SidebarMenuSub,
1197
+ SidebarMenuSubButton,
1198
+ SidebarMenuTreeItem,
341
1199
  SidebarProvider,
342
1200
  SidebarRail,
343
- SidebarTrigger,
344
1201
  SidebarToggle,
1202
+ SidebarTreeGroup,
1203
+ SidebarTrigger,
345
1204
  }
346
1205
  export type {
1206
+ SidebarFooterUserProps,
1207
+ SidebarGroupActionProps,
347
1208
  SidebarGroupLabelProps,
1209
+ SidebarHeaderActionProps,
1210
+ SidebarHeaderBrandProps,
1211
+ SidebarHeaderWorkspaceSwitchProps,
348
1212
  SidebarInsetProps,
1213
+ SidebarInteractionState,
1214
+ SidebarMenuBadgeProps,
349
1215
  SidebarMenuButtonProps,
1216
+ SidebarMenuButtonVariant,
350
1217
  SidebarMenuItemProps,
351
1218
  SidebarMenuProps,
1219
+ SidebarMenuSubButtonProps,
1220
+ SidebarMenuSubProps,
1221
+ SidebarMenuTreeItemProps,
352
1222
  SidebarProps,
353
1223
  SidebarProviderProps,
354
1224
  SidebarRailProps,
355
- SidebarTriggerProps,
356
1225
  SidebarSectionProps,
357
1226
  SidebarToggleProps,
1227
+ SidebarTreeGroupProps,
1228
+ SidebarTriggerProps,
1229
+ SidebarWorkspaceVariant,
358
1230
  }