diff --git a/src/form/form-nested-multi-select.tsx b/src/form/form-nested-multi-select.tsx new file mode 100644 index 000000000..4c196ce78 --- /dev/null +++ b/src/form/form-nested-multi-select.tsx @@ -0,0 +1,29 @@ +import { InputNestedMultiSelect } from "../input-nested-multi-select"; +import { FormWrapper } from "./form-wrapper"; +import { FormNestedMultiSelectProps } from "./types"; + +export const FormNestedMultiSelect = ({ + label, + errorMessage, + id = "form-nested-multi-select", + "data-error-testid": errorTestId, + "data-testid": testId, + ...otherProps +}: FormNestedMultiSelectProps): JSX.Element => { + return ( + + + + ); +}; diff --git a/src/form/index.ts b/src/form/index.ts index 9cf760faa..449fe660f 100644 --- a/src/form/index.ts +++ b/src/form/index.ts @@ -6,6 +6,7 @@ import { FormInputGroup } from "./form-input-group"; import { FormLabel } from "./form-label"; import { FormMultiSelect } from "./form-multi-select"; import { FormNestedSelect } from "./form-nested-select"; +import { FormNestedMultiSelect } from "./form-nested-multi-select"; import { FormPhoneNumberInput } from "./form-phone-number-input"; import { FormPredictiveTextInput } from "./form-predictive-text-input"; import { FormRangeSelect } from "./form-range-select"; @@ -23,6 +24,7 @@ export const Form = { Label: FormLabel, MultiSelect: FormMultiSelect, NestedSelect: FormNestedSelect, + NestedMultiSelect: FormNestedMultiSelect, Select: FormSelect, RangeSelect: FormRangeSelect, Textarea: FormTextarea, diff --git a/src/form/types.ts b/src/form/types.ts index c5c76884c..0ce7d09ff 100644 --- a/src/form/types.ts +++ b/src/form/types.ts @@ -3,6 +3,7 @@ import { DateRangeInputProps } from "../date-range-input/types"; import { InputGroupPartialProps } from "../input-group/types"; import { InputMultiSelectPartialProps } from "../input-multi-select/types"; import { InputNestedSelectPartialProps } from "../input-nested-select"; +import { InputNestedMultiSelectPartialProps } from "../input-nested-multi-select"; import { InputRangeSelectPartialProps } from "../input-range-select/types"; import { InputSelectPartialProps } from "../input-select/types"; import { TextareaPartialProps } from "../input-textarea/types"; @@ -81,6 +82,10 @@ export interface FormNestedSelectProps extends InputNestedSelectPartialProps, BaseFormElementProps {} +export interface FormNestedMultiSelectProps + extends InputNestedMultiSelectPartialProps, + BaseFormElementProps {} + export interface FormDateInputProps extends DateInputProps, BaseFormElementProps {} diff --git a/src/index.ts b/src/index.ts index f7080da65..17b27b468 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export * from "./input"; export * from "./input-group"; export * from "./input-multi-select"; export * from "./input-nested-select"; +export * from "./input-nested-multi-select"; export * from "./input-range-select"; export * from "./input-select"; export * from "./input-textarea"; diff --git a/src/input-nested-multi-select/index.ts b/src/input-nested-multi-select/index.ts new file mode 100644 index 000000000..bdbc3a314 --- /dev/null +++ b/src/input-nested-multi-select/index.ts @@ -0,0 +1,2 @@ +export * from "./input-nested-multi-select"; +export * from "./types"; diff --git a/src/input-nested-multi-select/input-nested-multi-select.tsx b/src/input-nested-multi-select/input-nested-multi-select.tsx new file mode 100644 index 000000000..deba99e06 --- /dev/null +++ b/src/input-nested-multi-select/input-nested-multi-select.tsx @@ -0,0 +1,405 @@ +import React, { useEffect, useRef, useState } from "react"; +import isEmpty from "lodash/isEmpty"; +import { NestedDropdownList } from "../shared/nested-dropdown-list/nested-dropdown-list"; +import { DropdownWrapper } from "../shared/dropdown-wrapper"; +import { + CombinedFormattedOptionProps, + SelectedItem, +} from "../shared/nested-dropdown-list/types"; +import { + Divider, + IconContainer, + LabelContainer, + PlaceholderLabel, + Selector, + StyledChevronIcon, + ValueLabel, +} from "../shared/dropdown-wrapper/dropdown-wrapper.styles"; +import { StringHelper } from "../util"; +import { InputNestedMultiSelectProps } from "./types"; +import { CombinedOptionProps } from "../input-nested-select"; + +export const InputNestedMultiSelect = ({ + placeholder = "Select", + options, + disabled, + error, + className, + "data-testid": testId, + id, + selectedKeyPaths: _selectedKeyPaths, + mode, + valueToStringFunction, + enableSearch, + searchPlaceholder, + hideNoResultsDisplay, + listStyleWidth, + readOnly, + onSearch, + onSelectOptions, + onShowOptions, + onHideOptions, + onRetry, + optionsLoadState = "success", + optionTruncationType = "end", + ...otherProps +}: InputNestedMultiSelectProps): JSX.Element => { + // ============================================================================= + // CONST, STATE + // ============================================================================= + const [selectedKeyPaths, setSelectedKeyPaths] = useState( + _selectedKeyPaths || [] + ); + const [selectedItems, setSelectedItems] = useState< + SelectedItem[] + >([]); + + const [showOptions, setShowOptions] = useState(false); + + const selectorRef = useRef(); + const labelContainerRef = useRef(); + + // ============================================================================= + // EFFECTS + // ============================================================================= + useEffect(() => { + const newKeyPath = _selectedKeyPaths || []; + const selectedItems = getSelectedItemFromKey(options, newKeyPath); + + setSelectedKeyPaths(newKeyPath); + setSelectedItems(selectedItems); + }, [_selectedKeyPaths, options]); + + // ============================================================================= + // EVENT HANDLERS + // ============================================================================= + const handleSelectorClick = (event: React.MouseEvent) => { + event.preventDefault(); + + if (disabled || readOnly) { + return; + } + + setShowOptions(!showOptions); + triggerOptionDisplayCallback(!showOptions); + }; + + const handleSelectItem = ( + item: CombinedFormattedOptionProps + ) => { + const selectedItem = getItemAtKeyPath(item.keyPath); + let newKeyPaths: string[][] = []; + + if (selectedItem.subItems) { + const selectableOptionKeyPaths = getSubItemKeyPaths( + selectedItem, + item.keyPath + ); + + const selectedCount = selectedKeyPaths.filter((keyPath) => + isSubItem(keyPath, item.keyPath) + ).length; + + if (selectedCount < selectableOptionKeyPaths.length) { + newKeyPaths = [ + ...new Map( + [...selectedKeyPaths, ...selectableOptionKeyPaths].map( + (k) => [k.join("-"), k] + ) + ).values(), + ]; + } else { + newKeyPaths = selectedKeyPaths.filter( + (keyPath) => !isSubItem(keyPath, item.keyPath) + ); + } + } else { + const selected = selectedKeyPaths.some((keyPath) => + isSubItem(keyPath, item.keyPath) + ); + + if (selected) { + const filteredItems = selectedItems.filter( + ({ keyPath }) => + JSON.stringify(keyPath) !== JSON.stringify(item.keyPath) + ); + newKeyPaths = filteredItems.map((i) => i.keyPath); + } else { + newKeyPaths = [...selectedKeyPaths, item.keyPath]; + } + } + + const newSelectedItems = getSelectedItemFromKey(options, newKeyPaths); + + setSelectedKeyPaths(newKeyPaths); + setSelectedItems(newSelectedItems); + + if (selectorRef.current) selectorRef.current.focus(); + + performOnSelectOptions(newKeyPaths, newSelectedItems); + }; + + const handleSelectAll = ( + keyPaths: string[][], + items: SelectedItem[] + ) => { + if (keyPaths && keyPaths.length > 0) { + setSelectedKeyPaths(keyPaths); + setSelectedItems(items); + performOnSelectOptions(keyPaths, items); + } else { + setSelectedKeyPaths([]); + setSelectedItems([]); + performOnSelectOptions(); + } + }; + + const handleListDismiss = (setSelectorFocus?: boolean | undefined) => { + if (showOptions) { + setShowOptions(false); + triggerOptionDisplayCallback(false); + } + + if (setSelectorFocus && selectorRef.current) { + selectorRef.current.focus(); + } + }; + + const handleWrapperBlur = () => { + setShowOptions(false); + triggerOptionDisplayCallback(false); + }; + + // ============================================================================= + // HELPER FUNCTION + // ============================================================================= + const performOnSelectOptions = ( + keyPaths: string[][] = [], + items: SelectedItem[] = [] + ) => { + if (onSelectOptions) { + const returnValue = items.map((item) => item.value); + onSelectOptions(keyPaths, returnValue); + } + }; + + const getDisplayValue = (): string => { + const { label, value } = selectedItems[0]; + + if (selectedItems.length > 1) { + return `${selectedItems.length} selected`; + } else if (valueToStringFunction) { + return valueToStringFunction(value) || value.toString(); + } else { + return label; + } + }; + + const getItemAtKeyPath = (_keyPath: string[]) => { + const find = ( + items: CombinedOptionProps[], + keyPath: string[] + ): CombinedOptionProps => { + const [currentKey, ...nextKeyPath] = keyPath; + + if (isEmpty(items) || !currentKey) return undefined; + + const item = items.find((item) => item.key === currentKey); + + if (!item || !nextKeyPath.length) return item; + + return find(item.subItems, nextKeyPath); + }; + + const item = find(options, _keyPath); + + return item; + }; + + const isSubItem = (listItemKeyPath: string[], categoryKeyPath: string[]) => + JSON.stringify(categoryKeyPath) === + JSON.stringify(listItemKeyPath.slice(0, categoryKeyPath.length)); + + const getSubItemKeyPaths = ( + _item: CombinedOptionProps, + selectedKeyPath: string[] + ) => { + const targetKeyPaths: string[][] = []; + const parentKey = selectedKeyPath.slice(0, -1); + + const find = ( + item: CombinedOptionProps, + parentKey: string[] + ) => { + const releventKey = [...parentKey, item.key]; + + if (!item.subItems) { + targetKeyPaths.push(releventKey); + return; + } + + item.subItems.forEach((subItem) => find(subItem, releventKey)); + }; + + find(_item, parentKey); + + return targetKeyPaths; + }; + + const getSelectedItemFromKey = ( + options: CombinedOptionProps[], + keyPaths: string[][] + ) => { + let count = 0; + + const findSelectedItem = ( + items: CombinedOptionProps[], + keyPath: string[] + ): SelectedItem | undefined => { + const [currentKey, ...nextKeyPath] = keyPath; + + if (isEmpty(items) || !currentKey) { + return undefined; + } + + const item = items.find((item) => item.key === currentKey); + const { label, value, subItems } = item; + + if (!item || !nextKeyPath.length) { + const result = { + label, + value, + keyPath: keyPaths[count], + }; + count = count + 1; + return result; + } + + return findSelectedItem(subItems, nextKeyPath); + }; + + const selectedItems = []; + + for (let i = 0; i < keyPaths.length; i++) { + const item = findSelectedItem(options, keyPaths[i]); + + if (item) { + selectedItems.push({ + value: item.value, + label: item.label, + keyPath: item.keyPath, + }); + } + } + + return selectedItems; + }; + + const truncateValue = (value: string) => { + if (optionTruncationType === "middle") { + let widthOfElement = 0; + if (labelContainerRef && labelContainerRef.current) { + widthOfElement = + labelContainerRef.current.getBoundingClientRect().width; + } + return StringHelper.truncateOneLine(value, widthOfElement, 120, 6); + } + + return value; + }; + + const triggerOptionDisplayCallback = (show: boolean) => { + if (!show && onHideOptions) { + onHideOptions(); + } + + if (show && onShowOptions) { + onShowOptions(); + } + }; + + // ============================================================================= + // RENDER FUNCTION + // ============================================================================= + const renderLabel = () => { + if (isEmpty(selectedItems)) { + return ( + + {placeholder} + + ); + } else { + return ( + + {truncateValue(getDisplayValue())} + + ); + } + }; + + const renderSelectorContent = () => ( + <> + + {renderLabel()} + + {!readOnly && ( + + + + )} + + ); + + const renderOptionList = () => { + if ((options && options.length > 0) || onRetry) { + return ( + + ); + } + + return null; + }; + + return ( + + + {renderSelectorContent()} + + {showOptions && } + {renderOptionList()} + + ); +}; diff --git a/src/input-nested-multi-select/types.ts b/src/input-nested-multi-select/types.ts new file mode 100644 index 000000000..e61b3f15e --- /dev/null +++ b/src/input-nested-multi-select/types.ts @@ -0,0 +1,34 @@ +import { + InputNestedSelectOptionsProps, + InputNestedSelectSharedProps, +} from "../input-nested-select"; +import { InputSelectSharedProps } from "../input-select"; +import { + DropdownSearchProps, + DropdownStyleProps, +} from "../shared/nested-dropdown-list/types"; + +// ============================================================================= +// INPUT SELECT PROPS +// ============================================================================= + +export interface InputNestedMultiSelectProps + extends React.HTMLAttributes, + InputNestedSelectOptionsProps, + Omit, "options">, + InputNestedSelectSharedProps, + DropdownSearchProps, + DropdownStyleProps { + /** Specifies key paths to select particular option label */ + selectedKeyPaths?: string[][] | undefined; + /** Called when a selection is made. Returns the key paths and values of selected items in the next selection state */ + onSelectOptions?: + | ((keyPaths: string[][], values: Array) => void) + | undefined; +} + +/** To be exposed for Form component inheritance */ +export type InputNestedMultiSelectPartialProps = Omit< + InputNestedMultiSelectProps, + "error" +>; diff --git a/src/input-nested-select/input-nested-select.tsx b/src/input-nested-select/input-nested-select.tsx index ae6c00cee..4353ac859 100644 --- a/src/input-nested-select/input-nested-select.tsx +++ b/src/input-nested-select/input-nested-select.tsx @@ -59,8 +59,8 @@ export const InputNestedSelect = ({ // ============================================================================= // CONST, STATE // ============================================================================= - const [selectedKeyPath, setSelectedKeyPath] = useState( - _selectedKeyPath || [] + const [selectedKeyPaths, setSelectedKeyPaths] = useState( + _selectedKeyPath ? [_selectedKeyPath] : [] ); const [selectedItem, setSelectedItem] = useState>(); @@ -74,10 +74,10 @@ export const InputNestedSelect = ({ // EFFECTS // ============================================================================= useEffect(() => { - const newKeyPath = _selectedKeyPath || []; - setSelectedKeyPath(newKeyPath); + const newKeyPath = _selectedKeyPath ? [_selectedKeyPath] : []; - updateSelectedItemFromKey(options, newKeyPath); + setSelectedKeyPaths(newKeyPath); + updateSelectedItemFromKey(options, _selectedKeyPath || []); }, [_selectedKeyPath, options]); // ============================================================================= @@ -99,7 +99,7 @@ export const InputNestedSelect = ({ ) => { const { keyPath, value, label } = item; - setSelectedKeyPath(keyPath); + setSelectedKeyPaths([keyPath]); setSelectedItem({ label, value }); setShowOptions(false); triggerOptionDisplayCallback(false); @@ -234,12 +234,12 @@ export const InputNestedSelect = ({ if ((options && options.length > 0) || onRetry) { return ( +export interface InputNestedSelectOptionsProps extends Omit, "options"> { options: L1OptionProps[]; } @@ -16,24 +16,29 @@ interface InputNestedSelectOptionsProps // ============================================================================= // INPUT SELECT PROPS // ============================================================================= +export interface InputNestedSelectSharedProps { + readOnly?: boolean | undefined; + /** Specifies if items are expanded or collapsed when the dropdown is opened */ + mode?: Mode | undefined; + /** Function to convert selected value into a string */ + valueToStringFunction?: ((value: V1 | V2 | V3) => string) | undefined; +} + export interface InputNestedSelectProps extends React.HTMLAttributes, InputNestedSelectOptionsProps, + InputNestedSelectSharedProps, Omit, "options">, DropdownSearchProps, DropdownStyleProps { - readOnly?: boolean | undefined; /** Specifies key path of the selected option */ selectedKeyPath?: string[] | undefined; - /** Specifies if items are expanded or collapsed when the dropdown is opened */ - mode?: Mode | undefined; /** If specified, the category label is selectable */ selectableCategory?: boolean | undefined; + /** Called when an option is selected. Returns the option's key path and value */ onSelectOption?: | ((keyPath: string[], value: V1 | V2 | V3) => void) | undefined; - /** Function to convert selected value into a string */ - valueToStringFunction?: ((value: V1 | V2 | V3) => string) | undefined; } /** To be exposed for Form component inheritance */ diff --git a/src/shared/nested-dropdown-list/list-item.styles.tsx b/src/shared/nested-dropdown-list/list-item.styles.tsx index d24194a07..042530c2d 100644 --- a/src/shared/nested-dropdown-list/list-item.styles.tsx +++ b/src/shared/nested-dropdown-list/list-item.styles.tsx @@ -1,5 +1,6 @@ import styled, { css } from "styled-components"; import { Color } from "../../color"; +import { Checkbox } from "../../checkbox"; import { TextStyleHelper } from "../../text"; import { TruncateType } from "./types"; import { IconButton } from "../../icon-button"; @@ -12,17 +13,27 @@ import { TriangleForwardFillIcon } from "@lifesg/react-icons/triangle-forward-fi interface ListProps { $expanded: boolean; + $multiSelect: boolean; } interface ListItemSelectorProps { $selected: boolean; - $level_3: boolean; + $multiSelect: boolean; } interface LabelProps { $truncateType?: TruncateType; } +interface ItemProps { + $level: number; + $multiSelect: boolean; +} + +interface CheckboxInputProps { + $type: "category" | "label"; +} + interface ArrowButtonProps extends Pick {} // ============================================================================= // STYLING @@ -36,6 +47,7 @@ export const Category = styled.div` `; export const ListItemSelector = styled.button` + display: flex; width: 100%; border: none; cursor: pointer; @@ -43,6 +55,7 @@ export const ListItemSelector = styled.button` text-align: left; padding: 0.5rem; min-height: 2.625rem; + cursor: pointer; :hover, :visited, @@ -52,22 +65,25 @@ export const ListItemSelector = styled.button` } :hover { - background-color: ${Color.Accent.Light[5]}; + background-color: ${(props) => + props.$multiSelect ? "transparent" : Color.Accent.Light[5]}; } ${(props) => { - if (props.$level_3) { + const { $selected, $multiSelect } = props; + if (!$multiSelect && $selected) { return css` - margin-left: 0.5rem; - width: calc(100% - 0.5rem); + background: ${Color.Accent.Light[5]}; `; } }} +`; +export const Item = styled.li` ${(props) => { - if (props.$selected) { + if (props.$multiSelect) { return css` - background: ${Color.Accent.Light[5]}; + margin-left: 2.125rem; `; } }} @@ -118,6 +134,28 @@ export const TruncateSecondLine = styled.div` text-align: right; `; +export const ButtonSection = styled.div` + display: flex; +`; + +export const CheckboxInput = styled(Checkbox)` + min-width: 1.5rem; + max-width: 1.5rem; + + ${(props) => { + switch (props.$type) { + case "category": + return css` + margin-left: 0.5rem; + `; + case "label": + return css` + margin-right: 0.5rem; + `; + } + }}; +`; + export const ArrowButton = styled(IconButton)` border: none; background: transparent; @@ -153,6 +191,7 @@ export const Title = styled.button` background: transparent; border: none; cursor: pointer; + width: 100%; padding: 0; overflow-wrap: anywhere; @@ -168,6 +207,5 @@ export const Title = styled.button` export const List = styled.ul` display: ${(props) => (props.$expanded ? "flex" : "none")}; flex-direction: column; - cursor: pointer; margin-left: 2.125rem; `; diff --git a/src/shared/nested-dropdown-list/list-item.tsx b/src/shared/nested-dropdown-list/list-item.tsx index 0ab0e6612..4f88a3e5c 100644 --- a/src/shared/nested-dropdown-list/list-item.tsx +++ b/src/shared/nested-dropdown-list/list-item.tsx @@ -4,7 +4,10 @@ import { StringHelper } from "../../util"; import { ArrowButton, Bold, + ButtonSection, Category, + CheckboxInput, + Item, Label, List, ListItemSelector, @@ -17,28 +20,30 @@ import { interface ListItemProps { item: CombinedFormattedOptionProps; - selectedKeyPath?: string[] | undefined; selectableCategory?: boolean | undefined; searchValue: string | undefined; itemTruncationType?: TruncateType | undefined; + multiSelect: boolean; visible: boolean; onBlur: () => void; onExpand: (parentKeys: string[]) => void; onRef: (ref: HTMLButtonElement, keyPaths: string[]) => void; onSelect: (item: CombinedFormattedOptionProps) => void; + onSelectCategory: (item: CombinedFormattedOptionProps) => void; } export const ListItem = ({ item, - selectedKeyPath, selectableCategory, searchValue, itemTruncationType, + multiSelect, visible, onBlur, onExpand, onRef, onSelect, + onSelectCategory, }: ListItemProps): JSX.Element => { // ============================================================================= // CONST, REF, STATE @@ -52,11 +57,17 @@ export const ListItem = ({ event.preventDefault(); onExpand(item.keyPath); }; + const handleSelect = (event: React.MouseEvent) => { event.preventDefault(); onSelect(item); }; + const handleSelectParent = (event: React.ChangeEvent) => { + event.stopPropagation(); + onSelectCategory(item); + }; + const handleBlur = () => { if (onBlur) { onBlur(); @@ -66,10 +77,6 @@ export const ListItem = ({ // ============================================================================= // HELPER FUNCTIONS // ============================================================================= - const checkListItemSelected = (keyPath: string[]): boolean => { - return JSON.stringify(selectedKeyPath) === JSON.stringify(keyPath); - }; - const hasExceededContainer = ( item: CombinedFormattedOptionProps ) => { @@ -111,7 +118,7 @@ export const ListItem = ({ const endIndex = startIndex + searchTerm.length; if (startIndex == -1) { - return <>{item.label}; + return <>{label}; } return ( @@ -127,87 +134,127 @@ export const ListItem = ({ const nextSubItems = item.subItems.values(); return ( - + {[...nextSubItems].map((item) => ( ))} ); }; - const renderTitleItem = () => { - if (selectableCategory) { - return ( - - onRef(ref, item.keyPath)} - onClick={handleExpand} - $expanded={item.expanded} - aria-expanded={item.expanded} - > - - - - <span>{item.label}</span> - - - ); - } - + const renderCategoryIcon = () => { return ( - + onRef(ref, item.keyPath)} $expanded={item.expanded} aria-expanded={item.expanded} + onClick={handleExpand} > - + {multiSelect && ( + <CheckboxInput + displaySize="small" + $type="category" + checked={item.checked} + indeterminate={item.indeterminate} + onChange={handleSelectParent} + /> + )} + </ButtonSection> + ); + }; + + const renderCategoryItem = () => { + let categoryProps = {}; + let titleProps = {}; + + if (selectableCategory) { + titleProps = { + onClick: handleSelect, + }; + } + + if (multiSelect) { + titleProps = { + onClick: handleExpand, + tabIndex: -1, + }; + } else { + categoryProps = { + onClick: handleExpand, + }; + } + + return ( + <Category {...categoryProps}> + {renderCategoryIcon()} + <Title {...titleProps}> <span>{item.label}</span> ); }; + const renderLabel = () => { + return ( + <> + {multiSelect && ( + + )} + + + ); + }; + if (!item.subItems) { return ( -
  • + onRef(ref, item.keyPath)} type="button" tabIndex={visible ? 0 : -1} - $selected={checkListItemSelected(item.keyPath)} - $level_3={item.keyPath.length === 3} + $selected={item.selected} + $multiSelect={multiSelect} onBlur={handleBlur} onClick={handleSelect} > - + {renderLabel()} -
  • + ); } return (
  • - {renderTitleItem()} + {renderCategoryItem()} {renderListItem()}
  • ); diff --git a/src/shared/nested-dropdown-list/nested-dropdown-list-helper.ts b/src/shared/nested-dropdown-list/nested-dropdown-list-helper.ts index 5555d73c6..cb6c1bf4a 100644 --- a/src/shared/nested-dropdown-list/nested-dropdown-list-helper.ts +++ b/src/shared/nested-dropdown-list/nested-dropdown-list-helper.ts @@ -1,31 +1,96 @@ import { produce } from "immer"; -import { CombinedFormattedOptionProps, FormattedOption } from "./types"; +import { + CombinedFormattedOptionProps, + FormattedOption, + Mode, + SelectedItem, +} from "./types"; +import { CombinedOptionProps } from "../../input-nested-select"; export type FormattedOptionMap = Map< string, FormattedOption >; +interface UpdateSelectedAllType + extends GetAllKeyPathsAndItemsType { + list: FormattedOptionMap; +} + +interface GetAllKeyPathsAndItemsType { + keyPaths: string[][]; + items: SelectedItem[]; +} + export namespace NestedDropdownListHelper { + export const getInitialItems = ( + list: CombinedOptionProps[], + parentKeys: string[], + mode: Mode + ): FormattedOptionMap => { + const formatted = ( + options: CombinedOptionProps[], + parentKeys: string[] + ): FormattedOptionMap => { + return options.reduce((result, option) => { + const { key, label, value, subItems } = option; + const stringKey = key.toString(); + + const keyPath = [...parentKeys, stringKey]; + + const item = { + label, + value, + expanded: mode === "expand", + isSearchTerm: false, + selected: false, + checked: false, + indeterminate: false, + keyPath, + subItems: subItems + ? formatted(subItems, keyPath) + : undefined, + }; + + result.set(stringKey, item); + + return result; + }, new Map()); + }; + + return formatted(list, parentKeys); + }; + export const getInitialDropdown = ( currentItems: FormattedOptionMap, - selectedKeyPath?: string[] | undefined + selectedKeyPaths: string[][] ) => { - let keyPath = selectedKeyPath; + let keyPaths = selectedKeyPaths; - if (!keyPath || !keyPath.length) { - keyPath = getInitialSubItem(currentItems); - keyPath = keyPath.slice(0, -1); + if (!keyPaths || !keyPaths.length) { + keyPaths = [getInitialSubItem(currentItems)]; } const list = produce( currentItems, - (draft: Map>) => { - const targetKey = []; - keyPath.forEach((key) => { - targetKey.push(key); - const item = getItemAtKeyPath(draft, targetKey); - item.expanded = true; + (draft: FormattedOptionMap) => { + let targetKey: string[] = []; + + keyPaths.forEach((keyPath) => { + targetKey = []; + keyPath.forEach((key) => { + targetKey.push(key); + const item = getItemAtKeyPath(draft, targetKey); + + const selected = selectedKeyPaths.some( + (keyPath) => + JSON.stringify(keyPath) === + JSON.stringify(item.keyPath) + ); + + if (item.subItems) item.expanded = true; + if (selected) item.selected = true; + }); }); } ); @@ -33,6 +98,50 @@ export namespace NestedDropdownListHelper { return list; }; + export const updateSelectedAll = ( + currentItems: FormattedOptionMap, + isSelectedAll: boolean + ): UpdateSelectedAllType => { + let list = currentItems; + let keyPaths: string[][] = []; + let items: SelectedItem[] = []; + + if (isSelectedAll) { + const { keyPaths: _keyPaths, items: _items } = + getAllKeyPathsAndItems(list); + + keyPaths = _keyPaths; + items = _items; + + list = produce( + currentItems, + (draft: FormattedOptionMap) => { + for (const item of draft.values()) { + const update = ( + item: CombinedFormattedOptionProps + ) => { + if (!item.subItems) return; + + item.expanded = true; + + return item.subItems.forEach((subItem) => + update(subItem) + ); + }; + + update(item); + } + } + ); + } + + return { + keyPaths, + items, + list, + }; + }; + export const getVisibleKeyPaths = ( list: Map> ): string[][] => { @@ -56,6 +165,79 @@ export namespace NestedDropdownListHelper { return keyPaths; }; + export const getUpdateCheckbox = ( + list: FormattedOptionMap, + selectedKeyPaths: string[][] + ) => { + const result = produce( + list, + (draft: FormattedOptionMap) => { + const update = ( + items: Map> + ) => { + for (const item of items.values()) { + if (!item.subItems) { + const checked = selectedKeyPaths.some( + (keyPath) => + JSON.stringify(keyPath) === + JSON.stringify(item.keyPath) + ); + item.checked = checked; + } else { + update(item.subItems); + + const subItems: Map< + string, + CombinedFormattedOptionProps + > = item.subItems; + + const { checked, indeterminate } = Array.from( + subItems + ).reduce( + (result, subItemMap) => { + const item = subItemMap[1]; + result.checked.push(item.checked); + result.indeterminate.push( + item.indeterminate + ); + + return result; + }, + { + checked: [], + indeterminate: [], + } + ); + + const isAllChecked = checked.every(Boolean); + const isPartialChecked = checked.some(Boolean); + const isPartialIndeterminate = + indeterminate.some(Boolean); + + if (isAllChecked) { + item.checked = true; + item.indeterminate = false; + } else if ( + isPartialChecked || + isPartialIndeterminate + ) { + item.checked = false; + item.indeterminate = true; + } else { + item.checked = false; + item.indeterminate = false; + } + } + } + }; + + update(draft); + } + ); + + return result; + }; + export const getItemAtKeyPath = ( draft: FormattedOptionMap, keyPath: string[] @@ -91,3 +273,36 @@ const getInitialSubItem = ( const value = list.values().next().value; return value.keyPath; }; + +const getAllKeyPathsAndItems = ( + list: FormattedOptionMap +) => { + const keyPaths: string[][] = []; + const items: SelectedItem[] = []; + + const getKeyPath = ( + _items: Map> + ): GetAllKeyPathsAndItemsType => { + if (!_items || !_items.size) return; + + for (const item of _items.values()) { + const { keyPath, label, value } = item; + if (item.subItems && item.subItems.size) { + getKeyPath(item.subItems); + } else { + keyPaths.push(item.keyPath); + items.push({ + label, + value, + keyPath, + }); + } + } + }; + + getKeyPath(list); + return { + keyPaths, + items, + }; +}; diff --git a/src/shared/nested-dropdown-list/nested-dropdown-list.styles.tsx b/src/shared/nested-dropdown-list/nested-dropdown-list.styles.tsx index b4e0a09bd..6ec51ddbe 100644 --- a/src/shared/nested-dropdown-list/nested-dropdown-list.styles.tsx +++ b/src/shared/nested-dropdown-list/nested-dropdown-list.styles.tsx @@ -65,17 +65,19 @@ export const LabelIcon = styled(ExclamationCircleFillIcon)` color: ${Color.Validation.Red.Icon}; `; +export const SelectAllContainer = styled.div` + width: 100%; + display: flex; + justify-content: flex-end; + padding: 0.5rem 0; +`; + export const DropdownCommonButton = styled.button` ${TextStyleHelper.getTextStyle("Body", "semibold")} + color: ${Color.Primary}; background-color: transparent; - background-repeat: no-repeat; border: none; cursor: pointer; overflow: hidden; outline: none; - ${(props) => { - return ` - color: ${Color.Primary(props)}; - `; - }} `; diff --git a/src/shared/nested-dropdown-list/nested-dropdown-list.tsx b/src/shared/nested-dropdown-list/nested-dropdown-list.tsx index f51352843..962558f33 100644 --- a/src/shared/nested-dropdown-list/nested-dropdown-list.tsx +++ b/src/shared/nested-dropdown-list/nested-dropdown-list.tsx @@ -3,10 +3,14 @@ import get from "lodash/get"; import { useEffect, useMemo, useRef, useState } from "react"; import { useSpring } from "react-spring"; import { Spinner } from "../../button/button.style"; -import { CombinedOptionProps } from "../../input-nested-select"; import { useEventListener } from "../../util/use-event-listener"; import { ListItem } from "./list-item"; import { DropdownSearch } from "../dropdown-list/dropdown-search"; +import { + FormattedOptionMap, + NestedDropdownListHelper, +} from "./nested-dropdown-list-helper"; +import { CombinedFormattedOptionProps, NestedDropdownListProps } from "./types"; import { Container, DropdownCommonButton, @@ -14,12 +18,8 @@ import { List, ResultStateContainer, ResultStateText, + SelectAllContainer, } from "./nested-dropdown-list.styles"; -import { CombinedFormattedOptionProps, NestedDropdownListProps } from "./types"; -import { - FormattedOptionMap, - NestedDropdownListHelper, -} from "./nested-dropdown-list-helper"; enableMapSet(); @@ -42,12 +42,14 @@ export const NestedDropdownList = ({ searchPlaceholder = "Search", visible, mode = "default", - selectedKeyPath, + multiSelect, + selectedKeyPaths, selectableCategory, itemsLoadState = "success", itemTruncationType = "end", onBlur, onDismiss, + onSelectAll, onRetry, onSearch, onSelectItem, @@ -58,36 +60,7 @@ export const NestedDropdownList = ({ // ============================================================================= const initialItems = useMemo((): FormattedOptionMap => { if (!_listItems || !_listItems.length) return new Map([]); - - const formatted = ( - options: CombinedOptionProps[], - parentKeys: string[] - ): FormattedOptionMap => { - return options.reduce((result, option) => { - const { key, label, value, subItems } = option; - const stringKey = key.toString(); - - const keyPath = [...parentKeys, stringKey]; - - const item = { - label, - value, - expanded: mode === "expand", - selected: false, - isSearchTerm: false, - keyPath, - subItems: subItems - ? formatted(subItems, keyPath) - : undefined, - }; - - result.set(stringKey, item); - - return result; - }, new Map()); - }; - - return formatted(_listItems, []); + return NestedDropdownListHelper.getInitialItems(_listItems, [], mode); }, [_listItems]); const [searchValue, setSearchValue] = useState(""); @@ -118,16 +91,26 @@ export const NestedDropdownList = ({ const list = getInitialDropdown(); const keyPaths = NestedDropdownListHelper.getVisibleKeyPaths(list); - setCurrentItems(list); - setVisibleKeyPaths(keyPaths); - if (searchInputRef.current) { searchInputRef.current.focus(); } else if (listItemRefs.current) { const target = keyPaths[focusedIndex]; - listItemRefs.current[target[0]].ref.focus(); + listItemRefs.current[target[0]]?.ref.focus(); } + if (multiSelect) { + const multiSelectList = + NestedDropdownListHelper.getUpdateCheckbox( + list, + selectedKeyPaths + ); + + setCurrentItems(multiSelectList); + } else { + setCurrentItems(list); + } + + setVisibleKeyPaths(keyPaths); // Give some time for the custom call-to-action to be rendered setTimeout(() => { setContentHeight(getContentHeight()); @@ -153,6 +136,19 @@ export const NestedDropdownList = ({ filterAndUpdateList(searchValue); }, [searchValue]); + useEffect(() => { + if (visible && multiSelect) { + const targetList = isSearch ? filteredItems : currentItems; + + const list = NestedDropdownListHelper.getUpdateCheckbox( + targetList, + selectedKeyPaths + ); + + isSearch ? setFilteredItems(list) : setCurrentItems(list); + } + }, [selectedKeyPaths, isSearch]); + useEventListener("keydown", handleKeyboardPress, "document"); // ============================================================================= @@ -160,7 +156,55 @@ export const NestedDropdownList = ({ // ============================================================================= const handleSelect = (item: CombinedFormattedOptionProps) => { - onSelectItem(item); + const { label, keyPath, value } = item; + onSelectItem({ label, keyPath, value }); + }; + + const handleSelectCategory = ( + item: CombinedFormattedOptionProps + ) => { + const targetList = isSearch ? filteredItems : currentItems; + const { label, keyPath, value } = item; + + const list = produce( + targetList, + (draft: FormattedOptionMap) => { + const item = NestedDropdownListHelper.getItemAtKeyPath( + draft, + keyPath + ); + + item.expanded = true; + + if (item.subItems && item.subItems.size) { + for (const nextItem of item.subItems.values()) { + nextItem.expanded = true; + } + } + } + ); + + const visibleKeyPaths = + NestedDropdownListHelper.getVisibleKeyPaths(list); + setVisibleKeyPaths(visibleKeyPaths); + isSearch ? setFilteredItems(list) : setCurrentItems(list); + + onSelectItem({ label, keyPath, value }); + }; + + const handleSelectAll = () => { + const isSelectedAll = !selectedKeyPaths.length; + + const { keyPaths, items, list } = + NestedDropdownListHelper.updateSelectedAll( + currentItems, + isSelectedAll + ); + + if (onSelectAll) { + setCurrentItems(list); + isSelectedAll ? onSelectAll(keyPaths, items) : onSelectAll([], []); + } }; const handleExpand = (keyPath: string[]) => { @@ -179,11 +223,7 @@ export const NestedDropdownList = ({ const keyPaths = NestedDropdownListHelper.getVisibleKeyPaths(list); setVisibleKeyPaths(keyPaths); - if (isSearch) { - setFilteredItems(list); - } else { - setCurrentItems(list); - } + isSearch ? setFilteredItems(list) : setCurrentItems(list); }; const handleListItemRef = ( @@ -372,7 +412,7 @@ export const NestedDropdownList = ({ // otherwise expand the first selected item or first subitem tree const list = NestedDropdownListHelper.getInitialDropdown( currentItems, - selectedKeyPath + selectedKeyPaths ); return list; @@ -391,11 +431,20 @@ export const NestedDropdownList = ({ setIsSearch(false); } else if (searchValue.trim().length >= 3) { listItemRefs.current = {}; - const isSearch = true; const filtered = updateSearchState(); setFilteredItems(filtered); resetVisbileKeyPaths(filtered); - setIsSearch(isSearch); + setIsSearch(true); + + if (multiSelect) { + const multiSelectList = + NestedDropdownListHelper.getUpdateCheckbox( + filtered, + selectedKeyPaths + ); + + setFilteredItems(multiSelectList); + } } }; @@ -410,15 +459,16 @@ export const NestedDropdownList = ({ )); } @@ -443,6 +493,28 @@ export const NestedDropdownList = ({ return null; }; + const renderSelectAll = () => { + if ( + multiSelect && + initialItems.size > 0 && + !isSearch && + itemsLoadState === "success" + ) { + return ( + + + {selectedKeyPaths.length === 0 + ? "Select all" + : "Clear all"} + + + ); + } + }; + const renderNoResults = () => { if (isSearch) { if (!hideNoResultsDisplay && !filteredItems.size) { @@ -501,6 +573,7 @@ export const NestedDropdownList = ({ {...otherProps} > {renderSearchInput()} + {renderSelectAll()} {renderLoading()} {renderNoResults()} {renderTryAgain()} @@ -511,9 +584,7 @@ export const NestedDropdownList = ({ /** TODO: - 3. renderSelectAll 16. renderBottomCta - 18. bold matched character 19. middle truncation */ diff --git a/src/shared/nested-dropdown-list/types.ts b/src/shared/nested-dropdown-list/types.ts index af832b54e..a3cf39271 100644 --- a/src/shared/nested-dropdown-list/types.ts +++ b/src/shared/nested-dropdown-list/types.ts @@ -3,13 +3,18 @@ import { L1OptionProps } from "../../input-nested-select/types"; export type TruncateType = "middle" | "end"; export type ItemsLoadStateType = "loading" | "fail" | "success"; export type Mode = "default" | "expand" | "collapse"; - export interface DropdownStyleProps { listStyleWidth?: string | undefined; } +export interface SelectedItem { + label: string; + keyPath: string[]; + value: V1 | V2 | V3; +} + export interface DropdownEventHandlerProps { - onSelectItem: (item: CombinedFormattedOptionProps) => void; + onSelectItem: (item: SelectedItem) => void; } export interface DropdownSearchProps { @@ -28,8 +33,9 @@ export interface NestedDropdownListProps DropdownStyleProps { listItems?: L1OptionProps[] | undefined; visible?: boolean | undefined; + multiSelect?: boolean | undefined; /** Specifies key path of selected option */ - selectedKeyPath?: string[] | undefined; + selectedKeyPaths: string[][]; /** Specifies if items are expanded or collapsed when the dropdown is opened */ mode?: Mode | undefined; /** If specified, the category label is selectable */ @@ -43,6 +49,9 @@ export interface NestedDropdownListProps itemTruncationType?: TruncateType | undefined; onDismiss?: ((setSelectorFocus?: boolean | undefined) => void) | undefined; + onSelectAll?: + | ((keyPaths: string[][], items: SelectedItem[]) => void) + | undefined; onRetry?: (() => void) | undefined; onBlur?: (() => void) | undefined; } @@ -60,6 +69,8 @@ interface BaseFormattedOptionProps { keyPath: string[]; expanded: boolean; selected: boolean; + checked: boolean; + indeterminate: boolean; isSearchTerm: boolean; } diff --git a/stories/form/form-nested-multi-select/form-nested-multi-select.stories.mdx b/stories/form/form-nested-multi-select/form-nested-multi-select.stories.mdx new file mode 100644 index 000000000..ad26c9d54 --- /dev/null +++ b/stories/form/form-nested-multi-select/form-nested-multi-select.stories.mdx @@ -0,0 +1,129 @@ +import { Canvas, Meta, Story } from "@storybook/addon-docs"; +import { useState } from "react"; +import { InputNestedMultiSelect } from "src/input-nested-multi-select"; +import { Form } from "src/form"; +import { Text } from "src/text"; +import { + Heading3, + Heading4, + Secondary, + StoryContainer, + Title, +} from "../../storybook-common"; +import { Container } from "../shared-doc-elements"; +import { PropsTable } from "./props-table"; +import { options } from "../form-nested-select/nested-data-list.ts"; + + + +Form.NestedMultiSelect + +Overview + +A field that provides a nested set of options for a user to select. + +```tsx +import { Form } from "@lifesg/react-design-system/form"; +``` + + + + {() => { + const [selectedKeyPaths, setSelectedKeyPaths] = useState([ + ["999", "820", "10002"], + ]); + return ( + + + + { + setSelectedKeyPaths(keyPaths); + }} + /> + + + + + + ); + }} + + + +Specifying mode + + + + + + + + + + + + +With search capabilities + + + + + + + + + + + +Using the field as a standalone + +In the case that you require the nested select field as a standalone, you can do this. + +```tsx +import { InputNestedMultiSelect } from "@lifesg/react-design-system/input-nested-multi-select"; +``` + + + + + + + + + + + +Component API + + diff --git a/stories/form/form-nested-multi-select/props-table.tsx b/stories/form/form-nested-multi-select/props-table.tsx new file mode 100644 index 000000000..4f508b873 --- /dev/null +++ b/stories/form/form-nested-multi-select/props-table.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { ApiTable } from "../../storybook-common/api-table"; +import { ApiTableSectionProps } from "../../storybook-common/api-table/types"; +import { SHARED_FORM_PROPS_DATA } from "../shared-props-data"; + +const DATA: ApiTableSectionProps[] = [ + { + name: "InputNestedMultiSelect specific props", + attributes: [ + { + name: "options", + mandatory: true, + description: "A list of options that a user can choose from", + propTypes: ["L1OptionProps[]"], + }, + { + name: "selectedKeyPaths", + description: "The key paths of the selected options", + propTypes: ["string[][]"], + }, + { + name: "placeholder", + description: "The placeholder text of the component", + propTypes: ["string"], + defaultValue: "Select", + }, + { + name: "disabled", + description: + "Indicates if the component is disabled and selection is not allowed", + propTypes: ["boolean"], + }, + { + name: "error", + description: ( + <> + Indicates if an error display is to be set  (Not + needed if you indicated errorMessage) + + ), + propTypes: ["boolean"], + }, + { + name: "className", + description: "Class selector for the component", + propTypes: ["string"], + }, + { + name: "data-testid", + description: "The test identifier of the component", + propTypes: ["string"], + }, + { + name: "readOnly", + description: + "Indicates if the component has a read only state and selection is not allowed", + propTypes: ["boolean"], + }, + { + name: "mode", + description: + "Determines if items are expanded or collapsed when the dropdown is opened", + propTypes: [`"default"`, `"expand"`, `"collapse"`], + defaultValue: `"default"`, + }, + { + name: "valueToStringFunction", + description: + "The function to convert a value to a string. Only single callback used for both selects. Assumption: values are homogenous for both selects.", + propTypes: ["(value: V1 | V2 | V3) => string"], + }, + { + name: "optionsLoadState", + description: + "The visual state to represent the progress when options are loaded asynchronously", + propTypes: [`"success"`, `"loading"`, `"failed"`], + defaultValue: `"success"`, + }, + { + name: "optionTruncationType", + description: + "Specifies the trunction type of the options display. Truncated text will be replaced with ellipsis", + propTypes: [`"end"`, `"middle"`], + defaultValue: `"end"`, + }, + { + name: "hideNoResultsDisplay", + description: + "If specified, the default no results display will not be rendered", + propTypes: ["boolean"], + defaultValue: "false", + }, + { + name: "listStyleWidth", + description: + "Style option: The width of the option display. (E.g. '100%' or '12rem')", + propTypes: ["string"], + }, + { + name: "enableSearch", + description: + "When specified, it will allow a text base search for the items in the list", + propTypes: ["boolean"], + defaultValue: "false", + }, + { + name: "searchPlaceholder", + description: "The placeholder for the search field", + propTypes: ["string"], + }, + { + name: "onSelectOptions", + description: "Called when an option is selected", + propTypes: [ + "(keyPaths: string[][], values: Array) => void", + ], + }, + { + name: "onShowOptions", + description: "Called when the options dropdown is expanded", + propTypes: ["() => void"], + }, + { + name: "onHideOptions", + description: "Called when options dropdown is minimised", + propTypes: ["() => void"], + }, + { + name: "onRetry", + description: + "Called when retry button is clicked to retry loading the options", + propTypes: ["() => void"], + }, + { + name: "onSearch", + description: "Called when a search is being executed", + propTypes: ["() => void"], + }, + ], + }, + ...SHARED_FORM_PROPS_DATA, +]; + +export const PropsTable = () => ; diff --git a/stories/form/form-nested-select/nested-data-list.ts b/stories/form/form-nested-select/nested-data-list.ts index 6b6d6e553..822ba9d29 100644 --- a/stories/form/form-nested-select/nested-data-list.ts +++ b/stories/form/form-nested-select/nested-data-list.ts @@ -90,6 +90,7 @@ export const options = [ { label: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sollicitudin dolor ut est rutrum vulputate. Maecenas lacinia viverra metus", value: { + id: 510, name: "Long sub category a", }, key: "510", @@ -97,6 +98,7 @@ export const options = [ { label: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tempor varius elit nec iaculis. Sed sed mauris iaculis, pretium dui vel, lacinia est.", value: { + id: 23, name: "Long item a", }, key: "23",