banhaten 0.5.0 → 0.6.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 (290) hide show
  1. package/CHANGELOG.md +177 -139
  2. package/CONTRIBUTING.md +46 -46
  3. package/README.md +192 -192
  4. package/SECURITY.md +15 -15
  5. package/banhaten.config.example.json +11 -11
  6. package/docs/design-spacing.md +13 -13
  7. package/docs/design-system/README.md +22 -22
  8. package/docs/design-system/adoption-quality-remediation.md +87 -87
  9. package/docs/design-system/agent-usage.md +13 -13
  10. package/docs/design-system/appearance-presets.md +189 -189
  11. package/docs/design-system/appearances/default.md +94 -94
  12. package/docs/design-system/appearances/rounded.md +95 -95
  13. package/docs/design-system/appearances/sharp.md +95 -95
  14. package/docs/design-system/component-docs-pattern.md +81 -81
  15. package/docs/design-system/component-implementation-ownership-audit.md +245 -245
  16. package/docs/design-system/component-quality-audit-2026-08-20.md +225 -225
  17. package/docs/design-system/component-showcase-consistency-report.md +219 -219
  18. package/docs/design-system/component-token-consistency-audit.md +173 -173
  19. package/docs/design-system/components/README.md +91 -90
  20. package/docs/design-system/components/accordion.md +52 -52
  21. package/docs/design-system/components/activity-feed.md +93 -93
  22. package/docs/design-system/components/activity-heatmap.md +56 -56
  23. package/docs/design-system/components/alert-dialog.md +71 -71
  24. package/docs/design-system/components/alert.md +103 -97
  25. package/docs/design-system/components/app-header.md +97 -0
  26. package/docs/design-system/components/aspect-ratio.md +45 -45
  27. package/docs/design-system/components/attribute.md +101 -89
  28. package/docs/design-system/components/autocomplete.md +75 -75
  29. package/docs/design-system/components/avatar.md +53 -53
  30. package/docs/design-system/components/badge.md +55 -55
  31. package/docs/design-system/components/banner.md +86 -86
  32. package/docs/design-system/components/breadcrumbs.md +181 -177
  33. package/docs/design-system/components/button-group.md +97 -97
  34. package/docs/design-system/components/button.md +80 -80
  35. package/docs/design-system/components/card.md +102 -88
  36. package/docs/design-system/components/carousel.md +46 -46
  37. package/docs/design-system/components/catalog-components.md +46 -46
  38. package/docs/design-system/components/checkbox.md +53 -53
  39. package/docs/design-system/components/collapsible.md +63 -63
  40. package/docs/design-system/components/command.md +61 -61
  41. package/docs/design-system/components/context-menu.md +45 -45
  42. package/docs/design-system/components/date-picker.md +80 -80
  43. package/docs/design-system/components/divider.md +102 -102
  44. package/docs/design-system/components/empty-state.md +56 -56
  45. package/docs/design-system/components/field.md +70 -70
  46. package/docs/design-system/components/file-upload.md +186 -186
  47. package/docs/design-system/components/hover-card.md +47 -47
  48. package/docs/design-system/components/icons.md +48 -48
  49. package/docs/design-system/components/input-group.md +57 -57
  50. package/docs/design-system/components/input-otp.md +56 -56
  51. package/docs/design-system/components/input.md +111 -109
  52. package/docs/design-system/components/item.md +58 -58
  53. package/docs/design-system/components/kbd.md +49 -49
  54. package/docs/design-system/components/label.md +49 -49
  55. package/docs/design-system/components/menu.md +94 -73
  56. package/docs/design-system/components/menubar.md +46 -46
  57. package/docs/design-system/components/modal.md +104 -104
  58. package/docs/design-system/components/native-select.md +53 -53
  59. package/docs/design-system/components/navigation-menu.md +49 -49
  60. package/docs/design-system/components/onboarding-step-list-item.md +81 -81
  61. package/docs/design-system/components/page-header.md +127 -93
  62. package/docs/design-system/components/pagination.md +56 -56
  63. package/docs/design-system/components/popover.md +59 -59
  64. package/docs/design-system/components/progress-slider.md +49 -49
  65. package/docs/design-system/components/progress.md +76 -76
  66. package/docs/design-system/components/radio-card.md +50 -50
  67. package/docs/design-system/components/radio-group.md +56 -56
  68. package/docs/design-system/components/resizable.md +43 -43
  69. package/docs/design-system/components/scroll-area.md +50 -50
  70. package/docs/design-system/components/segmented-control.md +54 -54
  71. package/docs/design-system/components/select.md +68 -64
  72. package/docs/design-system/components/sheet.md +66 -66
  73. package/docs/design-system/components/sidebar.md +167 -111
  74. package/docs/design-system/components/skeleton.md +74 -74
  75. package/docs/design-system/components/slideout.md +64 -64
  76. package/docs/design-system/components/slider.md +62 -62
  77. package/docs/design-system/components/social-button.md +49 -49
  78. package/docs/design-system/components/spinner.md +62 -62
  79. package/docs/design-system/components/steps.md +65 -65
  80. package/docs/design-system/components/table.md +499 -422
  81. package/docs/design-system/components/tabs.md +74 -59
  82. package/docs/design-system/components/tag.md +79 -79
  83. package/docs/design-system/components/textarea.md +49 -49
  84. package/docs/design-system/components/timeline.md +82 -82
  85. package/docs/design-system/components/toast.md +57 -57
  86. package/docs/design-system/components/toggle.md +80 -80
  87. package/docs/design-system/components/toolbar.md +89 -89
  88. package/docs/design-system/components/tooltip.md +92 -92
  89. package/docs/design-system/components/typography.md +18 -18
  90. package/docs/design-system/density-and-elevation.md +65 -65
  91. package/docs/design-system/design-system-test-missing-items.md +368 -368
  92. package/docs/design-system/direction-and-localization.md +23 -23
  93. package/docs/design-system/form-recipes.md +73 -73
  94. package/docs/design-system/gap-promotion-policy.md +21 -21
  95. package/docs/design-system/icons.md +74 -74
  96. package/docs/design-system/installation.md +70 -70
  97. package/docs/design-system/launch-comparison-shadcn.md +98 -98
  98. package/docs/design-system/registry-and-cli.md +64 -64
  99. package/docs/design-system/release-policy.md +30 -30
  100. package/docs/design-system/roadmap.md +14 -14
  101. package/docs/design-system/support-matrix.md +23 -23
  102. package/docs/design-system/tabs.md +54 -54
  103. package/docs/design-system/template-gap-audit.md +32 -32
  104. package/docs/design-system/templates.md +59 -59
  105. package/docs/design-system/token-governance.md +72 -72
  106. package/package.json +98 -98
  107. package/registry/component-contract.json +40 -40
  108. package/registry/components/accordion.tsx +155 -155
  109. package/registry/components/alert-dialog.tsx +297 -297
  110. package/registry/components/alert.tsx +309 -293
  111. package/registry/components/app-header.tsx +151 -0
  112. package/registry/components/aspect-ratio.tsx +30 -30
  113. package/registry/components/attribute.tsx +489 -454
  114. package/registry/components/autocomplete.tsx +598 -598
  115. package/registry/components/avatar.tsx +385 -385
  116. package/registry/components/badge.tsx +630 -629
  117. package/registry/components/button-group.tsx +565 -565
  118. package/registry/components/button.tsx +128 -127
  119. package/registry/components/card.tsx +651 -638
  120. package/registry/components/carousel.tsx +296 -296
  121. package/registry/components/chart.tsx +203 -203
  122. package/registry/components/checkbox.tsx +247 -247
  123. package/registry/components/collapsible.tsx +109 -109
  124. package/registry/components/command.tsx +208 -208
  125. package/registry/components/context-menu.tsx +236 -236
  126. package/registry/components/date-picker.tsx +1289 -1289
  127. package/registry/components/expanded/ActivityFeed.tsx +227 -227
  128. package/registry/components/expanded/ActivityHeatmap.tsx +64 -64
  129. package/registry/components/expanded/Banner.tsx +179 -179
  130. package/registry/components/expanded/Breadcrumbs.tsx +135 -131
  131. package/registry/components/expanded/CatalogComponentsShowcase.tsx +205 -205
  132. package/registry/components/expanded/CatalogDivider.tsx +49 -49
  133. package/registry/components/expanded/EmptyState.tsx +155 -155
  134. package/registry/components/expanded/FileUpload.tsx +543 -543
  135. package/registry/components/expanded/OnboardingStepListItem.tsx +60 -60
  136. package/registry/components/expanded/PageHeader.tsx +209 -179
  137. package/registry/components/expanded/Slideout.tsx +108 -108
  138. package/registry/components/expanded/Steps.tsx +402 -402
  139. package/registry/components/expanded/Table.tsx +1109 -1092
  140. package/registry/components/expanded/Timeline.tsx +203 -203
  141. package/registry/components/expanded/activityFeed.css +248 -248
  142. package/registry/components/expanded/activityHeatmap.css +66 -66
  143. package/registry/components/expanded/banner.css +260 -260
  144. package/registry/components/expanded/breadcrumbs.css +157 -140
  145. package/registry/components/expanded/catalogComponentsShowcase.css +87 -87
  146. package/registry/components/expanded/divider.css +75 -75
  147. package/registry/components/expanded/emptyState.css +111 -111
  148. package/registry/components/expanded/fileUpload.css +457 -457
  149. package/registry/components/expanded/onboardingStepListItem.css +135 -135
  150. package/registry/components/expanded/pageHeader.css +284 -248
  151. package/registry/components/expanded/slideout.css +276 -276
  152. package/registry/components/expanded/steps.css +356 -356
  153. package/registry/components/expanded/table.css +655 -606
  154. package/registry/components/expanded/timeline.css +286 -286
  155. package/registry/components/field.tsx +283 -283
  156. package/registry/components/hover-card.tsx +48 -48
  157. package/registry/components/input-content.ts +107 -107
  158. package/registry/components/input-group.tsx +130 -130
  159. package/registry/components/input-otp.tsx +584 -584
  160. package/registry/components/input.tsx +992 -990
  161. package/registry/components/item.tsx +189 -189
  162. package/registry/components/kbd.tsx +246 -246
  163. package/registry/components/label.tsx +78 -78
  164. package/registry/components/menu.tsx +1091 -978
  165. package/registry/components/menubar.tsx +226 -226
  166. package/registry/components/modal.tsx +872 -872
  167. package/registry/components/native-select.tsx +205 -205
  168. package/registry/components/navigation-menu.tsx +171 -171
  169. package/registry/components/pagination.css +36 -36
  170. package/registry/components/pagination.tsx +560 -560
  171. package/registry/components/popover.tsx +338 -338
  172. package/registry/components/progress-slider.tsx +216 -216
  173. package/registry/components/radio-card.tsx +676 -676
  174. package/registry/components/radio-group.tsx +570 -570
  175. package/registry/components/resizable.tsx +108 -108
  176. package/registry/components/scroll-area.tsx +91 -91
  177. package/registry/components/segmented-control.tsx +87 -87
  178. package/registry/components/select-content.tsx +218 -218
  179. package/registry/components/select.tsx +937 -937
  180. package/registry/components/sheet.tsx +305 -305
  181. package/registry/components/sidebar.tsx +1535 -1433
  182. package/registry/components/skeleton.css +54 -54
  183. package/registry/components/skeleton.tsx +482 -482
  184. package/registry/components/slider.tsx +380 -380
  185. package/registry/components/social-button.tsx +387 -387
  186. package/registry/components/spinner.tsx +78 -78
  187. package/registry/components/table-elements.tsx +287 -128
  188. package/registry/components/tabs.css +246 -222
  189. package/registry/components/tabs.tsx +245 -241
  190. package/registry/components/tag.tsx +423 -423
  191. package/registry/components/textarea.tsx +610 -610
  192. package/registry/components/toast.tsx +136 -136
  193. package/registry/components/toggle.tsx +256 -256
  194. package/registry/components/toolbar.tsx +539 -539
  195. package/registry/components/tooltip.tsx +433 -433
  196. package/registry/examples/alert-demo.tsx +34 -34
  197. package/registry/examples/alert-dialog-demo.tsx +42 -42
  198. package/registry/examples/app-header-demo.tsx +40 -0
  199. package/registry/examples/aspect-ratio-demo.tsx +11 -11
  200. package/registry/examples/attribute-demo.tsx +65 -65
  201. package/registry/examples/autocomplete-demo.tsx +109 -109
  202. package/registry/examples/avatar-demo.tsx +132 -132
  203. package/registry/examples/badge-demo.tsx +69 -69
  204. package/registry/examples/button-demo.tsx +86 -86
  205. package/registry/examples/button-group-demo.tsx +63 -63
  206. package/registry/examples/card-demo.tsx +86 -86
  207. package/registry/examples/carousel-demo.tsx +43 -43
  208. package/registry/examples/chart-demo.tsx +18 -18
  209. package/registry/examples/checkbox-demo.tsx +62 -62
  210. package/registry/examples/collapsible-demo.tsx +16 -16
  211. package/registry/examples/collection-filter-bar-demo.tsx +135 -135
  212. package/registry/examples/command-demo.tsx +42 -42
  213. package/registry/examples/command-palette-demo.tsx +235 -235
  214. package/registry/examples/context-menu-demo.tsx +29 -29
  215. package/registry/examples/control-density-contract-demo.tsx +65 -65
  216. package/registry/examples/date-picker-demo.tsx +127 -127
  217. package/registry/examples/divider-demo.tsx +17 -17
  218. package/registry/examples/expanded/activity-heatmap-demo.tsx +29 -29
  219. package/registry/examples/expanded/banner-demo.tsx +48 -48
  220. package/registry/examples/expanded/breadcrumbs-demo.tsx +59 -59
  221. package/registry/examples/expanded/empty-state-demo.tsx +39 -39
  222. package/registry/examples/expanded/file-upload-demo.tsx +60 -60
  223. package/registry/examples/expanded/onboarding-step-demo.tsx +11 -11
  224. package/registry/examples/expanded/slideout-demo.tsx +15 -15
  225. package/registry/examples/expanded/steps-demo.tsx +29 -29
  226. package/registry/examples/expanded/tabs-demo.tsx +13 -13
  227. package/registry/examples/expanded/timeline-demo.tsx +18 -18
  228. package/registry/examples/field-demo.tsx +51 -51
  229. package/registry/examples/hover-card-demo.tsx +23 -23
  230. package/registry/examples/input-actions-demo.tsx +36 -36
  231. package/registry/examples/input-demo.tsx +87 -87
  232. package/registry/examples/input-group-demo.tsx +16 -16
  233. package/registry/examples/input-otp-demo.tsx +72 -72
  234. package/registry/examples/item-demo.tsx +46 -46
  235. package/registry/examples/kbd-demo.tsx +12 -12
  236. package/registry/examples/label-demo.tsx +20 -20
  237. package/registry/examples/menu-demo.tsx +122 -122
  238. package/registry/examples/menubar-demo.tsx +34 -34
  239. package/registry/examples/modal-demo.tsx +74 -74
  240. package/registry/examples/native-select-demo.tsx +16 -16
  241. package/registry/examples/navigation-menu-demo.tsx +29 -29
  242. package/registry/examples/pagination-demo.tsx +20 -20
  243. package/registry/examples/popover-demo.tsx +547 -547
  244. package/registry/examples/progress-demo.tsx +37 -37
  245. package/registry/examples/progress-slider-demo.tsx +29 -29
  246. package/registry/examples/radio-card-demo.tsx +51 -51
  247. package/registry/examples/radio-group-demo.tsx +62 -62
  248. package/registry/examples/resizable-demo.tsx +22 -22
  249. package/registry/examples/results-toolbar-demo.tsx +44 -44
  250. package/registry/examples/scroll-area-demo.tsx +15 -15
  251. package/registry/examples/segmented-control-demo.tsx +39 -39
  252. package/registry/examples/select-demo.tsx +87 -87
  253. package/registry/examples/sheet-demo.tsx +47 -47
  254. package/registry/examples/sidebar-demo.tsx +101 -101
  255. package/registry/examples/slider-demo.tsx +31 -31
  256. package/registry/examples/social-button-demo.tsx +61 -61
  257. package/registry/examples/spinner-demo.tsx +59 -59
  258. package/registry/examples/tabs-demo.tsx +13 -13
  259. package/registry/examples/tag-demo.tsx +29 -29
  260. package/registry/examples/textarea-demo.tsx +79 -79
  261. package/registry/examples/toast-demo.tsx +35 -35
  262. package/registry/examples/toggle-demo.tsx +59 -59
  263. package/registry/examples/tooltip-demo.tsx +115 -115
  264. package/registry/guidance/USAGE.md +27 -27
  265. package/registry/hooks/use-direction.ts +207 -207
  266. package/registry/index.json +387 -17
  267. package/registry/styles/globals.css +46 -0
  268. package/registry.json +24 -2
  269. package/schema/config.schema.json +51 -51
  270. package/schema/registry.schema.json +194 -194
  271. package/schema/tokens.schema.json +167 -167
  272. package/src/cli/command-line.js +194 -194
  273. package/src/cli/config-editors.js +9 -9
  274. package/src/cli/doctor.js +399 -399
  275. package/src/cli/file-transaction.js +302 -302
  276. package/src/cli/global-css.js +153 -153
  277. package/src/cli/index.js +1002 -1002
  278. package/src/cli/install-plan.js +592 -592
  279. package/src/cli/install-receipt.js +171 -171
  280. package/src/cli/jsonc.js +203 -203
  281. package/src/cli/package-install-state.js +254 -254
  282. package/src/cli/package-manager.js +238 -238
  283. package/src/cli/package-plan.js +185 -185
  284. package/src/cli/package-version.js +97 -97
  285. package/src/cli/registry-query.js +207 -207
  286. package/src/cli/reporting.js +55 -55
  287. package/src/cli/schema-validation.js +19 -19
  288. package/src/cli/source-scanner.js +686 -686
  289. package/src/cli/vite-config.js +478 -478
  290. package/tokens/banhaten.tokens.json +184 -0
