banhaten 0.4.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 (214) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/CONTRIBUTING.md +6 -4
  3. package/README.md +4 -2
  4. package/docs/design-spacing.md +13 -0
  5. package/docs/design-system/README.md +2 -0
  6. package/docs/design-system/appearances/default.md +1 -1
  7. package/docs/design-system/appearances/rounded.md +1 -1
  8. package/docs/design-system/appearances/sharp.md +1 -1
  9. package/docs/design-system/component-showcase-consistency-report.md +5 -5
  10. package/docs/design-system/components/README.md +15 -0
  11. package/docs/design-system/components/accordion.md +1 -1
  12. package/docs/design-system/components/activity-feed.md +1 -1
  13. package/docs/design-system/components/activity-heatmap.md +1 -1
  14. package/docs/design-system/components/agent-input.md +85 -0
  15. package/docs/design-system/components/alert-dialog.md +1 -1
  16. package/docs/design-system/components/alert.md +10 -4
  17. package/docs/design-system/components/app-header.md +97 -0
  18. package/docs/design-system/components/approval-card.md +134 -0
  19. package/docs/design-system/components/attribute.md +14 -2
  20. package/docs/design-system/components/avatar.md +1 -1
  21. package/docs/design-system/components/badge.md +1 -1
  22. package/docs/design-system/components/banner.md +1 -1
  23. package/docs/design-system/components/breadcrumbs.md +8 -3
  24. package/docs/design-system/components/button-group.md +1 -1
  25. package/docs/design-system/components/button.md +8 -5
  26. package/docs/design-system/components/card.md +15 -1
  27. package/docs/design-system/components/catalog-components.md +1 -1
  28. package/docs/design-system/components/chart.md +95 -24
  29. package/docs/design-system/components/checkbox.md +1 -1
  30. package/docs/design-system/components/collapsible.md +20 -6
  31. package/docs/design-system/components/command.md +1 -1
  32. package/docs/design-system/components/context-menu.md +1 -1
  33. package/docs/design-system/components/date-picker.md +1 -1
  34. package/docs/design-system/components/file-upload.md +1 -1
  35. package/docs/design-system/components/hover-card.md +1 -1
  36. package/docs/design-system/components/inline-citations.md +93 -0
  37. package/docs/design-system/components/input-group.md +1 -1
  38. package/docs/design-system/components/input.md +59 -3
  39. package/docs/design-system/components/item.md +1 -1
  40. package/docs/design-system/components/kbd.md +6 -4
  41. package/docs/design-system/components/menu.md +35 -4
  42. package/docs/design-system/components/menubar.md +1 -1
  43. package/docs/design-system/components/modal.md +2 -2
  44. package/docs/design-system/components/native-select.md +1 -1
  45. package/docs/design-system/components/navigation-menu.md +1 -1
  46. package/docs/design-system/components/onboarding-step-list-item.md +1 -1
  47. package/docs/design-system/components/page-header.md +58 -17
  48. package/docs/design-system/components/pagination.md +2 -2
  49. package/docs/design-system/components/popover.md +1 -1
  50. package/docs/design-system/components/progress-slider.md +1 -1
  51. package/docs/design-system/components/radio-card.md +1 -1
  52. package/docs/design-system/components/radio-group.md +1 -1
  53. package/docs/design-system/components/resizable.md +1 -1
  54. package/docs/design-system/components/rolling-count.md +61 -0
  55. package/docs/design-system/components/scroll-area.md +1 -1
  56. package/docs/design-system/components/select.md +12 -2
  57. package/docs/design-system/components/sheet.md +1 -1
  58. package/docs/design-system/components/shimmer-text.md +67 -0
  59. package/docs/design-system/components/sidebar.md +75 -8
  60. package/docs/design-system/components/skeleton.md +2 -2
  61. package/docs/design-system/components/slideout.md +1 -1
  62. package/docs/design-system/components/slider.md +2 -2
  63. package/docs/design-system/components/social-button.md +2 -2
  64. package/docs/design-system/components/streaming-text.md +82 -0
  65. package/docs/design-system/components/table.md +103 -21
  66. package/docs/design-system/components/tabs.md +25 -6
  67. package/docs/design-system/components/tag.md +1 -1
  68. package/docs/design-system/components/task-list.md +95 -0
  69. package/docs/design-system/components/textarea.md +1 -1
  70. package/docs/design-system/components/thinking-reasoning.md +72 -0
  71. package/docs/design-system/components/thinking-state.md +67 -0
  72. package/docs/design-system/components/timeline.md +1 -1
  73. package/docs/design-system/components/toast.md +1 -1
  74. package/docs/design-system/components/toggle.md +2 -2
  75. package/docs/design-system/components/toolbar.md +11 -6
  76. package/docs/design-system/components/tooltip.md +6 -4
  77. package/docs/design-system/density-and-elevation.md +16 -16
  78. package/docs/design-system/design-system-test-missing-items.md +8 -8
  79. package/docs/design-system/installation.md +2 -0
  80. package/docs/design-system/tabs.md +2 -1
  81. package/docs/design-system/template-gap-audit.md +32 -0
  82. package/docs/design-system/templates.md +59 -0
  83. package/docs/design-system/token-governance.md +15 -2
  84. package/package.json +2 -2
  85. package/registry/components/accordion.tsx +2 -2
  86. package/registry/components/agent-input-parts.tsx +552 -0
  87. package/registry/components/agent-input-state.ts +156 -0
  88. package/registry/components/agent-input.css +307 -0
  89. package/registry/components/agent-input.tsx +382 -0
  90. package/registry/components/alert-dialog.tsx +2 -2
  91. package/registry/components/alert.tsx +23 -7
  92. package/registry/components/app-header.tsx +151 -0
  93. package/registry/components/approval-card.css +409 -0
  94. package/registry/components/approval-card.tsx +401 -0
  95. package/registry/components/approval-frame.tsx +108 -0
  96. package/registry/components/approval-questions.tsx +559 -0
  97. package/registry/components/attribute.tsx +48 -13
  98. package/registry/components/autocomplete.tsx +15 -68
  99. package/registry/components/avatar.tsx +2 -2
  100. package/registry/components/badge.tsx +8 -7
  101. package/registry/components/button-group.tsx +2 -2
  102. package/registry/components/button.tsx +19 -14
  103. package/registry/components/card.tsx +18 -5
  104. package/registry/components/chart-block-data.ts +177 -0
  105. package/registry/components/chart-block.css +171 -0
  106. package/registry/components/chart-block.tsx +260 -0
  107. package/registry/components/chart-interactions.ts +123 -0
  108. package/registry/components/chart-options.cartesian.ts +162 -0
  109. package/registry/components/chart-options.paint.ts +99 -0
  110. package/registry/components/chart-options.parts.ts +72 -0
  111. package/registry/components/chart-options.radial.ts +109 -0
  112. package/registry/components/chart-options.ts +44 -0
  113. package/registry/components/chart-options.types.ts +67 -0
  114. package/registry/components/chart.tsx +1 -1
  115. package/registry/components/checkbox.tsx +6 -6
  116. package/registry/components/collapsible.tsx +43 -3
  117. package/registry/components/command.tsx +1 -1
  118. package/registry/components/context-menu.tsx +1 -1
  119. package/registry/components/data-chart.tsx +40 -0
  120. package/registry/components/date-picker.tsx +9 -9
  121. package/registry/components/echart-theme.ts +246 -0
  122. package/registry/components/echart.css +49 -0
  123. package/registry/components/echart.tsx +178 -0
  124. package/registry/components/expanded/Breadcrumbs.tsx +5 -1
  125. package/registry/components/expanded/PageHeader.tsx +67 -24
  126. package/registry/components/expanded/Table.tsx +60 -22
  127. package/registry/components/expanded/activityFeed.css +2 -2
  128. package/registry/components/expanded/activityHeatmap.css +1 -1
  129. package/registry/components/expanded/banner.css +2 -2
  130. package/registry/components/expanded/breadcrumbs.css +19 -2
  131. package/registry/components/expanded/catalogComponentsShowcase.css +1 -1
  132. package/registry/components/expanded/fileUpload.css +7 -7
  133. package/registry/components/expanded/onboardingStepListItem.css +2 -2
  134. package/registry/components/expanded/pageHeader.css +38 -44
  135. package/registry/components/expanded/slideout.css +4 -4
  136. package/registry/components/expanded/table.css +58 -9
  137. package/registry/components/expanded/timeline.css +1 -1
  138. package/registry/components/hover-card.tsx +1 -1
  139. package/registry/components/inline-citations.css +126 -0
  140. package/registry/components/inline-citations.tsx +292 -0
  141. package/registry/components/input-group.tsx +4 -4
  142. package/registry/components/input.tsx +150 -137
  143. package/registry/components/item.tsx +1 -1
  144. package/registry/components/kbd.tsx +3 -2
  145. package/registry/components/menu.tsx +117 -12
  146. package/registry/components/menubar.tsx +2 -2
  147. package/registry/components/modal.tsx +3 -3
  148. package/registry/components/native-select.tsx +4 -4
  149. package/registry/components/navigation-menu.tsx +2 -2
  150. package/registry/components/pagination.tsx +4 -4
  151. package/registry/components/popover.tsx +3 -3
  152. package/registry/components/progress-slider.tsx +1 -1
  153. package/registry/components/radio-card.tsx +7 -7
  154. package/registry/components/radio-group.tsx +2 -2
  155. package/registry/components/resizable.tsx +2 -2
  156. package/registry/components/rolling-count.css +37 -0
  157. package/registry/components/rolling-count.tsx +110 -0
  158. package/registry/components/scroll-area.tsx +1 -1
  159. package/registry/components/select.tsx +15 -10
  160. package/registry/components/sheet.tsx +2 -2
  161. package/registry/components/shimmer-text.css +69 -0
  162. package/registry/components/shimmer-text.tsx +43 -0
  163. package/registry/components/sidebar.tsx +528 -223
  164. package/registry/components/skeleton.tsx +1 -1
  165. package/registry/components/slider.tsx +2 -2
  166. package/registry/components/social-button.tsx +1 -1
  167. package/registry/components/streaming-text.css +45 -0
  168. package/registry/components/streaming-text.tsx +165 -0
  169. package/registry/components/table-elements.tsx +164 -5
  170. package/registry/components/tabs.css +32 -4
  171. package/registry/components/tabs.tsx +5 -1
  172. package/registry/components/tag.tsx +1 -1
  173. package/registry/components/task-list.css +328 -0
  174. package/registry/components/task-list.tsx +429 -0
  175. package/registry/components/textarea.tsx +4 -4
  176. package/registry/components/thinking-reasoning.css +178 -0
  177. package/registry/components/thinking-reasoning.tsx +253 -0
  178. package/registry/components/thinking-state.css +8 -0
  179. package/registry/components/thinking-state.tsx +46 -0
  180. package/registry/components/toast.tsx +1 -1
  181. package/registry/components/toggle.tsx +2 -2
  182. package/registry/components/toolbar.tsx +6 -6
  183. package/registry/components/tooltip.tsx +6 -4
  184. package/registry/examples/agent-input-demo.tsx +90 -0
  185. package/registry/examples/app-header-demo.tsx +40 -0
  186. package/registry/examples/approval-card-demo.tsx +83 -0
  187. package/registry/examples/button-demo.tsx +3 -0
  188. package/registry/examples/chart-block-demo.tsx +5 -0
  189. package/registry/examples/chart-demo.tsx +6 -21
  190. package/registry/examples/collection-filter-bar-demo.tsx +1 -1
  191. package/registry/examples/control-density-contract-demo.tsx +5 -1
  192. package/registry/examples/inline-citations-demo.tsx +31 -0
  193. package/registry/examples/input-actions-demo.tsx +36 -0
  194. package/registry/examples/menu-demo.tsx +1 -1
  195. package/registry/examples/popover-demo.tsx +1 -0
  196. package/registry/examples/rolling-count-demo.tsx +39 -0
  197. package/registry/examples/shimmer-text-demo.tsx +20 -0
  198. package/registry/examples/streaming-text-demo.tsx +28 -0
  199. package/registry/examples/task-list-demo.tsx +74 -0
  200. package/registry/examples/thinking-reasoning-demo.tsx +74 -0
  201. package/registry/examples/thinking-state-demo.tsx +13 -0
  202. package/registry/guidance/charts.LICENSE.txt +24 -0
  203. package/registry/hooks/use-controllable-state.ts +33 -0
  204. package/registry/hooks/use-listbox-navigation.ts +73 -0
  205. package/registry/hooks/use-motion-end.ts +55 -0
  206. package/registry/hooks/use-reduced-motion.ts +28 -0
  207. package/registry/index.json +1808 -190
  208. package/registry/styles/globals.css +992 -1088
  209. package/registry.json +385 -10
  210. package/schema/registry.schema.json +5 -1
  211. package/schema/tokens.schema.json +8 -4
  212. package/src/cli/file-transaction.js +15 -8
  213. package/src/cli/index.js +22 -29
  214. package/tokens/banhaten.tokens.json +490 -192
