Last active
November 10, 2024 09:09
-
-
Save kibolho/8ab7c230b76ba3d6d1431f744a863e4b to your computer and use it in GitHub Desktop.
Address Form with Google Autocomplete and React Hook Form
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { zodResolver } from '@hookform/resolvers/zod'; | |
import { Input } from './Input'; | |
import { Controller, useForm } from 'react-hook-form'; | |
import { z } from 'zod'; | |
const addressSchema = z.object({ | |
id: z.string().optional(), | |
party_id: z.string().nonempty('Party is required'), | |
address_type: z.nativeEnum(AddressType), | |
address_line_1: z.string().nonempty('First Line is required').min(3, 'First Line is too short'), | |
city: z.string().nonempty('City is required').min(3, 'City is too short'), | |
region: z.string().nonempty('Region is required').min(2, 'Region is too short'), | |
postal_code: z.string().nonempty('Postal code is required').min(3, 'Postal code is too short'), | |
country: z.string().nonempty('Country is required'), | |
}); | |
export type AddressFields = z.infer<typeof addressSchema>; | |
type RequiredProps = { | |
onSubmit: (fields: AddressFields) => void; | |
}; | |
type OptionalProps = { | |
defaultValues?: Partial<AddressFields>; | |
disableAutoComplete?: boolean; | |
}; | |
type Props = RequiredProps & OptionalProps; | |
export const AddressForm: React.FC<Props> = ({ | |
onSubmit, | |
defaultValues = {}, | |
disableAutoComplete = false, | |
}) => { | |
const { | |
control, | |
register, | |
setValue, | |
handleSubmit, | |
formState: { errors }, | |
} = useForm<AddressFields>({ | |
mode: 'all', | |
defaultValues, | |
resolver: zodResolver(addressSchema), | |
}); | |
return ( | |
<form id="address-form" className="divide-y divide-gray-200" onSubmit={handleSubmit(onSubmit)}> | |
<div className="space-y-6 pb-5"> | |
<div> | |
<Input | |
required | |
id="address_line_1" | |
type="text" | |
label="Line 1" | |
helperText={errors.address_line_1?.message} | |
status={errors.address_line_1 ? 'error' : undefined} | |
{...register('address_line_1')} | |
/> | |
</div> | |
<div> | |
<Input | |
required | |
type="text" | |
label="City" | |
helperText={errors.city?.message} | |
status={errors.city ? 'error' : undefined} | |
{...register('city')} | |
/> | |
</div> | |
<div> | |
<Input | |
required | |
type="text" | |
label="Region" | |
helperText={errors.region?.message} | |
status={errors.region ? 'error' : undefined} | |
{...register('region')} | |
/> | |
</div> | |
<div> | |
<Input | |
required | |
type="text" | |
label="Postal Code" | |
helperText={errors.postal_code?.message} | |
status={errors.postal_code ? 'error' : undefined} | |
{...register('postal_code')} | |
/> | |
</div> | |
<div> | |
<Controller | |
name="country" | |
control={control} | |
render={({ field }) => ( | |
<Input | |
required | |
type="text" | |
label="Country" | |
helperText={errors.country?.message} | |
status={errors.country ? 'error' : undefined} | |
{...register('country')} | |
/> | |
)} | |
/> | |
</div> | |
</div> | |
</form> | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { zodResolver } from '@hookform/resolvers/zod'; | |
import { Input } from './Input'; | |
import { Controller, useForm } from 'react-hook-form'; | |
import { z } from 'zod'; | |
import { useGoogleAutocomplete } from './useGoogleAutocomplete'; | |
const addressSchema = z.object({ | |
id: z.string().optional(), | |
party_id: z.string().nonempty('Party is required'), | |
address_type: z.nativeEnum(AddressType), | |
address_line_1: z.string().nonempty('First Line is required').min(3, 'First Line is too short'), | |
city: z.string().nonempty('City is required').min(3, 'City is too short'), | |
region: z.string().nonempty('Region is required').min(2, 'Region is too short'), | |
postal_code: z.string().nonempty('Postal code is required').min(3, 'Postal code is too short'), | |
country: z.string().nonempty('Country is required'), | |
}); | |
export type AddressFields = z.infer<typeof addressSchema>; | |
type RequiredProps = { | |
onSubmit: (fields: AddressFields) => void; | |
}; | |
type OptionalProps = { | |
defaultValues?: Partial<AddressFields>; | |
disableAutoComplete?: boolean; | |
}; | |
type Props = RequiredProps & OptionalProps; | |
export const AddressForm: React.FC<Props> = ({ | |
onSubmit, | |
defaultValues = {}, | |
disableAutoComplete = false, | |
}) => { | |
const { | |
control, | |
register, | |
setValue, | |
handleSubmit, | |
formState: { errors }, | |
} = useForm<AddressFields>({ | |
mode: 'all', | |
defaultValues, | |
resolver: zodResolver(addressSchema), | |
}); | |
useGoogleAutocomplete({ | |
setFirstLine: (firstLine) => setValue('address_line_1', firstLine, { shouldValidate: true }), | |
setCity: (city) => setValue('city', city.long_name, { shouldValidate: true }), | |
setState: (state) => setValue('region', state.long_name, { shouldValidate: true }), | |
setCountry: (country) => | |
setValue('country', country.short_name, { | |
shouldValidate: true, | |
}), | |
setPostalCode: (postalCode) => | |
setValue('postal_code', postalCode.long_name, { shouldValidate: true }), | |
disabled: disableAutoComplete, | |
}); | |
return ( | |
<form id="address-form" className="divide-y divide-gray-200" onSubmit={handleSubmit(onSubmit)}> | |
<div className="space-y-6 pb-5"> | |
<div> | |
<Input | |
required | |
id="address_line_1" | |
type="text" | |
label="Line 1" | |
helperText={errors.address_line_1?.message} | |
status={errors.address_line_1 ? 'error' : undefined} | |
{...register('address_line_1')} | |
/> | |
</div> | |
<div> | |
<Input | |
required | |
type="text" | |
label="City" | |
helperText={errors.city?.message} | |
status={errors.city ? 'error' : undefined} | |
{...register('city')} | |
/> | |
</div> | |
<div> | |
<Input | |
required | |
type="text" | |
label="Region" | |
helperText={errors.region?.message} | |
status={errors.region ? 'error' : undefined} | |
{...register('region')} | |
/> | |
</div> | |
<div> | |
<Input | |
required | |
type="text" | |
label="Postal Code" | |
helperText={errors.postal_code?.message} | |
status={errors.postal_code ? 'error' : undefined} | |
{...register('postal_code')} | |
/> | |
</div> | |
<div> | |
<Controller | |
name="country" | |
control={control} | |
render={({ field }) => ( | |
<Input | |
required | |
type="text" | |
label="Country" | |
helperText={errors.country?.message} | |
status={errors.country ? 'error' : undefined} | |
{...register('country')} | |
/> | |
)} | |
/> | |
</div> | |
</div> | |
</form> | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ExclamationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/20/solid'; | |
import { cva, cx } from 'class-variance-authority'; | |
import { ForwardedRef, forwardRef, InputHTMLAttributes, ReactNode, useState } from 'react'; | |
import { Text } from './Text'; | |
const getWrapperVariant = cva( | |
'form-input flex shadow-sm w-full py-0.5 rounded-md border-0 ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 bg-base-100 dark:bg-dark-base-100', | |
{ | |
variants: { | |
status: { | |
undefined: 'ring-gray-300 focus:ring-primary focus-within:ring-primary', | |
error: 'ring-danger focus:ring-danger focus-within:ring-danger', | |
warning: 'ring-warning focus:ring-warning focus-within:ring-warning', | |
}, | |
hasAddon: { | |
true: 'divide-x pr-0', | |
}, | |
isAddonFocused: { | |
false: 'focus-within:ring-2 focus-within:ring-inset', | |
true: 'divide-transparent focus-within:ring-1 focus-within:ring-inset focus-within:ring-gray-300', | |
}, | |
hidden: { | |
true: 'hidden', | |
}, | |
}, | |
}, | |
); | |
const getInputVariant = cva( | |
'border-0 w-full py-1 py-0 bg-transparent [color-scheme:light] dark:[color-scheme:dark] sm:text-sm sm:leading-6 focus:border-0 focus:outline-none focus:ring-0', | |
{ | |
variants: { | |
status: { | |
error: 'text-danger-content placeholder-danger/75', | |
warning: 'text-warning-content placeholder-warning/75', | |
undefined: | |
'text-base-content dark:text-dark-base-content placeholder-text-base-content/75 placeholder-text-dark-base-content/75', | |
}, | |
hasAddon: { | |
false: 'px-0', | |
}, | |
hasPrefixOrSuffix: { | |
true: 'px-0', | |
}, | |
editable: { | |
false: 'caret-transparent', | |
}, | |
}, | |
compoundVariants: [{ hasAddon: true, hasPrefixOrSuffix: false, className: 'px-3' }], | |
}, | |
); | |
const getStatusColorVariant = cva('', { | |
variants: { | |
status: { | |
error: 'text-danger-content', | |
warning: 'text-warning-content', | |
undefined: 'text-base-content dark:text-dark-base-content', | |
}, | |
isReactNode: { | |
true: 'h-5 w-5', | |
}, | |
}, | |
compoundVariants: [{ status: undefined, isReactNode: true, className: 'text-gray-400' }], | |
}); | |
const getHelperTextVariant = cva('mt-2 text-sm', { | |
variants: { | |
status: { | |
error: 'text-danger-content', | |
warning: 'text-warning-content', | |
undefined: 'text-base-content dark:text-dark-base-content', | |
}, | |
}, | |
}); | |
const getStatusIconVariant = cva('h-5 w-5', { | |
variants: { | |
status: { | |
error: 'text-danger-content', | |
warning: 'text-warning-content', | |
}, | |
}, | |
}); | |
function InputRoot( | |
{ | |
id, | |
type, | |
label, | |
value, | |
status, | |
prefix, | |
suffix, | |
required, | |
className, | |
helperText, | |
placeholder, | |
editable = true, | |
optionalMarkText = 'optional', | |
...rest | |
}: InputProps, | |
ref?: ForwardedRef<HTMLInputElement>, | |
) { | |
const hasPrefixOrSuffix = !!prefix || !!suffix; | |
return ( | |
<div className={className}> | |
{label && ( | |
<label htmlFor={id} className="flex justify-between items-center mb-1"> | |
<Text as="span" size="sm" weight="medium"> | |
{label} | |
</Text> | |
{!required && ( | |
<Text intent="dimmed" as="span" size="xs" className="ml-2"> | |
({optionalMarkText}) | |
</Text> | |
)} | |
</label> | |
)} | |
<div | |
className={getWrapperVariant({ | |
status, | |
hasAddon, | |
isAddonFocused, | |
hidden: type === 'hidden', | |
})} | |
> | |
<div className="w-full flex"> | |
{prefix && ( | |
<div | |
className={'whitespace-nowrap select-none flex items-center pr-1'} | |
> | |
{typeof prefix === 'string' ? ( | |
<span className={cx('sm:text-sm', getStatusColorVariant({ status }))}> | |
{prefix} | |
</span> | |
) : ( | |
<div className="pr-2"> | |
<div className={getStatusColorVariant({ status, isReactNode: true })}> | |
{prefix} | |
</div> | |
</div> | |
)} | |
</div> | |
)} | |
<input | |
id={id} | |
ref={ref} | |
type={type} | |
value={value} | |
placeholder={placeholder} | |
className={getInputVariant({ | |
status, | |
hasAddon, | |
editable, | |
hasPrefixOrSuffix, | |
})} | |
{...rest} | |
/> | |
{suffix && ( | |
<div className="whitespace-nowrap select-none flex items-center pl-3"> | |
{typeof suffix === 'string' ? ( | |
<span className={cx('sm:text-sm', getStatusColorVariant({ status }))}> | |
{suffix} | |
</span> | |
) : ( | |
<div className={getStatusColorVariant({ status, isReactNode: true })}>{suffix}</div> | |
)} | |
</div> | |
)} | |
{status && ( | |
<div className="pointer-events-none select-none flex items-center pl-2"> | |
{status === 'warning' ? ( | |
<ExclamationTriangleIcon | |
aria-hidden="true" | |
className={getStatusIconVariant({ status })} | |
/> | |
) : ( | |
<ExclamationCircleIcon | |
aria-hidden="true" | |
className={getStatusIconVariant({ status })} | |
/> | |
)} | |
</div> | |
)} | |
</div> | |
</div> | |
{helperText && ( | |
<p className={getHelperTextVariant({ status })} id={`${name}-helper-text`}> | |
{helperText} | |
</p> | |
)} | |
</div> | |
); | |
} | |
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> { | |
id?: string; | |
type?: string; | |
name?: string; | |
label?: string; | |
className?: string; | |
placeholder?: string; | |
status?: 'error' | 'warning'; | |
helperText?: string; | |
optionalMarkText?: string; | |
prefix?: string | ReactNode; | |
suffix?: string | ReactNode; | |
editable?: boolean; | |
} | |
export const Input = forwardRef(InputRoot); | |
Input.displayName = 'Input'; | |
InputRoot.displayName = 'InputRoot'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ElementType, HTMLAttributes, ReactNode } from 'react'; | |
import { cva, cx, VariantProps } from 'class-variance-authority'; | |
const getVariant = cva('', { | |
variants: { | |
size: { | |
xs: 'text-xs', | |
sm: 'text-sm', | |
md: 'text-md', | |
lg: 'text-lg', | |
xl: 'text-xl', | |
'2xl': 'text-2xl', | |
'4xl': 'text-4xl', | |
'5xl': 'text-5xl', | |
'6xl': 'text-6xl', | |
'7xl': 'text-7xl', | |
'8xl': 'text-8xl', | |
'9xl': 'text-9xl', | |
}, | |
weight: { | |
thin: 'font-thin', | |
extralight: 'font-extralight', | |
light: 'font-light', | |
normal: 'font-normal', | |
medium: 'font-medium', | |
semibold: 'font-semibold', | |
bold: 'font-bold', | |
extrabold: 'font-extrabold', | |
black: 'font-black', | |
}, | |
lineHeight: { | |
none: 'leading-none', | |
tight: 'leading-tight', | |
snug: 'leading-snug', | |
normal: 'leading-normal', | |
relaxed: 'leading-relaxed', | |
loose: 'leading-loose', | |
}, | |
transform: { | |
normal: 'normal-case', | |
uppercase: 'uppercase', | |
lowercase: 'lowercase', | |
capitalize: 'capitalize', | |
}, | |
intent: { | |
default: 'text-base-content dark:text-dark-base-content', | |
primary: 'text-primary-content', | |
secondary: 'text-secondary-content', | |
info: 'text-info-content', | |
warning: 'text-warning-content', | |
success: 'text-success-content', | |
danger: 'text-danger-content', | |
dimmed: 'text-base-content/50 dark:text-dark-base-content/60', | |
}, | |
}, | |
defaultVariants: { | |
size: 'md', | |
intent: 'default', | |
weight: 'normal', | |
transform: 'normal', | |
lineHeight: 'normal', | |
}, | |
}); | |
export function Text({ | |
size, | |
weight, | |
children, | |
className, | |
lineHeight, | |
as = 'p', | |
intent = 'default', | |
transform = 'normal', | |
...props | |
}: TextProps) { | |
const Component = as; | |
return ( | |
<Component | |
className={cx(getVariant({ intent, size, weight, lineHeight, transform }), className)} | |
{...props} | |
> | |
{children} | |
</Component> | |
); | |
} | |
export type TextIntent = | |
| 'default' | |
| 'primary' | |
| 'secondary' | |
| 'info' | |
| 'warning' | |
| 'success' | |
| 'danger' | |
| 'dimmed'; | |
export interface TextProps | |
extends VariantProps<typeof getVariant>, | |
HTMLAttributes<HTMLParagraphElement | HTMLSpanElement | HTMLTimeElement | HTMLAnchorElement> { | |
children: ReactNode; | |
as?: ElementType; | |
dateTime?: string; | |
href?: string; | |
intent?: TextIntent; | |
} | |
Text.displayName = 'Text'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect, useRef } from 'react'; | |
interface AddressComponent { | |
long_name: string; | |
short_name: string; | |
} | |
const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY | |
export const useGoogleAutocomplete = ({ | |
setFirstLine, | |
setStreet, | |
setStreetNumber, | |
setComplement, | |
setCity, | |
setState, | |
setCountry, | |
setPostalCode, | |
inputId = 'address_line_1', | |
disabled = false, | |
}: { | |
setFirstLine?: (firstLine: string) => void; | |
setStreet?: (street: AddressComponent) => void; | |
setStreetNumber?: (streetNumber: AddressComponent) => void; | |
setComplement?: (complement: AddressComponent) => void; | |
setCity?: (city: AddressComponent) => void; | |
setState?: (state: AddressComponent) => void; | |
setCountry?: (country: AddressComponent) => void; | |
setPostalCode?: (postalCode: AddressComponent) => void; | |
disabled?: boolean; | |
inputId?: string; | |
}) => { | |
const autocompleteRef = useRef<google.maps.places.Autocomplete | null>(null); | |
//on mount, load google auto complete | |
useEffect(() => { | |
const renderGoogle = () => { | |
autocompleteRef.current = new window.google.maps.places.Autocomplete( | |
document.getElementById(inputId) as HTMLInputElement, | |
{}, | |
); | |
const handlePlaceSelect = () => { | |
const place = autocompleteRef.current?.getPlace(); | |
let street = ''; | |
let streetNumber = ''; | |
let complement = ''; | |
for (const component of place?.address_components ?? []) { | |
const type = component.types[0]; | |
switch (type) { | |
case 'street_number': | |
streetNumber = component.long_name; | |
setStreetNumber?.(component); | |
break; | |
case 'premise': | |
complement = component.long_name; | |
setComplement?.(component); | |
break; | |
case 'route': | |
street = component.long_name; | |
setStreet?.(component); | |
break; | |
case 'postal_town': | |
setPostalCode?.(component); | |
break; | |
case 'administrative_area_level_2': | |
setCity?.(component); | |
break; | |
case 'administrative_area_level_1': | |
setState?.(component); | |
break; | |
case 'neighborhood': | |
break; | |
case 'country': | |
setCountry?.(component); | |
break; | |
case 'postal_code': | |
setPostalCode?.(component); | |
break; | |
default: | |
console.log('irrelevant component type'); | |
break; | |
} | |
let firstLine = streetNumber ? streetNumber : street; | |
if (streetNumber && street) { | |
firstLine = `${streetNumber}, ${street}`; | |
} | |
firstLine = firstLine ? firstLine : complement; | |
if (firstLine && complement) { | |
firstLine = `${firstLine} - ${complement}`; | |
} | |
setFirstLine?.(firstLine); | |
} | |
}; | |
//listen for place change in input field | |
autocompleteRef.current.addListener('place_changed', handlePlaceSelect); | |
}; | |
//if places script is already found then listen for load and then renderGoogle | |
let found = document.getElementById('placesScript') ? true : false; | |
if (!disabled) { | |
if (!found && config.googleMapsApiKey) { | |
const script = document.createElement('script'); | |
script.id = 'placesScript'; | |
script.src = | |
'https://maps.googleapis.com/maps/api/js?key=' + | |
GOOGLE_MAPS_API_KEY + | |
'&libraries=places'; | |
script.async = true; | |
script.onload = () => renderGoogle(); | |
document.body.appendChild(script); | |
} | |
if (found) { | |
document.getElementById('placesScript')?.addEventListener('load', renderGoogle); | |
} | |
} | |
}, [ | |
inputId, | |
config.googleMapsApiKey, | |
setFirstLine, | |
setStreetNumber, | |
setComplement, | |
setStreet, | |
setPostalCode, | |
setCity, | |
setState, | |
setCountry, | |
disabled, | |
]); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment