@boxcustodia/library 2.0.0-alpha.13 → 2.0.0-alpha.15

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 (174) hide show
  1. package/dist/index.cjs.js +1 -138
  2. package/dist/index.d.ts +1083 -717
  3. package/dist/index.es.js +7059 -56179
  4. package/dist/theme.css +1 -1
  5. package/package.json +34 -26
  6. package/src/__doc__/Changelog.mdx +6 -6
  7. package/src/__doc__/Examples.tsx +1 -1
  8. package/src/__doc__/Intro.mdx +3 -3
  9. package/src/__doc__/Tabs.mdx +112 -0
  10. package/src/__doc__/V2.mdx +1245 -0
  11. package/src/components/accordion/accordion.stories.tsx +143 -0
  12. package/src/components/accordion/accordion.tsx +135 -0
  13. package/src/components/accordion/index.ts +1 -0
  14. package/src/components/alert/alert.stories.tsx +24 -4
  15. package/src/components/alert/alert.tsx +17 -9
  16. package/src/components/alert-dialog/alert-dialog.stories.tsx +24 -0
  17. package/src/components/alert-dialog/alert-dialog.test.tsx +1 -1
  18. package/src/components/alert-dialog/alert-dialog.tsx +58 -10
  19. package/src/components/auto-complete/auto-complete.stories.tsx +615 -200
  20. package/src/components/auto-complete/auto-complete.tsx +420 -68
  21. package/src/components/auto-complete/index.ts +0 -1
  22. package/src/components/avatar/avatar.stories.tsx +162 -21
  23. package/src/components/avatar/avatar.tsx +79 -20
  24. package/src/components/button/button.stories.tsx +236 -294
  25. package/src/components/button/button.test.tsx +10 -17
  26. package/src/components/button/button.tsx +53 -18
  27. package/src/components/button/components/base-button.tsx +25 -53
  28. package/src/components/button/index.ts +0 -1
  29. package/src/components/calendar/calendar.stories.tsx +1 -1
  30. package/src/components/calendar/calendar.tsx +4 -4
  31. package/src/components/card/card.stories.tsx +140 -69
  32. package/src/components/card/card.tsx +155 -54
  33. package/src/components/center/center.stories.tsx +22 -39
  34. package/src/components/checkbox/checkbox.stories.tsx +25 -5
  35. package/src/components/checkbox/checkbox.tsx +76 -15
  36. package/src/components/checkbox-group/checkbox-group.stories.tsx +116 -28
  37. package/src/components/checkbox-group/checkbox-group.tsx +84 -3
  38. package/src/components/combobox/combobox.stories.tsx +33 -23
  39. package/src/components/combobox/combobox.tsx +120 -104
  40. package/src/components/date-picker/date-input.stories.tsx +14 -6
  41. package/src/components/date-picker/date-input.tsx +3 -3
  42. package/src/components/date-picker/date-picker.model.ts +13 -4
  43. package/src/components/date-picker/date-picker.stories.tsx +38 -12
  44. package/src/components/date-picker/date-picker.tsx +29 -15
  45. package/src/components/dialog/dialog.stories.tsx +18 -0
  46. package/src/components/dialog/dialog.test.tsx +1 -1
  47. package/src/components/dialog/dialog.tsx +51 -20
  48. package/src/components/divider/divider.stories.tsx +6 -0
  49. package/src/components/dropzone/dropzone.stories.tsx +70 -90
  50. package/src/components/dropzone/dropzone.tsx +383 -105
  51. package/src/components/dropzone/index.ts +0 -1
  52. package/src/components/empty/empty.stories.tsx +164 -0
  53. package/src/components/empty/empty.tsx +156 -0
  54. package/src/components/empty/index.ts +1 -0
  55. package/src/components/field/field.stories.tsx +226 -3
  56. package/src/components/field/field.tsx +77 -42
  57. package/src/components/form/form.stories.tsx +320 -197
  58. package/src/components/form/form.tsx +3 -23
  59. package/src/components/index.ts +2 -6
  60. package/src/components/input/input.stories.tsx +5 -5
  61. package/src/components/input/input.tsx +5 -5
  62. package/src/components/kbd/kbd.stories.tsx +1 -0
  63. package/src/components/label/label.stories.tsx +16 -0
  64. package/src/components/label/label.tsx +13 -2
  65. package/src/components/loader/loader.stories.tsx +7 -5
  66. package/src/components/loader/loader.tsx +8 -3
  67. package/src/components/menu/menu-primitives.tsx +207 -196
  68. package/src/components/menu/menu.stories.tsx +275 -146
  69. package/src/components/menu/menu.tsx +146 -54
  70. package/src/components/number-input/number-input.stories.tsx +27 -4
  71. package/src/components/number-input/number-input.test.tsx +2 -2
  72. package/src/components/number-input/number-input.tsx +29 -33
  73. package/src/components/otp/index.ts +1 -0
  74. package/src/components/otp/otp.stories.tsx +209 -0
  75. package/src/components/otp/otp.tsx +100 -0
  76. package/src/components/pagination/index.ts +1 -0
  77. package/src/components/pagination/pagination.model.ts +2 -0
  78. package/src/components/pagination/pagination.stories.tsx +153 -59
  79. package/src/components/pagination/pagination.test.tsx +122 -57
  80. package/src/components/pagination/pagination.tsx +575 -77
  81. package/src/components/password/password.stories.tsx +18 -3
  82. package/src/components/password/password.tsx +26 -10
  83. package/src/components/popover/popover.stories.tsx +26 -5
  84. package/src/components/popover/popover.tsx +15 -23
  85. package/src/components/progress/progress.stories.tsx +1 -0
  86. package/src/components/radio-group/index.ts +1 -0
  87. package/src/components/radio-group/radio-group.stories.tsx +251 -0
  88. package/src/components/radio-group/radio-group.tsx +212 -0
  89. package/src/components/scroll-area/scroll-area.stories.tsx +1 -0
  90. package/src/components/select/select.stories.tsx +118 -19
  91. package/src/components/select/select.tsx +67 -62
  92. package/src/components/skeleton/skeleton.stories.tsx +1 -0
  93. package/src/components/stack/stack.stories.tsx +179 -89
  94. package/src/components/stack/stack.tsx +2 -2
  95. package/src/components/stepper/index.ts +1 -1
  96. package/src/components/stepper/stepper.stories.tsx +766 -83
  97. package/src/components/stepper/stepper.test.tsx +18 -18
  98. package/src/components/stepper/stepper.tsx +554 -0
  99. package/src/components/switch/switch.stories.tsx +15 -1
  100. package/src/components/switch/switch.tsx +17 -4
  101. package/src/components/table/index.ts +0 -2
  102. package/src/components/table/table.stories.tsx +131 -18
  103. package/src/components/table/table.test.tsx +1 -1
  104. package/src/components/table/table.tsx +183 -77
  105. package/src/components/tabs/tabs.stories.tsx +372 -155
  106. package/src/components/tabs/tabs.test.tsx +12 -12
  107. package/src/components/tabs/tabs.tsx +72 -149
  108. package/src/components/tag/index.ts +0 -1
  109. package/src/components/tag/tag.stories.tsx +147 -120
  110. package/src/components/tag/tag.tsx +47 -95
  111. package/src/components/textarea/textarea.stories.tsx +8 -22
  112. package/src/components/textarea/textarea.tsx +17 -79
  113. package/src/components/timeline/timeline.stories.tsx +322 -42
  114. package/src/components/timeline/timeline.tsx +359 -132
  115. package/src/components/toast/toast.stories.tsx +1 -0
  116. package/src/components/tooltip/tooltip.tsx +11 -9
  117. package/src/components/tree/index.ts +0 -1
  118. package/src/components/tree/tree.stories.tsx +364 -408
  119. package/src/components/tree/tree.test.tsx +163 -0
  120. package/src/components/tree/tree.tsx +212 -36
  121. package/src/hooks/useAsync/__doc__/useAsync.stories.tsx +5 -5
  122. package/src/hooks/useClipboard/__doc__/useClipboard.stories.tsx +1 -3
  123. package/src/hooks/useDebounceCallback/__doc__/useDebouncedCallback.stories.tsx +6 -6
  124. package/src/hooks/useDocumentTitle/__doc__/useDocumentTitle.stories.tsx +1 -1
  125. package/src/hooks/useEventListener/__test__/useEventListener.test.tsx +1 -1
  126. package/src/hooks/useLocalStorage/__doc__/useLocalStorage.stories.tsx +1 -1
  127. package/src/hooks/usePagination/usePagination.tsx +36 -24
  128. package/src/styles/theme.css +1 -1
  129. package/src/utils/form.tsx +69 -37
  130. package/src/utils/index.ts +1 -1
  131. package/src/__doc__/Migration.mdx +0 -451
  132. package/src/components/auto-complete/auto-complete-primitives.tsx +0 -155
  133. package/src/components/background-image/background-image.stories.tsx +0 -21
  134. package/src/components/background-image/background-image.test.tsx +0 -29
  135. package/src/components/background-image/background-image.tsx +0 -23
  136. package/src/components/background-image/index.ts +0 -1
  137. package/src/components/button/button.variants.ts +0 -44
  138. package/src/components/button/components/loader-overlay.tsx +0 -21
  139. package/src/components/button/components/loading-icon.tsx +0 -47
  140. package/src/components/dropzone/upload-primitives.tsx +0 -310
  141. package/src/components/dropzone/use-dropzone.ts +0 -122
  142. package/src/components/empty-state/empty-state.stories.tsx +0 -56
  143. package/src/components/empty-state/empty-state.tsx +0 -39
  144. package/src/components/empty-state/index.ts +0 -1
  145. package/src/components/heading/heading.stories.tsx +0 -74
  146. package/src/components/heading/heading.tsx +0 -28
  147. package/src/components/heading/heading.variants.ts +0 -27
  148. package/src/components/heading/index.ts +0 -1
  149. package/src/components/kbd/kbd.variants.ts +0 -26
  150. package/src/components/menu/util/render-menu-item.tsx +0 -54
  151. package/src/components/multi-select/hooks/use-multi-select.ts +0 -66
  152. package/src/components/multi-select/index.ts +0 -1
  153. package/src/components/multi-select/multi-select.stories.tsx +0 -294
  154. package/src/components/multi-select/multi-select.tsx +0 -300
  155. package/src/components/multi-select/multi-select.variants.ts +0 -22
  156. package/src/components/pagination/components/pagination-option.tsx +0 -27
  157. package/src/components/show/index.ts +0 -1
  158. package/src/components/show/show.stories.tsx +0 -197
  159. package/src/components/show/show.test.tsx +0 -41
  160. package/src/components/show/show.tsx +0 -16
  161. package/src/components/stepper/Stepper.tsx +0 -190
  162. package/src/components/stepper/context/stepper-context.tsx +0 -11
  163. package/src/components/table/table-primitives.tsx +0 -122
  164. package/src/components/table/table.model.ts +0 -20
  165. package/src/components/table-pagination/index.ts +0 -2
  166. package/src/components/table-pagination/table-pagination.model.ts +0 -2
  167. package/src/components/table-pagination/table-pagination.stories.tsx +0 -23
  168. package/src/components/table-pagination/table-pagination.test.tsx +0 -32
  169. package/src/components/table-pagination/table-pagination.tsx +0 -108
  170. package/src/components/tabs/context/tabs-context.tsx +0 -14
  171. package/src/components/tag/tag.variants.ts +0 -31
  172. package/src/components/timeline/timeline-status.ts +0 -5
  173. package/src/components/tree/hooks/use-controllable-tree-state.ts +0 -80
  174. package/src/components/tree/tree-primitives.tsx +0 -126
