mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronDown, XCircle, XIcon } from "lucide-react";
|
|
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
|
|
|
import { Badge } from "@/components/shadcn/badge/badge";
|
|
import { Button } from "@/components/shadcn/button/button";
|
|
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/shadcn/command";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/shadcn/popover";
|
|
import { Separator } from "@/components/shadcn/separator/separator";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface MultiSelectOption {
|
|
label: string;
|
|
value: string;
|
|
icon?: ReactNode;
|
|
description?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface EnhancedMultiSelectProps {
|
|
options: MultiSelectOption[];
|
|
onValueChange: (values: string[]) => void;
|
|
defaultValue?: string[];
|
|
placeholder?: string;
|
|
searchable?: boolean;
|
|
hideSelectAll?: boolean;
|
|
maxCount?: number;
|
|
closeOnSelect?: boolean;
|
|
resetOnDefaultValueChange?: boolean;
|
|
emptyIndicator?: ReactNode;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
id?: string;
|
|
"aria-label"?: string;
|
|
}
|
|
|
|
function arraysEqual(a: string[], b: string[]): boolean {
|
|
if (a.length !== b.length) return false;
|
|
const sortedA = [...a].sort();
|
|
const sortedB = [...b].sort();
|
|
return sortedA.every((val, index) => val === sortedB[index]);
|
|
}
|
|
|
|
export function EnhancedMultiSelect({
|
|
options,
|
|
onValueChange,
|
|
defaultValue = [],
|
|
placeholder = "Select options",
|
|
searchable = true,
|
|
hideSelectAll = false,
|
|
maxCount = 3,
|
|
closeOnSelect = false,
|
|
resetOnDefaultValueChange = true,
|
|
emptyIndicator,
|
|
disabled = false,
|
|
className,
|
|
id,
|
|
"aria-label": ariaLabel,
|
|
}: EnhancedMultiSelectProps) {
|
|
const [selectedValues, setSelectedValues] = useState<string[]>(defaultValue);
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(
|
|
null,
|
|
);
|
|
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const prevDefaultValueRef = useRef<string[]>(defaultValue);
|
|
const selectedAtOpenRef = useRef<string[]>(selectedValues);
|
|
const multiSelectId = useId();
|
|
const listboxId = `${multiSelectId}-listbox`;
|
|
|
|
// Detect dialog container for portal stacking (critical for Jira modal)
|
|
useEffect(() => {
|
|
if (!buttonRef.current) return;
|
|
const closestDialogContainer = buttonRef.current.closest(
|
|
"[data-slot='dialog-content'], [data-slot='modal-content'], [role='dialog']",
|
|
);
|
|
setPortalContainer(
|
|
closestDialogContainer instanceof HTMLElement
|
|
? closestDialogContainer
|
|
: null,
|
|
);
|
|
}, []);
|
|
|
|
// Reset when defaultValue changes externally (e.g. React Hook Form reset)
|
|
useEffect(() => {
|
|
if (!resetOnDefaultValueChange) return;
|
|
const prev = prevDefaultValueRef.current;
|
|
if (!arraysEqual(prev, defaultValue)) {
|
|
if (!arraysEqual(selectedValues, defaultValue)) {
|
|
setSelectedValues(defaultValue);
|
|
}
|
|
prevDefaultValueRef.current = [...defaultValue];
|
|
}
|
|
}, [defaultValue, selectedValues, resetOnDefaultValueChange]);
|
|
|
|
function handleOpenChange(nextOpen: boolean) {
|
|
if (nextOpen) {
|
|
selectedAtOpenRef.current = [...selectedValues];
|
|
} else {
|
|
setSearch("");
|
|
}
|
|
setOpen(nextOpen);
|
|
}
|
|
|
|
const enabledOptions = options.filter((o) => !o.disabled);
|
|
|
|
const filteredOptions = (
|
|
searchable && search
|
|
? options.filter(
|
|
(o) =>
|
|
o.label.toLowerCase().includes(search.toLowerCase()) ||
|
|
o.value.toLowerCase().includes(search.toLowerCase()),
|
|
)
|
|
: options
|
|
).toSorted((a, b) => {
|
|
const snapshot = selectedAtOpenRef.current;
|
|
const aSelected = snapshot.includes(a.value) ? 0 : 1;
|
|
const bSelected = snapshot.includes(b.value) ? 0 : 1;
|
|
return aSelected - bSelected;
|
|
});
|
|
|
|
function getOptionByValue(value: string) {
|
|
return options.find((o) => o.value === value);
|
|
}
|
|
|
|
function toggleOption(value: string) {
|
|
if (disabled) return;
|
|
const option = getOptionByValue(value);
|
|
if (option?.disabled) return;
|
|
const next = selectedValues.includes(value)
|
|
? selectedValues.filter((v) => v !== value)
|
|
: [...selectedValues, value];
|
|
setSelectedValues(next);
|
|
onValueChange(next);
|
|
if (closeOnSelect) setOpen(false);
|
|
}
|
|
|
|
function toggleAll() {
|
|
if (disabled) return;
|
|
if (selectedValues.length === enabledOptions.length) {
|
|
handleClear();
|
|
} else {
|
|
const all = enabledOptions.map((o) => o.value);
|
|
setSelectedValues(all);
|
|
onValueChange(all);
|
|
}
|
|
if (closeOnSelect) setOpen(false);
|
|
}
|
|
|
|
function handleClear() {
|
|
if (disabled) return;
|
|
setSelectedValues([]);
|
|
onValueChange([]);
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
id={id}
|
|
ref={buttonRef}
|
|
variant="outline"
|
|
onClick={() => !disabled && setOpen((prev) => !prev)}
|
|
disabled={disabled}
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
aria-controls={open ? listboxId : undefined}
|
|
aria-label={ariaLabel}
|
|
className={cn(
|
|
"border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 [&_svg]:pointer-events-auto",
|
|
disabled && "cursor-not-allowed opacity-50",
|
|
className,
|
|
)}
|
|
>
|
|
{selectedValues.length > 0 ? (
|
|
<div className="flex w-full items-center justify-between">
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{selectedValues
|
|
.slice(0, maxCount)
|
|
.map((value) => {
|
|
const option = getOptionByValue(value);
|
|
if (!option) return null;
|
|
return (
|
|
<Badge
|
|
key={value}
|
|
variant="tag"
|
|
className="m-1 cursor-default [&>svg]:pointer-events-auto"
|
|
>
|
|
<span className="cursor-default">{option.label}</span>
|
|
<span
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleOption(value);
|
|
}}
|
|
aria-label={`Remove ${option.label} from selection`}
|
|
className="focus:ring-border-input-primary-press -m-0.5 ml-2 inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center self-center rounded-sm p-0.5 focus:ring-1 focus:outline-none"
|
|
>
|
|
<XCircle className="h-3 w-3" />
|
|
</span>
|
|
</Badge>
|
|
);
|
|
})
|
|
.filter(Boolean)}
|
|
{selectedValues.length > maxCount && (
|
|
<Badge
|
|
variant="tag"
|
|
className="m-1 cursor-default [&>svg]:pointer-events-auto"
|
|
>
|
|
{`+ ${selectedValues.length - maxCount} more`}
|
|
<span
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const trimmed = selectedValues.slice(0, maxCount);
|
|
setSelectedValues(trimmed);
|
|
onValueChange(trimmed);
|
|
}}
|
|
className="ml-2 inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center self-center rounded-sm"
|
|
aria-label="Clear extra selected options"
|
|
>
|
|
<XCircle className="h-3 w-3" />
|
|
</span>
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleClear();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleClear();
|
|
}
|
|
}}
|
|
aria-label={`Clear all ${selectedValues.length} selected options`}
|
|
className="text-text-neutral-tertiary hover:text-text-neutral-primary focus:ring-border-input-primary-press mx-2 flex h-4 w-4 cursor-pointer items-center justify-center rounded-sm focus:ring-2 focus:ring-offset-1 focus:outline-none"
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</div>
|
|
<Separator
|
|
orientation="vertical"
|
|
className="flex h-full min-h-6"
|
|
/>
|
|
<ChevronDown
|
|
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer"
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mx-auto flex w-full items-center justify-between">
|
|
<span className="text-text-neutral-tertiary mx-3 text-sm">
|
|
{placeholder}
|
|
</span>
|
|
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
container={portalContainer}
|
|
id={listboxId}
|
|
role="listbox"
|
|
aria-multiselectable="true"
|
|
aria-label="Available options"
|
|
className="border-border-input-primary bg-bg-input-primary text-text-neutral-primary pointer-events-auto z-50 w-[var(--radix-popover-trigger-width)] max-w-[var(--radix-popover-trigger-width)] touch-manipulation rounded-lg p-0"
|
|
align="start"
|
|
onEscapeKeyDown={() => setOpen(false)}
|
|
>
|
|
<Command>
|
|
{searchable && (
|
|
<CommandInput
|
|
placeholder="Search options..."
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
aria-label="Search through available options"
|
|
/>
|
|
)}
|
|
<CommandList className="minimal-scrollbar multiselect-scrollbar max-h-[40vh] overflow-x-hidden overflow-y-auto overscroll-y-contain">
|
|
<CommandEmpty>{emptyIndicator || "No results found."}</CommandEmpty>
|
|
{!hideSelectAll && !search && (
|
|
<CommandGroup>
|
|
<CommandItem
|
|
key="all"
|
|
onSelect={toggleAll}
|
|
role="option"
|
|
aria-selected={
|
|
selectedValues.length === enabledOptions.length
|
|
}
|
|
className="cursor-pointer"
|
|
>
|
|
<Checkbox
|
|
checked={selectedValues.length === enabledOptions.length}
|
|
tabIndex={-1}
|
|
aria-hidden="true"
|
|
className="pointer-events-none mr-2 size-4"
|
|
/>
|
|
<span>Select All</span>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
)}
|
|
<CommandGroup>
|
|
{filteredOptions.map((option) => {
|
|
const isSelected = selectedValues.includes(option.value);
|
|
return (
|
|
<CommandItem
|
|
key={option.value}
|
|
onSelect={() => toggleOption(option.value)}
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
aria-disabled={option.disabled}
|
|
className={cn(
|
|
"cursor-pointer",
|
|
option.disabled && "cursor-not-allowed opacity-50",
|
|
)}
|
|
disabled={option.disabled}
|
|
>
|
|
<Checkbox
|
|
checked={isSelected}
|
|
disabled={option.disabled}
|
|
tabIndex={-1}
|
|
aria-hidden="true"
|
|
className="pointer-events-none mr-2 size-4"
|
|
/>
|
|
{option.icon && (
|
|
<span className="shrink-0">{option.icon}</span>
|
|
)}
|
|
<div className="flex min-w-0 flex-col">
|
|
<span className="truncate">{option.label}</span>
|
|
{option.description && (
|
|
<span className="text-text-neutral-tertiary text-xs">
|
|
{option.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
<Separator />
|
|
<div className="flex items-center justify-between p-1">
|
|
{selectedValues.length > 0 && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleClear}
|
|
className="flex-1"
|
|
>
|
|
Clear
|
|
</Button>
|
|
<Separator
|
|
orientation="vertical"
|
|
className="flex h-full min-h-6"
|
|
/>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setOpen(false)}
|
|
className="flex-1"
|
|
>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
EnhancedMultiSelect.displayName = "EnhancedMultiSelect";
|
|
export type { EnhancedMultiSelectProps, MultiSelectOption };
|