@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,277 @@
1
+ "use client"
2
+
3
+ import {
4
+ AsYouType, CountryCode, getCountries, getCountryCallingCode, parsePhoneNumberFromString
5
+ } from 'libphonenumber-js';
6
+ import { ChevronDown, Search } from 'lucide-react';
7
+ import * as React from 'react';
8
+
9
+ import { Button } from './button';
10
+ import { Input } from './input';
11
+ import { cn } from '../lib';
12
+ import {
13
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger
14
+ } from './dropdown-menu';
15
+
16
+ // Generate country flag emoji from country code
17
+ const getCountryFlag = (countryCode: CountryCode): string => {
18
+ return countryCode
19
+ .toUpperCase()
20
+ .replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397))
21
+ }
22
+
23
+ // Get country name from country code using browser's built-in Intl.DisplayNames
24
+ const getCountryName = (countryCode: CountryCode): string => {
25
+ try {
26
+ const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
27
+ return displayNames.of(countryCode) || countryCode
28
+ } catch {
29
+ // Fallback for unsupported country codes
30
+ return countryCode
31
+ }
32
+ }
33
+
34
+ // Generate all countries from libphonenumber-js
35
+ const getAllCountries = () => {
36
+ return getCountries().map(countryCode => ({
37
+ code: countryCode,
38
+ name: getCountryName(countryCode),
39
+ flag: getCountryFlag(countryCode),
40
+ dialCode: `+${getCountryCallingCode(countryCode)}`
41
+ })).sort((a, b) => a.name.localeCompare(b.name))
42
+ }
43
+
44
+ const COUNTRIES = getAllCountries()
45
+
46
+ export interface PhoneInputProps {
47
+ value?: string
48
+ onChange?: (value: string | undefined) => void
49
+ defaultCountry?: CountryCode
50
+ className?: string
51
+ placeholder?: string
52
+ disabled?: boolean
53
+ }
54
+
55
+ const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
56
+ ({
57
+ className,
58
+ value = '',
59
+ onChange,
60
+ defaultCountry = 'US',
61
+ placeholder = "Enter phone number",
62
+ disabled = false,
63
+ ...props
64
+ }, ref) => {
65
+ const [selectedCountry, setSelectedCountry] = React.useState<CountryCode>(defaultCountry)
66
+ const [inputValue, setInputValue] = React.useState('')
67
+ const [isDropdownOpen, setIsDropdownOpen] = React.useState(false)
68
+ const [searchQuery, setSearchQuery] = React.useState('')
69
+ const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
70
+
71
+ // Find country data
72
+ const currentCountry = COUNTRIES.find(c => c.code === selectedCountry) || COUNTRIES[0]!
73
+
74
+ // Filter countries based on search query
75
+ const filteredCountries = React.useMemo(() => {
76
+ if (!searchQuery.trim()) return COUNTRIES
77
+
78
+ const query = searchQuery.toLowerCase()
79
+ return COUNTRIES.filter(country =>
80
+ country.name.toLowerCase().includes(query) ||
81
+ country.dialCode.includes(query) ||
82
+ country.code.toLowerCase().includes(query)
83
+ )
84
+ }, [searchQuery])
85
+
86
+ // Initialize input value from props
87
+ React.useEffect(() => {
88
+ if (value) {
89
+ try {
90
+ const phoneNumber = parsePhoneNumberFromString(value)
91
+ if (phoneNumber) {
92
+ setSelectedCountry(phoneNumber.country || defaultCountry)
93
+ setInputValue(phoneNumber.nationalNumber)
94
+ } else {
95
+ setInputValue(value)
96
+ }
97
+ } catch {
98
+ setInputValue(value)
99
+ }
100
+ }
101
+ }, [value, defaultCountry])
102
+
103
+ // Reset highlighted index when filtered countries change
104
+ React.useEffect(() => {
105
+ setHighlightedIndex(-1)
106
+ }, [filteredCountries])
107
+
108
+ // Reset search when dropdown closes
109
+ React.useEffect(() => {
110
+ if (!isDropdownOpen) {
111
+ setSearchQuery('')
112
+ setHighlightedIndex(-1)
113
+ }
114
+ }, [isDropdownOpen])
115
+
116
+ // Handle country selection
117
+ const handleCountrySelect = (country: typeof COUNTRIES[0]) => {
118
+ setSelectedCountry(country.code)
119
+ setIsDropdownOpen(false)
120
+ setSearchQuery('')
121
+ setHighlightedIndex(-1)
122
+
123
+ // Format existing number for new country
124
+ if (inputValue) {
125
+ const formatter = new AsYouType(country.code)
126
+ const formatted = formatter.input(inputValue)
127
+ setInputValue(formatted)
128
+
129
+ // Get E.164 format for onChange
130
+ const phoneNumber = formatter.getNumber()
131
+ onChange?.(phoneNumber?.number)
132
+ }
133
+ }
134
+
135
+ // Handle keyboard navigation
136
+ const handleKeyDown = (e: React.KeyboardEvent) => {
137
+ if (!isDropdownOpen) return
138
+
139
+ switch (e.key) {
140
+ case 'ArrowDown':
141
+ e.preventDefault()
142
+ setHighlightedIndex(prev =>
143
+ prev < filteredCountries.length - 1 ? prev + 1 : 0
144
+ )
145
+ break
146
+ case 'ArrowUp':
147
+ e.preventDefault()
148
+ setHighlightedIndex(prev =>
149
+ prev > 0 ? prev - 1 : filteredCountries.length - 1
150
+ )
151
+ break
152
+ case 'Enter':
153
+ e.preventDefault()
154
+ if (highlightedIndex >= 0 && highlightedIndex < filteredCountries.length) {
155
+ handleCountrySelect(filteredCountries[highlightedIndex]!)
156
+ }
157
+ break
158
+ case 'Escape':
159
+ e.preventDefault()
160
+ setIsDropdownOpen(false)
161
+ break
162
+ }
163
+ }
164
+
165
+ // Handle input change
166
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
167
+ const input = e.target.value
168
+
169
+ // Use AsYouType formatter for real-time formatting
170
+ const formatter = new AsYouType(selectedCountry)
171
+ const formatted = formatter.input(input)
172
+
173
+ setInputValue(formatted)
174
+
175
+ // Get the parsed phone number for validation and E.164 format
176
+ const phoneNumber = formatter.getNumber()
177
+ onChange?.(phoneNumber?.number)
178
+ }
179
+
180
+ // Handle paste events to extract phone numbers
181
+ const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
182
+ const pastedText = e.clipboardData.getData('text')
183
+
184
+ try {
185
+ // Try to parse as international number first
186
+ const phoneNumber = parsePhoneNumberFromString(pastedText)
187
+ if (phoneNumber) {
188
+ e.preventDefault()
189
+ setSelectedCountry(phoneNumber.country || selectedCountry)
190
+ setInputValue(phoneNumber.nationalNumber)
191
+ onChange?.(phoneNumber.number)
192
+ return
193
+ }
194
+ } catch {
195
+ // Let default paste behavior handle it
196
+ }
197
+ }
198
+
199
+ return (
200
+ <div className={cn("relative flex", className)} onKeyDown={handleKeyDown}>
201
+ {/* Country Dropdown */}
202
+ <DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
203
+ <DropdownMenuTrigger asChild>
204
+ <Button
205
+ variant="outline"
206
+ size="sm"
207
+ className="h-10 px-3 rounded-r-none border-r-0 flex items-center gap-2"
208
+ disabled={disabled}
209
+ >
210
+ <span className="text-base">{currentCountry.flag}</span>
211
+ <span className="text-sm font-mono">{currentCountry.dialCode}</span>
212
+ <ChevronDown className="h-3 w-3 opacity-50" />
213
+ </Button>
214
+ </DropdownMenuTrigger>
215
+ <DropdownMenuContent align="start" className="w-80 max-h-80 p-0">
216
+ {/* Search Input */}
217
+ <div className="p-2 border-b">
218
+ <div className="relative">
219
+ <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
220
+ <Input
221
+ placeholder="Search countries..."
222
+ value={searchQuery}
223
+ onChange={(e) => setSearchQuery(e.target.value)}
224
+ className="pl-8 h-8"
225
+ autoFocus
226
+ />
227
+ </div>
228
+ </div>
229
+
230
+ {/* Countries List */}
231
+ <div className="max-h-60 overflow-y-auto">
232
+ {filteredCountries.length === 0 ? (
233
+ <div className="p-4 text-sm text-muted-foreground text-center">
234
+ No countries found
235
+ </div>
236
+ ) : (
237
+ filteredCountries.map((country, index) => (
238
+ <DropdownMenuItem
239
+ key={country.code}
240
+ onClick={() => handleCountrySelect(country)}
241
+ className={cn(
242
+ "flex items-center gap-3 px-3 py-2 cursor-pointer",
243
+ index === highlightedIndex && "bg-accent"
244
+ )}
245
+ >
246
+ <span className="text-base">{country.flag}</span>
247
+ <span className="flex-1 text-sm">{country.name}</span>
248
+ <span className="text-sm font-mono text-muted-foreground">
249
+ {country.dialCode}
250
+ </span>
251
+ </DropdownMenuItem>
252
+ ))
253
+ )}
254
+ </div>
255
+ </DropdownMenuContent>
256
+ </DropdownMenu>
257
+
258
+ {/* Phone Input */}
259
+ <Input
260
+ ref={ref}
261
+ type="tel"
262
+ value={inputValue}
263
+ onChange={handleInputChange}
264
+ onPaste={handlePaste}
265
+ placeholder={placeholder}
266
+ disabled={disabled}
267
+ className="rounded-l-none border-l-0 flex-1"
268
+ {...props}
269
+ />
270
+ </div>
271
+ )
272
+ }
273
+ )
274
+
275
+ PhoneInput.displayName = "PhoneInput"
276
+
277
+ export { PhoneInput }
@@ -0,0 +1,32 @@
1
+ "use client"
2
+
3
+ import { Toaster as Sonner } from 'sonner';
4
+ import { useResolvedTheme } from '../hooks/useResolvedTheme';
5
+
6
+ type ToasterProps = React.ComponentProps<typeof Sonner>
7
+
8
+ const Toaster = ({ ...props }: ToasterProps) => {
9
+ const theme = useResolvedTheme()
10
+
11
+ return (
12
+ <Sonner
13
+ theme={theme as ToasterProps["theme"]}
14
+ className="toaster group"
15
+ richColors
16
+ toastOptions={{
17
+ classNames: {
18
+ toast:
19
+ "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
20
+ description: "group-[.toast]:text-muted-foreground",
21
+ actionButton:
22
+ "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
23
+ cancelButton:
24
+ "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
25
+ },
26
+ }}
27
+ {...props}
28
+ />
29
+ )
30
+ }
31
+
32
+ export { Toaster }
@@ -13,3 +13,7 @@ export { useMediaQuery } from './useMediaQuery';
13
13
  export { useCopy } from './useCopy';
