@djangocfg/ui-nextjs 2.1.28 → 2.1.30

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.28",
3
+ "version": "2.1.30",
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.28",
62
- "@djangocfg/ui-core": "^2.1.28",
61
+ "@djangocfg/api": "^2.1.30",
62
+ "@djangocfg/ui-core": "^2.1.30",
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.28",
109
+ "@djangocfg/typescript-config": "^2.1.30",
108
110
  "@types/node": "^24.7.2",
109
111
  "eslint": "^9.37.0",
110
112
  "tailwindcss-animate": "1.0.7",
@@ -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
+ }