From 7b4409512fd9cbec5a2b43704d746dc18e991228 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 13 May 2026 09:50:05 +0530 Subject: [PATCH 1/2] feat: otp field component --- apps/www/src/components/playground/index.ts | 1 + .../playground/otp-field-examples.tsx | 126 +++++++++ .../content/docs/components/otp-field/demo.ts | 175 ++++++++++++ .../docs/components/otp-field/index.mdx | 123 +++++++++ .../docs/components/otp-field/props.ts | 109 ++++++++ .../otp-field/__tests__/otp-field.test.tsx | 259 ++++++++++++++++++ .../raystack/components/otp-field/index.tsx | 1 + .../components/otp-field/otp-field.module.css | 84 ++++++ .../components/otp-field/otp-field.tsx | 72 +++++ packages/raystack/index.tsx | 1 + packages/raystack/package.json | 2 +- pnpm-lock.yaml | 85 +++--- 12 files changed, 998 insertions(+), 40 deletions(-) create mode 100644 apps/www/src/components/playground/otp-field-examples.tsx create mode 100644 apps/www/src/content/docs/components/otp-field/demo.ts create mode 100644 apps/www/src/content/docs/components/otp-field/index.mdx create mode 100644 apps/www/src/content/docs/components/otp-field/props.ts create mode 100644 packages/raystack/components/otp-field/__tests__/otp-field.test.tsx create mode 100644 packages/raystack/components/otp-field/index.tsx create mode 100644 packages/raystack/components/otp-field/otp-field.module.css create mode 100644 packages/raystack/components/otp-field/otp-field.tsx diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index 3f1241e24..5b8d48e08 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -36,6 +36,7 @@ export * from './list-examples'; export * from './menu-examples'; export * from './menubar-examples'; export * from './number-field-examples'; +export * from './otp-field-examples'; export * from './popover-examples'; export * from './preview-card-examples'; export * from './radio-examples'; diff --git a/apps/www/src/components/playground/otp-field-examples.tsx b/apps/www/src/components/playground/otp-field-examples.tsx new file mode 100644 index 000000000..b629d6d9f --- /dev/null +++ b/apps/www/src/components/playground/otp-field-examples.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { Field, Flex, OTPField, Text } from '@raystack/apsara'; +import { useState } from 'react'; +import PlaygroundLayout from './playground-layout'; + +const renderSlots = (length: number, offset = 0) => + Array.from({ length }, (_, i) => ( + + )); + +function ControlledOTP() { + const [value, setValue] = useState(''); + return ( + + + {renderSlots(6)} + + + Current value: {value || '(empty)'} + + + ); +} + +function CompleteOTP() { + const [submitted, setSubmitted] = useState(''); + return ( + + + {renderSlots(6)} + + + {submitted ? `Submitted: ${submitted}` : 'Type all 6 digits to submit'} + + + ); +} + +export function OTPFieldExamples() { + return ( + + + Default (6 digits): + {renderSlots(6)} + + + + 4 digits: + {renderSlots(4)} + + + + With separator: + + {renderSlots(3)} + + {Array.from({ length: 3 }, (_, i) => ( + + ))} + + + + + Masked: + + {renderSlots(6)} + + + + + Alphanumeric: + + {renderSlots(6)} + + + + + Default value: + + {renderSlots(6)} + + + + + Controlled (value / onValueChange): + + + + + onValueComplete: + + + + + Disabled: + + {renderSlots(6)} + + + + + Read-only: + + {renderSlots(6)} + + + + + With Field: + + {renderSlots(6)} + + + + ); +} diff --git a/apps/www/src/content/docs/components/otp-field/demo.ts b/apps/www/src/content/docs/components/otp-field/demo.ts new file mode 100644 index 000000000..806c881a8 --- /dev/null +++ b/apps/www/src/content/docs/components/otp-field/demo.ts @@ -0,0 +1,175 @@ +'use client'; + +import type { ComponentPropsType } from '@/components/demo/types'; +import { getPropsString } from '@/lib/utils'; + +const renderInputs = ( + length: number +) => `Array.from({ length: ${length} }, (_, i) => ( + + ))`; + +export const preview = { + type: 'code', + code: ` + {${renderInputs(6)}} +` +}; + +const getCode = (props: ComponentPropsType) => { + const { length = 6, ...rest } = props; + const slotCount = Number(length) || 6; + return ` + {${renderInputs(slotCount)}} +`; +}; + +export const playground = { + type: 'playground', + controls: { + length: { + type: 'select', + options: ['4', '6', '8'], + defaultValue: '6' + }, + validationType: { + type: 'select', + options: ['numeric', 'alpha', 'alphanumeric', 'none'], + defaultValue: 'numeric' + }, + mask: { + type: 'checkbox', + defaultValue: false + }, + disabled: { + type: 'checkbox', + defaultValue: false + }, + readOnly: { + type: 'checkbox', + defaultValue: false + }, + autoSubmit: { + type: 'checkbox', + defaultValue: false + } + }, + getCode +}; + +export const separatorDemo = { + type: 'code', + code: ` + {Array.from({ length: 3 }, (_, i) => ( + + ))} + + {Array.from({ length: 3 }, (_, i) => ( + + ))} +` +}; + +export const maskedDemo = { + type: 'code', + code: ` + {Array.from({ length: 6 }, (_, i) => ( + + ))} +` +}; + +export const alphanumericDemo = { + type: 'code', + code: ` + {Array.from({ length: 6 }, (_, i) => ( + + ))} +` +}; + +export const disabledDemo = { + type: 'code', + code: ` + {Array.from({ length: 6 }, (_, i) => ( + + ))} +` +}; + +export const readOnlyDemo = { + type: 'code', + code: ` + {Array.from({ length: 6 }, (_, i) => ( + + ))} +` +}; + +export const controlledDemo = { + type: 'code', + code: `function ControlledOTP() { + const [value, setValue] = React.useState(''); + + return ( + + + {Array.from({ length: 6 }, (_, i) => ( + + ))} + + Current value: {value || '(empty)'} + + ); +}` +}; + +export const onCompleteDemo = { + type: 'code', + code: `function CompleteOTP() { + const [submitted, setSubmitted] = React.useState(''); + + return ( + + setSubmitted(value)} + > + {Array.from({ length: 6 }, (_, i) => ( + + ))} + + + {submitted ? \`Submitted: \${submitted}\` : 'Type all 6 digits to submit'} + + + ); +}` +}; + +export const customSanitizeDemo = { + type: 'code', + code: ` val.replace(/[^0-3]/g, '')} +> + {Array.from({ length: 4 }, (_, i) => ( + + ))} +` +}; + +export const withFieldDemo = { + type: 'code', + code: ` + + + {Array.from({ length: 6 }, (_, i) => ( + + ))} + + +` +}; diff --git a/apps/www/src/content/docs/components/otp-field/index.mdx b/apps/www/src/content/docs/components/otp-field/index.mdx new file mode 100644 index 000000000..623fdd9a8 --- /dev/null +++ b/apps/www/src/content/docs/components/otp-field/index.mdx @@ -0,0 +1,123 @@ +--- +title: OTP Field +description: A one-time password input split into individual character slots with automatic focus management. +source: packages/raystack/components/otp-field +tag: new +--- + +import { + playground, + separatorDemo, + maskedDemo, + alphanumericDemo, + disabledDemo, + readOnlyDemo, + controlledDemo, + onCompleteDemo, + customSanitizeDemo, + withFieldDemo, +} from "./demo.ts"; + + + +## Anatomy + +Import and assemble the component. `length` is required so the field can size, validate, and detect completion before all slots hydrate. + +```tsx +import { OTPField } from "@raystack/apsara"; + + + + + + + + + + +``` + +## API Reference + +### Root + +Groups all parts of the field and manages their state. + + + +### Input + +An individual character slot. Render one per slot (typically using `Array.from`). + + + +### Separator + +A visual separator between slot groups, styled to fit between OTP slots. + + + +## Examples + +### With separator + +Group slots visually with `OTPField.Separator` to make long codes easier to read. + + + +### Masked + +Use `mask` to obscure entered characters — useful for sensitive codes. + + + +### Alphanumeric + +Use `validationType` to accept letters, digits, or both. Defaults to `"numeric"`. + + + +### Disabled + +Set `disabled` to prevent interaction. + + + +### Read-only + +Set `readOnly` to display a value without allowing edits. + + + +### Controlled + +Pass `value` and `onValueChange` to control the field from React state. + + + +### Complete callback + +`onValueComplete` fires once all slots are filled. Combine with `autoSubmit` to submit the surrounding form automatically. + + + +### Custom sanitization + +Set `validationType="none"` and provide `sanitizeValue` to restrict input to a custom set of characters. + + + +### With Field + +Compose with `Field` to get an associated label and description. + + + +## Accessibility + +- Each slot must have an accessible name. Use a wrapping `