feat: pin input component (#936)
https://github.com/documenso/documenso/assets/55143799/fa3d14d6-59e6-4984-9287-7375198fcea0
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
|
||||
@@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({
|
||||
<FormLabel required>2FA token</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZDisable2FAForm = z.object({
|
||||
@@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
@@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
@@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||
@@ -372,9 +373,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Authentication Token</FormLabel>
|
||||
<FormLabel>Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -109,6 +109,7 @@
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
@@ -13767,6 +13768,15 @@
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
|
||||
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz",
|
||||
"integrity": "sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz",
|
||||
|
||||
@@ -124,10 +124,15 @@ module.exports = {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: 0 },
|
||||
},
|
||||
'caret-blink': {
|
||||
'0%,70%,100%': { opacity: '1' },
|
||||
'20%,50%': { opacity: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'caret-blink': 'caret-blink 1.25s ease-out infinite',
|
||||
},
|
||||
screens: {
|
||||
'3xl': '1920px',
|
||||
|
||||
76
packages/ui/primitives/pin-input.tsx
Normal file
76
packages/ui/primitives/pin-input.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { Minus } from 'lucide-react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const PinInput = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
PinInput.displayName = 'PinInput';
|
||||
|
||||
const PinInputGroup = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||
));
|
||||
|
||||
PinInputGroup.displayName = 'PinInputGroup';
|
||||
|
||||
const PinInputSlot = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const context = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = context.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-input relative flex h-10 w-10 items-center justify-center border-y border-r font-mono shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
|
||||
isActive && 'ring-ring z-10 ring-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PinInputSlot.displayName = 'PinInputSlot';
|
||||
|
||||
const PinInputSeparator = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus className="h-5 w-5" />
|
||||
</div>
|
||||
));
|
||||
|
||||
PinInputSeparator.displayName = 'PinInputSeparator';
|
||||
|
||||
export { PinInput, PinInputGroup, PinInputSlot, PinInputSeparator };
|
||||
Reference in New Issue
Block a user