@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
@@ -0,0 +1,212 @@
1
+ import { Field as FieldBase } from "@base-ui/react/field";
2
+ import { Radio as RadioPrimitive } from "@base-ui/react/radio";
3
+ import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
4
+ import { createContext, type ReactNode, useContext, useId } from "react";
5
+ import { cn } from "../../lib";
6
+ import {
7
+ FieldLegend,
8
+ FieldSet,
9
+ FieldValidity,
10
+ useFieldName,
11
+ useIsInsideFieldRoot,
12
+ } from "../field/field";
13
+ import { Label } from "../label";
14
+
15
+ // ── Primitives ─────────────────────────────────────────────────────────────────
16
+
17
+ export function RadioGroupRoot({
18
+ className,
19
+ ...props
20
+ }: RadioGroupPrimitive.Props) {
21
+ return (
22
+ <RadioGroupPrimitive
23
+ className={cn("grid w-full gap-2", className)}
24
+ data-slot="radio-group"
25
+ {...props}
26
+ />
27
+ );
28
+ }
29
+
30
+ export function RadioRoot({ className, ...props }: RadioPrimitive.Root.Props) {
31
+ return (
32
+ <RadioPrimitive.Root
33
+ className={cn(
34
+ "peer relative inline-flex size-4.5 shrink-0 items-center justify-center rounded-full border border-input bg-background not-dark:bg-clip-padding shadow-xs/5 outline-none ring-ring transition-shadow",
35
+ "before:pointer-events-none before:absolute before:inset-0 before:rounded-full",
36
+ "not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)]",
37
+ "focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-background",
38
+ "aria-invalid:border-error focus-visible:aria-invalid:border-error focus-visible:aria-invalid:ring-error",
39
+ "data-disabled:cursor-not-allowed data-disabled:opacity-64",
40
+ "sm:size-4",
41
+ "dark:not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]",
42
+ "[[data-disabled],[data-checked],[aria-invalid]]:shadow-none",
43
+ "data-checked:border-primary data-checked:bg-primary",
44
+ className,
45
+ )}
46
+ data-slot="radio"
47
+ {...props}
48
+ />
49
+ );
50
+ }
51
+
52
+ export function RadioIndicator({
53
+ className,
54
+ children,
55
+ ...props
56
+ }: RadioPrimitive.Indicator.Props) {
57
+ return (
58
+ <RadioPrimitive.Indicator
59
+ className={cn("flex size-full items-center justify-center", className)}
60
+ data-slot="radio-indicator"
61
+ {...props}
62
+ >
63
+ {children ?? (
64
+ <span className="size-2 rounded-full bg-primary-foreground" />
65
+ )}
66
+ </RadioPrimitive.Indicator>
67
+ );
68
+ }
69
+
70
+ // ── Group context ──────────────────────────────────────────────────────────────
71
+
72
+ const RadioGroupContext = createContext<{
73
+ controlFirst: boolean;
74
+ invalid: boolean;
75
+ }>({
76
+ controlFirst: true,
77
+ invalid: false,
78
+ });
79
+
80
+ // ── Composite: RadioGroup.Item ─────────────────────────────────────────────────
81
+
82
+ export interface RadioItemProps
83
+ extends Omit<RadioPrimitive.Root.Props, "children"> {
84
+ label: ReactNode;
85
+ }
86
+
87
+ function RadioGroupItem({ className, label, id, ...props }: RadioItemProps) {
88
+ const { controlFirst, invalid } = useContext(RadioGroupContext);
89
+ const generatedId = useId();
90
+ const idToUse = id ?? generatedId;
91
+
92
+ return (
93
+ <FieldBase.Root invalid={invalid}>
94
+ <div
95
+ className={cn(
96
+ "flex select-none items-center gap-x-2",
97
+ !controlFirst && "flex-row-reverse justify-end",
98
+ )}
99
+ >
100
+ <RadioRoot id={idToUse} className={className} {...props}>
101
+ <RadioIndicator />
102
+ </RadioRoot>
103
+ <Label htmlFor={idToUse}>{label}</Label>
104
+ </div>
105
+ </FieldBase.Root>
106
+ );
107
+ }
108
+
109
+ // ── Composite: RadioGroup.Legend ───────────────────────────────────────────────
110
+
111
+ export interface RadioGroupLegendProps {
112
+ children: ReactNode;
113
+ className?: string;
114
+ }
115
+
116
+ function RadioGroupLegend({ children, className }: RadioGroupLegendProps) {
117
+ return <FieldLegend className={className}>{children}</FieldLegend>;
118
+ }
119
+
120
+ // ── Composite: RadioGroup ──────────────────────────────────────────────────────
121
+
122
+ export interface RadioOption {
123
+ value: string;
124
+ label: ReactNode;
125
+ disabled?: boolean;
126
+ }
127
+
128
+ export interface RadioGroupProps {
129
+ legend?: ReactNode;
130
+ children?: ReactNode;
131
+ items?: RadioOption[];
132
+ defaultValue?: string;
133
+ value?: string;
134
+ onValueChange?: (value: string) => void;
135
+ disabled?: boolean;
136
+ required?: boolean;
137
+ readOnly?: boolean;
138
+ controlFirst?: boolean;
139
+ className?: string;
140
+ name?: string;
141
+ form?: string;
142
+ }
143
+
144
+ function RadioGroupComposite({
145
+ legend,
146
+ children,
147
+ items,
148
+ defaultValue,
149
+ value,
150
+ onValueChange,
151
+ disabled,
152
+ required,
153
+ readOnly,
154
+ controlFirst = true,
155
+ className,
156
+ name: nameProp,
157
+ form,
158
+ }: RadioGroupProps) {
159
+ const fieldName = useFieldName();
160
+ const isInsideField = useIsInsideFieldRoot();
161
+ const name = nameProp ?? fieldName;
162
+
163
+ const renderGroup = (invalid: boolean) => (
164
+ <RadioGroupContext.Provider value={{ controlFirst, invalid }}>
165
+ <RadioGroupPrimitive
166
+ defaultValue={defaultValue}
167
+ value={value}
168
+ onValueChange={onValueChange}
169
+ disabled={disabled}
170
+ required={required}
171
+ readOnly={readOnly}
172
+ name={name}
173
+ form={form}
174
+ >
175
+ <FieldSet className={className}>
176
+ {legend && <RadioGroupLegend>{legend}</RadioGroupLegend>}
177
+ <div className="flex flex-col gap-2">
178
+ {items
179
+ ? items.map((item) => (
180
+ <RadioGroupItem
181
+ key={item.value}
182
+ value={item.value}
183
+ label={item.label}
184
+ disabled={item.disabled}
185
+ />
186
+ ))
187
+ : children}
188
+ </div>
189
+ </FieldSet>
190
+ </RadioGroupPrimitive>
191
+ </RadioGroupContext.Provider>
192
+ );
193
+
194
+ if (!isInsideField) return renderGroup(false);
195
+
196
+ return (
197
+ <FieldValidity>
198
+ {({ validity }) => renderGroup(validity.valid === false)}
199
+ </FieldValidity>
200
+ );
201
+ }
202
+
203
+ // ── Compound export ────────────────────────────────────────────────────────────
204
+
205
+ export const RadioGroup = Object.assign(RadioGroupComposite, {
206
+ Item: RadioGroupItem,
207
+ Legend: RadioGroupLegend,
208
+ });
209
+
210
+ // ── Primitive escape hatch ─────────────────────────────────────────────────────
211
+
212
+ export { RadioGroupPrimitive, RadioPrimitive };
@@ -31,6 +31,7 @@ const meta: Meta<typeof ScrollArea> = {
31
31
  argTypes: {
32
32
  children: { control: false },
33
33
  },
34
+ tags: ["new"],
34
35
  };