14
14
  export { useImageLoader } from './useImageLoader';
15
15
  export { useToast, toast } from './useToast';
16
+ export { useResolvedTheme } from './useResolvedTheme';
17
+ export type { ResolvedTheme } from './useResolvedTheme';
18
+ export { useLocalStorage } from './useLocalStorage';
19
+ export { useSessionStorage } from './useSessionStorage';
@@ -20,19 +20,11 @@ export const useCopy = (options: UseCopyOptions = {}) => {
20
20
  const copyToClipboard = useCallback(async (text: string, customSuccessMessage?: string) => {
21
21
  try {
22
22
  await navigator.clipboard.writeText(text);
23
- toast({
24
- title: "Success!",
25
- description: customSuccessMessage || successMessage,
26
- variant: "default",
27
- });
23
+ toast.success(customSuccessMessage || successMessage);
28
24
  return true;
29
25
  } catch (error) {
30
26
  console.error('Failed to copy:', error);
31
- toast({
32
- title: "Error",
33
- description: errorMessage,
34
- variant: "destructive",
35
- });
27
+ toast.error(errorMessage);
36
28
  return false;
37
29
  }
38
30
  }, [toast, successMessage, errorMessage]);
@@ -0,0 +1,300 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ /**
6
+ * Storage wrapper format with metadata
7
+ * Used when TTL is specified
8
+ */
9
+ interface StorageWrapper<T> {
10
+ _meta: {
11
+ createdAt: number;
12
+ ttl: number;
13
+ };
14
+ _value: T;
15
+ }
16
+
17
+ /**
18
+ * Options for useLocalStorage hook
19
+ */
20
+ export interface UseLocalStorageOptions {
21
+ /**
22
+ * Time-to-live in milliseconds.
23
+ * After this time, value is considered expired and initialValue is returned.
24
+ * Data is automatically cleaned up on next read.
25
+ * @example 24 * 60 * 60 * 1000 // 24 hours
26
+ */
27
+ ttl?: number;
28
+ }
29
+
30
+ /**
31
+ * Check if data is in new wrapped format with _meta
32
+ */
33
+ function isWrappedFormat<T>(data: unknown): data is StorageWrapper<T> {
34
+ return (
35
+ data !== null &&
36
+ typeof data === 'object' &&
37
+ '_meta' in data &&
38
+ '_value' in data &&
39
+ typeof (data as StorageWrapper<T>)._meta === 'object' &&
40
+ typeof (data as StorageWrapper<T>)._meta.createdAt === 'number'
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Check if wrapped data is expired
46
+ */
47
+ function isExpired<T>(wrapped: StorageWrapper<T>): boolean {
48
+ if (!wrapped._meta.ttl) return false;
49
+ const age = Date.now() - wrapped._meta.createdAt;
50
+ return age > wrapped._meta.ttl;
51
+ }
52
+
53
+ /**
54
+ * Simple localStorage hook with better error handling and optional TTL support
55
+ *
56
+ * IMPORTANT: To prevent hydration mismatch, this hook:
57
+ * - Always returns initialValue on first render (same as SSR)
58
+ * - Reads from localStorage only after component mounts
59
+ *
60
+ * @param key - Storage key
61
+ * @param initialValue - Default value if key doesn't exist
62
+ * @param options - Optional configuration (ttl for auto-expiration)
63
+ * @returns [value, setValue, removeValue] - Current value, setter function, and remove function
64
+ *
65
+ * @example
66
+ * // Without TTL (backwards compatible)
67
+ * const [value, setValue] = useLocalStorage('key', 'default');
68
+ *
69
+ * @example
70
+ * // With TTL (24 hours)
71
+ * const [value, setValue] = useLocalStorage('key', 'default', {
72
+ * ttl: 24 * 60 * 60 * 1000
73
+ * });
74
+ */
75
+ export function useLocalStorage<T>(
76
+ key: string,
77
+ initialValue: T,
78
+ options?: UseLocalStorageOptions
79
+ ) {
80
+ const ttl = options?.ttl;
81
+ // Always start with initialValue to match SSR
82
+ const [storedValue, setStoredValue] = useState<T>(initialValue);
83
+ const [isHydrated, setIsHydrated] = useState(false);
84
+ const isInitialized = useRef(false);
85
+
86
+ // Read from localStorage after mount (avoids hydration mismatch)
87
+ useEffect(() => {
88
+ if (isInitialized.current) return;
89
+ isInitialized.current = true;
90
+
91
+ try {
92
+ const item = window.localStorage.getItem(key);
93
+ if (item !== null) {
94
+ // Try to parse as JSON first, fallback to string
95
+ try {
96
+ const parsed = JSON.parse(item);
97
+
98
+ // Check if new format with _meta
99
+ if (isWrappedFormat<T>(parsed)) {
100
+ // Check TTL expiration
101
+ if (isExpired(parsed)) {
102
+ // Expired! Clean up and use initial value
103
+ window.localStorage.removeItem(key);
104
+ // Keep initialValue (already set)
105
+ } else {
106
+ // Not expired, extract value
107
+ setStoredValue(parsed._value);
108
+ }
109
+ } else {
110
+ // Old format (backwards compatible)
111
+ setStoredValue(parsed as T);
112
+ }
113
+ } catch {
114
+ // If JSON.parse fails, return as string
115
+ setStoredValue(item as T);
116
+ }
117
+ }
118
+ } catch (error) {
119
+ console.error(`Error reading localStorage key "${key}":`, error);
120
+ }
121
+
122
+ setIsHydrated(true);
123
+ }, [key]);
124
+
125
+ // Check data size and limit
126
+ const checkDataSize = (data: any): boolean => {
127
+ try {
128
+ const jsonString = JSON.stringify(data);
129
+ const sizeInBytes = new Blob([jsonString]).size;
130
+ const sizeInKB = sizeInBytes / 1024;
131
+
132
+ // Limit to 1MB per item
133
+ if (sizeInKB > 1024) {
134
+ console.warn(`Data size (${sizeInKB.toFixed(2)}KB) exceeds 1MB limit for key "${key}"`);
135
+ return false;
136
+ }
137
+
138
+ return true;
139
+ } catch (error) {
140
+ console.error(`Error checking data size for key "${key}":`, error);
141
+ return false;
142
+ }
143
+ };
144
+
145
+ // Clear old data when localStorage is full
146
+ const clearOldData = () => {
147
+ try {
148
+ const keys = Object.keys(localStorage).filter(key => key && typeof key === 'string');
149
+ // Remove oldest items if we have more than 50 items
150
+ if (keys.length > 50) {
151
+ const itemsToRemove = Math.ceil(keys.length * 0.2);
152
+ for (let i = 0; i < itemsToRemove; i++) {
153
+ try {
154
+ const key = keys[i];
155
+ if (key) {
156
+ localStorage.removeItem(key);
157
+ }
158
+ } catch {
159
+ // Ignore errors when removing items
160
+ }
161
+ }
162
+ }
163
+ } catch (error) {
164
+ console.error('Error clearing old localStorage data:', error);
165
+ }
166
+ };
167
+
168
+ // Force clear all data if quota is exceeded
169
+ const forceClearAll = () => {
170
+ try {
171
+ const keys = Object.keys(localStorage);
172
+ for (const key of keys) {
173
+ try {
174
+ localStorage.removeItem(key);
175
+ } catch {
176
+ // Ignore errors when removing items
177
+ }
178
+ }
179
+ } catch (error) {
180
+ console.error('Error force clearing localStorage:', error);
181
+ }
182
+ };
183
+
184
+ // Prepare data for storage (with or without TTL wrapper)
185
+ const prepareForStorage = (value: T): string => {
186
+ if (ttl) {
187
+ // Wrap with _meta for TTL support
188
+ const wrapped: StorageWrapper<T> = {
189
+ _meta: {
190
+ createdAt: Date.now(),
191
+ ttl,
192
+ },
193
+ _value: value,
194
+ };
195
+ return JSON.stringify(wrapped);
196
+ }
197
+ // Old format (no wrapper) - for strings, store directly
198
+ if (typeof value === 'string') {
199
+ return value;
200
+ }
201
+ return JSON.stringify(value);
202
+ };
203
+
204
+ // Update localStorage when value changes
205
+ const setValue = (value: T | ((val: T) => T)) => {
206
+ try {
207
+ const valueToStore = value instanceof Function ? value(storedValue) : value;
208
+
209
+ // Check data size before attempting to save
210
+ if (!checkDataSize(valueToStore)) {
211
+ console.warn(`Data size too large for key "${key}", removing key`);
212
+ // Remove the key if data is too large
213
+ try {
214
+ window.localStorage.removeItem(key);
215
+ } catch {
216
+ // Ignore errors when removing
217
+ }
218
+ // Still update the state
219
+ setStoredValue(valueToStore);
220
+ return;
221
+ }
222
+
223
+ setStoredValue(valueToStore);
224
+
225
+ if (typeof window !== 'undefined') {
226
+ const dataToStore = prepareForStorage(valueToStore);
227
+
228
+ // Try to set the value
229
+ try {
230
+ window.localStorage.setItem(key, dataToStore);
231
+ } catch (storageError: any) {
232
+ // If quota exceeded, clear old data and try again
233
+ if (storageError.name === 'QuotaExceededError' ||
234
+ storageError.code === 22 ||
235
+ storageError.message?.includes('quota')) {
236
+ console.warn('localStorage quota exceeded, clearing old data...');
237
+ clearOldData();
238
+
239
+ // Try again after clearing
240
+ try {
241
+ window.localStorage.setItem(key, dataToStore);
242
+ } catch (retryError) {
243
+ console.error(`Failed to set localStorage key "${key}" after clearing old data:`, retryError);
244
+ // If still fails, force clear all and try one more time
245
+ try {
246
+ forceClearAll();
247
+ window.localStorage.setItem(key, dataToStore);
248
+ } catch (finalError) {
249
+ console.error(`Failed to set localStorage key "${key}" after force clearing:`, finalError);
250
+ // If still fails, just update the state without localStorage
251
+ setStoredValue(valueToStore);
252
+ }
253
+ }
254
+ } else {
255
+ throw storageError;
256
+ }
257
+ }
258
+ }
259
+ } catch (error) {
260
+ console.error(`Error setting localStorage key "${key}":`, error);
261
+ // Still update the state even if localStorage fails
262
+ const valueToStore = value instanceof Function ? value(storedValue) : value;
263
+ setStoredValue(valueToStore);
264
+ }
265
+ };
266
+
267
+ // Remove value from localStorage
268
+ const removeValue = () => {
269
+ try {
270
+ setStoredValue(initialValue);
271
+ if (typeof window !== 'undefined') {
272
+ try {
273
+ window.localStorage.removeItem(key);
274
+ } catch (removeError: any) {
275
+ // If removal fails due to quota, try to clear some data first
276
+ if (removeError.name === 'QuotaExceededError' ||
277
+ removeError.code === 22 ||
278
+ removeError.message?.includes('quota')) {
279
+ console.warn('localStorage quota exceeded during removal, clearing old data...');
280
+ clearOldData();
281
+
282
+ try {
283
+ window.localStorage.removeItem(key);
284
+ } catch (retryError) {
285
+ console.error(`Failed to remove localStorage key "${key}" after clearing:`, retryError);
286
+ // If still fails, force clear all
287
+ forceClearAll();
288
+ }
289
+ } else {
290
+ throw removeError;
291
+ }
292
+ }
293
+ }
294
+ } catch (error) {
295
+ console.error(`Error removing localStorage key "${key}":`, error);
296
+ }
297
+ };
298
+
299
+ return [storedValue, setValue, removeValue] as const;
300
+ }