@a4ui/core 0.37.1 → 0.38.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 (102) hide show
  1. package/README.md +8 -7
  2. package/dist/Collapse-Cr5cDqAl.js +375 -0
  3. package/dist/charts/AreaChart.d.ts +38 -0
  4. package/dist/charts/BarChart.d.ts +17 -1
  5. package/dist/charts/PieChart.d.ts +29 -0
  6. package/dist/charts/RadialBarChart.d.ts +33 -0
  7. package/dist/charts/index.d.ts +5 -0
  8. package/dist/charts/shared.d.ts +111 -0
  9. package/dist/charts.js +1493 -780
  10. package/dist/commerce.js +625 -634
  11. package/dist/elements.css +1076 -8
  12. package/dist/elements.iife.js +1 -6
  13. package/dist/elements.js +7774 -9133
  14. package/dist/full.css +1076 -8
  15. package/dist/index.d.ts +30 -3
  16. package/dist/index.js +17502 -15126
  17. package/dist/layout/Aurora.d.ts +16 -0
  18. package/dist/layout/EffectsToggle.d.ts +1 -1
  19. package/dist/layout/SpaceBackground.d.ts +1 -1
  20. package/dist/motion-CTIBwMCA.js +104 -0
  21. package/dist/styles.css +110 -3
  22. package/dist/ui/AspectRatio.d.ts +19 -0
  23. package/dist/ui/Attachment.d.ts +70 -0
  24. package/dist/ui/ButtonGroup.d.ts +37 -0
  25. package/dist/ui/ChatMarker.d.ts +20 -0
  26. package/dist/ui/ChatThread.d.ts +32 -8
  27. package/dist/ui/ContactSection.d.ts +49 -0
  28. package/dist/ui/CtaBanner.d.ts +28 -0
  29. package/dist/ui/Drawer.d.ts +5 -2
  30. package/dist/ui/Faq.d.ts +29 -0
  31. package/dist/ui/FeatureGrid.d.ts +35 -0
  32. package/dist/ui/Field.d.ts +148 -0
  33. package/dist/ui/Hero.d.ts +48 -0
  34. package/dist/ui/InputGroup.d.ts +93 -0
  35. package/dist/ui/Item.d.ts +63 -0
  36. package/dist/ui/Label.d.ts +23 -0
  37. package/dist/ui/Menubar.d.ts +83 -0
  38. package/dist/ui/Message.d.ts +51 -5
  39. package/dist/ui/Modal.d.ts +8 -2
  40. package/dist/ui/NativeSelect.d.ts +26 -0
  41. package/dist/ui/NavigationMenu.d.ts +85 -0
  42. package/dist/ui/PromptComposer.d.ts +5 -0
  43. package/dist/ui/Prose.d.ts +21 -0
  44. package/dist/ui/Questionnaire.d.ts +71 -0
  45. package/dist/ui/ScrollArea.d.ts +30 -0
  46. package/dist/ui/Sidebar.d.ts +229 -0
  47. package/dist/ui/SiteFooter.d.ts +39 -0
  48. package/dist/ui/SiteNav.d.ts +35 -0
  49. package/dist/ui/TeamGrid.d.ts +33 -0
  50. package/dist/ui/Testimonials.d.ts +37 -0
  51. package/dist/ui/internal/sectionKit.d.ts +22 -0
  52. package/dist/ui/internal/socialIcons.d.ts +9 -0
  53. package/package.json +38 -26
  54. package/preset.d.ts +23 -0
  55. package/src/charts/AreaChart.tsx +206 -0
  56. package/src/charts/BarChart.tsx +241 -5
  57. package/src/charts/DonutChart.tsx +8 -11
  58. package/src/charts/LineChart.tsx +2 -3
  59. package/src/charts/PieChart.tsx +145 -0
  60. package/src/charts/RadialBarChart.tsx +141 -0
  61. package/src/charts/index.ts +14 -0
  62. package/src/charts/shared.tsx +304 -0
  63. package/src/index.ts +192 -3
  64. package/src/layout/Aurora.tsx +36 -7
  65. package/src/layout/sceneEffects.ts +22 -3
  66. package/src/ui/AspectRatio.tsx +35 -0
  67. package/src/ui/Attachment.tsx +189 -0
  68. package/src/ui/ButtonGroup.tsx +89 -0
  69. package/src/ui/ChatMarker.tsx +58 -0
  70. package/src/ui/ChatThread.tsx +115 -23
  71. package/src/ui/ContactSection.tsx +199 -0
  72. package/src/ui/CtaBanner.tsx +62 -0
  73. package/src/ui/DateField.tsx +2 -1
  74. package/src/ui/DateRangePicker.tsx +2 -1
  75. package/src/ui/Drawer.tsx +37 -4
  76. package/src/ui/Faq.tsx +57 -0
  77. package/src/ui/FeatureGrid.tsx +77 -0
  78. package/src/ui/Field.tsx +374 -0
  79. package/src/ui/Hero.tsx +146 -0
  80. package/src/ui/InputGroup.tsx +219 -0
  81. package/src/ui/Item.tsx +185 -0
  82. package/src/ui/Label.tsx +50 -0
  83. package/src/ui/Menubar.tsx +212 -0
  84. package/src/ui/Message.tsx +139 -13
  85. package/src/ui/Modal.tsx +41 -5
  86. package/src/ui/MultiSelect.tsx +2 -1
  87. package/src/ui/NativeSelect.tsx +66 -0
  88. package/src/ui/NavigationMenu.tsx +262 -0
  89. package/src/ui/PromptComposer.tsx +11 -0
  90. package/src/ui/Prose.tsx +63 -0
  91. package/src/ui/Questionnaire.tsx +331 -0
  92. package/src/ui/ScrollArea.tsx +70 -0
  93. package/src/ui/Sidebar.tsx +753 -0
  94. package/src/ui/SiteFooter.tsx +112 -0
  95. package/src/ui/SiteNav.tsx +146 -0
  96. package/src/ui/TeamGrid.tsx +81 -0
  97. package/src/ui/Testimonials.tsx +103 -0
  98. package/src/ui/TimeField.tsx +2 -1
  99. package/src/ui/internal/sectionKit.tsx +72 -0
  100. package/src/ui/internal/socialIcons.tsx +41 -0
  101. package/dist/NumberInput-Bnh2u7pp.js +0 -381
  102. package/dist/motion-jYMWmZqB.js +0 -119
