Files
myeasycms-v2/apps/dev-tool/app/components/components/input-otp-story.tsx
Giancarlo Buomprisco ad427365c9 Storybook (#328)
* feat(docs): add interactive examples and API references for Button, Card, and LoadingFallback components
- Updated dependencies
- Set `retries` to a fixed value of 3 for consistent test retries across environments.
- Increased `timeout` from 60 seconds to 120 seconds to allow more time for tests to complete.
- Reduced `expect` timeout from 10 seconds to 5 seconds for quicker feedback on assertions.
2025-08-22 07:35:44 +08:00

814 lines
29 KiB
TypeScript

'use client';
import { useState } from 'react';
import { LockIcon, ShieldIcon, SmartphoneIcon } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@kit/ui/input-otp';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { Switch } from '@kit/ui/switch';
import {
generateImportStatement,
generatePropsString,
} from '../lib/story-utils';
import { ComponentStoryLayout } from './story-layout';
interface InputOTPStoryControls {
maxLength: number;
pattern: 'digits' | 'alphanumeric' | 'letters';
disabled: boolean;
showSeparator: boolean;
groupSize: number;
autoSubmit: boolean;
showValue: boolean;
}
const PATTERN_REGEX = {
digits: /^[0-9]+$/,
alphanumeric: /^[a-zA-Z0-9]+$/,
letters: /^[a-zA-Z]+$/,
};
export default function InputOTPStory() {
const [controls, setControls] = useState<InputOTPStoryControls>({
maxLength: 6,
pattern: 'digits',
disabled: false,
showSeparator: true,
groupSize: 3,
autoSubmit: false,
showValue: true,
});
const [otpValue, setOtpValue] = useState('');
const [submittedValue, setSubmittedValue] = useState<string | null>(null);
const generateCode = () => {
const components = ['InputOTP', 'InputOTPGroup', 'InputOTPSlot'];
if (controls.showSeparator) {
components.push('InputOTPSeparator');
}
const importStatement = generateImportStatement(
components,
'@kit/ui/input-otp',
);
const stateImport = "const [value, setValue] = useState('');";
const patternProp =
controls.pattern !== 'digits'
? `REGEXP_ONLY_${controls.pattern.toUpperCase()}`
: undefined;
const otpProps = generatePropsString(
{
maxLength: controls.maxLength,
value: 'value',
onChange: 'setValue',
disabled: controls.disabled ? true : undefined,
pattern: patternProp,
},
{
maxLength: 6,
disabled: false,
},
);
// Generate slots with groups and separators
const totalSlots = controls.maxLength;
const groupSize = controls.groupSize;
const slots = [];
let currentGroupSlots = [];
for (let i = 0; i < totalSlots; i++) {
currentGroupSlots.push(` <InputOTPSlot index={${i}} />`);
// If we've reached group size or it's the last slot
if (currentGroupSlots.length === groupSize || i === totalSlots - 1) {
slots.push(
` <InputOTPGroup>\n${currentGroupSlots.join('\n')}\n </InputOTPGroup>`,
);
// Add separator if not the last group and separators are enabled
if (i < totalSlots - 1 && controls.showSeparator) {
slots.push(' <InputOTPSeparator />');
}
currentGroupSlots = [];
}
}
const otpStructure = `<InputOTP${otpProps}>\n${slots.join('\n')}\n </InputOTP>`;
let patternConstants = '';
if (controls.pattern !== 'digits') {
patternConstants = `\n// Pattern for ${controls.pattern} input\nconst REGEXP_ONLY_${controls.pattern.toUpperCase()} = /${controls.pattern === 'alphanumeric' ? '^[a-zA-Z0-9]+$' : '^[a-zA-Z]+$'}/;\n`;
}
const fullComponent = `${importStatement}\n\n${stateImport}${patternConstants}\n\nfunction OTPInput() {\n return (\n ${otpStructure}\n );\n}`;
return fullComponent;
};
const handleOTPChange = (value: string) => {
setOtpValue(value);
if (controls.autoSubmit && value.length === controls.maxLength) {
setSubmittedValue(value);
setTimeout(() => setSubmittedValue(null), 3000);
}
};
const handleSubmit = () => {
setSubmittedValue(otpValue);
setTimeout(() => setSubmittedValue(null), 3000);
};
const renderOTPSlots = () => {
const slots = [];
const groupSize = controls.groupSize;
const totalSlots = controls.maxLength;
for (let i = 0; i < totalSlots; i++) {
if (i > 0 && i % groupSize === 0 && controls.showSeparator) {
slots.push(<InputOTPSeparator key={`separator-${i}`} />);
}
slots.push(<InputOTPSlot key={i} index={i} />);
}
return slots;
};
const controlsContent = (
<Card>
<CardHeader>
<CardTitle>OTP Input Controls</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-2 block text-sm font-medium">Max Length</label>
<Select
value={controls.maxLength.toString()}
onValueChange={(value) =>
setControls((prev) => ({ ...prev, maxLength: parseInt(value) }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="4">4 digits</SelectItem>
<SelectItem value="5">5 digits</SelectItem>
<SelectItem value="6">6 digits</SelectItem>
<SelectItem value="8">8 digits</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Pattern</label>
<Select
value={controls.pattern}
onValueChange={(value: InputOTPStoryControls['pattern']) =>
setControls((prev) => ({ ...prev, pattern: value }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="digits">Numbers only</SelectItem>
<SelectItem value="alphanumeric">Alphanumeric</SelectItem>
<SelectItem value="letters">Letters only</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Group Size</label>
<Select
value={controls.groupSize.toString()}
onValueChange={(value) =>
setControls((prev) => ({ ...prev, groupSize: parseInt(value) }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2 digits</SelectItem>
<SelectItem value="3">3 digits</SelectItem>
<SelectItem value="4">4 digits</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-4">
<div className="flex items-center space-x-2">
<Switch
id="disabled"
checked={controls.disabled}
onCheckedChange={(checked) =>
setControls((prev) => ({ ...prev, disabled: checked }))
}
/>
<label htmlFor="disabled" className="text-sm">
Disabled
</label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="showSeparator"
checked={controls.showSeparator}
onCheckedChange={(checked) =>
setControls((prev) => ({ ...prev, showSeparator: checked }))
}
/>
<label htmlFor="showSeparator" className="text-sm">
Show Separator
</label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="autoSubmit"
checked={controls.autoSubmit}
onCheckedChange={(checked) =>
setControls((prev) => ({ ...prev, autoSubmit: checked }))
}
/>
<label htmlFor="autoSubmit" className="text-sm">
Auto Submit
</label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="showValue"
checked={controls.showValue}
onCheckedChange={(checked) =>
setControls((prev) => ({ ...prev, showValue: checked }))
}
/>
<label htmlFor="showValue" className="text-sm">
Show Value
</label>
</div>
</div>
{controls.showValue && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="mb-1 text-sm font-medium">Current Value:</p>
<p className="font-mono text-sm">
{otpValue || 'Empty'} ({otpValue.length}/{controls.maxLength})
</p>
</div>
)}
{submittedValue && (
<Card className="border-green-200 bg-green-50">
<CardContent className="pt-3">
<p className="text-sm font-medium text-green-800">
OTP Submitted!
</p>
<p className="font-mono text-sm text-green-700">
{submittedValue}
</p>
</CardContent>
</Card>
)}
</CardContent>
</Card>
);
const previewContent = (
<Card>
<CardHeader>
<CardTitle>OTP Input Preview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex flex-col items-center space-y-4">
<InputOTP
maxLength={controls.maxLength}
value={otpValue}
onChange={handleOTPChange}
disabled={controls.disabled}
pattern={PATTERN_REGEX[controls.pattern]}
>
<InputOTPGroup>{renderOTPSlots()}</InputOTPGroup>
</InputOTP>
<div className="flex gap-2">
<Button
onClick={() => setOtpValue('')}
variant="outline"
size="sm"
disabled={controls.disabled || !otpValue}
>
Clear
</Button>
<Button
onClick={handleSubmit}
disabled={
controls.disabled || otpValue.length !== controls.maxLength
}
size="sm"
>
Verify OTP
</Button>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<h4 className="mb-2 font-semibold">Configuration:</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>Length: {controls.maxLength}</div>
<div>Pattern: {controls.pattern}</div>
<div>Group Size: {controls.groupSize}</div>
<div>Separator: {controls.showSeparator ? 'Yes' : 'No'}</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
return (
<ComponentStoryLayout
preview={previewContent}
controls={controlsContent}
previewTitle="Interactive OTP Input"
previewDescription="One-time password input with customizable length, patterns, and grouping"
controlsTitle="Configuration"
controlsDescription="Adjust OTP input length, pattern validation, and behavior"
generatedCode={generateCode()}
examples={
<div className="space-y-8">
<div>
<h3 className="mb-4 text-lg font-semibold">Authentication Forms</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Card>
<CardHeader className="text-center">
<LockIcon className="text-primary mx-auto mb-2 h-8 w-8" />
<CardTitle>Two-Factor Authentication</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
<p className="text-muted-foreground text-center text-sm">
Enter the 6-digit code from your authenticator app
</p>
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<Button className="w-full">Verify Code</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="text-center">
<SmartphoneIcon className="text-primary mx-auto mb-2 h-8 w-8" />
<CardTitle>SMS Verification</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
<p className="text-muted-foreground text-center text-sm">
We sent a code to +1 (555) 123-****
</p>
<InputOTP maxLength={4}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
</InputOTP>
<div className="flex w-full gap-2">
<Button variant="outline" className="flex-1">
Resend Code
</Button>
<Button className="flex-1">Verify</Button>
</div>
</CardContent>
</Card>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">Different Patterns</h3>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Numeric Only (Default)</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<InputOTP maxLength={6} pattern={PATTERN_REGEX.digits}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Alphanumeric Code</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<InputOTP maxLength={8} pattern={PATTERN_REGEX.alphanumeric}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
<InputOTPSlot index={7} />
</InputOTPGroup>
</InputOTP>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Letters Only</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<InputOTP maxLength={5} pattern={PATTERN_REGEX.letters}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
</InputOTPGroup>
</InputOTP>
</CardContent>
</Card>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">Security Context</h3>
<Card>
<CardHeader className="text-center">
<ShieldIcon className="text-primary mx-auto mb-2 h-12 w-12" />
<CardTitle>Secure Payment Confirmation</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-lg border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-800">
<strong>Security Alert:</strong> Please confirm this payment
by entering your 6-digit security code.
</p>
</div>
<div className="space-y-2 text-center">
<p className="text-muted-foreground text-sm">
Payment Amount: <strong>$249.99</strong>
</p>
<p className="text-muted-foreground text-sm">
Merchant: TechStore Inc.
</p>
</div>
<div className="flex justify-center">
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
<div className="flex gap-2">
<Button variant="outline" className="flex-1">
Cancel Payment
</Button>
<Button className="flex-1">Confirm Payment</Button>
</div>
</CardContent>
</Card>
</div>
</div>
}
apiReference={
<div className="space-y-8">
<div>
<h3 className="mb-4 text-lg font-semibold">InputOTP Components</h3>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b">
<th className="p-2 text-left font-medium">Component</th>
<th className="p-2 text-left font-medium">Props</th>
<th className="p-2 text-left font-medium">Description</th>
</tr>
</thead>
<tbody className="text-sm">
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">InputOTP</td>
<td className="p-2 font-mono">
maxLength, value, onChange, pattern, disabled
</td>
<td className="p-2">Root OTP input container</td>
</tr>
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">InputOTPGroup</td>
<td className="p-2 font-mono">className</td>
<td className="p-2">Groups OTP slots together</td>
</tr>
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">InputOTPSlot</td>
<td className="p-2 font-mono">index, className</td>
<td className="p-2">Individual character input slot</td>
</tr>
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">InputOTPSeparator</td>
<td className="p-2 font-mono">className</td>
<td className="p-2">Visual separator between groups</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">InputOTP Props</h3>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b">
<th className="p-2 text-left font-medium">Prop</th>
<th className="p-2 text-left font-medium">Type</th>
<th className="p-2 text-left font-medium">Default</th>
<th className="p-2 text-left font-medium">Description</th>
</tr>
</thead>
<tbody className="text-sm">
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">maxLength</td>
<td className="p-2 font-mono">number</td>
<td className="p-2">6</td>
<td className="p-2">Maximum number of characters</td>
</tr>
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">value</td>
<td className="p-2 font-mono">string</td>
<td className="p-2">''</td>
<td className="p-2">Current OTP value</td>
</tr>
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">onChange</td>
<td className="p-2 font-mono">
(value: string) ={'>'} void
</td>
<td className="p-2">-</td>
<td className="p-2">Callback when value changes</td>
</tr>
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">pattern</td>
<td className="p-2 font-mono">RegExp</td>
<td className="p-2">/^[0-9]+$/</td>
<td className="p-2">Pattern for input validation</td>
</tr>
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">disabled</td>
<td className="p-2 font-mono">boolean</td>
<td className="p-2">false</td>
<td className="p-2">Disable the input</td>
</tr>
<tr className="border-border/50 border-b">
<td className="p-2 font-mono">autoFocus</td>
<td className="p-2 font-mono">boolean</td>
<td className="p-2">false</td>
<td className="p-2">Auto focus first slot</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">Pattern Examples</h3>
<div className="space-y-4">
<div className="space-y-2 text-sm">
<h4 className="font-medium">Common Patterns</h4>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">Numbers only</Badge>
<Badge variant="secondary">Alphanumeric</Badge>
<Badge variant="secondary">Letters only</Badge>
<Badge variant="secondary">Custom pattern</Badge>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<pre className="overflow-x-auto text-sm">
{`// Numbers only (default)
pattern={/^[0-9]+$/}
// Alphanumeric
pattern={/^[a-zA-Z0-9]+$/}
// Letters only
pattern={/^[a-zA-Z]+$/}
// Custom: Numbers and dashes
pattern={/^[0-9-]+$/}`}
</pre>
</div>
</div>
</div>
</div>
}
usageGuidelines={
<div className="space-y-8">
<div>
<h3 className="mb-4 text-lg font-semibold">Basic Usage</h3>
<p className="text-muted-foreground mb-4 text-sm">
OTP inputs are commonly used for two-factor authentication, SMS
verification, and secure confirmations.
</p>
<div className="bg-muted/50 rounded-lg p-4">
<pre className="overflow-x-auto text-sm">
{`import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from '@kit/ui/input-otp';
function OTPForm() {
const [value, setValue] = useState('');
return (
<InputOTP
maxLength={6}
value={value}
onChange={setValue}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
);
}`}
</pre>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">Form Integration</h3>
<div className="bg-muted/50 rounded-lg p-4">
<pre className="overflow-x-auto text-sm">
{`import { useForm } from 'react-hook-form';
function TwoFactorForm() {
const form = useForm();
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="otp"
render={({ field }) => (
<FormItem>
<FormLabel>Verification Code</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Enter the 6-digit code from your authenticator app.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Verify</Button>
</form>
);
}`}
</pre>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">
Security Best Practices
</h3>
<div className="space-y-4">
<div className="space-y-2 text-sm">
<h4 className="font-medium">Implementation</h4>
<p> Set appropriate maxLength for your use case</p>
<p> Use pattern validation to restrict input types</p>
<p> Implement auto-submit when complete</p>
<p> Provide clear feedback for invalid codes</p>
</div>
<div className="space-y-2 text-sm">
<h4 className="font-medium">User Experience</h4>
<p> Auto-focus the first input slot</p>
<p> Allow paste operations for convenience</p>
<p> Provide resend functionality with rate limiting</p>
<p> Clear visual feedback for active slots</p>
</div>
<div className="space-y-2 text-sm">
<h4 className="font-medium">Accessibility</h4>
<p> Ensure proper keyboard navigation</p>
<p> Announce state changes to screen readers</p>
<p> Provide clear instructions and labels</p>
<p> Support standard form validation patterns</p>
</div>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">Common Use Cases</h3>
<div className="space-y-4">
<div className="space-y-2 text-sm">
<h4 className="font-medium">Authentication</h4>
<p> Two-factor authentication (6 digits)</p>
<p> SMS verification codes (4-6 digits)</p>
<p> Email verification (6-8 characters)</p>
<p> Backup codes (8-12 characters)</p>
</div>
<div className="space-y-2 text-sm">
<h4 className="font-medium">Transactions</h4>
<p> Payment confirmations (6 digits)</p>
<p> Transfer authorizations (4-8 digits)</p>
<p> Account access PINs (4-6 digits)</p>
<p> Secure operations (variable length)</p>
</div>
</div>
</div>
</div>
}
/>
);
}
export { InputOTPStory };