@keboola/design 2.0.0 → 2.0.1

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 (50) hide show
  1. package/dist/icons.cjs +8 -0
  2. package/dist/icons.js +1 -1
  3. package/dist/index.cjs +240 -38
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +230 -41
  6. package/dist/index.js.map +1 -1
  7. package/dist/markdown-editor.cjs +39 -16
  8. package/dist/markdown-editor.cjs.map +1 -1
  9. package/dist/markdown-editor.js +39 -16
  10. package/dist/markdown-editor.js.map +1 -1
  11. package/dist/mermaid.cjs +32 -9
  12. package/dist/mermaid.cjs.map +1 -1
  13. package/dist/mermaid.js +32 -9
  14. package/dist/mermaid.js.map +1 -1
  15. package/dist/styles.css +1 -1
  16. package/package.json +5 -4
  17. package/src/blocks/EmptyState/EmptyState.tsx +2 -2
  18. package/src/blocks/ErrorState/ErrorState.tsx +18 -3
  19. package/src/blocks/InputOtp/InputOtp.stories.tsx +214 -0
  20. package/src/blocks/InputOtp/InputOtp.test.tsx +162 -0
  21. package/src/blocks/InputOtp/InputOtp.tsx +62 -0
  22. package/src/blocks/InputOtp/InputOtpGroup.tsx +13 -0
  23. package/src/blocks/InputOtp/InputOtpSeparator.tsx +23 -0
  24. package/src/blocks/InputOtp/InputOtpSlot.styles.ts +52 -0
  25. package/src/blocks/InputOtp/InputOtpSlot.tsx +44 -0
  26. package/src/blocks/InputOtp/context.ts +23 -0
  27. package/src/blocks/InputOtp/index.ts +23 -0
  28. package/src/components/Alert/AlertIcon.tsx +1 -1
  29. package/src/components/Badge/BadgeAddon.tsx +1 -1
  30. package/src/components/Badge/BadgeButton.tsx +1 -1
  31. package/src/components/Breadcrumb/BreadcrumbLink.tsx +1 -1
  32. package/src/components/Breadcrumb/BreadcrumbSeparator.tsx +1 -1
  33. package/src/components/Button/styles.ts +33 -10
  34. package/src/components/Combobox/ComboboxItemIndicator.tsx +1 -1
  35. package/src/components/Command/CommandItem/CommandItem.tsx +1 -1
  36. package/src/components/DropdownMenu/DropdownMenuItemIndicator.tsx +4 -4
  37. package/src/components/DropdownMenu/ItemIcon.tsx +1 -1
  38. package/src/components/Modal/ModalIcon.tsx +1 -1
  39. package/src/components/Navbar/NavbarMenuItemAdornment.tsx +2 -2
  40. package/src/components/Pagination/PaginationEllipsis.tsx +1 -1
  41. package/src/components/Select/SelectContent.tsx +1 -1
  42. package/src/components/Sidebar/SidebarGroup/SidebarGroupAction.tsx +1 -1
  43. package/src/components/Sidebar/SidebarMenu/SidebarMenuAction.tsx +1 -1
  44. package/src/components/Sidebar/SidebarMenu/SidebarMenuIcon.tsx +1 -1
  45. package/src/components/Timeline/TimelineConnector.tsx +5 -1
  46. package/src/components/Toast/ToastDismiss.tsx +1 -1
  47. package/src/components/Toast/ToastRoot.tsx +1 -1
  48. package/src/components/Toggle/Toggle.tsx +1 -1
  49. package/src/icons/index.ts +2 -0
  50. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keboola/design",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Keboola UI platform design system — shadcn-aligned React primitives, Keboola-branded blocks, and the form bindings built on TanStack Form.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -79,13 +79,13 @@
79
79
  "eslint-plugin-storybook": "^10.3.5",
80
80
  "oxfmt": "^0.44.0",
81
81
  "oxlint": "^1.56.0",
82
- "playwright": "1.59.1",
82
+ "playwright": "1.60.0",
83
83
  "storybook": "^10.2.19",