@@ -0,0 +1,753 @@
1
+ // Composable app sidebar. `SidebarProvider` owns the open state (controlled or
2
+ // uncontrolled, Ctrl/Cmd+B toggles it); `Sidebar` is the glass panel that collapses
3
+ // off-canvas or down to an icon rail; below `md` it renders inside the Drawer.
4
+ // Menu parts follow the Item/ItemGroup composition style. Geometry is plain
5
+ // Tailwind + the `--sidebar-w` / `--sidebar-w-collapsed` tokens; the width change is
6
+ // a CSS transition (reduced motion is neutralised globally in tokens.css).
7
+ import { Tooltip as KTooltip } from '@kobalte/core/tooltip'
8
+ import { ChevronRight, PanelLeft } from 'lucide-solid'
9
+ import {
10
+ type Accessor,
11
+ type JSX,
12
+ type ParentProps,
13
+ Show,
14
+ createContext,
15
+ createEffect,
16
+ createMemo,
17
+ createSignal,
18
+ createUniqueId,
19
+ onCleanup,
20
+ onMount,
21
+ splitProps,
22
+ useContext,
23
+ } from 'solid-js'
24
+
25
+ import { cn } from '../lib/cn'
26
+ import { useMediaQuery } from '../lib/media'
27
+ import { Drawer } from './Drawer'
28
+ import { ScrollArea } from './ScrollArea'
29
+
30
+ export type SidebarSide = 'left' | 'right'
31
+ export type SidebarVariant = 'sidebar' | 'floating' | 'inset'
32
+ export type SidebarCollapsible = 'offcanvas' | 'icon' | 'none'
33
+
34
+ interface SidebarContextValue {
35
+ /** Desktop expanded state. */
36
+ open: Accessor<boolean>
37
+ setOpen: (open: boolean) => void
38
+ /** Flip the active state: the drawer on mobile, the desktop panel otherwise. */
39
+ toggle: () => void
40
+ /** True below the `md` breakpoint, where the sidebar renders inside a Drawer. */
41
+ isMobile: Accessor<boolean>
42
+ openMobile: Accessor<boolean>
43
+ setOpenMobile: (open: boolean) => void
44
+ /** @internal id of the sidebar panel, for `aria-controls`. */
45
+ panelId: string
46
+ /** @internal registered by `Sidebar` so `SidebarInset` / `SidebarRail` can adapt. */
47
+ config: Accessor<{ side: SidebarSide; variant: SidebarVariant; collapsible: SidebarCollapsible }>
48
+ setConfig: (c: { side: SidebarSide; variant: SidebarVariant; collapsible: SidebarCollapsible }) => void
49
+ }
50
+
51
+ const SidebarContext = createContext<SidebarContextValue>()
52
+
53
+ /** State of the nearest {@link SidebarProvider}. Throws outside one. */
54
+ export function useSidebar(): Omit<SidebarContextValue, 'panelId' | 'config' | 'setConfig'> {
55
+ const ctx = useContext(SidebarContext)
56
+ if (!ctx) throw new Error('useSidebar must be used inside <SidebarProvider>')
57
+ return ctx
58
+ }
59
+
60
+ function useSidebarInternal(): SidebarContextValue {
61
+ const ctx = useContext(SidebarContext)
62
+ if (!ctx) throw new Error('Sidebar parts must be used inside <SidebarProvider>')
63
+ return ctx
64
+ }
65
+
66
+ /** True when the icon rail is collapsed (labels hidden, tooltips on). */
67
+ const IconCollapsed = createContext<Accessor<boolean>>(() => false)
68
+
69
+ interface SidebarProviderProps extends ParentProps {
70
+ /** Controlled desktop open state. */
71
+ open?: boolean
72
+ /** Called with the next desktop open state (controlled or not). */
73
+ onOpenChange?: (open: boolean) => void
74
+ /** Initial state when uncontrolled. @default true */
75
+ defaultOpen?: boolean
76
+ /** Key toggled with Ctrl/Cmd; `false` disables the shortcut. @default 'b' */
77
+ shortcut?: string | false
78
+ class?: string
79
+ }
80
+
81
+ /**
82
+ * Owns the sidebar state and wraps `Sidebar` + `SidebarInset` in a flex row.
83
+ * Give it a bounded height (the sidebar fills it). Ctrl/Cmd+B toggles the sidebar.
84
+ *
85
+ * @example
86
+ * ```tsx
87
+ * <SidebarProvider class="h-screen">
88
+ * <Sidebar collapsible="icon">…</Sidebar>
89
+ * <SidebarInset>…</SidebarInset>
90
+ * </SidebarProvider>
91
+ * ```
92
+ */
93
+ export function SidebarProvider(props: SidebarProviderProps): JSX.Element {
94
+ const isMobile = useMediaQuery('(max-width: 767px)')
95
+ const [internal, setInternal] = createSignal(props.defaultOpen ?? true)
96
+ const [openMobile, setOpenMobile] = createSignal(false)
97
+ const [config, setConfig] = createSignal<ReturnType<SidebarContextValue['config']>>({
98
+ side: 'left',
99
+ variant: 'sidebar',
100
+ collapsible: 'offcanvas',
101
+ })
102
+ const open = () => (props.open !== undefined ? props.open : internal())
103
+ const setOpen = (next: boolean) => {
104
+ if (props.open === undefined) setInternal(next)
105
+ props.onOpenChange?.(next)
106
+ }
107
+ const toggle = () => (isMobile() ? setOpenMobile(!openMobile()) : setOpen(!open()))
108
+
109
+ onMount(() => {
110
+ const onKey = (e: KeyboardEvent) => {
111
+ const key = props.shortcut === undefined ? 'b' : props.shortcut
112
+ if (key === false || e.defaultPrevented) return
113
+ if (e.key.toLowerCase() !== key.toLowerCase() || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey)
114
+ return
115
+ const t = e.target
116
+ // Leave Ctrl+B alone inside text fields / rich editors.
117
+ if (t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))
118
+ return
119
+ e.preventDefault()
120
+ toggle()
121
+ }
122
+ window.addEventListener('keydown', onKey)
123
+ onCleanup(() => window.removeEventListener('keydown', onKey))
124
+ })
125
+
126
+ const value: SidebarContextValue = {
127
+ open,
128
+ setOpen,
129
+ toggle,
130
+ isMobile,
131
+ openMobile,
132
+ setOpenMobile,
133
+ panelId: `sidebar-${createUniqueId()}`,
134
+ config,
135
+ setConfig,
136
+ }
137
+ return (
138
+ <SidebarContext.Provider value={value}>
139
+ <div data-sidebar="provider" class={cn('flex h-full min-h-0 w-full', props.class)}>
140
+ {props.children}
141
+ </div>
142
+ </SidebarContext.Provider>
143
+ )
144
+ }
145
+
146
+ interface SidebarProps extends ParentProps {
147
+ /** Which edge it sits on. @default 'left' */
148
+ side?: SidebarSide
149
+ /** `sidebar` is flush, `floating` is a detached rounded panel, `inset` has no surface of its own (pair with `SidebarInset`). @default 'sidebar' */
150
+ variant?: SidebarVariant
151
+ /** `offcanvas` slides away, `icon` shrinks to an icon rail, `none` never collapses. @default 'offcanvas' */
152
+ collapsible?: SidebarCollapsible
153
+ /** Accessible name (and the drawer title on mobile). @default 'Navigation' */
154
+ label?: string
155
+ class?: string
156
+ }
157
+
158
+ /**
159
+ * The sidebar panel. Collapses per `collapsible`; below `md` it opens as a
160
+ * `Drawer` on the same `side` (toggled by `SidebarTrigger` / Ctrl+B).
161
+ *
162
+ * @example
163
+ * ```tsx
164
+ * <Sidebar collapsible="icon" variant="floating">
165
+ * <SidebarHeader>…</SidebarHeader>
166
+ * <SidebarContent>…</SidebarContent>
167
+ * </Sidebar>
168
+ * ```
169
+ */
170
+ export function Sidebar(props: SidebarProps): JSX.Element {
171
+ const ctx = useSidebarInternal()
172
+ const side = () => props.side ?? 'left'
173
+ const variant = () => props.variant ?? 'sidebar'
174
+ const collapsible = () => props.collapsible ?? 'offcanvas'
175
+ createEffect(() => ctx.setConfig({ side: side(), variant: variant(), collapsible: collapsible() }))
176
+
177
+ const expanded = () => collapsible() === 'none' || ctx.open()
178
+ const iconCollapsed = () => !ctx.isMobile() && collapsible() === 'icon' && !expanded()
179
+ const offscreen = () => collapsible() === 'offcanvas' && !expanded()
180
+ const label = () => props.label ?? 'Navigation'
181
+
182
+ const surface = () => (
183
+ <div
184
+ data-sidebar="surface"
185
+ class={cn(
186
+ 'relative flex h-full w-full min-w-0 flex-col',
187
+ variant() === 'sidebar' &&
188
+ cn('bg-glass', side() === 'left' ? 'border-r border-border' : 'border-l border-border'),
189
+ variant() === 'floating' && 'rounded-xl border border-border bg-glass shadow-lg',
190
+ props.class,
191
+ )}
192
+ >
193
+ {props.children}
194
+ </div>
195
+ )
196
+
197
+ return (
198
+ <IconCollapsed.Provider value={iconCollapsed}>
199
+ <Show
200
+ when={!ctx.isMobile()}
201
+ fallback={
202
+ <Drawer
203
+ open={ctx.openMobile()}
204
+ onOpenChange={ctx.setOpenMobile}
205
+ side={side()}
206
+ title={label()}
207
+ class="max-w-[var(--sidebar-w)] p-0"
208
+ >
209
+ <div id={ctx.panelId} data-sidebar="surface" class="flex min-h-0 flex-1 flex-col">
210
+ {props.children}
211
+ </div>
212
+ </Drawer>
213
+ }
214
+ >
215
+ <aside
216
+ id={ctx.panelId}
217
+ aria-label={label()}
218
+ data-sidebar="root"
219
+ data-state={expanded() ? 'expanded' : 'collapsed'}
220
+ data-side={side()}
221
+ data-variant={variant()}
222
+ data-collapsible={collapsible()}
223
+ data-icon-collapsed={iconCollapsed()}
224
+ class={cn(
225
+ 'group/sidebar relative h-full shrink-0 transition-[width] duration-200 ease-out',
226
+ expanded()
227
+ ? 'w-[var(--sidebar-w)]'
228
+ : collapsible() === 'icon'
229
+ ? 'w-[var(--sidebar-w-collapsed)]'
230
+ : 'w-0',
231
+ )}
232
+ >
233
+ <div
234
+ class={cn(
235
+ 'absolute inset-y-0 transition-[transform,visibility] duration-200 ease-out',
236
+ side() === 'left' ? 'left-0' : 'right-0',
237
+ variant() !== 'sidebar' && 'p-2',
238
+ collapsible() === 'icon' ? 'w-full' : 'w-[var(--sidebar-w)]',
239
+ offscreen() && cn('invisible', side() === 'left' ? '-translate-x-full' : 'translate-x-full'),
240
+ )}
241
+ >
242
+ {surface()}
243
+ </div>
244
+ </aside>
245
+ </Show>
246
+ </IconCollapsed.Provider>
247
+ )
248
+ }
249
+
250
+ interface SidebarTriggerProps {
251
+ /** Accessible name. @default 'Toggle sidebar' */
252
+ label?: string
253
+ class?: string
254
+ /** Replaces the default panel icon. */
255
+ children?: JSX.Element
256
+ }
257
+
258
+ /**
259
+ * Button that toggles the sidebar (the drawer on mobile). Exposes
260
+ * `aria-expanded` and `aria-controls` for the panel.
261
+ */
262
+ export function SidebarTrigger(props: SidebarTriggerProps): JSX.Element {
263
+ const ctx = useSidebarInternal()
264
+ const expanded = () => (ctx.isMobile() ? ctx.openMobile() : ctx.open())
265
+ return (
266
+ <button
267
+ type="button"
268
+ data-sidebar="trigger"
269
+ aria-label={props.label ?? 'Toggle sidebar'}
270
+ aria-expanded={expanded()}
271
+ aria-controls={ctx.panelId}
272
+ onClick={() => ctx.toggle()}
273
+ class={cn(
274
+ 'grid h-8 w-8 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
275
+ props.class,
276
+ )}
277
+ >
278
+ {props.children ?? <PanelLeft class="h-4 w-4" aria-hidden="true" />}
279
+ </button>
280
+ )
281
+ }
282
+
283
+ /**
284
+ * Thin hit area on the sidebar's inner edge that toggles it. Pointer-only
285
+ * affordance (`tabindex=-1`); keyboard users have `SidebarTrigger` and Ctrl+B.
286
+ * Place it as the last child of `Sidebar`.
287
+ */
288
+ export function SidebarRail(props: { class?: string }): JSX.Element {
289
+ const ctx = useSidebarInternal()
290
+ const left = () => ctx.config().side === 'left'
291
+ return (
292
+ <button
293
+ type="button"
294
+ data-sidebar="rail"
295
+ tabIndex={-1}
296
+ aria-label="Toggle sidebar edge"
297
+ title="Toggle sidebar"
298
+ onClick={() => ctx.toggle()}
299
+ class={cn(
300
+ 'absolute inset-y-0 z-10 hidden w-3 after:absolute after:inset-y-0 after:left-1/2 after:w-px after:bg-transparent after:transition-colors hover:after:bg-border md:block',
301
+ left() ? '-right-1.5 cursor-w-resize' : '-left-1.5 cursor-e-resize',
302
+ props.class,
303
+ )}
304
+ />
305
+ )
306
+ }
307
+
308
+ /**
309
+ * Main content wrapper. With `variant="inset"` it becomes a rounded glass panel
310
+ * beside the sidebar; otherwise it is a plain scrolling region.
311
+ *
312
+ * @example
313
+ * ```tsx
314
+ * <SidebarInset><main>…</main></SidebarInset>
315
+ * ```
316
+ */
317
+ export function SidebarInset(props: ParentProps<{ class?: string }>): JSX.Element {
318
+ const ctx = useSidebarInternal()
319
+ const inset = () => ctx.config().variant === 'inset'
320
+ return (
321
+ <div
322
+ data-sidebar="inset"
323
+ class={cn(
324
+ 'relative min-w-0 flex-1 overflow-auto',
325
+ inset() &&
326
+ cn(
327
+ 'm-2 rounded-xl border border-border bg-glass shadow-sm',
328
+ ctx.config().side === 'left' ? 'ml-0' : 'mr-0',
329
+ ),
330
+ props.class,
331
+ )}
332
+ >
333
+ {props.children}
334
+ </div>
335
+ )
336
+ }
337
+
338
+ /** Top slot (brand, switcher, search). */
339
+ export function SidebarHeader(props: ParentProps<{ class?: string }>): JSX.Element {
340
+ return (
341
+ <div data-sidebar="header" class={cn('flex flex-col gap-2 p-2', props.class)}>
342
+ {props.children}
343
+ </div>
344
+ )
345
+ }
346
+
347
+ /** Bottom slot (account, settings). */
348
+ export function SidebarFooter(props: ParentProps<{ class?: string }>): JSX.Element {
349
+ return (
350
+ <div data-sidebar="footer" class={cn('flex flex-col gap-2 p-2', props.class)}>
351
+ {props.children}
352
+ </div>
353
+ )
354
+ }
355
+
356
+ /** Scrolling middle region, built on `ScrollArea`. */
357
+ export function SidebarContent(props: ParentProps<{ class?: string }>): JSX.Element {
358
+ return (
359
+ <ScrollArea class={cn('flex min-h-0 flex-1 flex-col gap-2 p-2', props.class)}>
360
+ {props.children}
361
+ </ScrollArea>
362
+ )
363
+ }
364
+
365
+ const GroupContext = createContext<{ id: string; register: () => void }>()
366
+
367
+ /** A labelled section of the sidebar. */
368
+ export function SidebarGroup(props: ParentProps<{ class?: string }>): JSX.Element {
369
+ const id = `sidebar-group-${createUniqueId()}`
370
+ const [hasLabel, setHasLabel] = createSignal(false)
371
+ return (
372
+ <GroupContext.Provider value={{ id, register: () => setHasLabel(true) }}>
373
+ <div
374
+ role="group"
375
+ data-sidebar="group"
376
+ aria-labelledby={hasLabel() ? id : undefined}
377
+ class={cn('relative flex w-full min-w-0 flex-col gap-1', props.class)}
378
+ >
379
+ {props.children}
380
+ </div>
381
+ </GroupContext.Provider>
382
+ )
383
+ }
384
+
385
+ /** Small uppercase heading of a group; hidden when the rail is icon-collapsed. */
386
+ export function SidebarGroupLabel(props: ParentProps<{ class?: string }>): JSX.Element {
387
+ const group = useContext(GroupContext)
388
+ onMount(() => group?.register())
389
+ return (
390
+ <div
391
+ id={group?.id}
392
+ data-sidebar="group-label"
393
+ class={cn(
394
+ 'px-2 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground group-data-[icon-collapsed=true]/sidebar:hidden',
395
+ props.class,
396
+ )}
397
+ >
398
+ {props.children}
399
+ </div>
400
+ )
401
+ }
402
+
403
+ interface SidebarGroupActionProps extends ParentProps {
404
+ /** Required accessible name (the action is usually just an icon). */
405
+ label: string
406
+ onClick?: (e: MouseEvent) => void
407
+ class?: string
408
+ }
409
+
410
+ /** Icon button aligned to the right of the group label. */
411
+ export function SidebarGroupAction(props: SidebarGroupActionProps): JSX.Element {
412
+ return (
413
+ <button
414
+ type="button"
415
+ data-sidebar="group-action"
416
+ aria-label={props.label}
417
+ onClick={(e) => props.onClick?.(e)}
418
+ class={cn(
419
+ 'absolute right-1 top-0 grid h-6 w-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-data-[icon-collapsed=true]/sidebar:hidden [&>svg]:h-4 [&>svg]:w-4',
420
+ props.class,
421
+ )}
422
+ >
423
+ {props.children}
424
+ </button>
425
+ )
426
+ }
427
+
428
+ /** Wrapper for a group's items. */
429
+ export function SidebarGroupContent(props: ParentProps<{ class?: string }>): JSX.Element {
430
+ return (
431
+ <div data-sidebar="group-content" class={cn('w-full text-sm', props.class)}>
432
+ {props.children}
433
+ </div>
434
+ )
435
+ }
436
+
437
+ /** `<ul>` of menu items. */
438
+ export function SidebarMenu(props: ParentProps<{ class?: string }>): JSX.Element {
439
+ return (
440
+ <ul data-sidebar="menu" class={cn('flex w-full min-w-0 list-none flex-col gap-1', props.class)}>
441
+ {props.children}
442
+ </ul>
443
+ )
444
+ }
445
+
446
+ /** `<li>` of a menu; anchors `SidebarMenuAction` and `SidebarMenuBadge`. */
447
+ export function SidebarMenuItem(props: ParentProps<{ class?: string }>): JSX.Element {
448
+ return (
449
+ <li data-sidebar="menu-item" class={cn('group/item relative', props.class)}>
450
+ {props.children}
451
+ </li>
452
+ )
453
+ }
454
+
455
+ type MenuButtonSize = 'sm' | 'default' | 'lg'
456
+
457
+ interface SidebarMenuButtonProps extends ParentProps {
458
+ /** Marks the current page: sets `aria-current="page"` and the highlight. */
459
+ active?: boolean
460
+ /** Renders a link. Otherwise a `<button>` (use `onClick`). */
461
+ href?: string
462
+ onClick?: (e: MouseEvent) => void
463
+ /** Shown as a tooltip (beside the icon) only while the rail is icon-collapsed. */
464
+ tooltip?: JSX.Element
465
+ size?: MenuButtonSize
466
+ class?: string
467
+ }
468
+
469
+ const SIZE: Record<MenuButtonSize, string> = {
470
+ sm: 'h-7 text-xs',
471
+ default: 'h-9 text-sm',
472
+ lg: 'h-12 text-sm',
473
+ }
474
+
475
+ // Shared look of MenuButton, MenuSubTrigger. In the icon rail every direct text
476
+ // child is visually hidden (sr-only) so the accessible name survives, and the
477
+ // icon is centred.
478
+ const ITEM_BASE =
479
+ 'flex w-full min-w-0 items-center gap-2.5 rounded-lg px-2.5 text-left font-medium text-foreground/80 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-[current=page]:bg-muted aria-[current=page]:text-foreground group-has-[[data-sidebar=menu-action]]/item:pr-9 [&>svg]:h-[18px] [&>svg]:w-[18px] [&>svg]:shrink-0 [&>span]:truncate group-data-[icon-collapsed=true]/sidebar:justify-center group-data-[icon-collapsed=true]/sidebar:px-0 group-data-[icon-collapsed=true]/sidebar:[&>span]:sr-only'
480
+
481
+ function MenuTooltip(
482
+ props: ParentProps<{ tooltip?: JSX.Element; as: 'a' | 'button'; [key: string]: unknown }>,
483
+ ) {
484
+ const iconCollapsed = useContext(IconCollapsed)
485
+ const ctx = useSidebarInternal()
486
+ const [local, rest] = splitProps(props, ['tooltip', 'as', 'children'])
487
+ return (
488
+ <KTooltip
489
+ placement={ctx.config().side === 'left' ? 'right' : 'left'}
490
+ gutter={10}
491
+ openDelay={150}
492
+ closeDelay={0}
493
+ disabled={!local.tooltip || !iconCollapsed()}
494
+ >
495
+ <KTooltip.Trigger as={local.as} {...rest}>
496
+ {local.children}
497
+ </KTooltip.Trigger>
498
+ <KTooltip.Portal>
499
+ <KTooltip.Content class="z-50 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-card-foreground shadow-md">
500
+ {local.tooltip}
501
+ </KTooltip.Content>
502
+ </KTooltip.Portal>
503
+ </KTooltip>
504
+ )
505
+ }
506
+
507
+ /**
508
+ * Main navigation row. Pass `href` for a link or `onClick` for an action;
509
+ * `active` sets `aria-current="page"`. Put the label in a `<span>` after the icon
510
+ * so it can collapse; with `collapsible="icon"` the `tooltip` appears on
511
+ * hover/focus and the span stays as the accessible name.
512
+ *
513
+ * @example
514
+ * ```tsx
515
+ * <SidebarMenuButton href="#/inbox" active tooltip="Inbox"><Inbox /><span>Inbox</span></SidebarMenuButton>
516
+ * ```
517
+ */
518
+ export function SidebarMenuButton(props: SidebarMenuButtonProps): JSX.Element {
519
+ const cls = () => cn(ITEM_BASE, SIZE[props.size ?? 'default'], props.class)
520
+ return (
521
+ <Show
522
+ when={props.href !== undefined}
523
+ fallback={
524
+ <MenuTooltip
525
+ as="button"
526
+ type="button"
527
+ tooltip={props.tooltip}
528
+ data-sidebar="menu-button"
529
+ aria-current={props.active ? 'page' : undefined}
530
+ onClick={(e: MouseEvent) => props.onClick?.(e)}
531
+ class={cls()}
532
+ >
533
+ {props.children}
534
+ </MenuTooltip>
535
+ }
536
+ >
537
+ <MenuTooltip
538
+ as="a"
539
+ href={props.href}
540
+ tooltip={props.tooltip}
541
+ data-sidebar="menu-button"
542
+ aria-current={props.active ? 'page' : undefined}
543
+ onClick={(e: MouseEvent) => props.onClick?.(e)}
544
+ class={cls()}
545
+ >
546
+ {props.children}
547
+ </MenuTooltip>
548
+ </Show>
549
+ )
550
+ }
551
+
552
+ /** Icon button to the right of a `SidebarMenuButton` (add, more…). Needs a `label`. */
553
+ export function SidebarMenuAction(
554
+ props: ParentProps<{
555
+ label: string
556
+ onClick?: (e: MouseEvent) => void
557
+ showOnHover?: boolean
558
+ class?: string
559
+ }>,
560
+ ): JSX.Element {
561
+ return (
562
+ <button
563
+ type="button"
564
+ data-sidebar="menu-action"
565
+ aria-label={props.label}
566
+ onClick={(e) => props.onClick?.(e)}
567
+ class={cn(
568
+ 'absolute right-1 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-data-[icon-collapsed=true]/sidebar:hidden [&>svg]:h-4 [&>svg]:w-4',
569
+ props.showOnHover &&
570
+ 'opacity-0 focus-visible:opacity-100 group-focus-within/item:opacity-100 group-hover/item:opacity-100',
571
+ props.class,
572
+ )}
573
+ >
574
+ {props.children}
575
+ </button>
576
+ )
577
+ }
578
+
579
+ /** Count / status pill at the right edge of a menu item. */
580
+ export function SidebarMenuBadge(props: ParentProps<{ class?: string }>): JSX.Element {
581
+ return (
582
+ <span
583
+ data-sidebar="menu-badge"
584
+ class={cn(
585
+ 'pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 select-none rounded-md bg-primary/15 px-1.5 text-[11px] font-semibold tabular-nums text-primary group-data-[icon-collapsed=true]/sidebar:hidden',
586
+ props.class,
587
+ )}
588
+ >
589
+ {props.children}
590
+ </span>
591
+ )
592
+ }
593
+
594
+ interface SidebarMenuSubProps extends ParentProps {
595
+ /** Text of the row that expands the submenu. */
596
+ label: string
597
+ /** Icon shown before the label. */
598
+ icon?: JSX.Element
599
+ /** Controlled expanded state. */
600
+ open?: boolean
601
+ onOpenChange?: (open: boolean) => void
602
+ /** Initial state when uncontrolled. @default false */
603
+ defaultOpen?: boolean
604
+ class?: string
605
+ }
606
+
607
+ /**
608
+ * Collapsible nested menu: a toggle row (`aria-expanded` / `aria-controls`) and an
609
+ * indented list of `SidebarMenuSubItem`. Use inside a `SidebarMenuItem`. In the icon
610
+ * rail the toggle expands the sidebar first, then the submenu.
611
+ *
612
+ * @example
613
+ * ```tsx
614
+ * <SidebarMenuItem>
615
+ * <SidebarMenuSub label="Projects" icon={<Folder />}>
616
+ * <SidebarMenuSubItem><SidebarMenuSubButton href="#/a">Orbit</SidebarMenuSubButton></SidebarMenuSubItem>
617
+ * </SidebarMenuSub>
618
+ * </SidebarMenuItem>
619
+ * ```
620
+ */
621
+ export function SidebarMenuSub(props: SidebarMenuSubProps): JSX.Element {
622
+ const ctx = useSidebarInternal()
623
+ const iconCollapsed = useContext(IconCollapsed)
624
+ const id = `sidebar-sub-${createUniqueId()}`
625
+ const [internal, setInternal] = createSignal(props.defaultOpen ?? false)
626
+ const open = () => (props.open !== undefined ? props.open : internal())
627
+ const set = (next: boolean) => {
628
+ if (props.open === undefined) setInternal(next)
629
+ props.onOpenChange?.(next)
630
+ }
631
+ const shown = createMemo(() => open() && !iconCollapsed())
632
+ return (
633
+ <div data-sidebar="menu-sub" class={props.class}>
634
+ <MenuTooltip
635
+ as="button"
636
+ type="button"
637
+ tooltip={props.label}
638
+ data-sidebar="menu-sub-trigger"
639
+ aria-expanded={shown()}
640
+ aria-controls={id}
641
+ onClick={() => {
642
+ if (iconCollapsed()) {
643
+ ctx.setOpen(true)
644
+ set(true)
645
+ } else set(!open())
646
+ }}
647
+ class={cn(ITEM_BASE, SIZE.default)}
648
+ >
649
+ {props.icon}
650
+ <span>{props.label}</span>
651
+ <ChevronRight
652
+ aria-hidden="true"
653
+ class={cn(
654
+ 'ml-auto h-4 w-4 shrink-0 transition-transform duration-200 group-data-[icon-collapsed=true]/sidebar:hidden',
655
+ shown() && 'rotate-90',
656
+ )}
657
+ />
658
+ </MenuTooltip>
659
+ <div
660
+ id={id}
661
+ class={cn(
662
+ 'grid transition-[grid-template-rows,visibility] duration-200 ease-out',
663
+ shown() ? 'grid-rows-[1fr]' : 'invisible grid-rows-[0fr]',
664
+ )}
665
+ >
666
+ <div class="min-h-0 overflow-hidden">
667
+ <ul class="ml-4 mt-1 flex list-none flex-col gap-0.5 border-l border-border py-0.5 pl-2.5">
668
+ {props.children}
669
+ </ul>
670
+ </div>
671
+ </div>
672
+ </div>
673
+ )
674
+ }
675
+
676
+ /** `<li>` of a submenu. */
677
+ export function SidebarMenuSubItem(props: ParentProps<{ class?: string }>): JSX.Element {
678
+ return (
679
+ <li data-sidebar="menu-sub-item" class={cn('relative', props.class)}>
680
+ {props.children}
681
+ </li>
682
+ )
683
+ }
684
+
685
+ /** Link (`href`) or button row inside a submenu; `active` sets `aria-current="page"`. */
686
+ export function SidebarMenuSubButton(
687
+ props: ParentProps<{ active?: boolean; href?: string; onClick?: (e: MouseEvent) => void; class?: string }>,
688
+ ): JSX.Element {
689
+ const cls = () =>
690
+ cn(
691
+ 'flex h-8 w-full min-w-0 items-center gap-2 rounded-md px-2 text-left text-[13px] font-medium text-foreground/75 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-[current=page]:bg-muted aria-[current=page]:text-foreground [&>span]:truncate',
692
+ props.class,
693
+ )
694
+ return (
695
+ <Show
696
+ when={props.href !== undefined}
697
+ fallback={
698
+ <button
699
+ type="button"
700
+ data-sidebar="menu-sub-button"
701
+ aria-current={props.active ? 'page' : undefined}
702
+ onClick={(e) => props.onClick?.(e)}
703
+ class={cls()}
704
+ >
705
+ {props.children}
706
+ </button>
707
+ }
708
+ >
709
+ <a
710
+ href={props.href}
711
+ data-sidebar="menu-sub-button"
712
+ aria-current={props.active ? 'page' : undefined}
713
+ onClick={(e) => props.onClick?.(e)}
714
+ class={cls()}
715
+ >
716
+ {props.children}
717
+ </a>
718
+ </Show>
719
+ )
720
+ }
721
+
722
+ /** Loading placeholder shaped like a menu row. Decorative (`aria-hidden`). */
723
+ export function SidebarMenuSkeleton(props: { showIcon?: boolean; class?: string }): JSX.Element {
724
+ // Stable per instance so rows do not jitter between renders.
725
+ const width = `${50 + Math.floor(Math.random() * 40)}%`
726
+ return (
727
+ <div
728
+ data-sidebar="menu-skeleton"
729
+ aria-hidden="true"
730
+ class={cn(
731
+ 'flex h-9 items-center gap-2.5 rounded-lg px-2.5 group-data-[icon-collapsed=true]/sidebar:justify-center group-data-[icon-collapsed=true]/sidebar:px-0',
732
+ props.class,
733
+ )}
734
+ >
735
+ <Show when={props.showIcon}>
736
+ <div class="h-[18px] w-[18px] shrink-0 animate-pulse rounded-md bg-muted" />
737
+ </Show>
738
+ <div
739
+ class="h-3 flex-1 animate-pulse rounded bg-muted group-data-[icon-collapsed=true]/sidebar:hidden"
740
+ style={{ 'max-width': width }}
741
+ />
742
+ </div>
743
+ )
744
+ }
745
+
746
+ export type {
747
+ SidebarProps,
748
+ SidebarProviderProps,
749
+ SidebarTriggerProps,
750
+ SidebarGroupActionProps,
751
+ SidebarMenuButtonProps,
752
+ SidebarMenuSubProps,
753
+ }