@@ -30,8 +30,15 @@ type InputNativeProps = Omit<
30
30
  "className" | "dir" | "size"
31
31
  >
32
32
 
33
+ type InputActionButtonProps = Omit<React.ComponentProps<"button">, "children">
34
+ type InputStepperButtonProps = Omit<InputActionButtonProps, "type">
35
+
33
36
  type InputModeRenderProps = {
34
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
35
42
  inlineLeadingAddon?: React.ReactNode
36
43
  inlineTagsClassName?: string
37
44
  inlineTrailingAddon?: React.ReactNode
@@ -77,6 +84,7 @@ type InputCommonProps = InputNativeProps & {
77
84
  hasLeadingIcon?: boolean
78
85
  hasLabel?: boolean
79
86
  isOptional?: boolean
87
+ /** Shows the marker and exposes aria-required without enabling native validation. */
80
88
  isRequired?: boolean
81
89
  label?: React.ReactNode
82
90
  leadingIcon?: React.ReactNode | false
@@ -100,14 +108,14 @@ const inputSurface = cva(
100
108
  [
101
109
  "group/input-surface relative flex w-full overflow-hidden rounded-[var(--bh-input-radius)]",
102
110
  "transition-[background-color,box-shadow]",
103
- "[--bh-input-border:var(--bh-border-input)] [--shadow-input:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-input-border,var(--bh-border-input)),var(--shadow-component-default)]",
104
- "[--shadow-input-surface:var(--shadow-component-default)]",
105
- "[--shadow-input-overlay:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-input-border,var(--bh-border-input))]",
106
- "[--shadow-input-focus-surface:var(--shadow-input-focus-ring)]",
107
- "[--shadow-input-focus-overlay:none]",
108
- "[--shadow-input-disabled-overlay:inset_0px_0px_0px_var(--bh-border-width-default)_var(--bh-border-disabled)]",
109
- "after:pointer-events-none after:absolute after:inset-0 after:z-[var(--bh-z-raised)] after:rounded-[inherit] after:[box-shadow:var(--shadow-input-overlay)] after:content-['']",
110
- "focus-within:shadow-[var(--shadow-input-focus-surface)] focus-within:after:[box-shadow:var(--shadow-input-focus-overlay)]",
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)]",
111
119
  ],
112
120
  {
113
121
  variants: {
@@ -122,13 +130,13 @@ const inputSurface = cva(
122
130
  },
123
131
  state: {
124
132
  default:
125
- "shadow-[var(--shadow-input-surface)]",
133
+ "shadow-[var(--elevation-input-surface)]",
126
134
  filled:
127
- "shadow-[var(--shadow-input-surface)]",
135
+ "shadow-[var(--elevation-input-surface)]",
128
136
  error:
129
- "[--bh-input-border:var(--bh-border-danger-strong)] shadow-[var(--shadow-input-surface)] focus-within:shadow-[var(--shadow-input-surface)] focus-within:after:[box-shadow:var(--shadow-input-overlay)]",
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)]",
130
138
  disabled:
131
- "[--bh-input-border:var(--bh-border-disabled)] bg-[var(--bh-interactive-input-disabled)] shadow-none after:[box-shadow:var(--shadow-input-disabled-overlay)]",
139
+ "[--bh-input-border:var(--bh-border-disabled)] bg-[var(--bh-interactive-input-disabled)] shadow-none after:[box-shadow:var(--elevation-input-disabled-overlay)]",
132
140
  },
133
141
  },
