393 lines · 13.1 KB · TypeScript
RawDownload
"use client"; import * as React from "react";import { Combobox as ComboboxPrimitive } from "@base-ui/react";import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; import { cn } from "../lib/utils";import { Button } from "./button";import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "./input-group"; const Combobox = ComboboxPrimitive.Root; function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {  return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;} function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {  return (    <ComboboxPrimitive.Trigger      data-slot="combobox-trigger"      className={cn("[&_svg:not([class*='size-'])]:size-4", className)}      {...props}    >      {children}      <ChevronDownIcon        data-slot="combobox-trigger-icon"        className="pointer-events-none size-4 text-muted-foreground"      />    </ComboboxPrimitive.Trigger>  );} function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {  return (    <ComboboxPrimitive.Clear      data-slot="combobox-clear"      render={<InputGroupButton variant="ghost" size="icon-xs" />}      className={cn(className)}      {...props}    >      <XIcon className="pointer-events-none" />    </ComboboxPrimitive.Clear>  );} function ComboboxInput({  className,  inputClassName,  children,  disabled = false,  showTrigger = true,  showClear = false,  ...props}: ComboboxPrimitive.Input.Props & {  inputClassName?: string;  showTrigger?: boolean;  showClear?: boolean;}) {  return (    <InputGroup className={cn("w-auto", className)}>      <ComboboxPrimitive.Input        render={<InputGroupInput disabled={disabled} className={inputClassName} />}        {...props}      />      <InputGroupAddon align="inline-end">        {showTrigger && (          <InputGroupButton            size="icon-xs"            variant="ghost"            asChild            data-slot="input-group-button"            className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"            disabled={disabled}          >            <ComboboxTrigger />          </InputGroupButton>        )}        {showClear && <ComboboxClear disabled={disabled} />}      </InputGroupAddon>      {children}    </InputGroup>  );} function ComboboxContent({  className,  side = "bottom",  sideOffset = 6,  align = "start",  alignOffset = 0,  anchor,  ...props}: ComboboxPrimitive.Popup.Props &  Pick<    ComboboxPrimitive.Positioner.Props,    "side" | "align" | "sideOffset" | "alignOffset" | "anchor"  >) {  return (    <ComboboxPrimitive.Portal>      <ComboboxPrimitive.Positioner        side={side}        sideOffset={sideOffset}        align={align}        alignOffset={alignOffset}        anchor={anchor}        // `pointer-events-auto` re-enables interaction when the popup is portaled        // out of a modal dialog (Radix sets `pointer-events: none` on the body        // for modal dialogs/sheets; without this the portaled list can't be        // scrolled or clicked). Harmless outside a dialog (auto is the default).        className="isolate z-50 pointer-events-auto"      >        <ComboboxPrimitive.Popup          data-slot="combobox-content"          data-chips={!!anchor}          className={cn(            "group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) 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 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",            className,          )}          {...props}        />      </ComboboxPrimitive.Positioner>    </ComboboxPrimitive.Portal>  );} function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {  return (    <ComboboxPrimitive.List      data-slot="combobox-list"      className={cn(        "max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",        className,      )}      {...props}    />  );} function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {  return (    <ComboboxPrimitive.Item      data-slot="combobox-item"      className={cn(        "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",        className,      )}      {...props}    >      {children}      <ComboboxPrimitive.ItemIndicator        data-slot="combobox-item-indicator"        render={          <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />        }      >        <CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />      </ComboboxPrimitive.ItemIndicator>    </ComboboxPrimitive.Item>  );} function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {  return (    <ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />  );} function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {  return (    <ComboboxPrimitive.GroupLabel      data-slot="combobox-label"      className={cn(        "px-2 py-1.5 text-xs text-muted-foreground pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",        className,      )}      {...props}    />  );} function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {  return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;} function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {  return (    <ComboboxPrimitive.Empty      data-slot="combobox-empty"      className={cn(        "hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",        className,      )}      {...props}    />  );} function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {  return (    <ComboboxPrimitive.Separator      data-slot="combobox-separator"      className={cn("-mx-1 my-1 h-px bg-border", className)}      {...props}    />  );} function ComboboxChips({  className,  ...props}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {  return (    <ComboboxPrimitive.Chips      data-slot="combobox-chips"      className={cn(        "flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",        className,      )}      {...props}    />  );} function ComboboxChip({  className,  children,  showRemove = true,  ...props}: ComboboxPrimitive.Chip.Props & {  showRemove?: boolean;}) {  return (    <ComboboxPrimitive.Chip      data-slot="combobox-chip"      className={cn(        "flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",        className,      )}      {...props}    >      {children}      {showRemove && (        <ComboboxPrimitive.ChipRemove          render={<Button variant="ghost" size="icon-xs" />}          className="-ml-1 opacity-50 hover:opacity-100"          data-slot="combobox-chip-remove"        >          <XIcon className="pointer-events-none" />        </ComboboxPrimitive.ChipRemove>      )}    </ComboboxPrimitive.Chip>  );} function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {  return (    <ComboboxPrimitive.Input      data-slot="combobox-chip-input"      className={cn("min-w-16 flex-1 outline-none", className)}      {...props}    />  );} function useComboboxAnchor() {  return React.useRef<HTMLDivElement | null>(null);} export interface FreeformComboboxOption {  readonly value: string;  readonly label?: React.ReactNode;  readonly description?: React.ReactNode;} /** How many options the popup renders at once. base-ui slices the filtered list *  to this, so a multi-thousand-operation spec stays cheap: the empty-query open *  shows the top slice, and typing narrows to the top matches. */const FREEFORM_COMBOBOX_LIMIT = 100; function FreeformCombobox(props: {  readonly value: string;  readonly onValueChange: (value: string) => void;  readonly options: readonly FreeformComboboxOption[];  readonly placeholder?: string;  readonly emptyLabel?: React.ReactNode;  readonly className?: string;  readonly inputClassName?: string;  readonly disabled?: boolean;  /** Forwarded to the underlying text input so a `<Label htmlFor>` can target it   *  (and tests get a stable selector). */  readonly id?: string;}) {  const valueOption = props.value.trim();  const propsOptions = props.options;  const options = React.useMemo<readonly FreeformComboboxOption[]>(    () =>      valueOption.length > 0 && !propsOptions.some((option) => option.value === valueOption)        ? [{ value: valueOption, label: valueOption }, ...propsOptions]        : propsOptions,    [valueOption, propsOptions],  );  const selectedValue = options.some((option) => option.value === props.value) ? props.value : null;   const byValue = React.useMemo(() => {    const map = new Map<string, FreeformComboboxOption>();    for (const option of options) map.set(option.value, option);    return map;  }, [options]);  const items = React.useMemo(() => options.map((option) => option.value), [options]);   // base-ui only filters the popup when the list renders from `items` (the  // function-child / closed-template path below); a statically-mapped list never  // narrows as you type. Match the query against the value AND its string  // label/description, so typing a summary word or the human label finds the  // option, not just its raw id.  const filter = React.useCallback(    (item: string, query: string) => {      const needle = query.trim().toLowerCase();      if (needle === "") return true;      const option = byValue.get(item);      const haystacks = [        item,        typeof option?.label === "string" ? option.label : "",        typeof option?.description === "string" ? option.description : "",      ];      return haystacks.some((part) => part.toLowerCase().includes(needle));    },    [byValue],  );   return (    <Combobox      items={items}      inputValue={props.value}      value={selectedValue}      filter={filter}      limit={FREEFORM_COMBOBOX_LIMIT}      onInputValueChange={props.onValueChange}      onValueChange={(value) => {        if (value !== null) props.onValueChange(value);      }}    >      <ComboboxInput        id={props.id}        placeholder={props.placeholder}        className={props.className}        inputClassName={props.inputClassName}        disabled={props.disabled}        showClear={props.value.length > 0}      />      <ComboboxContent>        <ComboboxEmpty>{props.emptyLabel ?? "No options"}</ComboboxEmpty>        <ComboboxList>          {(item: string) => {            const option = byValue.get(item);            return (              <ComboboxItem key={item} value={item}>                <div className="min-w-0 flex-1">                  <div className="truncate">{option?.label ?? item}</div>                  {option?.description && (                    <div className="mt-0.5 truncate text-[10px] text-muted-foreground">                      {option.description}                    </div>                  )}                </div>              </ComboboxItem>            );          }}        </ComboboxList>      </ComboboxContent>    </Combobox>  );} export {  Combobox,  ComboboxInput,  ComboboxContent,  ComboboxList,  ComboboxItem,  ComboboxGroup,  ComboboxLabel,  ComboboxCollection,  ComboboxEmpty,  ComboboxSeparator,  ComboboxChips,  ComboboxChip,  ComboboxChipsInput,  ComboboxTrigger,  ComboboxValue,  FreeformCombobox,  useComboboxAnchor,};