Multi Select

PreviousNext

A select component for allowing multiple selections.

Docs
opticscomponent

Preview

Loading preview…
registry/optics/multi-select.jsx
"use client";

import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Button, buttonVariants } from "@/registry/optics/button";
import { cn } from "@/registry/optics/lib/utils";
import { Badge } from "@/registry/optics/badge";
import { Checkbox } from "@/registry/optics/checkbox";

// Contexto para compartir el estado de selección entre todos los items
const MultiSelectContext = React.createContext({
	values: {},
	setValue: () => {},
	onValuesChange: () => {},
	itemCount: 0,
	colors: {},
	registerColor: () => {},
});

function Select({
	onValuesChange = undefined,
	onOpenChange = undefined,
	...props
}) {
	const [values, setValues] = React.useState({});
	const [colors, setColors] = React.useState({});
	const [open, setOpen] = React.useState(false);
	const registeredItemsRef = React.useRef(new Set());

	// Función para registrar un item
	const registerItem = React.useCallback((value) => {
		if (value && !registeredItemsRef.current.has(value)) {
			registeredItemsRef.current.add(value);
			setValues((prev) => {
				if (!(value in prev)) {
					return { ...prev, [value]: false };
				}
				return prev;
			});
		}
	}, []);

	// Función para registrar el color de un item
	const registerColor = React.useCallback((value, color) => {
		if (value) {
			setColors((prev) => ({ ...prev, [value]: color }));
		}
	}, []);

	// Función para actualizar un valor
	const setValue = React.useCallback(
		(value, checked) => {
			setValues((prev) => {
				const newValues = { ...prev, [value]: checked };
				// Llamar al callback con el array de valores
				if (onValuesChange) {
					const valuesArray = Object.entries(newValues).map(([key, val]) => ({
						value: key,
						checked: val,
					}));
					onValuesChange(valuesArray);
				}
				return newValues;
			});
		},
		[onValuesChange],
	);

	const handleOpenChange = React.useCallback(
		(newOpen) => {
			setOpen(newOpen);
			if (onOpenChange) {
				onOpenChange(newOpen);
			}
		},
		[onOpenChange],
	);

	const contextValue = React.useMemo(
		() => ({
			values,
			setValue,
			registerItem,
			itemCount: registeredItemsRef.current.size,
			colors,
			registerColor,
		}),
		[values, setValue, registerItem, colors, registerColor],
	);

	return (
		<MultiSelectContext.Provider value={contextValue}>
			<SelectPrimitive.Root
				data-slot="select"
				open={open}
				onOpenChange={handleOpenChange}
				{...props}
			/>
		</MultiSelectContext.Provider>
	);
}

function SelectGroup({ ...props } = {}) {
	return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}

function SelectValue({ ...props } = {}) {
	return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}

function SelectTrigger({
	className = "",
	size = "default",
	children = null,
	variant = undefined,
	...props
}) {
	const { values, colors, itemCount } = React.useContext(MultiSelectContext);

	// Obtener los items seleccionados con sus colores
	const selectedItems = React.useMemo(() => {
		return Object.entries(values)
			.filter(([_, checked]) => checked)
			.map(([value, _]) => ({
				value,
				color: colors[value] || "bg-gray-200",
			}));
	}, [values, colors]);

	const selectedCount = selectedItems.length;
	const totalCount = itemCount;

	return (
		<SelectPrimitive.Trigger
			data-slot="select-trigger"
			data-size={size}
			className={cn(
				"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit min-w-3xs items-center justify-between gap-2 rounded-lg border bg-transparent px-4 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
				buttonVariants({
					variant: variant,
					size: "icon-lg",
					animation: "colors",
				}),
				className,
			)}
			{...props}
		>
			<div className="flex items-center gap-2 flex-1 min-w-0">
				{selectedCount > 0 && (
					<div className="flex items-center -space-x-0.25 -ml-1">
						{selectedItems.map((item, index) => (
							<Badge
								key={item.value}
								className={cn(
									"rounded-full squircle-none aspect-square size-3 p-0 border border-background -ml-1 first:ml-0",
									item.color,
								)}
								style={{
									zIndex: selectedItems.length - index,
								}}
							/>
						))}
					</div>
				)}
				{children}
			</div>
			{totalCount > 0 && (
				<span className="text-xs text-muted-foreground ml-2 shrink-0">
					{selectedCount}/{totalCount}
				</span>
			)}
			<SelectPrimitive.Icon
				render={<ChevronDownIcon className="size-4 opacity-50 shrink-0" />}
			/>
		</SelectPrimitive.Trigger>
	);
}

function SelectContent({
	className = "",
	children = null,
	side = "bottom",
	...props
}) {
	return (
		<SelectPrimitive.Portal>
			<SelectPrimitive.Positioner side={side} sideOffset={4} className="isolate z-50">
				<SelectPrimitive.Popup
					data-slot="select-content"
					className={cn(
						"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--available-height) min-w-[8rem] origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border shadow-md",
						className,
					)}
					{...props}
				>
					<SelectScrollUpButton />
					<SelectPrimitive.List className="p-1">
						{children}
					</SelectPrimitive.List>
					<SelectScrollDownButton />
				</SelectPrimitive.Popup>
			</SelectPrimitive.Positioner>
		</SelectPrimitive.Portal>
	);
}