@@ -28,7 +28,8 @@ const meta: Meta<typeof Password> = {
28
28
  hideIcon: { control: false },
29
29
  onShow: { control: false },
30
30
  onHide: { control: false },
31
- onChange: { control: false },
31
+ onValueChange: { control: false },
32
+ classNames: { control: false },
32
33
  },
33
34
  decorators: [
34
35
  (Story) => (
@@ -71,7 +72,7 @@ export const NonToggleable: Story = {
71
72
  };
72
73
 
73
74
  /**
74
- * `onChange` receives the string value as first argument, so no
75
+ * `onValueChange` receives the string value as first argument, so no
75
76
  * `event.target.value` needed.
76
77
  */
77
78
  export const Controlled: Story = {
@@ -81,12 +82,26 @@ export const Controlled: Story = {
81
82
  <Password
82
83
  placeholder="Enter your password"
83
84
  value={value}
84
- onChange={(val) => setValue(val)}
85
+ onValueChange={(val) => setValue(val)}
85
86
  />
86
87
  );
87
88
  },
88
89
  };
89
90
 
91
+ /**
92
+ * `className` estila el wrapper (el field visible). `classNames` expone los
93
+ * slots `input` (texto interno) y `toggle` (botón del ojo).
94
+ */
95
+ export const WithClassNames: Story = {
96
+ args: {
97
+ className: "border-primary focus-within:border-primary",
98
+ classNames: {
99
+ input: "font-mono",
100
+ toggle: "text-primary hover:text-primary/80",
101
+ },
102
+ },
103
+ };
104
+
90
105
  export const CustomIcons: Story = {
91
106
  args: {
92
107
  showIcon: <Lock className="h-4 w-4" />,
@@ -10,6 +10,13 @@ export type PasswordProps = Omit<InputProps, "unstyled" | "nativeInput"> & {
10
10
  onShow?: () => void;
11
11
  onHide?: () => void;
12
12
  toggleable?: boolean;
13
+ /** Styles applied to each internal slot. */
14
+ classNames?: {
15
+ /** Native `<input>` element (transparent inside the wrapper). */
16
+ input?: string;
17
+ /** Show/hide toggle button on the right. */
18
+ toggle?: string;
19
+ };
13
20
  };
14
21
 
15
22
  export function Password({
@@ -19,7 +26,8 @@ export function Password({
19
26
  onShow,
20
27
  onHide,
21
28
  toggleable = true,
22
- onChange,
29
+ onValueChange,
30
+ classNames,
23
31
  ...rest
24
32
  }: PasswordProps) {
25
33
  const [showPassword, toggleShowPassword] = useToggle(false);
@@ -29,16 +37,21 @@ export function Password({
29
37
  showPassword ? onHide?.() : onShow?.();
30
38
  };
31
39
 
32
- const handleChange = onChange
40
+ const handleChange = onValueChange
33
41
  ? (event: ChangeEvent<HTMLInputElement>) =>
34
- onChange(event.target.value, event)
42
+ onValueChange(event.target.value, event)
35
43
  : undefined;
36
44
 
37
45
  return (
38
46
  <div
39
47
  className={cn(
40
- "relative inline-flex w-full",
48
+ "relative inline-flex h-8 w-full items-center text-sm",
49
+ "rounded-md border border-input bg-background transition-shadow",
50
+ "focus-within:border-ring",
51
+ "aria-invalid:border-error focus-within:aria-invalid:ring-error/20",
52
+ "has-aria-invalid:border-error focus-within:has-aria-invalid:ring-error/20",
41
53
  "has-disabled:cursor-not-allowed has-disabled:opacity-50",
54
+ "[&::-ms-clear]:hidden [&::-ms-reveal]:hidden",
42
55
  className,
43
56
  )}
44
57
  data-slot="password"
@@ -48,12 +61,10 @@ export function Password({
48
61
  onChange={handleChange}
49
62
  type={showPassword ? "text" : "password"}
50
63
  className={cn(
51
- "h-10 w-full min-w-0 rounded-md border border-input bg-background",
52
- "pl-3 pr-10 text-sm outline-none transition-shadow placeholder:text-muted-foreground",
53
- "focus-visible:border-ring",
54
- "aria-invalid:border-error focus-visible:aria-invalid:ring-error/20",
64
+ "min-w-0 flex-1 bg-transparent pl-3 outline-none",
65
+ "placeholder:text-muted-foreground",
55
66
  "disabled:cursor-not-allowed",
56
- "[&::-ms-clear]:hidden [&::-ms-reveal]:hidden",
67
+ classNames?.input,
57
68
  )}
58
69
  />
59
70
  <button
@@ -61,7 +72,12 @@ export function Password({
61
72
  type="button"
62
73
  onClick={handleToggle}
63
74
  disabled={!toggleable}
64
- className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
75
+ className={cn(
76
+ "flex shrink-0 items-center px-2 text-muted-foreground transition-colors",
77
+ "hover:text-foreground",
78
+ "disabled:cursor-not-allowed disabled:opacity-50",
79
+ classNames?.toggle,
80
+ )}
65
81
  >
66
82
  {showPassword ? hideIcon : showIcon}
67
83
  </button>
@@ -19,15 +19,19 @@ import {
19
19
  * Provides two APIs: `Popover` (composite, single-component) and primitives
20
20
  * (`PopoverRoot` + `PopoverTrigger` + `PopoverPopup`) for full layout control.
21
21
  *
22
- * `trigger` accepts a `ReactElement` passed as the `render` prop of `PopoverTrigger` — no
23
- * wrapper is added. Use `popupClassName` to style the popup without going through `popupProps`.
24
- * `title` and `description` render `PopoverTitle` / `PopoverDescription` with automatic ARIA
25
- * wiring. Supports controlled state via `open` / `onOpenChange` and imperative control via
26
- * `PopoverCreateHandle`.
22
+ * `trigger` accepts a `ReactElement` passed as the `render` prop of
23
+ * `PopoverTrigger` — no wrapper is added. Style the popup via `className`
24
+ * (the visual root) or use `classNames.viewport` for the inner viewport.
25
+ * `title` and `description` render `PopoverTitle` / `PopoverDescription`
26
+ * with automatic ARIA wiring. Supports controlled state via `open` /
27
+ * `onOpenChange` and imperative control via `PopoverCreateHandle`.
27
28
  */
28
29
  const meta: Meta<typeof Popover> = {
29
30
  title: "Components/Popover",
30
31
  component: Popover,
32
+ argTypes: {
33
+ classNames: { control: false },
34
+ },
31
35
  };
32
36
 
33
37
  export default meta;
@@ -43,6 +47,23 @@ export const Default: Story = {
43
47
  ),
44
48
  };
45
49
 
50
+ /**
51
+ * `className` estila el popup (root visual). `classNames` expone el slot
52
+ * `viewport` para el contenedor interno con padding y overflow.
53
+ */
54
+ export const WithClassNames: Story = {
55
+ render: (args) => (
56
+ <Popover
57
+ {...args}
58
+ className="border-primary"
59
+ classNames={{ viewport: "py-6" }}
60
+ trigger={<Button variant="outline">Open</Button>}
61
+ >
62
+ Custom popup + viewport styling.
63
+ </Popover>
64
+ ),
65
+ };
66
+
46
67
  /**
47
68
  * `tooltipStyle` renders the popup compact — smaller padding, `rounded-md`, and `w-fit`.
48
69
  * Intended for brief contextual hints that don't need a full panel.
@@ -39,9 +39,11 @@ export function PopoverPopup({
39
39
  matchTriggerWidth = false,
40
40
  anchor,
41
41
  portalProps,
42
+ viewportClassName,
42
43
  ...props
43
44
  }: PopoverPrimitive.Popup.Props & {
44
45
  portalProps?: PopoverPrimitive.Portal.Props;
46
+ viewportClassName?: string;
45
47
  side?: PopoverPrimitive.Positioner.Props["side"];
46
48
  align?: PopoverPrimitive.Positioner.Props["align"];
47
49
  sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"];
@@ -79,6 +81,7 @@ export function PopoverPopup({
79
81
  tooltipStyle
80
82
  ? "py-1 [--viewport-inline-padding:--spacing(2)]"
81
83
  : "not-data-transitioning:overflow-y-auto",
84
+ viewportClassName,
82
85
  )}
83
86
  data-slot="popover-viewport"
84
87
  >
@@ -126,18 +129,6 @@ export { PopoverPrimitive, PopoverPopup as PopoverContent };
126
129
 
127
130
  // --- Composite ---
128
131
 
129
- type PopoverPopupOverrideProps = Omit<
130
- React.ComponentPropsWithoutRef<typeof PopoverPopup>,
131
- | "children"
132
- | "side"
133
- | "align"
134
- | "sideOffset"
135
- | "alignOffset"
136
- | "tooltipStyle"
137
- | "matchTriggerWidth"
138
- | "anchor"
139
- >;
140
-
141
132
  interface PopoverProps extends Omit<PopoverPrimitive.Root.Props, "children"> {
142
133
  trigger?: React.ReactElement;
143
134
  children: ReactNode;
@@ -150,9 +141,13 @@ interface PopoverProps extends Omit<PopoverPrimitive.Root.Props, "children"> {
150
141
  tooltipStyle?: boolean;
151
142
  matchTriggerWidth?: boolean;
152
143
  anchor?: PopoverPrimitive.Positioner.Props["anchor"];
153
- popupClassName?: string;
154
- triggerProps?: Omit<PopoverPrimitive.Trigger.Props, "render" | "children">;
155
- popupProps?: PopoverPopupOverrideProps;
144
+ /** Styles the popup (visual root). */
145
+ className?: string;
146
+ /** Styles applied to each internal slot. */
147
+ classNames?: {
148
+ /** Inner viewport that holds the popup content (padding, overflow). */
149
+ viewport?: string;
150
+ };
156
151
  }
157
152
 
158
153
  export function Popover({
@@ -167,18 +162,14 @@ export function Popover({
167
162
  tooltipStyle,
168
163
  matchTriggerWidth,
169
164
  anchor,
170
- popupClassName,
171
- triggerProps,
172
- popupProps,
165
+ className,
166
+ classNames,
173
167
  ...rootProps
174
168
  }: PopoverProps): React.ReactElement {
175
169
  return (
176
170
  <PopoverRoot {...rootProps}>
177
- {trigger !== undefined && (
178
- <PopoverTrigger render={trigger} {...triggerProps} />
179
- )}
171
+ {trigger !== undefined && <PopoverTrigger render={trigger} />}
180
172
  <PopoverPopup
181
- {...popupProps}
182
173
  side={side}
183
174
  align={align}
184
175
  sideOffset={sideOffset}
@@ -186,7 +177,8 @@ export function Popover({
186
177
  tooltipStyle={tooltipStyle}
187
178
  matchTriggerWidth={matchTriggerWidth}
188
179
  anchor={anchor}
189
- className={cn(popupProps?.className, popupClassName)}
180
+ className={className}
181
+ viewportClassName={classNames?.viewport}
190
182
  >
191
183
  {(title ?? description) && (
192
184
  <div className="mb-2">
@@ -22,6 +22,7 @@ const meta: Meta<typeof Progress> = {
22
22
  value: 60,
23
23
  "aria-label": "Loading",
24
24
  },
25
+ tags: ["new"],
25
26
  };
26
27
 
27
28
  export default meta;
@@ -0,0 +1 @@
1
+ export * from "./radio-group";
@@ -0,0 +1,251 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { action } from "storybook/actions";
4
+ import { Button } from "../../components";
5
+ import { Field } from "../field/field";
6
+ import {
7
+ RadioGroup,
8
+ RadioGroupRoot,
9
+ RadioIndicator,
10
+ RadioRoot,
11
+ } from "./radio-group";
12
+
13
+ /**
14
+ * RadioGroup manages single-selection across a set of radio buttons.
15
+ * Exactly one item can be selected at a time. Built on
16
+ * `@base-ui/react/radio-group` and `@base-ui/react/radio`.
17
+ *
18
+ * Designed to be used inside `Field` — `Field` provides the label, description,
19
+ * and error handling. Pass `name` to both `Field` (for server-error routing)
20
+ * and `RadioGroup` (for native `FormData`).
21
+ *
22
+ * **90% case** — pass `items` and let the composite render all radio buttons:
23
+ * ```tsx
24
+ * <RadioGroup
25
+ * name="plan"
26
+ * items={[{ value: "free", label: "Free" }, { value: "pro", label: "Pro" }]}
27
+ * onValueChange={(value) => console.log(value)}
28
+ * />
29
+ * ```
30
+ *
31
+ * **Compound API** for custom layouts:
32
+ * - `RadioGroup` — group wrapper with value management and a11y fieldset
33
+ * - `RadioGroup.Item` — labeled radio button for use inside `RadioGroup`
34
+ * - `RadioGroup.Legend` — optional fieldset legend when extra semantic grouping is needed
35
+ *
36
+ * Use `onValueChange` to react to selection changes.
37
+ *
38
+ * `controlFirst` (default `true`) places the radio before the label on all
39
+ * `RadioGroup.Item` children via context.
40
+ *
41
+ * Reference: [Radio – Base UI](https://base-ui.com/react/components/radio)
42
+ */
43
+ const meta: Meta<typeof RadioGroup> = {
44
+ title: "Components/RadioGroup",
45
+ component: RadioGroup,
46
+ parameters: { layout: "centered" },
47
+ args: {
48
+ legend: "Preferred contact",
49
+ },
50
+ argTypes: {
51
+ children: { control: false },
52
+ items: { control: false },
53
+ onValueChange: { control: false },
54
+ },
55
+ tags: ["new"],
56
+ };
57
+
58
+ export default meta;
59
+ type Story = StoryObj<typeof RadioGroup>;
60
+
61
+ const CONTACT_ITEMS = [
62
+ { value: "email", label: "Email" },
63
+ { value: "sms", label: "SMS" },
64
+ { value: "push", label: "Push notifications" },
65
+ ];
66
+
67
+ export const Default: Story = {
68
+ render: (args) => (
69
+ <RadioGroup {...args} items={CONTACT_ITEMS} defaultValue="email" />
70
+ ),
71
+ };
72
+
73
+ /**
74
+ * Wrap `RadioGroup` with `Field` to add a label, description, and error handling.
75
+ * Pass `name` to both — `Field` uses it for server-error routing, `RadioGroup`
76
+ * uses it for native `FormData`.
77
+ */
78
+ export const WithDescription: Story = {
79
+ render: ({ legend, ...args }) => (
80
+ <Field
81
+ label={legend}
82
+ description="We'll only contact you using this method."
83
+ >
84
+ <RadioGroup {...args} items={CONTACT_ITEMS} defaultValue="email" />
85
+ </Field>
86
+ ),
87
+ };
88
+
89
+ /**
90
+ * Pass `error` to `Field` — the error message renders below the group and
91
+ * all `RadioGroup.Item` children receive `aria-invalid` (red borders).
92
+ * `description` is hidden while the field is invalid.
93
+ */
94
+ export const WithError: Story = {
95
+ render: ({ legend, ...args }) => (
96
+ <Field
97
+ label={legend}
98
+ invalid
99
+ description="This text is hidden while error is present."
100
+ error="Please select a contact method."
101
+ >
102
+ <RadioGroup {...args} items={CONTACT_ITEMS} />
103
+ </Field>
104
+ ),
105
+ };
106
+
107
+ /**
108
+ * `controlFirst={false}` places the label before the radio button.
109
+ * Default is `true` (radio → label). Applied to all items via context.
110
+ */
111
+ export const LabelFirst: Story = {
112
+ render: (args) => (
113
+ <RadioGroup
114
+ {...args}
115
+ items={CONTACT_ITEMS}
116
+ controlFirst={false}
117
+ defaultValue="email"
118
+ />
119
+ ),
120
+ };
121
+
122
+ export const Disabled: Story = {
123
+ render: (args) => (
124
+ <RadioGroup {...args} items={CONTACT_ITEMS} disabled defaultValue="email" />
125
+ ),
126
+ };
127
+
128
+ /**
129
+ * `readOnly` prevents selection changes while keeping the group focusable
130
+ * for keyboard navigation. Unlike `disabled`, it does not apply opacity.
131
+ */
132
+ export const ReadOnly: Story = {
133
+ render: (args) => (
134
+ <RadioGroup {...args} items={CONTACT_ITEMS} readOnly defaultValue="email" />
135
+ ),
136
+ };
137
+
138
+ /**
139
+ * Use `value` + `onValueChange` for controlled mode.
140
+ */
141
+ export const Controlled: Story = {
142
+ render: (args) => {
143
+ const [value, setValue] = useState("email");
144
+ return (
145
+ <RadioGroup
146
+ {...args}
147
+ items={CONTACT_ITEMS}
148
+ value={value}
149
+ onValueChange={setValue}
150
+ />
151
+ );
152
+ },
153
+ };
154
+
155
+ /**
156
+ * Base UI injects a hidden `<input type="radio">` per item so `name` and
157
+ * `required` work with native `FormData` — no extra wiring needed.
158
+ */
159
+ export const Required: Story = {
160
+ render: (args) => (
161
+ <form
162
+ className="space-y-3"
163
+ onSubmit={(event) => {
164
+ event.preventDefault();
165
+ const data = new FormData(event.currentTarget);
166
+ action("submit")(Object.fromEntries(data));
167
+ }}
168
+ >
169
+ <RadioGroup {...args} items={CONTACT_ITEMS} name="contact" required />
170
+ <Button type="submit">Submit</Button>
171
+ </form>
172
+ ),
173
+ };
174
+
175
+ /**
176
+ * Pass `items` to let the composite render all radio buttons — the 90% case.
177
+ * Each item supports `value`, `label`, and an optional `disabled` flag for
178
+ * per-item control independent of the group `disabled` prop.
179
+ */
180
+ export const WithItems: Story = {
181
+ render: (args) => (
182
+ <RadioGroup
183
+ {...args}
184
+ defaultValue="email"
185
+ items={[
186
+ { value: "email", label: "Email" },
187
+ { value: "sms", label: "SMS" },
188
+ { value: "push", label: "Push notifications", disabled: true },
189
+ ]}
190
+ />
191
+ ),
192
+ };
193
+
194
+ /**
195
+ * `RadioGroup.Legend` can be placed as a direct child instead of the `legend`
196
+ * prop when you need custom styling or want to visually hide it with `sr-only`.
197
+ *
198
+ * ```tsx
199
+ * <RadioGroup>
200
+ * <RadioGroup.Legend className="sr-only">Contact</RadioGroup.Legend>
201
+ * <RadioGroup.Item value="email" label="Email" />
202
+ * </RadioGroup>
203
+ * ```
204
+ */
205
+ export const ComposableLegend: Story = {
206
+ render: () => (
207
+ <RadioGroup defaultValue="email">
208
+ <RadioGroup.Legend className="text-base font-semibold">
209
+ Preferred contact method
210
+ </RadioGroup.Legend>
211
+ <RadioGroup.Item value="email" label="Email" />
212
+ <RadioGroup.Item value="sms" label="SMS" />
213
+ <RadioGroup.Item value="push" label="Push notifications" />
214
+ </RadioGroup>
215
+ ),
216
+ };
217
+
218
+ /**
219
+ * Direct composition with primitives for full structural control.
220
+ * Use when the composite layout is not enough — custom ordering,
221
+ * extra elements between items, or non-standard layouts.
222
+ *
223
+ * ```tsx
224
+ * <RadioGroupRoot name="plan">
225
+ * <RadioRoot value="pro">
226
+ * <RadioIndicator />
227
+ * </RadioRoot>
228
+ * </RadioGroupRoot>
229
+ * ```
230
+ */
231
+ export const Primitive: Story = {
232
+ render: () => (
233
+ <RadioGroupRoot name="plan" defaultValue="pro">
234
+ <div className="flex flex-col gap-2">
235
+ {(["free", "pro", "enterprise"] as const).map((plan) => (
236
+ <div key={plan} className="flex items-center gap-2">
237
+ <RadioRoot id={`primitive-${plan}`} value={plan}>
238
+ <RadioIndicator />
239
+ </RadioRoot>
240
+ <label
241
+ htmlFor={`primitive-${plan}`}
242
+ className="select-none text-sm capitalize"
243
+ >
244
+ {plan}
245
+ </label>
246
+ </div>
247
+ ))}
248
+ </div>
249
+ </RadioGroupRoot>
250
+ ),
251
+ };