@@ -1,990 +1,992 @@
1
- import * as React from "react"
2
- import { cva } from "class-variance-authority"
3
- import {
4
- AtSignIcon,
5
- ChevronDownIcon,
6
- InfoIcon,
7
- MinusIcon as LucideMinusIcon,
8
- PlusIcon as LucidePlusIcon,
9
- } from "lucide-react"
10
-
11
- import { cn } from "@/lib/utils"
12
- import { Shortcut } from "@/components/ui/kbd"
13
- import {
14
- getInputDefaultValue,
15
- getInputHelperText,
16
- getInputPlaceholder,
17
- getInputTags,
18
- inputCopy,
19
- type InputKind,
20
- type InputTag,
21
- type InputVisualState,
22
- } from "./input-content"
23
-
24
- type ControlDensity = "compact" | "default" | "comfortable"
25
- type InputSize = "md" | "lg" | "comfortable"
26
- type InputVariant = "default" | "soft"
27
-
28
- type InputNativeProps = Omit<
29
- React.ComponentProps<"input">,
30
- "className" | "dir" | "size"
31
- >
32
-
33
- type InputActionButtonProps = Omit<React.ComponentProps<"button">, "children">
34
- type InputStepperButtonProps = Omit<InputActionButtonProps, "type">
35
-
36
- type InputModeRenderProps = {
37
- buttonLabel?: React.ReactNode
38
- /** Native props for the button or dropdown segment, including events and ARIA. */
39
- buttonProps?: InputActionButtonProps
40
- decreaseButtonProps?: InputStepperButtonProps
41
- increaseButtonProps?: InputStepperButtonProps
42
- inlineLeadingAddon?: React.ReactNode
43
- inlineTagsClassName?: string
44
- inlineTrailingAddon?: React.ReactNode
45
- leadingAddon?: React.ReactNode
46
- quantityLabel?: React.ReactNode
47
- shortcut?: React.ReactNode
48
- shortcutKeys?: readonly string[]
49
- tags?: InputTag[]
50
- trailingAddon?: React.ReactNode
51
- }
52
-
53
- type InputKindSpecificProps = InputModeRenderProps &
54
- (
55
- | { kind?: "default" }
56
- | { kind: "shortcut" }
57
- | { kind: "add-on" }
58
- | { kind: "inline-add-on" }
59
- | { kind: "tags" | "inline-tags" }
60
- | {
61
- kind:
62
- | "leading-dropdown"
63
- | "trailing-dropdown"
64
- | "leading-button"
65
- | "trailing-button"
66
- }
67
- | { kind: "quantity" | "quantity-2" }
68
- )
69
-
70
- type InputDynamicKindProps = { kind?: InputKind } & InputModeRenderProps
71
-
72
- type InputCommonProps = InputNativeProps & {
73
- className?: string
74
- controlClassName?: string
75
- density?: ControlDensity
76
- helperClassName?: string
77
- labelClassName?: string
78
- surfaceClassName?: string
79
- errorMessage?: React.ReactNode
80
- hasHelperText?: boolean
81
- /** @deprecated Use showInfo or an explicit trailingIcon. */
82
- hasInformationIcon?: boolean
83
- /** @deprecated Use showAtSign or an explicit leadingIcon. */
84
- hasLeadingIcon?: boolean
85
- hasLabel?: boolean
86
- isOptional?: boolean
87
- /** Shows the marker and exposes aria-required without enabling native validation. */
88
- isRequired?: boolean
89
- label?: React.ReactNode
90
- leadingIcon?: React.ReactNode | false
91
- message?: React.ReactNode
92
- optionalText?: React.ReactNode
93
- showAtSign?: boolean
94
- showInfo?: boolean
95
- size?: InputSize
96
- state?: InputVisualState
97
- trailingIcon?: React.ReactNode | false
98
- valueText?: string
99
- variant?: InputVariant
100
- dir?: "ltr" | "rtl" | "auto"
101
- }
102
-
103
- type InputProps = InputCommonProps & (InputKindSpecificProps | InputDynamicKindProps)
104
-
105
- const inputRoot = cva("grid w-[var(--bh-input-width)] max-w-full")
106
-
107
- const inputSurface = cva(
108
- [
109
- "group/input-surface relative flex w-full overflow-hidden rounded-[var(--bh-input-radius)]",
110
- "transition-[background-color,box-shadow]",
111
- "[--bh-input-border:var(--bh-border-input)] [--elevation-input:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-input-border,var(--bh-border-input)),var(--elevation-component-default)]",
112
- "[--elevation-input-surface:var(--elevation-component-default)]",
113
- "[--elevation-input-overlay:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-input-border,var(--bh-border-input))]",
114
- "[--elevation-input-focus-surface:var(--elevation-input-focus-ring)]",
115
- "[--elevation-input-focus-overlay:none]",
116
- "[--elevation-input-disabled-overlay:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-border-disabled)]",
117
- "after:pointer-events-none after:absolute after:inset-0 after:z-[var(--bh-z-raised)] after:rounded-[inherit] after:[box-shadow:var(--elevation-input-overlay)] after:content-['']",
118
- "focus-within:shadow-[var(--elevation-input-focus-surface)] focus-within:after:[box-shadow:var(--elevation-input-focus-overlay)]",
119
- ],
120
- {
121
- variants: {
122
- size: {
123
- md: "h-[var(--bh-input-md-height)]",
124
- lg: "h-[var(--bh-input-lg-height)]",
125
- comfortable: "h-[var(--bh-input-comfortable-height)]",
126
- },
127
- variant: {
128
- default: "bg-[var(--bh-interactive-input-default)]",
129
- soft: "bg-[var(--bh-interactive-input-soft)]",
130
- },
131
- state: {
132
- default:
133
- "shadow-[var(--elevation-input-surface)]",
134
- filled:
135
- "shadow-[var(--elevation-input-surface)]",
136
- error:
137
- "[--bh-input-border:var(--bh-border-danger-strong)] shadow-[var(--elevation-input-surface)] focus-within:shadow-[var(--elevation-input-surface)] focus-within:after:[box-shadow:var(--elevation-input-overlay)]",
138
- disabled:
139
- "[--bh-input-border:var(--bh-border-disabled)] bg-[var(--bh-interactive-input-disabled)] shadow-none after:[box-shadow:var(--elevation-input-disabled-overlay)]",
140
- },
141
- },
142
- compoundVariants: [
143
- {
144
- variant: "soft",
145
- state: "disabled",
146
- class: "bg-[var(--bh-interactive-input-soft-disabled)]",
147
- },
148
- ],
149
- defaultVariants: {
150
- size: "lg",
151
- state: "default",
152
- variant: "default",
153
- },
154
- }
155
- )
156
-
157
- const inputContent = cva(
158
- "flex min-w-0 flex-1 items-center gap-[var(--bh-input-content-gap)]",
159
- {
160
- variants: {
161
- size: {
162
- md: "px-[var(--bh-input-md-padding-x)]",
163
- lg: "px-[var(--bh-input-lg-padding-x)]",
164
- comfortable: "px-[var(--bh-input-comfortable-padding-x)]",
165
- },
166
- },
167
- defaultVariants: {
168
- size: "lg",
169
- },
170
- }
171
- )
172
-
173
- const inputControl = cva(
174
- [
175
- "min-w-0 flex-1 border-0 bg-transparent p-0 text-start",
176
- "text-[length:var(--bh-text-body-md-regular-font-size)]",
177
- "font-[var(--bh-text-body-md-regular-font-weight)]",
178
- "leading-[var(--bh-text-body-md-regular-line-height)]",
179
- "tracking-[var(--bh-text-body-md-regular-letter-spacing)]",
180
- "text-[var(--bh-content-default)] outline-none",
181
- "placeholder:text-[var(--bh-content-muted)]",
182
- "disabled:cursor-not-allowed disabled:text-[var(--bh-content-disabled)] disabled:placeholder:text-[var(--bh-content-disabled)]",
183
- ]
184
- )
185
-
186
- const inputTag = cva(
187
- [
188
- "inline-flex h-[var(--bh-input-tag-height)] shrink-0 items-center rounded-[var(--bh-control-md)] border border-solid",
189
- "border-[var(--bh-border-default)] bg-[var(--bh-interactive-secondary-default)] px-[var(--bh-input-tag-padding-x)]",
190
- "text-[length:var(--bh-text-body-xs-medium-font-size)]",
191
- "font-[var(--bh-text-body-xs-medium-font-weight)]",
192
- "leading-[var(--bh-text-body-xs-medium-line-height)]",
193
- "tracking-[var(--bh-text-body-xs-medium-letter-spacing)]",
194
- "text-[var(--bh-content-default)]",
195
- ],
196
- {
197
- variants: {
198
- disabled: {
199
- true:
200
- "border-[var(--bh-border-disabled)] bg-[var(--bh-interactive-secondary-disabled)] text-[var(--bh-content-disabled)]",
201
- false: "",
202
- },
203
- },
204
- defaultVariants: {
205
- disabled: false,
206
- },
207
- }
208
- )
209
-
210
- const inputSegment = cva(
211
- [
212
- "inline-flex h-full shrink-0 items-center justify-center gap-[var(--bh-input-segment-gap)]",
213
- "bg-transparent px-[var(--bh-input-segment-padding-x)]",
214
- "text-[length:var(--bh-text-body-md-regular-font-size)]",
215
- "font-[var(--bh-text-body-md-regular-font-weight)]",
216
- "leading-[var(--bh-text-body-md-regular-line-height)]",
217
- "tracking-[var(--bh-text-body-md-regular-letter-spacing)]",
218
- "text-[var(--bh-content-subtle)]",
219
- "disabled:text-[var(--bh-content-disabled)]",
220
- ],
221
- {
222
- variants: {
223
- side: {
224
- leading: "border-e border-[var(--bh-border-input)]",
225
- trailing: "border-s border-[var(--bh-border-input)]",
226
- },
227
- interactive: {
228
- true:
229
- "outline-none transition-[background-color,color] hover:bg-[var(--bh-interactive-secondary-hover)] focus-visible:bg-[var(--bh-interactive-secondary-hover)]",
230
- false: "",
231
- },
232
- disabled: {
233
- true:
234
- "border-[var(--bh-border-disabled)] text-[var(--bh-content-disabled)]",
235
- false: "",
236
- },
237
- },
238
- defaultVariants: {
239
- interactive: false,
240
- side: "leading",
241
- disabled: false,
242
- },
243
- }
244
- )
245
-
246
- const inputStepperButton = cva(
247
- [
248
- "inline-flex h-full w-[var(--bh-input-stepper-width,var(--bh-input-lg-height))] shrink-0 items-center justify-center",
249
- "bg-transparent text-[var(--bh-content-subtle)]",
250
- "outline-none transition-[background-color,color]",
251
- "hover:bg-[var(--bh-interactive-secondary-hover)] focus-visible:bg-[var(--bh-interactive-secondary-hover)]",
252
- "disabled:border-[var(--bh-border-disabled)] disabled:bg-transparent disabled:text-[var(--bh-content-disabled)]",
253
- ],
254
- {
255
- variants: {
256
- side: {
257
- leading: "border-e border-[var(--bh-border-input)]",
258
- trailing: "border-s border-[var(--bh-border-input)]",
259
- },
260
- },
261
- defaultVariants: {
262
- side: "trailing",
263
- },
264
- }
265
- )
266
-
267
- const Input = React.forwardRef<HTMLInputElement, InputProps>(
268
- (
269
- {
270
- className,
271
- controlClassName,
272
- helperClassName,
273
- inlineTagsClassName,
274
- labelClassName,
275
- surfaceClassName,
276
- buttonLabel,
277
- buttonProps,
278
- decreaseButtonProps,
279
- defaultValue,
280
- density,
281
- dir,
282
- disabled,
283
- errorMessage,
284
- hasHelperText,
285
- hasInformationIcon,
286
- hasLeadingIcon,
287
- hasLabel,
288
- id,
289
- increaseButtonProps,
290
- inlineLeadingAddon,
291
- inlineTrailingAddon,
292
- isOptional = false,
293
- isRequired = false,
294
- kind = "default",
295
- label,
296
- leadingAddon,
297
- leadingIcon,
298
- message,
299
- optionalText,
300
- placeholder,
301
- quantityLabel,
302
- readOnly,
303
- required,
304
- shortcut,
305
- shortcutKeys = inputCopy.shortcutKeys,
306
- showAtSign = false,
307
- showInfo = false,
308
- size,
309
- state = "default",
310
- tags,
311
- trailingAddon,
312
- trailingIcon,
313
- type,
314
- value,
315
- valueText,
316
- variant = "default",
317
- ...props
318
- },
319
- ref
320
- ) => {
321
- const generatedId = React.useId()
322
- const resolvedSize = resolveInputSize(density, size)
323
- const inputId = id || generatedId
324
- const helperId = `${inputId}-helper`
325
- const inputRef = React.useRef<HTMLInputElement | null>(null)
326
- const resolvedDir = dir
327
- const visualState = disabled ? "disabled" : state
328
- const isDisabled = visualState === "disabled"
329
- const isInvalid = visualState === "error" && !isDisabled
330
- const isSemanticallyRequired = isRequired || Boolean(required)
331
- const isTagInput = kind === "tags" || kind === "inline-tags"
332
- const isQuantity = kind === "quantity" || kind === "quantity-2"
333
- const selectedLabel = label
334
- const selectedOptionalText = optionalText
335
- const inferredPlaceholder = getInputPlaceholder({
336
- placeholder,
337
- })
338
- const inferredDefaultValue = getInputDefaultValue({
339
- defaultValue,
340
- isTagInput,
341
- state: visualState,
342
- value,
343
- valueText,
344
- })
345
- const helperText = getInputHelperText({
346
- errorMessage,
347
- isInvalid,
348
- message,
349
- })
350
- const shouldRenderLabel = hasLabel ?? hasRenderableContent(selectedLabel)
351
- const shouldRenderHelperText =
352
- hasHelperText ?? hasRenderableContent(helperText)
353
- const selectedTags = getInputTags({ kind, tags })
354
- const shouldShowLeadingIcon =
355
- shouldRenderLeadingIcon(kind) &&
356
- leadingIcon !== false &&
357
- (hasRenderableContent(leadingIcon) || showAtSign || Boolean(hasLeadingIcon))
358
- const shouldShowInformationIcon = shouldRenderInfoIcon(
359
- kind,
360
- trailingIcon !== false &&
361
- (hasRenderableContent(trailingIcon) || showInfo || Boolean(hasInformationIcon))
362
- )
363
-
364
- function focusInput(event: React.MouseEvent<HTMLDivElement>) {
365
- if (isDisabled || event.defaultPrevented) return
366
- if (event.target instanceof Element && event.target.closest("button, input, a, [tabindex]")) return
367
- inputRef.current?.focus()
368
- }
369
-
370
- function stepInput(direction: -1 | 1) {
371
- const input = inputRef.current
372
- if (!input || input.disabled || input.readOnly) return
373
- const previousValue = input.value
374
- const hasAnyStep = input.step === "any"
375
- // Native stepping owns min/max, decimal precision, and the step base.
376
- // `any` permits free entry; its buttons move by one unit.
377
- if (hasAnyStep) input.step = "1"
378
- try {
379
- if (direction === 1) input.stepUp()
380
- else input.stepDown()
381
- } finally {
382
- if (hasAnyStep) input.step = "any"
383
- }
384
- if (input.value !== previousValue) {
385
- // stepUp/stepDown do not emit input events. Dispatch through the native
386
- // event path so React onChange and controlled-value restoration work.
387
- const EventConstructor = input.ownerDocument.defaultView?.Event ?? Event
388
- input.dispatchEvent(new EventConstructor("input", { bubbles: true, composed: true }))
389
- }
390
- }
391
-
392
- function setInputRef(node: HTMLInputElement | null) {
393
- inputRef.current = node
394
-
395
- if (typeof ref === "function") {
396
- ref(node)
397
- } else if (ref) {
398
- ref.current = node
399
- }
400
- }
401
-
402
- const segmentContext = {
403
- buttonLabel,
404
- buttonProps,
405
- decreaseButtonProps,
406
- increaseButtonProps,
407
- disabled: isDisabled,
408
- kind,
409
- leadingAddon,
410
- quantityLabel,
411
- readOnly: Boolean(readOnly),
412
- onStep: stepInput,
413
- size: resolvedSize,
414
- trailingAddon,
415
- }
416
-
417
- return (
418
- <div
419
- data-kind={kind}
420
- data-density={density}
421
- data-size={resolvedSize}
422
- data-slot="input-root"
423
- data-state={visualState}
424
- data-variant={variant}
425
- dir={resolvedDir}
426
- className={cn(inputRoot(), className)}
427
- >
428
- {shouldRenderLabel ? (
429
- <InputLabel
430
- className={labelClassName}
431
- htmlFor={inputId}
432
- isOptional={isOptional}
433
- isRequired={isSemanticallyRequired}
434
- label={selectedLabel}
435
- optionalText={selectedOptionalText}
436
- />
437
- ) : null}
438
-
439
- <div
440
- data-slot="input-body"
441
- className={cn(
442
- "grid",
443
- shouldRenderLabel && "mt-[var(--bh-input-label-gap)]",
444
- selectedTags.inline.length > 0
445
- ? "gap-[var(--bh-input-inline-tags-gap)]"
446
- : "gap-[var(--bh-input-helper-gap)]"
447
- )}
448
- >
449
- <div
450
- aria-disabled={isDisabled || undefined}
451
- data-slot="input-surface"
452
- data-state={visualState}
453
- className={cn(
454
- inputSurface({
455
- size: resolvedSize,
456
- state: visualState,
457
- variant,
458
- }),
459
- surfaceClassName
460
- )}
461
- onClick={focusInput}
462
- >
463
- {renderInputSegment({ ...segmentContext, side: "leading" })}
464
-
465
- <span
466
- data-slot="input-content"
467
- className={cn(
468
- inputContent({ size: resolvedSize }),
469
- kind === "shortcut" &&
470
- resolvedSize === "lg" &&
471
- "px-[var(--bh-input-md-padding-x)]"
472
- )}
473
- >
474
- {shouldShowLeadingIcon ? (
475
- <InputLeadingIcon disabled={isDisabled} icon={leadingIcon} />
476
- ) : null}
477
-
478
- {kind === "inline-add-on" &&
479
- hasRenderableContent(inlineLeadingAddon) ? (
480
- <InputInlineAddon disabled={isDisabled}>
481
- {inlineLeadingAddon}
482
- </InputInlineAddon>
483
- ) : null}
484
-
485
- {selectedTags.inside.length > 0 ? (
486
- <InputTagList disabled={isDisabled} tags={selectedTags.inside} />
487
- ) : null}
488
-
489
- <input
490
- aria-describedby={shouldRenderHelperText ? helperId : undefined}
491
- aria-invalid={isInvalid || undefined}
492
- aria-required={isSemanticallyRequired || undefined}
493
- data-slot="input-control"
494
- data-state={visualState}
495
- defaultValue={inferredDefaultValue}
496
- disabled={isDisabled}
497
- id={inputId}
498
- placeholder={inferredPlaceholder}
499
- readOnly={readOnly}
500
- ref={setInputRef}
501
- required={required}
502
- type={isQuantity ? "number" : type}
503
- value={value}
504
- className={cn(
505
- inputControl(),
506
- isQuantity && "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
507
- controlClassName
508
- )}
509
- {...props}
510
- />
511
-
512
- {kind === "inline-add-on" &&
513
- hasRenderableContent(inlineTrailingAddon) ? (
514
- <InputInlineAddon disabled={isDisabled}>
515
- {inlineTrailingAddon}
516
- </InputInlineAddon>
517
- ) : null}
518
-
519
- {shouldShowInformationIcon ? (
520
- <InputInfoIcon
521
- disabled={isDisabled}
522
- invalid={isInvalid}
523
- icon={trailingIcon}
524
- />
525
- ) : null}
526
-
527
- {kind === "shortcut" ? (
528
- hasRenderableContent(shortcut) ? (
529
- <Shortcut disabled={isDisabled}>{shortcut}</Shortcut>
530
- ) : (
531
- <Shortcut disabled={isDisabled} keys={shortcutKeys} />
532
- )
533
- ) : null}
534
- </span>
535
-
536
- {renderInputSegment({ ...segmentContext, side: "trailing" })}
537
- </div>
538
-
539
- {selectedTags.inline.length > 0 ? (
540
- <InputTagList
541
- className={inlineTagsClassName}
542
- disabled={isDisabled}
543
- inline
544
- tags={selectedTags.inline}
545
- />
546
- ) : null}
547
-
548
- {shouldRenderHelperText ? (
549
- <InputHelperText
550
- className={helperClassName}
551
- id={helperId}
552
- invalid={isInvalid}
553
- >
554
- {helperText}
555
- </InputHelperText>
556
- ) : null}
557
- </div>
558
- </div>
559
- )
560
- }
561
- )
562
- Input.displayName = "Input"
563
-
564
- function InputLabel({
565
- className,
566
- htmlFor,
567
- isOptional,
568
- isRequired,
569
- label,
570
- optionalText,
571
- }: {
572
- className?: string
573
- htmlFor: string
574
- isOptional: boolean
575
- isRequired: boolean
576
- label: React.ReactNode
577
- optionalText: React.ReactNode
578
- }) {
579
- return (
580
- <label
581
- data-slot="input-label"
582
- htmlFor={htmlFor}
583
- className={cn(
584
- "flex w-full items-center gap-[var(--bh-input-inline-gap)] text-start",
585
- className
586
- )}
587
- >
588
- <span
589
- data-slot="input-label-text"
590
- dir="auto"
591
- className="min-w-0 shrink-0 whitespace-nowrap text-[length:var(--bh-text-body-sm-medium-font-size)] font-[var(--bh-text-body-sm-medium-font-weight)] leading-[var(--bh-text-body-sm-medium-line-height)] tracking-[var(--bh-text-body-sm-medium-letter-spacing)] text-[var(--bh-content-default)]"
592
- >
593
- {label}
594
- </span>
595
- {isRequired ? (
596
- <span
597
- aria-hidden="true"
598
- data-slot="input-label-required"
599
- className="shrink-0 whitespace-nowrap text-[length:var(--bh-text-body-xs-regular-font-size)] font-[var(--bh-text-body-xs-regular-font-weight)] leading-[var(--bh-text-body-xs-regular-line-height)] tracking-[var(--bh-text-body-xs-regular-letter-spacing)] text-[var(--bh-content-danger-default)]"
600
- >
601
- *
602
- </span>
603
- ) : null}
604
- {isOptional && hasRenderableContent(optionalText) ? (
605
- <span
606
- data-slot="input-label-optional"
607
- dir="auto"
608
- className="min-w-0 shrink-0 whitespace-nowrap text-[length:var(--bh-text-body-xs-regular-font-size)] font-[var(--bh-text-body-xs-regular-font-weight)] leading-[var(--bh-text-body-xs-regular-line-height)] tracking-[var(--bh-text-body-xs-regular-letter-spacing)] text-[var(--bh-content-subtle)]"
609
- >
610
- {optionalText}
611
- </span>
612
- ) : null}
613
- </label>
614
- )
615
- }
616
-
617
- function hasRenderableContent(content: React.ReactNode) {
618
- return (
619
- content !== undefined &&
620
- content !== null &&
621
- content !== false &&
622
- content !== ""
623
- )
624
- }
625
-
626
- function InputHelperText({
627
- children,
628
- className,
629
- id,
630
- invalid,
631
- }: {
632
- children: React.ReactNode
633
- className?: string
634
- id: string
635
- invalid: boolean
636
- }) {
637
- return (
638
- <p
639
- data-slot="input-helper-text"
640
- id={id}
641
- className={cn(
642
- "m-0 w-full text-start text-[length:var(--bh-text-body-xs-regular-font-size)]",
643
- "font-[var(--bh-text-body-xs-regular-font-weight)]",
644
- "leading-[var(--bh-text-body-xs-regular-line-height)]",
645
- "tracking-[var(--bh-text-body-xs-regular-letter-spacing)]",
646
- invalid ? "text-[var(--bh-content-danger-default)]" : "text-[var(--bh-content-subtle)]",
647
- className
648
- )}
649
- >
650
- <span data-slot="input-helper-label" dir="auto" className="min-w-0">
651
- {children}
652
- </span>
653
- </p>
654
- )
655
- }
656
-
657
- function InputLeadingIcon({
658
- disabled,
659
- icon,
660
- }: {
661
- disabled: boolean
662
- icon?: React.ReactNode
663
- }) {
664
- return (
665
- <span
666
- aria-hidden="true"
667
- data-slot="input-leading-icon"
668
- className={cn(
669
- "flex size-[var(--bh-input-icon-slot-size)] shrink-0 items-center justify-center text-[var(--bh-content-muted)]",
670
- "[&>img]:size-[var(--bh-input-icon-size)] [&>img]:shrink-0 [&>img]:object-contain",
671
- "[&>svg]:size-[var(--bh-input-icon-size)] [&>svg]:shrink-0",
672
- disabled && "text-[var(--bh-content-disabled)]"
673
- )}
674
- >
675
- {icon ?? (
676
- <AtSignIcon
677
- aria-hidden="true"
678
- className="size-[var(--bh-input-icon-size)]"
679
- focusable="false"
680
- strokeWidth="var(--bh-icon-stroke-225)"
681
- />
682
- )}
683
- </span>
684
- )
685
- }
686
-
687
- function InputInfoIcon({
688
- disabled,
689
- icon,
690
- invalid,
691
- }: {
692
- disabled: boolean
693
- icon?: React.ReactNode
694
- invalid: boolean
695
- }) {
696
- return (
697
- <span
698
- aria-hidden="true"
699
- data-slot="input-info-icon"
700
- className={cn(
701
- "flex size-[var(--bh-input-icon-size)] shrink-0 items-center justify-center text-[var(--bh-content-muted)]",
702
- invalid && "text-[var(--bh-content-danger-default)]",
703
- disabled && "text-[var(--bh-content-disabled)]"
704
- )}
705
- >
706
- {icon ?? (
707
- <InfoIcon
708
- aria-hidden="true"
709
- className="size-[var(--bh-input-info-icon-size)]"
710
- focusable="false"
711
- strokeWidth="var(--bh-icon-stroke-225)"
712
- />
713
- )}
714
- </span>
715
- )
716
- }
717
-
718
- function InputInlineAddon({
719
- children,
720
- disabled,
721
- }: {
722
- children: React.ReactNode
723
- disabled: boolean
724
- }) {
725
- return (
726
- <span
727
- data-slot="input-inline-addon"
728
- dir="auto"
729
- className={cn(
730
- "shrink-0 whitespace-nowrap text-[var(--bh-content-subtle)]",
731
- disabled && "text-[var(--bh-content-disabled)]"
732
- )}
733
- >
734
- {children}
735
- </span>
736
- )
737
- }
738
-
739
- function InputTagList({
740
- className,
741
- disabled,
742
- inline = false,
743
- tags,
744
- }: {
745
- className?: string
746
- disabled: boolean
747
- inline?: boolean
748
- tags: InputTag[]
749
- }) {
750
- return (
751
- <span
752
- data-inline={inline ? "true" : undefined}
753
- data-slot={inline ? "input-inline-tags" : "input-tags"}
754
- className={cn(
755
- "flex min-w-0 items-center gap-[var(--bh-input-inline-gap)]",
756
- inline && "flex-wrap",
757
- className
758
- )}
759
- >
760
- {tags.map((tag, index) => (
761
- <span
762
- key={tag.id || index}
763
- data-slot="input-tag"
764
- className={cn(inputTag({ disabled }))}
765
- >
766
- <span data-slot="input-tag-label" dir="auto" className="min-w-0 truncate">
767
- {tag.label}
768
- </span>
769
- </span>
770
- ))}
771
- </span>
772
- )
773
- }
774
-
775
- function InputSegment({
776
- buttonProps,
777
- children,
778
- disabled,
779
- interactive = false,
780
- side,
781
- }: {
782
- buttonProps?: InputActionButtonProps
783
- children: React.ReactNode
784
- disabled: boolean
785
- interactive?: boolean
786
- side: "leading" | "trailing"
787
- }) {
788
- const isDisabled = disabled || Boolean(interactive && buttonProps?.disabled)
789
- const className = cn(
790
- inputSegment({ disabled: isDisabled, interactive, side }),
791
- interactive && buttonProps?.className
792
- )
793
-
794
- if (!interactive) {
795
- return <span data-slot="input-segment" className={className}>{children}</span>
796
- }
797
-
798
- return (
799
- <button
800
- {...buttonProps}
801
- data-slot="input-segment"
802
- disabled={isDisabled}
803
- type={buttonProps?.type ?? "button"}
804
- className={className}
805
- >
806
- {children}
807
- </button>
808
- )
809
- }
810
-
811
- function InputStepperButton({
812
- buttonProps,
813
- children,
814
- disabled,
815
- label,
816
- onStep,
817
- side,
818
- size,
819
- }: {
820
- buttonProps?: InputStepperButtonProps
821
- children: React.ReactNode
822
- disabled: boolean
823
- label: string
824
- onStep: () => void
825
- side: "leading" | "trailing"
826
- size: InputSize
827
- }) {
828
- return (
829
- <button
830
- {...buttonProps}
831
- aria-label={buttonProps?.["aria-label"] ?? label}
832
- data-side={side}
833
- data-slot="input-stepper-button"
834
- disabled={disabled || buttonProps?.disabled}
835
- type="button"
836
- className={cn(
837
- inputStepperButton({ side }),
838
- size === "md" && "w-[var(--bh-input-md-height)]",
839
- size === "comfortable" && "w-[var(--bh-input-comfortable-height)]",
840
- buttonProps?.className
841
- )}
842
- onClick={(event) => {
843
- buttonProps?.onClick?.(event)
844
- if (!event.defaultPrevented) onStep()
845
- }}
846
- >
847
- {children}
848
- </button>
849
- )
850
- }
851
-
852
- type InputSegmentRenderContext = {
853
- buttonLabel?: React.ReactNode
854
- buttonProps?: InputActionButtonProps
855
- decreaseButtonProps?: InputStepperButtonProps
856
- increaseButtonProps?: InputStepperButtonProps
857
- disabled: boolean
858
- kind: InputKind
859
- leadingAddon?: React.ReactNode
860
- quantityLabel?: React.ReactNode
861
- readOnly: boolean
862
- onStep: (direction: -1 | 1) => void
863
- side: "leading" | "trailing"
864
- size: InputSize
865
- trailingAddon?: React.ReactNode
866
- }
867
-
868
- function renderInputSegment({
869
- buttonLabel, buttonProps, decreaseButtonProps, increaseButtonProps,
870
- disabled, kind, leadingAddon, onStep, quantityLabel, readOnly, side, size, trailingAddon,
871
- }: InputSegmentRenderContext) {
872
- if (kind === "quantity" || kind === "quantity-2") {
873
- const isDecrease = side === "leading"
874
- return (
875
- <InputStepperButton
876
- buttonProps={isDecrease ? decreaseButtonProps : increaseButtonProps}
877
- disabled={disabled || readOnly}
878
- label={formatStepperLabel(isDecrease ? "Decrease" : "Increase", quantityLabel)}
879
- onStep={() => onStep(isDecrease ? -1 : 1)}
880
- side={side}
881
- size={size}
882
- >
883
- {isDecrease ? <MinusIcon /> : <PlusIcon />}
884
- </InputStepperButton>
885
- )
886
- }
887
-
888
- if (kind === "add-on") {
889
- const addon = side === "leading" ? leadingAddon : trailingAddon
890
- return hasRenderableContent(addon) ? (
891
- <InputSegment disabled={disabled} side={side}>{addon}</InputSegment>
892
- ) : null
893
- }
894
-
895
- const isDropdown = kind === `${side}-dropdown`
896
- if (isDropdown || (kind === `${side}-button` && hasRenderableContent(buttonLabel))) {
897
- return (
898
- <InputSegment buttonProps={buttonProps} disabled={disabled} interactive side={side}>
899
- {buttonLabel}
900
- {isDropdown ? <ChevronIcon /> : null}
901
- </InputSegment>
902
- )
903
- }
904
- return null
905
- }
906
-
907
- function formatStepperLabel(action: "Decrease" | "Increase", quantityLabel?: React.ReactNode) {
908
- return typeof quantityLabel === "string" && quantityLabel.trim()
909
- ? `${action} ${quantityLabel}`
910
- : action
911
- }
912
-
913
- function ChevronIcon() {
914
- return (
915
- <ChevronDownIcon
916
- aria-hidden="true"
917
- className="size-[var(--bh-input-chevron-size)]"
918
- focusable="false"
919
- strokeWidth="var(--bh-icon-stroke-225)"
920
- />
921
- )
922
- }
923
-
924
- function MinusIcon() {
925
- return (
926
- <LucideMinusIcon
927
- aria-hidden="true"
928
- className="size-[var(--bh-input-stepper-icon-size)]"
929
- focusable="false"
930
- strokeWidth="var(--bh-icon-stroke-225)"
931
- />
932
- )
933
- }
934
-
935
- function PlusIcon() {
936
- return (
937
- <LucidePlusIcon
938
- aria-hidden="true"
939
- className="size-[var(--bh-input-stepper-icon-size)]"
940
- focusable="false"
941
- strokeWidth="var(--bh-icon-stroke-225)"
942
- />
943
- )
944
- }
945
-
946
- function shouldRenderLeadingIcon(kind: InputKind) {
947
- return [
948
- "default",
949
- "shortcut",
950
- "tags",
951
- "inline-tags",
952
- ].includes(kind)
953
- }
954
-
955
- function shouldRenderInfoIcon(kind: InputKind, hasInformationIcon: boolean) {
956
- if (!hasInformationIcon) return false
957
-
958
- return ![
959
- "shortcut",
960
- "leading-dropdown",
961
- "leading-button",
962
- "trailing-dropdown",
963
- "trailing-button",
964
- "quantity",
965
- "quantity-2",
966
- ].includes(kind)
967
- }
968
-
969
- function resolveInputSize(
970
- density: ControlDensity | undefined,
971
- size: InputSize | undefined
972
- ): InputSize {
973
- if (density === "compact") return "md"
974
- if (density === "default") return "lg"
975
- if (density === "comfortable") return "comfortable"
976
- return size ?? "lg"
977
- }
978
-
979
- export { Input, inputContent, inputControl, inputRoot, inputSurface }
980
- export type {
981
- ControlDensity,
982
- InputActionButtonProps,
983
- InputKind,
984
- InputProps,
985
- InputSize,
986
- InputStepperButtonProps,
987
- InputTag,
988
- InputVariant,
989
- InputVisualState,
990
- }
1
+ import * as React from "react"
2
+ import { cva } from "class-variance-authority"
3
+ import {
4
+ AtSignIcon,
5
+ ChevronDownIcon,
6
+ InfoIcon,
7
+ MinusIcon as LucideMinusIcon,
8
+ PlusIcon as LucidePlusIcon,
9
+ } from "lucide-react"
10
+
11
+ import { cn } from "@/lib/utils"
12
+ import { Shortcut } from "@/components/ui/kbd"
13
+ import {
14
+ getInputDefaultValue,
15
+ getInputHelperText,
16
+ getInputPlaceholder,
17
+ getInputTags,
18
+ inputCopy,
19
+ type InputKind,
20
+ type InputTag,
21
+ type InputVisualState,
22
+ } from "./input-content"
23
+
24
+ type ControlDensity = "compact" | "default" | "comfortable"
25
+ type InputSize = "md" | "lg" | "comfortable"
26
+ type InputVariant = "default" | "soft"
27
+
28
+ type InputNativeProps = Omit<
29
+ React.ComponentProps<"input">,
30
+ "className" | "dir" | "size"
31
+ >
32
+
33
+ type InputActionButtonProps = Omit<React.ComponentProps<"button">, "children">
34
+ type InputStepperButtonProps = Omit<InputActionButtonProps, "type">
35
+
36
+ type InputModeRenderProps = {
37
+ buttonLabel?: React.ReactNode
38
+ /** Native props for the button or dropdown segment, including events and ARIA. */
39
+ buttonProps?: InputActionButtonProps
40
+ decreaseButtonProps?: InputStepperButtonProps
41
+ increaseButtonProps?: InputStepperButtonProps
42
+ inlineLeadingAddon?: React.ReactNode
43
+ inlineTagsClassName?: string
44
+ inlineTrailingAddon?: React.ReactNode
45
+ leadingAddon?: React.ReactNode
46
+ quantityLabel?: React.ReactNode
47
+ shortcut?: React.ReactNode
48
+ shortcutKeys?: readonly string[]
49
+ tags?: InputTag[]
50
+ trailingAddon?: React.ReactNode
51
+ }
52
+
53
+ type InputKindSpecificProps = InputModeRenderProps &
54
+ (
55
+ | { kind?: "default" }
56
+ | { kind: "shortcut" }
57
+ | { kind: "add-on" }
58
+ | { kind: "inline-add-on" }
59
+ | { kind: "tags" | "inline-tags" }
60
+ | {
61
+ kind:
62
+ | "leading-dropdown"
63
+ | "trailing-dropdown"
64
+ | "leading-button"
65
+ | "trailing-button"
66
+ }
67
+ | { kind: "quantity" | "quantity-2" }
68
+ )
69
+
70
+ type InputDynamicKindProps = { kind?: InputKind } & InputModeRenderProps
71
+
72
+ type InputCommonProps = InputNativeProps & {
73
+ className?: string
74
+ controlClassName?: string
75
+ density?: ControlDensity
76
+ helperClassName?: string
77
+ labelClassName?: string
78
+ surfaceClassName?: string
79
+ errorMessage?: React.ReactNode
80
+ hasHelperText?: boolean
81
+ /** @deprecated Use showInfo or an explicit trailingIcon. */
82
+ hasInformationIcon?: boolean
83
+ /** @deprecated Use showAtSign or an explicit leadingIcon. */
84
+ hasLeadingIcon?: boolean
85
+ hasLabel?: boolean
86
+ isOptional?: boolean
87
+ /** Shows the marker and exposes aria-required without enabling native validation. */
88
+ isRequired?: boolean
89
+ label?: React.ReactNode
90
+ leadingIcon?: React.ReactNode | false
91
+ message?: React.ReactNode
92
+ optionalText?: React.ReactNode
93
+ showAtSign?: boolean
94
+ showInfo?: boolean
95
+ size?: InputSize
96
+ state?: InputVisualState
97
+ trailingIcon?: React.ReactNode | false
98
+ valueText?: string
99
+ variant?: InputVariant
100
+ dir?: "ltr" | "rtl" | "auto"
101
+ }
102
+
103
+ type InputProps = InputCommonProps & (InputKindSpecificProps | InputDynamicKindProps)
104
+
105
+ const inputRoot = cva("grid w-[var(--bh-input-width)] max-w-full")
106
+
107
+ const inputSurface = cva(
108
+ [
109
+ "group/input-surface relative flex w-full overflow-hidden rounded-[var(--bh-input-radius)]",
110
+ "transition-[background-color,box-shadow]",
111
+ "[--bh-input-border:var(--bh-border-input)] [--elevation-input:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-input-border,var(--bh-border-input)),var(--elevation-component-default)]",
112
+ "[--elevation-input-surface:var(--elevation-component-default)]",
113
+ "[--elevation-input-overlay:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-input-border,var(--bh-border-input))]",
114
+ "[--elevation-input-focus-surface:var(--elevation-input-focus-ring)]",
115
+ "[--elevation-input-focus-overlay:none]",
116
+ "[--elevation-input-disabled-overlay:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-border-disabled)]",
117
+ "after:pointer-events-none after:absolute after:inset-0 after:z-[var(--bh-z-raised)] after:rounded-[inherit] after:[box-shadow:var(--elevation-input-overlay)] after:content-['']",
118
+ "focus-within:shadow-[var(--elevation-input-focus-surface)] focus-within:after:[box-shadow:var(--elevation-input-focus-overlay)]",
119
+ ],
120
+ {
121
+ variants: {
122
+ size: {
123
+ md: "h-[var(--bh-input-md-height)]",
124
+ lg: "h-[var(--bh-input-lg-height)]",
125
+ comfortable: "h-[var(--bh-input-comfortable-height)]",
126
+ },
127
+ variant: {
128
+ default: "bg-[var(--bh-interactive-input-default)]",
129
+ soft: "bg-[var(--bh-interactive-input-soft)]",
130
+ },
131
+ state: {
132
+ default:
133
+ "shadow-[var(--elevation-input-surface)]",
134
+ filled:
135
+ "shadow-[var(--elevation-input-surface)]",
136
+ error:
137
+ "[--bh-input-border:var(--bh-border-danger-strong)] shadow-[var(--elevation-input-surface)] focus-within:shadow-[var(--elevation-input-surface)] focus-within:after:[box-shadow:var(--elevation-input-overlay)]",
138
+ disabled:
139
+ "[--bh-input-border:var(--bh-border-disabled)] bg-[var(--bh-interactive-input-disabled)] shadow-none after:[box-shadow:var(--elevation-input-disabled-overlay)]",
140
+ },
141
+ },
142
+ compoundVariants: [
143
+ {
144
+ variant: "soft",
145
+ state: "disabled",
146
+ class: "bg-[var(--bh-interactive-input-soft-disabled)]",
147
+ },
148
+ ],
149
+ defaultVariants: {
150
+ size: "lg",
151
+ state: "default",
152
+ variant: "default",
153
+ },
154
+ }
155
+ )
156
+
157
+ const inputContent = cva(
158
+ "flex min-w-0 flex-1 items-center gap-[var(--bh-input-content-gap)]",
159
+ {
160
+ variants: {
161
+ size: {
162
+ // Library Input frame: 8px content padding, and the value starts 4px further in when nothing precedes it,
163
+ // so text sits 12px from the edge and trailing icons 8px.
164
+ md: "px-[var(--bh-input-content-padding-x)] [&>[data-slot=input-control]:first-child]:ps-[var(--bh-input-value-inset)]",
165
+ lg: "px-[var(--bh-input-content-padding-x)] [&>[data-slot=input-control]:first-child]:ps-[var(--bh-input-value-inset)]",
166
+ comfortable: "px-[var(--bh-input-comfortable-padding-x)]",
167
+ },
168
+ },
169
+ defaultVariants: {
170
+ size: "lg",
171
+ },
172
+ }
173
+ )
174
+
175
+ const inputControl = cva(
176
+ [
177
+ "min-w-0 flex-1 border-0 bg-transparent p-0 text-start",
178
+ "text-[length:var(--bh-text-body-md-regular-font-size)]",
179
+ "font-[var(--bh-text-body-md-regular-font-weight)]",
180
+ "leading-[var(--bh-text-body-md-regular-line-height)]",
181
+ "tracking-[var(--bh-text-body-md-regular-letter-spacing)]",
182
+ "text-[var(--bh-content-default)] outline-none",
183
+ "placeholder:text-[var(--bh-content-muted)]",
184
+ "disabled:cursor-not-allowed disabled:text-[var(--bh-content-disabled)] disabled:placeholder:text-[var(--bh-content-disabled)]",
185
+ ]
186
+ )
187
+
188
+ const inputTag = cva(
189
+ [
190
+ "inline-flex h-[var(--bh-input-tag-height)] shrink-0 items-center rounded-[var(--bh-control-md)] border border-solid",
191
+ "border-[var(--bh-border-default)] bg-[var(--bh-interactive-secondary-default)] px-[var(--bh-input-tag-padding-x)]",
192
+ "text-[length:var(--bh-text-body-xs-medium-font-size)]",
193
+ "font-[var(--bh-text-body-xs-medium-font-weight)]",
194
+ "leading-[var(--bh-text-body-xs-medium-line-height)]",
195
+ "tracking-[var(--bh-text-body-xs-medium-letter-spacing)]",
196
+ "text-[var(--bh-content-default)]",
197
+ ],
198
+ {
199
+ variants: {
200
+ disabled: {
201
+ true:
202
+ "border-[var(--bh-border-disabled)] bg-[var(--bh-interactive-secondary-disabled)] text-[var(--bh-content-disabled)]",
203
+ false: "",
204
+ },
205
+ },
206
+ defaultVariants: {
207
+ disabled: false,
208
+ },
209
+ }
210
+ )
211
+
212
+ const inputSegment = cva(
213
+ [
214
+ "inline-flex h-full shrink-0 items-center justify-center gap-[var(--bh-input-segment-gap)]",
215
+ "bg-transparent px-[var(--bh-input-segment-padding-x)]",
216
+ "text-[length:var(--bh-text-body-md-regular-font-size)]",
217
+ "font-[var(--bh-text-body-md-regular-font-weight)]",
218
+ "leading-[var(--bh-text-body-md-regular-line-height)]",
219
+ "tracking-[var(--bh-text-body-md-regular-letter-spacing)]",
220
+ "text-[var(--bh-content-subtle)]",
221
+ "disabled:text-[var(--bh-content-disabled)]",
222
+ ],
223
+ {
224
+ variants: {
225
+ side: {
226
+ leading: "border-e border-[var(--bh-border-input)]",
227
+ trailing: "border-s border-[var(--bh-border-input)]",
228
+ },
229
+ interactive: {
230
+ true:
231
+ "outline-none transition-[background-color,color] hover:bg-[var(--bh-interactive-secondary-hover)] focus-visible:bg-[var(--bh-interactive-secondary-hover)]",
232
+ false: "",
233
+ },
234
+ disabled: {
235
+ true:
236
+ "border-[var(--bh-border-disabled)] text-[var(--bh-content-disabled)]",
237
+ false: "",
238
+ },
239
+ },
240
+ defaultVariants: {
241
+ interactive: false,
242
+ side: "leading",
243
+ disabled: false,
244
+ },
245
+ }
246
+ )
247
+
248
+ const inputStepperButton = cva(
249
+ [
250
+ "inline-flex h-full w-[var(--bh-input-stepper-width,var(--bh-input-lg-height))] shrink-0 items-center justify-center",
251
+ "bg-transparent text-[var(--bh-content-subtle)]",
252
+ "outline-none transition-[background-color,color]",
253
+ "hover:bg-[var(--bh-interactive-secondary-hover)] focus-visible:bg-[var(--bh-interactive-secondary-hover)]",
254
+ "disabled:border-[var(--bh-border-disabled)] disabled:bg-transparent disabled:text-[var(--bh-content-disabled)]",
255
+ ],
256
+ {
257
+ variants: {
258
+ side: {
259
+ leading: "border-e border-[var(--bh-border-input)]",
260
+ trailing: "border-s border-[var(--bh-border-input)]",
261
+ },
262
+ },
263
+ defaultVariants: {
264
+ side: "trailing",
265
+ },
266
+ }
267
+ )
268
+
269
+ const Input = React.forwardRef<HTMLInputElement, InputProps>(
270
+ (
271
+ {
272
+ className,
273
+ controlClassName,
274
+ helperClassName,
275
+ inlineTagsClassName,
276
+ labelClassName,
277
+ surfaceClassName,
278
+ buttonLabel,
279
+ buttonProps,
280
+ decreaseButtonProps,
281
+ defaultValue,
282
+ density,
283
+ dir,
284
+ disabled,
285
+ errorMessage,
286
+ hasHelperText,
287
+ hasInformationIcon,
288
+ hasLeadingIcon,
289
+ hasLabel,
290
+ id,
291
+ increaseButtonProps,
292
+ inlineLeadingAddon,
293
+ inlineTrailingAddon,
294
+ isOptional = false,
295
+ isRequired = false,
296
+ kind = "default",
297
+ label,
298
+ leadingAddon,
299
+ leadingIcon,
300
+ message,
301
+ optionalText,
302
+ placeholder,
303
+ quantityLabel,
304
+ readOnly,
305
+ required,
306
+ shortcut,
307
+ shortcutKeys = inputCopy.shortcutKeys,
308
+ showAtSign = false,
309
+ showInfo = false,
310
+ size,
311
+ state = "default",
312
+ tags,
313
+ trailingAddon,
314
+ trailingIcon,
315
+ type,
316
+ value,
317
+ valueText,
318
+ variant = "default",
319
+ ...props
320
+ },
321
+ ref
322
+ ) => {
323
+ const generatedId = React.useId()
324
+ const resolvedSize = resolveInputSize(density, size)
325
+ const inputId = id || generatedId
326
+ const helperId = `${inputId}-helper`
327
+ const inputRef = React.useRef<HTMLInputElement | null>(null)
328
+ const resolvedDir = dir
329
+ const visualState = disabled ? "disabled" : state
330
+ const isDisabled = visualState === "disabled"
331
+ const isInvalid = visualState === "error" && !isDisabled
332
+ const isSemanticallyRequired = isRequired || Boolean(required)
333
+ const isTagInput = kind === "tags" || kind === "inline-tags"
334
+ const isQuantity = kind === "quantity" || kind === "quantity-2"
335
+ const selectedLabel = label
336
+ const selectedOptionalText = optionalText
337
+ const inferredPlaceholder = getInputPlaceholder({
338
+ placeholder,
339
+ })
340
+ const inferredDefaultValue = getInputDefaultValue({
341
+ defaultValue,
342
+ isTagInput,
343
+ state: visualState,
344
+ value,
345
+ valueText,
346
+ })
347
+ const helperText = getInputHelperText({
348
+ errorMessage,
349
+ isInvalid,
350
+ message,
351
+ })
352
+ const shouldRenderLabel = hasLabel ?? hasRenderableContent(selectedLabel)
353
+ const shouldRenderHelperText =
354
+ hasHelperText ?? hasRenderableContent(helperText)
355
+ const selectedTags = getInputTags({ kind, tags })
356
+ const shouldShowLeadingIcon =
357
+ shouldRenderLeadingIcon(kind) &&
358
+ leadingIcon !== false &&
359
+ (hasRenderableContent(leadingIcon) || showAtSign || Boolean(hasLeadingIcon))
360
+ const shouldShowInformationIcon = shouldRenderInfoIcon(
361
+ kind,
362
+ trailingIcon !== false &&
363
+ (hasRenderableContent(trailingIcon) || showInfo || Boolean(hasInformationIcon))
364
+ )
365
+
366
+ function focusInput(event: React.MouseEvent<HTMLDivElement>) {
367
+ if (isDisabled || event.defaultPrevented) return
368
+ if (event.target instanceof Element && event.target.closest("button, input, a, [tabindex]")) return
369
+ inputRef.current?.focus()
370
+ }
371
+
372
+ function stepInput(direction: -1 | 1) {
373
+ const input = inputRef.current
374
+ if (!input || input.disabled || input.readOnly) return
375
+ const previousValue = input.value
376
+ const hasAnyStep = input.step === "any"
377
+ // Native stepping owns min/max, decimal precision, and the step base.
378
+ // `any` permits free entry; its buttons move by one unit.
379
+ if (hasAnyStep) input.step = "1"
380
+ try {
381
+ if (direction === 1) input.stepUp()
382
+ else input.stepDown()
383
+ } finally {
384
+ if (hasAnyStep) input.step = "any"
385
+ }
386
+ if (input.value !== previousValue) {
387
+ // stepUp/stepDown do not emit input events. Dispatch through the native
388
+ // event path so React onChange and controlled-value restoration work.
389
+ const EventConstructor = input.ownerDocument.defaultView?.Event ?? Event
390
+ input.dispatchEvent(new EventConstructor("input", { bubbles: true, composed: true }))
391
+ }
392
+ }
393
+
394
+ function setInputRef(node: HTMLInputElement | null) {
395
+ inputRef.current = node
396
+
397
+ if (typeof ref === "function") {
398
+ ref(node)
399
+ } else if (ref) {
400
+ ref.current = node
401
+ }
402
+ }
403
+
404
+ const segmentContext = {
405
+ buttonLabel,
406
+ buttonProps,
407
+ decreaseButtonProps,
408
+ increaseButtonProps,
409
+ disabled: isDisabled,
410
+ kind,
411
+ leadingAddon,
412
+ quantityLabel,
413
+ readOnly: Boolean(readOnly),
414
+ onStep: stepInput,
415
+ size: resolvedSize,
416
+ trailingAddon,
417
+ }
418
+
419
+ return (
420
+ <div
421
+ data-kind={kind}
422
+ data-density={density}
423
+ data-size={resolvedSize}
424
+ data-slot="input-root"
425
+ data-state={visualState}
426
+ data-variant={variant}
427
+ dir={resolvedDir}
428
+ className={cn(inputRoot(), className)}
429
+ >
430
+ {shouldRenderLabel ? (
431
+ <InputLabel
432
+ className={labelClassName}
433
+ htmlFor={inputId}
434
+ isOptional={isOptional}
435
+ isRequired={isSemanticallyRequired}
436
+ label={selectedLabel}
437
+ optionalText={selectedOptionalText}
438
+ />
439
+ ) : null}
440
+
441
+ <div
442
+ data-slot="input-body"
443
+ className={cn(
444
+ "grid",
445
+ shouldRenderLabel && "mt-[var(--bh-input-label-gap)]",
446
+ selectedTags.inline.length > 0
447
+ ? "gap-[var(--bh-input-inline-tags-gap)]"
448
+ : "gap-[var(--bh-input-helper-gap)]"
449
+ )}
450
+ >
451
+ <div
452
+ aria-disabled={isDisabled || undefined}
453
+ data-slot="input-surface"
454
+ data-state={visualState}
455
+ className={cn(
456
+ inputSurface({
457
+ size: resolvedSize,
458
+ state: visualState,
459
+ variant,
460
+ }),
461
+ surfaceClassName
462
+ )}
463
+ onClick={focusInput}
464
+ >
465
+ {renderInputSegment({ ...segmentContext, side: "leading" })}
466
+
467
+ <span
468
+ data-slot="input-content"
469
+ className={cn(
470
+ inputContent({ size: resolvedSize }),
471
+ kind === "shortcut" &&
472
+ resolvedSize === "lg" &&
473
+ "px-[var(--bh-input-md-padding-x)]"
474
+ )}
475
+ >
476
+ {shouldShowLeadingIcon ? (
477
+ <InputLeadingIcon disabled={isDisabled} icon={leadingIcon} />
478
+ ) : null}
479
+
480
+ {kind === "inline-add-on" &&
481
+ hasRenderableContent(inlineLeadingAddon) ? (
482
+ <InputInlineAddon disabled={isDisabled}>
483
+ {inlineLeadingAddon}
484
+ </InputInlineAddon>
485
+ ) : null}
486
+
487
+ {selectedTags.inside.length > 0 ? (
488
+ <InputTagList disabled={isDisabled} tags={selectedTags.inside} />
489
+ ) : null}
490
+
491
+ <input
492
+ aria-describedby={shouldRenderHelperText ? helperId : undefined}
493
+ aria-invalid={isInvalid || undefined}
494
+ aria-required={isSemanticallyRequired || undefined}
495
+ data-slot="input-control"
496
+ data-state={visualState}
497
+ defaultValue={inferredDefaultValue}
498
+ disabled={isDisabled}
499
+ id={inputId}
500
+ placeholder={inferredPlaceholder}
501
+ readOnly={readOnly}
502
+ ref={setInputRef}
503
+ required={required}
504
+ type={isQuantity ? "number" : type}
505
+ value={value}
506
+ className={cn(
507
+ inputControl(),
508
+ isQuantity && "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
509
+ controlClassName
510
+ )}
511
+ {...props}
512
+ />
513
+
514
+ {kind === "inline-add-on" &&
515
+ hasRenderableContent(inlineTrailingAddon) ? (
516
+ <InputInlineAddon disabled={isDisabled}>
517
+ {inlineTrailingAddon}
518
+ </InputInlineAddon>
519
+ ) : null}
520
+
521
+ {shouldShowInformationIcon ? (
522
+ <InputInfoIcon
523
+ disabled={isDisabled}
524
+ invalid={isInvalid}
525
+ icon={trailingIcon}
526
+ />
527
+ ) : null}
528
+
529
+ {kind === "shortcut" ? (
530
+ hasRenderableContent(shortcut) ? (
531
+ <Shortcut disabled={isDisabled}>{shortcut}</Shortcut>
532
+ ) : (
533
+ <Shortcut disabled={isDisabled} keys={shortcutKeys} />
534
+ )
535
+ ) : null}
536
+ </span>
537
+
538
+ {renderInputSegment({ ...segmentContext, side: "trailing" })}
539
+ </div>
540
+
541
+ {selectedTags.inline.length > 0 ? (
542
+ <InputTagList
543
+ className={inlineTagsClassName}
544
+ disabled={isDisabled}
545
+ inline
546
+ tags={selectedTags.inline}
547
+ />
548
+ ) : null}
549
+
550
+ {shouldRenderHelperText ? (
551
+ <InputHelperText
552
+ className={helperClassName}
553
+ id={helperId}
554
+ invalid={isInvalid}
555
+ >
556
+ {helperText}
557
+ </InputHelperText>
558
+ ) : null}
559
+ </div>
560
+ </div>
561
+ )
562
+ }
563
+ )
564
+ Input.displayName = "Input"
565
+
566
+ function InputLabel({
567
+ className,
568
+ htmlFor,
569
+ isOptional,
570
+ isRequired,
571
+ label,
572
+ optionalText,
573
+ }: {
574
+ className?: string
575
+ htmlFor: string
576
+ isOptional: boolean
577
+ isRequired: boolean
578
+ label: React.ReactNode
579
+ optionalText: React.ReactNode
580
+ }) {
581
+ return (
582
+ <label
583
+ data-slot="input-label"
584
+ htmlFor={htmlFor}
585
+ className={cn(
586
+ "flex w-full items-center gap-[var(--bh-input-inline-gap)] text-start",
587
+ className
588
+ )}
589
+ >
590
+ <span
591
+ data-slot="input-label-text"
592
+ dir="auto"
593
+ className="min-w-0 shrink-0 whitespace-nowrap text-[length:var(--bh-text-body-sm-medium-font-size)] font-[var(--bh-text-body-sm-medium-font-weight)] leading-[var(--bh-text-body-sm-medium-line-height)] tracking-[var(--bh-text-body-sm-medium-letter-spacing)] text-[var(--bh-content-default)]"
594
+ >
595
+ {label}
596
+ </span>
597
+ {isRequired ? (
598
+ <span
599
+ aria-hidden="true"
600
+ data-slot="input-label-required"
601
+ className="shrink-0 whitespace-nowrap text-[length:var(--bh-text-body-xs-regular-font-size)] font-[var(--bh-text-body-xs-regular-font-weight)] leading-[var(--bh-text-body-xs-regular-line-height)] tracking-[var(--bh-text-body-xs-regular-letter-spacing)] text-[var(--bh-content-danger-default)]"
602
+ >
603
+ *
604
+ </span>
605
+ ) : null}
606
+ {isOptional && hasRenderableContent(optionalText) ? (
607
+ <span
608
+ data-slot="input-label-optional"
609
+ dir="auto"
610
+ className="min-w-0 shrink-0 whitespace-nowrap text-[length:var(--bh-text-body-xs-regular-font-size)] font-[var(--bh-text-body-xs-regular-font-weight)] leading-[var(--bh-text-body-xs-regular-line-height)] tracking-[var(--bh-text-body-xs-regular-letter-spacing)] text-[var(--bh-content-subtle)]"
611
+ >
612
+ {optionalText}
613
+ </span>
614
+ ) : null}
615
+ </label>
616
+ )
617
+ }
618
+
619
+ function hasRenderableContent(content: React.ReactNode) {
620
+ return (
621
+ content !== undefined &&
622
+ content !== null &&
623
+ content !== false &&
624
+ content !== ""
625
+ )
626
+ }
627
+
628
+ function InputHelperText({
629
+ children,
630
+ className,
631
+ id,
632
+ invalid,
633
+ }: {
634
+ children: React.ReactNode
635
+ className?: string
636
+ id: string
637
+ invalid: boolean
638
+ }) {
639
+ return (
640
+ <p
641
+ data-slot="input-helper-text"
642
+ id={id}
643
+ className={cn(
644
+ "m-0 w-full text-start text-[length:var(--bh-text-body-xs-regular-font-size)]",
645
+ "font-[var(--bh-text-body-xs-regular-font-weight)]",
646
+ "leading-[var(--bh-text-body-xs-regular-line-height)]",
647
+ "tracking-[var(--bh-text-body-xs-regular-letter-spacing)]",
648
+ invalid ? "text-[var(--bh-content-danger-default)]" : "text-[var(--bh-content-subtle)]",
649
+ className
650
+ )}
651
+ >
652
+ <span data-slot="input-helper-label" dir="auto" className="min-w-0">
653
+ {children}
654
+ </span>
655
+ </p>
656
+ )
657
+ }
658
+
659
+ function InputLeadingIcon({
660
+ disabled,
661
+ icon,
662
+ }: {
663
+ disabled: boolean
664
+ icon?: React.ReactNode
665
+ }) {
666
+ return (
667
+ <span
668
+ aria-hidden="true"
669
+ data-slot="input-leading-icon"
670
+ className={cn(
671
+ "flex size-[var(--bh-input-icon-slot-size)] shrink-0 items-center justify-center text-[var(--bh-content-muted)]",
672
+ "[&>img]:size-[var(--bh-input-icon-size)] [&>img]:shrink-0 [&>img]:object-contain",
673
+ "[&>svg]:size-[var(--bh-input-icon-size)] [&>svg]:shrink-0",
674
+ disabled && "text-[var(--bh-content-disabled)]"
675
+ )}
676
+ >
677
+ {icon ?? (
678
+ <AtSignIcon
679
+ aria-hidden="true"
680
+ className="size-[var(--bh-input-icon-size)]"
681
+ focusable="false"
682
+ strokeWidth="var(--bh-icon-stroke-225)"
683
+ />
684
+ )}
685
+ </span>
686
+ )
687
+ }
688
+
689
+ function InputInfoIcon({
690
+ disabled,
691
+ icon,
692
+ invalid,
693
+ }: {
694
+ disabled: boolean
695
+ icon?: React.ReactNode
696
+ invalid: boolean
697
+ }) {
698
+ return (
699
+ <span
700
+ aria-hidden="true"
701
+ data-slot="input-info-icon"
702
+ className={cn(
703
+ "flex size-[var(--bh-input-icon-size)] shrink-0 items-center justify-center text-[var(--bh-content-muted)]",
704
+ invalid && "text-[var(--bh-content-danger-default)]",
705
+ disabled && "text-[var(--bh-content-disabled)]"
706
+ )}
707
+ >
708
+ {icon ?? (
709
+ <InfoIcon
710
+ aria-hidden="true"
711
+ className="size-[var(--bh-input-info-icon-size)]"
712
+ focusable="false"
713
+ strokeWidth="var(--bh-icon-stroke-225)"
714
+ />
715
+ )}
716
+ </span>
717
+ )
718
+ }
719
+
720
+ function InputInlineAddon({
721
+ children,
722
+ disabled,
723
+ }: {
724
+ children: React.ReactNode
725
+ disabled: boolean
726
+ }) {
727
+ return (
728
+ <span
729
+ data-slot="input-inline-addon"
730
+ dir="auto"
731
+ className={cn(
732
+ "shrink-0 whitespace-nowrap text-[var(--bh-content-subtle)]",
733
+ disabled && "text-[var(--bh-content-disabled)]"
734
+ )}
735
+ >
736
+ {children}
737
+ </span>
738
+ )
739
+ }
740
+
741
+ function InputTagList({
742
+ className,
743
+ disabled,
744
+ inline = false,
745
+ tags,
746
+ }: {
747
+ className?: string
748
+ disabled: boolean
749
+ inline?: boolean
750
+ tags: InputTag[]
751
+ }) {
752
+ return (
753
+ <span
754
+ data-inline={inline ? "true" : undefined}
755
+ data-slot={inline ? "input-inline-tags" : "input-tags"}
756
+ className={cn(
757
+ "flex min-w-0 items-center gap-[var(--bh-input-inline-gap)]",
758
+ inline && "flex-wrap",
759
+ className
760
+ )}
761
+ >
762
+ {tags.map((tag, index) => (
763
+ <span
764
+ key={tag.id || index}
765
+ data-slot="input-tag"
766
+ className={cn(inputTag({ disabled }))}
767
+ >
768
+ <span data-slot="input-tag-label" dir="auto" className="min-w-0 truncate">
769
+ {tag.label}
770
+ </span>
771
+ </span>
772
+ ))}
773
+ </span>
774
+ )
775
+ }
776
+
777
+ function InputSegment({
778
+ buttonProps,
779
+ children,
780
+ disabled,
781
+ interactive = false,
782
+ side,
783
+ }: {
784
+ buttonProps?: InputActionButtonProps
785
+ children: React.ReactNode
786
+ disabled: boolean
787
+ interactive?: boolean
788
+ side: "leading" | "trailing"
789
+ }) {
790
+ const isDisabled = disabled || Boolean(interactive && buttonProps?.disabled)
791
+ const className = cn(
792
+ inputSegment({ disabled: isDisabled, interactive, side }),
793
+ interactive && buttonProps?.className
794
+ )
795
+
796
+ if (!interactive) {
797
+ return <span data-slot="input-segment" className={className}>{children}</span>
798
+ }
799
+
800
+ return (
801
+ <button
802
+ {...buttonProps}
803
+ data-slot="input-segment"
804
+ disabled={isDisabled}
805
+ type={buttonProps?.type ?? "button"}
806
+ className={className}
807
+ >
808
+ {children}
809
+ </button>
810
+ )
811
+ }
812
+
813
+ function InputStepperButton({
814
+ buttonProps,
815
+ children,
816
+ disabled,
817
+ label,
818
+ onStep,
819
+ side,
820
+ size,
821
+ }: {
822
+ buttonProps?: InputStepperButtonProps
823
+ children: React.ReactNode
824
+ disabled: boolean
825
+ label: string
826
+ onStep: () => void
827
+ side: "leading" | "trailing"
828
+ size: InputSize
829
+ }) {
830
+ return (
831
+ <button
832
+ {...buttonProps}
833
+ aria-label={buttonProps?.["aria-label"] ?? label}
834
+ data-side={side}
835
+ data-slot="input-stepper-button"
836
+ disabled={disabled || buttonProps?.disabled}
837
+ type="button"
838
+ className={cn(
839
+ inputStepperButton({ side }),
840
+ size === "md" && "w-[var(--bh-input-md-height)]",
841
+ size === "comfortable" && "w-[var(--bh-input-comfortable-height)]",
842
+ buttonProps?.className
843
+ )}
844
+ onClick={(event) => {
845
+ buttonProps?.onClick?.(event)
846
+ if (!event.defaultPrevented) onStep()
847
+ }}
848
+ >
849
+ {children}
850
+ </button>
851
+ )
852
+ }
853
+
854
+ type InputSegmentRenderContext = {
855
+ buttonLabel?: React.ReactNode
856
+ buttonProps?: InputActionButtonProps
857
+ decreaseButtonProps?: InputStepperButtonProps
858
+ increaseButtonProps?: InputStepperButtonProps
859
+ disabled: boolean
860
+ kind: InputKind
861
+ leadingAddon?: React.ReactNode
862
+ quantityLabel?: React.ReactNode
863
+ readOnly: boolean
864
+ onStep: (direction: -1 | 1) => void
865
+ side: "leading" | "trailing"
866
+ size: InputSize
867
+ trailingAddon?: React.ReactNode
868
+ }
869
+
870
+ function renderInputSegment({
871
+ buttonLabel, buttonProps, decreaseButtonProps, increaseButtonProps,
872
+ disabled, kind, leadingAddon, onStep, quantityLabel, readOnly, side, size, trailingAddon,
873
+ }: InputSegmentRenderContext) {
874
+ if (kind === "quantity" || kind === "quantity-2") {
875
+ const isDecrease = side === "leading"
876
+ return (
877
+ <InputStepperButton
878
+ buttonProps={isDecrease ? decreaseButtonProps : increaseButtonProps}
879
+ disabled={disabled || readOnly}
880
+ label={formatStepperLabel(isDecrease ? "Decrease" : "Increase", quantityLabel)}
881
+ onStep={() => onStep(isDecrease ? -1 : 1)}
882
+ side={side}
883
+ size={size}
884
+ >
885
+ {isDecrease ? <MinusIcon /> : <PlusIcon />}
886
+ </InputStepperButton>
887
+ )
888
+ }
889
+
890
+ if (kind === "add-on") {
891
+ const addon = side === "leading" ? leadingAddon : trailingAddon
892
+ return hasRenderableContent(addon) ? (
893
+ <InputSegment disabled={disabled} side={side}>{addon}</InputSegment>
894
+ ) : null
895
+ }
896
+
897
+ const isDropdown = kind === `${side}-dropdown`
898
+ if (isDropdown || (kind === `${side}-button` && hasRenderableContent(buttonLabel))) {
899
+ return (
900
+ <InputSegment buttonProps={buttonProps} disabled={disabled} interactive side={side}>
901
+ {buttonLabel}
902
+ {isDropdown ? <ChevronIcon /> : null}
903
+ </InputSegment>
904
+ )
905
+ }
906
+ return null
907
+ }
908
+
909
+ function formatStepperLabel(action: "Decrease" | "Increase", quantityLabel?: React.ReactNode) {
910
+ return typeof quantityLabel === "string" && quantityLabel.trim()
911
+ ? `${action} ${quantityLabel}`
912
+ : action
913
+ }
914
+
915
+ function ChevronIcon() {
916
+ return (
917
+ <ChevronDownIcon
918
+ aria-hidden="true"
919
+ className="size-[var(--bh-input-chevron-size)]"
920
+ focusable="false"
921
+ strokeWidth="var(--bh-icon-stroke-225)"
922
+ />
923
+ )
924
+ }
925
+
926
+ function MinusIcon() {
927
+ return (
928
+ <LucideMinusIcon
929
+ aria-hidden="true"
930
+ className="size-[var(--bh-input-stepper-icon-size)]"
931
+ focusable="false"
932
+ strokeWidth="var(--bh-icon-stroke-225)"
933
+ />
934
+ )
935
+ }
936
+
937
+ function PlusIcon() {
938
+ return (
939
+ <LucidePlusIcon
940
+ aria-hidden="true"
941
+ className="size-[var(--bh-input-stepper-icon-size)]"
942
+ focusable="false"
943
+ strokeWidth="var(--bh-icon-stroke-225)"
944
+ />
945
+ )
946
+ }
947
+
948
+ function shouldRenderLeadingIcon(kind: InputKind) {
949
+ return [
950
+ "default",
951
+ "shortcut",
952
+ "tags",
953
+ "inline-tags",
954
+ ].includes(kind)
955
+ }
956
+
957
+ function shouldRenderInfoIcon(kind: InputKind, hasInformationIcon: boolean) {
958
+ if (!hasInformationIcon) return false
959
+
960
+ return ![
961
+ "shortcut",
962
+ "leading-dropdown",
963
+ "leading-button",
964
+ "trailing-dropdown",
965
+ "trailing-button",
966
+ "quantity",
967
+ "quantity-2",
968
+ ].includes(kind)
969
+ }
970
+
971
+ function resolveInputSize(
972
+ density: ControlDensity | undefined,
973
+ size: InputSize | undefined
974
+ ): InputSize {
975
+ if (density === "compact") return "md"
976
+ if (density === "default") return "lg"
977
+ if (density === "comfortable") return "comfortable"
978
+ return size ?? "lg"
979
+ }
980
+
981
+ export { Input, inputContent, inputControl, inputRoot, inputSurface }
982
+ export type {
983
+ ControlDensity,
984
+ InputActionButtonProps,
985
+ InputKind,
986
+ InputProps,
987
+ InputSize,
988
+ InputStepperButtonProps,
989
+ InputTag,
990
+ InputVariant,
991
+ InputVisualState,
992
+ }