@adres/mgda-ui 0.0.0-stage → 0.0.1

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 (325) hide show
  1. package/brand-knowledge.json +451 -0
  2. package/knowledge/accordion.md +135 -0
  3. package/knowledge/activity-rings.md +123 -0
  4. package/knowledge/alert.md +184 -0
  5. package/knowledge/answer-actions.md +217 -0
  6. package/knowledge/aspect-ratio.md +82 -0
  7. package/knowledge/avatar.md +158 -0
  8. package/knowledge/badge.md +172 -0
  9. package/knowledge/bar-graph.md +167 -0
  10. package/knowledge/bento-grid.md +115 -0
  11. package/knowledge/blockquote.md +153 -0
  12. package/knowledge/box-list.md +177 -0
  13. package/knowledge/bullet-list.md +128 -0
  14. package/knowledge/button-group.md +179 -0
  15. package/knowledge/button.md +231 -0
  16. package/knowledge/calendar.md +168 -0
  17. package/knowledge/card.md +321 -0
  18. package/knowledge/cards-nav.md +170 -0
  19. package/knowledge/chain-of-thought.md +164 -0
  20. package/knowledge/chat-container.md +135 -0
  21. package/knowledge/chat-message.md +224 -0
  22. package/knowledge/checkbox-card.md +172 -0
  23. package/knowledge/checkbox.md +111 -0
  24. package/knowledge/citations.md +241 -0
  25. package/knowledge/combobox.md +265 -0
  26. package/knowledge/countdown-pill.md +153 -0
  27. package/knowledge/cta.md +118 -0
  28. package/knowledge/data-list.md +173 -0
  29. package/knowledge/data-table.md +220 -0
  30. package/knowledge/dialog.md +249 -0
  31. package/knowledge/drawer.md +236 -0
  32. package/knowledge/dropdown-menu.md +238 -0
  33. package/knowledge/dropzone.md +153 -0
  34. package/knowledge/email-chips-input.md +161 -0
  35. package/knowledge/empty-state.md +106 -0
  36. package/knowledge/entity-header.md +166 -0
  37. package/knowledge/feature-grid.md +120 -0
  38. package/knowledge/feedback-bar.md +127 -0
  39. package/knowledge/footer.md +116 -0
  40. package/knowledge/heading.md +189 -0
  41. package/knowledge/hero.md +361 -0
  42. package/knowledge/input-button.md +187 -0
  43. package/knowledge/input-group.md +185 -0
  44. package/knowledge/input.md +256 -0
  45. package/knowledge/inset.md +177 -0
  46. package/knowledge/kpi-card.md +206 -0
  47. package/knowledge/list-nav.md +181 -0
  48. package/knowledge/logo-cloud.md +102 -0
  49. package/knowledge/media-gallery.md +158 -0
  50. package/knowledge/message-actions.md +175 -0
  51. package/knowledge/nav-sidebar.md +225 -0
  52. package/knowledge/number-stepper.md +159 -0
  53. package/knowledge/page-header.md +109 -0
  54. package/knowledge/pagination.md +225 -0
  55. package/knowledge/popover.md +159 -0
  56. package/knowledge/pricing.md +132 -0
  57. package/knowledge/progress.md +156 -0
  58. package/knowledge/prompt-input.md +337 -0
  59. package/knowledge/prompt-suggestions.md +168 -0
  60. package/knowledge/quota-bar.md +160 -0
  61. package/knowledge/quote.md +116 -0
  62. package/knowledge/radio-card.md +172 -0
  63. package/knowledge/radio-group.md +135 -0
  64. package/knowledge/reasoning.md +142 -0
  65. package/knowledge/search.md +131 -0
  66. package/knowledge/section-nav.md +162 -0
  67. package/knowledge/segmented-control.md +181 -0
  68. package/knowledge/select-button.md +205 -0
  69. package/knowledge/select.md +210 -0
  70. package/knowledge/separator.md +75 -0
  71. package/knowledge/shimmer-text.md +140 -0
  72. package/knowledge/sidebar.md +227 -0
  73. package/knowledge/skeleton.md +142 -0
  74. package/knowledge/slider.md +133 -0
  75. package/knowledge/slideshow.md +159 -0
  76. package/knowledge/source.md +156 -0
  77. package/knowledge/spinner.md +120 -0
  78. package/knowledge/stat-strip.md +128 -0
  79. package/knowledge/stats.md +117 -0
  80. package/knowledge/step-indicator.md +157 -0
  81. package/knowledge/switch.md +88 -0
  82. package/knowledge/table.md +190 -0
  83. package/knowledge/tabs.md +169 -0
  84. package/knowledge/testimonials.md +115 -0
  85. package/knowledge/text-link.md +192 -0
  86. package/knowledge/text.md +194 -0
  87. package/knowledge/thinking-bar.md +115 -0
  88. package/knowledge/thinking-steps.md +140 -0
  89. package/knowledge/toaster.md +127 -0
  90. package/knowledge/toggle.md +113 -0
  91. package/knowledge/tool-call-card.md +241 -0
  92. package/knowledge/tooltip.md +120 -0
  93. package/knowledge/top-nav.md +138 -0
  94. package/knowledge.json +19854 -0
  95. package/package.json +94 -4
  96. package/src/components/accordion.stories.tsx +46 -0
  97. package/src/components/accordion.tsx +74 -0
  98. package/src/components/activity-rings.stories.tsx +73 -0
  99. package/src/components/activity-rings.tsx +121 -0
  100. package/src/components/alert.stories.tsx +43 -0
  101. package/src/components/alert.tsx +132 -0
  102. package/src/components/answer-actions.stories.tsx +54 -0
  103. package/src/components/answer-actions.test.tsx +37 -0
  104. package/src/components/answer-actions.tsx +313 -0
  105. package/src/components/aspect-ratio.stories.tsx +32 -0
  106. package/src/components/aspect-ratio.tsx +5 -0
  107. package/src/components/avatar.stories.tsx +40 -0
  108. package/src/components/avatar.tsx +154 -0
  109. package/src/components/badge.stories.tsx +27 -0
  110. package/src/components/badge.tsx +63 -0
  111. package/src/components/bar-graph.stories.tsx +28 -0
  112. package/src/components/bar-graph.tsx +204 -0
  113. package/src/components/bento-grid.stories.tsx +23 -0
  114. package/src/components/bento-grid.tsx +67 -0
  115. package/src/components/blockquote.stories.tsx +28 -0
  116. package/src/components/blockquote.tsx +88 -0
  117. package/src/components/box-list.stories.tsx +25 -0
  118. package/src/components/box-list.tsx +108 -0
  119. package/src/components/bullet-list.stories.tsx +22 -0
  120. package/src/components/bullet-list.tsx +101 -0
  121. package/src/components/button-group.stories.tsx +31 -0
  122. package/src/components/button-group.tsx +156 -0
  123. package/src/components/button.stories.tsx +66 -0
  124. package/src/components/button.test.ts +17 -0
  125. package/src/components/button.tsx +139 -0
  126. package/src/components/calendar.stories.tsx +25 -0
  127. package/src/components/calendar.tsx +367 -0
  128. package/src/components/card.stories.tsx +44 -0
  129. package/src/components/card.tsx +392 -0
  130. package/src/components/cards-nav.stories.tsx +35 -0
  131. package/src/components/cards-nav.tsx +115 -0
  132. package/src/components/chain-of-thought.stories.tsx +67 -0
  133. package/src/components/chain-of-thought.test.tsx +50 -0
  134. package/src/components/chain-of-thought.tsx +170 -0
  135. package/src/components/chat-container.stories.tsx +47 -0
  136. package/src/components/chat-container.test.tsx +49 -0
  137. package/src/components/chat-container.tsx +222 -0
  138. package/src/components/chat-message.stories.tsx +52 -0
  139. package/src/components/chat-message.test.tsx +36 -0
  140. package/src/components/chat-message.tsx +163 -0
  141. package/src/components/checkbox-card.stories.tsx +27 -0
  142. package/src/components/checkbox-card.tsx +200 -0
  143. package/src/components/checkbox.stories.tsx +26 -0
  144. package/src/components/checkbox.tsx +61 -0
  145. package/src/components/citations.stories.tsx +64 -0
  146. package/src/components/citations.test.tsx +61 -0
  147. package/src/components/citations.tsx +220 -0
  148. package/src/components/combobox.stories.tsx +184 -0
  149. package/src/components/combobox.test.tsx +82 -0
  150. package/src/components/combobox.tsx +407 -0
  151. package/src/components/countdown-pill.stories.tsx +24 -0
  152. package/src/components/countdown-pill.tsx +129 -0
  153. package/src/components/cta.stories.tsx +29 -0
  154. package/src/components/cta.tsx +52 -0
  155. package/src/components/data-list.stories.tsx +50 -0
  156. package/src/components/data-list.test.tsx +35 -0
  157. package/src/components/data-list.tsx +181 -0
  158. package/src/components/data-table.stories.tsx +29 -0
  159. package/src/components/data-table.tsx +200 -0
  160. package/src/components/dialog.stories.tsx +42 -0
  161. package/src/components/dialog.tsx +200 -0
  162. package/src/components/drawer.stories.tsx +48 -0
  163. package/src/components/drawer.tsx +385 -0
  164. package/src/components/dropdown-menu.stories.tsx +41 -0
  165. package/src/components/dropdown-menu.tsx +252 -0
  166. package/src/components/dropzone.stories.tsx +19 -0
  167. package/src/components/dropzone.tsx +250 -0
  168. package/src/components/email-chips-input.stories.tsx +135 -0
  169. package/src/components/email-chips-input.tsx +210 -0
  170. package/src/components/empty-state.stories.tsx +22 -0
  171. package/src/components/empty-state.tsx +35 -0
  172. package/src/components/entity-header.stories.tsx +43 -0
  173. package/src/components/entity-header.test.tsx +35 -0
  174. package/src/components/entity-header.tsx +92 -0
  175. package/src/components/feature-grid.stories.tsx +25 -0
  176. package/src/components/feature-grid.tsx +87 -0
  177. package/src/components/feedback-bar.stories.tsx +25 -0
  178. package/src/components/feedback-bar.test.tsx +24 -0
  179. package/src/components/feedback-bar.tsx +62 -0
  180. package/src/components/footer.stories.tsx +23 -0
  181. package/src/components/footer.tsx +78 -0
  182. package/src/components/heading.stories.tsx +45 -0
  183. package/src/components/heading.test.tsx +22 -0
  184. package/src/components/heading.tsx +77 -0
  185. package/src/components/hero.stories.tsx +38 -0
  186. package/src/components/hero.tsx +498 -0
  187. package/src/components/input-button.stories.tsx +19 -0
  188. package/src/components/input-button.tsx +112 -0
  189. package/src/components/input-group.stories.tsx +38 -0
  190. package/src/components/input-group.tsx +157 -0
  191. package/src/components/input.stories.tsx +31 -0
  192. package/src/components/input.tsx +284 -0
  193. package/src/components/inset.stories.tsx +29 -0
  194. package/src/components/inset.tsx +70 -0
  195. package/src/components/kpi-card.stories.tsx +29 -0
  196. package/src/components/kpi-card.tsx +284 -0
  197. package/src/components/list-nav.stories.tsx +22 -0
  198. package/src/components/list-nav.tsx +145 -0
  199. package/src/components/logo-cloud.stories.tsx +23 -0
  200. package/src/components/logo-cloud.tsx +42 -0
  201. package/src/components/media-gallery.stories.tsx +25 -0
  202. package/src/components/media-gallery.tsx +347 -0
  203. package/src/components/message-actions.stories.tsx +45 -0
  204. package/src/components/message-actions.test.tsx +32 -0
  205. package/src/components/message-actions.tsx +119 -0
  206. package/src/components/nav-sidebar.stories.tsx +58 -0
  207. package/src/components/nav-sidebar.test.tsx +54 -0
  208. package/src/components/nav-sidebar.tsx +462 -0
  209. package/src/components/number-stepper.stories.tsx +19 -0
  210. package/src/components/number-stepper.tsx +158 -0
  211. package/src/components/page-header.stories.tsx +25 -0
  212. package/src/components/page-header.tsx +64 -0
  213. package/src/components/pagination.stories.tsx +110 -0
  214. package/src/components/pagination.test.tsx +46 -0
  215. package/src/components/pagination.tsx +144 -0
  216. package/src/components/popover.stories.tsx +26 -0
  217. package/src/components/popover.tsx +62 -0
  218. package/src/components/pricing.stories.tsx +42 -0
  219. package/src/components/pricing.tsx +130 -0
  220. package/src/components/progress.stories.tsx +22 -0
  221. package/src/components/progress.tsx +189 -0
  222. package/src/components/prompt-input.stories.tsx +62 -0
  223. package/src/components/prompt-input.test.tsx +106 -0
  224. package/src/components/prompt-input.tsx +343 -0
  225. package/src/components/prompt-suggestions.stories.tsx +53 -0
  226. package/src/components/prompt-suggestions.test.tsx +37 -0
  227. package/src/components/prompt-suggestions.tsx +122 -0
  228. package/src/components/quota-bar.stories.tsx +19 -0
  229. package/src/components/quota-bar.tsx +191 -0
  230. package/src/components/quote.stories.tsx +18 -0
  231. package/src/components/quote.tsx +43 -0
  232. package/src/components/radio-card.stories.tsx +27 -0
  233. package/src/components/radio-card.tsx +225 -0
  234. package/src/components/radio-group.stories.tsx +32 -0
  235. package/src/components/radio-group.tsx +68 -0
  236. package/src/components/reasoning.stories.tsx +50 -0
  237. package/src/components/reasoning.test.tsx +48 -0
  238. package/src/components/reasoning.tsx +143 -0
  239. package/src/components/search.stories.tsx +18 -0
  240. package/src/components/search.tsx +133 -0
  241. package/src/components/section-nav.stories.tsx +59 -0
  242. package/src/components/section-nav.test.tsx +36 -0
  243. package/src/components/section-nav.tsx +146 -0
  244. package/src/components/segmented-control.stories.tsx +22 -0
  245. package/src/components/segmented-control.tsx +301 -0
  246. package/src/components/select-button.stories.tsx +27 -0
  247. package/src/components/select-button.tsx +174 -0
  248. package/src/components/select.stories.tsx +27 -0
  249. package/src/components/select.tsx +198 -0
  250. package/src/components/separator.stories.tsx +22 -0
  251. package/src/components/separator.tsx +23 -0
  252. package/src/components/shimmer-text.stories.tsx +23 -0
  253. package/src/components/shimmer-text.test.tsx +35 -0
  254. package/src/components/shimmer-text.tsx +43 -0
  255. package/src/components/sidebar.stories.tsx +25 -0
  256. package/src/components/sidebar.tsx +234 -0
  257. package/src/components/skeleton.stories.tsx +22 -0
  258. package/src/components/skeleton.tsx +96 -0
  259. package/src/components/slider.stories.tsx +20 -0
  260. package/src/components/slider.tsx +176 -0
  261. package/src/components/slideshow.stories.tsx +30 -0
  262. package/src/components/slideshow.tsx +291 -0
  263. package/src/components/source.stories.tsx +32 -0
  264. package/src/components/source.test.tsx +40 -0
  265. package/src/components/source.tsx +161 -0
  266. package/src/components/spinner.stories.tsx +19 -0
  267. package/src/components/spinner.tsx +76 -0
  268. package/src/components/stat-strip.stories.tsx +36 -0
  269. package/src/components/stat-strip.test.tsx +25 -0
  270. package/src/components/stat-strip.tsx +85 -0
  271. package/src/components/stats.stories.tsx +24 -0
  272. package/src/components/stats.tsx +83 -0
  273. package/src/components/step-indicator.stories.tsx +18 -0
  274. package/src/components/step-indicator.test.tsx +18 -0
  275. package/src/components/step-indicator.tsx +286 -0
  276. package/src/components/switch.stories.tsx +26 -0
  277. package/src/components/switch.tsx +32 -0
  278. package/src/components/table.stories.tsx +38 -0
  279. package/src/components/table.tsx +220 -0
  280. package/src/components/tabs.stories.tsx +25 -0
  281. package/src/components/tabs.tsx +276 -0
  282. package/src/components/testimonials.stories.tsx +22 -0
  283. package/src/components/testimonials.tsx +109 -0
  284. package/src/components/text-link.stories.tsx +47 -0
  285. package/src/components/text-link.test.tsx +18 -0
  286. package/src/components/text-link.tsx +88 -0
  287. package/src/components/text.stories.tsx +49 -0
  288. package/src/components/text.tsx +105 -0
  289. package/src/components/thinking-bar.stories.tsx +19 -0
  290. package/src/components/thinking-bar.test.tsx +27 -0
  291. package/src/components/thinking-bar.tsx +64 -0
  292. package/src/components/thinking-steps.stories.tsx +44 -0
  293. package/src/components/thinking-steps.test.tsx +42 -0
  294. package/src/components/thinking-steps.tsx +92 -0
  295. package/src/components/toaster.stories.tsx +80 -0
  296. package/src/components/toaster.tsx +36 -0
  297. package/src/components/toggle.stories.tsx +21 -0
  298. package/src/components/toggle.tsx +103 -0
  299. package/src/components/tool-call-card.stories.tsx +97 -0
  300. package/src/components/tool-call-card.test.tsx +88 -0
  301. package/src/components/tool-call-card.tsx +231 -0
  302. package/src/components/tooltip.stories.tsx +25 -0
  303. package/src/components/tooltip.tsx +43 -0
  304. package/src/components/top-nav.stories.tsx +20 -0
  305. package/src/components/top-nav.tsx +76 -0
  306. package/src/docs/Colors.stories.tsx +192 -0
  307. package/src/docs/GettingStarted.mdx +72 -0
  308. package/src/docs/Mcp.mdx +52 -0
  309. package/src/docs/Typography.stories.tsx +114 -0
  310. package/src/hooks/use-in-view.ts +32 -0
  311. package/src/hooks/use-reduced-motion.ts +26 -0
  312. package/src/index.ts +343 -0
  313. package/src/lib/cn.test.ts +67 -0
  314. package/src/lib/cn.ts +81 -0
  315. package/src/lib/motion.ts +109 -0
  316. package/src/lib/prefix-utilities.test.ts +56 -0
  317. package/src/lib/prefix-utilities.ts +50 -0
  318. package/src/styles/base.css +489 -0
  319. package/src/styles/motion.css +69 -0
  320. package/src/styles/overrides.css +44 -0
  321. package/src/styles/typography.css +173 -0
  322. package/src/theme/theme.css +391 -0
  323. package/src/theme/tokens.ts +29 -0
  324. package/src/theme/types.ts +26 -0
  325. package/README.md +0 -3
