Skip to content

Commit

Permalink
EPMRPP-97531 || Add multiselect option to dropdown (#62)
Browse files Browse the repository at this point in the history
  • Loading branch information
maria-hambardzumian authored Jan 21, 2025
1 parent 4ee6bbc commit 28c1da7
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 37 deletions.
26 changes: 16 additions & 10 deletions src/components/dropdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -16,31 +17,36 @@ 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

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

Expand Down
6 changes: 6 additions & 0 deletions src/components/dropdown/dropdown.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,9 @@ $Z-INDEX-POPUP: 10;
}
}
}

.divider {
height: 1px;
margin: 8px 12px;
background-color: var(--rp-ui-base-e-100);
}
77 changes: 75 additions & 2 deletions src/components/dropdown/dropdown.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Dropdown> = {
title: 'Dropdown',
Expand Down Expand Up @@ -28,9 +30,80 @@ export default meta;

type Story = StoryObj<typeof Dropdown>;

interface FooterApplyProps {
selected: number;
total: number;
onApply: () => void;
}

const FooterApply: FC<FooterApplyProps> = ({ selected, total, onApply }) => {
return (
<div className={'apply-container'}>
<p className={'info-text'}>{`${selected} of ${total} selected`}</p>
<Button onClick={onApply} variant={'text'}>
Apply
</Button>
</div>
);
};

export const Default: Story = {
render: (args: DropdownProps) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [selectedValues, setSelectedValues] = useState<Set<number | string | boolean>>(
new Set([]),
);

return (
<div>
<Dropdown
{...args}
onChange={(value) => {
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={
<FooterApply
selected={selectedValues.size}
total={args.options.length}
onApply={() => {}}
/>
}
/>
</div>
);
},
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' },
},
};

Expand Down
76 changes: 60 additions & 16 deletions src/components/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<DropdownProps> = ({
value = '',
multiSelect = false,
value = multiSelect ? [] : '',
options = [],
disabled = false,
error,
Expand All @@ -57,11 +62,18 @@ export const Dropdown: FC<DropdownProps> = ({
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<string | null>(null);

const multiSelectedItems: DropdownOptionType[] | null =
multiSelect && Array.isArray(value)
? options.filter((option) => value.includes(option.value))
: null;
const { refs, floatingStyles } = useFloating({
middleware: [
offset(5),
Expand All @@ -84,7 +96,7 @@ export const Dropdown: FC<DropdownProps> = ({
return;
}
onChange(option.value);
setOpened((prevState) => !prevState);
setOpened((prevState) => multiSelect || !prevState);
};

const getSelectedOption = (): DropdownOptionType =>
Expand Down Expand Up @@ -137,14 +149,21 @@ export const Dropdown: FC<DropdownProps> = ({
};

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<string[]>((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<HTMLButtonElement> = (event) => {
Expand All @@ -163,8 +182,10 @@ export const Dropdown: FC<DropdownProps> = ({
if (keyCode === KeyCodes.ENTER_KEY_CODE) {
const option = options[highlightedIndex];
handleChange(option);
setOpened(false);
onBlur?.();
if (!multiSelect) {
setOpened(false);
onBlur?.();
}
return;
}

Expand All @@ -177,21 +198,44 @@ export const Dropdown: FC<DropdownProps> = ({

const renderOptions = () => (
<div className={cx('options-container')}>
{multiSelect && isOptionAllVisible && Array.isArray(value) && (
<>
<DropdownOption
option={optionAll}
selected={value.length === options.length}
onChange={onSelectAll}
multiSelect={multiSelect}
isPartiallyChecked={!!value.length}
/>
<div className={cx('divider')} />{' '}
</>
)}
{options.map((option, index) => (
<DropdownOption
key={option.value}
{...getItemProps({
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}
onChange={option.disabled ? null : () => handleChange(option)}
onMouseEnter={() => setHighlightedIndex(index)}
/>
))}
{footer && (
<>
<div className={cx('divider')} />
{footer}
</>
)}
</div>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,7 +48,6 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 9px 12px 7px;
}

.sub-option {
Expand Down
12 changes: 8 additions & 4 deletions src/components/dropdown/dropdownOption/dropdownOption.tsx
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -14,10 +15,12 @@ export const DropdownOption: FC<DropdownOptionProps> = forwardRef(
render,
highlightHovered,
onMouseEnter,
multiSelect,
isPartiallyChecked = false,
} = props;
const onChangeHandler = () => {
if (onChange) {
onChange(value);
const onChangeHandler: MouseEventHandler<HTMLDivElement | HTMLInputElement> = (e) => {
if (e.target instanceof HTMLDivElement || e.target instanceof HTMLInputElement) {
onChange?.(value);
}
};

Expand All @@ -34,6 +37,7 @@ export const DropdownOption: FC<DropdownOptionProps> = forwardRef(
ref={ref}
onMouseEnter={onMouseEnter}
>
{multiSelect && <Checkbox value={!!selected} partiallyChecked={isPartiallyChecked} />}
<div className={cx('single-option', { 'sub-option': !!groupRef })}>
{render ? render(props) : label}
</div>
Expand Down
18 changes: 18 additions & 0 deletions src/components/dropdown/stories.scss
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/components/dropdown/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ export interface DropdownOptionProps {
highlightHovered?: boolean;
render?: RenderDropdownOption;
onMouseEnter?: MouseEventHandler<HTMLDivElement>;
multiSelect?: boolean;
isPartiallyChecked?: boolean;
option: DropdownOptionType;
}
Loading

0 comments on commit 28c1da7

Please sign in to comment.