@boxcustodia/library 2.0.0-alpha.12 → 2.0.0-alpha.14

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 +1087 -720
  3. package/dist/index.es.js +7011 -56097
  4. package/dist/theme.css +1 -1
  5. package/package.json +34 -26
  6. package/src/__doc__/Examples.tsx +1 -1
  7. package/src/__doc__/Intro.mdx +3 -3
  8. package/src/__doc__/Tabs.mdx +112 -0
  9. package/src/__doc__/V2.mdx +1246 -0
  10. package/src/components/accordion/accordion.stories.tsx +143 -0
  11. package/src/components/accordion/accordion.tsx +135 -0
  12. package/src/components/accordion/index.ts +1 -0
  13. package/src/components/alert/alert.stories.tsx +24 -4
  14. package/src/components/alert/alert.tsx +17 -9
  15. package/src/components/alert-dialog/alert-dialog.stories.tsx +24 -0
  16. package/src/components/alert-dialog/alert-dialog.test.tsx +1 -1
  17. package/src/components/alert-dialog/alert-dialog.tsx +58 -10
  18. package/src/components/auto-complete/auto-complete.stories.tsx +616 -200
  19. package/src/components/auto-complete/auto-complete.tsx +420 -68
  20. package/src/components/auto-complete/index.ts +0 -1
  21. package/src/components/avatar/avatar.stories.tsx +162 -21
  22. package/src/components/avatar/avatar.tsx +79 -20
  23. package/src/components/button/button.stories.tsx +219 -294
  24. package/src/components/button/button.test.tsx +10 -17
  25. package/src/components/button/button.tsx +78 -19
  26. package/src/components/button/components/base-button.tsx +30 -53
  27. package/src/components/button/index.ts +0 -1
  28. package/src/components/calendar/calendar.stories.tsx +1 -1
  29. package/src/components/calendar/calendar.tsx +4 -4
  30. package/src/components/card/card.stories.tsx +141 -69
  31. package/src/components/card/card.tsx +155 -54
  32. package/src/components/center/center.stories.tsx +22 -39
  33. package/src/components/checkbox/checkbox.stories.tsx +25 -5
  34. package/src/components/checkbox/checkbox.tsx +76 -15
  35. package/src/components/checkbox-group/checkbox-group.stories.tsx +116 -28
  36. package/src/components/checkbox-group/checkbox-group.tsx +84 -3
  37. package/src/components/combobox/combobox.stories.tsx +33 -23
  38. package/src/components/combobox/combobox.tsx +99 -77
  39. package/src/components/date-picker/date-input.stories.tsx +14 -6
  40. package/src/components/date-picker/date-input.tsx +2 -2
  41. package/src/components/date-picker/date-picker.model.ts +13 -4
  42. package/src/components/date-picker/date-picker.stories.tsx +38 -12
  43. package/src/components/date-picker/date-picker.tsx +28 -14
  44. package/src/components/dialog/dialog.stories.tsx +18 -0
  45. package/src/components/dialog/dialog.test.tsx +1 -1
  46. package/src/components/dialog/dialog.tsx +51 -20
  47. package/src/components/divider/divider.stories.tsx +126 -51
  48. package/src/components/divider/divider.tsx +16 -16
  49. package/src/components/dropzone/dropzone.stories.tsx +71 -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 +165 -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 +227 -4
  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 +4 -4
  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 +276 -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 +31 -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 +154 -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 +29 -9
  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 +767 -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 +373 -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 +155 -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 +323 -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 +365 -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 +67 -37
  130. package/src/utils/index.ts +1 -1
  131. package/src/__doc__/Migration.mdx +0 -475
  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
