shadimage
Shuip
uirhf-address-fieldsource: stale · stale

Rhf Address Field

npx shadcn add "https://shuip.plvo.dev/r/rhf-address-field.json"
free

This source snapshot is older than our freshness window and couldn't be re-verified just now — it may be outdated.

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
forms@hookform/lensesreact-hook-formzod
iconslucide-react

Registry dependencies

commandfieldinputpopover

Files2 · source cached 10/8/2026, 7:43:33 AM

./items/react-hook-form/address-field/component.tsx
'use client';

import type { Lens } from '@hookform/lenses';
import { Loader2, MapPin } from 'lucide-react';
import * as React from 'react';
import { useController } from 'react-hook-form';
import { z } from 'zod';
import { getPlaceDetails, getPlacesAutocomplete } from '@/actions/shuip/places';
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';

const DEFAULT_COUNTRY = 'US';
const LANGUAGE_RESULT = 'en';
const DEBOUNCE_TIME = 300;

export const addressSchema = z.object({
  street: z.string().min(1, 'Street is required'),
  city: z.string().min(1, 'City is required'),
  postalCode: z.string().min(1, 'Postal code is required'),
  country: z.string().min(1, 'Country is required'),
  fullAddress: z.string().min(1, 'Address is required'),
  placeId: z.string().optional(),
});

export type AddressData = z.infer<typeof addressSchema>;

interface AddressSuggestion {
  placeId: string;
  description: string;
  mainText: string;
  secondaryText: string;
  types: string[];
}

export interface AddressFieldProps extends Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'> {
  lens: Lens<AddressData>;
  label?: string;
  placeholder?: string;
  description?: string;
  country?: string;
}