134
142
  compoundVariants: [
@@ -151,8 +159,10 @@ const inputContent = cva(
151
159
  {
152
160
  variants: {
153
161
  size: {
154
- md: "px-[var(--bh-input-md-padding-x)]",
155
- lg: "px-[var(--bh-input-lg-padding-x)]",
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)]",
156
166
  comfortable: "px-[var(--bh-input-comfortable-padding-x)]",
157
167
  },
158
168
  },
@@ -266,6 +276,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
266
276
  labelClassName,
267
277
  surfaceClassName,
268
278
  buttonLabel,
279
+ buttonProps,
280
+ decreaseButtonProps,
269
281
  defaultValue,
270
282
  density,
271
283
  dir,
@@ -276,6 +288,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
276
288
  hasLeadingIcon,
277
289
  hasLabel,
278
290
  id,
291
+ increaseButtonProps,
279
292
  inlineLeadingAddon,
280
293
  inlineTrailingAddon,
281
294
  isOptional = false,
@@ -299,6 +312,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
299
312
  tags,
300
313
  trailingAddon,
301
314
  trailingIcon,
315
+ type,
302
316
  value,
303
317
  valueText,
304
318
  variant = "default",
@@ -315,7 +329,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
315
329
  const visualState = disabled ? "disabled" : state
316
330
  const isDisabled = visualState === "disabled"
317
331
  const isInvalid = visualState === "error" && !isDisabled
332
+ const isSemanticallyRequired = isRequired || Boolean(required)
318
333
  const isTagInput = kind === "tags" || kind === "inline-tags"
334
+ const isQuantity = kind === "quantity" || kind === "quantity-2"
319
335
  const selectedLabel = label
320
336
  const selectedOptionalText = optionalText
321
337
  const inferredPlaceholder = getInputPlaceholder({
@@ -347,8 +363,32 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
347
363
  (hasRenderableContent(trailingIcon) || showInfo || Boolean(hasInformationIcon))
348
364
  )
349
365
 
350
- function focusInput() {
351
- if (!isDisabled) inputRef.current?.focus()
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
+ }
352
392
  }
353
393
 
354
394
  function setInputRef(node: HTMLInputElement | null) {
@@ -361,6 +401,21 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
361
401
  }
362
402
  }
