diff --git a/docs/docs/components/select.mdx b/docs/docs/components/select.mdx index c5ad5c7..706a4ea 100644 --- a/docs/docs/components/select.mdx +++ b/docs/docs/components/select.mdx @@ -213,27 +213,56 @@ export const App = () => { +##### MultiSelect + +```jsx +const options = [ + { label: "First", value: "first" }, + { label: "Second", value: "second", disabled: true }, + { label: "Third", value: "third" }, + { label: "Fourth", value: "fourth" }, +]; + +
+ +
+ +> Note: All the above mentioned combinations Eg. Searchable, Cleareable, Disabled etc...are also present with MultiSelect + ### Props Reference -| Attributes | Values | Default Value | Optional ? | -| :---------------- | :----------------------------: | :--------------: | ---------: | -| placeholder | `string` | `Pick one` | Yes | -| options | `Array of` **_Option Object_** | | No | -| value | `string` | `number` | `horizontal` | Yes | -| height | `string` | `40px` | Yes | -| color | `string` | `#2F80ED` | Yes | -| width | `string` | `100%` | Yes | -| isSearchable | `boolean` | `false` | Yes | -| error | `boolean` | `false` | Yes | -| maxDropDownHeight | `string` | `auto` | Yes | -| borderRadius | `string` | `0` | Yes | -| onChange | `(selectedOption,event)=>void` | `()=>{}` | Yes | -| isDisabled | `boolean` | `false` | Yes | -| isClearable | `boolean` | `false` | Yes | -| nothingFoundLabel | `string` | `JSX Element` | `Nothing Found!` | Yes | -| DropIcon | `JSX Element` | | Yes | -| onDropDownOpen | `()=> void ` | | Yes | -| onDropDownClose | `()=> void ` | | Yes | +| Attributes | Values | Default Value | Optional ? | +| :---------------- | :-----------------------------------------------------------------------------------------------------: | :--------------: | ---------: | +| placeholder | `string` | `Pick one` | Yes | +| options | `Array of` **_Option Object_** | | No | +| value | `string` | `number`
when isMulti is `true` then value is
`(string` | ` number)[]` | | Yes | +| isMulti | `boolean` | `false` | Yes | +| color | `string` | `#2F80ED` | Yes | +| width | `string` | `100%` | Yes | +| isSearchable | `boolean` | `false` | Yes | +| error | `boolean` | `false` | Yes | +| maxDropDownHeight | `string` | `auto` | Yes | +| borderRadius | `string` | `0` | Yes | +| onChange | `(selectedOption,event)=>void` | `()=>{}` | Yes | +| isDisabled | `boolean` | `false` | Yes | +| isClearable | `boolean` | `false` | Yes | +| nothingFoundLabel | `string` | `JSX Element` | `Nothing Found!` | Yes | +| DropIcon | `JSX Element` | | Yes | +| onDropDownOpen | `()=> void ` | | Yes | +| onDropDownClose | `()=> void ` | | Yes | ### Option Object @@ -243,3 +272,16 @@ export const App = () => { | value | `string` | `number` | No | | disabled | `boolean` | Yes | | ref | `MutableRefObject` | Yes | + +### Keyboard Controls + +> Keyboard controls will only work when the ** Select ** is focused (selected) + +| Key | type | +| :-------- | :-------------------------------------------------------: | +| Space | `Opens Options List` | +| ArrowDown | `Next Option` | +| ArrowUp | `Previous Option` | +| Home | _ if not **disabled ** _
`First Option ` | +| End | _ if not **disabled ** _
`Last Option` | +| Backspace | _ if **isMulti ** _
`Clears the last selected pill` | diff --git a/lib/src/components/NativeSelect/NativeSelect.tsx b/lib/src/components/NativeSelect/NativeSelect.tsx index 17810ab..84d79d3 100644 --- a/lib/src/components/NativeSelect/NativeSelect.tsx +++ b/lib/src/components/NativeSelect/NativeSelect.tsx @@ -51,7 +51,7 @@ export const NativeSelect: FC = ({ {!multiple && ( - + )}
diff --git a/lib/src/components/Select/Pill/Pill.tsx b/lib/src/components/Select/Pill/Pill.tsx new file mode 100644 index 0000000..160312b --- /dev/null +++ b/lib/src/components/Select/Pill/Pill.tsx @@ -0,0 +1,26 @@ +import { MouseEvent } from "react"; +import { Flex } from "src/components/Flex"; +import CloseIcon from "src/components/_internal/Icons/Close"; +import { pillIconStyles, pillStyles } from "./pill.css"; + +type pillPropTypes = { + value: string; + clearValue: (event: MouseEvent) => void; +}; + +export const Pill = ({ value, clearValue = () => {} }: pillPropTypes) => { + return ( + + {value} + { + event.stopPropagation(); + clearValue(event); + }} + /> + + ); +}; diff --git a/lib/src/components/Select/Pill/pill.css.ts b/lib/src/components/Select/Pill/pill.css.ts new file mode 100644 index 0000000..4ecda59 --- /dev/null +++ b/lib/src/components/Select/Pill/pill.css.ts @@ -0,0 +1,12 @@ +import { style } from "@vanilla-extract/css"; + +export const pillStyles = style({ + padding: "6px 8px 6px 12px", + fontSize: "12px", + borderRadius: "50px", + background: "#ededed", +}); + +export const pillIconStyles = style({ + cursor: "pointer", +}); diff --git a/lib/src/components/Select/Select.tsx b/lib/src/components/Select/Select.tsx index fddf65f..a74f015 100644 --- a/lib/src/components/Select/Select.tsx +++ b/lib/src/components/Select/Select.tsx @@ -14,6 +14,7 @@ import { useClickOutside } from "src/hooks/useClickOutside"; import { Flex } from "../Flex"; import { ArrowDown } from "../_internal/Icons/ArrowDown"; import { Clear } from "../_internal/Icons/Clear"; +import { Pill } from "./Pill/Pill"; import { selectContainerStyles, selectErrorMsg, @@ -28,6 +29,7 @@ import { inputRecipe, } from "./select.css"; import { SelectPropsType, OptionsType } from "./select.types"; +import "./select.global.styles.css"; const SelectComponent: ForwardRefRenderFunction< HTMLDivElement, @@ -37,11 +39,10 @@ const SelectComponent: ForwardRefRenderFunction< placeholder, options, value, - height = "40px", width = "100%", borderRadius = "0", color = "#2F80ED", - maxDropDownHeight = "auto", + maxDropDownHeight = "200px", onChange = () => {}, isSearchable = false, isClearable = false, @@ -50,16 +51,23 @@ const SelectComponent: ForwardRefRenderFunction< DropIcon, error = false, nothingFoundLabel, + className, + style, onDropDownClose = () => {}, onDropDownOpen = () => {}, }, ref ) => { - const [selectValue, setSelectValue] = useState(value); + const [selectValue, setSelectValue] = useState< + string | number | (string | number)[] | undefined + >(value); const [isDropped, setIsDropped] = useState(false); const [internalOptions, setInternalOptions] = useState(options); const [searchText, setSearchText] = useState(""); const selectRef = useRef() as MutableRefObject; + const inputRef = useRef() as MutableRefObject; + const optionsListRef = useRef() as MutableRefObject; + const [cursor, setCursor] = useState(-1); useEffect(() => { if (value !== undefined) { @@ -68,49 +76,138 @@ const SelectComponent: ForwardRefRenderFunction< }, [value]); useEffect(() => { - setSearchText( - options.find((option) => option.value === selectValue)?.label || "" + if (isMulti) { + typeof selectValue === "object" && + setInternalOptions( + options.filter((option) => !selectValue.includes(option.value)) + ); + } else { + setInternalOptions(options); + setSearchText(getLabel()); + } + }, [selectValue, isMulti]); + + useEffect(() => { + focusElement(cursor); + }, [cursor]); + + useEffect(() => { + if (internalOptions.length !== 0) { + let skipCount = cursor; + while (internalOptions[skipCount]?.disabled) { + skipCount++; + } + skipCount < internalOptions.length && + !internalOptions[skipCount]?.disabled && + setCursor(skipCount); + internalOptions.every((option) => option.disabled) && blurAllElements(); + } + }, [internalOptions]); + + useEffect(() => { + if (optionsListRef.current && inputRef.current) { + optionsListRef.current.style.top = `${ + inputRef.current.offsetHeight - 5 + }px`; + } + }, [inputRef, optionsListRef, isDropped, selectValue]); + + useEffect(() => { + if (!isDropped) { + setCursor(-1); + } + }, [isDropped]); + + const getLabel = (extValue?: string | number) => { + return ( + options.find((option) => option.value === (extValue || selectValue)) + ?.label || "" ); - setInternalOptions(options); - }, [selectValue]); + }; + + const focusElement = (pointer: number) => { + const optionsList = getOptionsRefAsArray(); + optionsList?.map((option, index) => { + if (index === pointer) { + option.setAttribute("data-hover", "true"); + } else option.setAttribute("data-hover", "false"); + }); + }; + + const blurAllElements = () => { + const optionsList = getOptionsRefAsArray(); + optionsList?.map((option) => option.setAttribute("data-hover", "false")); + }; + + const getOptionsRefAsArray = () => { + if (optionsListRef.current) { + const optionsList = [ + ...optionsListRef.current.childNodes, + ] as HTMLElement[]; + return optionsList; + } + }; const internalClickHandler = ( option: OptionsType, - event: MouseEvent | KeyboardEvent + event: + | MouseEvent + | KeyboardEvent + | MouseEvent ) => { - if (value !== undefined) { - isClearable && option.value === selectValue - ? onChange("", event) - : onChange(option.value, event); + if (isMulti) { + setSearchText(""); + const multiValue = + typeof selectValue === "object" ? [...selectValue] : []; + multiValue.push(option.value); + if (value !== undefined) { + onChange(multiValue, event); + } else { + setSelectValue(multiValue); + } } else { - isClearable && option.value === selectValue - ? setSelectValue("") - : setSelectValue(option.value); + if (value !== undefined) { + isClearable && option.value === selectValue + ? onChange("", event) + : onChange(option.value, event); + } else { + isClearable && option.value === selectValue + ? setSelectValue("") + : setSelectValue(option.value); + } + setIsDropped(false); + setInternalOptions(options); + onDropDownClose(); } - setInternalOptions(options); - setIsDropped(false); - onDropDownClose(); }; const internalChangeHandler = (event: ChangeEvent) => { + event.stopPropagation(); const text = event.target.value; + const mainOptions = + isMulti && typeof selectValue === "object" + ? options.filter((option) => !selectValue.includes(option.value)) + : options; setIsDropped(true); setSearchText(text); - const filteredOptions = options.filter((option) => - option.label.toLowerCase().includes(text.toLowerCase()) - ); + const filteredOptions = mainOptions.filter((option) => { + return option.label + .trim() + .toLowerCase() + .includes(text.trim().toLowerCase()); + }); text === "" - ? setInternalOptions(options) + ? setInternalOptions(mainOptions) : setInternalOptions(filteredOptions); }; - const handleIconClick = (event: MouseEvent) => { + const handleIconClick = (event: MouseEvent) => { event.stopPropagation(); if (isClearable) { if (value !== undefined) { - onChange("", event); + isMulti ? onChange([], event) : onChange("", event); } else { - setSelectValue(""); + isMulti ? setSelectValue([]) : setSelectValue(""); } } else { setIsDropped(!isDropped); @@ -123,6 +220,140 @@ const SelectComponent: ForwardRefRenderFunction< isDropped ? onDropDownClose() : onDropDownOpen(); }; + const clearPill = ( + clearValue: string | number, + event: MouseEvent | KeyboardEvent + ) => { + let tempArr = typeof selectValue === "object" ? [...selectValue] : []; + tempArr = tempArr.filter((arr) => arr !== clearValue); + if (value !== undefined) { + onChange(tempArr, event); + } else { + setSelectValue(tempArr); + } + setIsDropped(true); + }; + + const checkIfAllValuesSelected = () => { + return ( + typeof selectValue === "object" && + selectValue.length === options.length && + options.every((el) => selectValue.includes(el.value)) + ); + }; + + const handleInputKeyChange = (event: KeyboardEvent) => { + const optionsList = getOptionsRefAsArray(); + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + !isDropped && setIsDropped(true); + setTimeout(() => { + if (cursor === -1) { + focusFirstOption(); + } else focusNextOption(); + }); + break; + case "ArrowUp": + event.preventDefault(); + focusPrevOption(); + break; + case "Home": + event.preventDefault(); + focusFirstOption(); + break; + case "End": + event.preventDefault(); + focusLastOption(); + break; + default: + break; + } + + if ( + isMulti && + event.code === "Backspace" && + searchText === "" && + typeof selectValue === "object" + ) { + let lastValue = selectValue[selectValue.length - 1]; + clearPill(lastValue, event); + } + + if (event.key === "Enter" || event.code === "Space") { + if (event.currentTarget.tagName !== "INPUT") { + event.preventDefault(); + } else { + event.code !== "Space" && event.preventDefault(); + } + if (isDropped) { + optionsList?.map((option) => { + if (option.getAttribute("data-hover") === "true") { + const optionValue = internalOptions.find( + (arr) => arr.value === option.getAttribute("data-value") + ) as OptionsType; + !optionValue.disabled && internalClickHandler(optionValue, event); + } + }); + } else if (!isDropped) { + setIsDropped(true); + } + } + }; + + const focusNextOption = () => { + let skipStep = 1; + while ( + cursor + skipStep < internalOptions.length && + internalOptions[cursor + skipStep].disabled + ) { + skipStep++; + } + cursor + skipStep < internalOptions.length && setCursor(cursor + skipStep); + }; + + const focusPrevOption = () => { + let skipStep = 1; + while ( + cursor - skipStep >= 0 && + internalOptions[cursor - skipStep].disabled + ) { + skipStep++; + } + cursor - skipStep >= 0 && setCursor(cursor - skipStep); + }; + + const focusFirstOption = () => { + let skipStep = 0; + while ( + skipStep < internalOptions.length && + internalOptions[skipStep].disabled + ) { + skipStep++; + } + if (skipStep < internalOptions.length) { + setCursor(skipStep); + focusElement(skipStep); + } + }; + + const focusLastOption = () => { + let skipStep = 1; + while ( + internalOptions.length - skipStep >= 0 && + internalOptions[internalOptions.length - skipStep].disabled + ) { + skipStep++; + } + internalOptions.length - skipStep >= 0 && + setCursor(internalOptions.length - skipStep); + }; + + const inputKeyDownHandler = (event: KeyboardEvent) => { + event.stopPropagation(); + handleInputKeyChange(event); + }; + const selectIconClass = selectIconRecipe({ isDropped, }); @@ -137,6 +368,7 @@ const SelectComponent: ForwardRefRenderFunction< }); const inputStyles = inputRecipe({ error: error ? true : false, + isMulti, }); useClickOutside( @@ -159,31 +391,49 @@ const SelectComponent: ForwardRefRenderFunction< } }} flexDirection="column" - className={selectContainerStyles} - style={assignInlineVars({ - [selectVars.borderRadius]: borderRadius, - [selectVars.color]: color, - [selectVars.height]: height, - [selectVars.width]: width, - [selectVars.maxDropDownHeight]: maxDropDownHeight, - })} + className={`${selectContainerStyles} ${className}`} + style={{ + ...style, + ...assignInlineVars({ + [selectVars.borderRadius]: borderRadius, + [selectVars.color]: color, + [selectVars.width]: width, + [selectVars.maxDropDownHeight]: maxDropDownHeight, + }), + }} > { - if (event.key === "Enter") { - changeDrop(); - } - }} + onKeyDown={handleInputKeyChange} > -
- {isMulti &&
} {/*pill container */} + + {isMulti && + (typeof selectValue === "object" && selectValue.length !== 0 + ? selectValue?.map((arr) => { + return ( + clearPill(arr, event)} + /> + ); + }) + : !isSearchable && ( +
+ {placeholder || "Pick anything!"} +
+ ))} {isSearchable && ( )} - {!isMulti && !isSearchable && ( -
- {options.find((option) => option.value === selectValue) - ?.label || ( -
- {placeholder || "Pick one"} -
- )} -
- )} -
+ {!isMulti && + !isSearchable && + (getLabel() || ( +
+ {placeholder || "Pick one"} +
+ ))} +
- {DropIcon ? !isClearable && DropIcon : !isClearable && } - {isClearable && } + {DropIcon + ? !isClearable && DropIcon + : !isClearable && } + {isClearable && } {isDropped && ( {internalOptions.length !== 0 ? ( internalOptions.map((option, ind) => { const selectListClass = selectListRecipe({ disabled: option.disabled, - active: option.value === selectValue, + active: !isMulti && option.value === selectValue, }); return ( - !option.disabled && internalClickHandler(option, event) } - onKeyDown={(event) => - event.key === "Enter" && - !option.disabled && - internalClickHandler(option, event) - } + onMouseEnter={(event) => { + if (!option.disabled) { + setCursor(ind); + focusElement(ind); + } + }} + onMouseLeave={(event) => { + event.currentTarget.setAttribute("data-hover", "false"); + }} > - {option.label} - + {option.label} + ); }) ) : (
- {nothingFoundLabel || "Nothing Found!"} + {checkIfAllValuesSelected() + ? "No more Data!" + : nothingFoundLabel || "Nothing Found!"}
)}
diff --git a/lib/src/components/Select/select.css.ts b/lib/src/components/Select/select.css.ts index 43fcc2c..284ab82 100644 --- a/lib/src/components/Select/select.css.ts +++ b/lib/src/components/Select/select.css.ts @@ -1,4 +1,4 @@ -import { createTheme, style } from "@vanilla-extract/css"; +import { createTheme, globalStyle, style } from "@vanilla-extract/css"; import { calc } from "@vanilla-extract/css-utils"; import { recipe } from "@vanilla-extract/recipes"; import { SelectTheme } from "./select.types"; @@ -7,7 +7,6 @@ export const [selectClass, selectVars]: SelectTheme = createTheme({ borderRadius: "0", color: "#2F80ED", maxDropDownHeight: "auto", - height: "40px", width: "100%", }); @@ -19,12 +18,17 @@ export const selectContainerStyles = style({ export const selectInputRecipe = recipe({ base: { background: "white", - padding: "10px 16px", + padding: "8px 16px", width: "100%", - height: selectVars.height, + height: "auto", + minHeight: "40px", border: "1px solid #ced4da ", borderRadius: selectVars.borderRadius, cursor: "default", + ":focus-within": { + border: `1px solid ${selectVars.color} `, + outline: "none", + }, }, variants: { error: { @@ -49,7 +53,6 @@ export const selectListContainerStyle = style({ position: "absolute", background: "white", marginTop: "8px", - top: `${calc.subtract(selectVars.height, "5px")}`, left: 0, width: "100%", maxHeight: selectVars.maxDropDownHeight, @@ -67,7 +70,6 @@ export const selectListContainerStyle = style({ border: "4px solid rgba(0, 0, 0, 0)", borderLeft: "none", backgroundClip: "padding-box", - // borderRadius: "100px", }, }); @@ -78,10 +80,6 @@ export const selectListRecipe = recipe({ background: "white", cursor: "pointer", borderRadius: `${calc.subtract(selectVars.borderRadius, "4px")}`, - ":hover": { - background: "#ebe8e8", - color: "black", - }, }, variants: { disabled: { @@ -113,7 +111,7 @@ export const selectErrorMsg = style({ }); export const selectPlaceholderRecipe = recipe({ - base: { color: "#787878" }, + base: { color: "#787878", padding: "2px" }, variants: { error: { true: { @@ -153,12 +151,21 @@ export const inputRecipe = recipe({ "::placeholder": { color: "#DA2C2C" }, }, }, + isMulti: { + true: { + width: 0, + minWidth: "50px", + flex: 1, + fontSize: "14px", + }, + }, }, }); export const inputTextContainer = style({ - width: "85%", + width: "80%", fontSize: "16px", whiteSpace: "nowrap", overflow: "hidden", + height: "100%", }); diff --git a/lib/src/components/Select/select.global.styles.css.ts b/lib/src/components/Select/select.global.styles.css.ts new file mode 100644 index 0000000..0698578 --- /dev/null +++ b/lib/src/components/Select/select.global.styles.css.ts @@ -0,0 +1,8 @@ +import { globalStyle } from "@vanilla-extract/css"; +import { selectContainerStyles } from "./select.css"; + +globalStyle(`${selectContainerStyles} [data-hover="true"]`, { + background: "#ebe8e8", + color: "black", + outline: "none", +}); diff --git a/lib/src/components/Select/select.types.ts b/lib/src/components/Select/select.types.ts index 0b5e498..47a1c9b 100644 --- a/lib/src/components/Select/select.types.ts +++ b/lib/src/components/Select/select.types.ts @@ -1,14 +1,21 @@ -import { KeyboardEvent, MouseEvent, MutableRefObject } from "react"; +import { + ChangeEvent, + KeyboardEvent, + MouseEvent, + MutableRefObject, +} from "react"; type divType = Omit; export type SelectPropsType = divType & { placeholder?: string; options: OptionsType[]; - value?: string | number; - height?: string; + value?: string | number | (string | number)[]; width?: string; onChange?: ( - value: string | number, - event?: MouseEvent | KeyboardEvent + value: string | number | (string | number)[], + event?: + | MouseEvent + | KeyboardEvent + | MouseEvent ) => void; isSearchable?: boolean; maxDropDownHeight?: string; @@ -28,7 +35,7 @@ export type OptionsType = { label: string; value: string | number; disabled?: boolean | undefined; - ref?: MutableRefObject; + ref?: MutableRefObject; }; export type SelectTheme = [ @@ -37,7 +44,6 @@ export type SelectTheme = [ borderRadius: string; color: string; maxDropDownHeight: string; - height: string; width: string; } ]; diff --git a/lib/src/components/_internal/Icons/ArrowDown.tsx b/lib/src/components/_internal/Icons/ArrowDown.tsx index 705c49f..37663b1 100644 --- a/lib/src/components/_internal/Icons/ArrowDown.tsx +++ b/lib/src/components/_internal/Icons/ArrowDown.tsx @@ -1,20 +1,14 @@ -export const ArrowDown = () => { +import { FC } from "react"; +import { Icon } from "src/components/Icon"; +import { IIconProps } from "src/components/Icon/icon.type"; + +export const ArrowDown: FC = (props) => { return ( - + - + ); }; diff --git a/lib/src/components/_internal/Icons/Clear.tsx b/lib/src/components/_internal/Icons/Clear.tsx index c70698d..d9b62cd 100644 --- a/lib/src/components/_internal/Icons/Clear.tsx +++ b/lib/src/components/_internal/Icons/Clear.tsx @@ -1,20 +1,13 @@ -export const Clear = () => { +import { FC } from "react"; +import { Icon } from "src/components/Icon"; +import { IIconProps } from "src/components/Icon/icon.type"; + +export const Clear: FC = (props) => { return ( - + - + ); };