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;