UNPKG

commonux2

Version:

A collection of styled components for use in ABB projects, designed for React and Next.js. It features TypeScript support, integrates Lucide icons, and is built on Radix primitives with Tailwind CSS.

346 lines (345 loc) 19.7 kB
"use client"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; import { Command as CommandPrimitive, useCommandState } from "cmdk"; import { X } from "lucide-react"; import * as React from "react"; import { forwardRef, useEffect } from "react"; import { Label } from "../label"; import { Badge } from "../badge"; import { Command, CommandGroup, CommandItem, CommandList } from "../command"; import { Checkbox } from "../checkbox"; import { cn } from "../../lib/utils"; export function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = React.useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay || 500); return () => { clearTimeout(timer); }; }, [value, delay]); return debouncedValue; } function transToGroupOption(options, groupBy) { if (options.length === 0) { return {}; } if (!groupBy) { return { "": options, }; } const groupOption = {}; options.forEach((option) => { const key = option[groupBy] || ""; if (!groupOption[key]) { groupOption[key] = []; } groupOption[key].push(option); }); return groupOption; } function isOptionsExist(groupOption, targetOption) { for (const [, value] of Object.entries(groupOption)) { if (value.some((option) => targetOption.find((p) => p.value === option.value))) { return true; } } return false; } /** * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. * So we create one and copy the `Empty` implementation from `cmdk`. * * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 **/ const CommandEmpty = forwardRef((_a, forwardedRef) => { var { className } = _a, props = __rest(_a, ["className"]); const render = useCommandState((state) => state.filtered.count === 0); if (!render) return null; return React.createElement("div", Object.assign({ ref: forwardedRef, className: cn("py-6 text-center text-sm", className), "cmdk-empty": "", role: "presentation" }, props)); }); CommandEmpty.displayName = "CommandEmpty"; const MultipleSelector = React.forwardRef(({ value, onChange, placeholder, defaultOptions: arrayDefaultOptions = [], options: arrayOptions, delay, onSearch, onSearchSync, loadingIndicator, emptyIndicator, maxSelected = Number.MAX_SAFE_INTEGER, onMaxSelected, hidePlaceholderWhenSelected, disabled, groupBy, className, badgeClassName, selectFirstItem = true, creatable = false, triggerSearchOnFocus = false, commandProps, inputProps, hideClearAllButton = false, label, description, id = "multiseltbox", variant, }, ref) => { const inputRef = React.useRef(null); const [open, setOpen] = React.useState(false); const [onScrollbar, setOnScrollbar] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); const dropdownRef = React.useRef(null); // Added this const [selected, setSelected] = React.useState(value || []); const [options, setOptions] = React.useState(transToGroupOption(arrayDefaultOptions, groupBy)); const [inputValue, setInputValue] = React.useState(""); const debouncedSearchTerm = useDebounce(inputValue, delay || 500); React.useImperativeHandle(ref, () => ({ selectedValue: [...selected], input: inputRef.current, focus: () => { var _a; return (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.focus(); }, }), [selected]); const handleClickOutside = (event) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target) && inputRef.current && !inputRef.current.contains(event.target)) { setOpen(false); } }; const handleUnselect = React.useCallback((option) => { const newOptions = selected.filter((s) => s.value !== option.value); setSelected(newOptions); onChange === null || onChange === void 0 ? void 0 : onChange(newOptions); }, [onChange, selected]); const handleKeyDown = React.useCallback((e) => { const input = inputRef.current; if (input) { if (e.key === "Delete" || e.key === "Backspace") { if (input.value === "" && selected.length > 0) { const lastSelectOption = selected[selected.length - 1]; // If last item is fixed, we should not remove it. if (!lastSelectOption.fixed) { handleUnselect(selected[selected.length - 1]); } } } // This is not a default behavior of the <input /> field if (e.key === "Escape") { input.blur(); } } }, [handleUnselect, selected]); useEffect(() => { if (open) { document.addEventListener("mousedown", handleClickOutside); document.addEventListener("touchend", handleClickOutside); } else { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("touchend", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("touchend", handleClickOutside); }; }, [open]); useEffect(() => { if (value) { setSelected(value); } }, [value]); useEffect(() => { /** If `onSearch` is provided, do not trigger options updated. */ if (!arrayOptions || onSearch) { return; } const newOption = transToGroupOption(arrayOptions || [], groupBy); if (JSON.stringify(newOption) !== JSON.stringify(options)) { setOptions(newOption); } }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); useEffect(() => { /** sync search */ const doSearchSync = () => { const res = onSearchSync === null || onSearchSync === void 0 ? void 0 : onSearchSync(debouncedSearchTerm); setOptions(transToGroupOption(res || [], groupBy)); }; const exec = () => __awaiter(void 0, void 0, void 0, function* () { if (!onSearchSync || !open) return; if (triggerSearchOnFocus) { doSearchSync(); } if (debouncedSearchTerm) { doSearchSync(); } }); void exec(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); useEffect(() => { /** async search */ const doSearch = () => __awaiter(void 0, void 0, void 0, function* () { setIsLoading(true); const res = yield (onSearch === null || onSearch === void 0 ? void 0 : onSearch(debouncedSearchTerm)); setOptions(transToGroupOption(res || [], groupBy)); setIsLoading(false); }); const exec = () => __awaiter(void 0, void 0, void 0, function* () { if (!onSearch || !open) return; if (triggerSearchOnFocus) { yield doSearch(); } if (debouncedSearchTerm) { yield doSearch(); } }); void exec(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); const CreatableItem = () => { if (!creatable) return undefined; if (isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || selected.find((s) => s.value === inputValue)) { return undefined; } const Item = (React.createElement(CommandItem, { value: inputValue, className: "cursor-pointer", onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); }, onSelect: (value) => { if (selected.length >= maxSelected) { onMaxSelected === null || onMaxSelected === void 0 ? void 0 : onMaxSelected(selected.length); return; } setInputValue(""); const newOptions = [...selected, { value, label: value }]; setSelected(newOptions); onChange === null || onChange === void 0 ? void 0 : onChange(newOptions); } }, `Create "${inputValue}"`)); // For normal creatable if (!onSearch && inputValue.length > 0) { return Item; } // For async search creatable. avoid showing creatable item before loading at first. if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { return Item; } return undefined; }; const EmptyItem = React.useCallback(() => { if (!emptyIndicator) return undefined; // For async search that showing emptyIndicator if (onSearch && !creatable && Object.keys(options).length === 0) { return (React.createElement(CommandItem, { value: "-", disabled: true }, emptyIndicator)); } return React.createElement(CommandEmpty, null, emptyIndicator); }, [creatable, emptyIndicator, onSearch, options]); const selectables = options; /** Avoid Creatable Selector freezing or lagging when paste a long string. */ const commandFilter = React.useCallback(() => { if (commandProps === null || commandProps === void 0 ? void 0 : commandProps.filter) { return commandProps.filter; } if (creatable) { return (value, search) => { return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; }; } // Using default filter in `cmdk`. We don't have to provide it. return undefined; }, [creatable, commandProps === null || commandProps === void 0 ? void 0 : commandProps.filter]); return (React.createElement("div", { className: "flex flex-col gap-1" }, label && (React.createElement(Label, { htmlFor: id }, React.createElement("p", null, label, " "))), React.createElement(Command, Object.assign({ ref: dropdownRef }, commandProps, { onKeyDown: (e) => { var _a; handleKeyDown(e); (_a = commandProps === null || commandProps === void 0 ? void 0 : commandProps.onKeyDown) === null || _a === void 0 ? void 0 : _a.call(commandProps, e); }, className: cn("h-auto overflow-visible bg-transparent", commandProps === null || commandProps === void 0 ? void 0 : commandProps.className), shouldFilter: (commandProps === null || commandProps === void 0 ? void 0 : commandProps.shouldFilter) !== undefined ? commandProps.shouldFilter : !onSearch, filter: commandFilter() }), React.createElement("div", { className: cn("min-h-10 max-h-16 overflow-auto rounded-md border text-sm border-[#dbdbdb]", { "px-3 py-2": selected.length !== 0, "cursor-text": !disabled && selected.length !== 0, }, className, variant === "error" && "border-red-600"), onClick: () => { var _a; if (disabled) return; (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.focus(); } }, React.createElement("div", { className: "relative flex flex-wrap gap-1" }, selected.map((option) => { return (React.createElement(Badge, { key: option.value, className: cn("bg-[#d6d6d6] text-black", "data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground", "data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground", badgeClassName), "data-fixed": option.fixed, "data-disabled": disabled || undefined }, option.label, React.createElement("button", { className: cn("ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2", (disabled || option.fixed) && "hidden"), onKeyDown: (e) => { if (e.key === "Enter") { handleUnselect(option); } }, onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); }, onClick: () => handleUnselect(option) }, React.createElement(X, { className: "w-3 h-3 text-muted-foreground hover:text-foreground" })))); }), React.createElement(CommandPrimitive.Input, Object.assign({}, inputProps, { ref: inputRef, value: inputValue, disabled: disabled, onValueChange: (value) => { var _a; setInputValue(value); (_a = inputProps === null || inputProps === void 0 ? void 0 : inputProps.onValueChange) === null || _a === void 0 ? void 0 : _a.call(inputProps, value); }, onBlur: (event) => { var _a; if (!onScrollbar) { setOpen(false); } (_a = inputProps === null || inputProps === void 0 ? void 0 : inputProps.onBlur) === null || _a === void 0 ? void 0 : _a.call(inputProps, event); }, onFocus: (event) => { var _a; setOpen(true); if (triggerSearchOnFocus) { onSearch === null || onSearch === void 0 ? void 0 : onSearch(debouncedSearchTerm); } (_a = inputProps === null || inputProps === void 0 ? void 0 : inputProps.onFocus) === null || _a === void 0 ? void 0 : _a.call(inputProps, event); }, placeholder: hidePlaceholderWhenSelected && selected.length !== 0 ? "" : placeholder, className: cn("flex-1 bg-transparent outline-none placeholder:text-muted-foreground", { "w-full": hidePlaceholderWhenSelected, "px-3 py-2": selected.length === 0, "ml-1": selected.length !== 0, }, inputProps === null || inputProps === void 0 ? void 0 : inputProps.className) })), React.createElement("button", { type: "button", onClick: () => { setSelected(selected.filter((s) => s.fixed)); onChange === null || onChange === void 0 ? void 0 : onChange(selected.filter((s) => s.fixed)); }, className: cn("absolute right-0 h-6 w-6 p-0", (hideClearAllButton || disabled || selected.length < 1 || selected.filter((s) => s.fixed).length === selected.length) && "hidden") }, React.createElement(X, null)))), React.createElement("div", { className: "relative" }, open && (React.createElement(CommandList, { className: cn("absolute top-1 z-10 w-[100%] rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in", className), onMouseLeave: () => { setOnScrollbar(false); }, onMouseEnter: () => { setOnScrollbar(true); }, onMouseUp: () => { var _a; (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.focus(); } }, isLoading ? (React.createElement(React.Fragment, null, loadingIndicator)) : (React.createElement(React.Fragment, null, EmptyItem(), CreatableItem(), !selectFirstItem && React.createElement(CommandItem, { value: "-", className: "hidden" }), Object.entries(selectables).map(([key, dropdowns]) => (React.createElement(CommandGroup, { key: key, heading: key, className: "h-full overflow-auto" }, React.createElement(React.Fragment, null, dropdowns.map((option) => { return (React.createElement(CommandItem, { key: option.value, value: option.value, disabled: option.disable, onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); }, onSelect: () => { if (selected.length >= maxSelected) { onMaxSelected === null || onMaxSelected === void 0 ? void 0 : onMaxSelected(selected.length); return; } setInputValue(""); const isSelected = selected.find((value) => value === option); if (isSelected) { handleUnselect(option); } else { const newOptions = [...selected, option]; setSelected(newOptions); onChange === null || onChange === void 0 ? void 0 : onChange(newOptions); } }, className: cn("cursor-pointer", option.disable && "cursor-default text-muted-foreground") }, React.createElement(Checkbox, { id: option.value, checked: selected.find((item) => item.value === option.value) ? true : false }), React.createElement("div", { className: "grid gap-1.5 leading-none" }, React.createElement("label", { htmlFor: option.value, className: "ml-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" }, option.label)))); }))))))))))), description && React.createElement("p", { className: cn("text-[14px] text-muted-foreground", variant == "error" && "text-destructive") }, description))); }); MultipleSelector.displayName = "MultipleSelector"; export { MultipleSelector };