function SelectLabel({ className = "", ...props }) {
	return (
		<SelectPrimitive.GroupLabel
			data-slot="select-label"
			className={cn("text-muted-foreground p-1.5 text-xs", className)}
			{...props}
		/>
	);
}

function SelectItem({
	className = "",
	children = null,
	value,
	color = undefined,
	...props
}) {
	const { values, setValue, registerItem, registerColor } =
		React.useContext(MultiSelectContext);
	const [isHoveringCheckbox, setIsHoveringCheckbox] = React.useState(false);
	const [isHoveringText, setIsHoveringText] = React.useState(false);

	// Registrar el item cuando se monta
	React.useEffect(() => {
		if (value) {
			registerItem(value);
		}
	}, [value, registerItem]);

	// Registrar el color cuando se monta o cambia
	React.useEffect(() => {
		if (value && color) {
			registerColor(value, color);
		}
	}, [value, color, registerColor]);

	const checked = value ? (values[value] ?? false) : false;

	// Calcular estados
	const allChecked = Object.values(values).every((v) => v === true);
	const someChecked = Object.values(values).some((v) => v === true);
	const noneChecked = !someChecked;

	// Determinar qué texto mostrar según los comentarios
	const getActionText = () => {
		if (checked && isHoveringCheckbox) return "Uncheck";
		if (noneChecked && (isHoveringCheckbox || isHoveringText)) return "Check";
		if (someChecked && !checked && isHoveringCheckbox) return "Check";
		if (!checked && someChecked && isHoveringText) return "Only";
		if (checked && allChecked && isHoveringText) return "Only";
		if (checked && someChecked && !allChecked && isHoveringText) return "Check All";
		return "";
	};

	const handleCheckboxClick = (e) => {
		e.stopPropagation();
		e.preventDefault();
		if (value) {
			setValue(value, !checked);
		}
	};

	const handleTextClick = (e) => {
		e.stopPropagation();
		e.preventDefault();
		if (!value) return;

		if (checked && someChecked && !allChecked && isHoveringText) {
			Object.keys(values).forEach((key) => {
				setValue(key, true);
			});
		} else if (
			isHoveringText &&
			((!checked && someChecked) || (checked && allChecked))
		) {
			Object.keys(values).forEach((key) => {
				setValue(key, key === value);
			});
		} else {
			setValue(value, true);
		}
	};

	return (
		<div className="w-full min-w-0 flex items-center gap-0.5 group overflow-hidden">
			<div
				className={cn(
					buttonVariants({
						variant: "ghost",
						size: "icon",
						animation: "none",
						className: "hover:bg-accent/75",
					}),
					"aspect-square [&_svg]:!size-3.5 shrink-0",
				)}
				onClick={handleCheckboxClick}
				onMouseEnter={() => setIsHoveringCheckbox(true)}
				onMouseLeave={() => setIsHoveringCheckbox(false)}
			>
				<Checkbox checked={checked} />
			</div>

			<div
				className={cn(
					buttonVariants({
						variant: "ghost",
						size: "",
						animation: "none",
						className: "hover:bg-accent/75",
					}),
					"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex items-center justify-between gap-2 flex-1 min-w-0 cursor-pointer rounded-lg py-1.5 pr-2 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 overflow-hidden",
					className,
				)}
				onClick={handleTextClick}
				onMouseEnter={() => setIsHoveringText(true)}
				onMouseLeave={() => setIsHoveringText(false)}
				{...props}
			>
				<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
					<Badge
						className={cn(
							"rounded-full squircle-none aspect-square size-3 p-0 bg-gray-200 border border-background shrink-0",
							color,
						)}
					/>
					<span className="truncate min-w-0">{children}</span>
				</div>

				<div className="flex items-center justify-center text-xs text-muted-foreground font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-50 shrink-0 ml-1">
					{getActionText()}
				</div>
			</div>
		</div>
	);
}

function SelectSeparator({ className, ...props }) {
	return (
		<SelectPrimitive.Separator
			data-slot="select-separator"
			className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
			{...props}
		/>
	);
}

function SelectScrollUpButton({ className, ...props }) {
	return (
		<SelectPrimitive.ScrollUpArrow
			data-slot="select-scroll-up-button"
			className={cn(
				"flex cursor-default items-center justify-center py-1",
				className,
			)}
			{...props}
		>
			<ChevronUpIcon className="size-4" />
		</SelectPrimitive.ScrollUpArrow>
	);
}

function SelectScrollDownButton({ className, ...props }) {
	return (
		<SelectPrimitive.ScrollDownArrow
			data-slot="select-scroll-down-button"
			className={cn(
				"flex cursor-default items-center justify-center py-1",
				className,
			)}
			{...props}
		>
			<ChevronDownIcon className="size-4" />
		</SelectPrimitive.ScrollDownArrow>
	);
}

export {
	Select,
	SelectContent,
	SelectGroup,
	SelectItem,
	SelectLabel,
	SelectScrollDownButton,
	SelectScrollUpButton,
	SelectSeparator,
	SelectTrigger,
	SelectValue,
};

Installation

npx shadcn@latest add @optics/multi-select

Usage

import { MultiSelect } from "@/components/multi-select"
<MultiSelect />