Accordion Multiselect

Next

An accordion multiselect component with categories and services.

Docs
abuicomponent

Preview

Loading preview…
registry/abui/ui/accordion-multiselect.tsx
"use client"

import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"

// --- Architecture: Primitives (Unstyled but behaving) ---
// We are using Radix UI Accordion and Checkbox primitives directly.
// This component acts as a "Component" layer in the architecture:
// - Wraps primitives with styling
// - Supports controlled/uncontrolled usage
// - Uses data-attributes for state and slots for targeting

// --- Context ---
// We use a context to share selection state between the root and items/checkboxes
// This allows for the Compound Component pattern.
interface AccordionMultiselectContextValue {
  selectedValues: string[]
  onSelectionChange: (value: string, checked: boolean) => void
}

const AccordionMultiselectContext = React.createContext<AccordionMultiselectContextValue | null>(null)

function useAccordionMultiselect() {
  const context = React.useContext(AccordionMultiselectContext)
  if (!context) {
    throw new Error("AccordionMultiselect subcomponents must be used within AccordionMultiselect")
  }
  return context
}

// --- Components ---

// We can't directly extend AccordionPrimitive.Root because it has specific props that might conflict or be typed in a way TS doesn't like for intersection
// Instead, we'll define our props and intersect with the primitive's props, excluding what we override/don't need if necessary
type AccordionRootProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root>

interface AccordionMultiselectProps
  extends Omit<AccordionRootProps, "type" | "value" | "defaultValue" | "onValueChange"> {
  value?: string[]
  onValueChange?: (value: string[]) => void
  defaultValue?: string[]
}

function AccordionMultiselect({
  value: controlledValue,
  onValueChange,
  defaultValue = [],
  className,
  children,
  ...props
}: AccordionMultiselectProps) {
  // Controlled/Uncontrolled pattern
  const [internalValue, setInternalValue] = React.useState<string[]>(defaultValue)
  const isControlled = controlledValue !== undefined
  const selectedValues = isControlled ? controlledValue : internalValue

  const handleSelectionChange = React.useCallback(
    (value: string, checked: boolean) => {
      let newValues: string[]
      if (checked) {
        newValues = [...selectedValues, value]
      } else {
        newValues = selectedValues.filter(id => id !== value)
      }

      if (!isControlled) {
        setInternalValue(newValues)
      }
      onValueChange?.(newValues)
    },
    [selectedValues, isControlled, onValueChange],
  )

  return (
    <AccordionMultiselectContext.Provider value={{ selectedValues, onSelectionChange: handleSelectionChange }}>
      <AccordionPrimitive.Root
        type="multiple"
        data-slot="accordion-multiselect-root"
        className={cn("w-full space-y-4", className)}
        {...props}
      >
        {children}
      </AccordionPrimitive.Root>
    </AccordionMultiselectContext.Provider>
  )
}

function AccordionMultiselectItem({
  className,
  ...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>) {
  return (
    <AccordionPrimitive.Item
      data-slot="accordion-multiselect-item"
      className={cn("border-b-0", className)}
      {...props}
    />
  )
}

function AccordionMultiselectTrigger({
  className,
  children,
  ...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>) {
  return (
    <AccordionPrimitive.Header className="flex">
      <AccordionPrimitive.Trigger
        data-slot="accordion-multiselect-trigger"
        className={cn(
          "flex flex-1 items-center justify-between py-2 font-semibold text-base transition-all hover:no-underline [&[data-state=open]>svg]:rotate-180 cursor-pointer",
          className,
        )}
        {...props}
      >
        {children}
        <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 text-muted-foreground" />
      </AccordionPrimitive.Trigger>
    </AccordionPrimitive.Header>
  )
}

function AccordionMultiselectContent({
  className,
  children,
  ...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>) {
  return (
    <AccordionPrimitive.Content
      data-slot="accordion-multiselect-content"
      className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
      {...props}
    >
      <div className={cn("pt-2 pb-4 flex flex-col gap-2", className)}>{children}</div>
    </AccordionPrimitive.Content>
  )
}

// --- Sub-components for Item Options ---

interface AccordionMultiselectOptionProps extends React.HTMLAttributes<HTMLDivElement> {
  value: string
  showCheckbox?: boolean
  children: React.ReactNode
}

function AccordionMultiselectOption({
  value,
  showCheckbox = false,
  className,
  onClick,
  children,
  ...props
}: AccordionMultiselectOptionProps) {
  const { selectedValues, onSelectionChange } = useAccordionMultiselect()
  const isSelected = selectedValues.includes(value)

  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
    onSelectionChange(value, !isSelected)
    onClick?.(e)
  }

  return (
    <div
      data-slot="accordion-multiselect-option"
      data-state={isSelected ? "checked" : "unchecked"}
      className={cn(
        "flex items-start space-x-3 rounded-md group cursor-pointer transition-all border border-transparent",
        "hover:border-foreground/10 data-[state=checked]:bg-foreground/6",
        className,
      )}
      onClick={handleClick}
      {...props}
    >
      {showCheckbox && (
        <div className="flex items-center h-5 mt-1">
          <CheckboxPrimitive.Root
            id={value}
            checked={isSelected}
            onCheckedChange={checked => onSelectionChange(value, checked as boolean)}
            onClick={e => e.stopPropagation()}
            className={cn(
              "peer h-4 w-4 shrink-0 rounded-[3px] border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
            )}
          >
            <CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
              <Check className="h-4 w-4" />
            </CheckboxPrimitive.Indicator>
          </CheckboxPrimitive.Root>
        </div>
      )}
      <div className="flex-1">{children}</div>
    </div>
  )
}

export {
  AccordionMultiselect,
  AccordionMultiselectItem,
  AccordionMultiselectTrigger,
  AccordionMultiselectContent,
  AccordionMultiselectOption,
}

Installation

npx shadcn@latest add @abui/accordion-multiselect

Usage

import { AccordionMultiselect } from "@/components/accordion-multiselect"
<AccordionMultiselect />