banhaten 0.1.3 → 0.3.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 (275) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/CONTRIBUTING.md +44 -0
  3. package/README.md +65 -13
  4. package/SECURITY.md +15 -0
  5. package/banhaten.config.example.json +0 -2
  6. package/docs/design-system/README.md +9 -0
  7. package/docs/design-system/adoption-quality-remediation.md +87 -0
  8. package/docs/design-system/agent-usage.md +13 -0
  9. package/docs/design-system/appearance-presets.md +6 -1
  10. package/docs/design-system/component-docs-pattern.md +81 -0
  11. package/docs/design-system/component-implementation-ownership-audit.md +244 -0
  12. package/docs/design-system/component-quality-audit-2026-08-20.md +223 -0
  13. package/docs/design-system/component-showcase-consistency-report.md +2 -0
  14. package/docs/design-system/component-token-consistency-audit.md +13 -3
  15. package/docs/design-system/components/README.md +4 -2
  16. package/docs/design-system/components/accordion.md +16 -0
  17. package/docs/design-system/components/activity-feed.md +17 -1
  18. package/docs/design-system/components/activity-heatmap.md +73 -0
  19. package/docs/design-system/components/alert-dialog.md +17 -0
  20. package/docs/design-system/components/alert.md +46 -14
  21. package/docs/design-system/components/aspect-ratio.md +15 -0
  22. package/docs/design-system/components/attribute.md +16 -0
  23. package/docs/design-system/components/autocomplete.md +19 -0
  24. package/docs/design-system/components/avatar.md +18 -0
  25. package/docs/design-system/components/badge.md +19 -0
  26. package/docs/design-system/components/banner.md +19 -1
  27. package/docs/design-system/components/breadcrumbs.md +17 -0
  28. package/docs/design-system/components/button-group.md +35 -0
  29. package/docs/design-system/components/button.md +18 -0
  30. package/docs/design-system/components/card.md +26 -1
  31. package/docs/design-system/components/carousel.md +20 -3
  32. package/docs/design-system/components/catalog-components.md +18 -1
  33. package/docs/design-system/components/chart.md +15 -0
  34. package/docs/design-system/components/checkbox.md +16 -0
  35. package/docs/design-system/components/collapsible.md +15 -0
  36. package/docs/design-system/components/command.md +15 -0
  37. package/docs/design-system/components/context-menu.md +15 -0
  38. package/docs/design-system/components/date-picker.md +21 -1
  39. package/docs/design-system/components/divider.md +16 -0
  40. package/docs/design-system/components/empty-state.md +17 -0
  41. package/docs/design-system/components/field.md +17 -0
  42. package/docs/design-system/components/file-upload.md +18 -0
  43. package/docs/design-system/components/hover-card.md +15 -0
  44. package/docs/design-system/components/input-group.md +17 -0
  45. package/docs/design-system/components/input-otp.md +18 -0
  46. package/docs/design-system/components/input.md +43 -7
  47. package/docs/design-system/components/item.md +72 -0
  48. package/docs/design-system/components/kbd.md +18 -0
  49. package/docs/design-system/components/label.md +16 -0
  50. package/docs/design-system/components/menu.md +17 -0
  51. package/docs/design-system/components/menubar.md +15 -0
  52. package/docs/design-system/components/modal.md +25 -4
  53. package/docs/design-system/components/native-select.md +18 -0
  54. package/docs/design-system/components/navigation-menu.md +15 -0
  55. package/docs/design-system/components/onboarding-step-list-item.md +16 -1
  56. package/docs/design-system/components/page-header.md +17 -1
  57. package/docs/design-system/components/pagination.md +31 -8
  58. package/docs/design-system/components/popover.md +19 -0
  59. package/docs/design-system/components/progress-slider.md +18 -0
  60. package/docs/design-system/components/progress.md +17 -0
  61. package/docs/design-system/components/radio-card.md +16 -0
  62. package/docs/design-system/components/radio-group.md +17 -0
  63. package/docs/design-system/components/resizable.md +15 -0
  64. package/docs/design-system/components/scroll-area.md +20 -1
  65. package/docs/design-system/components/segmented-control.md +71 -0
  66. package/docs/design-system/components/select.md +44 -7
  67. package/docs/design-system/components/sheet.md +17 -0
  68. package/docs/design-system/components/sidebar.md +17 -0
  69. package/docs/design-system/components/skeleton.md +18 -0
  70. package/docs/design-system/components/slideout.md +17 -0
  71. package/docs/design-system/components/slider.md +20 -0
  72. package/docs/design-system/components/social-button.md +21 -3
  73. package/docs/design-system/components/spinner.md +16 -0
  74. package/docs/design-system/components/steps.md +19 -0
  75. package/docs/design-system/components/table.md +36 -4
  76. package/docs/design-system/components/tabs.md +17 -0
  77. package/docs/design-system/components/tag.md +17 -0
  78. package/docs/design-system/components/textarea.md +16 -0
  79. package/docs/design-system/components/timeline.md +17 -0
  80. package/docs/design-system/components/toast.md +17 -0
  81. package/docs/design-system/components/toggle.md +17 -0
  82. package/docs/design-system/components/toolbar.md +21 -1
  83. package/docs/design-system/components/tooltip.md +18 -1
  84. package/docs/design-system/density-and-elevation.md +61 -0
  85. package/docs/design-system/direction-and-localization.md +23 -0
  86. package/docs/design-system/form-recipes.md +73 -0
  87. package/docs/design-system/gap-promotion-policy.md +21 -0
  88. package/docs/design-system/gap-promotion-report.md +100 -0
  89. package/docs/design-system/installation.md +68 -0
  90. package/docs/design-system/launch-comparison-shadcn.md +98 -0
  91. package/docs/design-system/registry-and-cli.md +30 -7
  92. package/docs/design-system/release-policy.md +30 -0
  93. package/docs/design-system/roadmap.md +14 -0
  94. package/docs/design-system/support-matrix.md +23 -0
  95. package/docs/design-system/tabs.md +1 -1
  96. package/docs/design-system/token-governance.md +23 -1
  97. package/package.json +30 -15
  98. package/registry/component-contract.json +40 -0
  99. package/registry/components/alert-dialog.tsx +2 -2
  100. package/registry/components/alert.tsx +101 -76
  101. package/registry/components/autocomplete.tsx +23 -9
  102. package/registry/components/avatar.tsx +14 -10
  103. package/registry/components/badge.tsx +18 -18
  104. package/registry/components/button-group.tsx +324 -5
  105. package/registry/components/button.tsx +38 -17
  106. package/registry/components/card.tsx +87 -62
  107. package/registry/components/carousel.tsx +67 -5
  108. package/registry/components/chart.tsx +36 -3
  109. package/registry/components/checkbox.tsx +3 -3
  110. package/registry/components/collapsible.tsx +1 -1
  111. package/registry/components/command.tsx +38 -4
  112. package/registry/components/context-menu.tsx +1 -1
  113. package/registry/components/date-picker.tsx +312 -135
  114. package/registry/components/expanded/ActivityFeed.tsx +9 -22
  115. package/registry/components/expanded/ActivityHeatmap.tsx +64 -0
  116. package/registry/components/expanded/Banner.tsx +3 -4
  117. package/registry/components/expanded/Breadcrumbs.tsx +8 -5
  118. package/registry/components/expanded/CatalogComponentsShowcase.tsx +16 -17
  119. package/registry/components/expanded/CatalogDivider.tsx +4 -3
  120. package/registry/components/expanded/EmptyState.tsx +1 -1
  121. package/registry/components/expanded/FileUpload.tsx +16 -7
  122. package/registry/components/expanded/OnboardingStepListItem.tsx +2 -6
  123. package/registry/components/expanded/PageHeader.tsx +47 -56
  124. package/registry/components/expanded/Slideout.tsx +29 -424
  125. package/registry/components/expanded/Steps.tsx +151 -13
  126. package/registry/components/expanded/Table.tsx +161 -82
  127. package/registry/components/expanded/Timeline.tsx +14 -27
  128. package/registry/components/expanded/activityFeed.css +9 -9
  129. package/registry/components/expanded/activityHeatmap.css +66 -0
  130. package/registry/components/expanded/banner.css +21 -11
  131. package/registry/components/expanded/breadcrumbs.css +2 -2
  132. package/registry/components/expanded/divider.css +1 -1
  133. package/registry/components/expanded/emptyState.css +1 -1
  134. package/registry/components/expanded/fileUpload.css +7 -7
  135. package/registry/components/expanded/onboardingStepListItem.css +2 -2
  136. package/registry/components/expanded/pageHeader.css +14 -11
  137. package/registry/components/expanded/slideout.css +10 -690
  138. package/registry/components/expanded/steps.css +78 -15
  139. package/registry/components/expanded/table.css +60 -69
  140. package/registry/components/expanded/timeline.css +19 -18
  141. package/registry/components/field.tsx +61 -8
  142. package/registry/components/input-content.ts +1 -0
  143. package/registry/components/input-otp.tsx +13 -3
  144. package/registry/components/input.tsx +68 -43
  145. package/registry/components/item.tsx +189 -0
  146. package/registry/components/kbd.tsx +111 -4
  147. package/registry/components/menu.tsx +87 -251
  148. package/registry/components/menubar.tsx +1 -1
  149. package/registry/components/modal.tsx +143 -103
  150. package/registry/components/native-select.tsx +2 -2
  151. package/registry/components/navigation-menu.tsx +1 -1
  152. package/registry/components/pagination.css +36 -0
  153. package/registry/components/pagination.tsx +88 -64
  154. package/registry/components/popover.tsx +162 -664
  155. package/registry/components/progress-slider.tsx +1 -1
  156. package/registry/components/radio-card.tsx +116 -87
  157. package/registry/components/resizable.tsx +36 -2
  158. package/registry/components/scroll-area.tsx +30 -6
  159. package/registry/components/segmented-control.tsx +87 -0
  160. package/registry/components/select-content.tsx +2 -2
  161. package/registry/components/select.tsx +86 -27
  162. package/registry/components/sheet.tsx +1 -1
  163. package/registry/components/sidebar.tsx +12 -6
  164. package/registry/components/skeleton.css +0 -3
  165. package/registry/components/social-button.tsx +59 -30
  166. package/registry/components/spinner.tsx +18 -55
  167. package/registry/components/table-elements.tsx +128 -0
  168. package/registry/components/tabs.css +1 -1
  169. package/registry/components/tabs.tsx +13 -14
  170. package/registry/components/tag.tsx +3 -3
  171. package/registry/components/textarea.tsx +3 -3
  172. package/registry/components/toast.tsx +16 -11
  173. package/registry/components/toggle.tsx +4 -4
  174. package/registry/components/toolbar.tsx +94 -22
  175. package/registry/components/tooltip.tsx +2 -2
  176. package/registry/examples/alert-demo.tsx +25 -5
  177. package/registry/examples/attribute-demo.tsx +1 -1
  178. package/registry/examples/autocomplete-demo.tsx +3 -3
  179. package/registry/examples/avatar-demo.tsx +14 -11
  180. package/registry/examples/badge-demo.tsx +1 -1
  181. package/registry/examples/button-demo.tsx +6 -6
  182. package/registry/examples/button-group-demo.tsx +23 -2
  183. package/registry/examples/card-demo.tsx +46 -8
  184. package/registry/examples/carousel-demo.tsx +20 -2
  185. package/registry/examples/chart-demo.tsx +1 -1
  186. package/registry/examples/checkbox-demo.tsx +5 -5
  187. package/registry/examples/collection-filter-bar-demo.tsx +135 -0
  188. package/registry/examples/command-demo.tsx +3 -3
  189. package/registry/examples/{expanded/command-bar-demo.tsx → command-palette-demo.tsx} +5 -15
  190. package/registry/examples/context-menu-demo.tsx +1 -1
  191. package/registry/examples/control-density-contract-demo.tsx +61 -0
  192. package/registry/examples/date-picker-demo.tsx +12 -12
  193. package/registry/examples/divider-demo.tsx +3 -3
  194. package/registry/examples/expanded/activity-heatmap-demo.tsx +29 -0
  195. package/registry/examples/expanded/banner-demo.tsx +1 -1
  196. package/registry/examples/expanded/breadcrumbs-demo.tsx +4 -4
  197. package/registry/examples/expanded/empty-state-demo.tsx +1 -1
  198. package/registry/examples/expanded/file-upload-demo.tsx +1 -1
  199. package/registry/examples/expanded/onboarding-step-demo.tsx +1 -1
  200. package/registry/examples/expanded/slideout-demo.tsx +2 -2
  201. package/registry/examples/expanded/steps-demo.tsx +1 -1
  202. package/registry/examples/expanded/tabs-demo.tsx +1 -1
  203. package/registry/examples/expanded/timeline-demo.tsx +1 -1
  204. package/registry/examples/hover-card-demo.tsx +2 -2
  205. package/registry/examples/input-demo.tsx +6 -6
  206. package/registry/examples/input-otp-demo.tsx +4 -4
  207. package/registry/examples/item-demo.tsx +46 -0
  208. package/registry/examples/kbd-demo.tsx +1 -1
  209. package/registry/examples/label-demo.tsx +1 -1
  210. package/registry/examples/menu-demo.tsx +1 -1
  211. package/registry/examples/modal-demo.tsx +67 -9
  212. package/registry/examples/navigation-menu-demo.tsx +1 -1
  213. package/registry/examples/pagination-demo.tsx +10 -7
  214. package/registry/examples/popover-demo.tsx +19 -19
  215. package/registry/examples/progress-demo.tsx +1 -1
  216. package/registry/examples/progress-slider-demo.tsx +1 -1
  217. package/registry/examples/radio-card-demo.tsx +4 -4
  218. package/registry/examples/radio-group-demo.tsx +2 -2
  219. package/registry/examples/resizable-demo.tsx +1 -1
  220. package/registry/examples/results-toolbar-demo.tsx +44 -0
  221. package/registry/examples/scroll-area-demo.tsx +3 -3
  222. package/registry/examples/segmented-control-demo.tsx +39 -0
  223. package/registry/examples/select-demo.tsx +1 -1
  224. package/registry/examples/sidebar-demo.tsx +5 -3
  225. package/registry/examples/slider-demo.tsx +3 -3
  226. package/registry/examples/social-button-demo.tsx +18 -8
  227. package/registry/examples/spinner-demo.tsx +13 -13
  228. package/registry/examples/tabs-demo.tsx +1 -1
  229. package/registry/examples/tag-demo.tsx +1 -1
  230. package/registry/examples/textarea-demo.tsx +4 -4
  231. package/registry/examples/toast-demo.tsx +1 -1
  232. package/registry/examples/toggle-demo.tsx +6 -6
  233. package/registry/examples/tooltip-demo.tsx +4 -4
  234. package/registry/guidance/USAGE.md +27 -0
  235. package/registry/hooks/use-direction.ts +193 -13
  236. package/registry/index.json +5668 -375
  237. package/registry/styles/globals.css +2404 -3999
  238. package/registry.json +156 -149
  239. package/schema/config.schema.json +11 -8
  240. package/schema/registry.schema.json +150 -45
  241. package/schema/tokens.schema.json +134 -34
  242. package/src/cli/command-line.js +194 -0
  243. package/src/cli/config-editors.js +9 -0
  244. package/src/cli/doctor.js +399 -0
  245. package/src/cli/file-transaction.js +295 -0
  246. package/src/cli/global-css.js +153 -0
  247. package/src/cli/index.js +567 -1613
  248. package/src/cli/install-plan.js +592 -0
  249. package/src/cli/install-receipt.js +171 -0
  250. package/src/cli/jsonc.js +203 -0
  251. package/src/cli/package-install-state.js +254 -0
  252. package/src/cli/package-manager.js +238 -0
  253. package/src/cli/package-plan.js +185 -0
  254. package/src/cli/package-version.js +97 -0
  255. package/src/cli/registry-query.js +207 -0
  256. package/src/cli/reporting.js +55 -0
  257. package/src/cli/schema-validation.js +19 -0
  258. package/src/cli/source-scanner.js +686 -0
  259. package/src/cli/vite-config.js +478 -0
  260. package/tokens/banhaten.tokens.json +4444 -331
  261. package/docs/design-system/components/command-bar.md +0 -57
  262. package/registry/components/expanded/BannerBoard.tsx +0 -225
  263. package/registry/components/expanded/CatalogTag.tsx +0 -85
  264. package/registry/components/expanded/CommandBar.tsx +0 -386
  265. package/registry/components/expanded/IconExplorer.tsx +0 -612
  266. package/registry/components/expanded/TimelineShowcase.tsx +0 -158
  267. package/registry/components/expanded/commandBar.css +0 -470
  268. package/registry/components/expanded/iconExplorer.css +0 -764
  269. package/registry/components/expanded/iconPacks.ts +0 -866
  270. package/registry/components/expanded/tag.css +0 -148
  271. package/registry/examples/expanded/icons-demo.tsx +0 -5
  272. package/tokens/Color mode.zip +0 -0
  273. package/tokens/Numbers.zip +0 -0
  274. package/tokens/Radius.zip +0 -0
  275. package/tokens/Theme.zip +0 -0
