Select

A select displays a collapsible list of options and allows a user to select one of them.

RAC Docs

Searchable Select

Model

Popover Classname

Source

"use client";

import type { ReactNode } from "react";

import type {
	SelectProps as AriaSelectProps,
	ListBoxItemProps,
	ValidationResult,
} from "react-aria-components";
import {
	Select as AriaSelect,
	Autocomplete,
	Button,
	FieldError,
	Input,
	Label,
	ListBox,
	ListBoxItem,
	Popover,
	SearchField,
	SelectValue,
	Text,
	useFilter,
} from "react-aria-components";
import { tv } from "tailwind-variants";

import { CheckIcon, ChevronDown, Search } from "lucide-react";

const select = tv({
	slots: {
		group: "group flex flex-col gap-1",
		button:
			"group flex w-fit items-center justify-between gap-4 rounded-full border border-border bg-surface px-4 py-2.75 align-middle font-semibold text-fg text-sm outline-none ring-fg transition-all data-[hovered]:bg-surface-2 group-data-[focus-visible]:border-transparent group-data-[open]:bg-surface-2 group-data-[focus-visible]:ring-2",
		item: "relative m-1 flex cursor-default flex-col rounded-lg p-2 font-semibold outline-none data-[disabled]:cursor-not-allowed data-[focused]:bg-secondary data-[disabled]:text-fg-disabled",
		searchField:
			"group m-1 flex items-center rounded-full border border-border bg-surface px-2 py-1.5",
		searchInput:
			"flex-1 bg-transparent text-fg outline-none placeholder:text-fg-muted",
		searchIcon: "mr-2 size-4 text-fg-muted",
		clearButton:
			"ml-2 rounded p-0.5 text-fg-muted transition-colors hover:bg-surface-2 hover:text-fg group-empty:invisible",
		popover:
			"min-w-[var(--trigger-width)] rounded-xl border border-border/25 bg-surface p-1 text-fg shadow-lg outline-none",
	},
});

const styles = select();

interface SelectProps<T extends ListBoxItemProps>
	extends Omit<AriaSelectProps<T>, "className"> {
	className?: string;
	popoverClassName?: string;
	label?: string;
	description?: string;
	errorMessage?: string | ((validation: ValidationResult) => string);
}

const Select = <T extends ListBoxItemProps>({
	label,
	className,
	description,
	errorMessage,
	popoverClassName,
	children,
	...props
}: SelectProps<T>) => (
	<AriaSelect className={styles.group()} {...props}>
		{label && <Label className="text-sm">{label}</Label>}
		<Button className={styles.button({ className })}>
			<SelectValue className="data-[placeholder]:text-fg-muted" />
			<ChevronDown className="size-4 text-fg-muted group-data-[open]:rotate-180 group-data-[focused]:text-fg" />
		</Button>
		{description && (
			<Text className="text-fg-muted text-sm" slot="description">
				{description}
			</Text>
		)}
		<FieldError className="text-danger text-sm">{errorMessage}</FieldError>
		<Popover className={styles.popover({ className: popoverClassName })}>
			<ListBox className="outline-none">{children}</ListBox>
		</Popover>
	</AriaSelect>
);

interface SelectItemProps
	extends Omit<ListBoxItemProps, "className" | "children"> {
	children: ReactNode;
	className?: string;
}

const SelectItem = ({ className, ...props }: SelectItemProps) => (
	<ListBoxItem {...props} className={styles.item({ className })}>
		{({ isSelected }) => (
			<div className="flex items-center justify-between gap-2">
				<span>{props.children}</span>
				{isSelected && <CheckIcon className="size-4" />}
			</div>
		)}
	</ListBoxItem>
);

interface SearchableSelectProps<T extends ListBoxItemProps>
	extends SelectProps<T> {
	searchPlaceholder?: string;
}

const SearchableSelect = <T extends ListBoxItemProps>({
	label,
	className,
	description,
	errorMessage,
	searchPlaceholder = "Search...",
	children,
	popoverClassName,
	...props
}: SearchableSelectProps<T>) => {
	const { contains } = useFilter({ sensitivity: "base" });

	return (
		<AriaSelect className={styles.group()} {...props}>
			{label && <Label className="text-sm">{label}</Label>}
			<Button className={styles.button({ className })}>
				<SelectValue className="data-[placeholder]:text-fg-muted" />
				<ChevronDown className="size-4 text-fg-muted group-data-[open]:rotate-180 group-data-[focused]:text-fg" />
			</Button>
			{description && (
				<Text className="text-fg-muted text-sm" slot="description">
					{description}
				</Text>
			)}
			<FieldError className="text-danger text-sm">{errorMessage}</FieldError>
			<Popover className={styles.popover({ className: popoverClassName })}>
				<Autocomplete filter={contains}>
					<SearchField
						aria-label="Search"
						autoFocus
						className={styles.searchField()}
					>
						<Search className={styles.searchIcon()} />
						<Input
							placeholder={searchPlaceholder}
							className={styles.searchInput()}
						/>
					</SearchField>
					<ListBox className="max-h-48 overflow-auto outline-none">
						{children}
					</ListBox>
				</Autocomplete>
			</Popover>
		</AriaSelect>
	);
};

export { Select, SelectItem, SearchableSelect };
export type { SelectProps, SelectItemProps, SearchableSelectProps };