363
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
+
364
419
  return (
365
420
  <div
366
421
  data-kind={kind}
@@ -377,7 +432,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
377
432
  className={labelClassName}
378
433
  htmlFor={inputId}
379
434
  isOptional={isOptional}
380
- isRequired={isRequired || Boolean(required)}
435
+ isRequired={isSemanticallyRequired}
381
436
  label={selectedLabel}
382
437
  optionalText={selectedOptionalText}
383
438
  />
@@ -407,15 +462,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
407
462
  )}
408
463
  onClick={focusInput}
409
464
  >
410
- {renderLeadingSegment({
411
- buttonLabel,
412
- disabled: isDisabled,
413
- kind,
414
- leadingAddon,
415
- quantityLabel,
416
- size: resolvedSize,
417
- trailingAddon,
418
- })}
465
+ {renderInputSegment({ ...segmentContext, side: "leading" })}
419
466
 
420
467
  <span
421
468
  data-slot="input-content"
@@ -444,6 +491,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
444
491
  <input
445
492
  aria-describedby={shouldRenderHelperText ? helperId : undefined}
446
493
  aria-invalid={isInvalid || undefined}
494
+ aria-required={isSemanticallyRequired || undefined}
447
495
  data-slot="input-control"
448
496
  data-state={visualState}
