feat(forms): add PhoneInput and unify module form validation UX

This commit is contained in:
2026-06-23 16:28:28 +05:30
parent 27cf5c9d3f
commit 066374bebc
30 changed files with 1415 additions and 229 deletions

View File

@@ -37,7 +37,7 @@ export function FormField({
</div>
{children}
{error ? (
<p className={cn('-mt-1 text-sm text-destructive', errorClassName)}>
<p className={cn('-mt-1 text-sm text-destructive normal-case', errorClassName)}>
{error}
</p>
) : null}

View File

@@ -1,6 +1,7 @@
export { FormField } from './FormField';
export { MultiSelectPopover } from './MultiSelectPopover';
export type { MultiSelectOption } from './MultiSelectPopover';
export { PhoneInput } from './phone-input/PhoneInput';
export { SelectPopover } from './SelectPopover';
export type { SelectPopoverOption } from './SelectPopover';
export { PasswordField } from './PasswordField';

View File

@@ -0,0 +1,137 @@
'use client';
import { useCallback, useRef, useState, type RefObject } from 'react';
import type { CountryCode } from 'libphonenumber-js';
import type { Virtualizer } from '@tanstack/react-virtual';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxList,
ComboboxTrigger,
} from '@/components/ui/combobox';
import {
COUNTRY_OPTIONS,
filterCountryOption,
} from './countries';
import { CountryFlag } from './CountryFlag';
import type { CountryOption } from './types';
import { VirtualCountryList } from './VirtualCountryList';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
interface CountryComboboxProps {
value: CountryCode;
onValueChange: (country: CountryCode) => void;
disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
}
export function CountryCombobox({
value,
onValueChange,
disabled = false,
portalContainer,
}: CountryComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
const selectedOption =
COUNTRY_OPTIONS.find((option) => option.value === value) ?? null;
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) {
setSearch('');
}
}, []);
const handleValueChange = useCallback(
(item: CountryOption | null) => {
if (item?.value) {
onValueChange(item.value);
}
},
[onValueChange],
);
const handleItemHighlighted = useCallback(
(
_item: CountryOption | undefined,
event: { reason: string; index: number },
) => {
const virtualizer = virtualizerRef.current;
if (!virtualizer) {
return;
}
const { reason, index } = event;
const isStart = index === 0;
const isEnd = index === virtualizer.options.count - 1;
const shouldScroll =
reason === 'none' || (reason === 'keyboard' && (isStart || isEnd));
if (shouldScroll) {
queueMicrotask(() => {
virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' });
});
}
},
[],
);
return (
<Combobox
virtualized
items={COUNTRY_OPTIONS}
value={selectedOption}
onValueChange={handleValueChange}
open={open}
onOpenChange={handleOpenChange}
inputValue={search}
onInputValueChange={setSearch}
filter={filterCountryOption}
disabled={disabled}
itemToStringLabel={(item) => item.label}
isItemEqualToValue={(item, currentValue) =>
item.value === currentValue.value
}
onItemHighlighted={handleItemHighlighted}
>
<ComboboxTrigger
render={
<Button
type="button"
variant="ghost"
disabled={disabled}
className="h-9 shrink-0 gap-1 rounded-none border-r px-2.5 shadow-none hover:bg-transparent"
>
<CountryFlag country={value} />
</Button>
}
/>
<ComboboxContent
container={portalContainer}
align="start"
side="bottom"
sideOffset={4}
className="w-72 min-w-72 max-w-72"
>
<ComboboxInput
showTrigger={false}
placeholder="Search country"
disabled={disabled}
/>
<ComboboxEmpty>No countries found.</ComboboxEmpty>
<ComboboxList className="p-0">
<VirtualCountryList open={open} virtualizerRef={virtualizerRef} />
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}

View File

