next.js/apps/bundle-analyzer/components/ui/multi-select.tsx
multi-select.tsx188 lines5.3 KB
'use client'

import * as React from 'react'
import { Button } from '@/components/ui/button'
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover'
import { ChevronDown, CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'

export interface MultiSelectPropOption {
  value: string
  label: string
  icon?: React.ReactNode
}

interface MultiSelectProps {
  options: MultiSelectPropOption[]
  value: string[]
  onValueChange: (value: string[]) => void
  placeholder?: string
  emptyMessage?: string
  selectionName?: {
    singular: string
    plural: string
  }
  className?: string
  triggerClassName?: string
  triggerIcon?: React.ReactNode
  'aria-label'?: string
  size?: 'sm' | 'default'
}

export function MultiSelect({
  options,
  value,
  onValueChange,
  placeholder = 'Select items...',
  selectionName = {
    singular: 'item',
    plural: 'items',
  },
  className,
  triggerClassName,
  triggerIcon,
  'aria-label': ariaLabel,
}: MultiSelectProps) {
  const [open, setOpen] = React.useState(false)

  function handleToggle(optionValue: string, checked: boolean) {
    const newValue = checked
      ? [...value, optionValue]
      : value.filter((v) => v !== optionValue)
    if (newValue.length > 0) {
      onValueChange(newValue)
    }
  }

  let displayText: string
  if (value.length === options.length) {
    displayText = `All ${selectionName.plural}`
  } else if (value.length === 0) {
    displayText = placeholder
  } else {
    displayText = `${value.length} ${value.length === 1 ? selectionName.singular : selectionName.plural}`
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          className={cn(
            'justify-between border focus-visible:ring-[3px] focus-visible:ring-ring/50 font-normal px-3 py-2 h-9 text-xs',
            triggerClassName
          )}
          role="combobox"
          aria-expanded={open}
          aria-haspopup="dialog"
          aria-label={ariaLabel}
          onKeyDown={(e) => {
            if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !open) {
              e.preventDefault()
              setOpen(true)
            }
          }}
        >
          <div className="flex items-center gap-1">
            {triggerIcon && (
              <span
                className="shrink-0 text-muted-foreground"
                aria-hidden="true"
              >
                {triggerIcon}
              </span>
            )}
            <span>{displayText}</span>
          </div>
          <ChevronDown className="h-3.5 w-3.5 opacity-50" aria-hidden="true" />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        className={cn('w-48 p-2', className)}
        align="end"
        onKeyDown={(e) => {
          if (e.key === 'Escape') {
            e.preventDefault()
            setOpen(false)
            return
          }

          const checkboxes = Array.from(
            e.currentTarget.querySelectorAll('input[type="checkbox"]')
          ) as HTMLInputElement[]
          const currentIndex = checkboxes.indexOf(
            document.activeElement as HTMLInputElement
          )

          if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
            e.preventDefault()
            if (currentIndex === -1) {
              checkboxes[0]?.focus()
            } else {
              const nextIndex =
                e.key === 'ArrowDown'
                  ? (currentIndex + 1) % checkboxes.length
                  : (currentIndex - 1 + checkboxes.length) % checkboxes.length
              checkboxes[nextIndex]?.focus()
            }
          }
        }}
      >
        <fieldset className="space-y-2">
          <legend className="sr-only">{ariaLabel || placeholder}</legend>
          {options.map((option) => (
            <MultiSelectOption
              key={option.value}
              label={option.label}
              icon={option.icon}
              checked={value.includes(option.value)}
              onChange={(checked) => handleToggle(option.value, checked)}
            />
          ))}
        </fieldset>
      </PopoverContent>
    </Popover>
  )
}

function MultiSelectOption({
  label,
  icon,
  checked,
  onChange,
}: {
  label: string
  icon?: React.ReactNode
  checked: boolean
  onChange: (checked: boolean) => void
}) {
  return (
    <label
      className={cn(
        "text-xs hover:bg-accent hover:text-accent-foreground has-[input:focus-visible]:bg-accent has-[input:focus-visible]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 select-none outline-hidden [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
      )}
    >
      <input
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.target.checked)}
        className="sr-only peer focus:outline-hidden focus-visible:outline-2"
        aria-label={label}
      />
      {icon && (
        <span className="shrink-0" aria-hidden="true">
          {icon}
        </span>
      )}
      <span className="flex items-center gap-2">{label}</span>
      <span className="absolute right-2 flex size-3.5 items-center justify-center">
        {checked && <CheckIcon className="size-4" />}
      </span>
    </label>
  )
}
Quest for Codev2.0.0
/
SIGN IN