diff --git a/packages/ui/src/dropdown/Readme.md b/packages/ui/src/dropdown/Readme.md new file mode 100644 index 000000000..314347b1e --- /dev/null +++ b/packages/ui/src/dropdown/Readme.md @@ -0,0 +1,44 @@ +Below is a detailed list of the props included: + +### Root Props +- value: string | string[]; - Current selected value. +- onChange: (value: string | string []) => void; - Callback function for handling value changes. +- options: TDropdownOption[] | undefined; - Array of options. +- onOpen?: () => void; - Callback function triggered when the dropdown opens. +- onClose?: () => void; - Callback function triggered when the dropdown closes. +- containerClassName?: (isOpen: boolean) => string; - Function to return the class name for the container based on the open state. +- tabIndex?: number; - Sets the tab index for the dropdown. +- placement?: Placement; - Determines the placement of the dropdown (e.g., top, bottom, left, right). +- disabled?: boolean; - Disables the dropdown if set to true. + +--- + +### Button Props +- buttonContent?: (isOpen: boolean) => React.ReactNode; - Function to render the content of the button based on the open state. +- buttonContainerClassName?: string; - Class name for the button container. +- buttonClassName?: string; - Class name for the button itself. + +--- + +### Input Props +- disableSearch?: boolean; - Disables the search input if set to true. +- inputPlaceholder?: string; - Placeholder text for the search input. +- inputClassName?: string; - Class name for the search input. +- inputIcon?: React.ReactNode; - Icon to be displayed in the search input. +- inputContainerClassName?: string; - Class name for the search input container. + +--- + +### Options Props +- keyExtractor: (option: TDropdownOption) => string; - Function to extract the key from each option. +- optionsContainerClassName?: string; - Class name for the options container. +- queryArray: string[]; - Array of strings to be used for querying the options. +- sortByKey: string; - Key to sort the options by. +- firstItem?: (optionValue: string) => boolean; - Function to determine if an option should be the first item. +- renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; - Function to render each option. +- loader?: React.ReactNode; - Loader element to be displayed while options are being loaded. +- disableSorting?: boolean; - Disables sorting of the options if set to true. + +--- + +These properties offer extensive control over the dropdown's behavior and presentation, making it a highly versatile component suitable for various scenarios. \ No newline at end of file diff --git a/packages/ui/src/dropdown/common/button.tsx b/packages/ui/src/dropdown/common/button.tsx new file mode 100644 index 000000000..c0a4627e9 --- /dev/null +++ b/packages/ui/src/dropdown/common/button.tsx @@ -0,0 +1,38 @@ +import React, { Fragment } from "react"; +// headless ui +import { Combobox } from "@headlessui/react"; +// helper +import { cn } from "../../../helpers"; +import { IMultiSelectDropdownButton, ISingleSelectDropdownButton } from "../dropdown"; + +export const DropdownButton: React.FC = (props) => { + const { + isOpen, + buttonContent, + buttonClassName, + buttonContainerClassName, + handleOnClick, + value, + setReferenceElement, + disabled, + } = props; + return ( + + + + ); +}; diff --git a/packages/ui/src/dropdown/common/index.ts b/packages/ui/src/dropdown/common/index.ts new file mode 100644 index 000000000..f9a6d7388 --- /dev/null +++ b/packages/ui/src/dropdown/common/index.ts @@ -0,0 +1,4 @@ +export * from "./input-search"; +export * from "./button"; +export * from "./options"; +export * from "./loader"; diff --git a/packages/ui/src/dropdown/common/input-search.tsx b/packages/ui/src/dropdown/common/input-search.tsx new file mode 100644 index 000000000..10fc258e1 --- /dev/null +++ b/packages/ui/src/dropdown/common/input-search.tsx @@ -0,0 +1,58 @@ +import React, { FC, useEffect, useRef } from "react"; +// headless ui +import { Combobox } from "@headlessui/react"; +// icons +import { Search } from "lucide-react"; +// helpers +import { cn } from "../../../helpers"; + +interface IInputSearch { + isOpen: boolean; + query: string; + updateQuery: (query: string) => void; + inputIcon?: React.ReactNode; + inputContainerClassName?: string; + inputClassName?: string; + inputPlaceholder?: string; +} + +export const InputSearch: FC = (props) => { + const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder } = props; + + const inputRef = useRef(null); + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + updateQuery(""); + } + }; + + useEffect(() => { + if (isOpen) { + inputRef.current && inputRef.current.focus(); + } + }, [isOpen]); + return ( +
+ {inputIcon ? <>{inputIcon} :
+ ); +}; diff --git a/packages/ui/src/dropdown/common/loader.tsx b/packages/ui/src/dropdown/common/loader.tsx new file mode 100644 index 000000000..0ec1f053b --- /dev/null +++ b/packages/ui/src/dropdown/common/loader.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export const DropdownOptionsLoader = () => ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ ))} +
+); diff --git a/packages/ui/src/dropdown/common/options.tsx b/packages/ui/src/dropdown/common/options.tsx new file mode 100644 index 000000000..f17a431a1 --- /dev/null +++ b/packages/ui/src/dropdown/common/options.tsx @@ -0,0 +1,88 @@ +import React from "react"; +// headless ui +import { Combobox } from "@headlessui/react"; +// icons +import { Check } from "lucide-react"; +// components +import { DropdownOptionsLoader, InputSearch } from "."; +// helpers +import { cn } from "../../../helpers"; +// types +import { IMultiSelectDropdownOptions, ISingleSelectDropdownOptions } from "../dropdown"; + +export const DropdownOptions: React.FC = (props) => { + const { + isOpen, + query, + setQuery, + inputIcon, + inputPlaceholder, + inputClassName, + inputContainerClassName, + disableSearch, + keyExtractor, + options, + value, + renderItem, + loader, + } = props; + return ( + <> + {!disableSearch && ( + setQuery(query)} + inputIcon={inputIcon} + inputPlaceholder={inputPlaceholder} + inputClassName={inputClassName} + inputContainerClassName={inputContainerClassName} + /> + )} +
+ <> + {options ? ( + options.length > 0 ? ( + options?.map((option) => ( + + cn( + "flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5", + { + "bg-custom-background-80": active, + "text-custom-text-100": selected, + "text-custom-text-200": !selected, + }, + option.className && option.className({ active, selected }) + ) + } + > + {({ selected }) => ( + <> + {renderItem ? ( + <>{renderItem({ value: option.data[option.value], selected })} + ) : ( + <> + {value} + {selected && } + + )} + + )} + + )) + ) : ( +

No matching results

+ ) + ) : loader ? ( + <> {loader} + ) : ( + + )} + +
+ + ); +}; diff --git a/packages/ui/src/dropdown/dropdown.d.ts b/packages/ui/src/dropdown/dropdown.d.ts new file mode 100644 index 000000000..1f109add6 --- /dev/null +++ b/packages/ui/src/dropdown/dropdown.d.ts @@ -0,0 +1,94 @@ +import { Placement } from "@popperjs/core"; + +export interface IDropdown { + // root props + onOpen?: () => void; + onClose?: () => void; + containerClassName?: (isOpen: boolean) => string; + tabIndex?: number; + placement?: Placement; + disabled?: boolean; + + // button props + buttonContent?: (isOpen: boolean) => React.ReactNode; + buttonContainerClassName?: string; + buttonClassName?: string; + + // input props + disableSearch?: boolean; + inputPlaceholder?: string; + inputClassName?: string; + inputIcon?: React.ReactNode; + inputContainerClassName?: string; + + // options props + keyExtractor: (option: TDropdownOption) => string; + optionsContainerClassName?: string; + queryArray: string[]; + sortByKey: string; + firstItem?: (optionValue: string) => boolean; + renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; + loader?: React.ReactNode; + disableSorting?: boolean; +} + +export interface TDropdownOption { + data: any; + value: string; + className?: ({ active, selected }: { active: boolean; selected: boolean }) => string; +} + +export interface IMultiSelectDropdown extends IDropdown { + value: string[]; + onChange: (value: string[]) => void; + options: TDropdownOption[] | undefined; +} + +export interface ISingleSelectDropdown extends IDropdown { + value: string; + onChange: (value: string) => void; + options: TDropdownOption[] | undefined; +} + +export interface IDropdownButton { + isOpen: boolean; + buttonContent?: (isOpen: boolean) => React.ReactNode; + buttonClassName?: string; + buttonContainerClassName?: string; + handleOnClick: (e: React.MouseEvent) => void; + setReferenceElement: (element: HTMLButtonElement | null) => void; + disabled?: boolean; +} + +export interface IMultiSelectDropdownButton extends IDropdownButton { + value: string[]; +} + +export interface ISingleSelectDropdownButton extends IDropdownButton { + value: string; +} + +export interface IDropdownOptions { + isOpen: boolean; + query: string; + setQuery: (query: string) => void; + + inputPlaceholder?: string; + inputClassName?: string; + inputIcon?: React.ReactNode; + inputContainerClassName?: string; + disableSearch?: boolean; + + keyExtractor: (option: TDropdownOption) => string; + renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined; + options: TDropdownOption[] | undefined; + loader?: React.ReactNode; +} + +export interface IMultiSelectDropdownOptions extends IDropdownOptions { + value: string[]; +} + +export interface ISingleSelectDropdownOptions extends IDropdownOptions { + value: string; +} diff --git a/packages/ui/src/dropdown/index.ts b/packages/ui/src/dropdown/index.ts new file mode 100644 index 000000000..a15df9567 --- /dev/null +++ b/packages/ui/src/dropdown/index.ts @@ -0,0 +1,3 @@ +export * from "./common"; +export * from "./multi-select"; +export * from "./single-select"; diff --git a/packages/ui/src/dropdown/multi-select.tsx b/packages/ui/src/dropdown/multi-select.tsx new file mode 100644 index 000000000..08bc58638 --- /dev/null +++ b/packages/ui/src/dropdown/multi-select.tsx @@ -0,0 +1,167 @@ +import React, { FC, useMemo, useRef, useState } from "react"; +import sortBy from "lodash/sortBy"; +// headless ui +import { Combobox } from "@headlessui/react"; +// popper-js +import { usePopper } from "react-popper"; +// components +import { DropdownButton } from "./common"; +import { DropdownOptions } from "./common/options"; +// hooks +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; +// helper +import { cn } from "../../helpers"; +// types +import { IMultiSelectDropdown } from "./dropdown"; + +export const MultiSelectDropdown: FC = (props) => { + const { + value, + onChange, + options, + onOpen, + onClose, + containerClassName, + tabIndex, + placement, + disabled, + buttonContent, + buttonContainerClassName, + buttonClassName, + disableSearch, + inputPlaceholder, + inputClassName, + inputIcon, + inputContainerClassName, + keyExtractor, + optionsContainerClassName, + queryArray, + sortByKey, + firstItem, + renderItem, + loader = false, + disableSorting, + } = props; + + // states + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + // handlers + const toggleDropdown = () => { + if (!isOpen) onOpen?.(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose?.(); + }; + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose?.(); + setQuery?.(""); + }; + + // options + const sortedOptions = useMemo(() => { + if (!options) return undefined; + + const filteredOptions = (options || []).filter((options) => { + const queryString = queryArray.map((query) => options.data[query]).join(" "); + return queryString.toLowerCase().includes(query.toLowerCase()); + }); + + if (disableSorting) return filteredOptions; + + return sortBy(filteredOptions, [ + (option) => firstItem && firstItem(option.data[option.value]), + (option) => !(value ?? []).includes(option.data[option.value]), + () => sortByKey && sortByKey.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, options]); + + // hooks + const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose); + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + + {isOpen && ( + +
+ +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/dropdown/single-select.tsx b/packages/ui/src/dropdown/single-select.tsx new file mode 100644 index 000000000..045296873 --- /dev/null +++ b/packages/ui/src/dropdown/single-select.tsx @@ -0,0 +1,166 @@ +import React, { FC, useMemo, useRef, useState } from "react"; +import sortBy from "lodash/sortBy"; +// headless ui +import { Combobox } from "@headlessui/react"; +// popper-js +import { usePopper } from "react-popper"; +// components +import { DropdownButton } from "./common"; +import { DropdownOptions } from "./common/options"; +// hooks +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; +// helper +import { cn } from "../../helpers"; +// types +import { ISingleSelectDropdown } from "./dropdown"; + +export const Dropdown: FC = (props) => { + const { + value, + onChange, + options, + onOpen, + onClose, + containerClassName, + tabIndex, + placement, + disabled, + buttonContent, + buttonContainerClassName, + buttonClassName, + disableSearch, + inputPlaceholder, + inputClassName, + inputIcon, + inputContainerClassName, + keyExtractor, + optionsContainerClassName, + queryArray, + sortByKey, + firstItem, + renderItem, + loader = false, + disableSorting, + } = props; + + // states + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + // handlers + const toggleDropdown = () => { + if (!isOpen) onOpen?.(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose?.(); + }; + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose?.(); + setQuery?.(""); + }; + + // options + const sortedOptions = useMemo(() => { + if (!options) return undefined; + + const filteredOptions = (options || []).filter((options) => { + const queryString = queryArray.map((query) => options.data[query]).join(" "); + return queryString.toLowerCase().includes(query.toLowerCase()); + }); + + if (disableSorting) return filteredOptions; + + return sortBy(filteredOptions, [ + (option) => firstItem && firstItem(option.data[option.value]), + (option) => !(value ?? []).includes(option.data[option.value]), + () => sortByKey && sortByKey.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, options]); + + // hooks + const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose); + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + + {isOpen && ( + +
+ +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/hooks/use-dropdown-key-pressed.ts b/packages/ui/src/hooks/use-dropdown-key-pressed.ts new file mode 100644 index 000000000..15552d34d --- /dev/null +++ b/packages/ui/src/hooks/use-dropdown-key-pressed.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; + +type TUseDropdownKeyPressed = { + ( + onEnterKeyDown: () => void, + onEscKeyDown: () => void, + stopPropagation?: boolean + ): (event: React.KeyboardEvent) => void; +}; + +export const useDropdownKeyPressed: TUseDropdownKeyPressed = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => { + const stopEventPropagation = useCallback( + (event: React.KeyboardEvent) => { + if (stopPropagation) { + event.stopPropagation(); + event.preventDefault(); + } + }, + [stopPropagation] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + stopEventPropagation(event); + onEnterKeyDown(); + } else if (event.key === "Escape") { + stopEventPropagation(event); + onEscKeyDown(); + } else if (event.key === "Tab") onEscKeyDown(); + }, + [onEnterKeyDown, onEscKeyDown, stopEventPropagation] + ); + + return handleKeyDown; +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 23fc7ed62..c3c0cd4f1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -4,6 +4,7 @@ export * from "./badge"; export * from "./button"; export * from "./emoji"; export * from "./dropdowns"; +export * from "./dropdown"; export * from "./form-fields"; export * from "./icons"; export * from "./progress";