Multi Select

Categorystyled
ArchitectureReact Client Component
Dependencies

Usage

Simple Demo

Styled Demo

Install Instructions

Copy and paste the code into that file

import {
  type ComponentPropsWithoutRef,
  type ElementType,
  type MutableRefObject,
  type ReactNode,
  createContext,
  createElement,
  useCallback,
  useContext,
  useRef,
} from "react";

//----- Third-Party Utils -----//
import { twMerge } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";

//----- MultiSelect Context -----//
interface MultiSelectContextInterface {
  selected?: string[] | undefined;
  onSelected?: undefined | ((v: string[]) => void);
  onButtonClick: (inputRef: MutableRefObject<HTMLInputElement | null>) => void;
  disabled?: boolean;
  icon?: ReactNode;
}

const MultiSelectContext = createContext<MultiSelectContextInterface>({
  selected: undefined,
  onSelected: () => {},
  onButtonClick: () => {},
  disabled: false,
  icon: undefined,
});

//----- MultiSelect -----//
interface MultiSelectProps extends ComponentPropsWithoutRef<"div"> {
  label: string;
  selected?: string[];
  onSelected?: undefined | ((v: string[]) => void);
  disabled?: boolean;
  icon?: ReactNode;
}

export const MultiSelect = ({
  id,
  label,
  children,
  selected,
  onSelected,
  disabled = false,
  icon,
  ...props
}: MultiSelectProps) => {
  const onButtonClick = useCallback<
    MultiSelectContextInterface["onButtonClick"]
  >(
    (inputRef) => {
      if (disabled) return;
      const { current: input } = inputRef;

      if (input === null) return;
      input.click();

      const targetId = input.id;

      // Toggling the state of the button
      const button = document.querySelector(`[data-button-for=${targetId}]`);
      const currState = button?.getAttribute("data-selected") === "true";
      button?.setAttribute("data-selected", `${!currState}`);

      if (onSelected && selected) {
        onSelected(
          !currState
            ? [...selected, targetId]
            : selected.filter((item) => item !== targetId)
        );
      }
      (button as HTMLElement | null)?.blur();
    },
    [selected, onSelected, disabled]
  );

  return (
    <div id={id} {...props}>
      <span>
        <label>{label}</label>
      </span>
      <MultiSelectContext.Provider
        value={{
          selected,
          onSelected,
          onButtonClick,
          disabled,
          icon,
        }}
      >
        {children}
      </MultiSelectContext.Provider>
    </div>
  );
};

//----- SelectItem -----//
interface BaseSelectItemProps<T extends ElementType> {
  label: string;
  id: string;
  displayLabel?: boolean;
  children?: ReactNode;
  component?: T;
  wrapperClassName?: string;
  disabled?: boolean;
}

const selectButtonClassNames = cva(["not-prose", "relative"], {
  variants: {
    styled: {
      false: ["[&_*]:pointer-events-none"],
      true: [
        "shadow-neutral-100",
        "text-neutral-100",
        "dark:text-neutral-400",
        "border-transparent",
        "dark:bg-neutral-600",
        "bg-neutral-200",
        "data-[selected=true]:bg-primary-200",
        "dark:data-[selected=true]:bg-primary-400",
        "rounded-lg",
        "transition-all",
        "[box-shadow:_0_0_0_2px_transparent]",
        "md:hover:data-[selected=false]:[box-shadow:_0_0_0_2px_theme(colors.primary.400/75%)]",
        "focus:data-[selected=false]:[box-shadow:_0_0_0_2px_theme(colors.primary.400/75%)]",
        "[&[data-selected=false]_*]:opacity-0",
        "dark:[&[data-selected=true]_*]:text-neutral-800",
        "[&[data-selected=true]_*]:text-neutral-600",
        "[&[data-selected=true]_*]:transition-all",
        "[&[data-selected=true]_*]:duration-300",
      ],
    },
    styledWithIcon: {
      true: ["p-1"],
      false: [],
    },
    styledWithoutIcon: {
      true: ["w-4", "h-4"],
      false: [],
    },
  },
  defaultVariants: {
    styled: true,
    styledWithIcon: false,
    styledWithoutIcon: false,
  },
});
const selectItemClassNames = cva(
  ["flex", "gap-2", "justify-start", "items-center", "content-center"],
  {
    variants: {
      stacking: {
        vertical: ["flex-col"],
        horizontal: [],
      },
    },
    defaultVariants: {
      stacking: "horizontal",
    },
  }
);

type SelectItemProps<T extends ElementType> = BaseSelectItemProps<T> &
  Omit<ComponentPropsWithoutRef<T>, keyof BaseSelectItemProps<T>> &
  VariantProps<typeof selectItemClassNames> &
  VariantProps<typeof selectButtonClassNames>;

export const SelectItem = <T extends ElementType = "button">({
  label,
  displayLabel = false,
  children,
  id,
  component,
  wrapperClassName = "",
  className,
  stacking,
  styled = true,
  disabled = false,
  ...props
}: SelectItemProps<T>) => {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const {
    onButtonClick,
    selected,
    disabled: wrapperDisabled,
    icon,
  } = useContext(MultiSelectContext);

  return (
    <div
      className={twMerge(selectItemClassNames({ stacking }), wrapperClassName)}
    >
      {createElement(
        component ? component : "button",
        {
          ...props,
          className: twMerge(
            selectButtonClassNames({
              styled,
              styledWithIcon: styled && typeof icon !== "undefined",
              styledWithoutIcon: styled && typeof icon === "undefined",
            }),
            className ? className : ""
          ),
          onClick:
            !disabled && !wrapperDisabled
              ? onButtonClick.bind(null, inputRef)
              : undefined,
          "data-selected": selected ? selected.includes(id) : false,
          "data-button-for": id,
          "data-disabled": wrapperDisabled || disabled,
        },
        typeof children === "undefined" && typeof icon !== "undefined"
          ? icon
          : children
      )}
      <input
        ref={inputRef}
        id={id}
        type="checkbox"
        style={{ display: "none" }}
      />
      <label
        style={
          !displayLabel
            ? {
                fontSize: "0rem",
                userSelect: "none",
                opacity: 0,
                color: "transparent",
                pointerEvents: "none",
                width: "0px",
                maxWidth: "0px",
                overflow: "hidden",
                position: "absolute",
              }
            : undefined
        }
        onClick={(e) => {
          e.preventDefault();
          onButtonClick(inputRef);
        }}
        htmlFor={id}
        className="text-sm hover:cursor-pointer hover:dark:text-primary-200 hover:text-primary-400 transition-colors select-none"
      >
        {label}
      </label>
    </div>
  );
};

//----- Added Exports -----//
MultiSelect.Item = SelectItem;

Loading...