35
36
 
36
37
  export default meta;
@@ -32,14 +32,14 @@ const animals: Animal[] = [
32
32
  ];
33
33
 
34
34
  /**
35
- * Single-selection dropdown built on Base UI. Items shaped as `{ label, value }`
36
- * or plain strings/numbers work with no extra props. Provide `getLabel`/`getValue`
37
- * for custom object shapes, or `renderItem` to control the content inside each
38
- * option — the checkmark indicator is always appended on the right.
35
+ * Single-selection dropdown built on Base UI. Items shaped as `{ id, name }`,
36
+ * `{ label, value }`, or plain strings/numbers work with no extra props. Provide
37
+ * `getLabel` / `getId` for custom object shapes, or `renderItem` to control the
38
+ * content inside each option — the checkmark indicator is always appended on the right.
39
39
  *
40
- * Exposes `onChange` instead of Base UI's `onValueChange`. `onChange` receives
41
- * the full item object (or `null`). For controlled usage, pass `value` + `onChange`
42
- * together. Use `SelectRoot` + primitives for full structural control.
40
+ * Exposes `onValueChange` that receives the full item object (or `null`).
41
+ * For controlled usage, pass `value` + `onValueChange` together.
42
+ * Use `SelectRoot` + primitives for full structural control.
43
43
  */
