feat(forms): add PhoneInput and unify module form validation UX
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
137
src/components/form/phone-input/CountryCombobox.tsx
Normal file
137
src/components/form/phone-input/CountryCombobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/components/form/phone-input/CountryFlag.tsx
Normal file
15
src/components/form/phone-input/CountryFlag.tsx
Normal 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()}`,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
87
src/components/form/phone-input/PhoneInput.tsx
Normal file
87
src/components/form/phone-input/PhoneInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/components/form/phone-input/VirtualCountryList.tsx
Normal file
105
src/components/form/phone-input/VirtualCountryList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/components/form/phone-input/countries.ts
Normal file
59
src/components/form/phone-input/countries.ts
Normal 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}` : '';
|
||||
}
|
||||
7
src/components/form/phone-input/types.ts
Normal file
7
src/components/form/phone-input/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CountryCode } from 'libphonenumber-js';
|
||||
|
||||
export type CountryOption = {
|
||||
value: CountryCode;
|
||||
label: string;
|
||||
callingCode: string;
|
||||
};
|
||||
Reference in New Issue
Block a user