@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.
@@ -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
+ }