@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
@@ -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,7 +32,7 @@ export function NumberInputGroup({
32
32
  return (
33
33
  <NumberFieldPrimitive.Group
34
34
  className={cn(
35
- "flex h-10 w-full items-center overflow-hidden rounded-md bg-background text-sm transition-shadow",
35
+ "flex h-8 w-full items-center overflow-hidden rounded-md bg-background text-sm transition-shadow",
36
36
  "outline outline-1 outline-input [outline-offset:-1px]",
37
37
  "focus-within:outline-ring",
38
38
  "aria-invalid:outline-error focus-within:aria-invalid:ring-error/20",
@@ -55,7 +55,7 @@ export function NumberInputInput({
55
55
  return (
56
56
  <NumberFieldPrimitive.Input
57
57
  className={cn(
58
- "min-w-0 flex-1 bg-transparent px-3 py-2 text-left tabular-nums outline-none",
58
+ "min-w-0 flex-1 bg-transparent px-3 text-left tabular-nums outline-none",
59
59
  "placeholder:text-muted-foreground",
60
60
  className,
61
61
  )}
@@ -73,7 +73,7 @@ export function NumberInputDecrement({
73
73
  return (
74
74
  <NumberFieldPrimitive.Decrement
75
75
  className={cn(
76
- "flex shrink-0 cursor-pointer items-center justify-center border-r border-input px-2 py-2 text-muted-foreground transition-colors",
76
+ "flex shrink-0 cursor-pointer items-center justify-center border-r border-input px-2 text-muted-foreground transition-colors",
77
77
  "hover:bg-accent hover:text-foreground",
78
78
  "disabled:pointer-events-none disabled:opacity-50",
79
79
  className,
@@ -94,7 +94,7 @@ export function NumberInputIncrement({
94
94
  return (
95
95
  <NumberFieldPrimitive.Increment
96
96
  className={cn(
97
- "flex shrink-0 cursor-pointer items-center justify-center border-l border-input px-2 py-2 text-muted-foreground transition-colors",
97
+ "flex shrink-0 cursor-pointer items-center justify-center border-l border-input px-2 text-muted-foreground transition-colors",
98
98
  "hover:bg-accent hover:text-foreground",
99
99
  "disabled:pointer-events-none disabled:opacity-50",
100
100
  className,
@@ -154,71 +154,67 @@ export function NumberInputCursorIcon(props: ComponentProps<"svg">) {
154
154
  export interface NumberInputProps extends NumberFieldPrimitive.Root.Props {
155
155
  /** Hides the increment/decrement buttons. */
156
156
  hideControls?: boolean;
157
- /** Shorthand for `onValueChange` — receives only the value, without event details. */
158
- onChange?: (value: number | null) => void;
159
157
  /** Marks the field as invalid, adding the error border to the group. */
160
158
  invalid?: boolean;
161
159
  /** Forwarded to the underlying input element. */
162
160
  placeholder?: string;
163
- groupProps?: NumberFieldPrimitive.Group.Props;
164
- inputProps?: NumberFieldPrimitive.Input.Props;
165
- decrementProps?: NumberFieldPrimitive.Decrement.Props;
166
- 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
+ };
167
172
  }
168
173
 
169
174
  export function NumberInput({
170
175
  className,
171
176
  hideControls = false,
172
- onChange,
173
177
  onValueChange,
174
178
  invalid,
175
179
  placeholder,
176
- groupProps,
177
- inputProps,
178
- decrementProps,
179
- incrementProps,
180
+ classNames,
180
181
  ...props
181
182
  }: NumberInputProps) {
182
183
  return (
183
- <NumberInputRoot
184
- onValueChange={(v, d) => {
185
- onChange?.(v);
186
- onValueChange?.(v, d);
187
- }}
188
- {...props}
189
- >
184
+ <NumberInputRoot onValueChange={onValueChange} {...props}>
190
185
  <NumberInputGroup
191
186
  aria-invalid={invalid || undefined}
192
- {...groupProps}
193
- className={cn(className, groupProps?.className)}
187
+ className={className}
194
188
  >
195
189
  <NumberInputInput
196
190
  {...(invalid ? { "aria-invalid": true } : {})}
197
191
  placeholder={placeholder}
198
- {...inputProps}
199
- className={cn("self-stretch", inputProps?.className)}
192
+ className={cn("self-stretch", classNames?.input)}
200
193
  />
201
194
  {!hideControls && (
202
- <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
+ >
203
201
  <NumberInputIncrement
204
202
  data-testid="increment-trigger"
205
- {...incrementProps}
206
203
  className={cn(
207
204
  "flex-1 border-l-0 py-0 [&_svg]:size-3",
208
- incrementProps?.className,
205
+ classNames?.increment,
209
206
  )}
210
207
  >
211
- {incrementProps?.children ?? <ChevronUpIcon />}
208
+ <ChevronUpIcon />
212
209
  </NumberInputIncrement>
213
210
  <NumberInputDecrement
214
211
  data-testid="decrement-trigger"
215
- {...decrementProps}
216
212
  className={cn(
217
213
  "flex-1 border-r-0 py-0 [&_svg]:size-3",
218
- decrementProps?.className,
214
+ classNames?.decrement,
219
215
  )}
220
216
  >
221
- {decrementProps?.children ?? <ChevronDownIcon />}
217
+ <ChevronDownIcon />
222
218
  </NumberInputDecrement>
223
219
  </div>
224
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
+ };