@@ -0,0 +1,462 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ChevronDown, ChevronLeft } from "lucide-react"
5
+ import { Sidebar, SidebarSection } from "./sidebar"
6
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
7
+ import { cn } from "../lib/cn"
8
+
9
+ // ─── Nav config types ────────────────────────────────────────────────────────
10
+
11
+ export interface NavSubItem {
12
+ href: string
13
+ label: string
14
+ /** Optional trailing count rendered on the inline-end of the row, e.g. a
15
+ * per-category item tally. Muted + `tabular-nums` so columns of numbers
16
+ * line up. Omit (or pass `undefined`) to render no trailing element. */
17
+ count?: number
18
+ }
19
+
20
+ export interface NavItem {
21
+ href: string
22
+ label: string
23
+ icon: React.ReactNode
24
+ /** When set, the item is active only on an exact pathname match. */
25
+ exact?: boolean
26
+ /** Show a small accent dot at the top-end corner of the icon. Accepts
27
+ * `true` for "just-on" or a number that is treated as truthy (>0 → on).
28
+ * Items with children never render a dot - the chevron toggle owns the
29
+ * trailing space and the dot would compete with it. */
30
+ dot?: boolean | number
31
+ /** Optional trailing count, e.g. saved items or drafts. Rendered like
32
+ * `NavSubItem.count` (muted, `tabular-nums`, hidden at 0). Collapsed rows
33
+ * show it in the tooltip instead. Ignored on items with children, where the
34
+ * chevron owns the trailing space. */
35
+ count?: number
36
+ /** Nested children expand under this item with a tree-indent guide and a
37
+ * chevron toggle. */
38
+ children?: NavSubItem[]
39
+ }
40
+
41
+ export interface NavSection {
42
+ /** Overline section label (omitted when undefined). */
43
+ label?: string
44
+ items: NavItem[]
45
+ }
46
+
47
+ // ─── Component props ─────────────────────────────────────────────────────────
48
+
49
+ export interface NavSidebarProps {
50
+ sections: NavSection[]
51
+ /** The application's current pathname - used to compute active rows and to
52
+ * auto-open the active branch on first render. Pass `usePathname()` from
53
+ * your router. */
54
+ currentPath: string
55
+ /** Optional brand slot rendered in the sidebar header. Typically a logo
56
+ * with an optional sub-label. Hidden in collapsed mode (the collapse
57
+ * toggle takes the slot instead) - the consumer is responsible for
58
+ * rendering a compact mark separately if needed. */
59
+ brand?: React.ReactNode
60
+ /** Optional footer slot, typically a `SidebarUserCard`. */
61
+ footer?: React.ReactNode
62
+
63
+ // ─── Controlled / uncontrolled collapse ────────────────────────────────
64
+ collapsed?: boolean
65
+ defaultCollapsed?: boolean
66
+ onCollapsedChange?: (collapsed: boolean) => void
67
+ /** Hide the built-in collapse toggle. Useful when the consumer controls
68
+ * collapse from elsewhere (e.g. a media-query hook on mobile). */
69
+ hideCollapseToggle?: boolean
70
+
71
+ // ─── Controlled / uncontrolled per-item open overrides ─────────────────
72
+ /** Map of item href → explicit open state. Items not present in the map
73
+ * fall back to auto-open-when-active. */
74
+ expandedItems?: Record<string, boolean>
75
+ defaultExpandedItems?: Record<string, boolean>
76
+ onExpandedItemsChange?: (next: Record<string, boolean>) => void
77
+
78
+ // ─── Link integration ──────────────────────────────────────────────────
79
+ /** Component used to render navigable rows. Defaults to a plain anchor;
80
+ * pass your router's link component (e.g. Next.js `<Link>`) to get
81
+ * client-side navigation. Receives standard anchor props. */
82
+ LinkComponent?: React.ElementType
83
+
84
+ // ─── i18n / a11y labels ────────────────────────────────────────────────
85
+ expandSidebarLabel?: string
86
+ collapseSidebarLabel?: string
87
+ /** Called with `(label)` to format aria-labels for chevron toggle buttons.
88
+ * Defaults to `"Expand {label}"` / `"Collapse {label}"`. */
89
+ expandItemLabel?: (label: string) => string
90
+ collapseItemLabel?: (label: string) => string
91
+ }
92
+
93
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
94
+
95
+ function isItemActive(href: string, pathname: string, exact?: boolean): boolean {
96
+ if (exact) return pathname === href
97
+ return pathname === href || pathname.startsWith(`${href}/`)
98
+ }
99
+
100
+ function isItemOpenAuto(item: NavItem, pathname: string): boolean {
101
+ if (!item.children?.length) return false
102
+ if (isItemActive(item.href, pathname, item.exact)) return true
103
+ return item.children.some((c) => isItemActive(c.href, pathname))
104
+ }
105
+
106
+ function countLabel(count: number) {
107
+ return `${count} item${count === 1 ? "" : "s"}`
108
+ }
109
+
110
+ function NavCount({ count }: { count: number }) {
111
+ return (
112
+ <span className="ax:t-caption ax:text-muted-foreground ax:tabular-nums ax:shrink-0" aria-label={countLabel(count)}>
113
+ {count}
114
+ </span>
115
+ )
116
+ }
117
+
118
+ // ─── Internal: NavRow ────────────────────────────────────────────────────────
119
+ //
120
+ // Renders a single top-level nav row. When the item has children the chevron
121
+ // is split out as a sibling button so clicking the chevron toggles the
122
+ // submenu without navigating. The "raised white card" active treatment lives
123
+ // on the row when there are no children; when children exist the parent
124
+ // wrapper renders the card so the submenu sits on the same surface.
125
+
126
+ interface NavRowProps {
127
+ item: NavItem
128
+ active: boolean
129
+ collapsed: boolean
130
+ open?: boolean
131
+ onToggle?: () => void
132
+ LinkComponent: React.ElementType
133
+ expandItemLabel: (label: string) => string
134
+ collapseItemLabel: (label: string) => string
135
+ }
136
+
137
+ function NavRow({
138
+ item,
139
+ active,
140
+ collapsed,
141
+ open,
142
+ onToggle,
143
+ LinkComponent,
144
+ expandItemLabel,
145
+ collapseItemLabel,
146
+ }: NavRowProps) {
147
+ const hasChildren = Boolean(item.children?.length)
148
+ const dotValue = typeof item.dot === "number" ? item.dot : undefined
149
+ const showDot = item.dot === true || (typeof item.dot === "number" && item.dot > 0)
150
+ const count = !hasChildren && item.count !== undefined && item.count > 0 ? item.count : undefined
151
+
152
+ const linkBase =
153
+ "ax:group ax:flex ax:items-center ax:gap-xs ax:t-label-sm " +
154
+ "ax:rounded-md ax:py-2xs " +
155
+ "ax:transition-colors ax:transition-snappy " +
156
+ "ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring ax:focus-visible:ring-offset-2 ax:focus-visible:ring-offset-background "
157
+
158
+ const inactiveText = "ax:text-foreground-muted ax:hover:text-foreground-strong"
159
+ const activeText = "ax:text-primary-foreground"
160
+ const standaloneActive = !hasChildren && active
161
+
162
+ const row = (
163
+ <LinkComponent
164
+ href={item.href}
165
+ aria-current={active ? "page" : undefined}
166
+ // Collapsed rows render only the icon, so the label has to come from here.
167
+ aria-label={collapsed ? (count !== undefined ? `${item.label}, ${countLabel(count)}` : item.label) : undefined}
168
+ className={cn(
169
+ linkBase,
170
+ hasChildren ? "ax:ps-xs ax:pe-2xs ax:flex-1" : "ax:ps-xs ax:pe-xs",
171
+ standaloneActive
172
+ ? "ax:bg-card ax:shadow-xs " + activeText
173
+ : active
174
+ ? activeText
175
+ : inactiveText + " ax:hover:bg-card/60",
176
+ )}
177
+ >
178
+ <span className="ax:relative ax:size-4 ax:shrink-0 ax:flex ax:items-center ax:justify-center ax:[&>svg]:size-4 ax:[&>svg]:shrink-0">
179
+ {item.icon}
180
+ {showDot && !hasChildren && (
181
+ <span
182
+ className="ax:absolute ax:-top-0.5 ax:-inset-e-0.5 ax:size-2 ax:rounded-full ax:bg-accent ax:shrink-0"
183
+ aria-label={dotValue ? `${dotValue} unread` : "Unread"}
184
+ />
185
+ )}
186
+ </span>
187
+ {!collapsed && <span className="ax:flex-1 ax:truncate ax:leading-normal">{item.label}</span>}
188
+ {!collapsed && count !== undefined && <NavCount count={count} />}
189
+ </LinkComponent>
190
+ )
191
+
192
+ // ─── Collapsed: icon-only row with a tooltip ─────────────────────────
193
+ if (collapsed) {
194
+ return (
195
+ <Tooltip>
196
+ <TooltipTrigger asChild>{row}</TooltipTrigger>
197
+ <TooltipContent side="right">
198
+ {item.label}
199
+ {count !== undefined && <span className="ax:tabular-nums">{` · ${count}`}</span>}
200
+ </TooltipContent>
201
+ </Tooltip>
202
+ )
203
+ }
204
+
205
+ if (!hasChildren) return row
206
+
207
+ // ─── With children: row + chevron toggle as siblings ─────────────────
208
+ return (
209
+ <div
210
+ className={cn(
211
+ "ax:flex ax:items-center ax:rounded-md ax:transition-colors ax:transition-snappy",
212
+ active ? "" : "ax:hover:bg-card/60",
213
+ )}
214
+ >
215
+ {row}
216
+ <button
217
+ type="button"
218
+ onClick={onToggle}
219
+ aria-label={open ? collapseItemLabel(item.label) : expandItemLabel(item.label)}
220
+ aria-expanded={open}
221
+ className={cn(
222
+ "ax:shrink-0 ax:size-2xl ax:me-2xs ax:rounded-md ax:flex ax:items-center ax:justify-center",
223
+ "ax:text-muted-foreground ax:hover:text-foreground ax:transition-colors",
224
+ "ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring ax:focus-visible:ring-offset-2 ax:focus-visible:ring-offset-background",
225
+ )}
226
+ >
227
+ <ChevronDown
228
+ className={cn(
229
+ "ax:size-3.5 ax:transition-transform",
230
+ open ? "ax:rotate-0" : "ax:-rotate-90 ax:rtl:rotate-90",
231
+ )}
232
+ aria-hidden="true"
233
+ />
234
+ </button>
235
+ </div>
236
+ )
237
+ }
238
+
239
+ // ─── Internal: NavSubRow ─────────────────────────────────────────────────────
240
+
241
+ interface NavSubRowProps {
242
+ href: string
243
+ label: string
244
+ active: boolean
245
+ count?: number
246
+ LinkComponent: React.ElementType
247
+ }
248
+
249
+ function NavSubRow({ href, label, count, active, LinkComponent }: NavSubRowProps) {
250
+ return (
251
+ <LinkComponent
252
+ href={href}
253
+ aria-current={active ? "page" : undefined}
254
+ className={cn(
255
+ "ax:flex ax:items-center ax:gap-sm ax:ps-md ax:pe-sm ax:py-2xs ax:t-label-sm ax:rounded-md",
256
+ "ax:transition-colors ax:transition-snappy",
257
+ "ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring ax:focus-visible:ring-offset-2 ax:focus-visible:ring-offset-background",
258
+ active ? "ax:text-foreground" : "ax:text-muted-foreground ax:hover:text-foreground",
259
+ )}
260
+ >
261
+ <span className="ax:flex-1 ax:truncate ax:leading-normal">{label}</span>
262
+ {count !== undefined && count > 0 && <NavCount count={count} />}
263
+ </LinkComponent>
264
+ )
265
+ }
266
+
267
+ // ─── NavSidebar ──────────────────────────────────────────────────────────────
268
+
269
+ /**
270
+ * NavSidebar - opinionated, product-agnostic sidebar navigation.
271
+ *
272
+ * Combines the structural primitives (`Sidebar`, `SidebarSection`) with a
273
+ * custom row visual: an active "raised white card" state that extends to
274
+ * wrap nested sub-items, a chevron toggle that opens/closes the submenu
275
+ * without navigating, and a status-dot badge that sits on the top-end
276
+ * corner of the icon (visible in both expanded and collapsed modes).
277
+ *
278
+ * Pass your router's link component via `LinkComponent` for client-side
279
+ * navigation; defaults to a plain `<a>`. State (collapse, per-item
280
+ * open overrides) can be controlled or uncontrolled.
281
+ *
282
+ * @example
283
+ * <NavSidebar
284
+ * LinkComponent={Link}
285
+ * currentPath={usePathname()}
286
+ * brand={<MyLogo />}
287
+ * footer={<SidebarUserCard avatar={…} name="Sara" />}
288
+ * sections={[
289
+ * {
290
+ * label: "Main menu",
291
+ * items: [
292
+ * { href: "/dashboard", label: "Dashboard", icon: <Home /> },
293
+ * {
294
+ * href: "/courses",
295
+ * label: "Courses",
296
+ * icon: <Book />,
297
+ * children: [
298
+ * { href: "/courses", label: "All courses" },
299
+ * { href: "/courses/categories", label: "Categories" },
300
+ * ],
301
+ * },
302
+ * ],
303
+ * },
304
+ * ]}
305
+ * />
306
+ */
307
+ export function NavSidebar({
308
+ sections,
309
+ currentPath,
310
+ brand,
311
+ footer,
312
+ collapsed: collapsedProp,
313
+ defaultCollapsed = false,
314
+ onCollapsedChange,
315
+ hideCollapseToggle,
316
+ expandedItems: expandedProp,
317
+ defaultExpandedItems,
318
+ onExpandedItemsChange,
319
+ LinkComponent = "a",
320
+ expandSidebarLabel = "Expand sidebar",
321
+ collapseSidebarLabel = "Collapse sidebar",
322
+ expandItemLabel = (label) => `Expand ${label}`,
323
+ collapseItemLabel = (label) => `Collapse ${label}`,
324
+ }: NavSidebarProps) {
325
+ // ─── Uncontrolled collapse state ──────────────────────────────────────
326
+ const [collapsedInternal, setCollapsedInternal] = React.useState(defaultCollapsed)
327
+ const collapsed = collapsedProp ?? collapsedInternal
328
+ const setCollapsed = (next: boolean) => {
329
+ if (collapsedProp === undefined) setCollapsedInternal(next)
330
+ onCollapsedChange?.(next)
331
+ }
332
+
333
+ // ─── Uncontrolled per-item open overrides ─────────────────────────────
334
+ const [overridesInternal, setOverridesInternal] = React.useState<Record<string, boolean>>(
335
+ defaultExpandedItems ?? {},
336
+ )
337
+ const overrides = expandedProp ?? overridesInternal
338
+ const setOverrides = (next: Record<string, boolean>) => {
339
+ if (expandedProp === undefined) setOverridesInternal(next)
340
+ onExpandedItemsChange?.(next)
341
+ }
342
+
343
+ const isItemOpenResolved = (item: NavItem): boolean => {
344
+ if (item.href in overrides) return overrides[item.href]!
345
+ return isItemOpenAuto(item, currentPath)
346
+ }
347
+
348
+ const toggleItem = (item: NavItem) => {
349
+ const current = isItemOpenResolved(item)
350
+ setOverrides({ ...overrides, [item.href]: !current })
351
+ }
352
+
353
+ // ─── Brand area ────────────────────────────────────────────────────────
354
+ // The collapse toggle used to live here as an icon button; it's now the
355
+ // floating circular button straddling the sidebar's edge (below), so the
356
+ // brand row just hides the logo when collapsed.
357
+ const brandSlot = collapsed ? null : (
358
+ <div className="ax:flex ax:items-center ax:w-full ax:overflow-hidden ax:gap-xs">{brand}</div>
359
+ )
360
+
361
+ // The wrapper needs its own explicit width matching `Sidebar`'s (rather than
362
+ // shrink-wrapping it) so the floating toggle's `end-0` anchor lands exactly
363
+ // on the sidebar's real edge instead of drifting with any ambiguity in the
364
+ // surrounding flex layout. Mirrors `Sidebar`'s own default width/collapsed
365
+ // width (16rem / 4rem) since `width` isn't exposed as a prop here.
366
+ const wrapperWidth = collapsed ? "4rem" : "16rem"
367
+
368
+ return (
369
+ <div className="ax:relative ax:h-full ax:shrink-0 ax:transition-[width] ax:duration-300" style={{ width: wrapperWidth }}>
370
+ <Sidebar
371
+ collapsed={collapsed}
372
+ brand={brandSlot}
373
+ footer={footer}
374
+ className="ax:bg-transparent ax:border-e-0"
375
+ >
376
+ {sections.map((section, sectionIdx) => (
377
+ <SidebarSection
378
+ key={section.label ?? sectionIdx}
379
+ label={section.label}
380
+ collapsed={collapsed}
381
+ className={sectionIdx === 0 ? undefined : "ax:border-t ax:border-border ax:mt-sm"}
382
+ >
383
+ {section.items.map((item) => {
384
+ const hasChildren = Boolean(item.children?.length)
385
+ const active = isItemActive(item.href, currentPath, item.exact)
386
+ const open = hasChildren ? isItemOpenResolved(item) : false
387
+ // When an item has children AND is active, the elevated white
388
+ // card wraps both the parent row and the submenu so the whole
389
+ // branch reads as one surface. When inactive, the wrapper is a
390
+ // transparent layout container.
391
+ const groupActiveCard = hasChildren && active
392
+
393
+ return (
394
+ <div
395
+ key={item.href}
396
+ className={cn(
397
+ "ax:flex ax:flex-col ax:gap-2xs ax:rounded-md ax:transition-colors",
398
+ groupActiveCard && "ax:bg-transparent",
399
+ )}
400
+ >
401
+ <NavRow
402
+ item={item}
403
+ active={active}
404
+ collapsed={collapsed}
405
+ open={open}
406
+ onToggle={() => toggleItem(item)}
407
+ LinkComponent={LinkComponent}
408
+ expandItemLabel={expandItemLabel}
409
+ collapseItemLabel={collapseItemLabel}
410
+ />
411
+ {!collapsed && hasChildren && open && (
412
+ <div className="ax:relative ax:ms-md ax:ps-sm ax:pb-sm ax:flex ax:flex-col ax:gap-2xs">
413
+ <span
414
+ aria-hidden="true"
415
+ className="ax:absolute ax:inset-y-0 ax:inset-s-0 ax:w-px ax:bg-border"
416
+ />
417
+ {item.children!.map((child) => (
418
+ <NavSubRow
419
+ key={child.href}
420
+ href={child.href}
421
+ label={child.label}
422
+ count={child.count}
423
+ active={isItemActive(child.href, currentPath)}
424
+ LinkComponent={LinkComponent}
425
+ />
426
+ ))}
427
+ </div>
428
+ )}
429
+ </div>
430
+ )
431
+ })}
432
+ </SidebarSection>
433
+ ))}
434
+ </Sidebar>
435
+
436
+ {/* Floating collapse/expand toggle - vertically centred, straddling
437
+ the border between the sidebar and the main container. */}
438
+ {!hideCollapseToggle && (
439
+ <button
440
+ type="button"
441
+ onClick={() => setCollapsed(!collapsed)}
442
+ aria-label={collapsed ? expandSidebarLabel : collapseSidebarLabel}
443
+ className={cn(
444
+ "ax:absolute ax:top-1/2 ax:inset-e-0 ax:z-20 ax:-translate-y-1/2 ax:translate-x-1/2 ax:rtl:-translate-x-1/2 ax:-mr-3.5",
445
+ "ax:flex ax:h-8 ax:w-6 ax:items-center ax:justify-center ax:rounded-sm",
446
+ "ax:border ax:border-border ax:bg-card ax:text-muted-foreground ax:shadow-xs",
447
+ "ax:transition-colors ax:hover:bg-muted ax:hover:text-foreground",
448
+ "ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring ax:focus-visible:ring-offset-2 ax:focus-visible:ring-offset-background",
449
+ )}
450
+ >
451
+ <ChevronLeft
452
+ aria-hidden="true"
453
+ className={cn(
454
+ "ax:size-4 ax:transition-transform",
455
+ collapsed ? "ax:rotate-180 ax:rtl:rotate-0" : "ax:rotate-0 ax:rtl:rotate-180",
456
+ )}
457
+ />
458
+ </button>
459
+ )}
460
+ </div>
461
+ )
462
+ }
@@ -0,0 +1,19 @@
1
+ import React from "react"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { NumberStepper } from "./number-stepper"
4
+
5
+ const meta: Meta<typeof NumberStepper> = {
6
+ title: "Components/NumberStepper",
7
+ component: NumberStepper,
8
+ tags: ["autodocs"],
9
+ args: {
10
+ defaultValue: 1,
11
+ min: 0,
12
+ max: 10,
13
+ },
14
+ }
15
+
16
+ export default meta
17
+ type Story = StoryObj<typeof NumberStepper>
18
+
19
+ export const Default: Story = {}
@@ -0,0 +1,158 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Minus, Plus } from "lucide-react"
5
+ import { Button } from "./button"
6
+ import { Input } from "./input"
7
+ import { cn } from "../lib/cn"
8
+
9
+ export type NumberStepperSize = "sm" | "md" | "lg"
10
+
11
+ export interface NumberStepperProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
12
+ value: number
13
+ onChange: (next: number) => void
14
+ /** Smallest value the stepper allows. Defaults to 1. */
15
+ min?: number
16
+ /** Largest value the stepper allows. Defaults to 999. */
17
+ max?: number
18
+ /** Increment / decrement amount. Defaults to 1. */
19
+ step?: number
20
+ /** Disables the whole control (both buttons + input). Per-bound disabling
21
+ * of the +/- buttons still happens automatically at min/max. */
22
+ disabled?: boolean
23
+ /** Visual density. Matches Button / Input size scale. */
24
+ size?: NumberStepperSize
25
+ /** Pass-through id for the central <input>, so a sibling <label htmlFor>
26
+ * binds correctly. */
27
+ id?: string
28
+ /** Accessible label for the wrapper. Set this when context isn't obvious
29
+ * from the surrounding label element. */
30
+ ariaLabel?: string
31
+ }
32
+
33
+ // Height token per size. Button's `size="icon"` is fixed at h-3xl (40px), so
34
+ // the button's height is overridden here to match the requested stepper size.
35
+ const HEIGHT: Record<NumberStepperSize, string> = {
36
+ sm: "ax:h-2xl", // 32px
37
+ md: "ax:h-3xl", // 40px (matches Button md / Input md)
38
+ lg: "ax:h-4xl", // 48px
39
+ }
40
+
41
+ const ICON_SIZE: Record<NumberStepperSize, string> = {
42
+ sm: "ax:size-3",
43
+ md: "ax:size-3.5",
44
+ lg: "ax:size-4",
45
+ }
46
+
47
+ /** Joined +/-/input control. The three segments split the parent width
48
+ * equally via `flex-1` so the control reads as a single unit at any
49
+ * container size. Negative 1px margins prevent the doubled border between
50
+ * segments; the focus ring on the input is elevated above its neighbours
51
+ * via z-index so it isn't clipped.
52
+ *
53
+ * The wrapper is `w-full`: constrain the parent (e.g. `max-w-sm`) for a
54
+ * more traditional, compact stepper; leave unconstrained to fill the row.
55
+ *
56
+ * Common uses: seat counts in request forms, quantity selectors, any
57
+ * bounded integer input where direct typing should remain possible. */
58
+ export const NumberStepper = React.forwardRef<HTMLDivElement, NumberStepperProps>(
59
+ (
60
+ {
61
+ value,
62
+ onChange,
63
+ min = 1,
64
+ max = 999,
65
+ step = 1,
66
+ disabled = false,
67
+ size = "md",
68
+ id,
69
+ ariaLabel = "Number",
70
+ className,
71
+ ...props
72
+ },
73
+ ref,
74
+ ) => {
75
+ // Local string buffer so the user can clear the field mid-edit, type
76
+ // multi-digit values that transit through the bounds, etc. We commit
77
+ // live ONLY when the typed string parses to a value already within
78
+ // bounds; out-of-range or empty strings stay as a draft until blur.
79
+ const [draft, setDraft] = React.useState(String(value))
80
+
81
+ // Sync the buffer when `value` changes from the outside (e.g. parent
82
+ // form state update). Avoids tearing between controlled prop and draft.
83
+ React.useEffect(() => {
84
+ setDraft(String(value))
85
+ }, [value])
86
+
87
+ const clamp = (n: number) => Math.min(max, Math.max(min, n))
88
+
89
+ const decrement = () => onChange(clamp(value - step))
90
+ const increment = () => onChange(clamp(value + step))
91
+
92
+ const handleType = (raw: string) => {
93
+ setDraft(raw)
94
+ const parsed = parseInt(raw, 10)
95
+ // Commit live only for clean, in-bounds integers, keeps disabled-state
96
+ // buttons accurate as the user types, without snapping mid-edit.
97
+ if (!Number.isNaN(parsed) && parsed >= min && parsed <= max && parsed !== value) {
98
+ onChange(parsed)
99
+ }
100
+ }
101
+
102
+ const handleBlur = () => {
103
+ const parsed = parseInt(draft, 10)
104
+ const next = Number.isNaN(parsed) ? min : clamp(parsed)
105
+ setDraft(String(next))
106
+ if (next !== value) onChange(next)
107
+ }
108
+
109
+ return (
110
+ <div
111
+ ref={ref}
112
+ className={cn("ax:flex ax:items-stretch ax:w-full", className)}
113
+ role="group"
114
+ aria-label={ariaLabel}
115
+ {...props}
116
+ >
117
+ <Button
118
+ variant="outline"
119
+ size="icon"
120
+ onClick={decrement}
121
+ disabled={disabled || value <= min}
122
+ aria-label="Decrease"
123
+ className={cn("ax:flex-1 ax:w-0 ax:rounded-e-none ax:-me-px ax:bg-neutral-weakest", HEIGHT[size])}
124
+ >
125
+ <Minus className={ICON_SIZE[size]} />
126
+ </Button>
127
+ <Input
128
+ id={id}
129
+ type="number"
130
+ inputMode="numeric"
131
+ min={min}
132
+ max={max}
133
+ step={step}
134
+ size={size}
135
+ value={draft}
136
+ onChange={(e) => handleType(e.target.value)}
137
+ onBlur={handleBlur}
138
+ disabled={disabled}
139
+ className={cn(
140
+ "ax:flex-1 ax:w-0 ax:text-center ax:rounded-none ax:focus-visible:relative ax:focus-visible:z-10",
141
+ "ax:[appearance:textfield] ax:[&::-webkit-outer-spin-button]:appearance-none ax:[&::-webkit-inner-spin-button]:appearance-none",
142
+ )}
143
+ />
144
+ <Button
145
+ variant="outline"
146
+ size="icon"
147
+ onClick={increment}
148
+ disabled={disabled || value >= max}
149
+ aria-label="Increase"
150
+ className={cn("ax:flex-1 ax:w-0 ax:rounded-s-none ax:-ms-px ax:bg-neutral-weakest", HEIGHT[size])}
151
+ >
152
+ <Plus className={ICON_SIZE[size]} />
153
+ </Button>
154
+ </div>
155
+ )
156
+ },
157
+ )
158
+ NumberStepper.displayName = "NumberStepper"
@@ -0,0 +1,25 @@
1
+ import React from "react"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { Button } from "./button"
4
+ import { PageHeader } from "./page-header"
5
+
6
+ const meta: Meta<typeof PageHeader> = {
7
+ title: "Components/PageHeader",
8
+ component: PageHeader,
9
+ tags: ["autodocs"],
10
+ args: {
11
+ title: "Students",
12
+ subtitle: "38 active learners",
13
+ children: (
14
+ <>
15
+ <Button variant="outline">Import CSV</Button>
16
+ <Button>Invite students</Button>
17
+ </>
18
+ ),
19
+ },
20
+ }
21
+
22
+ export default meta
23
+ type Story = StoryObj<typeof PageHeader>
24
+
25
+ export const Default: Story = {}