infoalloggi-monorepo/apps/infoalloggi/src/components/phone-input.tsx

235 lines
6.3 KiB
TypeScript
Raw Normal View History

2025-08-04 17:45:44 +02:00
"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
import { CheckIcon, ChevronsUpDown, Globe } from "lucide-react";
import * as React from "react";
import * as RPNInput from "react-phone-number-input";
import flags from "react-phone-number-input/flags";
import EN from "react-phone-number-input/locale/en";
import { Button } from "~/components/ui/button";
import {
2025-08-28 18:27:07 +02:00
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
2025-08-04 17:45:44 +02:00
} from "~/components/ui/command";
import { Input } from "~/components/ui/input";
import {
2025-08-28 18:27:07 +02:00
Popover,
PopoverContent,
PopoverTrigger,
2025-08-04 17:45:44 +02:00
} from "~/components/ui/popover";
import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
type PhoneInputProps = Omit<
2025-08-28 18:27:07 +02:00
React.ComponentProps<"input">,
"onChange" | "value" | "ref"
2025-08-04 17:45:44 +02:00
> &
2025-08-28 18:27:07 +02:00
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void;
};
2025-08-04 17:45:44 +02:00
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = React.memo(
2025-08-28 18:27:07 +02:00
React.forwardRef<
React.ComponentRef<typeof RPNInput.default>,
PhoneInputProps
>(({ className, onChange, ...props }, ref) => {
return (
<RPNInput.default
className={cn(
2025-10-10 16:18:43 +02:00
"[&_input]:ph-input-fix [&_*]:!h-auto flex h-auto w-full flex-1",
2025-08-28 18:27:07 +02:00
className,
)}
countrySelectComponent={CountrySelect}
defaultCountry="IT"
2025-08-29 16:18:32 +02:00
flagComponent={FlagComponent}
focusInputOnCountrySelection
inputComponent={InputComponent}
2025-08-28 18:27:07 +02:00
international
labels={EN}
2025-08-29 16:18:32 +02:00
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
ref={ref}
2025-08-28 18:27:07 +02:00
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
2025-08-29 16:18:32 +02:00
smartCaret={false}
2025-08-28 18:27:07 +02:00
{...props}
/>
);
}),
2025-08-04 17:45:44 +02:00
);
PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<
2025-08-28 18:27:07 +02:00
HTMLInputElement,
React.ComponentProps<"input">
2025-08-04 17:45:44 +02:00
>(({ className, ...props }, ref) => (
2025-08-28 18:27:07 +02:00
<Input
className={cn("w-full flex-1 rounded-s-none rounded-e-md", className)}
{...props}
ref={ref}
/>
2025-08-04 17:45:44 +02:00
));
InputComponent.displayName = "InputComponent";
type CountryEntry = { label: string; value: RPNInput.Country | undefined };
type CountrySelectProps = {
2025-08-28 18:27:07 +02:00
disabled?: boolean;
value: RPNInput.Country;
options: CountryEntry[];
onChange: (country: RPNInput.Country) => void;
2025-08-04 17:45:44 +02:00
};
const CountrySelect = React.memo(
2025-08-28 18:27:07 +02:00
({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}: CountrySelectProps) => {
const [filteredCountries, setFilteredCountries] = React.useState<
CountryEntry[]
>(countryList || []);
const [open, setOpen] = React.useState(false);
const [parentNode, setParentNode] = React.useState<HTMLDivElement | null>(
null,
);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const refCallback = React.useCallback((node: HTMLDivElement) => {
if (node) {
setParentNode(node);
}
}, []);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const virtualizer = useVirtualizer({
count: filteredCountries.length,
estimateSize: () => 35,
2025-08-29 16:18:32 +02:00
getScrollElement: () => parentNode,
2025-08-28 18:27:07 +02:00
overscan: 5,
});
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const handleSearch = (search: string) => {
if (!search.trim()) {
setFilteredCountries(countryList);
} else {
setFilteredCountries(
countryList.filter((country) =>
country.label.toLowerCase().includes(search.toLowerCase()),
),
);
}
};
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return (
2025-08-29 16:18:32 +02:00
<Popover onOpenChange={setOpen} open={open}>
2025-08-28 18:27:07 +02:00
<PopoverTrigger asChild>
<Button
aria-expanded={open}
className="flex gap-1 rounded-md rounded-e-none border-r-0 px-3 py-2 focus:z-10"
2025-08-29 16:18:32 +02:00
role="combobox"
variant="outline"
2025-08-28 18:27:07 +02:00
>
<FlagComponent
country={selectedCountry}
countryName={
countryList.find((c) => c.value === selectedCountry)?.label ||
""
}
/>
<ChevronsUpDown
className={cn(
"-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100",
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command shouldFilter={false}>
<CommandInput
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
2025-08-29 16:18:32 +02:00
onValueChange={handleSearch}
placeholder="Search country..."
2025-08-28 18:27:07 +02:00
/>
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
<ScrollArea className="h-[400px] overflow-auto">
<CommandEmpty>No countries found</CommandEmpty>
<CommandGroup ref={refCallback}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
2025-08-29 16:18:32 +02:00
width: "100%",
2025-08-28 18:27:07 +02:00
}}
>
{virtualizer
.getVirtualItems()
.map(({ index, size, start }) => {
const country = filteredCountries[index];
if (!country?.value) return null;
return (
<CommandItem
key={country.value}
2025-08-29 16:18:32 +02:00
// biome-ignore lint/style/noNonNullAssertion: <exists>
onSelect={() => onChange(country.value!)}
2025-08-28 18:27:07 +02:00
style={{
2025-08-29 16:18:32 +02:00
height: `${size}px`,
left: 0,
2025-08-28 18:27:07 +02:00
position: "absolute",
top: 0,
transform: `translateY(${start}px)`,
2025-08-29 16:18:32 +02:00
width: "100%",
2025-08-28 18:27:07 +02:00
}}
value={country.value}
>
<FlagComponent
country={country.value}
countryName={country.label}
/>
<span className="flex-1 text-sm">
{country.label}
</span>
<span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(
country.value,
)}`}</span>
<CheckIcon
className={`ml-auto size-4 ${
country.value === selectedCountry
? "opacity-100"
: "opacity-0"
}`}
/>
</CommandItem>
);
})}
</div>
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
);
},
2025-08-04 17:45:44 +02:00
);
CountrySelect.displayName = "CountrySelect";
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
2025-08-28 18:27:07 +02:00
const Flag = flags[country];
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return (
<span className="flex size-6 items-center justify-center overflow-hidden rounded-sm">
{Flag ? <Flag title={countryName} /> : <Globe className="size-4" />}
</span>
);
2025-08-04 17:45:44 +02:00
};
export { PhoneInput };