Files
silo-server/web/src/components/LibraryMultiSelect.tsx
T

150 lines
4.4 KiB
TypeScript

import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown } from "lucide-react";
interface LibraryOption {
id: number;
name: string;
type?: string;
}
function isLibraryEligible(library: LibraryOption, eligibleKinds?: string[]): boolean {
if (!eligibleKinds || eligibleKinds.length === 0) return true;
if (!library.type) return true;
if (library.type === "mixed") return true;
return eligibleKinds.includes(library.type);
}
function formatLibraryFilterSummary(
libraryIds: number[],
libraries: LibraryOption[],
emptyLabel: string,
): string {
if (libraryIds.length === 0) {
return emptyLabel;
}
const names = libraryIds
.map((libraryId) => libraries.find((library) => library.id === libraryId)?.name)
.filter((name): name is string => Boolean(name));
if (names.length === 0) {
return `${libraryIds.length} libraries`;
}
if (names.length === 1) {
return names[0] ?? "1 library";
}
if (names.length === 2) {
return `${names[0] ?? "Library"}, ${names[1] ?? "Library"}`;
}
return `${names[0] ?? "Library"} +${names.length - 1} more`;
}
function toggleLibrarySelection(
selectedIds: number[],
libraryId: number,
checked: boolean,
): number[] {
if (checked) {
return selectedIds.includes(libraryId) ? selectedIds : [...selectedIds, libraryId];
}
return selectedIds.filter((id) => id !== libraryId);
}
export default function LibraryMultiSelect({
libraries,
value,
onChange,
eligibleKinds,
emptyLabel = "All Libraries",
hideAllOption = false,
ineligibleReason,
triggerClassName,
}: {
libraries: LibraryOption[];
value: number[];
onChange: (libraryIds: number[]) => void;
eligibleKinds?: string[];
emptyLabel?: string;
hideAllOption?: boolean;
ineligibleReason?: string;
triggerClassName?: string;
}) {
const hasIneligible =
Array.isArray(eligibleKinds) &&
eligibleKinds.length > 0 &&
libraries.some((library) => !isLibraryEligible(library, eligibleKinds));
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className={triggerClassName ?? "w-full justify-between"}
>
<span className="truncate">
{formatLibraryFilterSummary(value, libraries, emptyLabel)}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[260px]" align="start">
{hideAllOption ? null : (
<>
<DropdownMenuItem
disabled={value.length === 0}
onSelect={(event) => {
event.preventDefault();
onChange([]);
}}
>
{emptyLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{libraries.map((library) => {
const eligible = isLibraryEligible(library, eligibleKinds);
const selected = value.includes(library.id);
// Allow deselecting an already-selected ineligible library (e.g.
// eligibility shrank after the library type changed), but block
// selecting a new ineligible one.
const handleCheckedChange = (checked: boolean | "indeterminate") => {
const next = Boolean(checked);
if (!eligible && next) return;
onChange(toggleLibrarySelection(value, library.id, next));
};
return (
<DropdownMenuCheckboxItem
key={library.id}
checked={selected}
disabled={!eligible && !selected}
onCheckedChange={handleCheckedChange}
onSelect={(event) => event.preventDefault()}
>
{library.name}
</DropdownMenuCheckboxItem>
);
})}
{hasIneligible && ineligibleReason ? (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-muted-foreground text-xs leading-snug font-normal whitespace-normal">
{ineligibleReason}
</DropdownMenuLabel>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}