;
import { Check, ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
type FacetedValue<Multiple extends boolean> = Multiple extends true
? string[]
: string;
interface FacetedContextValue<Multiple extends boolean = boolean> {
value?: FacetedValue<Multiple>;
onItemSelect?: (value: string) => void;
multiple?: Multiple;
}
const FacetedContext = React.createContext<FacetedContextValue<boolean> | null>(
null,
);
function useFacetedContext(name: string) {
const context = React.useContext(FacetedContext);
if (!context) {
throw new Error(`\`${name}\` must be within Faceted`);
}
return context;
}
interface FacetedProps<Multiple extends boolean = false>
extends React.ComponentProps<typeof Popover> {
value?: FacetedValue<Multiple>;
onValueChange?: (value: FacetedValue<Multiple> | undefined) => void;
children?: React.ReactNode;
multiple?: Multiple;
}
function Faceted<Multiple extends boolean = false>(
props: FacetedProps<Multiple>,
) {
const {
open: openProp,
onOpenChange: onOpenChangeProp,
value,
onValueChange,
children,
multiple = false,
...facetedProps
} = props;
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const isControlled = openProp !== undefined;
const open = isControlled ? openProp : uncontrolledOpen;
const onOpenChange = React.useCallback(
(newOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(newOpen);
}
onOpenChangeProp?.(newOpen);
},
[isControlled, onOpenChangeProp],
);
const onItemSelect = React.useCallback(
(selectedValue: string) => {
if (!onValueChange) return;
if (multiple) {
const currentValue = (Array.isArray(value) ? value : []) as string[];
const newValue = currentValue.includes(selectedValue)
? currentValue.filter((v) => v !== selectedValue)
: [...currentValue, selectedValue];
onValueChange(newValue as FacetedValue<Multiple>);
} else {
if (value === selectedValue) {
onValueChange(undefined);
} else {
onValueChange(selectedValue as FacetedValue<Multiple>);
}
requestAnimationFrame(() => onOpenChange(false));
}
},
[multiple, value, onValueChange, onOpenChange],
);
const contextValue = React.useMemo<FacetedContextValue<typeof multiple>>(
() => ({ value, onItemSelect, multiple }),
[value, onItemSelect, multiple],
);
return (
<FacetedContext.Provider value={contextValue}>
<Popover data-slot="faceted" open={open} onOpenChange={onOpenChange} {...facetedProps}>
{children}
</Popover>
</FacetedContext.Provider>
);
}
function FacetedTrigger(props: React.ComponentProps<typeof PopoverTrigger>) {
const { className, children, ...triggerProps } = props;
return (
<PopoverTrigger
data-slot="faceted-trigger"
{...triggerProps}
className={cn("justify-between text-left", className)}
>
{children}
</PopoverTrigger>
);
}
interface FacetedBadgeListProps extends React.ComponentProps<"div"> {
options?: { label: string; value: string }[];
max?: number;
badgeClassName?: string;
placeholder?: string;
}
function FacetedBadgeList(props: FacetedBadgeListProps) {
const {
options = [],
max = 2,
placeholder = "Select options...",
className,
badgeClassName,
...badgeListProps
} = props;
const context = useFacetedContext("FacetedBadgeList");
const values = Array.isArray(context.value)
? context.value
: ([context.value].filter(Boolean) as string[]);
const getLabel = React.useCallback(
(value: string) => {
const option = options.find((opt) => opt.value === value);
return option?.label ?? value;
},
[options],
);
if (!values || values.length === 0) {
return (
<div
data-slot="faceted-badge-list"
{...badgeListProps}
className="flex w-full items-center gap-1 text-muted-foreground"
>
{placeholder}
<ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50" />
</div>
);
}
return (
<div
data-slot="faceted-badge-list"
{...badgeListProps}
className={cn("flex flex-wrap items-center gap-1", className)}
>
{values.length > max ? (
<Badge
variant="secondary"
className={cn("rounded-sm px-1 font-normal", badgeClassName)}
>
{values.length} selected
</Badge>
) : (
values.map((value) => (
<Badge
key={value}
variant="secondary"
className={cn("rounded-sm px-1 font-normal", badgeClassName)}
>
<span className="truncate">{getLabel(value)}</span>
</Badge>
))
)}
</div>
);
}
function FacetedContent(props: React.ComponentProps<typeof PopoverContent>) {
const { className, children, ...contentProps } = props;
return (
<PopoverContent
data-slot="faceted-content"
{...contentProps}
align="start"
className={cn(
"w-[200px] origin-(--radix-popover-content.tsx-transform-origin) p-0",
className,
)}
>
<Command>{children}</Command>
</PopoverContent>
);
}
function FacetedInput(props: React.ComponentProps<typeof CommandInput>) {
return <CommandInput data-slot="faceted-input" {...props} />;
}
function FacetedList(props: React.ComponentProps<typeof CommandList>) {
return <CommandList data-slot="faceted-list" {...props} />;
}
function FacetedEmpty(props: React.ComponentProps<typeof CommandEmpty>) {
return <CommandEmpty data-slot="faceted-empty" {...props} />;
}
function FacetedGroup(props: React.ComponentProps<typeof CommandGroup>) {
return <CommandGroup data-slot="faceted-group" {...props} />;
}
interface FacetedItemProps extends React.ComponentProps<typeof CommandItem> {
value: string;
}
function FacetedItem(props: FacetedItemProps) {
const { value, onSelect, className, children, ...itemProps } = props;
const context = useFacetedContext("FacetedItem");
const isSelected = context.multiple
? Array.isArray(context.value) && context.value.includes(value)
: context.value === value;
const onItemSelect = React.useCallback(
(currentValue: string) => {
if (onSelect) {
onSelect(currentValue);
} else if (context.onItemSelect) {
context.onItemSelect(currentValue);
}
},
[onSelect, context.onItemSelect],
);
return (
<CommandItem
data-slot="faceted-item"
aria-selected={isSelected}
data-selected={isSelected}
className={cn("gap-2", className)}
onSelect={() => onItemSelect(value)}
{...itemProps}
>
<span
className={cn(
"flex size-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<Check className="size-4" />
</span>
{children}
</CommandItem>
);
}
function FacetedSeparator(props: React.ComponentProps<typeof CommandSeparator>) {
return <CommandSeparator data-slot="faceted-separator" {...props} />;
}
export {
Faceted,
FacetedBadgeList,
FacetedContent,
FacetedEmpty,
FacetedGroup,
FacetedInput,
FacetedItem,
FacetedList,
FacetedSeparator,
FacetedTrigger,
};
pnpm dlx codebase add faceted
import { Faceted } from "@/components/atom/faceted"<Faceted />