@komensal/ui 0.1.0

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.
Files changed (42) hide show
  1. package/package.json +48 -0
  2. package/src/components/alert.tsx +45 -0
  3. package/src/components/auth-header.tsx +26 -0
  4. package/src/components/button.tsx +121 -0
  5. package/src/components/command.tsx +125 -0
  6. package/src/components/dialog.tsx +118 -0
  7. package/src/components/form-fields/index.ts +5 -0
  8. package/src/components/form-fields/input-field.tsx +131 -0
  9. package/src/components/form-fields/label-field.tsx +20 -0
  10. package/src/components/form-fields/map-picker-field.tsx +49 -0
  11. package/src/components/form-fields/otp-field.tsx +76 -0
  12. package/src/components/form-fields/select-field.tsx +77 -0
  13. package/src/components/form.tsx +138 -0
  14. package/src/components/google-map-picker.tsx +243 -0
  15. package/src/components/input.tsx +40 -0
  16. package/src/components/label.tsx +17 -0
  17. package/src/components/loading.tsx +19 -0
  18. package/src/components/otp-input.tsx +62 -0
  19. package/src/components/password-checklist.tsx +37 -0
  20. package/src/components/popover.tsx +31 -0
  21. package/src/components/select-input.tsx +236 -0
  22. package/src/components/select.tsx +143 -0
  23. package/src/components/spinner.tsx +61 -0
  24. package/src/components/step-header.tsx +16 -0
  25. package/src/components/stepper.tsx +69 -0
  26. package/src/components/toast/index.ts +2 -0
  27. package/src/components/toast/toast-provider.tsx +123 -0
  28. package/src/components/toast/toast.tsx +106 -0
  29. package/src/components/tooltip.tsx +56 -0
  30. package/src/components/wrapper.tsx +12 -0
  31. package/src/index.ts +27 -0
  32. package/src/locales/es/ui.json +28 -0
  33. package/src/locales/index.ts +7 -0
  34. package/src/styles/index.css +1 -0
  35. package/src/styles/theme.css +105 -0
  36. package/src/types/password.ts +12 -0
  37. package/src/types/toast.ts +19 -0
  38. package/src/utils/cn.ts +6 -0
  39. package/src/utils/index.ts +3 -0
  40. package/src/utils/styles.ts +19 -0
  41. package/src/utils/toast-emitter.ts +37 -0
  42. package/src/vite-env.d.ts +3 -0
