@djangocfg/ui-nextjs 2.1.29 → 2.1.31
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.31",
|
|
4
4
|
"description": "Next.js UI component library with Radix UI primitives, Tailwind CSS styling, charts, and form components",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"check": "tsc --noEmit"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@djangocfg/api": "^2.1.
|
|
62
|
-
"@djangocfg/ui-core": "^2.1.
|
|
61
|
+
"@djangocfg/api": "^2.1.31",
|
|
62
|
+
"@djangocfg/ui-core": "^2.1.31",
|
|
63
63
|
"@types/react": "^19.1.0",
|
|
64
64
|
"@types/react-dom": "^19.1.0",
|
|
65
65
|
"consola": "^3.4.2",
|
|
@@ -74,9 +74,11 @@
|
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
77
|
+
"@radix-ui/react-icons": "^1.3.2",
|
|
77
78
|
"@radix-ui/react-menubar": "^1.1.16",
|
|
78
79
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
79
80
|
"@radix-ui/react-slot": "^1.2.4",
|
|
81
|
+
"input-otp": "1.4.2",
|
|
80
82
|
"@rjsf/core": "^6.1.2",
|
|
81
83
|
"@rjsf/utils": "^6.1.2",
|
|
82
84
|
"@rjsf/validator-ajv8": "^6.1.2",
|
|
@@ -104,7 +106,7 @@
|
|
|
104
106
|
"vidstack": "next"
|
|
105
107
|
},
|
|
106
108
|
"devDependencies": {
|
|
107
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
109
|
+
"@djangocfg/typescript-config": "^2.1.31",
|
|
108
110
|
"@types/node": "^24.7.2",
|
|
109
111
|
"eslint": "^9.37.0",
|
|
110
112
|
"tailwindcss-animate": "1.0.7",
|
package/src/components/index.ts
CHANGED
|
@@ -84,3 +84,7 @@ export type { OptionBuilderConfig } from './multi-select-pro/helpers';
|
|
|
84
84
|
// Markdown Components
|
|
85
85
|
export { MarkdownMessage } from './markdown';
|
|
86
86
|
export type { MarkdownMessageProps } from './markdown';
|
|
87
|
+
|
|
88
|
+
// OTP Components (Smart OTP input with validation)
|
|
89
|
+
export { OTPInput, InputOTPGroup, InputOTPSlot, InputOTPSeparator, useSmartOTP } from './otp';
|
|
90
|
+
export type { OTPInputProps, OTPValidationMode, OTPPasteBehavior, OTPValidator } from './otp';
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '@djangocfg/ui-core'
|
|
5
|
+
import {
|
|
6
|
+
InputOTP,
|
|
7
|
+
InputOTPGroup,
|
|
8
|
+
InputOTPSlot,
|
|
9
|
+
} from '@djangocfg/ui-core'
|
|
10
|
+
import { MinusIcon } from 'lucide-react'
|
|
11
|
+
import { useSmartOTP, createPasteHandler } from './use-otp-input'
|
|
12
|
+
import type { SmartOTPProps } from './types'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Size variants for OTP slots
|
|
16
|
+
*/
|
|
17
|
+
const sizeVariants = {
|
|
18
|
+
sm: 'h-8 w-8 text-sm',
|
|
19
|
+
default: 'h-10 w-10 text-base',
|
|
20
|
+
lg: 'h-14 w-14 text-2xl',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* OTP Separator Component
|
|
25
|
+
*/
|
|
26
|
+
const InputOTPSeparator = React.forwardRef<
|
|
27
|
+
HTMLDivElement,
|
|
28
|
+
React.ComponentPropsWithoutRef<'div'>
|
|
29
|
+
>(({ className, ...props }, ref) => (
|
|
30
|
+
<div ref={ref} role="separator" className={cn('flex items-center', className)} {...props}>
|
|
31
|
+
<MinusIcon />
|
|
32
|
+
</div>
|
|
33
|
+
))
|
|
34
|
+
InputOTPSeparator.displayName = 'InputOTPSeparator'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Smart OTP Input Component
|
|
38
|
+
*
|
|
39
|
+
* Features:
|
|
40
|
+
* - Automatic paste handling with cleaning
|
|
41
|
+
* - Validation (numeric, alphanumeric, alpha, custom)
|
|
42
|
+
* - Auto-submit on completion
|
|
43
|
+
* - Customizable appearance
|
|
44
|
+
* - Error/success states
|
|
45
|
+
* - Optional separator
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* <OTPInput
|
|
50
|
+
* length={6}
|
|
51
|
+
* value={code}
|
|
52
|
+
* onChange={setCode}
|
|
53
|
+
* onComplete={(value) => console.log('Complete:', value)}
|
|
54
|
+
* />
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @example With custom validation
|
|
58
|
+
* ```tsx
|
|
59
|
+
* <OTPInput
|
|
60
|
+
* length={4}
|
|
61
|
+
* validationMode="custom"
|
|
62
|
+
* customValidator={(char) => /[A-F0-9]/i.test(char)}
|
|
63
|
+
* value={hexCode}
|
|
64
|
+
* onChange={setHexCode}
|
|
65
|
+
* />
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export const OTPInput = React.forwardRef<
|
|
69
|
+
React.ComponentRef<typeof InputOTP>,
|
|
70
|
+
SmartOTPProps
|
|
71
|
+
>(
|
|
72
|
+
(
|
|
73
|
+
{
|
|
74
|
+
length = 6,
|
|
75
|
+
value,
|
|
76
|
+
onChange,
|
|
77
|
+
onComplete,
|
|
78
|
+
validationMode = 'numeric',
|
|
79
|
+
customValidator,
|
|
80
|
+
pasteBehavior = 'clean',
|
|
81
|
+
autoSubmit = false,
|
|
82
|
+
disabled = false,
|
|
83
|
+
showSeparator = false,
|
|
84
|
+
separatorIndex,
|
|
85
|
+
containerClassName,
|
|
86
|
+
slotClassName,
|
|
87
|
+
separatorClassName,
|
|
88
|
+
autoFocus = true,
|
|
89
|
+
size = 'default',
|
|
90
|
+
error = false,
|
|
91
|
+
success = false,
|
|
92
|
+
...props
|
|
93
|
+
},
|
|
94
|
+
ref
|
|
95
|
+
) => {
|
|
96
|
+
const {
|
|
97
|
+
value: otpValue,
|
|
98
|
+
handleChange,
|
|
99
|
+
handleComplete,
|
|
100
|
+
} = useSmartOTP({
|
|
101
|
+
length,
|
|
102
|
+
value,
|
|
103
|
+
onChange,
|
|
104
|
+
onComplete,
|
|
105
|
+
validationMode,
|
|
106
|
+
customValidator,
|
|
107
|
+
autoSubmit,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Create paste handler
|
|
111
|
+
const pasteHandler = React.useMemo(
|
|
112
|
+
() =>
|
|
113
|
+
createPasteHandler(
|
|
114
|
+
length,
|
|
115
|
+
validationMode,
|
|
116
|
+
pasteBehavior,
|
|
117
|
+
handleChange,
|
|
118
|
+
customValidator
|
|
119
|
+
),
|
|
120
|
+
[length, validationMode, pasteBehavior, handleChange, customValidator]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// Calculate separator position (default to middle)
|
|
124
|
+
const separatorPosition = separatorIndex ?? Math.floor(length / 2)
|
|
125
|
+
|
|
126
|
+
// Render slots
|
|
127
|
+
const slots = React.useMemo(() => {
|
|
128
|
+
const slotElements: React.ReactNode[] = []
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < length; i++) {
|
|
131
|
+
// Add separator if needed
|
|
132
|
+
if (showSeparator && i === separatorPosition && i !== 0) {
|
|
133
|
+
slotElements.push(
|
|
134
|
+
<InputOTPSeparator
|
|
135
|
+
key={`separator-${i}`}
|
|
136
|
+
className={cn('mx-1', separatorClassName)}
|
|
137
|
+
/>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Add slot
|
|
142
|
+
slotElements.push(
|
|
143
|
+
<InputOTPSlot
|
|
144
|
+
key={i}
|
|
145
|
+
index={i}
|
|
146
|
+
className={cn(
|
|
147
|
+
sizeVariants[size],
|
|
148
|
+
error && 'border-destructive ring-destructive/20',
|
|
149
|
+
success && 'border-green-500 ring-green-500/20',
|
|
150
|
+
slotClassName
|
|
151
|
+
)}
|
|
152
|
+
/>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return slotElements
|
|
157
|
+
}, [length, showSeparator, separatorPosition, separatorClassName, size, error, success, slotClassName])
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<InputOTP
|
|
161
|
+
ref={ref}
|
|
162
|
+
maxLength={length}
|
|
163
|
+
value={otpValue}
|
|
164
|
+
onChange={handleChange}
|
|
165
|
+
onComplete={handleComplete}
|
|
166
|
+
disabled={disabled}
|
|
167
|
+
containerClassName={cn('gap-2', containerClassName)}
|
|
168
|
+
autoFocus={autoFocus}
|
|
169
|
+
onPaste={pasteHandler}
|
|
170
|
+
{...props}
|
|
171
|
+
>
|
|
172
|
+
<InputOTPGroup>{slots}</InputOTPGroup>
|
|
173
|
+
</InputOTP>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
OTPInput.displayName = 'OTPInput'
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Re-export base components for advanced usage
|
|
182
|
+
*/
|
|
183
|
+
export { InputOTPGroup, InputOTPSlot } from '@djangocfg/ui-core'
|
|
184
|
+
export { InputOTPSeparator }
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Re-export types
|
|
188
|
+
*/
|
|
189
|
+
export type {
|
|
190
|
+
SmartOTPProps as OTPInputProps,
|
|
191
|
+
OTPValidationMode,
|
|
192
|
+
OTPPasteBehavior,
|
|
193
|
+
OTPValidator,
|
|
194
|
+
} from './types'
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Re-export hook for advanced usage
|
|
198
|
+
*/
|
|
199
|
+
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,224 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useMemo, useEffect } from 'react'
|
|
4
|
+
import type {
|
|
5
|
+
SmartOTPProps,
|
|
6
|
+
UseSmartOTPReturn,
|
|
7
|
+
OTPValidationMode,
|
|
8
|
+
OTPPasteBehavior,
|
|
9
|
+
OTPValidator,
|
|
10
|
+
} from './types'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validation patterns for different modes
|
|
14
|
+
*/
|
|
15
|
+
const VALIDATION_PATTERNS: Record<Exclude<OTPValidationMode, 'custom'>, RegExp> = {
|
|
16
|
+
numeric: /^\d+$/,
|
|
17
|
+
alphanumeric: /^[a-zA-Z0-9]+$/,
|
|
18
|
+
alpha: /^[a-zA-Z]+$/,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Clean input based on validation mode
|
|
23
|
+
*/
|
|
24
|
+
function cleanInput(
|
|
25
|
+
input: string,
|
|
26
|
+
validationMode: OTPValidationMode,
|
|
27
|
+
customValidator?: OTPValidator
|
|
28
|
+
): string {
|
|
29
|
+
if (!input) return ''
|
|
30
|
+
|
|
31
|
+
// Remove all whitespace and convert to uppercase for consistency
|
|
32
|
+
let cleaned = input.replace(/\s+/g, '').trim()
|
|
33
|
+
|
|
34
|
+
if (validationMode === 'custom' && customValidator) {
|
|
35
|
+
// For custom validation, filter character by character
|
|
36
|
+
return cleaned
|
|
37
|
+
.split('')
|
|
38
|
+
.filter((char) => customValidator(char))
|
|
39
|
+
.join('')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// For built-in modes, use regex patterns
|
|
43
|
+
const pattern = VALIDATION_PATTERNS[validationMode as keyof typeof VALIDATION_PATTERNS]
|
|
44
|
+
if (!pattern) return cleaned
|
|
45
|
+
|
|
46
|
+
return cleaned
|
|
47
|
+
.split('')
|
|
48
|
+
.filter((char) => pattern.test(char))
|
|
49
|
+
.join('')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate entire value
|
|
54
|
+
*/
|
|
55
|
+
function validateValue(
|
|
56
|
+
value: string,
|
|
57
|
+
validationMode: OTPValidationMode,
|
|
58
|
+
customValidator?: OTPValidator
|
|
59
|
+
): boolean {
|
|
60
|
+
if (!value) return true // Empty is valid
|
|
61
|
+
|
|
62
|
+
if (validationMode === 'custom' && customValidator) {
|
|
63
|
+
return customValidator(value)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const pattern = VALIDATION_PATTERNS[validationMode as keyof typeof VALIDATION_PATTERNS]
|
|
67
|
+
return pattern ? pattern.test(value) : true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Process pasted content based on paste behavior
|
|
72
|
+
*/
|
|
73
|
+
function processPaste(
|
|
74
|
+
pastedText: string,
|
|
75
|
+
validationMode: OTPValidationMode,
|
|
76
|
+
pasteBehavior: OTPPasteBehavior,
|
|
77
|
+
length: number,
|
|
78
|
+
customValidator?: OTPValidator
|
|
79
|
+
): string | null {
|
|
80
|
+
const cleaned = cleanInput(pastedText, validationMode, customValidator)
|
|
81
|
+
|
|
82
|
+
switch (pasteBehavior) {
|
|
83
|
+
case 'strict':
|
|
84
|
+
// Only accept if the cleaned version matches the original (no invalid chars)
|
|
85
|
+
if (cleaned.length !== pastedText.replace(/\s+/g, '').length) {
|
|
86
|
+
return null // Reject paste
|
|
87
|
+
}
|
|
88
|
+
return cleaned.slice(0, length)
|
|
89
|
+
|
|
90
|
+
case 'lenient':
|
|
91
|
+
// Accept and use only valid characters
|
|
92
|
+
return cleaned.slice(0, length)
|
|
93
|
+
|
|
94
|
+
case 'clean':
|
|
95
|
+
default:
|
|
96
|
+
// Clean and use valid characters
|
|
97
|
+
return cleaned.slice(0, length)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Smart OTP Input Hook
|
|
103
|
+
* Handles validation, paste behavior, and state management
|
|
104
|
+
*/
|
|
105
|
+
export function useSmartOTP({
|
|
106
|
+
length = 6,
|
|
107
|
+
value: controlledValue,
|
|
108
|
+
onChange,
|
|
109
|
+
onComplete,
|
|
110
|
+
validationMode = 'numeric',
|
|
111
|
+
customValidator,
|
|
112
|
+
autoSubmit = false,
|
|
113
|
+
}: Pick<
|
|
114
|
+
SmartOTPProps,
|
|
115
|
+
| 'length'
|
|
116
|
+
| 'value'
|
|
117
|
+
| 'onChange'
|
|
118
|
+
| 'onComplete'
|
|
119
|
+
| 'validationMode'
|
|
120
|
+
| 'customValidator'
|
|
121
|
+
| 'autoSubmit'
|
|
122
|
+
>): UseSmartOTPReturn {
|
|
123
|
+
const [internalValue, setInternalValue] = useState('')
|
|
124
|
+
|
|
125
|
+
// Use controlled value if provided, otherwise use internal state
|
|
126
|
+
const value = controlledValue !== undefined ? controlledValue : internalValue
|
|
127
|
+
|
|
128
|
+
const isComplete = useMemo(() => value.length === length, [value, length])
|
|
129
|
+
|
|
130
|
+
const isValid = useMemo(
|
|
131
|
+
() => validateValue(value, validationMode, customValidator),
|
|
132
|
+
[value, validationMode, customValidator]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handle value change with validation
|
|
137
|
+
*/
|
|
138
|
+
const handleChange = useCallback(
|
|
139
|
+
(newValue: string) => {
|
|
140
|
+
// Clean the input
|
|
141
|
+
const cleaned = cleanInput(newValue, validationMode, customValidator)
|
|
142
|
+
|
|
143
|
+
// Limit to max length
|
|
144
|
+
const limited = cleaned.slice(0, length)
|
|
145
|
+
|
|
146
|
+
// Update state
|
|
147
|
+
if (controlledValue === undefined) {
|
|
148
|
+
setInternalValue(limited)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Call onChange callback
|
|
152
|
+
onChange?.(limited)
|
|
153
|
+
},
|
|
154
|
+
[validationMode, customValidator, length, onChange, controlledValue]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Handle completion
|
|
159
|
+
*/
|
|
160
|
+
const handleComplete = useCallback(
|
|
161
|
+
(completedValue: string) => {
|
|
162
|
+
if (completedValue.length === length && isValid) {
|
|
163
|
+
onComplete?.(completedValue)
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
[length, isValid, onComplete]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Clear value
|
|
171
|
+
*/
|
|
172
|
+
const clear = useCallback(() => {
|
|
173
|
+
if (controlledValue === undefined) {
|
|
174
|
+
setInternalValue('')
|
|
175
|
+
}
|
|
176
|
+
onChange?.('')
|
|
177
|
+
}, [onChange, controlledValue])
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Auto-submit when complete
|
|
181
|
+
*/
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (autoSubmit && isComplete && isValid) {
|
|
184
|
+
handleComplete(value)
|
|
185
|
+
}
|
|
186
|
+
}, [autoSubmit, isComplete, isValid, value, handleComplete])
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
value,
|
|
190
|
+
handleChange,
|
|
191
|
+
handleComplete,
|
|
192
|
+
isComplete,
|
|
193
|
+
isValid,
|
|
194
|
+
clear,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create a paste handler for OTP input
|
|
200
|
+
*/
|
|
201
|
+
export function createPasteHandler(
|
|
202
|
+
length: number,
|
|
203
|
+
validationMode: OTPValidationMode,
|
|
204
|
+
pasteBehavior: OTPPasteBehavior,
|
|
205
|
+
onChange: (value: string) => void,
|
|
206
|
+
customValidator?: OTPValidator
|
|
207
|
+
) {
|
|
208
|
+
return (e: React.ClipboardEvent) => {
|
|
209
|
+
e.preventDefault()
|
|
210
|
+
const pastedText = e.clipboardData.getData('text')
|
|
211
|
+
|
|
212
|
+
const processed = processPaste(
|
|
213
|
+
pastedText,
|
|
214
|
+
validationMode,
|
|
215
|
+
pasteBehavior,
|
|
216
|
+
length,
|
|
217
|
+
customValidator
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if (processed !== null) {
|
|
221
|
+
onChange(processed)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -26,3 +26,7 @@ export type { UseHotkeyOptions, HotkeyCallback, Keys } from './useHotkey';
|
|
|
26
26
|
// Device detection
|
|
27
27
|
export { useDeviceDetect } from './useDeviceDetect';
|
|
28
28
|
export type { DeviceDetectResult } from './useDeviceDetect';
|
|
29
|
+
|
|
30
|
+
// Browser detection (advanced - detects Chromium browsers correctly)
|
|
31
|
+
export { useBrowserDetect } from './useBrowserDetect';
|
|
32
|
+
export type { BrowserInfo } from './useBrowserDetect';
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advanced browser detection hook
|
|
3
|
+
*
|
|
4
|
+
* Detects modern browsers including Chromium-based browsers that may
|
|
5
|
+
* incorrectly report as Safari (Arc, Brave, Vivaldi, Comet, etc.)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import { useMemo } from 'react';
|
|
11
|
+
|
|
12
|
+
export interface BrowserInfo {
|
|
13
|
+
// Core browser types
|
|
14
|
+
isChrome: boolean;
|
|
15
|
+
isChromium: boolean; // Any Chromium-based browser
|
|
16
|
+
isSafari: boolean; // Real Safari (WebKit on macOS/iOS)
|
|
17
|
+
isFirefox: boolean;
|
|
18
|
+
isEdge: boolean;
|
|
19
|
+
isOpera: boolean;
|
|
20
|
+
|
|
21
|
+
// Modern Chromium-based browsers
|
|
22
|
+
isBrave: boolean;
|
|
23
|
+
isArc: boolean;
|
|
24
|
+
isVivaldi: boolean;
|
|
25
|
+
isYandex: boolean;
|
|
26
|
+
isSamsungBrowser: boolean;
|
|
27
|
+
isUCBrowser: boolean;
|
|
28
|
+
|
|
29
|
+
// Browser name
|
|
30
|
+
browserName: string;
|
|
31
|
+
|
|
32
|
+
// Engine
|
|
33
|
+
isWebKit: boolean; // Safari's engine
|
|
34
|
+
isBlink: boolean; // Chromium's engine
|
|
35
|
+
isGecko: boolean; // Firefox's engine
|
|
36
|
+
|
|
37
|
+
// For debugging
|
|
38
|
+
userAgent: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect browser with improved accuracy for Chromium-based browsers
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```tsx
|
|
46
|
+
* const browser = useBrowserDetect();
|
|
47
|
+
*
|
|
48
|
+
* if (browser.isSafari && !browser.isChromium) {
|
|
49
|
+
* // Real Safari
|
|
50
|
+
* }
|
|
51
|
+
*
|
|
52
|
+
* if (browser.isChromium) {
|
|
53
|
+
* // Any Chromium-based browser (Chrome, Edge, Brave, Arc, etc.)
|
|
54
|
+
* }
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function useBrowserDetect(): BrowserInfo {
|
|
58
|
+
return useMemo(() => {
|
|
59
|
+
if (typeof window === 'undefined') {
|
|
60
|
+
return {
|
|
61
|
+
isChrome: false,
|
|
62
|
+
isChromium: false,
|
|
63
|
+
isSafari: false,
|
|
64
|
+
isFirefox: false,
|
|
65
|
+
isEdge: false,
|
|
66
|
+
isOpera: false,
|
|
67
|
+
isBrave: false,
|
|
68
|
+
isArc: false,
|
|
69
|
+
isVivaldi: false,
|
|
70
|
+
isYandex: false,
|
|
71
|
+
isSamsungBrowser: false,
|
|
72
|
+
isUCBrowser: false,
|
|
73
|
+
browserName: 'unknown',
|
|
74
|
+
isWebKit: false,
|
|
75
|
+
isBlink: false,
|
|
76
|
+
isGecko: false,
|
|
77
|
+
userAgent: '',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ua = window.navigator.userAgent.toLowerCase();
|
|
82
|
+
|
|
83
|
+
// Check for specific browsers first (most specific to least specific)
|
|
84
|
+
|
|
85
|
+
// Edge (Chromium-based)
|
|
86
|
+
const isEdge = ua.includes('edg/') || ua.includes('edge/');
|
|
87
|
+
|
|
88
|
+
// Brave (check for Brave-specific API)
|
|
89
|
+
const isBrave = !!(window.navigator as any).brave;
|
|
90
|
+
|
|
91
|
+
// Arc (check for Arc-specific markers in UA)
|
|
92
|
+
const isArc = ua.includes('arc/');
|
|
93
|
+
|
|
94
|
+
// Vivaldi
|
|
95
|
+
const isVivaldi = ua.includes('vivaldi');
|
|
96
|
+
|
|
97
|
+
// Yandex Browser
|
|
98
|
+
const isYandex = ua.includes('yabrowser');
|
|
99
|
+
|
|
100
|
+
// Samsung Internet
|
|
101
|
+
const isSamsungBrowser = ua.includes('samsungbrowser');
|
|
102
|
+
|
|
103
|
+
// UC Browser
|
|
104
|
+
const isUCBrowser = ua.includes('ucbrowser') || ua.includes('uc browser');
|
|
105
|
+
|
|
106
|
+
// Opera (modern Chromium-based)
|
|
107
|
+
const isOpera = ua.includes('opr/') || ua.includes('opera');
|
|
108
|
+
|
|
109
|
+
// Chrome (not Edge, not other Chromium browsers)
|
|
110
|
+
const isChrome = ua.includes('chrome') &&
|
|
111
|
+
!isEdge &&
|
|
112
|
+
!isOpera &&
|
|
113
|
+
!isYandex &&
|
|
114
|
+
!isSamsungBrowser &&
|
|
115
|
+
!isVivaldi &&
|
|
116
|
+
!isArc &&
|
|
117
|
+
!isBrave;
|
|
118
|
+
|
|
119
|
+
// Firefox
|
|
120
|
+
const isFirefox = ua.includes('firefox') && !ua.includes('seamonkey');
|
|
121
|
+
|
|
122
|
+
// Safari (real Safari, not Chromium pretending to be Safari)
|
|
123
|
+
// Safari will have 'safari' in UA but NOT 'chrome' or 'chromium'
|
|
124
|
+
// Real Safari uses WebKit engine
|
|
125
|
+
// Additional check: Safari has 'version/' in UA, Chromium browsers don't combine it with Safari
|
|
126
|
+
const hasSafariUA = ua.includes('safari');
|
|
127
|
+
const hasChrome = ua.includes('chrome') || ua.includes('crios');
|
|
128
|
+
const hasVersion = ua.includes('version/'); // Real Safari includes Version/XX.X
|
|
129
|
+
const isSafari = hasSafariUA && !hasChrome && hasVersion;
|
|
130
|
+
|
|
131
|
+
// Chromium detection (any browser using Chromium/Blink engine)
|
|
132
|
+
// If it has "chrome" in UA or is one of the known Chromium browsers
|
|
133
|
+
const isChromium = hasChrome ||
|
|
134
|
+
isEdge ||
|
|
135
|
+
isOpera ||
|
|
136
|
+
isYandex ||
|
|
137
|
+
isSamsungBrowser ||
|
|
138
|
+
isVivaldi ||
|
|
139
|
+
isArc ||
|
|
140
|
+
isBrave ||
|
|
141
|
+
isUCBrowser;
|
|
142
|
+
|
|
143
|
+
// Engine detection
|
|
144
|
+
const isWebKit = !isChromium && isSafari;
|
|
145
|
+
const isBlink = isChromium;
|
|
146
|
+
const isGecko = isFirefox;
|
|
147
|
+
|
|
148
|
+
// Determine browser name
|
|
149
|
+
let browserName = 'unknown';
|
|
150
|
+
if (isBrave) browserName = 'Brave';
|
|
151
|
+
else if (isArc) browserName = 'Arc';
|
|
152
|
+
else if (isVivaldi) browserName = 'Vivaldi';
|
|
153
|
+
else if (isYandex) browserName = 'Yandex';
|
|
154
|
+
else if (isSamsungBrowser) browserName = 'Samsung Internet';
|
|
155
|
+
else if (isUCBrowser) browserName = 'UC Browser';
|
|
156
|
+
else if (isEdge) browserName = 'Edge';
|
|
157
|
+
else if (isOpera) browserName = 'Opera';
|
|
158
|
+
else if (isChrome) browserName = 'Chrome';
|
|
159
|
+
else if (isSafari) browserName = 'Safari';
|
|
160
|
+
else if (isFirefox) browserName = 'Firefox';
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
isChrome,
|
|
164
|
+
isChromium,
|
|
165
|
+
isSafari,
|
|
166
|
+
isFirefox,
|
|
167
|
+
isEdge,
|
|
168
|
+
isOpera,
|
|
169
|
+
isBrave,
|
|
170
|
+
isArc,
|
|
171
|
+
isVivaldi,
|
|
172
|
+
isYandex,
|
|
173
|
+
isSamsungBrowser,
|
|
174
|
+
isUCBrowser,
|
|
175
|
+
browserName,
|
|
176
|
+
isWebKit,
|
|
177
|
+
isBlink,
|
|
178
|
+
isGecko,
|
|
179
|
+
userAgent: window.navigator.userAgent,
|
|
180
|
+
};
|
|
181
|
+
}, []);
|
|
182
|
+
}
|