shadimage

Select

A select component with searchable options and custom styling

registry/preskok/ui/preskok-ui/select.tsx

"use client"

import { twJoin } from "cn"
import { ChevronsUpDownIcon } from "lucide-react"
import type {
  ListBoxProps,
  PopoverProps,
  SelectProps as SelectPrimitiveProps,
} from "react-aria-components/Select"
import {
  Button,
  ListBox,
  Select as SelectPrimitive,
  SelectValue,
} from "react-aria-components/Select"

import { cx } from "@/lib/primitive"

import {
  DropdownDescription,
  DropdownItem,
  DropdownLabel,
  DropdownSection,
  DropdownSeparator,
} from "./dropdown"
import { fieldStyles } from "./field"
import { PopoverContent } from "./popover"

interface SelectProps<
  T extends object,
  M extends "single" | "multiple" = "single",
> extends SelectPrimitiveProps<T, M> {
  items?: Iterable<T, M>
}

const Select = <T extends object, M extends "single" | "multiple" = "single">({
  className,
  ...props
}: SelectProps<T, M>) => {
  return (
    <SelectPrimitive
      data-slot="control"
      className={cx(fieldStyles({ className: "group/select" }), className)}
      {...props}
    />
  )
}

interface SelectListProps<T extends object> extends Omit<
  ListBoxProps<T>,
  "layout" | "orientation"
> {
  items?: Iterable<T>
  popover?: Omit<PopoverProps, "children">
}

const SelectContent = <T extends object>({
  items,
  className,
  popover,
  ...props
}: SelectListProps<T>) => {
  return (
    <PopoverContent
      placement={popover?.placement ?? "bottom"}
      className={cx(
        "min-w-(--trigger-width) scroll-py-1 overflow-y-auto overscroll-contain",
        popover?.className
      )}
      {...popover}
    >
      <ListBox
        layout="stack"
        orientation="vertical"
        className={cx(
          "grid max-h-96 w-full grid-cols-[auto_1fr] flex-col gap-y-1 p-1 outline-hidden *:[[role='group']+[role=group]]:mt-4 *:[[role='group']+[role=separator]]:mt-1",
          className
        )}
        items={items}
        {...props}
      />
    </PopoverContent>
  )
}

interface SelectTriggerProps extends React.ComponentProps<typeof Button> {
  prefix?: React.ReactNode
  className?: string
}

const SelectTrigger = ({
  children,
  className,
  ...props
}: SelectTriggerProps) => {
  return (
    <span data-slot="control" className="relative block w-full">
      <Button
        className={cx(
          "group/select-trigger border-input text-foreground flex w-full min-w-0 cursor-default items-center gap-x-2 rounded-lg border px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] text-start outline-hidden transition duration-200 sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6 sm:*:text-sm/6 dark:shadow-none",
          "focus:border-ring/70 focus:ring-ring/20 focus:enabled:hover:border-ring/80 focus:ring-3",
          "enabled:hover:border-muted-foreground/30",
          "group-open/select:border-ring/70 group-open/select:ring-ring/20 group-open/select:ring-3",
          "group-open/select:invalid:border-destructive/70 group-open/select:invalid:ring-destructive/20 group-invalid/select:border-destructive/70 group-invalid/select:ring-destructive/20 group-invalid/select:enabled:hover:border-destructive/80 group-focus/select:group-invalid/select:border-destructive/70 group-focus/select:group-invalid/select:ring-destructive/20 group-focus/select:group-invalid/select:enabled:hover:border-destructive/80 group-open/select:invalid:ring-3",
          "*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center sm:*:data-[slot=icon]:size-4",
          "*:data-[slot=loader]:size-5 *:data-[slot=loader]:shrink-0 *:data-[slot=loader]:self-center sm:*:data-[slot=loader]:size-4",
          "forced-colors:group-invalid/select:border-[Mark] forced-colors:group-focus/select:border-[Highlight] forced-colors:group-focus/select:group-invalid/select:border-[Mark]",
          "group-disabled/select:bg-muted group-disabled/select:opacity-50 forced-colors:group-disabled/select:border-[GrayText] forced-colors:group-disabled/select:text-[GrayText]",
          "in-disabled:bg-muted in-disabled:opacity-50 forced-colors:in-disabled:border-[GrayText] forced-colors:in-disabled:text-[GrayText]",
          "dark:scheme-dark",
          className
        )}
      >
        {(values) => (
          <>
            {props.prefix && (
              <span className="text-muted-foreground">{props.prefix}</span>
            )}
            {typeof children === "function" ? children(values) : children}

            {!children && (
              <>
                <SelectValue
                  data-slot="select-value"
                  className={twJoin([
                    "data-placeholder:text-muted-foreground truncate text-start sm:text-sm/6 [&_[slot=description]]:hidden",
                    "has-data-[slot=avatar]:grid has-data-[slot=avatar]:grid-cols-[1fr_auto] has-data-[slot=avatar]:items-center has-data-[slot=avatar]:gap-x-2",
                    "has-data-[slot=icon]:grid has-data-[slot=icon]:grid-cols-[1fr_auto] has-data-[slot=icon]:items-center has-data-[slot=icon]:gap-x-2",
                    "*:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4",
                    "*:data-[slot=avatar]:[--avatar-size:--spacing(5)] sm:*:data-[slot=avatar]:[--avatar-size:--spacing(4.5)]",
                  ])}
                />
                <ChevronsUpDownIcon
                  data-slot="chevron"
                  className="-mr-1 ml-auto size-5 text-muted-foreground sm:size-4"
                />
              </>
            )}
          </>
        )}
      </Button>
    </span>
  )
}

const SelectSection = DropdownSection
const SelectSeparator = DropdownSeparator
const SelectLabel = DropdownLabel
const SelectDescription = DropdownDescription
const SelectItem = DropdownItem

export {
  Select,
  SelectContent,
  SelectDescription,
  SelectItem,
  SelectLabel,
  SelectSection,
  SelectSeparator,
  SelectTrigger,
}
export type { SelectProps, SelectTriggerProps }
Interaction
dropdown

DependenciesBundle weight · light

icons
lucide-react