@@ -6,19 +6,33 @@ import { cn } from "@/lib/utils"
6
6
 
7
7
  type ButtonGroupSize = "md" | "sm" | "xs"
8
8
  type ButtonGroupMode = "default" | "iconOnly"
9
+ type ButtonGroupItemWidth = "fixed" | "content"
10
+ type ControlDensity = "compact" | "default" | "comfortable"
11
+ type ToggleGroupType = "single" | "multiple"
12
+ type ToggleGroupValue = string | string[]
9
13
 
10
14
  type ButtonGroupContextValue = {
15
+ itemWidth: ButtonGroupItemWidth
11
16
  size: ButtonGroupSize
12
17
  mode: ButtonGroupMode
13
18
  }
14
19
 
15
20
  const buttonGroupContext = React.createContext<ButtonGroupContextValue>({
21
+ itemWidth: "fixed",
16
22
  size: "md",
17
23
  mode: "default",
18
24
  })
19
25
 
26
+ type ToggleGroupContextValue = {
27
+ disabled: boolean
28
+ isPressed: (value: string) => boolean
29
+ toggle: (value: string) => void
30
+ }
31
+
32
+ const toggleGroupContext = React.createContext<ToggleGroupContextValue | null>(null)
33
+
20
34
  const buttonGroupVariants = cva(
21
- "inline-flex w-fit items-center overflow-hidden rounded-[var(--bh-button-group-radius)] bg-[var(--bh-button-group-bg)] text-[var(--bh-button-group-content)] shadow-[var(--shadow-button-group)] [--shadow-button-group:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-button-group-border),var(--shadow-component-default)]",
35
+ "inline-flex w-fit max-w-full items-center overflow-x-auto overflow-y-hidden rounded-[var(--bh-button-group-radius)] bg-[var(--bh-button-group-bg)] text-[var(--bh-button-group-content)] shadow-[var(--shadow-button-group)] outline-none focus-visible:shadow-[var(--shadow-button-group-focus)] [--shadow-button-group:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-button-group-border),var(--shadow-component-default)]",
22
36
  {
23
37
  variants: {
24
38
  size: {
@@ -46,26 +60,51 @@ const buttonGroupItemVariants = cva(
46
60
  default: "",
47
61
  iconOnly: "",
48
62
  },
63
+ itemWidth: {
64
+ fixed: "",
65
+ content: "",
66
+ },
49
67
  },
50
68
  compoundVariants: [
51
69
  {
52
70
  size: "md",
53
71
  mode: "default",
72
+ itemWidth: "fixed",
54
73
  class:
55
74
  "w-[var(--bh-button-group-md-item-width)] px-[var(--bh-button-group-md-padding-x)] rtl:w-[var(--bh-button-group-md-item-rtl-width)]",
56
75
  },
57
76
  {
58
77
  size: "sm",
59
78
  mode: "default",
79
+ itemWidth: "fixed",
60
80
  class:
61
81
  "w-[var(--bh-button-group-sm-item-width)] px-[var(--bh-button-group-sm-padding-x)] rtl:w-[var(--bh-button-group-sm-item-rtl-width)]",
62
82
  },
63
83
  {
64
84
  size: "xs",
65
85
  mode: "default",
86
+ itemWidth: "fixed",
66
87
  class:
67
88
  "w-[var(--bh-button-group-xs-item-width)] px-[var(--bh-button-group-xs-padding-x)] rtl:w-[var(--bh-button-group-xs-item-rtl-width)]",
68
89
  },
90
+ {
91
+ size: "md",
92
+ mode: "default",
93
+ itemWidth: "content",
94
+ class: "w-auto px-[var(--bh-button-group-md-padding-x)]",
95
+ },
96
+ {
97
+ size: "sm",
98
+ mode: "default",
99
+ itemWidth: "content",
100
+ class: "w-auto px-[var(--bh-button-group-sm-padding-x)]",
101
+ },
102
+ {
103
+ size: "xs",
104
+ mode: "default",
105
+ itemWidth: "content",
106
+ class: "w-auto px-[var(--bh-button-group-xs-padding-x)]",
107
+ },
69
108
  {
70
109
  size: "md",
71
110
  mode: "iconOnly",
@@ -88,39 +127,103 @@ const buttonGroupItemVariants = cva(
88
127
  defaultVariants: {
89
128
  size: "md",
90
129
  mode: "default",
130
+ itemWidth: "fixed",
131
+ },
132
+ }
133
+ )
134
+
135
+ const buttonGroupLabelVariants = cva(
136
+ "relative inline-flex shrink-0 items-center justify-center whitespace-nowrap bg-[var(--bh-button-group-item-bg)] text-[length:var(--bh-text-button-font-size)] font-[var(--bh-text-button-font-weight)] leading-[var(--bh-text-button-line-height)] tracking-[var(--bh-text-button-letter-spacing)] text-[var(--bh-button-group-content)] before:absolute before:inset-y-0 before:start-0 before:w-[var(--bh-border-width-default)] before:bg-[var(--bh-button-group-divider)] before:content-[''] first:before:hidden",
137
+ {
138
+ variants: {
139
+ size: {
140
+ md: "h-[var(--bh-button-group-md-height)]",
141
+ sm: "h-[var(--bh-button-group-sm-height)]",
142
+ xs: "h-[var(--bh-button-group-xs-height)]",
143
+ },
144
+ itemWidth: {
145
+ fixed: "",
146
+ content: "",
147
+ },
148
+ },
149
+ compoundVariants: [
150
+ {
151
+ size: "md",
152
+ itemWidth: "fixed",
153
+ class: "w-[var(--bh-button-group-md-item-width)] px-[var(--bh-button-group-md-padding-x)] rtl:w-[var(--bh-button-group-md-item-rtl-width)]",
154
+ },
155
+ {
156
+ size: "sm",
157
+ itemWidth: "fixed",
158
+ class: "w-[var(--bh-button-group-sm-item-width)] px-[var(--bh-button-group-sm-padding-x)] rtl:w-[var(--bh-button-group-sm-item-rtl-width)]",
159
+ },
160
+ {
161
+ size: "xs",
162
+ itemWidth: "fixed",
163
+ class: "w-[var(--bh-button-group-xs-item-width)] px-[var(--bh-button-group-xs-padding-x)] rtl:w-[var(--bh-button-group-xs-item-rtl-width)]",
164
+ },
165
+ {
166
+ size: "md",
167
+ itemWidth: "content",
168
+ class: "w-auto px-[var(--bh-button-group-md-padding-x)]",
169
+ },
170
+ {
171
+ size: "sm",
172
+ itemWidth: "content",
173
+ class: "w-auto px-[var(--bh-button-group-sm-padding-x)]",
174
+ },
175
+ {
176
+ size: "xs",
177
+ itemWidth: "content",
178
+ class: "w-auto px-[var(--bh-button-group-xs-padding-x)]",
179
+ },
180
+ ],
181
+ defaultVariants: {
182
+ itemWidth: "content",
183
+ size: "sm",
91
184
  },
92
185
  }
93
186
  )
94
187
 
95
188
  type ButtonGroupProps = React.ComponentProps<"div"> &
96
189
  VariantProps<typeof buttonGroupVariants> & {
190
+ density?: ControlDensity
191
+ itemWidth?: ButtonGroupItemWidth
97
192
  mode?: ButtonGroupMode
98
193
  }
99
194
 
100
195
  const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(function ButtonGroup({
101
196
  className,
102
- size = "md",
197
+ density,
198
+ itemWidth = "fixed",
199
+ size,
103
200
  mode = "default",
104
201
  children,
105
202
  role = "group",
203
+ tabIndex = 0,
106
204
  ...props
107
205
  }, ref) {
206
+ const resolvedSize = resolveButtonGroupSize(density, size)
108
207
  const contextValue = React.useMemo(
109
208
  () => ({
110
- size: (size || "md") as ButtonGroupSize,
209
+ itemWidth,
210
+ size: resolvedSize,
111
211
  mode: mode || "default",
112
212
  }),
113
- [size, mode]
213
+ [itemWidth, mode, resolvedSize]
114
214
  )
115
215
 
116
216
  return (
117
217
  <buttonGroupContext.Provider value={contextValue}>
118
218
  <div
119
219
  data-mode={contextValue.mode}
220
+ data-density={density}
221
+ data-item-width={contextValue.itemWidth}
120
222
  data-size={contextValue.size}
121
223
  data-slot="button-group"
122
224
  ref={ref}
123
225
  role={role}
226
+ tabIndex={tabIndex}
124
227
  className={cn(buttonGroupVariants({ size: contextValue.size }), className)}
125
228
  {...props}
126
229
  >
@@ -138,6 +241,7 @@ type ButtonGroupItemProps = React.ComponentProps<"button"> &
138
241
  const ButtonGroupItem = React.forwardRef<HTMLButtonElement, ButtonGroupItemProps>(function ButtonGroupItem({
139
242
  className,
140
243
  size,
244
+ itemWidth,
141
245
  mode,
142
246
  asChild = false,
143
247
  children,
@@ -151,6 +255,7 @@ const ButtonGroupItem = React.forwardRef<HTMLButtonElement, ButtonGroupItemProps
151
255
  const context = React.useContext(buttonGroupContext)
152
256
  const selectedSize = (size || context.size) as ButtonGroupSize
153
257
  const selectedMode = mode || context.mode
258
+ const selectedItemWidth = itemWidth || context.itemWidth
154
259
  const Comp = asChild ? Slot : "button"
155
260
  const accessibleLabel =
156
261
  selectedMode === "iconOnly" ? ariaLabel || getTextAlternative(children) : ariaLabel
@@ -170,6 +275,7 @@ const ButtonGroupItem = React.forwardRef<HTMLButtonElement, ButtonGroupItemProps
170
275
  aria-label={accessibleLabel}
171
276
  aria-pressed={ariaPressed}
172
277
  data-mode={selectedMode}
278
+ data-item-width={selectedItemWidth}
173
279
  data-size={selectedSize}
174
280
  data-slot="button-group-item"
175
281
  ref={ref}
@@ -177,6 +283,7 @@ const ButtonGroupItem = React.forwardRef<HTMLButtonElement, ButtonGroupItemProps
177
283
  buttonGroupItemVariants({
178
284
  size: selectedSize,
179
285
  mode: selectedMode,
286
+ itemWidth: selectedItemWidth,
180
287
  className,
181
288
  })
182
289
  )}
@@ -192,6 +299,39 @@ const ButtonGroupItem = React.forwardRef<HTMLButtonElement, ButtonGroupItemProps
192
299
  )
193
300
  })
194
301
 
302
+ type ButtonGroupLabelProps = React.ComponentProps<"span"> &
303
+ VariantProps<typeof buttonGroupLabelVariants>
304
+
305
+ const ButtonGroupLabel = React.forwardRef<HTMLSpanElement, ButtonGroupLabelProps>(
306
+ function ButtonGroupLabel(
307
+ { "aria-hidden": ariaHidden = true, className, itemWidth, size, ...props },
308
+ ref
309
+ ) {
310
+ const context = React.useContext(buttonGroupContext)
311
+ const selectedSize = (size || context.size) as ButtonGroupSize
312
+ const selectedItemWidth = itemWidth || context.itemWidth
313
+
314
+ return (
315
+ <span
316
+ aria-hidden={ariaHidden}
317
+ className={cn(
318
+ buttonGroupLabelVariants({
319
+ itemWidth: selectedItemWidth,
320
+ size: selectedSize,
321
+ }),
322
+ className
323
+ )}
324
+ data-item-width={selectedItemWidth}
325
+ data-size={selectedSize}
326
+ data-slot="button-group-label"
327
+ dir="auto"
328
+ ref={ref}
329
+ {...props}
330
+ />
331
+ )
332
+ }
333
+ )
334
+
195
335
  function ButtonGroupItemChildren({
196
336
  children,
197
337
  mode,
@@ -237,10 +377,189 @@ function getTextAlternative(children: React.ReactNode) {
237
377
  return text.trim() || undefined
238
378
  }
239
379
 
240
- export { ButtonGroup, ButtonGroupItem, buttonGroupVariants, buttonGroupItemVariants }
380
+ function resolveButtonGroupSize(
381
+ density: ControlDensity | undefined,
382
+ size: ButtonGroupSize | null | undefined
383
+ ): ButtonGroupSize {
384
+ if (density === "compact") return "xs"
385
+ if (density === "default") return "sm"
386
+ if (density === "comfortable") return "md"
387
+ return size ?? "md"
388
+ }
389
+
390
+ type ToggleGroupProps = Omit<ButtonGroupProps, "onChange"> & {
391
+ defaultValue?: ToggleGroupValue
392
+ disabled?: boolean
393
+ onValueChange?: (value: ToggleGroupValue) => void
394
+ type?: ToggleGroupType
395
+ value?: ToggleGroupValue
396
+ }
397
+
398
+ const ToggleGroup = React.forwardRef<HTMLDivElement, ToggleGroupProps>(
399
+ function ToggleGroup(
400
+ {
401
+ children,
402
+ defaultValue,
403
+ disabled = false,
404
+ onKeyDown,
405
+ onValueChange,
406
+ type = "single",
407
+ value,
408
+ ...props
409
+ },
410
+ ref
411
+ ) {
412
+ const controlled = value !== undefined
413
+ const [internalValue, setInternalValue] = React.useState<ToggleGroupValue>(
414
+ defaultValue ?? (type === "multiple" ? [] : "")
415
+ )
416
+ const selectedValue = normalizeToggleGroupValue(
417
+ controlled ? value : internalValue,
418
+ type
419
+ )
420
+
421
+ const updateValue = React.useCallback(
422
+ (nextValue: ToggleGroupValue) => {
423
+ if (!controlled) setInternalValue(nextValue)
424
+ onValueChange?.(nextValue)
425
+ },
426
+ [controlled, onValueChange]
427
+ )
428
+
429
+ const context = React.useMemo<ToggleGroupContextValue>(
430
+ () => ({
431
+ disabled,
432
+ isPressed: (itemValue) =>
433
+ Array.isArray(selectedValue)
434
+ ? selectedValue.includes(itemValue)
435
+ : selectedValue === itemValue,
436
+ toggle: (itemValue) => {
437
+ if (disabled) return
438
+ if (type === "multiple") {
439
+ const values = Array.isArray(selectedValue) ? selectedValue : []
440
+ updateValue(
441
+ values.includes(itemValue)
442
+ ? values.filter((entry) => entry !== itemValue)
443
+ : [...values, itemValue]
444
+ )
445
+ return
446
+ }
447
+ updateValue(selectedValue === itemValue ? "" : itemValue)
448
+ },
449
+ }),
450
+ [disabled, selectedValue, type, updateValue]
451
+ )
452
+
453
+ return (
454
+ <toggleGroupContext.Provider value={context}>
455
+ <ButtonGroup
456
+ aria-disabled={disabled || undefined}
457
+ data-selection-type={type}
458
+ data-slot="toggle-group"
459
+ onKeyDown={(event) => {
460
+ onKeyDown?.(event)
461
+ if (!event.defaultPrevented) moveToggleGroupFocus(event)
462
+ }}
463
+ ref={ref}
464
+ {...props}
465
+ >
466
+ {children}
467
+ </ButtonGroup>
468
+ </toggleGroupContext.Provider>
469
+ )
470
+ }
471
+ )
472
+
473
+ type ToggleGroupItemProps = Omit<ButtonGroupItemProps, "aria-pressed"> & {
474
+ value: string
475
+ }
476
+
477
+ const ToggleGroupItem = React.forwardRef<
478
+ HTMLButtonElement,
479
+ ToggleGroupItemProps
480
+ >(function ToggleGroupItem(
481
+ { disabled, onClick, value, ...props },
482
+ ref
483
+ ) {
484
+ const context = React.useContext(toggleGroupContext)
485
+ if (!context) {
486
+ throw new Error("ToggleGroupItem must be used within ToggleGroup")
487
+ }
488
+
489
+ const pressed = context.isPressed(value)
490
+ const isDisabled = disabled || context.disabled
491
+
492
+ return (
493
+ <ButtonGroupItem
494
+ aria-pressed={pressed}
495
+ data-slot="toggle-group-item"
496
+ data-value={value}
497
+ disabled={isDisabled}
498
+ onClick={(event) => {
499
+ onClick?.(event)
500
+ if (!event.defaultPrevented) context.toggle(value)
501
+ }}
502
+ ref={ref}
503
+ {...props}
504
+ />
505
+ )
506
+ })
507
+
508
+ function normalizeToggleGroupValue(
509
+ value: ToggleGroupValue,
510
+ type: ToggleGroupType
511
+ ): ToggleGroupValue {
512
+ if (type === "multiple") return Array.isArray(value) ? value : value ? [value] : []
513
+ return Array.isArray(value) ? value[0] ?? "" : value
514
+ }
515
+
516
+ function moveToggleGroupFocus(event: React.KeyboardEvent<HTMLDivElement>) {
517
+ const horizontalKeys = ["ArrowLeft", "ArrowRight"]
518
+ const verticalKeys = ["ArrowUp", "ArrowDown"]
519
+ if (![...horizontalKeys, ...verticalKeys, "Home", "End"].includes(event.key)) {
520
+ return
521
+ }
522
+
523
+ const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>(
524
+ '[data-slot="toggle-group-item"]:not(:disabled)'
525
+ )]
526
+ if (!items.length) return
527
+
528
+ const currentIndex = Math.max(0, items.indexOf(document.activeElement as HTMLButtonElement))
529
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl"
530
+ let nextIndex = currentIndex
531
+
532
+ if (event.key === "Home") nextIndex = 0
533
+ else if (event.key === "End") nextIndex = items.length - 1
534
+ else if (event.key === "ArrowRight") nextIndex += rtl ? -1 : 1
535
+ else if (event.key === "ArrowLeft") nextIndex += rtl ? 1 : -1
536
+ else if (event.key === "ArrowDown") nextIndex += 1
537
+ else if (event.key === "ArrowUp") nextIndex -= 1
538
+
539
+ event.preventDefault()
540
+ items[(nextIndex + items.length) % items.length]?.focus()
541
+ }
542
+
543
+ export {
544
+ ButtonGroup,
545
+ ButtonGroupItem,
546
+ ButtonGroupLabel,
547
+ ToggleGroup,
548
+ ToggleGroupItem,
549
+ buttonGroupVariants,
550
+ buttonGroupItemVariants,
551
+ buttonGroupLabelVariants,
552
+ }
241
553
  export type {
554
+ ButtonGroupLabelProps,
555
+ ButtonGroupItemWidth,
242
556
  ButtonGroupItemProps,
243
557
  ButtonGroupMode,
244
558
  ButtonGroupProps,
245
559
  ButtonGroupSize,
560
+ ControlDensity,
561
+ ToggleGroupItemProps,
562
+ ToggleGroupProps,
563
+ ToggleGroupType,
564
+ ToggleGroupValue,
246
565
  }
@@ -4,38 +4,40 @@ import { cva, type VariantProps } from "class-variance-authority"
4
4
 
5
5
  import { cn } from "@/lib/utils"
6
6
 
7
+ type ControlDensity = "compact" | "default" | "comfortable"
8
+
7
9
  const buttonVariants = cva(
8
- "group/button inline-flex min-w-0 items-center justify-center whitespace-nowrap rounded-[var(--bh-control-default)] text-[length:var(--bh-text-button-font-size)] font-[var(--bh-text-button-font-weight)] leading-[var(--bh-text-button-line-height)] tracking-[var(--bh-text-button-letter-spacing)] outline-none transition-[background-color,border-color,color,box-shadow] focus-visible:shadow-[var(--shadow-button-focus)] disabled:pointer-events-none disabled:shadow-none aria-invalid:border aria-invalid:border-destructive aria-invalid:shadow-[var(--shadow-button-danger-focus)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-[var(--bh-button-icon-size)] [&_svg[data-icon='inline-start']]:ms-[var(--bh-button-icon-offset)] [&_svg[data-icon='inline-end']]:me-[var(--bh-button-icon-offset)] rtl:[&_svg[data-rtl-flip='true']]:-scale-x-100",
10
+ "group/button inline-flex min-w-0 items-center justify-center whitespace-nowrap rounded-[var(--bh-control-default)] text-[length:var(--bh-text-button-font-size)] font-[var(--bh-text-button-font-weight)] leading-[var(--bh-text-button-line-height)] tracking-[var(--bh-text-button-letter-spacing)] outline-none transition-[background-color,border-color,color,box-shadow] focus-visible:shadow-[var(--shadow-button-focus)] disabled:pointer-events-none disabled:shadow-none aria-invalid:border-[length:var(--bh-border-width-default)] aria-invalid:border-[var(--bh-border-danger-strong)] aria-invalid:shadow-[var(--shadow-button-danger-focus)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-[var(--bh-button-icon-size)] [&_svg[data-icon='inline-start']]:ms-[var(--bh-button-icon-offset)] [&_svg[data-icon='inline-end']]:me-[var(--bh-button-icon-offset)] rtl:[&_svg[data-rtl-flip='true']]:-scale-x-100",
9
11
  {
10
12
  variants: {
11
13
  variant: {
12
14
  default:
13
- "bg-primary text-primary-foreground shadow-[var(--shadow-button-raised)] hover:bg-[var(--primary-hover)] active:bg-[var(--primary-pressed)] disabled:bg-[var(--bh-interactive-brand-disabled)] disabled:text-[var(--bh-content-disabled)]",
15
+ "bg-[var(--bh-interactive-brand-default)] text-[var(--bh-content-on-brand)] shadow-[var(--shadow-button-raised)] hover:bg-[var(--bh-interactive-brand-hover)] active:bg-[var(--bh-interactive-brand-pressed)] disabled:bg-[var(--bh-interactive-brand-disabled)] disabled:text-[var(--bh-content-disabled)]",
14
16
  danger:
15
- "bg-destructive text-destructive-foreground shadow-[var(--shadow-button-raised)] hover:bg-[var(--destructive-hover)] active:bg-[var(--destructive-pressed)] focus-visible:shadow-[var(--shadow-button-danger-focus)] disabled:bg-[var(--bh-interactive-danger-disabled)] disabled:text-[var(--bh-content-disabled)]",
17
+ "bg-[var(--bh-interactive-danger-default)] text-[var(--bh-content-on-color)] shadow-[var(--shadow-button-raised)] hover:bg-[var(--bh-interactive-danger-hover)] active:bg-[var(--bh-interactive-danger-pressed)] focus-visible:shadow-[var(--shadow-button-danger-focus)] disabled:bg-[var(--bh-interactive-danger-disabled)] disabled:text-[var(--bh-content-disabled)]",
16
18
  destructive:
17
- "bg-destructive text-destructive-foreground shadow-[var(--shadow-button-raised)] hover:bg-[var(--destructive-hover)] active:bg-[var(--destructive-pressed)] focus-visible:shadow-[var(--shadow-button-danger-focus)] disabled:bg-[var(--bh-interactive-danger-disabled)] disabled:text-[var(--bh-content-disabled)]",
19
+ "bg-[var(--bh-interactive-danger-default)] text-[var(--bh-content-on-color)] shadow-[var(--shadow-button-raised)] hover:bg-[var(--bh-interactive-danger-hover)] active:bg-[var(--bh-interactive-danger-pressed)] focus-visible:shadow-[var(--shadow-button-danger-focus)] disabled:bg-[var(--bh-interactive-danger-disabled)] disabled:text-[var(--bh-content-disabled)]",
18
20
  success:
19
- "bg-success text-success-foreground shadow-[var(--shadow-button-raised)] hover:bg-[var(--success-hover)] active:bg-[var(--success-pressed)] disabled:bg-[var(--bh-interactive-brand-disabled)] disabled:text-[var(--bh-content-disabled)]",
21
+ "bg-[var(--bh-interactive-success-default)] text-[var(--bh-content-on-success)] shadow-[var(--shadow-button-raised)] hover:bg-[var(--bh-interactive-success-hover)] active:bg-[var(--bh-interactive-success-pressed)] disabled:bg-[var(--bh-interactive-brand-disabled)] disabled:text-[var(--bh-content-disabled)]",
20
22
  warning:
21
- "bg-warning text-warning-foreground shadow-[var(--shadow-button-raised)] hover:bg-[var(--warning-hover)] active:bg-[var(--warning-pressed)] disabled:bg-[var(--bh-interactive-brand-disabled)] disabled:text-[var(--bh-content-disabled)]",
23
+ "bg-[var(--bh-interactive-warning-default)] text-[var(--bh-content-on-warning)] shadow-[var(--shadow-button-raised)] hover:bg-[var(--bh-interactive-warning-hover)] active:bg-[var(--bh-interactive-warning-pressed)] disabled:bg-[var(--bh-interactive-brand-disabled)] disabled:text-[var(--bh-content-disabled)]",
22
24
  secondary:
23
- "bg-secondary text-secondary-foreground shadow-[var(--shadow-button-secondary)] [--bh-button-secondary-border:var(--bh-border-default)] hover:bg-[var(--secondary-hover)] active:bg-[var(--secondary-pressed)] focus-visible:[--bh-button-secondary-focus-border:var(--bh-border-brand-strong)] focus-visible:shadow-[var(--shadow-button-secondary-focus)] disabled:border disabled:border-[var(--bh-border-disabled)] disabled:bg-[var(--bh-interactive-secondary-disabled)] disabled:text-[var(--bh-content-disabled)]",
25
+ "bg-[var(--bh-interactive-secondary-default)] text-[var(--bh-content-default)] shadow-[var(--shadow-button-secondary)] [--bh-button-secondary-border:var(--bh-border-default)] hover:bg-[var(--bh-interactive-secondary-hover)] active:bg-[var(--bh-interactive-secondary-pressed)] focus-visible:[--bh-button-secondary-focus-border:var(--bh-border-brand-strong)] focus-visible:shadow-[var(--shadow-button-secondary-focus)] disabled:border-[length:var(--bh-border-width-default)] disabled:border-[var(--bh-border-disabled)] disabled:bg-[var(--bh-interactive-secondary-disabled)] disabled:text-[var(--bh-content-disabled)]",
24
26
  soft:
25
- "border border-transparent bg-[var(--soft)] text-foreground shadow-[var(--shadow-button-soft)] hover:bg-[var(--soft-hover)] active:bg-[var(--soft-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:bg-[var(--bh-interactive-soft-disabled)] disabled:text-[var(--bh-content-disabled)]",
27
+ "border-[length:var(--bh-border-width-default)] border-transparent bg-[var(--bh-interactive-soft-default)] text-[var(--bh-content-default)] shadow-[var(--shadow-button-soft)] hover:bg-[var(--bh-interactive-soft-hover)] active:bg-[var(--bh-interactive-soft-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:bg-[var(--bh-interactive-soft-disabled)] disabled:text-[var(--bh-content-disabled)]",
26
28
  outline:
27
- "border border-[var(--bh-border-default)] bg-[var(--outline)] text-foreground hover:bg-[var(--outline-hover)] active:bg-[var(--outline-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:border-[var(--bh-border-disabled)] disabled:bg-[var(--bh-interactive-outlined-disabled)] disabled:text-[var(--bh-content-disabled)]",
29
+ "border-[length:var(--bh-border-width-default)] border-[var(--bh-border-default)] bg-[var(--bh-interactive-outlined-default)] text-[var(--bh-content-default)] hover:bg-[var(--bh-interactive-outlined-hover)] active:bg-[var(--bh-interactive-outlined-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:border-[var(--bh-border-disabled)] disabled:bg-[var(--bh-interactive-outlined-disabled)] disabled:text-[var(--bh-content-disabled)]",
28
30
  ghost:
29
- "border border-transparent bg-[var(--ghost)] text-foreground hover:bg-[var(--ghost-hover)] active:bg-[var(--ghost-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:bg-[var(--bh-interactive-ghost-disabled)] disabled:text-[var(--bh-content-disabled)]",
31
+ "border-[length:var(--bh-border-width-default)] border-transparent bg-[var(--bh-interactive-ghost-default)] text-[var(--bh-content-default)] hover:bg-[var(--bh-interactive-ghost-hover)] active:bg-[var(--bh-interactive-ghost-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:bg-[var(--bh-interactive-ghost-disabled)] disabled:text-[var(--bh-content-disabled)]",
30
32
  "ghost-primary":
31
- "border border-transparent bg-[var(--ghost)] text-[var(--bh-content-brand-default)] hover:bg-[var(--ghost-hover)] active:bg-[var(--ghost-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:bg-[var(--bh-interactive-ghost-disabled)] disabled:text-[var(--bh-content-disabled)]",
33
+ "border-[length:var(--bh-border-width-default)] border-transparent bg-[var(--bh-interactive-ghost-default)] text-[var(--bh-content-brand-default)] hover:bg-[var(--bh-interactive-ghost-hover)] active:bg-[var(--bh-interactive-ghost-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:bg-[var(--bh-interactive-ghost-disabled)] disabled:text-[var(--bh-content-disabled)]",
32
34
  white:
33
- "border border-transparent bg-[var(--bh-bg-always-white)] text-[var(--bh-content-on-light)] focus-visible:border-[var(--bh-border-focus)] disabled:opacity-[var(--bh-opacity-30)]",
35
+ "border-[length:var(--bh-border-width-default)] border-transparent bg-[var(--bh-bg-always-white)] text-[var(--bh-content-on-light)] focus-visible:border-[var(--bh-border-focus)] disabled:opacity-[var(--bh-opacity-30)]",
34
36
  "soft-danger":
35
- "border border-transparent bg-[var(--destructive-soft)] text-[var(--destructive-soft-foreground)] hover:bg-[var(--destructive-soft-hover)] active:bg-[var(--destructive-soft-pressed)] focus-visible:border-[var(--destructive)] focus-visible:shadow-[var(--shadow-button-danger-focus)] disabled:bg-[var(--bh-interactive-danger-soft-disabled)] disabled:text-[var(--bh-content-disabled)]",
37
+ "border-[length:var(--bh-border-width-default)] border-transparent bg-[var(--bh-interactive-danger-soft-default)] text-[var(--bh-content-danger-default)] hover:bg-[var(--bh-interactive-danger-soft-hover)] active:bg-[var(--bh-interactive-danger-soft-pressed)] focus-visible:border-[var(--bh-interactive-danger-default)] focus-visible:shadow-[var(--shadow-button-danger-focus)] disabled:bg-[var(--bh-interactive-danger-soft-disabled)] disabled:text-[var(--bh-content-disabled)]",
36
38
  "soft-destructive":
37
- "border border-transparent bg-[var(--destructive-soft)] text-[var(--destructive-soft-foreground)] hover:bg-[var(--destructive-soft-hover)] active:bg-[var(--destructive-soft-pressed)] focus-visible:border-[var(--destructive)] focus-visible:shadow-[var(--shadow-button-danger-focus)] disabled:bg-[var(--bh-interactive-danger-soft-disabled)] disabled:text-[var(--bh-content-disabled)]",
38
- link: "bg-[var(--ghost)] text-[var(--bh-content-brand-default)] hover:bg-[var(--ghost-hover)] active:bg-[var(--ghost-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:text-[var(--bh-content-disabled)]",
39
+ "border-[length:var(--bh-border-width-default)] border-transparent bg-[var(--bh-interactive-danger-soft-default)] text-[var(--bh-content-danger-default)] hover:bg-[var(--bh-interactive-danger-soft-hover)] active:bg-[var(--bh-interactive-danger-soft-pressed)] focus-visible:border-[var(--bh-interactive-danger-default)] focus-visible:shadow-[var(--shadow-button-danger-focus)] disabled:bg-[var(--bh-interactive-danger-soft-disabled)] disabled:text-[var(--bh-content-disabled)]",
40
+ link: "bg-[var(--bh-interactive-ghost-default)] text-[var(--bh-content-brand-default)] hover:bg-[var(--bh-interactive-ghost-hover)] active:bg-[var(--bh-interactive-ghost-pressed)] focus-visible:border-[var(--bh-border-brand-strong)] disabled:text-[var(--bh-content-disabled)]",
39
41
  },
40
42
  size: {
41
43
  xs: "h-[var(--bh-button-xs-height)] gap-[var(--bh-button-gap-xs)] px-[var(--bh-button-xs-padding-x)] py-[var(--bh-button-sm-padding-y)] has-[>svg]:px-[var(--bh-button-xs-padding-x)]",
@@ -60,23 +62,30 @@ const buttonVariants = cva(
60
62
  type ButtonProps = React.ComponentProps<"button"> &
61
63
  VariantProps<typeof buttonVariants> & {
62
64
  asChild?: boolean
65
+ density?: ControlDensity
63
66
  }
64
67
 
68
+ type ButtonSize = NonNullable<VariantProps<typeof buttonVariants>["size"]>
69
+
65
70
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button({
66
71
  className,
67
72
  variant,
68
73
  size,
74
+ density,
69
75
  asChild = false,
70
76
  children,
71
77
  ...props
72
78
  }, ref) {
73
79
  const Comp = asChild ? Slot : "button"
80
+ const resolvedSize = resolveButtonSize(density, size)
74
81
 
75
82
  return (
76
83
  <Comp
84
+ data-density={density}
85
+ data-size={resolvedSize}
77
86
  data-slot="button"
78
87
  ref={ref}
79
- className={cn(buttonVariants({ variant, size, className }))}
88
+ className={cn(buttonVariants({ variant, size: resolvedSize, className }))}
80
89
  {...props}
81
90
  >
82
91
  {asChild ? children : <ButtonChildren>{children}</ButtonChildren>}
@@ -98,5 +107,17 @@ function ButtonChildren({ children }: { children: React.ReactNode }) {
98
107
  })
99
108
  }
100
109
 
110
+ function resolveButtonSize(
111
+ density: ControlDensity | undefined,
112
+ size: ButtonSize | null | undefined
113
+ ): ButtonSize | null | undefined {
114
+ if (!density) return size
115
+
116
+ const iconOnly = size?.startsWith("icon") ?? false
117
+ if (density === "compact") return iconOnly ? "icon-xs" : "xs"
118
+ if (density === "default") return iconOnly ? "icon-sm" : "sm"
119
+ return iconOnly ? "icon" : "default"
120
+ }
121
+
101
122
  export { Button, buttonVariants }
102
- export type { ButtonProps }
123
+ export type { ButtonProps, ControlDensity }