@@ -1,100 +1,192 @@
1
- import React, { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
1
+ "use client";
2
+ import * as React from "react";
3
+ import { cn } from "../../lib";
2
4
  import {
3
5
  MenuCheckboxItem,
4
- MenuContent,
6
+ MenuGroup,
5
7
  MenuItem,
6
8
  MenuLabel,
9
+ MenuPopup,
10
+ MenuRadioGroup,
7
11
  MenuRadioItem,
8
12
  MenuRoot,
9
13
  MenuSeparator,
10
14
  MenuShortcut,
15
+ MenuSub,
16
+ MenuSubContent,
11
17
  MenuSubTrigger,
12
18
  MenuTrigger,
13
19
  } from "./menu-primitives";
14
- import { renderMenuItem } from "./util/render-menu-item";
15
20
 
16
- type MenuShortcutProps = ComponentPropsWithoutRef<typeof MenuShortcut>;
17
-
18
- type Shortcut = Partial<{
19
- shortcut: MenuShortcutProps["keys"];
20
- onShortcut: MenuShortcutProps["handler"];
21
- shortcutOptions: MenuShortcutProps["options"];
22
- }>;
23
-
24
- type MenuItemBase<T extends ElementType> = ComponentPropsWithoutRef<T> & {
25
- label?: ReactNode;
26
- type: string;
27
- } & Shortcut;
28
-
29
- type MenuItemItem = MenuItemBase<typeof MenuItem> & {
21
+ type MenuItemItem = {
30
22
  type: "item";
31
- };
32
- type MenuItemCheckbox = MenuItemBase<typeof MenuCheckboxItem> & {
23
+ label: React.ReactNode;
24
+ shortcut?: string;
25
+ } & Omit<React.ComponentProps<typeof MenuItem>, "children">;
26
+
27
+ type MenuItemCheckbox = {
33
28
  type: "checkbox";
34
- };
29
+ label: React.ReactNode;
30
+ shortcut?: string;
31
+ } & Omit<React.ComponentProps<typeof MenuCheckboxItem>, "children">;
35
32
 
36
- type MenuItemRadio = MenuItemBase<typeof MenuRadioItem> & {
33
+ type MenuItemRadio = {
37
34
  type: "radio";
35
+ label: React.ReactNode;
36
+ shortcut?: string;
37
+ } & Omit<React.ComponentProps<typeof MenuRadioItem>, "children">;
38
+
39
+ type MenuItemRadioGroup = {
40
+ type: "radio-group";
41
+ value?: string;
42
+ onValueChange?: (value: string) => void;
43
+ items: MenuItemRadio[];
38
44
  };
39
- type MenuItemLabel = MenuItemBase<typeof MenuLabel> & {
45
+
46
+ type MenuItemLabel = {
40
47
  type: "label";
41
- };
48
+ label: React.ReactNode;
49
+ } & Omit<React.ComponentProps<typeof MenuLabel>, "children">;
42
50
 
43
- type MenuItemSeparator = MenuItemBase<typeof MenuSeparator> & {
51
+ type MenuItemSeparator = {
44
52
  type: "separator";
53
+ } & React.ComponentProps<typeof MenuSeparator>;
54
+
55
+ type MenuItemGroup = {
56
+ type: "group";
57
+ label?: React.ReactNode;
58
+ items: MenuItemType[];
45
59
  };
46
60
 
47
- type MenuItemSubmenu = MenuItemBase<typeof MenuSubTrigger> & {
61
+ type MenuItemSubmenu = {
48
62
  type: "submenu";
63
+ label: React.ReactNode;
49
64
  items: MenuItemType[];
50
- };
65
+ } & Omit<React.ComponentProps<typeof MenuSubTrigger>, "children">;
51
66
 
52
67
  export type MenuItemType =
53
68
  | MenuItemItem
54
69
  | MenuItemCheckbox
55
70
  | MenuItemRadio
71
+ | MenuItemRadioGroup
56
72
  | MenuItemLabel
57
73
  | MenuItemSeparator
74
+ | MenuItemGroup
58
75
  | MenuItemSubmenu;
59
76
 
60
- export interface GenericMenuProps {
61
- trigger: React.ReactNode;
77
+ function renderItem(item: MenuItemType, index: number): React.ReactNode {
78
+ switch (item.type) {
79
+ case "item": {
80
+ const { type: _, label, shortcut, ...props } = item;
81
+ return (
82
+ <MenuItem key={index} {...props}>
83
+ {label}
84
+ {shortcut && <MenuShortcut>{shortcut}</MenuShortcut>}
85
+ </MenuItem>
86
+ );
87
+ }
88
+ case "checkbox": {
89
+ const { type: _, label, shortcut, ...props } = item;
90
+ return (
91
+ <MenuCheckboxItem key={index} {...props}>
92
+ {label}
93
+ {shortcut && <MenuShortcut>{shortcut}</MenuShortcut>}
94
+ </MenuCheckboxItem>
95
+ );
96
+ }
97
+ case "radio": {
98
+ const { type: _, label, shortcut, ...props } = item;
99
+ return (
100
+ <MenuRadioItem key={index} {...props}>
101
+ {label}
102
+ {shortcut && <MenuShortcut>{shortcut}</MenuShortcut>}
103
+ </MenuRadioItem>
104
+ );
105
+ }
106
+ case "radio-group": {
107
+ const { type: _, value, onValueChange, items } = item;
108
+ return (
109
+ <MenuRadioGroup
110
+ key={index}
111
+ value={value}
112
+ onValueChange={onValueChange}
113
+ >
114
+ {items.map((subItem, i) => renderItem(subItem, i))}
115
+ </MenuRadioGroup>
116
+ );
117
+ }
118
+ case "label": {
119
+ const { type: _, label, ...props } = item;
120
+ return (
121
+ <MenuGroup key={index}>
122
+ <MenuLabel {...props}>{label}</MenuLabel>
123
+ </MenuGroup>
124
+ );
125
+ }
126
+ case "separator": {
127
+ const { type: _, ...props } = item;
128
+ return <MenuSeparator key={index} {...props} />;
129
+ }
130
+ case "group": {
131
+ const { type: _, label, items } = item;
132
+ return (
133
+ <MenuGroup key={index}>
134
+ {label && <MenuLabel>{label}</MenuLabel>}
135
+ {items.map((subItem, i) => renderItem(subItem, i))}
136
+ </MenuGroup>
137
+ );
138
+ }
139
+ case "submenu": {
140
+ const { type: _, label, items, ...props } = item;
141
+ return (
142
+ <MenuSub key={index}>
143
+ <MenuSubTrigger {...props}>{label}</MenuSubTrigger>
144
+ <MenuSubContent>
145
+ {items.map((subItem, i) => renderItem(subItem, i))}
146
+ </MenuSubContent>
147
+ </MenuSub>
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ export interface MenuProps {
154
+ trigger: React.ReactElement;
62
155
  items: MenuItemType[];
63
- side?: "top" | "right" | "bottom" | "left";
64
- offset?: ComponentPropsWithoutRef<typeof MenuContent>["sideOffset"];
65
- contentProps?: ComponentPropsWithoutRef<typeof MenuContent>;
156
+ className?: string;
157
+ side?: React.ComponentProps<typeof MenuPopup>["side"];
158
+ align?: React.ComponentProps<typeof MenuPopup>["align"];
159
+ offset?: React.ComponentProps<typeof MenuPopup>["sideOffset"];
160
+ alignOffset?: React.ComponentProps<typeof MenuPopup>["alignOffset"];
161
+ /** Styles applied to each internal slot. */
162
+ classNames?: {
163
+ /** Popup panel that opens below the trigger. */
164
+ popup?: string;
165
+ };
66
166
  }
67
167
 
68
168
  export const Menu = ({
69
169
  trigger,
70
170
  items,
71
- contentProps,
171
+ className,
72
172
  side = "bottom",
173
+ align = "start",
73
174
  offset,
74
- }: GenericMenuProps) => {
175
+ alignOffset,
176
+ classNames,
177
+ }: MenuProps) => {
75
178
  return (
76
179
  <MenuRoot>
77
- <MenuTrigger asChild>{trigger}</MenuTrigger>
78
- <MenuContent side={side} sideOffset={offset} {...contentProps}>
79
- {items.map(
80
- ({ shortcut, onShortcut, shortcutOptions, ...item }, index) => (
81
- <React.Fragment key={index}>
82
- {renderMenuItem(
83
- item,
84
- shortcut && (
85
- <MenuShortcut
86
- keys={shortcut}
87
- handler={(event) => onShortcut?.(event)}
88
- options={shortcutOptions}
89
- >
90
- {shortcut}
91
- </MenuShortcut>
92
- ),
93
- )}
94
- </React.Fragment>
95
- ),
96
- )}
97
- </MenuContent>
180
+ <MenuTrigger render={trigger} />
181
+ <MenuPopup
182
+ className={cn(classNames?.popup, className)}
183
+ side={side}
184
+ align={align}
185
+ sideOffset={offset}
186
+ alignOffset={alignOffset}
187
+ >
188
+ {items.map((item, index) => renderItem(item, index))}
189
+ </MenuPopup>
98
190
  </MenuRoot>
99
191
  );
100
192
  };
@@ -29,6 +29,9 @@ const meta: Meta<typeof NumberInput> = {
29
29
  name: "quantity",
30
30
  defaultValue: 0,
31
31
  },
32
+ argTypes: {
33
+ classNames: { control: false },
34
+ },
32
35
  };
33
36
 
34
37
  export default meta;
@@ -37,6 +40,22 @@ type Story = StoryObj<typeof NumberInput>;
37
40
 
38
41
  export const Default: Story = {};
39
42
 
43
+ /**
44
+ * `className` estila el grupo (el contenedor con borde — root visual del
45
+ * componente). `classNames` expone los slots `input`, `controls`,
46
+ * `increment` y `decrement` para personalizar las partes internas.
47
+ */
48
+ export const WithClassNames: Story = {
49
+ args: {
50
+ className: "outline-primary",
51
+ classNames: {
52
+ input: "font-mono text-primary",
53
+ increment: "text-primary",
54
+ decrement: "text-primary",
55
+ },
56
+ },
57
+ };
58
+
40
59
  export const Disabled: Story = {
41
60
  args: {
42
61
  disabled: true,
@@ -89,15 +108,19 @@ export const Scrub: Story = {
89
108
  };
90
109
 
91
110
  /**
92
- * Usá `value` + `onChange` para controlar el valor externamente.
93
- * `onChange` recibe `number | null` — `null` cuando el campo está vacío o contiene un valor inválido.
111
+ * Usá `value` + `onValueChange` para controlar el valor externamente.
112
+ * `onValueChange` recibe `number | null` — `null` cuando el campo está vacío o contiene un valor inválido.
94
113
  */
95
114
  export const Controlled: Story = {
96
115
  render: () => {
97
116
  const [value, setValue] = useState<number | null>(0);
98
117
  return (
99
118
  <div className="flex w-48 flex-col gap-2">
100
- <NumberInput name="quantity" value={value} onChange={setValue} />
119
+ <NumberInput
120
+ name="quantity"
121
+ value={value}
122
+ onValueChange={(v) => setValue(v)}
123
+ />
101
124
  <p className="text-muted-foreground text-xs">
102
125
  Valor: {value ?? "vacío"}
103
126
  </p>
@@ -118,7 +141,7 @@ export const Range: Story = {
118
141
  <NumberInput
119
142
  name="progress"
120
143
  value={value}
121
- onChange={(v) => v !== null && setValue(v)}
144
+ onValueChange={(v) => v !== null && setValue(v)}
122
145
  min={0}
123
146
  max={100}
124
147
  step={10}
@@ -33,9 +33,9 @@ describe("NumberInput component", () => {
33
33
  expect(ref.current).toBeInstanceOf(HTMLInputElement);
34
34
  });
35
35
 
36
- it("llama a onChange cuando el valor cambia", () => {
36
+ it("llama a onValueChange cuando el valor cambia", () => {
37
37
  const handleChange = vi.fn();
38
- render(<NumberInput name="cantidad" onChange={handleChange} />);
38
+ render(<NumberInput name="cantidad" onValueChange={handleChange} />);
39
39
  const input = screen.getByRole("textbox");
40
40
 
41
41
  fireEvent.input(input, { target: { value: "5" } });
@@ -32,11 +32,12 @@ export function NumberInputGroup({
32
32
  return (
33
33
  <NumberFieldPrimitive.Group
34
34
  className={cn(
35
- "flex w-full items-center overflow-hidden rounded-md border border-input bg-background text-sm transition-shadow",
36
- "focus-within:border-ring",
37
- "aria-invalid:border-error focus-within:aria-invalid:ring-error/20",
38
- "has-aria-invalid:border-error focus-within:has-aria-invalid:ring-error/20",
39
- "data-[invalid]:border-error focus-within:data-[invalid]:ring-error/20",
35
+ "flex h-10 w-full items-center overflow-hidden rounded-md bg-background text-sm transition-shadow",
36
+ "outline outline-1 outline-input [outline-offset:-1px]",
37
+ "focus-within:outline-ring",
38
+ "aria-invalid:outline-error focus-within:aria-invalid:ring-error/20",
39
+ "has-aria-invalid:outline-error focus-within:has-aria-invalid:ring-error/20",
40
+ "data-[invalid]:outline-error focus-within:data-[invalid]:ring-error/20",
40
41
  "data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:pointer-events-none",
41
42
  "[&_svg]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
42
43
  className,
@@ -153,70 +154,67 @@ export function NumberInputCursorIcon(props: ComponentProps<"svg">) {
153
154
  export interface NumberInputProps extends NumberFieldPrimitive.Root.Props {
154
155
  /** Hides the increment/decrement buttons. */
155
156
  hideControls?: boolean;
156
- /** Shorthand for `onValueChange` — receives only the value, without event details. */
157
- onChange?: (value: number | null) => void;
158
157
  /** Marks the field as invalid, adding the error border to the group. */
159
158
  invalid?: boolean;
160
159
  /** Forwarded to the underlying input element. */
161
160
  placeholder?: string;
162
- groupProps?: NumberFieldPrimitive.Group.Props;
163
- inputProps?: NumberFieldPrimitive.Input.Props;
164
- decrementProps?: NumberFieldPrimitive.Decrement.Props;
165
- incrementProps?: NumberFieldPrimitive.Increment.Props;
161
+ /** Styles applied to each internal slot. */
162
+ classNames?: {
163
+ /** Native `<input>` element. */
164
+ input?: string;
165
+ /** Column wrapper around the increment/decrement buttons. */
166
+ controls?: string;
167
+ /** Increment button (Chevron up). */
168
+ increment?: string;
169
+ /** Decrement button (Chevron down). */
170
+ decrement?: string;
171
+ };
166
172
  }
167
173
 
168
174
  export function NumberInput({
169
175
  className,
170
176
  hideControls = false,
171
- onChange,
172
177
  onValueChange,
173
178
  invalid,
174
179
  placeholder,
175
- groupProps,
176
- inputProps,
177
- decrementProps,
178
- incrementProps,
180
+ classNames,
179
181
  ...props
180
182
  }: NumberInputProps) {
181
183
  return (
182
- <NumberInputRoot
183
- onValueChange={(v, d) => {
184
- onChange?.(v);
185
- onValueChange?.(v, d);
186
- }}
187
- {...props}
188
- >
184
+ <NumberInputRoot onValueChange={onValueChange} {...props}>
189
185
  <NumberInputGroup
190
186
  aria-invalid={invalid || undefined}
191
- {...groupProps}
192
- className={cn(className, groupProps?.className)}
187
+ className={className}
193
188
  >
194
189
  <NumberInputInput
195
190
  {...(invalid ? { "aria-invalid": true } : {})}
196
191
  placeholder={placeholder}
197
- {...inputProps}
192
+ className={cn("self-stretch", classNames?.input)}
198
193
  />
199
194
  {!hideControls && (
200
- <div className="flex flex-col self-stretch divide-y divide-input border-l border-input">
195
+ <div
196
+ className={cn(
197
+ "flex flex-col self-stretch divide-y divide-input border-l border-input",
198
+ classNames?.controls,
199
+ )}
200
+ >
201
201
  <NumberInputIncrement
202
202
  data-testid="increment-trigger"
203
- {...incrementProps}
204
203
  className={cn(
205
204
  "flex-1 border-l-0 py-0 [&_svg]:size-3",
206
- incrementProps?.className,
205
+ classNames?.increment,
207
206
  )}
208
207
  >
209
- {incrementProps?.children ?? <ChevronUpIcon />}
208
+ <ChevronUpIcon />
210
209
  </NumberInputIncrement>
211
210
  <NumberInputDecrement
212
211
  data-testid="decrement-trigger"
213
- {...decrementProps}
214
212
  className={cn(
215
213
  "flex-1 border-r-0 py-0 [&_svg]:size-3",
216
- decrementProps?.className,
214
+ classNames?.decrement,
217
215
  )}
218
216
  >
219
- {decrementProps?.children ?? <ChevronDownIcon />}
217
+ <ChevronDownIcon />
220
218
  </NumberInputDecrement>
221
219
  </div>
222
220
  )}
@@ -0,0 +1 @@
1
+ export * from "./otp";
@@ -0,0 +1,209 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import React from "react";
3
+
4
+ import { Button } from "../button/button";
5
+ import { Field } from "../field/field";
6
+ import { Form } from "../form/form";
7
+ import { OTP, OTPInput, OTPRoot, OTPSeparator } from "./otp";
8
+
9
+ /**
10
+ * Segmented input for one-time passwords and verification codes.
11
+ * The composite `OTP` generates slots from `length` and places
12
+ * `OTPSeparator` automatically via `split`. Use `inputProps` to forward
13
+ * props to every slot. Compose primitives (`OTPRoot`, `OTPInput`,
14
+ * `OTPSeparator`) directly for full control over slot layout.
15
+ */
16
+ const meta: Meta<typeof OTP> = {
17
+ title: "Components/OTP",
18
+ component: OTP,
19
+ parameters: { layout: "centered" },
20
+ args: {
21
+ length: 6,
22
+ "aria-label": "Verification code",
23
+ },
24
+ tags: ["new"],
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof OTP>;
29
+
30
+ export const Default: Story = {};
31
+
32
+ export const Sizes: Story = {
33
+ render: () => (
34
+ <div className="flex flex-col gap-4">
35
+ <OTP aria-label="Default size" length={6} />
36
+ <OTP aria-label="Large size" length={6} size="lg" />
37
+ </div>
38
+ ),
39
+ };
40
+
41
+ /**
42
+ * `split={n}` inserts `OTPSeparator` after the nth slot (1-indexed).
43
+ */
44
+ export const WithSeparator: Story = {
45
+ args: {
46
+ split: 3,
47
+ },
48
+ };
49
+
50
+ /**
51
+ * Associate a visible label using a `<label>` pointing to the root's `id`.
52
+ * When inside a `Field` with `FieldLabel`, the association is automatic.
53
+ */
54
+ export const WithLabel: Story = {
55
+ render: () => (
56
+ <div className="flex flex-col gap-1.5">
57
+ <label className="text-sm font-medium" htmlFor="otp-with-label">
58
+ Verification code
59
+ </label>
60
+ <OTP
61
+ id="otp-with-label"
62
+ aria-label="Verification code"
63
+ length={6}
64
+ split={3}
65
+ />
66
+ </div>
67
+ ),
68
+ };
69
+
70
+ export const Disabled: Story = {
71
+ args: {
72
+ disabled: true,
73
+ split: 3,
74
+ },
75
+ };
76
+
77
+ /**
78
+ * `validationType="alphanumeric"` for recovery, backup, or invite codes
79
+ * that mix letters and numbers. Forward `inputMode="text"` via `inputProps`
80
+ * so mobile keyboards show letters alongside digits.
81
+ */
82
+ export const Alphanumeric: Story = {
83
+ args: {
84
+ validationType: "alphanumeric",
85
+ split: 3,
86
+ inputProps: { inputMode: "text" },
87
+ },
88
+ };
89
+
90
+ /**
91
+ * `mask` obscures typed characters in each slot while preserving keyboard
92
+ * navigation and paste behavior — useful on shared screens.
93
+ */
94
+ export const Masked: Story = {
95
+ args: {
96
+ mask: true,
97
+ },
98
+ };
99
+
100
+ /**
101
+ * `placeholder` on each slot shows a hint character. Forward via `inputProps`
102
+ * alongside `focus-visible:placeholder:text-transparent` to hide the hint on
103
+ * the active slot so it does not compete with typed input.
104
+ */
105
+ export const WithPlaceholder: Story = {
106
+ args: {
107
+ inputProps: {
108
+ placeholder: "1",
109
+ className: "focus-visible:placeholder:text-transparent",
110
+ },
111
+ },
112
+ };
113
+
114
+ /**
115
+ * Controlled via `value` + `onValueChange`. Fires with the full string on
116
+ * every keystroke. Check `value.length === length` to detect completion.
117
+ */
118
+ export const Controlled: Story = {
119
+ render: (args) => {
120
+ const [value, setValue] = React.useState("");
121
+ const done = value.length === 6;
122
+ return (
123
+ <div className="flex flex-col items-start gap-3">
124
+ <OTP {...args} value={value} onValueChange={setValue} />
125
+ <p className="text-sm text-muted-foreground">
126
+ {done ? (
127
+ <span className="text-foreground font-medium">Code: {value}</span>
128
+ ) : (
129
+ <>{value.length} / 6 characters</>
130
+ )}
131
+ </p>
132
+ </div>
133
+ );
134
+ },
135
+ };
136
+
137
+ /**
138
+ * Uncontrolled via `name` — value is read from `FormData` on submit.
139
+ * Declare constraint errors on `Field` so Base UI intercepts native browser
140
+ * validation and shows messages inline instead of the browser tooltip.
141
+ * `patternMismatch` fires when the OTP is partially filled; `valueMissing`
142
+ * when empty with `required`. `onFormSubmit` only runs when all constraints pass.
143
+ */
144
+ export const WithForm: Story = {
145
+ render: () => {
146
+ const [result, setResult] = React.useState<string | null>(null);
147
+
148
+ return (
149
+ <Form
150
+ className="flex flex-col items-start gap-3"
151
+ onFormSubmit={(data) => setResult(data.code as string)}
152
+ >
153
+ <Field
154
+ name="code"
155
+ label="Verification code"
156
+ error={[
157
+ {
158
+ message: "Verification code is required.",
159
+ match: "valueMissing",
160
+ },
161
+ { message: "Enter all 6 digits.", match: "patternMismatch" },
162
+ ]}
163
+ >
164
+ <OTP length={6} split={3} required />
165
+ </Field>
166
+ <Button type="submit">Verify</Button>
167
+ {result && (
168
+ <p className="text-sm text-muted-foreground">
169
+ Submitted:{" "}
170
+ <span className="font-medium text-foreground">{result}</span>
171
+ </p>
172
+ )}
173
+ </Form>
174
+ );
175
+ },
176
+ };
177
+
178
+ /**
179
+ * Use primitives directly when you need custom slot layout, conditional
180
+ * rendering, or per-slot props that `inputProps` cannot express uniformly.
181
+ */
182
+ export const Primitive: Story = {
183
+ render: () => (
184
+ <OTPRoot aria-label="Verification code" length={6}>
185
+ <OTPInput aria-label="Character 1 of 6" />
186
+ <OTPInput aria-label="Character 2 of 6" />
187
+ <OTPInput aria-label="Character 3 of 6" />
188
+ <OTPSeparator />
189
+ <OTPInput aria-label="Character 4 of 6" />
190
+ <OTPInput aria-label="Character 5 of 6" />
191
+ <OTPInput aria-label="Character 6 of 6" />
192
+ </OTPRoot>
193
+ ),
194
+ parameters: {
195
+ docs: {
196
+ source: {
197
+ code: `<OTPRoot aria-label="Verification code" length={6}>
198
+ <OTPInput aria-label="Character 1 of 6" />
199
+ <OTPInput aria-label="Character 2 of 6" />
200
+ <OTPInput aria-label="Character 3 of 6" />
201
+ <OTPSeparator />
202
+ <OTPInput aria-label="Character 4 of 6" />
203
+ <OTPInput aria-label="Character 5 of 6" />
204
+ <OTPInput aria-label="Character 6 of 6" />
205
+ </OTPRoot>`,
206
+ },
207
+ },
208
+ },
209
+ };