From 28c1da7e544eea017d5ab606b1eaa0928d8486d8 Mon Sep 17 00:00:00 2001 From: maria-hambardzumian <164881199+maria-hambardzumian@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:29:58 +0400 Subject: [PATCH] EPMRPP-97531 || Add multiselect option to dropdown (#62) --- src/components/dropdown/README.md | 26 ++++--- src/components/dropdown/dropdown.module.scss | 6 ++ src/components/dropdown/dropdown.stories.tsx | 77 ++++++++++++++++++- src/components/dropdown/dropdown.tsx | 76 ++++++++++++++---- .../dropdownOption/dropdownOption.module.scss | 4 +- .../dropdownOption/dropdownOption.tsx | 12 ++- src/components/dropdown/stories.scss | 18 +++++ src/components/dropdown/types.ts | 2 + src/components/dropdown/utils.ts | 7 +- src/components/table/table.stories.tsx | 1 - 10 files changed, 192 insertions(+), 37 deletions(-) create mode 100644 src/components/dropdown/stories.scss diff --git a/src/components/dropdown/README.md b/src/components/dropdown/README.md index b121c70..d3b7c55 100644 --- a/src/components/dropdown/README.md +++ b/src/components/dropdown/README.md @@ -4,7 +4,8 @@ Default width - 240px ### Props: -- **value**: _string_ or _number_ or _object_, optional, default = "" +- **value**: _string_ or _number_ or _object_ or _array_ (for multi-select), optional, default = "" + _**Important**_: For multi-select, the value should be an array of selected values. - **options**: _array_, optional, default = [] - **disabled**: _bool_, optional, default = false - **error**: _string_, optional, default = "" @@ -16,12 +17,20 @@ Default width - 240px - **placeholder**: _string_, optional, default = "" - **defaultWidth**: _bool_, optional, default = true - **isListWidthLimited**: _bool_, optional, default = false +- **transparentBackground**: _bool_, optional, default = false +- **className**: _string_, optional, default = "" +- **toggleButtonClassName**: _string_, optional, default = "" +- **multiSelect**: _bool_, optional, default = false +- **optionAll**: _object_, optional, default = null +- **isOptionAllVisible**: _bool_, optional, default = false +- **onSelectAll**: _function_, optional +- **footer**: _ReactNode_, optional, default = null ### Events: -- **onChange** -- **onFocus** -- **onBlur** +- **onChange**: _function(value: DropdownValue)_ - Triggered when the value changes. +- **onFocus**: _function()_, optional - Triggered when the dropdown gains focus. +- **onBlur**: _function()_, optional - Triggered when the dropdown loses focus. ### Variants @@ -29,18 +38,15 @@ The Dropdown comes with theme variants: _light_ (default), _dark_ and _ghost_ ### Icon -Only text variant can be used with icon. You can pass imported svg icon -via _icon_ prop to display it on the left side +Only text variant can be used with icon. You can pass an imported svg icon via the _icon_ prop to display it on the left side. ### Default width -By default, width is set to 240px. -To disable this behavior set the _defaultWidth_ prop to false +By default, width is set to 240px. To disable this behavior, set the _defaultWidth_ prop to false. ### Positioning -Automatic positioning is used according to the no space strategy. -Dropdown will be positioned to the upside of the screen if there is not enough space on the downside. +Automatic positioning is used according to the no-space strategy. The dropdown will be positioned to the upside of the screen if there is not enough space on the downside. ### List width limitation diff --git a/src/components/dropdown/dropdown.module.scss b/src/components/dropdown/dropdown.module.scss index 17a729f..0ec2ac0 100644 --- a/src/components/dropdown/dropdown.module.scss +++ b/src/components/dropdown/dropdown.module.scss @@ -163,3 +163,9 @@ $Z-INDEX-POPUP: 10; } } } + +.divider { + height: 1px; + margin: 8px 12px; + background-color: var(--rp-ui-base-e-100); +} \ No newline at end of file diff --git a/src/components/dropdown/dropdown.stories.tsx b/src/components/dropdown/dropdown.stories.tsx index 7a8af34..a1cb005 100644 --- a/src/components/dropdown/dropdown.stories.tsx +++ b/src/components/dropdown/dropdown.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react'; - -import { Dropdown } from './dropdown'; +import { FC, useState } from 'react'; +import { Dropdown, DropdownProps } from './dropdown'; +import { Button } from '@components/button'; +import './stories.scss'; const meta: Meta = { title: 'Dropdown', @@ -28,9 +30,80 @@ export default meta; type Story = StoryObj; +interface FooterApplyProps { + selected: number; + total: number; + onApply: () => void; +} + +const FooterApply: FC = ({ selected, total, onApply }) => { + return ( +
+

{`${selected} of ${total} selected`}

+ +
+ ); +}; + export const Default: Story = { + render: (args: DropdownProps) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [selectedValues, setSelectedValues] = useState>( + new Set([]), + ); + + return ( +
+ { + const newSelectedValues = new Set(selectedValues); + if (newSelectedValues.has(value)) { + newSelectedValues.delete(value); + } else { + newSelectedValues.add(value); + } + setSelectedValues(newSelectedValues); + }} + onSelectAll={() => { + if (selectedValues.size === args.options.length) { + setSelectedValues(new Set()); + } else { + const allValues = new Set(args.options.map((item) => item.value)); + setSelectedValues(allValues); + } + }} + value={[...selectedValues]} + footer={ + {}} + /> + } + /> +
+ ); + }, args: { + options: [ + { value: 1, label: 'One' }, + { value: 2, label: 'Two' }, + { value: 4, label: '4' }, + { value: 5, label: '5' }, + { value: 6, label: '6' }, + { value: 7, label: '7' }, + { value: 8, label: '8' }, + { value: 9, label: '9' }, + ], + className: 'dropdown-default', value: 1, + multiSelect: true, + placeholder: 'Select value', + isOptionAllVisible: true, + optionAll: { value: 'all', label: 'All' }, }, }; diff --git a/src/components/dropdown/dropdown.tsx b/src/components/dropdown/dropdown.tsx index 4449c16..6dc7852 100644 --- a/src/components/dropdown/dropdown.tsx +++ b/src/components/dropdown/dropdown.tsx @@ -14,10 +14,10 @@ import styles from './dropdown.module.scss'; const cx = classNames.bind(styles); -interface DropdownProps { +export interface DropdownProps { // TODO: make value and options optional options: DropdownOptionType[]; - value: DropdownValue; + value: DropdownValue | DropdownValue[]; disabled?: boolean; error?: string; mobileDisabled?: boolean; @@ -34,12 +34,17 @@ interface DropdownProps { onBlur?: () => void; renderOption?: RenderDropdownOption; isListWidthLimited?: boolean; + multiSelect?: boolean; + optionAll?: DropdownOptionType; + isOptionAllVisible?: boolean; + onSelectAll?: () => void; + footer?: ReactNode; } // DS link - https://www.figma.com/file/gjYQPbeyf4YsH3wZiVKoaj/%F0%9F%9B%A0-RP-DS-6?type=design&node-id=3424-12207&mode=design&t=dDq6moPaTzQLviS1-0 -// TODO: implement multiple select export const Dropdown: FC = ({ - value = '', + multiSelect = false, + value = multiSelect ? [] : '', options = [], disabled = false, error, @@ -57,11 +62,18 @@ export const Dropdown: FC = ({ className, toggleButtonClassName, isListWidthLimited = false, + optionAll = { value: 'all', label: 'All' }, + isOptionAllVisible = false, + onSelectAll, + footer, }): ReactElement => { const [opened, setOpened] = useState(false); const containerRef = useRef(null); const [eventName, setEventName] = useState(null); - + const multiSelectedItems: DropdownOptionType[] | null = + multiSelect && Array.isArray(value) + ? options.filter((option) => value.includes(option.value)) + : null; const { refs, floatingStyles } = useFloating({ middleware: [ offset(5), @@ -84,7 +96,7 @@ export const Dropdown: FC = ({ return; } onChange(option.value); - setOpened((prevState) => !prevState); + setOpened((prevState) => multiSelect || !prevState); }; const getSelectedOption = (): DropdownOptionType => @@ -137,14 +149,21 @@ export const Dropdown: FC = ({ }; const getDisplayedValue = () => { - if (!value && value !== false && value !== 0) return placeholder; - let displayedValue = value; - options.forEach((option) => { - if (option.value === value) { - displayedValue = option.label; + if ((!value && value !== false && value !== 0) || (Array.isArray(value) && !value.length)) + return placeholder; + + if (multiSelect && Array.isArray(value) && options.length === value.length) { + return optionAll.label; + } + + const displayedValue = options.reduce((labels, option) => { + if ((Array.isArray(value) && value.includes(option.value)) || option.value === value) { + labels.push(option.label); } - }); - return displayedValue; + return labels; + }, []); + + return displayedValue.join(', '); }; const handleToggleButtonKeyDown: KeyboardEventHandler = (event) => { @@ -163,8 +182,10 @@ export const Dropdown: FC = ({ if (keyCode === KeyCodes.ENTER_KEY_CODE) { const option = options[highlightedIndex]; handleChange(option); - setOpened(false); - onBlur?.(); + if (!multiSelect) { + setOpened(false); + onBlur?.(); + } return; } @@ -177,6 +198,18 @@ export const Dropdown: FC = ({ const renderOptions = () => (
+ {multiSelect && isOptionAllVisible && Array.isArray(value) && ( + <> + +
{' '} + + )} {options.map((option, index) => ( = ({ item: option, index, })} - selected={option.value === (selectedItem?.value ?? selectedItem)} + multiSelect={multiSelect} + selected={ + multiSelect + ? multiSelectedItems?.some((item) => item.value === option.value) + : option.value === (selectedItem?.value ?? selectedItem) + } option={{ title: option.label, ...option }} highlightHovered={highlightedIndex === index && eventName !== EventName.ON_CLICK} render={renderOption} @@ -192,6 +230,12 @@ export const Dropdown: FC = ({ onMouseEnter={() => setHighlightedIndex(index)} /> ))} + {footer && ( + <> +
+ {footer} + + )}
); diff --git a/src/components/dropdown/dropdownOption/dropdownOption.module.scss b/src/components/dropdown/dropdownOption/dropdownOption.module.scss index 209ac29..8909dbc 100644 --- a/src/components/dropdown/dropdownOption/dropdownOption.module.scss +++ b/src/components/dropdown/dropdownOption/dropdownOption.module.scss @@ -2,13 +2,14 @@ @import 'src/assets/styles/mixins/font-scale'; .dropdown-option { - display: inline-block; + display: flex; font-family: var(--rp-ui-base-font-family); font-weight: $fw-regular; @include font-scale(); box-sizing: border-box; cursor: pointer; color: var(--rp-ui-color-text-3); + padding: 9px 12px 7px; &.disabled { pointer-events: none; @@ -47,7 +48,6 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - padding: 9px 12px 7px; } .sub-option { diff --git a/src/components/dropdown/dropdownOption/dropdownOption.tsx b/src/components/dropdown/dropdownOption/dropdownOption.tsx index 192bf96..61e3eb3 100644 --- a/src/components/dropdown/dropdownOption/dropdownOption.tsx +++ b/src/components/dropdown/dropdownOption/dropdownOption.tsx @@ -1,7 +1,8 @@ -import { forwardRef, FC, ForwardedRef, ReactElement } from 'react'; +import { forwardRef, FC, ForwardedRef, ReactElement, MouseEventHandler } from 'react'; import classNames from 'classnames/bind'; import { DropdownOptionProps } from '../types'; import styles from './dropdownOption.module.scss'; +import { Checkbox } from '@components/checkbox'; const cx = classNames.bind(styles); @@ -14,10 +15,12 @@ export const DropdownOption: FC = forwardRef( render, highlightHovered, onMouseEnter, + multiSelect, + isPartiallyChecked = false, } = props; - const onChangeHandler = () => { - if (onChange) { - onChange(value); + const onChangeHandler: MouseEventHandler = (e) => { + if (e.target instanceof HTMLDivElement || e.target instanceof HTMLInputElement) { + onChange?.(value); } }; @@ -34,6 +37,7 @@ export const DropdownOption: FC = forwardRef( ref={ref} onMouseEnter={onMouseEnter} > + {multiSelect && }
{render ? render(props) : label}
diff --git a/src/components/dropdown/stories.scss b/src/components/dropdown/stories.scss new file mode 100644 index 0000000..045fc0a --- /dev/null +++ b/src/components/dropdown/stories.scss @@ -0,0 +1,18 @@ +.dropdown-default { + width: 300px; +} + +.apply-container { + display: flex; + justify-content: space-between; + padding: 0 16px; + height: 22px; + align-items: center; +} + +.info-text { + font-size: 13px; + color: var(--rp-ui-base-sm-info-line-100); + font-family: var(--rp-ui-base-font-family); + margin: 0; +} \ No newline at end of file diff --git a/src/components/dropdown/types.ts b/src/components/dropdown/types.ts index 65db8e7..6d3cb1d 100644 --- a/src/components/dropdown/types.ts +++ b/src/components/dropdown/types.ts @@ -21,5 +21,7 @@ export interface DropdownOptionProps { highlightHovered?: boolean; render?: RenderDropdownOption; onMouseEnter?: MouseEventHandler; + multiSelect?: boolean; + isPartiallyChecked?: boolean; option: DropdownOptionType; } diff --git a/src/components/dropdown/utils.ts b/src/components/dropdown/utils.ts index 382cd10..3945190 100644 --- a/src/components/dropdown/utils.ts +++ b/src/components/dropdown/utils.ts @@ -2,8 +2,11 @@ import { DropdownValue, DropdownOptionType } from './types'; export const calculateDefaultIndex = ( options: DropdownOptionType[], - selectedValue: DropdownValue, -): number => options.map(({ value }) => value).indexOf(selectedValue); + selectedValue: DropdownValue | DropdownValue[], +): number => { + const selectedValues = Array.isArray(selectedValue) ? selectedValue : [selectedValue]; + return options.findIndex(({ value }) => selectedValues.includes(value)); +}; const calculateCurrentItemIndex = (index: number, itemsCount: number): number => ((index % itemsCount) + itemsCount) % itemsCount; diff --git a/src/components/table/table.stories.tsx b/src/components/table/table.stories.tsx index 141121d..9eaf94f 100644 --- a/src/components/table/table.stories.tsx +++ b/src/components/table/table.stories.tsx @@ -131,7 +131,6 @@ export const Default: Story = { newCheckedRows.add(id); } setCheckedRows(newCheckedRows); - console.log('checkedRows:', checkedRows); }} onToggleAllRowsSelection={() => { if (checkedRows.size === data.length) {