mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: revised phone nubmer input UI
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react"
|
||||
import * as RPNInput from "react-phone-number-input"
|
||||
import flags from "react-phone-number-input/flags"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const PhoneInput = React.forwardRef(
|
||||
({ className, onChange, value, ...props }, ref) => {
|
||||
return (
|
||||
<RPNInput.default
|
||||
ref={ref}
|
||||
className={cn("flex", className)}
|
||||
flagComponent={FlagComponent}
|
||||
countrySelectComponent={CountrySelect}
|
||||
inputComponent={InputComponent}
|
||||
smartCaret={false}
|
||||
value={value || undefined}
|
||||
/**
|
||||
* react-phone-number-input fires onChange as undefined when the
|
||||
* field is emptied — coerce to "" so react-hook-form's required
|
||||
* check still fires correctly.
|
||||
*/
|
||||
onChange={(value) => onChange?.(value || "")}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
PhoneInput.displayName = "PhoneInput"
|
||||
|
||||
const InputComponent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<Input
|
||||
className={cn("rounded-e-lg rounded-s-none", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
InputComponent.displayName = "InputComponent"
|
||||
|
||||
const CountrySelect = ({
|
||||
disabled,
|
||||
value: selectedCountry,
|
||||
options: countryList,
|
||||
onChange,
|
||||
}) => {
|
||||
const scrollAreaRef = React.useRef(null)
|
||||
const [searchValue, setSearchValue] = React.useState("")
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
modal
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open)
|
||||
open && setSearchValue("")
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
|
||||
disabled={disabled}
|
||||
>
|
||||
<FlagComponent
|
||||
country={selectedCountry}
|
||||
countryName={selectedCountry}
|
||||
/>
|
||||
<ChevronsUpDown
|
||||
className={cn(
|
||||
"-mr-2 size-4 opacity-50",
|
||||
disabled ? "hidden" : "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
value={searchValue}
|
||||
onValueChange={(value) => {
|
||||
setSearchValue(value)
|
||||
setTimeout(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
const viewportElement = scrollAreaRef.current.querySelector(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
)
|
||||
if (viewportElement) {
|
||||
viewportElement.scrollTop = 0
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
placeholder="Search country..."
|
||||
/>
|
||||
<CommandList>
|
||||
<ScrollArea ref={scrollAreaRef} className="h-72">
|
||||
<CommandEmpty>No country found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{countryList.map(({ value, label }) =>
|
||||
value ? (
|
||||
<CountrySelectOption
|
||||
key={value}
|
||||
country={value}
|
||||
countryName={label}
|
||||
selectedCountry={selectedCountry}
|
||||
onChange={onChange}
|
||||
onSelectComplete={() => setIsOpen(false)}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const CountrySelectOption = ({
|
||||
country,
|
||||
countryName,
|
||||
selectedCountry,
|
||||
onChange,
|
||||
onSelectComplete,
|
||||
}) => {
|
||||
const handleSelect = () => {
|
||||
onChange(country)
|
||||
onSelectComplete()
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandItem className="flex items-center justify-between gap-2" onSelect={handleSelect}>
|
||||
<div className="flex items-center flex-1 gap-2">
|
||||
<FlagComponent country={country} countryName={countryName} />
|
||||
<span className="text-sm truncate">{countryName}</span>
|
||||
</div>
|
||||
<div className="whitespace-nowrap flex min-w-0">
|
||||
<span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
|
||||
<CheckIcon
|
||||
className={`size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
const FlagComponent = ({ country, countryName }) => {
|
||||
const Flag = flags[country]
|
||||
|
||||
return (
|
||||
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg:not([class*='size-'])]:size-full">
|
||||
{Flag && <Flag title={countryName} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { PhoneInput }
|
||||
Reference in New Issue
Block a user