@@ -0,0 +1,49 @@
1
+ import type { Control, FieldValues, Path } from 'react-hook-form'
2
+
3
+ import { cn } from '../../utils'
4
+ import { FormControl, FormField, FormItem, FormMessage } from '../form'
5
+ import { GoogleMapPicker, type GoogleMapPickerProps } from '../google-map-picker'
6
+ import { LabelField } from './label-field'
7
+
8
+ type MapPickerFieldProps<T extends FieldValues> = Omit<GoogleMapPickerProps, 'hasError' | 'onChange' | 'value'> & {
9
+ control: Control<T>
10
+ label?: string
11
+ labelClassName?: string
12
+ mainClassName?: string
13
+ name: Path<T>
14
+ visualRequired?: boolean
15
+ }
16
+
17
+ export const MapPickerField = <T extends FieldValues>({
18
+ control,
19
+ label,
20
+ labelClassName,
21
+ mainClassName,
22
+ name,
23
+ visualRequired,
24
+ ...pickerProps
25
+ }: MapPickerFieldProps<T>) => (
26
+ <FormField
27
+ control={control}
28
+ name={name}
29
+ render={({ field, fieldState }) => (
30
+ <FormItem className={cn('flex flex-col', mainClassName)}>
31
+ <LabelField
32
+ label={label}
33
+ labelClassName={cn(labelClassName, fieldState.error && 'text-destructive')}
34
+ name={name}
35
+ visualRequired={visualRequired}
36
+ />
37
+ <FormControl>
38
+ <GoogleMapPicker
39
+ {...pickerProps}
40
+ hasError={!!fieldState.error}
41
+ onChange={field.onChange}
42
+ value={field.value ?? ''}
43
+ />
44
+ </FormControl>
45
+ <FormMessage />
46
+ </FormItem>
47
+ )}
48
+ />
49
+ )
@@ -0,0 +1,76 @@
1
+ import React from 'react'
2
+ import { Control, FieldPath, FieldValues } from 'react-hook-form'
3
+
4
+ import { cn } from '../../utils'
5
+ import { FormControl, FormField, FormItem, FormMessage } from '../form'
6
+ import { Label } from '../label'
7
+ import { InputOTP, InputOTPGroup, InputOTPSlot } from '../otp-input'
8
+
9
+ interface OtpInputFormFieldProps<
10
+ TFieldValues extends FieldValues = FieldValues,
11
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
12
+ > {
13
+ autoFocus?: boolean
14
+ className?: string
15
+ containerClassName?: string
16
+ control: Control<TFieldValues>
17
+ disabled?: boolean
18
+ label?: string
19
+ labelClassName?: string
20
+ name: TName
21
+ otpLength?: number
22
+ }
23
+
24
+ export function OTPField<
25
+ TFieldValues extends FieldValues = FieldValues,
26
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
27
+ >({
28
+ autoFocus,
29
+ className,
30
+ containerClassName,
31
+ control,
32
+ disabled,
33
+ label,
34
+ labelClassName,
35
+ name,
36
+ otpLength = 6
37
+ }: OtpInputFormFieldProps<TFieldValues, TName>) {
38
+ return (
39
+ <FormField
40
+ control={control}
41
+ name={name}
42
+ render={({ field, fieldState }) => (
43
+ <FormItem className={cn('flex flex-col', className)}>
44
+ {label ? (
45
+ <Label className={cn('text-start text-sm font-semibold', labelClassName)} htmlFor={name}>
46
+ {label}
47
+ </Label>
48
+ ) : null}
49
+ <FormControl>
50
+ <InputOTP
51
+ aria-invalid={fieldState.invalid || undefined}
52
+ autoComplete='one-time-code'
53
+ autoFocus={autoFocus}
54
+ containerClassName={cn('justify-center', containerClassName)}
55
+ disabled={disabled}
56
+ id={name}
57
+ inputMode='numeric'
58
+ maxLength={otpLength}
59
+ onChange={(val: string) => field.onChange(val)}
60
+ onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => field.onChange(e.clipboardData.getData('text'))}
61
+ pattern='[0-9]*'
62
+ value={field.value ?? ''}
63
+ >
64
+ <InputOTPGroup>
65
+ {Array.from({ length: otpLength }).map((_, i) => (
66
+ <InputOTPSlot aria-invalid={fieldState.invalid || undefined} index={i} key={i} />
67
+ ))}
68
+ </InputOTPGroup>
69
+ </InputOTP>
70
+ </FormControl>
71
+ <FormMessage />
72
+ </FormItem>
73
+ )}
74
+ />
75
+ )
76
+ }
@@ -0,0 +1,77 @@
1
+ import { Control, FieldValues, Path } from 'react-hook-form'
2
+
3
+ import { cn } from '../../utils'
4
+ import { FormControl, FormField, FormItem, FormMessage } from '../form'
5
+ import { SelectInput } from '../select-input'
6
+ import { LabelField } from './label-field'
7
+
8
+ export interface Options {
9
+ label: string
10
+ value: number | string
11
+ }
12
+
13
+ interface SelectFieldProps<T extends FieldValues = FieldValues> {
14
+ ableDeleteOption?: boolean
15
+ className?: string
16
+ control: Control<T>
17
+ disabled?: boolean
18
+ label?: string
19
+ labelClassName?: string
20
+ mainClassName?: string
21
+ name: Path<T>
22
+ onChange?: (...event: unknown[]) => void
23
+ options: Options[] | undefined
24
+ placeholder?: string
25
+ search?: boolean
26
+ visualRequired?: boolean
27
+ }
28
+
29
+ export const SelectField = <T extends FieldValues = FieldValues>({
30
+ ableDeleteOption,
31
+ className,
32
+ control,
33
+ disabled,
34
+ label,
35
+ labelClassName,
36
+ mainClassName,
37
+ name,
38
+ options,
39
+ placeholder,
40
+ search,
41
+ visualRequired
42
+ }: SelectFieldProps<T>) => {
43
+ return (
44
+ <FormField
45
+ control={control}
46
+ name={name}
47
+ render={({ field, fieldState }) => {
48
+ const hasError = !!fieldState.error
49
+
50
+ return (
51
+ <FormItem className={cn('flex flex-col', mainClassName)}>
52
+ <LabelField
53
+ label={label}
54
+ labelClassName={cn(labelClassName, hasError && 'text-destructive')}
55
+ name={name}
56
+ visualRequired={visualRequired}
57
+ />
58
+ <FormControl>
59
+ <SelectInput
60
+ ableDeleteOption={ableDeleteOption}
61
+ className={className}
62
+ disabled={disabled}
63
+ error={fieldState.error}
64
+ items={options}
65
+ onChange={field.onChange}
66
+ placeholder={placeholder}
67
+ search={search}
68
+ value={typeof field.value === 'number' ? `${field.value}` : field.value}
69
+ />
70
+ </FormControl>
71
+ <FormMessage />
72
+ </FormItem>
73
+ )
74
+ }}
75
+ />
76
+ )
77
+ }
@@ -0,0 +1,138 @@
1
+ import * as LabelPrimitive from '@radix-ui/react-label'
2
+ import { Slot } from '@radix-ui/react-slot'
3
+ import * as React from 'react'
4
+ import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form'
5
+
6
+ import { cn } from '../utils'
7
+ import { Label } from './label'
8
+
9
+ const Form = FormProvider
10
+
11
+ interface FormFieldContextValue<
12
+ TFieldValues extends FieldValues = FieldValues,
13
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
14
+ > {
15
+ name: TName
16
+ }
17
+
18
+ const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
19
+
20
+ const FormField = <
21
+ TFieldValues extends FieldValues = FieldValues,
22
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
23
+ >({
24
+ ...props
25
+ }: ControllerProps<TFieldValues, TName>) => {
26
+ return (
27
+ <FormFieldContext.Provider value={{ name: props.name }}>
28
+ <Controller {...props} />
29
+ </FormFieldContext.Provider>
30
+ )
31
+ }
32
+
33
+ const useFormField = () => {
34
+ const fieldContext = React.useContext(FormFieldContext)
35
+ const itemContext = React.useContext(FormItemContext)
36
+ const { formState, getFieldState } = useFormContext()
37
+
38
+ if (!fieldContext) {
39
+ throw new Error('useFormField must be used within <FormField>')
40
+ }
41
+
42
+ if (!itemContext) {
43
+ throw new Error('useFormField must be used within <FormItem>')
44
+ }
45
+
46
+ const fieldState = getFieldState(fieldContext.name, formState)
47
+
48
+ const { id } = itemContext
49
+
50
+ return {
51
+ formDescriptionId: `${id}-form-item-description`,
52
+ formItemId: `${id}-form-item`,
53
+ formMessageId: `${id}-form-item-message`,
54
+ id,
55
+ name: fieldContext.name,
56
+ ...fieldState
57
+ }
58
+ }
59
+
60
+ interface FormItemContextValue {
61
+ id: string
62
+ }
63
+
64
+ const FormItemContext = React.createContext<FormItemContextValue | null>(null)
65
+
66
+ const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
67
+ ({ className, ...props }, ref) => {
68
+ const id = React.useId()
69
+
70
+ return (
71
+ <FormItemContext.Provider value={{ id }}>
72
+ <div className={cn('space-y-1', className)} ref={ref} {...props} />
73
+ </FormItemContext.Provider>
74
+ )
75
+ }
76
+ )
77
+ FormItem.displayName = 'FormItem'
78
+
79
+ const FormLabel = React.forwardRef<
80
+ React.ElementRef<typeof LabelPrimitive.Root>,
81
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
82
+ >(({ className, ...props }, ref) => {
83
+ const { error, formItemId } = useFormField()
84
+
85
+ return <Label className={cn(error && 'text-destructive', className)} htmlFor={formItemId} ref={ref} {...props} />
86
+ })
87
+ FormLabel.displayName = 'FormLabel'
88
+
89
+ const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
90
+ ({ ...props }, ref) => {
91
+ const { error, formDescriptionId, formItemId, formMessageId } = useFormField()
92
+
93
+ return (
94
+ <Slot
95
+ aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
96
+ aria-invalid={!!error}
97
+ id={formItemId}
98
+ ref={ref}
99
+ {...props}
100
+ />
101
+ )
102
+ }
103
+ )
104
+ FormControl.displayName = 'FormControl'
105
+
106
+ const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
107
+ ({ className, ...props }, ref) => {
108
+ const { formDescriptionId } = useFormField()
109
+
110
+ return <p className={cn('text-muted text-[0.8rem]', className)} id={formDescriptionId} ref={ref} {...props} />
111
+ }
112
+ )
113
+ FormDescription.displayName = 'FormDescription'
114
+
115
+ const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
116
+ ({ children, className, ...props }, ref) => {
117
+ const { error, formMessageId } = useFormField()
118
+ const body = error?.message ? String(error.message) : children
119
+
120
+ if (!body) {
121
+ return null
122
+ }
123
+
124
+ return (
125
+ <p
126
+ className={cn('text-destructive text-[0.8rem] font-medium', className)}
127
+ id={formMessageId}
128
+ ref={ref}
129
+ {...props}
130
+ >
131
+ {body}
132
+ </p>
133
+ )
134
+ }
135
+ )
136
+ FormMessage.displayName = 'FormMessage'
137
+
138
+ export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField }
@@ -0,0 +1,243 @@
1
+ import type { MapMouseEvent } from '@vis.gl/react-google-maps'
2
+
3
+ import { AdvancedMarker, APIProvider, Map, useMap, useMapsLibrary } from '@vis.gl/react-google-maps'
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+
6
+ import { cn, inputBase, inputFocus } from '../utils'
7
+
8
+ const DEFAULT_CENTER = { lat: 4.711, lng: -74.0721 }
9
+ const PICKER_MAP_ID = 'komensal-restaurant-map'
10
+ const SELECTED_ZOOM = 15
11
+ const DEBOUNCE_MS = 300
12
+
13
+ export interface GoogleMapPickerProps {
14
+ centerOn?: google.maps.LatLngLiteral
15
+ disabled?: boolean
16
+ hasError?: boolean
17
+ locationQuery?: string
18
+ locationZoom?: number
19
+ onChange: (address: string) => void
20
+ placeholder?: string
21
+ value: string
22
+ }
23
+
24
+ const CITY_ZOOM = 12
25
+
26
+ const MapPickerContent = ({
27
+ centerOn,
28
+ disabled,
29
+ hasError,
30
+ locationQuery,
31
+ locationZoom,
32
+ onChange,
33
+ placeholder,
34
+ value
35
+ }: GoogleMapPickerProps) => {
36
+ const placesLib = useMapsLibrary('places')
37
+ const geocodingLib = useMapsLibrary('geocoding')
38
+ const map = useMap(PICKER_MAP_ID)
39
+ const onChangeRef = useRef(onChange)
40
+ const geocoder = useMemo<google.maps.Geocoder | null>(
41
+ () => (geocodingLib ? new geocodingLib.Geocoder() : null),
42
+ [geocodingLib]
43
+ )
44
+ const debounceRef = useRef<null | ReturnType<typeof setTimeout>>(null)
45
+
46
+ const [inputValue, setInputValue] = useState(value)
47
+ const [suggestions, setSuggestions] = useState<google.maps.places.AutocompleteSuggestion[]>([])
48
+ const [showDropdown, setShowDropdown] = useState(false)
49
+ const [sessionToken, setSessionToken] = useState<google.maps.places.AutocompleteSessionToken | undefined>()
50
+ const [position, setPosition] = useState<google.maps.LatLngLiteral | null>(null)
51
+
52
+ useEffect(() => {
53
+ onChangeRef.current = onChange
54
+ })
55
+
56
+ useEffect(() => {
57
+ setInputValue(value)
58
+ }, [value])
59
+
60
+ useEffect(() => {
61
+ if (map && centerOn) {
62
+ map.panTo(centerOn)
63
+ map.setZoom(CITY_ZOOM)
64
+ }
65
+ }, [map, centerOn])
66
+
67
+ useEffect(() => {
68
+ if (map && position) {
69
+ map.panTo(position)
70
+ map.setZoom(SELECTED_ZOOM)
71
+ }
72
+ }, [map, position])
73
+
74
+ useEffect(() => {
75
+ if (!placesLib) return
76
+ setSessionToken(new placesLib.AutocompleteSessionToken())
77
+ }, [placesLib])
78
+
79
+ useEffect(() => {
80
+ if (!geocoder || !locationQuery || !map) return
81
+ const g = geocoder
82
+ const ctrl = { cancelled: false }
83
+ void (async () => {
84
+ try {
85
+ const { results } = await g.geocode({ address: locationQuery })
86
+ if (ctrl.cancelled || results.length === 0) return
87
+ const loc = results[0].geometry.location
88
+ map.panTo({ lat: loc.lat(), lng: loc.lng() })
89
+ map.setZoom(locationZoom ?? CITY_ZOOM)
90
+ } catch {
91
+ return
92
+ }
93
+ })()
94
+ return () => {
95
+ ctrl.cancelled = true
96
+ }
97
+ }, [geocoder, locationQuery, locationZoom, map])
98
+
99
+ const fetchSuggestions = useCallback(
100
+ async (input: string) => {
101
+ if (!placesLib || input.length < 2) {
102
+ setSuggestions([])
103
+ return
104
+ }
105
+ try {
106
+ const result = await placesLib.AutocompleteSuggestion.fetchAutocompleteSuggestions({
107
+ input,
108
+ sessionToken
109
+ })
110
+ setSuggestions(result.suggestions)
111
+ setShowDropdown(true)
112
+ } catch {
113
+ setSuggestions([])
114
+ }
115
+ },
116
+ [placesLib, sessionToken]
117
+ )
118
+
119
+ const reverseGeocode = useCallback(
120
+ async (latLng: google.maps.LatLngLiteral) => {
121
+ if (!geocoder) return
122
+ try {
123
+ const { results } = await geocoder.geocode({ location: latLng })
124
+ if (results.length > 0) {
125
+ const address = results[0].formatted_address
126
+ setInputValue(address)
127
+ onChangeRef.current(address)
128
+ }
129
+ } catch {
130
+ return
131
+ }
132
+ },
133
+ [geocoder]
134
+ )
135
+
136
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
137
+ const val = e.target.value
138
+ setInputValue(val)
139
+ onChangeRef.current(val)
140
+
141
+ if (debounceRef.current) clearTimeout(debounceRef.current)
142
+ debounceRef.current = setTimeout(() => void fetchSuggestions(val), DEBOUNCE_MS)
143
+ }
144
+
145
+ const handleSelect = async (suggestion: google.maps.places.AutocompleteSuggestion) => {
146
+ const prediction = suggestion.placePrediction
147
+ if (!prediction) return
148
+
149
+ const place = prediction.toPlace()
150
+ await place.fetchFields({ fields: ['formattedAddress', 'location'] })
151
+
152
+ const address = place.formattedAddress ?? prediction.text.text
153
+ setInputValue(address)
154
+ onChangeRef.current(address)
155
+ setSuggestions([])
156
+ setShowDropdown(false)
157
+
158
+ if (place.location) {
159
+ setPosition({ lat: place.location.lat(), lng: place.location.lng() })
160
+ }
161
+
162
+ if (placesLib) setSessionToken(new placesLib.AutocompleteSessionToken())
163
+ }
164
+
165
+ const handleBlur = () => {
166
+ setTimeout(() => setShowDropdown(false), 150)
167
+ }
168
+
169
+ const handleMapClick = useCallback(
170
+ (e: MapMouseEvent) => {
171
+ if (disabled || !e.detail.latLng) return
172
+ const latLng = e.detail.latLng
173
+ setPosition(latLng)
174
+ void reverseGeocode(latLng)
175
+ },
176
+ [disabled, reverseGeocode]
177
+ )
178
+
179
+ const handleMarkerDragEnd = useCallback(
180
+ (e: google.maps.MapMouseEvent) => {
181
+ const latLng = e.latLng
182
+ if (!latLng) return
183
+ const pos = { lat: latLng.lat(), lng: latLng.lng() }
184
+ setPosition(pos)
185
+ void reverseGeocode(pos)
186
+ },
187
+ [reverseGeocode]
188
+ )
189
+
190
+ return (
191
+ <div className='flex flex-col gap-3'>
192
+ <div className='relative'>
193
+ <input
194
+ className={cn(inputBase, inputFocus, hasError && 'border-destructive')}
195
+ disabled={disabled}
196
+ onBlur={handleBlur}
197
+ onChange={handleInputChange}
198
+ onFocus={() => suggestions.length > 0 && setShowDropdown(true)}
199
+ placeholder={placeholder}
200
+ type='text'
201
+ value={inputValue}
202
+ />
203
+
204
+ {showDropdown && suggestions.length > 0 && (
205
+ <ul className='border-border bg-surface absolute z-50 mt-1 w-full overflow-hidden rounded-[10px] border shadow-md'>
206
+ {suggestions.map((suggestion) => (
207
+ <li key={suggestion.placePrediction?.placeId ?? suggestion.placePrediction?.text.text}>
208
+ <button
209
+ className='text-foreground hover:bg-hover w-full px-4 py-3 text-left text-sm transition-colors'
210
+ onClick={() => void handleSelect(suggestion)}
211
+ type='button'
212
+ >
213
+ {suggestion.placePrediction?.text.text ?? ''}
214
+ </button>
215
+ </li>
216
+ ))}
217
+ </ul>
218
+ )}
219
+ </div>
220
+
221
+ <div className='border-border h-59 overflow-hidden rounded-[10px] border md:h-87.5'>
222
+ <Map
223
+ defaultCenter={DEFAULT_CENTER}
224
+ defaultZoom={5}
225
+ disableDefaultUI
226
+ gestureHandling='cooperative'
227
+ id={PICKER_MAP_ID}
228
+ mapId={(import.meta.env.VITE_GOOGLE_MAPS_MAP_ID as string | undefined) ?? 'DEMO_MAP_ID'}
229
+ onClick={handleMapClick}
230
+ style={{ height: '100%', width: '100%' }}
231
+ >
232
+ {position && <AdvancedMarker draggable={!disabled} onDragEnd={handleMarkerDragEnd} position={position} />}
233
+ </Map>
234
+ </div>
235
+ </div>
236
+ )
237
+ }
238
+
239
+ export const GoogleMapPicker = (props: GoogleMapPickerProps) => (
240
+ <APIProvider apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY ?? ''} language='es' region='CO'>
241
+ <MapPickerContent {...props} />
242
+ </APIProvider>
243
+ )
@@ -0,0 +1,40 @@
1
+ import * as React from 'react'
2
+
3
+ import { cn, inputBase, inputFocus } from '../utils'
4
+
5
+ export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
6
+
7
+ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
8
+ return (
9
+ <input
10
+ className={cn(
11
+ inputBase,
12
+ inputFocus,
13
+ 'file:border-0 file:bg-transparent file:text-base file:font-medium',
14
+ className
15
+ )}
16
+ onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
17
+ const key = e.key
18
+ const allowedKeys = ['Backspace', 'Tab', 'Enter', 'Escape', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown']
19
+ if (allowedKeys.includes(key)) {
20
+ return
21
+ }
22
+ if (!(key >= '0' && key <= '9') && type === 'number') {
23
+ e.preventDefault()
24
+ }
25
+ if (type === 'onlyText') {
26
+ const regex = /^[a-zA-ZáéíóúÁÉÍÓÚñÑ ]*$/
27
+ if (!regex.test(key)) {
28
+ e.preventDefault()
29
+ }
30
+ }
31
+ }}
32
+ ref={ref}
33
+ type={type}
34
+ {...props}
35
+ />
36
+ )
37
+ })
38
+ Input.displayName = 'Input'
39
+
40
+ export { Input }
@@ -0,0 +1,17 @@
1
+ import * as LabelPrimitive from '@radix-ui/react-label'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import * as React from 'react'
4
+
5
+ import { cn } from '../utils'
6
+
7
+ const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')
8
+
9
+ const Label = React.forwardRef<
10
+ React.ElementRef<typeof LabelPrimitive.Root>,
11
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
12
+ >(({ className, ...props }, ref) => (
13
+ <LabelPrimitive.Root className={cn(labelVariants(), className)} ref={ref} {...props} />
14
+ ))
15
+ Label.displayName = LabelPrimitive.Root.displayName
16
+
17
+ export { Label }
@@ -0,0 +1,19 @@
1
+ import { cn } from '../utils'
2
+ import { Spinner } from './spinner'
3
+
4
+ interface LoadingProps {
5
+ className?: string
6
+ isSpinner?: boolean
7
+ }
8
+
9
+ export function Loading({ className = '', isSpinner = true }: Readonly<LoadingProps>) {
10
+ return (
11
+ <div className={cn('relative flex min-h-screen items-center justify-center gap-6', className)}>
12
+ {isSpinner && (
13
+ <div className='absolute right-0 bottom-0 m-10'>
14
+ <Spinner />
15
+ </div>
16
+ )}
17
+ </div>
18
+ )
19
+ }