Skip to content

Instantly share code, notes, and snippets.

@kibolho
Last active November 10, 2024 09:09
Show Gist options
  • Save kibolho/8ab7c230b76ba3d6d1431f744a863e4b to your computer and use it in GitHub Desktop.
Save kibolho/8ab7c230b76ba3d6d1431f744a863e4b to your computer and use it in GitHub Desktop.
Address Form with Google Autocomplete and React Hook Form
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>
);
};
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>
);
};
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';
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';
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