export function AddressField({
  lens,
  label = 'Address',
  placeholder = 'Enter your address',
  description,
  country = DEFAULT_COUNTRY,
  ...props
}: AddressFieldProps) {
  const fullAddress = useController(lens.focus('fullAddress').interop());
  const street = useController(lens.focus('street').interop());
  const city = useController(lens.focus('city').interop());
  const postalCode = useController(lens.focus('postalCode').interop());
  const countryField = useController(lens.focus('country').interop());
  const placeId = useController(lens.focus('placeId').interop());

  const [searchQuery, setSearchQuery] = React.useState('');
  const [suggestions, setSuggestions] = React.useState<AddressSuggestion[]>([]);
  const [showSuggestions, setShowSuggestions] = React.useState(false);
  const [selectedIndex, setSelectedIndex] = React.useState(-1);
  const [isPending, startTransition] = React.useTransition();
  const debounceTimerRef = React.useRef<NodeJS.Timeout | null>(null);
  const requestIdRef = React.useRef(0);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const popoverRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    if (debounceTimerRef.current) {
      clearTimeout(debounceTimerRef.current);
    }

    if (searchQuery.length < 3) {
      setSuggestions([]);
      setShowSuggestions(false);
      return;
    }

    debounceTimerRef.current = setTimeout(() => {
      const requestId = ++requestIdRef.current;
      startTransition(async () => {
        try {
          const result = await getPlacesAutocomplete({
            input: searchQuery,
            components: country ? `country:${country}` : undefined,
            types: 'address',
            language: LANGUAGE_RESULT,
          });

          if (requestId !== requestIdRef.current) return;

          if (result.error) {
            throw new Error(result.error);
          }

          setSuggestions(result.predictions || []);
          setShowSuggestions(result.predictions?.length > 0);
          setSelectedIndex(-1);
        } catch (error) {
          if (requestId !== requestIdRef.current) return;
          console.error('Error searching addresses:', error);
          setSuggestions([]);
          setShowSuggestions(false);
        }
      });
    }, DEBOUNCE_TIME);

    return () => {
      if (debounceTimerRef.current) {
        clearTimeout(debounceTimerRef.current);
      }
      requestIdRef.current++;
    };
  }, [searchQuery, country]);

  const handleSelectAddress = (suggestion: AddressSuggestion) => {
    setShowSuggestions(false);
    setSelectedIndex(-1);
    setSearchQuery('');

    startTransition(async () => {
      const details = await getPlaceDetails({
        placeId: suggestion.placeId,
        fields: ['address_components', 'formatted_address', 'geometry'],
        language: LANGUAGE_RESULT,
      });

      if (details?.result) {
        const addressComponents = details.result.address_components || [];

        let streetValue = '';
        let cityValue = '';
        let postalCodeValue = '';
        let countryValue = '';

        addressComponents.forEach((component: any) => {
          const types = component.types;

          if (types.includes('street_number')) {
            streetValue = `${component.long_name} ${streetValue}`;
          }
          if (types.includes('route')) {
            streetValue = `${streetValue} ${component.long_name}`;
          }
          if (types.includes('locality') || types.includes('administrative_area_level_2')) {
            cityValue = component.long_name;
          }
          if (types.includes('postal_code')) {
            postalCodeValue = component.long_name;
          }
          if (types.includes('country')) {
            countryValue = component.long_name;
          }
        });

        street.field.onChange(streetValue.trim());
        city.field.onChange(cityValue.trim());
        postalCode.field.onChange(postalCodeValue.trim());
        countryField.field.onChange(countryValue.trim());
        fullAddress.field.onChange(details.result.formatted_address.trim());
        placeId.field.onChange(suggestion.placeId.trim());
      } else {
        fullAddress.field.onChange(suggestion.description.trim());
        placeId.field.onChange(suggestion.placeId.trim());
      }
    });
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (!showSuggestions || suggestions.length === 0) return;

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setSelectedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
        break;
      case 'Enter':
        e.preventDefault();
        if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
          handleSelectAddress(suggestions[selectedIndex]);
        }
        break;
      case 'Escape':
        e.preventDefault();
        setShowSuggestions(false);
        setSelectedIndex(-1);
        inputRef.current?.blur();
        break;
    }
  };

  const handleFocus = () => {
    if (suggestions.length > 0 && searchQuery.length >= 3) {
      setShowSuggestions(true);
    }
  };

  const handleBlur = (e: React.FocusEvent) => {
    const relatedTarget = e.relatedTarget as HTMLElement;
    if (popoverRef.current?.contains(relatedTarget)) {
      return;
    }

    fullAddress.field.onBlur();

    setTimeout(() => {
      setShowSuggestions(false);
      setSelectedIndex(-1);
    }, 150);
  };

  const fullAddressInvalid = fullAddress.fieldState.invalid;
  const id = props.id ?? fullAddress.field.name;

  return (
    <Field className='gap-2' data-invalid={fullAddressInvalid}>
      <FieldLabel htmlFor={id} className='flex items-center justify-between'>
        {label}
        {fullAddressInvalid && (
          <FieldError className='max-sm:hidden text-xs opacity-80' errors={[fullAddress.fieldState.error]} />
        )}
      </FieldLabel>
      <div className='relative'>
        <Popover open={showSuggestions} onOpenChange={setShowSuggestions}>
          <PopoverTrigger asChild>
            <div className='relative'>
              <Input
                ref={inputRef}
                name={fullAddress.field.name}
                value={fullAddress.field.value ?? ''}
                placeholder={placeholder}
                autoComplete='off'
                {...props}
                id={id}
                onChange={(e) => {
                  const value = e.target.value;
                  fullAddress.field.onChange(value);
                  setSearchQuery(value);
                }}
                onFocus={handleFocus}
                onBlur={handleBlur}
                onKeyDown={handleKeyDown}
                aria-invalid={fullAddressInvalid}
              />
              <div className='absolute inset-y-0 right-0 flex items-center pr-3'>
                {isPending ? (
                  <Loader2 className='size-4 animate-spin text-muted-foreground' />
                ) : (
                  <MapPin className='size-4 text-muted-foreground' />
                )}
              </div>
            </div>
          </PopoverTrigger>
          <PopoverContent
            ref={popoverRef}
            className='p-0'
            align='start'
            onOpenAutoFocus={(e) => e.preventDefault()}
            style={{ width: 'var(--radix-popover-trigger-width)' }}
          >
            <Command className='w-full'>
              <CommandList className='max-h-60'>
                <CommandEmpty>{isPending ? 'Searching...' : 'No addresses found'}</CommandEmpty>
                <CommandGroup>
                  {suggestions.map((suggestion, index) => (
                    <CommandItem
                      key={suggestion.placeId}
                      value={suggestion.description}
                      onSelect={() => handleSelectAddress(suggestion)}
                      className={cn(
                        'flex items-start space-x-2 p-3 cursor-pointer',
                        selectedIndex === index && 'bg-accent',
                      )}
                    >
                      <MapPin className='size-4 mt-0.5 text-muted-foreground flex-shrink-0' />
                      <div className='flex-1 min-w-0'>
                        <div className='font-medium text-sm'>{suggestion.mainText}</div>
                        <div className='text-xs text-muted-foreground truncate'>{suggestion.secondaryText}</div>
                      </div>
                    </CommandItem>
                  ))}
                </CommandGroup>
              </CommandList>
            </Command>
          </PopoverContent>
        </Popover>
      </div>
      {description && <p className='text-sm text-muted-foreground'>{description}</p>}
      {fullAddressInvalid && (
        <FieldError className='sm:hidden text-xs text-left opacity-80' errors={[fullAddress.fieldState.error]} />
      )}
    </Field>
  );
}
./items/react-hook-form/address-field/extras/places.action.ts
'use server';