44
44
  const meta: Meta<typeof Select> = {
45
45
  title: "Components/Select",
@@ -53,13 +53,12 @@ const meta: Meta<typeof Select> = {
53
53
  ),
54
54
  ],
55
55
  argTypes: {
56
- onChange: { control: false },
56
+ onValueChange: { control: false },
57
57
  getLabel: { control: false },
58
- getValue: { control: false },
58
+ getId: { control: false },
59
+ getDisabled: { control: false },
59
60
  renderItem: { control: false },
60
- triggerProps: { control: false },
61
- popupProps: { control: false },
62
- itemProps: { control: false },
61
+ classNames: { control: false },
63
62
  },
64
63
  args: {
65
64
  items: animals,
@@ -72,13 +71,113 @@ type Story = StoryObj<typeof Select>;
72
71
 
73
72
  export const Default: Story = {};
74
73
 
74
+ /**
75
+ * `className` styles the trigger. `classNames` exposes the `popup` and
76
+ * `item` slots.
77
+ */
78
+ export const WithClassNames: Story = {
79
+ args: {
80
+ classNames: {
81
+ popup: "min-w-56",
82
+ item: "font-mono",
83
+ },
84
+ },
85
+ };
86
+
75
87
  export const Disabled: Story = {
76
88
  args: { disabled: true },
77
89
  };
78
90
 
79
91
  /**
80
- * `getLabel` and `getValue` accept functions so they work with any object
81
- * shape, not just `{ label, value }`.
92
+ * `alignItemWithTrigger` controla cómo posiciona Base UI el popup respecto al trigger.
93
+ *
94
+ * - **Default (`false`)**: el popup aparece debajo del trigger como un combo común.
95
+ * Es el comportamiento esperado cuando hay muchos items o cuando el popup no debería
96
+ * crecer hasta llenar el viewport intentando alinear el item seleccionado con el trigger.
97
+ * - **`true`**: Base UI intenta alinear el item seleccionado con el trigger y expande
98
+ * el popup hacia arriba y abajo. Útil para listas cortas con un valor preseleccionado,
99
+ * estilo `<select>` nativo de macOS.
100
+ *
101
+ * Es un cambio estructural — usá `SelectRoot` + `SelectPopup` directamente
102
+ * para activarlo:
103
+ *
104
+ * ```tsx
105
+ * <SelectRoot>
106
+ * <SelectTrigger><SelectValue /></SelectTrigger>
107
+ * <SelectPopup alignItemWithTrigger>…</SelectPopup>
108
+ * </SelectRoot>
109
+ * ```
110
+ */
111
+ export const AlignItemWithTrigger: Story = {
112
+ render: () => {
113
+ const years = Array.from({ length: 40 }, (_, i) => {
114
+ const year = 1990 + i;
115
+ return { label: String(year), value: String(year) };
116
+ });
117
+ const preselected = years[20];
118
+
119
+ return (
120
+ <div className="flex flex-col gap-6">
121
+ <div className="flex flex-col gap-1">
122
+ <span className="text-muted-foreground text-xs">
123
+ Default (alignItemWithTrigger=false)
124
+ </span>
125
+ <Select items={years} defaultValue={preselected} />
126
+ </div>
127
+ <div className="flex flex-col gap-1">
128
+ <span className="text-muted-foreground text-xs">
129
+ alignItemWithTrigger=true
130
+ </span>
131
+ <SelectRoot defaultValue={preselected.value}>
132
+ <SelectTrigger>
133
+ <SelectValue />
134
+ </SelectTrigger>
135
+ <SelectPopup alignItemWithTrigger>
136
+ {years.map((y) => (
137
+ <SelectItem key={y.value} value={y.value}>
138
+ {y.label}
139
+ </SelectItem>
140
+ ))}
141
+ </SelectPopup>
142
+ </SelectRoot>
143
+ </div>
144
+ </div>
145
+ );
146
+ },
147
+ };
148
+
149
+ /**
150
+ * `getDisabled` receives a predicate `(item) => boolean` to disable individual
151
+ * options. Items with a `disabled: true` property are disabled automatically
152
+ * without passing this prop.
153
+ *
154
+ * A disabled item is skipped during keyboard navigation and cannot be selected,
155
+ * but it remains visible in the list at reduced opacity.
156
+ */
157
+ export const DisabledItem: Story = {
158
+ render: () => {
159
+ const items = [
160
+ { label: "Cat", value: "cat" },
161
+ { label: "Dog", value: "dog" },
162
+ { label: "Bird", value: "bird" },
163
+ { label: "Rabbit", value: "rabbit" },
164
+ { label: "Fish", value: "fish" },
165
+ ];
166
+
167
+ return (
168
+ <Select
169
+ items={items}
170
+ getDisabled={(item) => item.value === "bird" || item.value === "fish"}
171
+ placeholder="Select an animal"
172
+ />
173
+ );
174
+ },
175
+ };
176
+
177
+ /**
178
+ * `getLabel` and `getId` accept functions so they work with any object shape.
179
+ * For common shapes (`{ id, name }`, `{ label, value }`, primitives) the
180
+ * defaults resolve everything without passing either function.
82
181
  */
83
182
  export const CustomObject: Story = {
84
183
  render: () => {
@@ -92,7 +191,7 @@ export const CustomObject: Story = {
92
191
  <Select
93
192
  items={items}
94
193
  getLabel={(a) => a.name}
95
- getValue={(a) => a.key}
194
+ getId={(a) => a.key}
96
195
  placeholder="Select an animal"
97
196
  />
98
197
  );
@@ -102,7 +201,7 @@ export const CustomObject: Story = {
102
201
  /**
103
202
  * `multiple` enables multi-selection. The trigger shows selected labels as a
104
203
  * comma-separated list with automatic truncation. Use `value: TItem[]` and
105
- * `onChange: (value: TItem[]) => void` for controlled usage — the discriminated
204
+ * `onValueChange: (value: TItem[]) => void` for controlled usage — the discriminated
106
205
  * union ensures type safety between single and multiple modes.
107
206
  */
108
207
  export const Multiple: Story = {
@@ -127,7 +226,7 @@ export const CustomRender: Story = {
127
226
  <Select
128
227
  items={items}
129
228
  getLabel={(a) => a.name}
130
- getValue={(a) => a.key}
229
+ getId={(a) => a.key}
131
230
  renderItem={(a) => (
132
231
  <span className="flex items-center gap-2">
133
232
  <span>{a.emoji}</span>
@@ -141,7 +240,7 @@ export const CustomRender: Story = {
141
240
  };
142
241
 
143
242
  /**
144
- * `onChange` receives the full item object (or `null`).
243
+ * `onValueChange` receives the full item object (or `null`).
145
244
  * `value` accepts the item object directly — no string conversion needed.
146
245
  */
147
246
  export const Controlled: Story = {
@@ -152,7 +251,7 @@ export const Controlled: Story = {
152
251
  <Select
153
252
  {...args}
154
253
  value={value ?? null}
155
- onChange={(item) => updateArgs({ value: item })}
254
+ onValueChange={(item) => updateArgs({ value: item })}
156
255
  />
157
256
  <p className="text-sm text-muted-foreground">
158
257
  Selected: {value ? (value as Animal).label : "none"}
@@ -30,12 +30,9 @@ export function SelectRoot<
30
30
  Value = string,
31
31
  Multiple extends boolean | undefined = false,
32
32
  >({
33
- onChange,
34
33
  ...props
35
- }: Omit<SelectPrimitive.Root.Props<Value, Multiple>, "onValueChange"> & {
36
- onChange?: SelectPrimitive.Root.Props<Value, Multiple>["onValueChange"];
37
- }): React.ReactElement {
38
- return <SelectPrimitive.Root onValueChange={onChange} {...props} />;
34
+ }: SelectPrimitive.Root.Props<Value, Multiple>): React.ReactElement {
35
+ return <SelectPrimitive.Root {...props} />;
39
36
  }
40
37
 
41
38
  export function SelectTrigger({
@@ -117,7 +114,7 @@ export function SelectPopup({
117
114
  sideOffset = 4,
118
115
  align = "start",
119
116
  alignOffset = 0,
120
- alignItemWithTrigger = true,
117
+ alignItemWithTrigger = false,
121
118
  anchor,
122
119
  portalProps,
123
120
  ...props
@@ -156,7 +153,7 @@ export function SelectPopup({
156
153
  <div className="relative h-full min-w-(--anchor-width) rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
157
154
  <SelectPrimitive.List
158
155
  className={cn(
159
- "max-h-(--available-height) overflow-y-auto p-1",
156
+ "max-h-[min(24rem,var(--available-height))] overflow-y-auto p-1",
160
157
  className,
161
158
  )}
162
159
  data-slot="select-list"
@@ -265,41 +262,53 @@ export function SelectLabel({
265
262
  function defaultGetLabel(item: unknown): string {
266
263
  if (item == null) return "";
267
264
  if (typeof item === "string" || typeof item === "number") return String(item);
268
- if (typeof item === "object" && "label" in item)
269
- return String((item as Record<string, unknown>).label);
265
+ if (typeof item === "object") {
266
+ const record = item as Record<string, unknown>;
267
+ if ("label" in record) return String(record.label);
268
+ if ("name" in record) return String(record.name);
269
+ if ("title" in record) return String(record.title);
270
+ }
270
271
  return String(item);
271
272
  }
272
273
 
273
- function defaultGetValue(item: unknown): string {
274
+ function defaultGetId(item: unknown): string {
274
275
  if (item == null) return "";
275
276
  if (typeof item === "string" || typeof item === "number") return String(item);
276
- if (typeof item === "object" && "value" in item)
277
- return String((item as Record<string, unknown>).value);
277
+ if (typeof item === "object") {
278
+ const record = item as Record<string, unknown>;
279
+ if ("id" in record) return String(record.id);
280
+ if ("value" in record) return String(record.value);
281
+ }
278
282
  return String(item);
279
283
  }
280
284
 
281
- type SelectRootPropsAlias<V = string> = Omit<
282
- SelectPrimitive.Root.Props<V, false>,
283
- "onValueChange"
284
- > & {
285
- onChange?: SelectPrimitive.Root.Props<V, false>["onValueChange"];
286
- };
285
+ function defaultGetDisabled(item: unknown): boolean {
286
+ if (item != null && typeof item === "object" && "disabled" in item)
287
+ return Boolean((item as Record<string, unknown>).disabled);
288
+ return false;
289
+ }
287
290
 
288
291
  type SelectBaseProps<TItem = unknown> = Omit<
289
- SelectRootPropsAlias<string>,
290
- "children" | "onChange" | "value" | "defaultValue" | "items" | "multiple"
292
+ SelectPrimitive.Root.Props<string, false>,
293
+ "children" | "onValueChange" | "value" | "defaultValue" | "items" | "multiple"
291
294
  > & {
292
295
  items: readonly TItem[];
296
+ /** Returns the display text for an item. Used for the trigger value and ARIA. Defaults to `item.label`, `item.name`, `item.title`, or the stringified primitive. */
293
297
  getLabel?: (item: TItem) => string;
294
- getValue?: (item: TItem) => string;
298
+ /** Returns a stable string identifier for an item. Used as the React key and the underlying option value. Defaults to `item.id`, `item.value`, or the stringified primitive. */
299
+ getId?: (item: TItem) => string;
300
+ /** Returns whether an item should be disabled. Defaults to `item.disabled === true`. */
301
+ getDisabled?: (item: TItem) => boolean;
295
302
  renderItem?: (item: TItem) => React.ReactNode;
296
303
  placeholder?: string;
297
- triggerProps?: React.ComponentProps<typeof SelectTrigger>;
298
- popupProps?: React.ComponentProps<typeof SelectPopup>;
299
- popupClassName?: string;
300
- itemProps?: SelectPrimitive.Item.Props;
301
- itemClassName?: string;
302
304
  className?: string;
305
+ /** Styles applied to each internal slot. */
306
+ classNames?: {
307
+ /** Popup panel containing the item list. */
308
+ popup?: string;
309
+ /** Individual item row. */
310
+ item?: string;
311
+ };
303
312
  };
304
313
 
305
314
  export type SelectProps<TItem = unknown> =
@@ -307,25 +316,26 @@ export type SelectProps<TItem = unknown> =
307
316
  multiple?: false;
308
317
  value?: TItem | null;
309
318
  defaultValue?: TItem | null;
310
- onChange?: (value: TItem | null) => void;
319
+ onValueChange?: (value: TItem | null) => void;
311
320
  })
312
321
  | (SelectBaseProps<TItem> & {
313
322
  multiple: true;
314
323
  value?: TItem[];
315
324
  defaultValue?: TItem[];
316
- onChange?: (value: TItem[]) => void;
325
+ onValueChange?: (value: TItem[]) => void;
317
326
  });
318
327
 
319
328
  /**
320
- * Composite select for single and multiple selection. Items shaped as
321
- * `{ label, value }` or plain strings/numbers work with no extra props.
322
- * For other shapes, provide `getLabel` and/or `getValue`.
329
+ * Composite select for single and multiple selection.
330
+ *
331
+ * Items shaped as `{ id, name }`, `{ label, value }`, or plain strings/numbers
332
+ * work with no extra props. For other shapes, provide `getLabel` and/or `getId`.
323
333
  *
324
334
  * `renderItem` customizes the content **inside** each `SelectItem` (the
325
335
  * checkmark indicator is always rendered by the item wrapper).
326
336
  *
327
337
  * Use `SelectRoot` + primitives directly when you need full structural
328
- * control beyond what escape-hatch props offer.
338
+ * control beyond what `className` + `classNames` slots offer.
329
339
  */
330
340
  export function Select<TItem = unknown>(
331
341
  allProps: SelectProps<TItem>,
@@ -333,88 +343,83 @@ export function Select<TItem = unknown>(
333
343
  const {
334
344
  items,
335
345
  getLabel: getLabelProp,
336
- getValue: getValueProp,
346
+ getId: getIdProp,
347
+ getDisabled: getDisabledProp,
337
348
  renderItem,
338
349
  placeholder,
339
- onChange,
350
+ onValueChange,
340
351
  value,
341
352
  defaultValue,
342
353
  multiple,
343
- triggerProps,
344
- popupProps,
345
- itemProps,
346
- itemClassName,
347
354
  className,
348
- popupClassName,
355
+ classNames,
349
356
  ...rest
350
357
  } = allProps as SelectBaseProps<TItem> & {
351
358
  multiple?: boolean;
352
- onChange?: (value: any) => void;
359
+ onValueChange?: (value: any) => void;
353
360
  value?: any;
354
361
  defaultValue?: any;
355
362
  };
356
363
 
357
364
  const getLabel: (item: TItem) => string = getLabelProp ?? defaultGetLabel;
358
- const getValue: (item: TItem) => string = getValueProp ?? defaultGetValue;
365
+ const getId: (item: TItem) => string = getIdProp ?? defaultGetId;
366
+ const getDisabled: (item: TItem) => boolean =
367
+ getDisabledProp ?? defaultGetDisabled;
359
368
 
360
369
  const stringValue = multiple
361
- ? (value as TItem[] | undefined)?.map(getValue)
370
+ ? (value as TItem[] | undefined)?.map(getId)
362
371
  : value != null
363
- ? getValue(value as TItem)
372
+ ? getId(value as TItem)
364
373
  : undefined;
365
374
 
366
375
  const stringDefaultValue = multiple
367
- ? (defaultValue as TItem[] | undefined)?.map(getValue)
376
+ ? (defaultValue as TItem[] | undefined)?.map(getId)
368
377
  : defaultValue != null
369
- ? getValue(defaultValue as TItem)
378
+ ? getId(defaultValue as TItem)
370
379
  : undefined;
371
380
 
372
381
  const handleChange = (stringVal: string | string[] | null) => {
373
382
  if (multiple) {
374
383
  const vals = (stringVal as string[]) ?? [];
375
384
  const found = vals
376
- .map((sv) => items.find((i) => getValue(i) === sv) ?? null)
385
+ .map((sv) => items.find((i) => getId(i) === sv) ?? null)
377
386
  .filter(Boolean) as TItem[];
378
- onChange?.(found);
387
+ onValueChange?.(found);
379
388
  } else {
380
389
  if (stringVal == null) {
381
- onChange?.(null);
390
+ onValueChange?.(null);
382
391
  return;
383
392
  }
384
393
  const item =
385
- items.find((i) => getValue(i) === (stringVal as string)) ?? null;
386
- onChange?.(item);
394
+ items.find((i) => getId(i) === (stringVal as string)) ?? null;
395
+ onValueChange?.(item);
387
396
  }
388
397
  };
389
398
 
390
399
  const itemToStringLabel = (stringVal: string) => {
391
- const item = items.find((i) => getValue(i) === stringVal);
400
+ const item = items.find((i) => getId(i) === stringVal);
392
401
  return item ? getLabel(item) : stringVal;
393
402
  };
394
403
 
395
404
  return (
396
405
  <SelectRoot
397
406
  itemToStringLabel={itemToStringLabel}
398
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
399
407
  multiple={multiple as any}
400
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
401
408
  value={stringValue as any}
402
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
403
409
  defaultValue={stringDefaultValue as any}
404
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
405
- onChange={handleChange as any}
410
+ onValueChange={handleChange as any}
406
411
  {...(rest as any)}
407
412
  >
408
- <SelectTrigger className={className} {...triggerProps}>
413
+ <SelectTrigger className={className}>
409
414
  <SelectValue placeholder={placeholder} />
410
415
  </SelectTrigger>
411
- <SelectPopup className={popupClassName} {...popupProps}>
416
+ <SelectPopup className={classNames?.popup}>
412
417
  {items.map((item) => (
413
418
  <SelectItem
414
- key={getValue(item)}
415
- value={getValue(item)}
416
- className={itemClassName}
417
- {...itemProps}
419
+ key={getId(item)}
420
+ value={getId(item)}
421
+ disabled={getDisabled(item)}
422
+ className={classNames?.item}
418
423
  >
419
424
  {renderItem ? renderItem(item) : getLabel(item)}
420
425
  </SelectItem>
@@ -12,6 +12,7 @@ const meta: Meta<typeof Skeleton> = {
12
12
  args: {
13
13
  className: "h-4 w-[200px]",
14
14
  },
15
+ parameters: { layout: "centered" },
15
16
  };
16
17
 
17
18
  export default meta;