@djangocfg/ui-core 2.1.90 → 2.1.91
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.
- package/README.md +8 -9
- package/package.json +5 -3
- package/src/components/button-download.tsx +276 -0
- package/src/components/dropdown-menu.tsx +219 -0
- package/src/components/index.ts +16 -2
- package/src/components/menubar.tsx +274 -0
- package/src/components/multi-select-pro/async.tsx +600 -0
- package/src/components/multi-select-pro/helpers.tsx +84 -0
- package/src/components/multi-select-pro/index.tsx +613 -0
- package/src/components/navigation-menu.tsx +153 -0
- package/src/components/otp/index.tsx +198 -0
- package/src/components/otp/types.ts +133 -0
- package/src/components/otp/use-otp-input.ts +225 -0
- package/src/components/phone-input.tsx +277 -0
- package/src/components/sonner.tsx +32 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useCopy.ts +2 -10
- package/src/hooks/useLocalStorage.ts +300 -0
- package/src/hooks/useResolvedTheme.ts +68 -0
- package/src/hooks/useSessionStorage.ts +290 -0
- package/src/hooks/useToast.ts +20 -244
- package/src/lib/index.ts +1 -0
- package/src/lib/logger/index.ts +10 -0
- package/src/lib/logger/logStore.ts +122 -0
- package/src/lib/logger/logger.ts +175 -0
- package/src/lib/logger/types.ts +82 -0
- package/src/utils/LazyComponent.tsx +116 -0
- package/src/utils/index.ts +9 -0
- package/src/components/toast.tsx +0 -144
- package/src/components/toaster.tsx +0 -41
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { cva } from 'class-variance-authority';
|
|
4
|
+
import { ChevronDown } from 'lucide-react';
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib';
|
|
8
|
+
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
|
9
|
+
|
|
10
|
+
const NavigationMenu = React.forwardRef<
|
|
11
|
+
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
|
12
|
+
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
|
13
|
+
>(({ className, children, ...props }, ref) => (
|
|
14
|
+
<NavigationMenuPrimitive.Root
|
|
15
|
+
ref={ref}
|
|
16
|
+
className={cn(
|
|
17
|
+
"relative z-10 flex max-w-max items-center justify-center",
|
|
18
|
+
className
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
{children}
|
|
23
|
+
<NavigationMenuViewport />
|
|
24
|
+
</NavigationMenuPrimitive.Root>
|
|
25
|
+
))
|
|
26
|
+
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
|
27
|
+
|
|
28
|
+
const NavigationMenuList = React.forwardRef<
|
|
29
|
+
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
|
30
|
+
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
|
31
|
+
>(({ className, ...props }, ref) => (
|
|
32
|
+
<NavigationMenuPrimitive.List
|
|
33
|
+
ref={ref}
|
|
34
|
+
className={cn(
|
|
35
|
+
"group flex flex-1 list-none items-center justify-center space-x-1",
|
|
36
|
+
className
|
|
37
|
+
)}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
))
|
|
41
|
+
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
|
42
|
+
|
|
43
|
+
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
|
44
|
+
|
|
45
|
+
const navigationMenuTriggerStyle = cva(
|
|
46
|
+
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const NavigationMenuTrigger = React.forwardRef<
|
|
50
|
+
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
|
51
|
+
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
|
52
|
+
>(({ className, children, ...props }, ref) => (
|
|
53
|
+
<NavigationMenuPrimitive.Trigger
|
|
54
|
+
ref={ref}
|
|
55
|
+
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
{children}{" "}
|
|
59
|
+
<ChevronDown
|
|
60
|
+
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
/>
|
|
63
|
+
</NavigationMenuPrimitive.Trigger>
|
|
64
|
+
))
|
|
65
|
+
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
|
66
|
+
|
|
67
|
+
const NavigationMenuContent = React.forwardRef<
|
|
68
|
+
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
|
69
|
+
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
|
70
|
+
>(({ className, ...props }, ref) => (
|
|
71
|
+
<NavigationMenuPrimitive.Content
|
|
72
|
+
ref={ref}
|
|
73
|
+
className={cn(
|
|
74
|
+
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
|
75
|
+
className
|
|
76
|
+
)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
))
|
|
80
|
+
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
|
81
|
+
|
|
82
|
+
const NavigationMenuLink = React.forwardRef<
|
|
83
|
+
React.ElementRef<typeof NavigationMenuPrimitive.Link>,
|
|
84
|
+
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Link> & {
|
|
85
|
+
href?: string
|
|
86
|
+
}
|
|
87
|
+
>(({ href, children, ...props }, ref) => {
|
|
88
|
+
if (href) {
|
|
89
|
+
return (
|
|
90
|
+
<NavigationMenuPrimitive.Link asChild ref={ref} {...props}>
|
|
91
|
+
<a href={href}>{children}</a>
|
|
92
|
+
</NavigationMenuPrimitive.Link>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<NavigationMenuPrimitive.Link ref={ref} {...props}>
|
|
98
|
+
{children}
|
|
99
|
+
</NavigationMenuPrimitive.Link>
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
NavigationMenuLink.displayName = "NavigationMenuLink"
|
|
103
|
+
|
|
104
|
+
const NavigationMenuViewport = React.forwardRef<
|
|
105
|
+
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
|
106
|
+
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
|
107
|
+
>(({ className, ...props }, ref) => (
|
|
108
|
+
<div
|
|
109
|
+
className={cn("absolute top-full flex justify-center")}
|
|
110
|
+
style={{ left: 0, right: 0 }}
|
|
111
|
+
>
|
|
112
|
+
<NavigationMenuPrimitive.Viewport
|
|
113
|
+
className={cn(
|
|
114
|
+
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] overflow-hidden rounded-md border backdrop-blur-xl bg-popover/80 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 w-[var(--radix-navigation-menu-viewport-width)]",
|
|
115
|
+
className
|
|
116
|
+
)}
|
|
117
|
+
ref={ref}
|
|
118
|
+
{...props}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
))
|
|
122
|
+
NavigationMenuViewport.displayName =
|
|
123
|
+
NavigationMenuPrimitive.Viewport.displayName
|
|
124
|
+
|
|
125
|
+
const NavigationMenuIndicator = React.forwardRef<
|
|
126
|
+
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
|
127
|
+
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
|
128
|
+
>(({ className, ...props }, ref) => (
|
|
129
|
+
<NavigationMenuPrimitive.Indicator
|
|
130
|
+
ref={ref}
|
|
131
|
+
className={cn(
|
|
132
|
+
"top-full z-10 flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
|
133
|
+
className
|
|
134
|
+
)}
|
|
135
|
+
{...props}
|
|
136
|
+
>
|
|
137
|
+
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
|
138
|
+
</NavigationMenuPrimitive.Indicator>
|
|
139
|
+
))
|
|
140
|
+
NavigationMenuIndicator.displayName =
|
|
141
|
+
NavigationMenuPrimitive.Indicator.displayName
|
|
142
|
+
|
|
143
|
+
export {
|
|
144
|
+
navigationMenuTriggerStyle,
|
|
145
|
+
NavigationMenu,
|
|
146
|
+
NavigationMenuList,
|
|
147
|
+
NavigationMenuItem,
|
|
148
|
+
NavigationMenuContent,
|
|
149
|
+
NavigationMenuTrigger,
|
|
150
|
+
NavigationMenuLink,
|
|
151
|
+
NavigationMenuIndicator,
|
|
152
|
+
NavigationMenuViewport,
|
|
153
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { MinusIcon } from 'lucide-react';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
|
|
6
|
+
import { InputOTP, InputOTPGroup, InputOTPSlot } from '../input-otp';
|
|
7
|
+
import { cn } from '../../lib';
|
|
8
|
+
|
|
9
|
+
import { createPasteHandler, useSmartOTP } from './use-otp-input';
|
|
10
|
+
|
|
11
|
+
import type { SmartOTPProps } from './types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Size variants for OTP slots
|
|
15
|
+
*/
|
|
16
|
+
const sizeVariants = {
|
|
17
|
+
sm: 'h-8 w-8 text-sm',
|
|
18
|
+
default: 'h-10 w-10 text-base',
|
|
19
|
+
lg: 'h-14 w-14 text-2xl',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* OTP Separator Component
|
|
24
|
+
*/
|
|
25
|
+
const InputOTPSeparator = React.forwardRef<
|
|
26
|
+
HTMLDivElement,
|
|
27
|
+
React.ComponentPropsWithoutRef<'div'>
|
|
28
|
+
>(({ className, ...props }, ref) => (
|
|
29
|
+
<div ref={ref} role="separator" className={cn('flex items-center', className)} {...props}>
|
|
30
|
+
<MinusIcon />
|
|
31
|
+
</div>
|
|
32
|
+
))
|
|
33
|
+
InputOTPSeparator.displayName = 'InputOTPSeparator'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Smart OTP Input Component
|
|
37
|
+
*
|
|
38
|
+
* Features:
|
|
39
|
+
* - Automatic paste handling with cleaning
|
|
40
|
+
* - Validation (numeric, alphanumeric, alpha, custom)
|
|
41
|
+
* - Auto-submit on completion
|
|
42
|
+
* - Customizable appearance
|
|
43
|
+
* - Error/success states
|
|
44
|
+
* - Optional separator
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <OTPInput
|
|
49
|
+
* length={6}
|
|
50
|
+
* value={code}
|
|
51
|
+
* onChange={setCode}
|
|
52
|
+
* onComplete={(value) => console.log('Complete:', value)}
|
|
53
|
+
* />
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @example With custom validation
|
|
57
|
+
* ```tsx
|
|
58
|
+
* <OTPInput
|
|
59
|
+
* length={4}
|
|
60
|
+
* validationMode="custom"
|
|
61
|
+
* customValidator={(char) => /[A-F0-9]/i.test(char)}
|
|
62
|
+
* value={hexCode}
|
|
63
|
+
* onChange={setHexCode}
|
|
64
|
+
* />
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export const OTPInput = React.forwardRef<
|
|
68
|
+
React.ComponentRef<typeof InputOTP>,
|
|
69
|
+
SmartOTPProps
|
|
70
|
+
>(
|
|
71
|
+
(
|
|
72
|
+
{
|
|
73
|
+
length = 6,
|
|
74
|
+
value,
|
|
75
|
+
onChange,
|
|
76
|
+
onComplete,
|
|
77
|
+
validationMode = 'numeric',
|
|
78
|
+
customValidator,
|
|
79
|
+
pasteBehavior = 'clean',
|
|
80
|
+
autoSubmit = false,
|
|
81
|
+
disabled = false,
|
|
82
|
+
showSeparator = false,
|
|
83
|
+
separatorIndex,
|
|
84
|
+
containerClassName,
|
|
85
|
+
slotClassName,
|
|
86
|
+
separatorClassName,
|
|
87
|
+
autoFocus = true,
|
|
88
|
+
size = 'default',
|
|
89
|
+
error = false,
|
|
90
|
+
success = false,
|
|
91
|
+
...props
|
|
92
|
+
},
|
|
93
|
+
ref
|
|
94
|
+
) => {
|
|
95
|
+
const {
|
|
96
|
+
value: otpValue,
|
|
97
|
+
handleChange,
|
|
98
|
+
handleComplete,
|
|
99
|
+
} = useSmartOTP({
|
|
100
|
+
length,
|
|
101
|
+
value,
|
|
102
|
+
onChange,
|
|
103
|
+
onComplete,
|
|
104
|
+
validationMode,
|
|
105
|
+
customValidator,
|
|
106
|
+
autoSubmit,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Create paste handler
|
|
110
|
+
const pasteHandler = React.useMemo(
|
|
111
|
+
() =>
|
|
112
|
+
createPasteHandler(
|
|
113
|
+
length,
|
|
114
|
+
validationMode,
|
|
115
|
+
pasteBehavior,
|
|
116
|
+
handleChange,
|
|
117
|
+
customValidator
|
|
118
|
+
),
|
|
119
|
+
[length, validationMode, pasteBehavior, handleChange, customValidator]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Calculate separator position (default to middle)
|
|
123
|
+
const separatorPosition = separatorIndex ?? Math.floor(length / 2)
|
|
124
|
+
|
|
125
|
+
// Render slots
|
|
126
|
+
const slots = React.useMemo(() => {
|
|
127
|
+
const slotElements: React.ReactNode[] = []
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < length; i++) {
|
|
130
|
+
// Add separator if needed
|
|
131
|
+
if (showSeparator && i === separatorPosition && i !== 0) {
|
|
132
|
+
slotElements.push(
|
|
133
|
+
<InputOTPSeparator
|
|
134
|
+
key={`separator-${i}`}
|
|
135
|
+
className={cn('mx-1', separatorClassName)}
|
|
136
|
+
/>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Add slot
|
|
141
|
+
slotElements.push(
|
|
142
|
+
<InputOTPSlot
|
|
143
|
+
key={i}
|
|
144
|
+
index={i}
|
|
145
|
+
className={cn(
|
|
146
|
+
sizeVariants[size],
|
|
147
|
+
error && 'border-destructive ring-destructive/20',
|
|
148
|
+
success && 'border-green-500 ring-green-500/20',
|
|
149
|
+
slotClassName
|
|
150
|
+
)}
|
|
151
|
+
/>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return slotElements
|
|
156
|
+
}, [length, showSeparator, separatorPosition, separatorClassName, size, error, success, slotClassName])
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<InputOTP
|
|
160
|
+
ref={ref}
|
|
161
|
+
maxLength={length}
|
|
162
|
+
value={otpValue}
|
|
163
|
+
onChange={handleChange}
|
|
164
|
+
onComplete={handleComplete}
|
|
165
|
+
disabled={disabled}
|
|
166
|
+
containerClassName={cn('gap-2', containerClassName)}
|
|
167
|
+
autoFocus={autoFocus}
|
|
168
|
+
onPaste={pasteHandler}
|
|
169
|
+
{...props}
|
|
170
|
+
>
|
|
171
|
+
<InputOTPGroup>{slots}</InputOTPGroup>
|
|
172
|
+
</InputOTP>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
OTPInput.displayName = 'OTPInput'
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Re-export base components for advanced usage
|
|
181
|
+
*/
|
|
182
|
+
export { InputOTPGroup, InputOTPSlot } from '@djangocfg/ui-core'
|
|
183
|
+
export { InputOTPSeparator }
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Re-export types
|
|
187
|
+
*/
|
|
188
|
+
export type {
|
|
189
|
+
SmartOTPProps as OTPInputProps,
|
|
190
|
+
OTPValidationMode,
|
|
191
|
+
OTPPasteBehavior,
|
|
192
|
+
OTPValidator,
|
|
193
|
+
} from './types'
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Re-export hook for advanced usage
|
|
197
|
+
*/
|
|
198
|
+
export { useSmartOTP } from './use-otp-input'
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { OTPInputProps as BaseOTPInputProps } from 'input-otp'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OTP Input validation modes
|
|
5
|
+
*/
|
|
6
|
+
export type OTPValidationMode = 'numeric' | 'alphanumeric' | 'alpha' | 'custom'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OTP Input paste behavior
|
|
10
|
+
*/
|
|
11
|
+
export type OTPPasteBehavior = 'clean' | 'strict' | 'lenient'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Custom validator function
|
|
15
|
+
*/
|
|
16
|
+
export type OTPValidator = (value: string) => boolean
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Props for the smart OTP component
|
|
20
|
+
*/
|
|
21
|
+
export interface SmartOTPProps {
|
|
22
|
+
/**
|
|
23
|
+
* Number of OTP slots
|
|
24
|
+
* @default 6
|
|
25
|
+
*/
|
|
26
|
+
length?: number
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Current OTP value
|
|
30
|
+
*/
|
|
31
|
+
value?: string
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Callback when value changes
|
|
35
|
+
*/
|
|
36
|
+
onChange?: (value: string) => void
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Callback when OTP is complete
|
|
40
|
+
*/
|
|
41
|
+
onComplete?: (value: string) => void
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validation mode
|
|
45
|
+
* @default 'numeric'
|
|
46
|
+
*/
|
|
47
|
+
validationMode?: OTPValidationMode
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Custom validator function (used when validationMode is 'custom')
|
|
51
|
+
*/
|
|
52
|
+
customValidator?: OTPValidator
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Paste behavior
|
|
56
|
+
* - clean: Remove all non-valid characters
|
|
57
|
+
* - strict: Only accept paste if all characters are valid
|
|
58
|
+
* - lenient: Accept paste and keep valid characters only
|
|
59
|
+
* @default 'clean'
|
|
60
|
+
*/
|
|
61
|
+
pasteBehavior?: OTPPasteBehavior
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Auto-submit when complete
|
|
65
|
+
* @default false
|
|
66
|
+
*/
|
|
67
|
+
autoSubmit?: boolean
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Disabled state
|
|
71
|
+
*/
|
|
72
|
+
disabled?: boolean
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Show separator between slots
|
|
76
|
+
* @default false
|
|
77
|
+
*/
|
|
78
|
+
showSeparator?: boolean
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Separator position (0-based index, appears after this index)
|
|
82
|
+
*/
|
|
83
|
+
separatorIndex?: number
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Custom class name for container
|
|
87
|
+
*/
|
|
88
|
+
containerClassName?: string
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Custom class name for slots
|
|
92
|
+
*/
|
|
93
|
+
slotClassName?: string
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Custom class name for separator
|
|
97
|
+
*/
|
|
98
|
+
separatorClassName?: string
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Auto-focus first slot on mount
|
|
102
|
+
* @default true
|
|
103
|
+
*/
|
|
104
|
+
autoFocus?: boolean
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Slot size variant
|
|
108
|
+
* @default 'default'
|
|
109
|
+
*/
|
|
110
|
+
size?: 'sm' | 'default' | 'lg'
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Error state
|
|
114
|
+
*/
|
|
115
|
+
error?: boolean
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Success state
|
|
119
|
+
*/
|
|
120
|
+
success?: boolean
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Return type for useSmartOTP hook
|
|
125
|
+
*/
|
|
126
|
+
export interface UseSmartOTPReturn {
|
|
127
|
+
value: string
|
|
128
|
+
handleChange: (newValue: string) => void
|
|
129
|
+
handleComplete: (completedValue: string) => void
|
|
130
|
+
isComplete: boolean
|
|
131
|
+
isValid: boolean
|
|
132
|
+
clear: () => void
|
|
133
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
SmartOTPProps,
|
|
7
|
+
UseSmartOTPReturn,
|
|
8
|
+
OTPValidationMode,
|
|
9
|
+
OTPPasteBehavior,
|
|
10
|
+
OTPValidator,
|
|
11
|
+
} from './types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validation patterns for different modes
|
|
15
|
+
*/
|
|
16
|
+
const VALIDATION_PATTERNS: Record<Exclude<OTPValidationMode, 'custom'>, RegExp> = {
|
|
17
|
+
numeric: /^\d+$/,
|
|
18
|
+
alphanumeric: /^[a-zA-Z0-9]+$/,
|
|
19
|
+
alpha: /^[a-zA-Z]+$/,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Clean input based on validation mode
|
|
24
|
+
*/
|
|
25
|
+
function cleanInput(
|
|
26
|
+
input: string,
|
|
27
|
+
validationMode: OTPValidationMode,
|
|
28
|
+
customValidator?: OTPValidator
|
|
29
|
+
): string {
|
|
30
|
+
if (!input) return ''
|
|
31
|
+
|
|
32
|
+
// Remove all whitespace and convert to uppercase for consistency
|
|
33
|
+
let cleaned = input.replace(/\s+/g, '').trim()
|
|
34
|
+
|
|
35
|
+
if (validationMode === 'custom' && customValidator) {
|
|
36
|
+
// For custom validation, filter character by character
|
|
37
|
+
return cleaned
|
|
38
|
+
.split('')
|
|
39
|
+
.filter((char) => customValidator(char))
|
|
40
|
+
.join('')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// For built-in modes, use regex patterns
|
|
44
|
+
const pattern = VALIDATION_PATTERNS[validationMode as keyof typeof VALIDATION_PATTERNS]
|
|
45
|
+
if (!pattern) return cleaned
|
|
46
|
+
|
|
47
|
+
return cleaned
|
|
48
|
+
.split('')
|
|
49
|
+
.filter((char) => pattern.test(char))
|
|
50
|
+
.join('')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate entire value
|
|
55
|
+
*/
|
|
56
|
+
function validateValue(
|
|
57
|
+
value: string,
|
|
58
|
+
validationMode: OTPValidationMode,
|
|
59
|
+
customValidator?: OTPValidator
|
|
60
|
+
): boolean {
|
|
61
|
+
if (!value) return true // Empty is valid
|
|
62
|
+
|
|
63
|
+
if (validationMode === 'custom' && customValidator) {
|
|
64
|
+
return customValidator(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const pattern = VALIDATION_PATTERNS[validationMode as keyof typeof VALIDATION_PATTERNS]
|
|
68
|
+
return pattern ? pattern.test(value) : true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Process pasted content based on paste behavior
|
|
73
|
+
*/
|
|
74
|
+
function processPaste(
|
|
75
|
+
pastedText: string,
|
|
76
|
+
validationMode: OTPValidationMode,
|
|
77
|
+
pasteBehavior: OTPPasteBehavior,
|
|
78
|
+
length: number,
|
|
79
|
+
customValidator?: OTPValidator
|
|
80
|
+
): string | null {
|
|
81
|
+
const cleaned = cleanInput(pastedText, validationMode, customValidator)
|
|
82
|
+
|
|
83
|
+
switch (pasteBehavior) {
|
|
84
|
+
case 'strict':
|
|
85
|
+
// Only accept if the cleaned version matches the original (no invalid chars)
|
|
86
|
+
if (cleaned.length !== pastedText.replace(/\s+/g, '').length) {
|
|
87
|
+
return null // Reject paste
|
|
88
|
+
}
|
|
89
|
+
return cleaned.slice(0, length)
|
|
90
|
+
|
|
91
|
+
case 'lenient':
|
|
92
|
+
// Accept and use only valid characters
|
|
93
|
+
return cleaned.slice(0, length)
|
|
94
|
+
|
|
95
|
+
case 'clean':
|
|
96
|
+
default:
|
|
97
|
+
// Clean and use valid characters
|
|
98
|
+
return cleaned.slice(0, length)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Smart OTP Input Hook
|
|
104
|
+
* Handles validation, paste behavior, and state management
|
|
105
|
+
*/
|
|
106
|
+
export function useSmartOTP({
|
|
107
|
+
length = 6,
|
|
108
|
+
value: controlledValue,
|
|
109
|
+
onChange,
|
|
110
|
+
onComplete,
|
|
111
|
+
validationMode = 'numeric',
|
|
112
|
+
customValidator,
|
|
113
|
+
autoSubmit = false,
|
|
114
|
+
}: Pick<
|
|
115
|
+
SmartOTPProps,
|
|
116
|
+
| 'length'
|
|
117
|
+
| 'value'
|
|
118
|
+
| 'onChange'
|
|
119
|
+
| 'onComplete'
|
|
120
|
+
| 'validationMode'
|
|
121
|
+
| 'customValidator'
|
|
122
|
+
| 'autoSubmit'
|
|
123
|
+
>): UseSmartOTPReturn {
|
|
124
|
+
const [internalValue, setInternalValue] = useState('')
|
|
125
|
+
|
|
126
|
+
// Use controlled value if provided, otherwise use internal state
|
|
127
|
+
const value = controlledValue !== undefined ? controlledValue : internalValue
|
|
128
|
+
|
|
129
|
+
const isComplete = useMemo(() => value.length === length, [value, length])
|
|
130
|
+
|
|
131
|
+
const isValid = useMemo(
|
|
132
|
+
() => validateValue(value, validationMode, customValidator),
|
|
133
|
+
[value, validationMode, customValidator]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Handle value change with validation
|
|
138
|
+
*/
|
|
139
|
+
const handleChange = useCallback(
|
|
140
|
+
(newValue: string) => {
|
|
141
|
+
// Clean the input
|
|
142
|
+
const cleaned = cleanInput(newValue, validationMode, customValidator)
|
|
143
|
+
|
|
144
|
+
// Limit to max length
|
|
145
|
+
const limited = cleaned.slice(0, length)
|
|
146
|
+
|
|
147
|
+
// Update state
|
|
148
|
+
if (controlledValue === undefined) {
|
|
149
|
+
setInternalValue(limited)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Call onChange callback
|
|
153
|
+
onChange?.(limited)
|
|
154
|
+
},
|
|
155
|
+
[validationMode, customValidator, length, onChange, controlledValue]
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Handle completion
|
|
160
|
+
*/
|
|
161
|
+
const handleComplete = useCallback(
|
|
162
|
+
(completedValue: string) => {
|
|
163
|
+
if (completedValue.length === length && isValid) {
|
|
164
|
+
onComplete?.(completedValue)
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
[length, isValid, onComplete]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Clear value
|
|
172
|
+
*/
|
|
173
|
+
const clear = useCallback(() => {
|
|
174
|
+
if (controlledValue === undefined) {
|
|
175
|
+
setInternalValue('')
|
|
176
|
+
}
|
|
177
|
+
onChange?.('')
|
|
178
|
+
}, [onChange, controlledValue])
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Auto-submit when complete
|
|
182
|
+
*/
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (autoSubmit && isComplete && isValid) {
|
|
185
|
+
handleComplete(value)
|
|
186
|
+
}
|
|
187
|
+
}, [autoSubmit, isComplete, isValid, value, handleComplete])
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
value,
|
|
191
|
+
handleChange,
|
|
192
|
+
handleComplete,
|
|
193
|
+
isComplete,
|
|
194
|
+
isValid,
|
|
195
|
+
clear,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Create a paste handler for OTP input
|
|
201
|
+
*/
|
|
202
|
+
export function createPasteHandler(
|
|
203
|
+
length: number,
|
|
204
|
+
validationMode: OTPValidationMode,
|
|
205
|
+
pasteBehavior: OTPPasteBehavior,
|
|
206
|
+
onChange: (value: string) => void,
|
|
207
|
+
customValidator?: OTPValidator
|
|
208
|
+
) {
|
|
209
|
+
return (e: React.ClipboardEvent) => {
|
|
210
|
+
e.preventDefault()
|
|
211
|
+
const pastedText = e.clipboardData.getData('text')
|
|
212
|
+
|
|
213
|
+
const processed = processPaste(
|
|
214
|
+
pastedText,
|
|
215
|
+
validationMode,
|
|
216
|
+
pasteBehavior,
|
|
217
|
+
length,
|
|
218
|
+
customValidator
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (processed !== null) {
|
|
222
|
+
onChange(processed)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|