Feat/full custom select (#6865)
* Inital Setup - refactors/optimise * Escape clears focus * A11y and optimisations * A11y enter fix * Sort out flatternt list indexing * Fix spacing - fix single selected state * Solves group highlights breaking things * Fix selecting while filtering list * Remove styles and rename story * Hide labels while searching for now * Remove group item * move onclick hook to hooks * Fix typo * Use check icon on single select * scroll into view when keyboard navigating * add comment explaining dropdown close * Remove tenery and make disabled easier to read * Typo * remove log * Remove destructing of classNames props * Refactor callbacks + hooks * Style updates with ciaran * Final style changes * center icon * Add darkmode
This commit is contained in:
parent
74fe20802d
commit
c68ff54e77
|
@ -0,0 +1,46 @@
|
|||
import { useState, useEffect, RefObject, useRef } from "react";
|
||||
|
||||
export function useKeyPress(
|
||||
targetKey: string,
|
||||
ref?: RefObject<HTMLInputElement>,
|
||||
handler?: () => void
|
||||
): boolean {
|
||||
// State for keeping track of whether key is pressed
|
||||
const [keyPressed, setKeyPressed] = useState(false);
|
||||
const placeHolderRef = ref?.current;
|
||||
// If pressed key is our target key then set to true
|
||||
function downHandler({ key }: { key: string }) {
|
||||
if (key === targetKey) {
|
||||
setKeyPressed(true);
|
||||
handler && handler();
|
||||
}
|
||||
}
|
||||
// If released key is our target key then set to false
|
||||
const upHandler = ({ key }: { key: string }) => {
|
||||
if (key === targetKey) {
|
||||
setKeyPressed(false);
|
||||
}
|
||||
};
|
||||
// Add event listeners
|
||||
useEffect(() => {
|
||||
if (ref && placeHolderRef) {
|
||||
placeHolderRef.addEventListener("keydown", downHandler);
|
||||
placeHolderRef.addEventListener("keyup", upHandler);
|
||||
return () => {
|
||||
placeHolderRef?.removeEventListener("keydown", downHandler);
|
||||
placeHolderRef?.removeEventListener("keyup", upHandler);
|
||||
};
|
||||
} else {
|
||||
window.addEventListener("keydown", downHandler);
|
||||
window.addEventListener("keyup", upHandler);
|
||||
// Remove event listeners on cleanup
|
||||
return () => {
|
||||
window.removeEventListener("keydown", downHandler);
|
||||
window.removeEventListener("keyup", upHandler);
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty array ensures that effect is only run on mount and unmount
|
||||
|
||||
return keyPressed;
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import React, { useEffect } from "react";
|
||||
|
||||
export default function useOnClickOutside(
|
||||
ref: React.RefObject<HTMLDivElement>,
|
||||
handler: (e?: MouseEvent | TouchEvent) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent | TouchEvent) => {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler(event);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", listener);
|
||||
document.addEventListener("touchstart", listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", listener);
|
||||
document.removeEventListener("touchstart", listener);
|
||||
};
|
||||
}, [ref, handler]);
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import React from "react";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
|
||||
import { classNames as cn } from "@calcom/lib";
|
||||
|
||||
import { useSelectContext } from "./SelectProvider";
|
||||
import { Option } from "./type";
|
||||
|
||||
interface ItemProps {
|
||||
item: Option;
|
||||
index?: number;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
const Item: React.FC<ItemProps> = ({ item, index, focused }) => {
|
||||
const { classNames, selectedItems, handleValueChange } = useSelectContext();
|
||||
const isMultiple = Array.isArray(selectedItems);
|
||||
const isSelected =
|
||||
(isMultiple && selectedItems?.some((selection) => selection.value === item.value)) ||
|
||||
(!isMultiple && selectedItems?.value === item.value);
|
||||
|
||||
if (item.disabled) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"dark:text-darkgray-400 flex cursor-not-allowed select-none justify-between truncate rounded-[4px] px-3 py-2 text-gray-300 ",
|
||||
focused ? "dark:bg-darkgray-200 bg-gray-50" : "dark:hover:bg-darkgray-200 hover:bg-gray-100"
|
||||
)}>
|
||||
<>
|
||||
<div className="space-x flex items-center">
|
||||
{item.leftNode && item.leftNode}
|
||||
<p>{item.label}</p>
|
||||
</div>
|
||||
{isMultiple ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded-[4px] border opacity-70 ltr:mr-2 rtl:ml-2",
|
||||
isSelected
|
||||
? "dark:bg-darkgray-200 dark:text-darkgray-900 dark:border-darkgray-300 bg-gray-800 text-gray-50"
|
||||
: "dark:text-darkgray-600 dark:bg-darkgray-200 dark:border-darkgray-300 border-gray-300 bg-gray-50 text-gray-600"
|
||||
)}>
|
||||
{isSelected && <FiCheck className="h-3 w-3 text-current" />}
|
||||
</div>
|
||||
) : (
|
||||
isSelected && <FiCheck className="h-3 w-3 text-black" strokeWidth={2} />
|
||||
)}
|
||||
</>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-selected={isSelected}
|
||||
tabIndex={index}
|
||||
role="option"
|
||||
onClick={() => handleValueChange(item)}
|
||||
className={cn(
|
||||
"block flex cursor-pointer select-none items-center justify-between truncate border-transparent px-3 py-2 transition duration-200",
|
||||
isSelected
|
||||
? "dark:bg-darkgray-200 dark:text-darkgray-900 bg-gray-100 text-gray-900"
|
||||
: "dark:text-darkgray-700 dark:hover:bg-darkgray-200 text-gray-700 hover:bg-gray-100",
|
||||
focused && "dark:bg-darkgray-200 bg-gray-50",
|
||||
classNames?.listItem
|
||||
)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.leftNode && item.leftNode}
|
||||
<p>{item.label}</p>
|
||||
</div>
|
||||
|
||||
{isMultiple ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded-[4px] border ltr:mr-2 rtl:ml-2",
|
||||
isSelected
|
||||
? "dark:bg-darkgray-200 dark:text-darkgray-900 dark:border-darkgray-300 bg-gray-800 text-gray-50"
|
||||
: "dark:text-darkgray-600 dark:bg-darkgray-200 dark:border-darkgray-300 border-gray-300 bg-gray-50 text-gray-600"
|
||||
)}>
|
||||
{isSelected && <FiCheck className="h-3 w-3 text-current" />}
|
||||
</div>
|
||||
) : (
|
||||
isSelected && <FiCheck className="h-3 w-3 text-black" strokeWidth={2} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default Item;
|
|
@ -0,0 +1,136 @@
|
|||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useKeyPress } from "@calcom/lib/hooks/useKeyPress";
|
||||
|
||||
import { Label } from "../../inputs/Label";
|
||||
import Item from "./Item";
|
||||
import { SelectContext } from "./SelectProvider";
|
||||
import { Option } from "./type";
|
||||
|
||||
interface OptionsProps<T extends Option> {
|
||||
list: T[];
|
||||
inputValue: string;
|
||||
isMultiple: boolean;
|
||||
selected: T | T[] | null;
|
||||
searchBoxRef: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
type FlattenedOption = Option & { current: number; groupedIndex?: number };
|
||||
|
||||
const flattenOptions = (options: Option[], groupCount?: number): FlattenedOption[] => {
|
||||
return options.reduce((acc, option, current) => {
|
||||
if (option.options) {
|
||||
return [...acc, ...flattenOptions(option.options, current + (groupCount || 0))];
|
||||
}
|
||||
return [...acc, { ...option, current, groupedIndex: groupCount }];
|
||||
}, [] as FlattenedOption[]);
|
||||
};
|
||||
|
||||
function FilteredItem<T extends Option>({
|
||||
index,
|
||||
keyboardFocus,
|
||||
item,
|
||||
inputValue,
|
||||
list,
|
||||
}: {
|
||||
index: number;
|
||||
keyboardFocus: number;
|
||||
item: FlattenedOption;
|
||||
inputValue: string;
|
||||
list: T[];
|
||||
}) {
|
||||
const focused = index === keyboardFocus;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && focused) {
|
||||
ref.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [ref, focused]);
|
||||
|
||||
return (
|
||||
<div key={index} ref={ref}>
|
||||
{item.current === 0 && item.groupedIndex !== undefined && !inputValue && (
|
||||
<div>
|
||||
{index !== 0 && <hr className="mt-2" />}
|
||||
<Label
|
||||
className={classNames(
|
||||
"dark:text-darkgray-700 mb-2 pl-3 text-xs font-normal uppercase leading-none text-gray-600",
|
||||
index !== 0 ? "mt-5" : "mt-4" // rest, first
|
||||
)}>
|
||||
{list[item.groupedIndex].label}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<Item item={item} index={index} focused={focused} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options<T extends Option>({ list, inputValue, searchBoxRef }: OptionsProps<T>) {
|
||||
const { classNames, handleValueChange } = useContext(SelectContext);
|
||||
const [keyboardFocus, setKeyboardFocus] = useState(-1);
|
||||
const enterPress = useKeyPress("Enter", searchBoxRef);
|
||||
|
||||
const flattenedList = useMemo(() => flattenOptions(list), [list]);
|
||||
|
||||
const totalOptionsLength = useMemo(() => {
|
||||
return flattenedList.length;
|
||||
}, [flattenedList]);
|
||||
|
||||
useKeyPress("ArrowDown", searchBoxRef, () => setKeyboardFocus((prev) => (prev + 1) % totalOptionsLength));
|
||||
useKeyPress("ArrowUp", searchBoxRef, () =>
|
||||
setKeyboardFocus((prev) => (prev - 1 + totalOptionsLength) % totalOptionsLength)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (enterPress) {
|
||||
const item = filteredList[keyboardFocus];
|
||||
if (!item || item.disabled) return;
|
||||
handleValueChange(item);
|
||||
}
|
||||
// We don't want to re-run this effect when handleValueChange changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enterPress, keyboardFocus, list]);
|
||||
|
||||
const search = useCallback((optionsArray: FlattenedOption[], searchTerm: string) => {
|
||||
// search options by label, or group label or options.options
|
||||
return optionsArray.reduce((acc: FlattenedOption[], option: FlattenedOption) => {
|
||||
// @TODO: add search by lavbel group gets awkward as it doesnt exist in the flattened list
|
||||
if (option.label.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
acc.push(option);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as FlattenedOption[]);
|
||||
}, []);
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
if (inputValue.length > 0) {
|
||||
return search(flattenedList, inputValue);
|
||||
}
|
||||
|
||||
return flattenedList;
|
||||
}, [inputValue, flattenedList, search]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
classNames?.list ?? "flex max-h-72 flex-col space-y-[1px] overflow-y-auto overflow-y-scroll"
|
||||
}>
|
||||
{filteredList?.map((item, index) => (
|
||||
<FilteredItem
|
||||
key={index}
|
||||
item={item}
|
||||
index={index}
|
||||
keyboardFocus={keyboardFocus}
|
||||
inputValue={inputValue}
|
||||
list={list}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Options;
|
|
@ -0,0 +1,52 @@
|
|||
import React, { useContext } from "react";
|
||||
|
||||
import { FiSearch } from "../../../icon";
|
||||
import { SelectContext } from "./SelectProvider";
|
||||
|
||||
interface SearchInputProps {
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
name?: string;
|
||||
searchInputRef?: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const SearchInput: React.FC<SearchInputProps> = ({
|
||||
placeholder = "",
|
||||
value = "",
|
||||
onChange,
|
||||
name = "",
|
||||
searchInputRef,
|
||||
}) => {
|
||||
const { classNames } = useContext(SelectContext);
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
classNames && classNames.searchContainer ? classNames.searchContainer : "relative py-1 px-2.5"
|
||||
}>
|
||||
<FiSearch
|
||||
className={
|
||||
classNames && classNames.searchIcon
|
||||
? classNames.searchIcon
|
||||
: "dark:text-darkgray-500 absolute mt-2.5 ml-2 h-5 w-5 pb-0.5 text-gray-500"
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className={
|
||||
classNames && classNames.searchBox
|
||||
? classNames.searchBox
|
||||
: "dark:border-darkgray-300 dark:bg-darkgray-100 dark:text-darkgray-900 focus:border-darkgray-900 w-full rounded-[6px] border border-gray-200 py-2 pl-8 text-sm text-gray-500 focus:border-gray-900 focus:outline-none focus:ring-0"
|
||||
}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchInput;
|
|
@ -0,0 +1,256 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { classNames as cn } from "@calcom/lib";
|
||||
import { useKeyPress } from "@calcom/lib/hooks/useKeyPress";
|
||||
import useOnClickOutside from "@calcom/lib/hooks/useOnclickOutside";
|
||||
|
||||
import { FiX, FiChevronDown } from "../../../icon";
|
||||
import Options from "./Options";
|
||||
import SearchInput from "./SearchInput";
|
||||
import SelectProvider from "./SelectProvider";
|
||||
import { Option } from "./type";
|
||||
|
||||
interface SelectProps<T extends Option> {
|
||||
options: T[];
|
||||
selectedItems: T | T[] | null;
|
||||
onChange: (value?: Option | Option[] | null) => void;
|
||||
placeholder?: string;
|
||||
isMultiple?: boolean;
|
||||
isClearable?: boolean;
|
||||
isSearchable?: boolean;
|
||||
isDisabled?: boolean;
|
||||
loading?: boolean;
|
||||
menuIsOpen?: boolean;
|
||||
searchInputPlaceholder?: string;
|
||||
noOptionsMessage?: string;
|
||||
classNames?: {
|
||||
menuButton?: ({ isDisabled }: { isDisabled: boolean }) => string;
|
||||
menu?: string;
|
||||
tagItem?: ({ isDisabled }: { isDisabled: boolean }) => string;
|
||||
tagItemText?: string;
|
||||
tagItemIconContainer?: string;
|
||||
tagItemIcon?: string;
|
||||
list?: string;
|
||||
listGroupLabel?: string;
|
||||
listItem?: ({ isSelected }: { isSelected: boolean }) => string;
|
||||
listDisabledItem?: string;
|
||||
ChevronIcon?: ({ open }: { open: boolean }) => string;
|
||||
searchContainer?: string;
|
||||
searchBox?: string;
|
||||
searchIcon?: string;
|
||||
closeIcon?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function Select<T extends Option>({
|
||||
options = [],
|
||||
selectedItems = null,
|
||||
onChange,
|
||||
placeholder = "Select...",
|
||||
searchInputPlaceholder = "Search...",
|
||||
isMultiple = false,
|
||||
isClearable = false,
|
||||
isSearchable = false,
|
||||
isDisabled = false,
|
||||
menuIsOpen = false,
|
||||
classNames,
|
||||
}: SelectProps<T>) {
|
||||
const [open, setOpen] = useState<boolean>(menuIsOpen);
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const searchBoxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isMultipleValue = Array.isArray(selectedItems) && isMultiple;
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!isDisabled) {
|
||||
setOpen(!open);
|
||||
}
|
||||
}, [isDisabled, open]);
|
||||
|
||||
const closeDropDown = useCallback(() => {
|
||||
if (open) setOpen(false);
|
||||
}, [open]);
|
||||
|
||||
useOnClickOutside(ref, () => {
|
||||
closeDropDown();
|
||||
});
|
||||
|
||||
useKeyPress("Escape", undefined, () => {
|
||||
closeDropDown();
|
||||
});
|
||||
|
||||
const onPressEnterOrSpace = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if ((e.code === "Enter" || e.code === "Space") && !isDisabled) {
|
||||
toggle();
|
||||
}
|
||||
},
|
||||
[isDisabled, toggle]
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(item: Option) => {
|
||||
// remove the item from the selected items
|
||||
if (Array.isArray(selectedItems)) {
|
||||
const newSelectedItems = selectedItems.filter((selectedItem) => selectedItem.value !== item.value);
|
||||
onChange(newSelectedItems);
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
},
|
||||
[onChange, selectedItems]
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(selected: Option) => {
|
||||
function update() {
|
||||
if (!isMultiple && !Array.isArray(selectedItems)) {
|
||||
// Close dropdown when you select an item when we are on single select
|
||||
closeDropDown();
|
||||
onChange(selected);
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the selected item is already selected
|
||||
if (Array.isArray(selectedItems)) {
|
||||
const isAlreadySelected = selectedItems.some((item) => item.value === selected.value);
|
||||
if (isAlreadySelected) {
|
||||
removeItem(selected);
|
||||
return;
|
||||
}
|
||||
onChange(selectedItems === null ? [selected] : [...selectedItems, selected]);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.disabled) return;
|
||||
|
||||
if (selected !== selectedItems) {
|
||||
update();
|
||||
}
|
||||
},
|
||||
[closeDropDown, isMultiple, onChange, selectedItems, removeItem]
|
||||
);
|
||||
|
||||
const clearValue = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
onChange(isMultiple ? [] : null);
|
||||
},
|
||||
[onChange, isMultiple]
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectProvider
|
||||
options={{
|
||||
classNames,
|
||||
}}
|
||||
selectedItems={selectedItems}
|
||||
handleValueChange={handleValueChange}>
|
||||
<div className="relative w-full" ref={ref}>
|
||||
<div
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onKeyDown={onPressEnterOrSpace}
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
"flex max-h-[36px] items-center justify-between rounded-md border border-gray-300 text-sm text-gray-400 transition-all duration-300 focus:outline-none",
|
||||
isDisabled
|
||||
? " dark:bg-darkgray-200 dark:border-darkgray-300 border-gray-200 bg-gray-100 text-gray-400 dark:text-gray-500"
|
||||
: "dark:border-darkgray-300 dark:bg-darkgray-50 dark:text-darkgray-500 dark:focus:border-darkgray-700 dark:focus:bg-darkgray-100 dark:focus:text-darkgray-900 dark:hover:text-darkgray-900 bg-white hover:border-gray-600 focus:border-gray-900"
|
||||
)}>
|
||||
<div className="flex w-full grow-0 items-center gap-1 overflow-x-hidden">
|
||||
<>
|
||||
{((isMultipleValue && selectedItems.length === 0) || selectedItems === null) && (
|
||||
<div className="py-2.5 px-3 text-gray-400 dark:text-current">
|
||||
<p>{placeholder}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.isArray(selectedItems) ? (
|
||||
<div className="over-flow-x flex gap-1 p-1 ">
|
||||
{selectedItems.map((item, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"dark:bg-darkgray-200 flex items-center space-x-2 rounded bg-gray-200 px-2 py-[6px]"
|
||||
)}
|
||||
key={index}>
|
||||
<p
|
||||
className={cn(
|
||||
classNames?.tagItemText ??
|
||||
"dark:text-darkgray-900 cursor-default select-none truncate text-sm leading-none text-gray-700"
|
||||
)}>
|
||||
{item.label}
|
||||
</p>
|
||||
{!isDisabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeItem(item);
|
||||
}}>
|
||||
<FiX
|
||||
className={
|
||||
classNames?.tagItemIcon ??
|
||||
"dark:text-darkgray-700 dark:hover:text-darkgray-900 h-4 w-4 text-gray-500 hover:text-gray-900"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dark:text-darkgray-900 py-2.5 px-3 text-sm leading-none text-gray-900">
|
||||
<p>{selectedItems?.label}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div className="dark:text-darkgray-900 flex flex-none items-center rounded-[6px] p-1.5 text-gray-900 opacity-75 ">
|
||||
{isClearable && !isDisabled && selectedItems !== null && (
|
||||
<div className="cursor-pointer" onClick={clearValue}>
|
||||
<FiX
|
||||
className={classNames && classNames.closeIcon ? classNames.closeIcon : "h-5 w-5 p-0.5"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FiChevronDown
|
||||
className={cn("h-5 w-5 transition duration-300", open && " rotate-180 transform")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && !isDisabled && (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={
|
||||
classNames?.menu ??
|
||||
"dark:bg-darkgray-100 dark:border-darkgray-300 absolute z-10 mt-1.5 w-full max-w-[256px] overflow-x-hidden rounded border bg-white py-1 text-sm text-gray-700 shadow-sm"
|
||||
}>
|
||||
{isSearchable && (
|
||||
<SearchInput
|
||||
searchInputRef={searchBoxRef}
|
||||
value={inputValue}
|
||||
placeholder={searchInputPlaceholder}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Options
|
||||
searchBoxRef={searchBoxRef}
|
||||
list={options}
|
||||
inputValue={inputValue}
|
||||
isMultiple={isMultiple}
|
||||
selected={selectedItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Select;
|
|
@ -0,0 +1,44 @@
|
|||
import React, { createContext, useContext, useMemo } from "react";
|
||||
|
||||
import { ClassNames, Option } from "./type";
|
||||
|
||||
interface Store {
|
||||
selectedItems: Option | Option[] | null;
|
||||
handleValueChange: (selected: Option) => void;
|
||||
classNames?: ClassNames;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
selectedItems: Option | Option[] | null;
|
||||
handleValueChange: (selected: Option) => void;
|
||||
children: JSX.Element;
|
||||
options: {
|
||||
classNames?: ClassNames;
|
||||
};
|
||||
}
|
||||
|
||||
export const SelectContext = createContext<Store>({
|
||||
selectedItems: null,
|
||||
handleValueChange: (selected) => {
|
||||
return selected;
|
||||
},
|
||||
classNames: undefined,
|
||||
});
|
||||
|
||||
export const useSelectContext = (): Store => {
|
||||
return useContext(SelectContext);
|
||||
};
|
||||
|
||||
const SelectProvider: React.FC<Props> = ({ selectedItems, handleValueChange, options, children }) => {
|
||||
const store = useMemo(() => {
|
||||
return {
|
||||
selectedItems,
|
||||
handleValueChange,
|
||||
classNames: options?.classNames,
|
||||
} as Store;
|
||||
}, [handleValueChange, options, selectedItems]);
|
||||
|
||||
return <SelectContext.Provider value={store}>{children}</SelectContext.Provider>;
|
||||
};
|
||||
|
||||
export default SelectProvider;
|
|
@ -0,0 +1,26 @@
|
|||
export interface Option {
|
||||
value?: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
options?: Option[];
|
||||
leftNode?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ClassNames {
|
||||
menuButton?: (args: { isDisabled: boolean }) => string;
|
||||
menu?: string;
|
||||
tagItem?: (args: { isDisabled: boolean }) => string;
|
||||
tagItemText?: string;
|
||||
tagItemIconContainer?: string;
|
||||
tagItemIcon?: string;
|
||||
list?: string;
|
||||
listGroupLabel?: string;
|
||||
listItem?: (args: { isSelected: boolean }) => string;
|
||||
listDisabledItem?: string;
|
||||
ChevronIcon?: (args: { open: boolean }) => string;
|
||||
searchContainer?: string;
|
||||
searchBox?: string;
|
||||
searchIcon?: string;
|
||||
closeIcon?: string;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import Select from "./components/Select";
|
||||
|
||||
export default Select;
|
|
@ -0,0 +1,86 @@
|
|||
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
|
||||
|
||||
import {
|
||||
Examples,
|
||||
Example,
|
||||
Note,
|
||||
Title,
|
||||
CustomArgsTable,
|
||||
VariantRow,
|
||||
VariantsTable,
|
||||
} from "@calcom/storybook/components";
|
||||
|
||||
import { FiPlus } from "../../icon";
|
||||
import Select from "./components/Select";
|
||||
|
||||
<Meta title="UI/Form/CustomSelect" component={Select} />
|
||||
|
||||
<Title title="Select" suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
|
||||
|
||||
export const options = [
|
||||
{
|
||||
//4
|
||||
label: "Cal.com Inc",
|
||||
options: [
|
||||
{ value: "UserA", label: "Pro" }, // 5
|
||||
{ value: "UserB", label: "Teampro" }, // 6
|
||||
{ value: "UserC", label: "Example" },
|
||||
{ value: "UserD", label: "Admin", disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
// 5
|
||||
label: "Acme Inc",
|
||||
options: [
|
||||
{ value: "UserE", label: "Acme Pro" }, // 1 == 6
|
||||
{ value: "UserF", label: "Acme Teampro" },
|
||||
{ value: "UserG", label: "Acme example" },
|
||||
{ value: "UserH", label: "Acme Admin", disabled: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const SelectWithState = (...args) => {
|
||||
const [value, setValue] = React.useState(options[0].options[0]);
|
||||
return <Select options={options} selectedItems={value} onChange={(e) => setValue(e)} isClearable />;
|
||||
};
|
||||
|
||||
export const MultiWithState = (...args) => {
|
||||
const [value, setValue] = React.useState([options[0].options[0]]);
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
selectedItems={value}
|
||||
onChange={(e) => {
|
||||
setValue(e);
|
||||
}}
|
||||
isSearchable
|
||||
isMultiple
|
||||
isClearable
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
<Examples title="State">
|
||||
<Example title="Single" isFullWidth>
|
||||
<SelectWithState />
|
||||
</Example>
|
||||
<Example title="Multi" isFullWidth>
|
||||
<MultiWithState />
|
||||
</Example>
|
||||
</Examples>
|
||||
|
||||
<Canvas>
|
||||
<Story name="Default">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<VariantsTable titles={[]} columnMinWidth={300}>
|
||||
<VariantRow variant="Single">
|
||||
<SelectWithState />
|
||||
</VariantRow>
|
||||
<VariantRow variant="Multi">
|
||||
<MultiWithState />
|
||||
</VariantRow>
|
||||
</VariantsTable>
|
||||
</div>
|
||||
</Story>
|
||||
</Canvas>
|
Loading…
Reference in New Issue
Block a user