84
84
  "tailwindcss": "^3.4.17",
85
85
  "tsup": "^8.5.1",
86
86
  "typescript": "^6.0.3",
87
- "vite": "^8.0.8",
88
- "vitest": "^4.1.2"
87
+ "vite": "^8.0.16",
88
+ "vitest": "^4.1.8"
89
89
  },
90
90
  "dependencies": {
91
91
  "@awesome.me/kit-be8bcf7e5e": "1.0.223",
@@ -144,6 +144,7 @@
144
144
  "clsx": "^2.1.1",
145
145
  "cmdk": "^1.1.1",
146
146
  "diff": "^9.0.0",
147
+ "input-otp": "^1.4.2",
147
148
  "lexical": "^0.42.0",
148
149
  "lucide-react": "^1.17.0",
149
150
  "mermaid": "^11.15.0",
@@ -19,8 +19,8 @@ const emptyStateIconClassnames = cva(
19
19
  {
20
20
  variants: {
21
21
  variant: {
22
- default: ['tw-size-16', 'tw-text-4xl', '[&_svg]:tw-size-10'],
23
- compact: ['tw-size-10', 'tw-text-2xl', '[&_svg]:tw-size-6'],
22
+ default: ['tw-size-16', 'tw-text-4xl', '[&_svg:not([class*="tw-size-"])]:tw-size-10'],
23
+ compact: ['tw-size-10', 'tw-text-2xl', '[&_svg:not([class*="tw-size-"])]:tw-size-6'],
24
24
  },
25
25
  },
26
26
  defaultVariants: {
@@ -18,9 +18,24 @@ const errorStateClassnames = cva(['tw-flex', 'tw-flex-col', 'tw-items-center', '
18
18
  const errorStateIconClassnames = cva(['tw-flex', 'tw-items-center', 'tw-justify-center'], {
19
19
  variants: {
20
20
  variant: {
21
- default: ['tw-size-16', 'tw-text-4xl', '[&_svg]:tw-size-10', 'tw-text-warning-500'],
22
- compact: ['tw-size-10', 'tw-text-2xl', '[&_svg]:tw-size-6', 'tw-text-warning-500'],
23
- destructive: ['tw-size-16', 'tw-text-4xl', '[&_svg]:tw-size-10', 'tw-text-error-500'],
21
+ default: [
22
+ 'tw-size-16',
23
+ 'tw-text-4xl',
24
+ '[&_svg:not([class*="tw-size-"])]:tw-size-10',
25
+ 'tw-text-warning-500',
26
+ ],
27
+ compact: [
28
+ 'tw-size-10',
29
+ 'tw-text-2xl',
30
+ '[&_svg:not([class*="tw-size-"])]:tw-size-6',
31
+ 'tw-text-warning-500',
32
+ ],
33
+ destructive: [
34
+ 'tw-size-16',
35
+ 'tw-text-4xl',
36
+ '[&_svg:not([class*="tw-size-"])]:tw-size-10',
37
+ 'tw-text-error-500',
38
+ ],
24
39
  },
25
40
  },
26
41
  defaultVariants: {
@@ -0,0 +1,214 @@
1
+ import { useState } from 'react';
2
+ import type { ComponentProps, FormEventHandler } from 'react';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+ import { action } from 'storybook/actions';
5
+
6
+ import { Button } from '../../components/Button';
7
+ import { FormGroup } from '../../components/FormGroup';
8
+ import { Label } from '../../components/Label';
9
+
10
+ import { REGEXP_ONLY_DIGITS_AND_CHARS } from './index';
11
+ import { InputOtp } from './InputOtp';
12
+
13
+ type Story = StoryObj<typeof InputOtp>;
14
+ type InputOtpProps = ComponentProps<typeof InputOtp>;
15
+ type Sizing = NonNullable<InputOtpProps['sizing']>;
16
+ type State = NonNullable<InputOtpProps['state']>;
17
+
18
+ const sizings: Sizing[] = ['sm', 'md', 'lg'];
19
+ const states: State[] = ['default', 'error'];
20
+
21
+ const ContiguousSlots = ({ length }: { length: number }) => (
22
+ <InputOtp.Group>
23
+ {Array.from({ length }, (_, index) => (
24
+ <InputOtp.Slot key={index} index={index} />
25
+ ))}
26
+ </InputOtp.Group>
27
+ );
28
+
29
+ const SplitSlots = ({ length }: { length: number }) => {
30
+ const half = Math.ceil(length / 2);
31
+ return (
32
+ <>
33
+ <InputOtp.Group>
34
+ {Array.from({ length: half }, (_, index) => (
35
+ <InputOtp.Slot key={index} index={index} />
36
+ ))}
37
+ </InputOtp.Group>
38
+ {length > half && (
39
+ <>
40
+ <InputOtp.Separator />
41
+ <InputOtp.Group>
42
+ {Array.from({ length: length - half }, (_, i) => (
43
+ <InputOtp.Slot key={half + i} index={half + i} />
44
+ ))}
45
+ </InputOtp.Group>
46
+ </>
47
+ )}
48
+ </>
49
+ );
50
+ };
51
+
52
+ export default {
53
+ component: InputOtp,
54
+ parameters: {
55
+ docs: {
56
+ description: {
57
+ component: [
58
+ 'OTP / verification-code input. Wraps the headless `input-otp` primitive with a',
59
+ 'Keboola-themed slot, group, and separator using semantic Tailwind tokens. Paste,',
60
+ 'SMS autofill, and per-character focus management are inherited from `input-otp`.',
61
+ '',
62
+ '**When to use:** entering a short fixed-length code — MFA / 2FA, email confirmation,',
63
+ 'sudo-mode step-up, invite acceptance.',
64
+ '',
65
+ '**When NOT to use:** free-form text → `TextInput` / `FormGroup.TextInput`;',
66
+ 'variable-length secrets → `PasswordInput`; numeric values → `NumberInput`.',
67
+ '',
68
+ '**Composition:** wrap slots in `InputOtp.Group`s, optionally separated by',
69
+ '`InputOtp.Separator`. For full control over slot rendering, call',
70
+ '`useInputOtpContext()` inside a custom child.',
71
+ '',
72
+ '**Sizing:** `sm` / `md` / `lg` match InputGroup heights (≈ 32 / 36 / 40px) so an OTP',
73
+ 'field can sit next to a regular text input without the row jumping.',
74
+ ].join('\n'),
75
+ },
76
+ },
77
+ },
78
+ args: {
79
+ maxLength: 6,
80
+ sizing: 'md',
81
+ state: 'default',
82
+ disabled: false,
83
+ autoFocus: false,
84
+ onComplete: action('onComplete'),
85
+ onChange: action('onChange'),
86
+ },
87
+ argTypes: {
88
+ sizing: {
89
+ description: 'Slot size',
90
+ type: { name: 'enum', value: sizings },
91
+ },
92
+ state: {
93
+ description: 'Validation state',
94
+ type: { name: 'enum', value: states },
95
+ },
96
+ },
97
+ render: (args) => (
98
+ <InputOtp {...args}>
99
+ <ContiguousSlots length={args.maxLength} />
100
+ </InputOtp>
101
+ ),
102
+ } satisfies Meta<typeof InputOtp>;
103
+
104
+ export const Default: Story = {};
105
+
106
+ export const WithSeparator: Story = {
107
+ render: (args) => (
108
+ <InputOtp {...args}>
109
+ <SplitSlots length={args.maxLength} />
110
+ </InputOtp>
111
+ ),
112
+ };
113
+
114
+ export const Sizing: Story = {
115
+ render: (args) => (
116
+ <div className="tw-flex tw-flex-col tw-gap-6">
117
+ {sizings.map((sizing) => (
118
+ <div key={sizing} className="tw-flex tw-flex-col tw-gap-1">
119
+ <span className="tw-text-xs tw-text-muted-foreground">{sizing}</span>
120
+ <InputOtp {...args} sizing={sizing}>
121
+ <ContiguousSlots length={args.maxLength} />
122
+ </InputOtp>
123
+ </div>
124
+ ))}
125
+ </div>
126
+ ),
127
+ };
128
+
129
+ export const States: Story = {
130
+ render: (args) => (
131
+ <div className="tw-flex tw-flex-col tw-gap-6">
132
+ <div className="tw-flex tw-flex-col tw-gap-1">
133
+ <span className="tw-text-xs tw-text-muted-foreground">default</span>
134
+ <InputOtp {...args} state="default">
135
+ <ContiguousSlots length={args.maxLength} />
136
+ </InputOtp>
137
+ </div>
138
+ <div className="tw-flex tw-flex-col tw-gap-1">
139
+ <span className="tw-text-xs tw-text-muted-foreground">error</span>
140
+ <InputOtp {...args} state="error" defaultValue="123">
141
+ <ContiguousSlots length={args.maxLength} />
142
+ </InputOtp>
143
+ </div>
144
+ <div className="tw-flex tw-flex-col tw-gap-1">
145
+ <span className="tw-text-xs tw-text-muted-foreground">disabled</span>
146
+ <InputOtp {...args} disabled defaultValue="42">
147
+ <ContiguousSlots length={args.maxLength} />
148
+ </InputOtp>
149
+ </div>
150
+ </div>
151
+ ),
152
+ };
153
+
154
+ export const Alphanumeric: Story = {
155
+ args: {
156
+ pattern: REGEXP_ONLY_DIGITS_AND_CHARS,
157
+ },
158
+ };
159
+
160
+ const ControlledDemo = (props: InputOtpProps) => {
161
+ const [value, setValue] = useState('');
162
+ return (
163
+ <div className="tw-flex tw-flex-col tw-gap-3">
164
+ <InputOtp
165
+ {...props}
166
+ value={value}
167
+ onChange={(next) => {
168
+ action('onChange')(next);
169
+ setValue(next);
170
+ }}
171
+ >
172
+ <SplitSlots length={props.maxLength} />
173
+ </InputOtp>
174
+ <span className="tw-text-sm tw-text-muted-foreground">Entered: {value || '(empty)'}</span>
175
+ </div>
176
+ );
177
+ };
178
+
179
+ export const Controlled: Story = {
180
+ render: ControlledDemo,
181
+ };
182
+
183
+ const UncontrolledDemo = (props: InputOtpProps) => {
184
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
185
+ event.preventDefault();
186
+ const values = new FormData(event.currentTarget);
187
+ action('onSubmit')(Object.fromEntries(values.entries()));
188
+ };
189
+
190
+ return (
191
+ <form className="tw-flex tw-items-end tw-gap-3" onSubmit={handleSubmit}>
192
+ <InputOtp {...props} name="code" defaultValue="" onComplete={action('onComplete')}>
193
+ <SplitSlots length={props.maxLength} />
194
+ </InputOtp>
195
+ <Button type="submit">Verify</Button>
196
+ </form>
197
+ );
198
+ };
199
+
200
+ export const Uncontrolled: Story = {
201
+ render: UncontrolledDemo,
202
+ };
203
+
204
+ export const WithLabel: Story = {
205
+ render: (args) => (
206
+ <FormGroup className="tw-max-w-sm">
207
+ <Label>Verification code</Label>
208
+ <InputOtp {...args}>
209
+ <SplitSlots length={args.maxLength} />
210
+ </InputOtp>
211
+ <FormGroup.Help>We just sent a 6-digit code to your authenticator app.</FormGroup.Help>
212
+ </FormGroup>
213
+ ),
214
+ };
@@ -0,0 +1,162 @@
1
+ import { useState } from 'react';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { REGEXP_ONLY_DIGITS, useInputOtpContext } from './index';
6
+ import { InputOtp } from './InputOtp';
7
+
8
+ // `input-otp` uses ResizeObserver to align its hidden input over the slots;
9
+ // jsdom doesn't ship one, so stub a no-op implementation for the suite.
10
+ beforeAll(() => {
11
+ vi.stubGlobal(
12
+ 'ResizeObserver',
13
+ class {
14
+ observe() {}
15
+ unobserve() {}
16
+ disconnect() {}
17
+ },
18
+ );
19
+ });
20
+
21
+ const SixDigit = (props: Partial<React.ComponentProps<typeof InputOtp>> = {}) => (
22
+ <InputOtp aria-label="code" maxLength={6} {...props}>
23
+ <InputOtp.Group>
24
+ <InputOtp.Slot index={0} />
25
+ <InputOtp.Slot index={1} />
26
+ <InputOtp.Slot index={2} />
27
+ <InputOtp.Slot index={3} />
28
+ <InputOtp.Slot index={4} />
29
+ <InputOtp.Slot index={5} />
30
+ </InputOtp.Group>
31
+ </InputOtp>
32
+ );
33
+
34
+ const getInput = () => screen.getByLabelText('code') as HTMLInputElement;
35
+
36
+ describe('<InputOtp />', () => {
37
+ it('renders one slot per maxLength position', () => {
38
+ render(<SixDigit />);
39
+ const slots = document.querySelectorAll('[data-slot-index]');
40
+ expect(slots).toHaveLength(6);
41
+ });
42
+
43
+ it('displays typed characters in their slots', () => {
44
+ const Controlled = () => {
45
+ const [value, setValue] = useState('');
46
+ return <SixDigit value={value} onChange={setValue} />;
47
+ };
48
+ render(<Controlled />);
49
+ fireEvent.change(getInput(), { target: { value: '123' } });
50
+ const slots = document.querySelectorAll('[data-slot-index]');
51
+ expect(slots[0]?.textContent).toBe('1');
52
+ expect(slots[1]?.textContent).toBe('2');
53
+ expect(slots[2]?.textContent).toBe('3');
54
+ expect(slots[3]?.textContent).toBe('');
55
+ });
56
+
57
+ it('fires onComplete exactly once when maxLength is reached', () => {
58
+ const onComplete = vi.fn();
59
+ render(<SixDigit onComplete={onComplete} />);
60
+ fireEvent.change(getInput(), { target: { value: '123456' } });
61
+ expect(onComplete).toHaveBeenCalledTimes(1);
62
+ expect(onComplete).toHaveBeenCalledWith('123456');
63
+ });
64
+
65
+ it('re-fires onComplete after editing and retyping a completed code', () => {
66
+ const onComplete = vi.fn();
67
+ const Controlled = () => {
68
+ const [value, setValue] = useState('');
69
+ return <SixDigit value={value} onChange={setValue} onComplete={onComplete} />;
70
+ };
71
+ render(<Controlled />);
72
+ fireEvent.change(getInput(), { target: { value: '123456' } });
73
+ fireEvent.change(getInput(), { target: { value: '12345' } });
74
+ fireEvent.change(getInput(), { target: { value: '123459' } });
75
+ expect(onComplete).toHaveBeenCalledTimes(2);
76
+ expect(onComplete).toHaveBeenLastCalledWith('123459');
77
+ });
78
+
79
+ it('truncates input beyond maxLength', () => {
80
+ const onChange = vi.fn();
81
+ render(<SixDigit onChange={onChange} />);
82
+ fireEvent.change(getInput(), { target: { value: '1234567890' } });
83
+ expect(onChange).toHaveBeenLastCalledWith('123456');
84
+ });
85
+
86
+ it('rejects characters that fail the pattern', () => {
87
+ const onChange = vi.fn();
88
+ render(<SixDigit pattern={REGEXP_ONLY_DIGITS} onChange={onChange} />);
89
+ fireEvent.change(getInput(), { target: { value: 'abc' } });
90
+ expect(onChange).not.toHaveBeenCalled();
91
+ expect(getInput().value).toBe('');
92
+ });
93
+
94
+ it('marks the input as aria-invalid when state="error"', () => {
95
+ render(<SixDigit state="error" />);
96
+ expect(getInput().getAttribute('aria-invalid')).toBe('true');
97
+ });
98
+
99
+ it('does not set aria-invalid in the default state', () => {
100
+ render(<SixDigit />);
101
+ expect(getInput().getAttribute('aria-invalid')).toBeNull();
102
+ });
103
+
104
+ it('blocks input when disabled', () => {
105
+ render(<SixDigit disabled />);
106
+ expect(getInput().disabled).toBe(true);
107
+ });
108
+
109
+ it('seeds the value from defaultValue', () => {
110
+ render(<SixDigit defaultValue="42" />);
111
+ const slots = document.querySelectorAll('[data-slot-index]');
112
+ expect(slots[0]?.textContent).toBe('4');
113
+ expect(slots[1]?.textContent).toBe('2');
114
+ });
115
+
116
+ it('exposes the render context to custom children via useInputOtpContext', () => {
117
+ const FirstSlotChar = () => {
118
+ const { slots } = useInputOtpContext();
119
+ return <span data-testid="first-slot-char">{slots[0]?.char ?? ''}</span>;
120
+ };
121
+ const Controlled = () => {
122
+ const [value, setValue] = useState('');
123
+ return (
124
+ <InputOtp aria-label="code" maxLength={6} value={value} onChange={setValue}>
125
+ <FirstSlotChar />
126
+ </InputOtp>
127
+ );
128
+ };
129
+ render(<Controlled />);
130
+ fireEvent.change(getInput(), { target: { value: '7' } });
131
+ expect(screen.getByTestId('first-slot-char').textContent).toBe('7');
132
+ });
133
+
134
+ it('throws if useInputOtpContext is called outside <InputOtp>', () => {
135
+ const Bare = () => {
136
+ useInputOtpContext();
137
+ return null;
138
+ };
139
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
140
+ expect(() => render(<Bare />)).toThrow(/inside <InputOtp>/);
141
+ consoleError.mockRestore();
142
+ });
143
+
144
+ it('renders a custom separator when provided', () => {
145
+ render(
146
+ <InputOtp aria-label="code" maxLength={6}>
147
+ <InputOtp.Group>
148
+ <InputOtp.Slot index={0} />
149
+ <InputOtp.Slot index={1} />
150
+ <InputOtp.Slot index={2} />
151
+ </InputOtp.Group>
152
+ <InputOtp.Separator>·</InputOtp.Separator>
153
+ <InputOtp.Group>
154
+ <InputOtp.Slot index={3} />
155
+ <InputOtp.Slot index={4} />
156
+ <InputOtp.Slot index={5} />
157
+ </InputOtp.Group>
158
+ </InputOtp>,
159
+ );
160
+ expect(screen.getByRole('separator')).toHaveTextContent('·');
161
+ });
162
+ });
@@ -0,0 +1,62 @@
1
+ import type { ComponentProps, ReactNode, Ref } from 'react';
2
+ import { OTPInput, REGEXP_ONLY_DIGITS } from 'input-otp';
3
+
4
+ import { cn } from '../../utils';
5
+
6
+ import { type InputOtpSizing, type InputOtpState, InputOtpVariantContext } from './context';
7
+ import { InputOtpGroup } from './InputOtpGroup';
8
+ import { InputOtpSeparator } from './InputOtpSeparator';
9
+ import { InputOtpSlot } from './InputOtpSlot';
10
+
11
+ // The `OTPInput` props union pairs `render` with `children?: never` (and vice versa).
12
+ // `Extract` picks the children-arm so passing `render` is a compile error.
13
+ type OtpInputProps = ComponentProps<typeof OTPInput>;
14
+ type OtpInputChildrenProps = Omit<
15
+ Extract<OtpInputProps, { children: ReactNode }>,
16
+ 'containerClassName'
17
+ >;
18
+
19
+ type RootOwnProps = {
20
+ /** Visual size of each slot. Defaults to `md`. Matches InputGroup heights (sm/md/lg ≈ 32 / 36 / 40px). */
21
+ sizing?: InputOtpSizing;
22
+ /** Drives the error ring/border on each slot and `aria-invalid` on the inner input. */
23
+ state?: InputOtpState;
24
+ /** Class for the outer flex container that holds groups + separators. */
25
+ containerClassName?: string;
26
+ };
27
+
28
+ type RootProps = OtpInputChildrenProps & RootOwnProps;
29
+
30
+ const Root = ({
31
+ ref,
32
+ sizing = 'md',
33
+ state = 'default',
34
+ pattern = REGEXP_ONLY_DIGITS,
35
+ containerClassName,
36
+ className,
37
+ children,
38
+ ...rest
39
+ }: RootProps & { ref?: Ref<HTMLInputElement> }) => (
40
+ <InputOtpVariantContext value={{ sizing, state }}>
41
+ <OTPInput
42
+ {...rest}
43
+ ref={ref}
44
+ pattern={pattern}
45
+ aria-invalid={state === 'error' || undefined}
46
+ containerClassName={cn(
47
+ 'tw-flex tw-items-center tw-gap-2 has-[:disabled]:tw-opacity-50',
48
+ containerClassName,
49
+ )}
50
+ className={cn('disabled:tw-cursor-not-allowed', className)}
51
+ >
52
+ {children}
53
+ </OTPInput>
54
+ </InputOtpVariantContext>
55
+ );
56
+
57
+ export const InputOtp = Object.assign(Root, {
58
+ Group: InputOtpGroup,
59
+ Slot: InputOtpSlot,
60
+ Separator: InputOtpSeparator,
61
+ });
62
+ export type { RootProps as InputOtpProps };
@@ -0,0 +1,13 @@
1
+ import type { ComponentProps, Ref } from 'react';
2
+
3
+ import { cn } from '../../utils';
4
+
5
+ type GroupProps = ComponentProps<'div'>;
6
+
7
+ export const InputOtpGroup = ({
8
+ ref,
9
+ className,
10
+ ...props
11
+ }: GroupProps & { ref?: Ref<HTMLDivElement> }) => (
12
+ <div ref={ref} className={cn('tw-flex tw-items-center tw-gap-2', className)} {...props} />
13
+ );
@@ -0,0 +1,23 @@
1
+ import type { ComponentProps, Ref } from 'react';
2
+
3
+ import { Minus } from '../../icons';
4
+ import { cn } from '../../utils';
5
+
6
+ type SeparatorProps = ComponentProps<'div'>;
7
+
8
+ export const InputOtpSeparator = ({
9
+ ref,
10
+ children,
11
+ className,
12
+ ...props
13
+ }: SeparatorProps & { ref?: Ref<HTMLDivElement> }) => (
14
+ <div
15
+ ref={ref}
16
+ role="separator"
17
+ aria-orientation="vertical"
18
+ className={cn('tw-flex tw-items-center tw-text-muted-foreground', className)}
19
+ {...props}
20
+ >
21
+ {children ?? <Minus className="tw-size-4" />}
22
+ </div>
23
+ );
@@ -0,0 +1,52 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+
3
+ // Slot sizes mirror InputGroup (`small` / `medium` / `default` ≈ 32 / 36 / 40px) so an OTP
4
+ // field can sit next to a regular text input without the row jumping.
5
+ export const slotVariants = cva(
6
+ [
7
+ 'tw-relative',
8
+ 'tw-flex',
9
+ 'tw-items-center',
10
+ 'tw-justify-center',
11
+ 'tw-border',
12
+ 'tw-border-input',
13
+ 'tw-bg-background',
14
+ 'tw-text-foreground',
15
+ 'tw-font-medium',
16
+ 'tw-tabular-nums',
17
+ 'tw-rounded-lg',
18
+ 'tw-transition',
19
+ 'tw-outline-none',
20
+ // active slot sits above its neighbours so its ring isn't clipped
21
+ 'data-[active=true]:tw-z-10',
22
+ 'data-[active=true]:tw-border-primary',
23
+ 'data-[active=true]:tw-ring-2',
24
+ 'data-[active=true]:tw-ring-primary/50',
25
+ ],
26
+ {
27
+ defaultVariants: {
28
+ sizing: 'md',
29
+ state: 'default',
30
+ },
31
+ variants: {
32
+ sizing: {
33
+ sm: ['tw-h-8', 'tw-w-8', 'tw-text-sm'],
34
+ md: ['tw-h-9', 'tw-w-9', 'tw-text-base'],
35
+ lg: ['tw-h-10', 'tw-w-10', 'tw-text-lg'],
36
+ },
37
+ // `!` forces the border colour over the base `tw-border-input` once tailwind-merge dedupes.
38
+ // Matches the pattern InputGroup uses for its argo error state.
39
+ state: {
40
+ default: [],
41
+ error: [
42
+ '!tw-border-destructive',
43
+ 'tw-bg-destructive/5',
44
+ 'data-[active=true]:!tw-border-destructive',
45
+ 'data-[active=true]:tw-ring-destructive/40',
46
+ ],
47
+ },
48
+ },
49
+ },
50
+ );
51
+
52
+ export type SlotVariants = VariantProps<typeof slotVariants>;
@@ -0,0 +1,44 @@
1
+ import type { ComponentProps, Ref } from 'react';
2
+
3
+ import { cn } from '../../utils';
4
+
5
+ import { useInputOtpRenderContext, useInputOtpVariant } from './context';
6
+ import { slotVariants } from './InputOtpSlot.styles';
7
+
8
+ type SlotProps = Omit<ComponentProps<'div'>, 'children'> & {
9
+ index: number;
10
+ };
11
+
12
+ export const InputOtpSlot = ({
13
+ ref,
14
+ index,
15
+ className,
16
+ ...props
17
+ }: SlotProps & { ref?: Ref<HTMLDivElement> }) => {
18
+ const { slots } = useInputOtpRenderContext();
19
+ const { sizing, state } = useInputOtpVariant();
20
+ const slot = slots[index];
21
+ const char = slot?.char ?? null;
22
+ const isActive = slot?.isActive ?? false;
23
+ const hasFakeCaret = slot?.hasFakeCaret ?? false;
24
+
25
+ return (
26
+ <div
27
+ ref={ref}
28
+ data-active={isActive || undefined}
29
+ data-slot-index={index}
30
+ className={cn(slotVariants({ sizing, state }), className)}
31
+ {...props}
32
+ >
33
+ {char}
34
+ {hasFakeCaret && (
35
+ <div
36
+ aria-hidden="true"
37
+ className="tw-pointer-events-none tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
38
+ >
39
+ <div className="tw-h-1/2 tw-w-px tw-animate-caret-blink tw-bg-foreground" />
40
+ </div>
41
+ )}
42
+ </div>
43
+ );
44
+ };
@@ -0,0 +1,23 @@
1
+ import { createContext, use } from 'react';
2
+ import { OTPInputContext } from 'input-otp';
3
+
4
+ import { type SlotVariants } from './InputOtpSlot.styles';
5
+
6
+ type Sizing = NonNullable<SlotVariants['sizing']>;
7
+ type State = NonNullable<SlotVariants['state']>;
8
+
9
+ type InputOtpVariantContextValue = {
10
+ sizing: Sizing;
11
+ state: State;
12
+ };
13
+
14
+ export const InputOtpVariantContext = createContext<InputOtpVariantContextValue>({
15
+ sizing: 'md',
16
+ state: 'default',
17
+ });
18
+
19
+ export const useInputOtpVariant = () => use(InputOtpVariantContext);
20
+
21
+ export const useInputOtpRenderContext = () => use(OTPInputContext);
22
+
23
+ export type { Sizing as InputOtpSizing, State as InputOtpState };