const GOOGLE_PLACES_API_KEY = process.env.GOOGLE_PLACES_API_KEY;

export interface AutocompleteParams {
  input: string;
  components?: string;
  types?: string;
  language?: string;
}

export async function getPlacesAutocomplete({ input, components, types, language = 'fr' }: AutocompleteParams) {
  try {
    if (!GOOGLE_PLACES_API_KEY) {
      throw new Error('Google Places API key not configured');
    }

    if (!input || input.length < 3) {
      return { predictions: [] };
    }

    // Construire l'URL de l'API Google Places Autocomplete
    const params = new URLSearchParams({
      input,
      key: GOOGLE_PLACES_API_KEY,
      language,
    });

    if (components) {
      params.append('components', components);
    }

    if (types) {
      params.append('types', types);
    }

    const response = await fetch(`https://maps.googleapis.com/maps/api/place/autocomplete/json?${params}`, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    });

    if (!response.ok) {
      throw new Error(`Google Places API error: ${response.status}`);
    }

    const data = await response.json();

    if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
      throw new Error(`Google Places API error: ${data.status}`);
    }

    // Transformer les données pour notre format
    const predictions =
      data.predictions?.map((prediction: any) => ({
        placeId: prediction.place_id,
        description: prediction.description,
        mainText: prediction.structured_formatting?.main_text || prediction.description,
        secondaryText: prediction.structured_formatting?.secondary_text || '',
        types: prediction.types || [],
      })) || [];

    return { predictions };
  } catch (error) {
    return { error: error instanceof Error ? error.message : 'Internal server error', predictions: [] };
  }
}

export interface PlaceDetailsParams {
  placeId: string;
  fields?: string[];
  language?: string;
}

export async function getPlaceDetails({ placeId, fields, language = 'fr' }: PlaceDetailsParams) {
  try {
    if (!GOOGLE_PLACES_API_KEY) {
      throw new Error('Google Places API key not configured');
    }

    if (!placeId) {
      throw new Error('Place ID is required');
    }

    const params = new URLSearchParams({
      place_id: placeId,
      key: GOOGLE_PLACES_API_KEY,
      language,
    });

    if (fields && Array.isArray(fields)) {
      params.append('fields', fields.join(','));
    }

    const response = await fetch(`https://maps.googleapis.com/maps/api/place/details/json?${params}`, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    });

    if (!response.ok) {
      throw new Error(`Google Places API error: ${response.status}`);
    }

    const data = await response.json();

    if (data.status !== 'OK') {
      throw new Error(`Google Places API error: ${data.status}`);
    }

    return data;
  } catch (error) {
    return { error: error instanceof Error ? error.message : 'Internal server error' };
  }
}

More from Shuip

Browse all components in this library →