449
497
  defaultValue={inferredDefaultValue}
@@ -453,8 +501,13 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
453
501
  readOnly={readOnly}
454
502
  ref={setInputRef}
455
503
  required={required}
504
+ type={isQuantity ? "number" : type}
456
505
  value={value}
457
- className={cn(inputControl(), controlClassName)}
506
+ className={cn(
507
+ inputControl(),
508
+ isQuantity && "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
509
+ controlClassName
510
+ )}
458
511
  {...props}
459
512
  />
460
513
 
@@ -482,15 +535,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
482
535
  ) : null}
483
536
  </span>
484
537
 
485
- {renderTrailingSegment({
486
- buttonLabel,
487
- disabled: isDisabled,
488
- kind,
489
- leadingAddon,
490
- quantityLabel,
491
- size: resolvedSize,
492
- trailingAddon,
493
- })}
538
+ {renderInputSegment({ ...segmentContext, side: "trailing" })}
494
539
  </div>
495
540
 
496
541
  {selectedTags.inline.length > 0 ? (
@@ -730,60 +775,75 @@ function InputTagList({
730
775
  }
731
776
 
732
777
  function InputSegment({
778
+ buttonProps,
733
779
  children,
734
780
  disabled,
735
781
  interactive = false,
736
782
  side,
737
783
  }: {
784
+ buttonProps?: InputActionButtonProps
738
785
  children: React.ReactNode
739
786
  disabled: boolean
740
787
  interactive?: boolean
741
788
  side: "leading" | "trailing"
742
789
  }) {
743
- const Comp = interactive ? "button" : "span"
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
+ }
744
799
 
745
800
  return (
746
- <Comp
801
+ <button
802
+ {...buttonProps}
747
803
  data-slot="input-segment"
748
- disabled={interactive ? disabled : undefined}
749
- type={interactive ? "button" : undefined}
750
- className={cn(inputSegment({ disabled, interactive, side }))}
751
- onClick={(event) => {
752
- if (interactive) event.stopPropagation()
753
- }}
804
+ disabled={isDisabled}
805
+ type={buttonProps?.type ?? "button"}
806
+ className={className}
754
807
  >
755
808
  {children}
756
- </Comp>
809
+ </button>
757
810
  )
758
811
  }
759
812
 
760
813
  function InputStepperButton({
814
+ buttonProps,
761
815
  children,
762
816
  disabled,
763
817
  label,
818
+ onStep,
764
819
  side,
765
820
  size,
766
821
  }: {
822
+ buttonProps?: InputStepperButtonProps
767
823
  children: React.ReactNode
768
824
  disabled: boolean
769
825
  label: string
826
+ onStep: () => void
770
827
  side: "leading" | "trailing"
771
828
  size: InputSize
772
829
  }) {
773
830
  return (
774
831
  <button
775
- aria-label={label}
832
+ {...buttonProps}
833
+ aria-label={buttonProps?.["aria-label"] ?? label}
776
834
  data-side={side}
777
835
  data-slot="input-stepper-button"
778
- disabled={disabled}
836
+ disabled={disabled || buttonProps?.disabled}
779
837
  type="button"
780
838
  className={cn(
781
839
  inputStepperButton({ side }),
782
840
  size === "md" && "w-[var(--bh-input-md-height)]",
783
- size === "comfortable" && "w-[var(--bh-input-comfortable-height)]"
841
+ size === "comfortable" && "w-[var(--bh-input-comfortable-height)]",
842
+ buttonProps?.className
784
843
  )}
785
844
  onClick={(event) => {
786
- event.stopPropagation()
845
+ buttonProps?.onClick?.(event)
846
+ if (!event.defaultPrevented) onStep()
787
847
  }}
788
848
  >
789
849
  {children}
@@ -793,106 +853,57 @@ function InputStepperButton({
793
853
 
794
854
  type InputSegmentRenderContext = {
795
855
  buttonLabel?: React.ReactNode
856
+ buttonProps?: InputActionButtonProps
857
+ decreaseButtonProps?: InputStepperButtonProps
858
+ increaseButtonProps?: InputStepperButtonProps
796
859
  disabled: boolean
797
860
  kind: InputKind
798
861
  leadingAddon?: React.ReactNode
799
862
  quantityLabel?: React.ReactNode
863
+ readOnly: boolean
864
+ onStep: (direction: -1 | 1) => void
865
+ side: "leading" | "trailing"
800
866
  size: InputSize
801
867
  trailingAddon?: React.ReactNode
802
868
  }
803
869
 
804
- type InputSegmentRenderer = (
805
- context: InputSegmentRenderContext
806
- ) => React.ReactNode
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
+ }
807
889
 
808
- const leadingSegmentRenderers: Partial<Record<InputKind, InputSegmentRenderer>> = {
809
- "add-on": ({ disabled, leadingAddon }) =>
810
- hasRenderableContent(leadingAddon) ? (
811
- <InputSegment disabled={disabled} side="leading">
812
- {leadingAddon}
813
- </InputSegment>
814
- ) : null,
815
- "leading-button": ({ buttonLabel, disabled }) =>
816
- hasRenderableContent(buttonLabel) ? (
817
- <InputSegment disabled={disabled} interactive side="leading">
818
- {buttonLabel}
819
- </InputSegment>
820
- ) : null,
821
- "leading-dropdown": ({ buttonLabel, disabled }) => (
822
- <InputSegment disabled={disabled} interactive side="leading">
823
- {buttonLabel}
824
- <ChevronIcon />
825
- </InputSegment>
826
- ),
827
- quantity: ({ disabled, quantityLabel, size }) => (
828
- <InputStepperButton
829
- disabled={disabled}
830
- label={formatStepperLabel("Decrease", quantityLabel)}
831
- side="leading"
832
- size={size}
833
- >
834
- <MinusIcon />
835
- </InputStepperButton>
836
- ),
837
- "quantity-2": ({ disabled, quantityLabel, size }) => (
838
- <InputStepperButton
839
- disabled={disabled}
840
- label={formatStepperLabel("Decrease", quantityLabel)}
841
- side="leading"
842
- size={size}
843
- >
844
- <MinusIcon />
845
- </InputStepperButton>
846
- ),
847
- }
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
+ }
848
896
 
849
- const trailingSegmentRenderers: Partial<Record<InputKind, InputSegmentRenderer>> = {
850
- "add-on": ({ disabled, trailingAddon }) =>
851
- hasRenderableContent(trailingAddon) ? (
852
- <InputSegment disabled={disabled} side="trailing">
853
- {trailingAddon}
854
- </InputSegment>
855
- ) : null,
856
- "trailing-button": ({ buttonLabel, disabled }) =>
857
- hasRenderableContent(buttonLabel) ? (
858
- <InputSegment disabled={disabled} interactive side="trailing">
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}>
859
901
  {buttonLabel}
902
+ {isDropdown ? <ChevronIcon /> : null}
860
903
  </InputSegment>
861
- ) : null,
862
- "trailing-dropdown": ({ buttonLabel, disabled }) => (
863
- <InputSegment disabled={disabled} interactive side="trailing">
864
- {buttonLabel}
865
- <ChevronIcon />
866
- </InputSegment>
867
- ),
868
- quantity: ({ disabled, quantityLabel, size }) => (
869
- <InputStepperButton
870
- disabled={disabled}
871
- label={formatStepperLabel("Increase", quantityLabel)}
872
- side="trailing"
873
- size={size}
874
- >
875
- <PlusIcon />
876
- </InputStepperButton>
877
- ),
878
- "quantity-2": ({ disabled, quantityLabel, size }) => (
879
- <InputStepperButton
880
- disabled={disabled}
881
- label={formatStepperLabel("Increase", quantityLabel)}
882
- side="trailing"
883
- size={size}
884
- >
885
- <PlusIcon />
886
- </InputStepperButton>
887
- ),
888
- }
889
-
890
- function renderLeadingSegment(context: InputSegmentRenderContext) {
891
- return leadingSegmentRenderers[context.kind]?.(context) ?? null
892
- }
893
-
894
- function renderTrailingSegment(context: InputSegmentRenderContext) {
895
- return trailingSegmentRenderers[context.kind]?.(context) ?? null
904
+ )
905
+ }
906
+ return null
896
907
  }
897
908
 
898
909
  function formatStepperLabel(action: "Decrease" | "Increase", quantityLabel?: React.ReactNode) {
@@ -970,9 +981,11 @@ function resolveInputSize(
970
981
  export { Input, inputContent, inputControl, inputRoot, inputSurface }
971
982
  export type {
972
983
  ControlDensity,
984
+ InputActionButtonProps,
973
985
  InputKind,
974
986
  InputProps,
975
987
  InputSize,
988
+ InputStepperButtonProps,
976
989
  InputTag,
977
990
  InputVariant,
978
991
  InputVisualState,
@@ -8,7 +8,7 @@ const itemVariants = cva(
8
8
  [
9
9
  "group/item flex min-w-0 items-center gap-[var(--bh-item-gap)] rounded-[var(--bh-item-radius)]",
10
10
  "px-[var(--bh-item-padding-x)] py-[var(--bh-item-padding-y)] text-start text-[var(--bh-content-default)]",
11
- "outline-none transition-[background-color,box-shadow] focus-visible:shadow-[var(--shadow-button-focus)]",
11
+ "outline-none transition-[background-color,box-shadow] focus-visible:shadow-[var(--elevation-button-focus)]",
12
12
  "aria-disabled:pointer-events-none aria-disabled:text-[var(--bh-content-disabled)]",
13
13
  ],
14
14
  {
@@ -10,7 +10,7 @@ const kbdVariants = cva(
10
10
  "inline-flex min-w-[var(--bh-space-5xl-24)] items-center justify-center whitespace-nowrap rounded-[var(--bh-radius-md-6)]",
11
11
  "border border-[var(--bh-border-default)] bg-[var(--bh-interactive-soft-default)] text-[var(--bh-content-subtle)]",
12
12
  "[font-family:var(--bh-font-family)] text-[length:var(--bh-text-body-xs-medium-font-size)] font-[var(--bh-text-body-xs-medium-font-weight)]",
13
- "leading-[var(--bh-text-body-xs-medium-line-height)] tracking-[var(--bh-text-body-xs-medium-letter-spacing)] shadow-[var(--shadow-button-soft)]",
13
+ "leading-[var(--bh-text-body-xs-medium-line-height)] tracking-[var(--bh-text-body-xs-medium-letter-spacing)] shadow-[var(--elevation-button-soft)]",
14
14
  "data-[disabled=true]:border-[var(--bh-border-disabled)] data-[disabled=true]:bg-[var(--bh-interactive-soft-disabled)] data-[disabled=true]:text-[var(--bh-content-disabled)] data-[disabled=true]:shadow-none",
15
15
  ],
16
16
  {
@@ -40,13 +40,14 @@ type KbdProps = React.ComponentProps<"kbd"> &
40
40
  }
41
41
 
42
42
  const Kbd = React.forwardRef<HTMLElement, KbdProps>(function Kbd(
43
- { className, disabled = false, size, ...props },
43
+ { className, dir = "ltr", disabled = false, size, ...props },
44
44
  ref
45
45
  ) {
46
46
  return (
47
47
  <kbd
48
48
  data-disabled={disabled ? "true" : undefined}
49
49
  data-slot="kbd"
50
+ dir={dir}
50
51
  ref={ref}
51
52
  className={cn(kbdVariants({ size, className }))}
52
53
  {...props}
@@ -1,6 +1,7 @@
1
1
  import * as React from "react"
2
2
  import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
3
3
  import { Slot } from "@radix-ui/react-slot"
4
+ import { CheckIcon } from "lucide-react"
4
5
  import { cva, type VariantProps } from "class-variance-authority"
5
6
 
6
7
  import { cn } from "@/lib/utils"
@@ -21,7 +22,7 @@ const MENU_COLLISION_PADDING_PX = 8
21
22
  const MENU_SUBCONTENT_SIDE_OFFSET_PX = 4
22
23
 
23
24
  const menuVariants = cva(
24
- "flex flex-col items-start gap-[var(--bh-menu-gap)] overflow-hidden rounded-[var(--bh-menu-radius)] bg-[var(--bh-menu-bg)] py-[var(--bh-menu-padding-y)] shadow-[var(--shadow-menu)]",
25
+ "flex flex-col items-start gap-[var(--bh-menu-gap)] overflow-hidden rounded-[var(--bh-menu-radius)] bg-[var(--bh-menu-bg)] py-[var(--bh-menu-padding-y)] shadow-[var(--elevation-menu)]",
25
26
  {
26
27
  variants: {
27
28
  width: {
@@ -117,6 +118,8 @@ const MenuRoot = DropdownMenuPrimitive.Root
117
118
  const MenuSub = DropdownMenuPrimitive.Sub
118
119
  const MenuTrigger = DropdownMenuPrimitive.Trigger
119
120
  const MenuStaticContext = React.createContext(false)
121
+ // The chosen value of the MenuRadioGroup around an item in a static MenuPreview (Radix tracks it in a live menu).
122
+ const MenuRadioStaticContext = React.createContext<string | undefined>(undefined)
120
123
 
121
124
  function Menu({ dir, ...props }: MenuProps) {
122
125
  const directionBoundaryRef = React.useRef<HTMLSpanElement | null>(null)
@@ -258,17 +261,9 @@ function getMenuItemTextValue(children: React.ReactNode) {
258
261
  return undefined
259
262
  }
260
263
 
261
- type MenuItemPrimitiveProps = Pick<
262
- React.ComponentProps<typeof DropdownMenuPrimitive.Item>,
263
- "onSelect" | "textValue"
264
- >
265
-
266
- type MenuItemProps = React.ComponentProps<"div"> &
267
- VariantProps<typeof menuItemVariants> &
268
- MenuItemPrimitiveProps & {
269
- asChild?: boolean
270
- disabled?: boolean
271
- }
264
+ type MenuItemProps =
265
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> &
266
+ VariantProps<typeof menuItemVariants>
272
267
 
273
268
  type MenuSubTriggerPrimitiveProps = Pick<
274
269
  React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>,
@@ -356,6 +351,112 @@ const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(function MenuIt
356
351
 
357
352
  MenuItem.displayName = "MenuItem"
358
353
 
354
+ type MenuRadioGroupProps = React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup>
355
+
356
+ // One choice among MenuRadioItems: value is the chosen item's value, onValueChange receives the new one. The group
357
+ // is a full-width column with the menu's item gap, so its items stretch and space as the menu's other items do.
358
+ const menuRadioGroupClassName = "flex w-full flex-col gap-[var(--bh-menu-gap)]"
359
+
360
+ function MenuRadioGroup({ children, className, onValueChange, value, ...props }: MenuRadioGroupProps) {
361
+ const isStaticMenu = React.useContext(MenuStaticContext)
362
+
363
+ if (isStaticMenu) {
364
+ return (
365
+ <MenuRadioStaticContext.Provider value={value}>
366
+ <div data-slot="menu-radio-group" role="group" className={cn(menuRadioGroupClassName, className)} {...props}>
367
+ {children}
368
+ </div>
369
+ </MenuRadioStaticContext.Provider>
370
+ )
371
+ }
372
+
373
+ return (
374
+ <DropdownMenuPrimitive.RadioGroup
375
+ data-slot="menu-radio-group"
376
+ className={cn(menuRadioGroupClassName, className)}
377
+ onValueChange={onValueChange}
378
+ value={value}
379
+ {...props}
380
+ >
381
+ {children}
382
+ </DropdownMenuPrimitive.RadioGroup>
383
+ )
384
+ }
385
+
386
+ type MenuRadioItemProps =
387
+ Omit<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>, "asChild"> &
388
+ VariantProps<typeof menuItemVariants>
389
+
390
+ // A menu item that is one of a MenuRadioGroup's choices: role menuitemradio with aria-checked, and a trailing check
391
+ // on the chosen one. Radix sets the role and checked state in a live menu; a static MenuPreview sets them here.
392
+ const MenuRadioItem = React.forwardRef<HTMLDivElement, MenuRadioItemProps>(function MenuRadioItem({
393
+ className,
394
+ kind = "default",
395
+ disabled = false,
396
+ children,
397
+ onSelect,
398
+ textValue,
399
+ value,
400
+ ...props
401
+ }, ref) {
402
+ const isStaticMenu = React.useContext(MenuStaticContext)
403
+ const staticValue = React.useContext(MenuRadioStaticContext)
404
+ const selectedKind: MenuItemKind = kind ?? "default"
405
+ const staticChecked = staticValue === value
406
+ const indicator = (
407
+ <MenuItemIcon data-slot="menu-radio-item-indicator" position="trailing">
408
+ <CheckIcon aria-hidden="true" focusable={false} strokeWidth="var(--bh-icon-stroke-200)" />
409
+ </MenuItemIcon>
410
+ )
411
+ // In a live menu, role, aria-checked and data-state come from Radix and must not be overridden.
412
+ const staticSemantics = isStaticMenu
413
+ ? {
414
+ "aria-checked": staticChecked,
415
+ "data-state": staticChecked ? "checked" : "unchecked",
416
+ role: "menuitemradio",
417
+ }
418
+ : {}
419
+
420
+ const item = (
421
+ <div
422
+ {...staticSemantics}
423
+ aria-disabled={disabled ? true : undefined}
424
+ data-disabled={disabled ? "true" : "false"}
425
+ data-kind={selectedKind}
426
+ data-slot="menu-item"
427
+ ref={ref}
428
+ tabIndex={disabled ? -1 : 0}
429
+ className={cn(menuItemVariants({ kind: selectedKind, className }))}
430
+ {...props}
431
+ >
432
+ <MenuItemContent kind={selectedKind}>
433
+ <MenuItemChildren kind={selectedKind}>{children}</MenuItemChildren>
434
+ {isStaticMenu ? (
435
+ staticChecked ? indicator : null
436
+ ) : (
437
+ <DropdownMenuPrimitive.ItemIndicator asChild>{indicator}</DropdownMenuPrimitive.ItemIndicator>
438
+ )}
439
+ </MenuItemContent>
440
+ </div>
441
+ )
442
+
443
+ if (isStaticMenu) return item
444
+
445
+ return (
446
+ <DropdownMenuPrimitive.RadioItem
447
+ asChild
448
+ disabled={disabled}
449
+ onSelect={onSelect}
450
+ textValue={textValue ?? getMenuItemTextValue(children)}
451
+ value={value}
452
+ >
453
+ {item}
454
+ </DropdownMenuPrimitive.RadioItem>
455
+ )
456
+ })
457
+
458
+ MenuRadioItem.displayName = "MenuRadioItem"
459
+
359
460
  function findMenuItemSwitchState(children: React.ReactNode): boolean | undefined {
360
461
  let state: boolean | undefined
361
462
 
@@ -951,6 +1052,8 @@ export {
951
1052
  MenuProgress,
952
1053
  MenuPreview,
953
1054
  MenuPortal,
1055
+ MenuRadioGroup,
1056
+ MenuRadioItem,
954
1057
  MenuRoot,
955
1058
  MenuSeparator,
956
1059
  MenuSub,
@@ -981,6 +1084,8 @@ export type {
981
1084
  MenuProgressProps,
982
1085
  MenuProps,
983
1086
  MenuPreviewProps,
1087
+ MenuRadioGroupProps,
1088
+ MenuRadioItemProps,
984
1089
  MenuSurfaceProps,
985
1090
  MenuWidth,
986
1091
  }