@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,301 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
5
+ import { cva } from "class-variance-authority"
6
+ import { cn } from "../lib/cn"
7
+
8
+ export type SegmentedControlSize = "sm" | "md" | "lg"
9
+
10
+ /** Colour theme for the whole control. Drives the track background (a weak
11
+ * `{variant}` tint) and the selected thumb's drop shadow (`{variant}-strong`),
12
+ * so the control picks up the brand's neutral, primary, or secondary hue. */
13
+ export type SegmentedControlVariant = "neutral" | "primary" | "secondary"
14
+
15
+ // ─── Context ──────────────────────────────────────────────────────────────────
16
+
17
+ interface SegmentedControlContextValue {
18
+ size: SegmentedControlSize
19
+ currentValue: string
20
+ variant: SegmentedControlVariant
21
+ registerItem: (value: string, el: HTMLButtonElement | null) => void
22
+ }
23
+
24
+ const SegmentedControlContext = React.createContext<SegmentedControlContextValue>({
25
+ size: "md",
26
+ currentValue: "",
27
+ variant: "neutral",
28
+ registerItem: () => {},
29
+ })
30
+
31
+ // ─── Variants ─────────────────────────────────────────────────────────────────
32
+
33
+ // Outer heights mirror Button/Input/Dropdown so a SegmentedControl sits at
34
+ // the same height as those primitives when placed side-by-side, and item
35
+ // padding mirrors Button so each item has identical type metrics to a
36
+ // standalone Button at the same size:
37
+ // sm → 32px (h-2xl) md → 40px (h-3xl) lg → 48px (h-4xl)
38
+ //
39
+ // No root inset: items go edge-to-edge so the total outer width matches the
40
+ // sum of equivalent standalone Buttons (no extra 2px frame).
41
+ //
42
+ // Uses --input-radius (not --radius) so the control stays squared in pill
43
+ // brands. Only Button/Badge should pick up the brand's button-pill setting.
44
+ const rootVariants = cva(
45
+ "ax:relative ax:inline-flex ax:items-stretch ax:rounded-(--input-radius)",
46
+ {
47
+ variants: {
48
+ size: {
49
+ sm: "ax:h-xl ax:gap-px",
50
+ md: "ax:h-2xl ax:gap-px",
51
+ lg: "ax:h-3xl ax:gap-px",
52
+ },
53
+ // Track fill tint per theme. Neutral keeps the established `bg-muted`;
54
+ // primary/secondary use a weak brand tint. Applied via compoundVariants so
55
+ // it's suppressed when `transparent`.
56
+ variant: {
57
+ neutral: "",
58
+ primary: "",
59
+ secondary: "",
60
+ },
61
+ // `transparent` drops the track fill, for placing the control on an
62
+ // already-tinted surface where the track tint would clash.
63
+ transparent: {
64
+ true: "ax:bg-transparent",
65
+ false: "",
66
+ },
67
+ },
68
+ compoundVariants: [
69
+ { variant: "neutral", transparent: false, class: "ax:bg-neutral-weakest" },
70
+ { variant: "primary", transparent: false, class: "ax:bg-primary-weakest" },
71
+ { variant: "secondary", transparent: false, class: "ax:bg-secondary-weakest" },
72
+ ],
73
+ defaultVariants: { size: "md", variant: "neutral", transparent: false },
74
+ }
75
+ )
76
+
77
+ const itemVariants = cva(
78
+ [
79
+ "ax:relative ax:z-10 ax:flex ax:items-center ax:justify-center ax:cursor-pointer ax:select-none ax:whitespace-nowrap",
80
+ "ax:rounded-(--input-radius)",
81
+ "ax:transition-colors ax:transition-snappy",
82
+ "ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring ax:focus-visible:ring-offset-1 ax:focus-visible:ring-offset-muted",
83
+ "ax:disabled:cursor-not-allowed ax:disabled:opacity-50",
84
+ ],
85
+ {
86
+ variants: {
87
+ size: {
88
+ sm: "ax:px-sm ax:t-label-xs",
89
+ md: "ax:px-md ax:t-label-sm",
90
+ lg: "ax:px-xl ax:t-label-md",
91
+ },
92
+ },
93
+ defaultVariants: { size: "md" },
94
+ }
95
+ )
96
+
97
+ // Colours the floating indicator's shadow per theme. The default `--shadow-sm`
98
+ // token bakes its colour in at ~4% alpha, so simply retinting it reads as a
99
+ // plain neutral shadow. Instead we rebuild the token's diffuse layer falloff
100
+ // tinted with the raw `{variant}-strong` triplet (which auto-inverts in dark
101
+ // mode) at a visible alpha, so the selected thumb's lift clearly carries the hue.
102
+ const tintedShadow = (colorVar: string): string =>
103
+ [
104
+ `hsl(${colorVar} / 0.10) 0px 0px 0px 1px`,
105
+ `hsl(${colorVar} / 0.20) 0px 0.5625px 0.5625px -0.28125px`,
106
+ `hsl(${colorVar} / 0.20) 0px 1.6875px 1.6875px -0.84375px`,
107
+ `hsl(${colorVar} / 0.18) 0px 3.375px 3.375px -1.6875px`,
108
+ `hsl(${colorVar} / 0.16) 0px 6.75px 6.75px -3.375px`,
109
+ `hsl(${colorVar} / 0.14) 0px 13.5px 13.5px -6.75px`,
110
+ ].join(", ")
111
+
112
+ // Neutral keeps the default `--shadow-sm` token (unchanged subtle look, no
113
+ // regression for existing usages); branded variants get the tinted shadow.
114
+ const variantBoxShadow: Record<SegmentedControlVariant, string | undefined> = {
115
+ neutral: undefined,
116
+ primary: tintedShadow("var(--primary)"),
117
+ secondary: tintedShadow("var(--secondary)"),
118
+ }
119
+
120
+ // Selected item's label colour, following the control's variant: neutral reads
121
+ // as plain `foreground`; branded variants tint the label `{variant}-stronger`
122
+ // and bump the weight to reinforce the selection.
123
+ const activeLabelClass: Record<SegmentedControlVariant, string> = {
124
+ neutral: "ax:text-foreground",
125
+ primary: "ax:text-primary-foreground ax:font-medium",
126
+ secondary: "ax:text-secondary-foreground ax:font-medium",
127
+ }
128
+
129
+ // Unselected items' label colour, following the control's variant: the
130
+ // `{variant}-strong` hue held back at half opacity, lifting toward full on
131
+ // hover so the option still reads as interactive.
132
+ const inactiveLabelClass: Record<SegmentedControlVariant, string> = {
133
+ neutral: "ax:text-muted-foreground ax:hover:opacity-75",
134
+ primary: "ax:text-primary-foreground ax:hover:opacity-85",
135
+ secondary: "ax:text-secondary-foreground ax:hover:opacity-85",
136
+ }
137
+
138
+ // ─── Root ─────────────────────────────────────────────────────────────────────
139
+
140
+ export interface SegmentedControlProps
141
+ extends Omit<
142
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>,
143
+ "orientation"
144
+ > {
145
+ size?: SegmentedControlSize
146
+ /** When true, the control stretches to fill its container and each item
147
+ * takes an equal share of the width. Use for view toggles embedded in
148
+ * toolbars, modal footers, or any layout where the control should fill
149
+ * the available space rather than hug its content. */
150
+ fullWidth?: boolean
151
+ /** When true, the track background is transparent (drops the `bg-muted` fill).
152
+ * Use when the control sits on an already-tinted surface. */
153
+ transparent?: boolean
154
+ /** Colour theme for the whole control: tints the track background (weak
155
+ * `{variant}`), the selected thumb's shadow (`{variant}-strong`), and the
156
+ * selected item's label (`{variant}-stronger`). Defaults to `neutral`. */
157
+ variant?: SegmentedControlVariant
158
+ }
159
+
160
+ export const SegmentedControl = React.forwardRef<
161
+ React.ElementRef<typeof RadioGroupPrimitive.Root>,
162
+ SegmentedControlProps
163
+ >(({ className, size = "md", fullWidth = false, transparent = false, variant = "neutral", value, defaultValue, onValueChange, children, ...props }, ref) => {
164
+ const rootRef = React.useRef<HTMLDivElement>(null)
165
+ const itemRefs = React.useRef(new Map<string, HTMLButtonElement>())
166
+ const positioned = React.useRef(false)
167
+
168
+ const [internalValue, setInternalValue] = React.useState<string>(
169
+ value ?? defaultValue ?? ""
170
+ )
171
+ const currentValue = value ?? internalValue
172
+
173
+ const [indicatorStyle, setIndicatorStyle] = React.useState<React.CSSProperties>({ opacity: 0 })
174
+
175
+ const updateIndicator = React.useCallback((val: string) => {
176
+ const el = itemRefs.current.get(val)
177
+ const root = rootRef.current
178
+ if (!el || !root) return
179
+
180
+ const rootRect = root.getBoundingClientRect()
181
+ const itemRect = el.getBoundingClientRect()
182
+
183
+ setIndicatorStyle({
184
+ left: itemRect.left - rootRect.left,
185
+ width: itemRect.width,
186
+ top: itemRect.top - rootRect.top,
187
+ height: itemRect.height,
188
+ opacity: 1,
189
+ transition: positioned.current ? "left 150ms ease, width 150ms ease" : undefined,
190
+ })
191
+ positioned.current = true
192
+ }, [])
193
+
194
+ // Reposition when value or children change.
195
+ React.useLayoutEffect(() => {
196
+ updateIndicator(currentValue)
197
+ }, [currentValue, updateIndicator])
198
+
199
+ // Reposition on container resize (theme panel open/close shifts layout).
200
+ React.useEffect(() => {
201
+ const root = rootRef.current
202
+ if (!root) return
203
+ const observer = new ResizeObserver(() => updateIndicator(currentValue))
204
+ observer.observe(root)
205
+ return () => observer.disconnect()
206
+ }, [currentValue, updateIndicator])
207
+
208
+ const handleValueChange = React.useCallback(
209
+ (val: string) => {
210
+ setInternalValue(val)
211
+ onValueChange?.(val)
212
+ },
213
+ [onValueChange]
214
+ )
215
+
216
+ const registerItem = React.useCallback(
217
+ (val: string, el: HTMLButtonElement | null) => {
218
+ if (el) itemRefs.current.set(val, el)
219
+ else itemRefs.current.delete(val)
220
+ },
221
+ []
222
+ )
223
+
224
+ // Merge the forwarded ref with our local root ref.
225
+ const mergedRef = React.useCallback(
226
+ (node: HTMLDivElement | null) => {
227
+ (rootRef as React.MutableRefObject<HTMLDivElement | null>).current = node
228
+ if (typeof ref === "function") ref(node)
229
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
230
+ },
231
+ [ref]
232
+ )
233
+
234
+ return (
235
+ <SegmentedControlContext.Provider value={{ size, currentValue, variant, registerItem }}>
236
+ <RadioGroupPrimitive.Root
237
+ ref={mergedRef}
238
+ orientation="horizontal"
239
+ value={currentValue}
240
+ onValueChange={handleValueChange}
241
+ className={cn(
242
+ rootVariants({ size, variant, transparent }),
243
+ fullWidth && "ax:flex ax:w-full *:flex-1",
244
+ className
245
+ )}
246
+ {...props}
247
+ >
248
+ {/* Floating indicator - slides behind the active item */}
249
+ <div
250
+ aria-hidden="true"
251
+ // Dark mode: a raised neutral-stronger thumb, lighter than the `bg-muted`
252
+ // track, so the selection pops off the track (mirrors the light-mode
253
+ // white-on-grey lift) consistently across brands. Light stays --card.
254
+ className="ax:absolute ax:bg-card ax:shadow-sm ax:rounded-(--input-radius) ax:pointer-events-none"
255
+ style={{ ...indicatorStyle, boxShadow: variantBoxShadow[variant] }}
256
+ />
257
+ {children}
258
+ </RadioGroupPrimitive.Root>
259
+ </SegmentedControlContext.Provider>
260
+ )
261
+ })
262
+ SegmentedControl.displayName = "SegmentedControl"
263
+
264
+ // ─── Item ─────────────────────────────────────────────────────────────────────
265
+
266
+ export interface SegmentedControlItemProps
267
+ extends React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> {}
268
+
269
+ export const SegmentedControlItem = React.forwardRef<
270
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
271
+ SegmentedControlItemProps
272
+ >(({ className, value, children, ...props }, ref) => {
273
+ const { size, currentValue, variant, registerItem } = React.useContext(SegmentedControlContext)
274
+ const isActive = currentValue === value
275
+ const localRef = React.useRef<HTMLButtonElement>(null)
276
+
277
+ React.useImperativeHandle(ref, () => localRef.current!)
278
+
279
+ React.useLayoutEffect(() => {
280
+ registerItem(value, localRef.current)
281
+ return () => registerItem(value, null)
282
+ }, [value, registerItem])
283
+
284
+ return (
285
+ <RadioGroupPrimitive.Item
286
+ ref={localRef}
287
+ value={value}
288
+ className={cn(
289
+ itemVariants({ size }),
290
+ isActive
291
+ ? activeLabelClass[variant]
292
+ : inactiveLabelClass[variant],
293
+ className
294
+ )}
295
+ {...props}
296
+ >
297
+ {children}
298
+ </RadioGroupPrimitive.Item>
299
+ )
300
+ })
301
+ SegmentedControlItem.displayName = "SegmentedControlItem"
@@ -0,0 +1,27 @@
1
+ import React from "react"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { SelectButton } from "./select-button"
4
+ import { SelectItem } from "./select"
5
+
6
+ const meta: Meta<typeof SelectButton> = {
7
+ title: "Components/SelectButton",
8
+ component: SelectButton,
9
+ tags: ["autodocs"],
10
+ args: {
11
+ defaultValue: "all",
12
+ placeholder: "Choose status",
13
+ buttonLabel: "Apply",
14
+ children: (
15
+ <>
16
+ <SelectItem value="all">All statuses</SelectItem>
17
+ <SelectItem value="active">Active</SelectItem>
18
+ <SelectItem value="archived">Archived</SelectItem>
19
+ </>
20
+ ),
21
+ },
22
+ }
23
+
24
+ export default meta
25
+ type Story = StoryObj<typeof SelectButton>
26
+
27
+ export const Default: Story = {}
@@ -0,0 +1,174 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as SelectPrimitive from "@radix-ui/react-select"
5
+ import { ChevronDown } from "lucide-react"
6
+ import { Button, type ButtonProps } from "./button"
7
+ import { SelectContent } from "./select"
8
+ import { cn } from "../lib/cn"
9
+
10
+ /**
11
+ * SelectButton - a Select + Button compactly joined into a single inline group.
12
+ *
13
+ * The two halves share the same height (sm/md/lg align with Button, Input,
14
+ * and Dropdown so they line up when placed side-by-side). The Select trigger
15
+ * occupies the left half; the Button occupies the right half and accepts any
16
+ * Button `variant`. SelectItem children render inside the Select's popover.
17
+ *
18
+ * <SelectButton
19
+ * size="md"
20
+ * variant="primary"
21
+ * placeholder="Choose status"
22
+ * value={value}
23
+ * onValueChange={setValue}
24
+ * buttonLabel="Apply"
25
+ * onButtonClick={handleApply}
26
+ * >
27
+ * <SelectItem value="active">Active</SelectItem>
28
+ * <SelectItem value="archived">Archived</SelectItem>
29
+ * </SelectButton>
30
+ */
31
+
32
+ export type SelectButtonSize = "sm" | "md" | "lg"
33
+
34
+ /**
35
+ * Variant whitelist for the right-hand action button.
36
+ * Excludes Button variants that don't fit the joined-pill composite:
37
+ * ghost - transparent background reads as "no button"
38
+ * accent - wrong semantics for a commit action
39
+ * destructive - wrong semantics for a select+commit toolbar
40
+ * link - underline-on-hover text breaks the joined-pill shape
41
+ */
42
+ export type SelectButtonVariant = Exclude<NonNullable<ButtonProps["variant"]>, "ghost" | "accent" | "destructive">
43
+
44
+
45
+ const TRIGGER_HEIGHT: Record<SelectButtonSize, string> = {
46
+ sm: "ax:h-2xl", // 32px
47
+ md: "ax:h-3xl", // 40px
48
+ lg: "ax:h-4xl", // 48px
49
+ }
50
+
51
+ // Mirrors the standalone Select / Input trigger scale so a SelectButton's
52
+ // left half has identical horizontal padding + type metrics to a standalone
53
+ // Select at the same size.
54
+ const TRIGGER_PADDING_X: Record<SelectButtonSize, string> = {
55
+ sm: "ax:px-sm",
56
+ md: "ax:px-md",
57
+ lg: "ax:px-xl",
58
+ }
59
+
60
+ const TRIGGER_TEXT: Record<SelectButtonSize, string> = {
61
+ sm: "ax:t-label-sm",
62
+ md: "ax:t-label-sm",
63
+ lg: "ax:text-base",
64
+ }
65
+
66
+ export interface SelectButtonProps {
67
+ /** Shared height/typography ladder - matches Button, Input, Dropdown. */
68
+ size?: SelectButtonSize
69
+ /** Visual treatment of the right-hand action button. */
70
+ variant?: SelectButtonVariant
71
+
72
+ // ─── Select half ────────────────────────────────────────────────────────────
73
+ value?: string
74
+ defaultValue?: string
75
+ onValueChange?: (value: string) => void
76
+ placeholder?: string
77
+ /** Disables BOTH halves. Use `buttonDisabled` to disable only the button. */
78
+ disabled?: boolean
79
+ name?: string
80
+ /** SelectItem (and SelectLabel/SelectSeparator) children for the popover. */
81
+ children: React.ReactNode
82
+
83
+ // ─── Button half ────────────────────────────────────────────────────────────
84
+ buttonLabel: React.ReactNode
85
+ onButtonClick?: React.MouseEventHandler<HTMLButtonElement>
86
+ buttonDisabled?: boolean
87
+ buttonType?: "button" | "submit" | "reset"
88
+ /** Extra className appended to the inner Button. */
89
+ buttonClassName?: string
90
+
91
+ className?: string
92
+ }
93
+
94
+ export const SelectButton = React.forwardRef<HTMLDivElement, SelectButtonProps>(
95
+ (
96
+ {
97
+ size = "md",
98
+ variant = "primary",
99
+ value,
100
+ defaultValue,
101
+ onValueChange,
102
+ placeholder,
103
+ disabled,
104
+ name,
105
+ children,
106
+ buttonLabel,
107
+ onButtonClick,
108
+ buttonDisabled,
109
+ buttonType = "button",
110
+ buttonClassName,
111
+ className,
112
+ },
113
+ ref
114
+ ) => (
115
+ <div
116
+ ref={ref}
117
+ data-slot="select-button"
118
+ className={cn(
119
+ "ax:inline-flex ax:items-stretch ax:isolate",
120
+ disabled && "ax:opacity-50 ax:pointer-events-none",
121
+ className
122
+ )}
123
+ >
124
+ <SelectPrimitive.Root
125
+ value={value}
126
+ defaultValue={defaultValue}
127
+ onValueChange={onValueChange}
128
+ disabled={disabled}
129
+ name={name}
130
+ >
131
+ <SelectPrimitive.Trigger
132
+ className={cn(
133
+ "ax:inline-flex ax:items-center ax:justify-between ax:gap-xs ax:whitespace-nowrap ax:cursor-pointer ax:select-none",
134
+ "ax:bg-transparent ax:text-foreground",
135
+ "ax:border ax:border-input ax:rounded-l-var(--input-radius) ax:rounded-r-none ax:-mr-px",
136
+ "ax:transition-[border-color,box-shadow] ax:transition-snappy",
137
+ "ax:focus:outline-none ax:focus:z-10 ax:focus:ring-2 ax:focus:ring-ring ax:focus:ring-offset-1 ax:focus:ring-offset-background ax:focus:border-transparent",
138
+ "ax:disabled:cursor-not-allowed ax:disabled:opacity-50",
139
+ "ax:data-placeholder:text-muted-foreground",
140
+ // leading-snug on the value span: t-label-sm is leading-none, and line-clamp-1
141
+ // sets overflow-hidden: a 1.0 line-height would shave descenders (g, y, p).
142
+ "ax:[&>span]:line-clamp-1 ax:[&>span]:leading-snug",
143
+ TRIGGER_HEIGHT[size],
144
+ TRIGGER_PADDING_X[size],
145
+ TRIGGER_TEXT[size]
146
+ )}
147
+ >
148
+ <SelectPrimitive.Value placeholder={placeholder} />
149
+ <SelectPrimitive.Icon asChild>
150
+ <ChevronDown className="ax:size-4 ax:shrink-0 ax:text-muted-foreground" />
151
+ </SelectPrimitive.Icon>
152
+ </SelectPrimitive.Trigger>
153
+ <SelectContent>{children}</SelectContent>
154
+ </SelectPrimitive.Root>
155
+ <Button
156
+ type={buttonType}
157
+ size={size}
158
+ variant={variant}
159
+ onClick={onButtonClick}
160
+ disabled={buttonDisabled || disabled}
161
+ className={cn(
162
+ // Use --input-radius (not --radius) on the right corner so the
163
+ // composite control stays squared in pill brands. Only the
164
+ // Button/Badge tokens should pick up the brand button-pill.
165
+ "ax:rounded-l-none ax:rounded-r-[var(--input-radius)] ax:focus-visible:z-10",
166
+ buttonClassName
167
+ )}
168
+ >
169
+ {buttonLabel}
170
+ </Button>
171
+ </div>
172
+ )
173
+ )
174
+ SelectButton.displayName = "SelectButton"
@@ -0,0 +1,27 @@
1
+ import React from "react"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"
4
+
5
+ const meta: Meta<typeof Select> = {
6
+ title: "Components/Select",
7
+ component: Select,
8
+ tags: ["autodocs"],
9
+ }
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof Select>
13
+
14
+ export const Default: Story = {
15
+ render: () => (
16
+ <Select defaultValue="apple">
17
+ <SelectTrigger className="ax:w-[200px]">
18
+ <SelectValue placeholder="Select a fruit" />
19
+ </SelectTrigger>
20
+ <SelectContent>
21
+ <SelectItem value="apple">Apple</SelectItem>
22
+ <SelectItem value="banana">Banana</SelectItem>
23
+ <SelectItem value="orange">Orange</SelectItem>
24
+ </SelectContent>
25
+ </Select>
26
+ ),
27
+ }