MIF_E31221222/sigap-website/app/_components/custom-dropdown-switcher.tsx

271 lines
7.6 KiB
TypeScript

"use client";
import React, {
useEffect,
useState,
useMemo,
useRef,
useCallback,
} from "react";
import { Button } from "@/app/_components/ui/button";
import { Check, Search, type LucideIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/_components/ui/dropdown-menu";
import { Badge } from "@/app/_components/ui/badge";
import { Input } from "@/app/_components/ui/input";
import { cn } from "@/lib/utils";
type Option<T> = {
value: T;
prefix?: LucideIcon;
label: string;
subLabel?: string;
beta?: boolean;
isCurrent?: boolean;
};
type Variant = "outline" | "ghost";
interface DropdownSwitcherProps<T> {
options: Option<T>[];
selectedValue: T;
onChange: (value: T) => void;
showTitle?: boolean;
title?: string;
prefix?: boolean;
suffix?: LucideIcon;
variant?: Variant;
searchable?: boolean;
searchPlaceholder?: string;
currentLabel?: string;
selectLabel?: string;
disabled?: boolean;
}
const ICON_SIZE = 16;
const DropdownSwitcher = <T extends string>({
options,
selectedValue,
onChange,
showTitle = false,
title = "Select",
prefix = true,
suffix,
variant = "outline",
searchable = false,
searchPlaceholder = "Search...",
currentLabel = "Current timezone",
selectLabel = "Select a timezone",
disabled = false,
}: DropdownSwitcherProps<T>) => {
const [searchQuery, setSearchQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const currentOption = useMemo(
() =>
options.find((option) => option.value === selectedValue) || options[0],
[selectedValue, options]
);
const filteredOptions = useMemo(() => {
if (!searchQuery.trim()) return options;
const query = searchQuery.toLowerCase();
return options.filter(
(option) =>
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query) ||
(option.subLabel && option.subLabel.toLowerCase().includes(query))
);
}, [options, searchQuery]);
const { currentOptions, selectableOptions } = useMemo(
() => ({
currentOptions: filteredOptions.filter(
(opt) => opt.value === selectedValue
),
selectableOptions: filteredOptions.filter(
(opt) => opt.value !== selectedValue
),
}),
[filteredOptions, selectedValue]
);
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
}
}, [isOpen]);
useEffect(() => {
if (isOpen && searchable) {
const timeoutId = setTimeout(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, 10);
return () => clearTimeout(timeoutId);
}
}, [isOpen, searchable]);
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
},
[]
);
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
}, []);
const handleOptionSelect = useCallback(
(value: T) => {
onChange(value);
setIsOpen(false);
},
[onChange]
);
const handleSearchInteraction = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
},
[]
);
return (
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={variant}
size={showTitle ? "sm" : "icon"}
className={cn(
variant !== "outline" ? "" : "border-2",
showTitle ? "flex justify-center items-center" : ""
)}
aria-label={`Current selection: ${selectedValue}`}
disabled={disabled}
>
{prefix && currentOption.prefix && (
<currentOption.prefix
size={ICON_SIZE}
className="text-muted-foreground"
/>
)}
{showTitle && (
<span className="text-muted-foreground font-medium">{title}</span>
)}
{!prefix &&
suffix &&
React.createElement(suffix, {
size: ICON_SIZE,
className: "text-muted-foreground",
})}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[300px] max-h-[400px] overflow-auto"
align="start"
onCloseAutoFocus={(e) => e.preventDefault()}
>
{searchable && (
<div
className="sticky top-0 bg-background z-50 px-2 py-2"
onClick={handleSearchInteraction}
onMouseDown={handleSearchInteraction}
>
<div className="relative"></div>
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder={searchPlaceholder}
value={searchQuery}
onChange={handleSearchChange}
className="pl-8 h-9"
onClick={handleSearchInteraction}
onMouseDown={handleSearchInteraction}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
setIsOpen(false);
}
}}
disabled={disabled}
/>
</div>
)}
{currentOptions.length > 0 && (
<>
<div className="px-2 py-1.5">
<p className="text-sm text-muted-foreground mb-1">
{currentLabel}
</p>
{currentOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center justify-between py-0"
>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
{option.subLabel && (
<span className="text-sm text-muted-foreground">
{option.subLabel}
</span>
)}
</div>
<Check className="h-4 w-4" />
</DropdownMenuItem>
))}
</div>
<DropdownMenuSeparator />
</>
)}
<div className="px-2 py-1.5">
<p className="text-sm text-muted-foreground mb-1">{selectLabel}</p>
{selectableOptions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No timezones found
</p>
) : (
selectableOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center justify-between py-2"
onClick={() => handleOptionSelect(option.value)}
disabled={disabled}
>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
{option.subLabel && (
<span className="text-sm text-muted-foreground">
{option.subLabel}
</span>
)}
</div>
{option.beta && (
<Badge className="w-fit bg-secondary-foreground dark:bg-muted hover:bg-secondary-foreground dark:hover:bg-muted text-[10px] rounded px-1 py-0">
BETA
</Badge>
)}
</DropdownMenuItem>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default DropdownSwitcher;