@@ -0,0 +1,15 @@
import type { CountryCode } from 'libphonenumber-js';
import { cn } from '@/lib/utils';
export function CountryFlag({ country }: { country: CountryCode }) {
return (
<div
aria-hidden
className={cn(
'flag inline-block h-3.75 w-5.5 shrink-0 rounded-[2px] bg-size-[100%_auto]',
`flag-${country.toLowerCase()}`,
)}
/>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { useEffect, useState, type RefObject } from 'react';
import {
parsePhoneNumberFromString,
type CountryCode,
} from 'libphonenumber-js';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { buildPhoneValue, getNationalValue } from './countries';
import { CountryCombobox } from './CountryCombobox';
interface PhoneInputProps {
value?: string;
onChange: (value: string) => void;
onBlur?: () => void;
disabled?: boolean;
'aria-invalid'?: boolean;
placeholder?: string;
defaultCountry?: CountryCode;
portalContainer?: RefObject<HTMLDivElement | null>;
}
export function PhoneInput({
value = '',
onChange,
onBlur,
disabled = false,
'aria-invalid': ariaInvalid,
placeholder = 'Enter phone number',
defaultCountry = 'IN',
portalContainer,
}: PhoneInputProps) {
const parsedCountry = parsePhoneNumberFromString(value)?.country;
const [country, setCountry] = useState<CountryCode>(
parsedCountry ?? defaultCountry,
);
useEffect(() => {
const parsed = parsePhoneNumberFromString(value);
if (parsed?.country) {
setCountry(parsed.country);
}
}, [value]);
const nationalValue = getNationalValue(value, country);
function handleCountryChange(nextCountry: CountryCode) {
setCountry(nextCountry);
onChange(buildPhoneValue(nextCountry, nationalValue));
}
function handleNationalChange(nextNationalValue: string) {
onChange(buildPhoneValue(country, nextNationalValue));
}
return (
<div
className={cn(
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30',
ariaInvalid &&
'border-destructive focus-within:border-destructive focus-within:ring-destructive/20',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<CountryCombobox
value={country}
onValueChange={handleCountryChange}
disabled={disabled}
portalContainer={portalContainer}
/>
<Input
type="tel"
value={nationalValue}
onChange={(event) => handleNationalChange(event.target.value)}
onBlur={onBlur}
disabled={disabled}
aria-invalid={ariaInvalid}
placeholder={placeholder}
className="h-9 flex-1 rounded-none border-0 bg-transparent px-3 py-1 shadow-none focus-visible:ring-0"
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import {
useCallback,
useImperativeHandle,
useRef,
type RefObject,
} from 'react';
import { Combobox } from '@base-ui/react/combobox';
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
import { ComboboxItem } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import { CountryFlag } from './CountryFlag';
import type { CountryOption } from './types';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
interface VirtualCountryListProps {
open: boolean;
virtualizerRef: RefObject<VirtualizerHandle | null>;
estimateSize?: number;
}
export function VirtualCountryList({
open,
virtualizerRef,
estimateSize = 36,
}: VirtualCountryListProps) {
const filteredItems = Combobox.useFilteredItems<CountryOption>();
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
enabled: open,
count: filteredItems.length,
getScrollElement: () => scrollElementRef.current,
estimateSize: () => estimateSize,
overscan: 8,
paddingStart: 4,
paddingEnd: 4,
});
useImperativeHandle(virtualizerRef, () => virtualizer, [virtualizer]);
const handleScrollElementRef = useCallback(
(element: HTMLDivElement | null) => {
scrollElementRef.current = element;
if (element) {
virtualizer.measure();
}
},
[virtualizer],
);
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
if (!filteredItems.length) {
return null;
}
return (
<div
ref={handleScrollElementRef}
className={cn(
'max-h-[min(21.75rem,calc(var(--available-height)-2.25rem))] overflow-y-auto overscroll-contain p-1',
)}
style={{ height: Math.min(totalSize + 8, 348) }}
>
<div className="relative w-full" style={{ height: totalSize }}>
{virtualItems.map((virtualItem) => {
const item = filteredItems[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ComboboxItem
value={item}
index={virtualItem.index}
aria-setsize={filteredItems.length}
aria-posinset={virtualItem.index + 1}
className="gap-2"
>
<CountryFlag country={item.value} />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className="mr-4 shrink-0 tabular-nums text-muted-foreground">
+{item.callingCode}
</span>
</ComboboxItem>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import {
getCountries,
getCountryCallingCode,
parsePhoneNumberFromString,
type CountryCode,
} from 'libphonenumber-js';
import type { CountryOption } from './types';
const regionNames =
typeof Intl !== 'undefined'
? new Intl.DisplayNames(['en'], { type: 'region' })
: null;
function getCountryName(country: CountryCode) {
return regionNames?.of(country) ?? country;
}
export const COUNTRY_OPTIONS: CountryOption[] = getCountries()
.map((code) => ({
value: code,
label: getCountryName(code),
callingCode: getCountryCallingCode(code),
}))
.sort((a, b) => a.label.localeCompare(b.label));
export function filterCountryOption(item: CountryOption, query: string) {
const term = query.trim().toLowerCase();
if (!term) return true;
return (
item.label.toLowerCase().includes(term) ||
item.value.toLowerCase().includes(term) ||
item.callingCode.includes(term.replace(/^\+/, ''))
);
}
export function getNationalValue(
value: string | undefined,
country: CountryCode,
) {
if (!value) return '';
const parsed = parsePhoneNumberFromString(value);
if (parsed?.country === country) {
return parsed.nationalNumber;
}
const callingCode = getCountryCallingCode(country);
return value.replace(new RegExp(`^\\+${callingCode}`), '').replace(/\D/g, '');
}
export function buildPhoneValue(
country: CountryCode,
nationalValue: string,
) {
const digits = nationalValue.replace(/\D/g, '');
return digits ? `+${getCountryCallingCode(country)}${digits}` : '';
}

View File

@@ -0,0 +1,7 @@
import type { CountryCode } from 'libphonenumber-js';
export type CountryOption = {
value: CountryCode;
label: string;
callingCode: string;
};