Skip to content

Commit

Permalink
feat(multiselect): new All option for multiselect (#16236)
Browse files Browse the repository at this point in the history
* feat: Add selectAll option in Multiselect

* disabled items and length corrected

* changes for select all

* changes

* fix: select all option when all items are selected

* feat(multiselect): using filteredItems from prev changes

* fix: added intermediate checkbox icon

* fix: fixed All option should not be in active state as per design

* feat(multiselect): tests and avt fixed

* fix: added prop for selectall option and changed css for all option

* feat(multiselect): modified avt for default

* feat: test fixed

* feat: added test case for select all

* fix: format issue

* feat(multiselect): added missed proptypes and typescript types

* feat: updated tests

* feat: fixing the build

* feat: updates for test success

* feat: changes according to review comments(except tests)

* feat: fixed hasSelectAll and added warning

* feat: made hasselectall prop experimental

* fix: removed duplicate OnChangeData

* feat: ran yarn test

* fix: should deselect all on click to indeterminate icon

* chore: added a new story for selectAll and a prop for selectAll label

* fix: active state of selectAll option

* fix: changed items to match with documentation

* fix: update snapshot

* fix: alignment issue

* fix: firefox issue

* feat: format run

* feat: fixed storybook

* feat: formatting ci fail fix

* feat: select all is exempt from sorting

* chore: remove isSelectAll from ListBoxMenuItem

* chore: update prop names

* chore: rename variables

* feat: changed from prop to object property

* fix: focus issue

* fix: remove console

* feat: fixed tests

* feat: fixed typing test

* fix: focus issue

* Update packages/styles/scss/components/multiselect/_multiselect.scss

Co-authored-by: kennylam <[email protected]>

* fix: sort issue resolved

* fix: test case

* fix: alignment issue

* test(multiselect): refactor assertions off data attributes

---------

Co-authored-by: Preeti Bansal <[email protected]>
Co-authored-by: preetibansalui <[email protected]>
Co-authored-by: Taylor Jones <[email protected]>
Co-authored-by: Taylor Jones <[email protected]>
Co-authored-by: kennylam <[email protected]>
  • Loading branch information
6 people authored Aug 30, 2024
1 parent 009351a commit 20c5abc
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 99 deletions.
18 changes: 17 additions & 1 deletion packages/react/src/components/ListBox/ListBoxMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
*/

import cx from 'classnames';
import React, { ForwardedRef, useEffect, useRef, useState } from 'react';
import React, {
ForwardedRef,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import PropTypes from 'prop-types';
import { usePrefix } from '../../internal/usePrefix';
import { ForwardRefReturn, ReactAttr } from '../../types/common';
Expand All @@ -24,6 +30,11 @@ function useIsTruncated(ref) {
}

export interface ListBoxMenuItemProps extends ReactAttr<HTMLLIElement> {
/**
* Specify any children nodes that should be rendered inside of the ListBox
* Menu Item
*/
children?: ReactNode;
/**
* Specify whether the current menu item is "active".
*/
Expand All @@ -38,6 +49,11 @@ export interface ListBoxMenuItemProps extends ReactAttr<HTMLLIElement> {
* Specify whether the item should be disabled
*/
disabled?: boolean;

/**
* Provide an optional tooltip for the ListBoxMenuItem
*/
title?: string;
}

export type ListBoxMenuItemForwardedRef =
Expand Down
56 changes: 56 additions & 0 deletions packages/react/src/components/MultiSelect/MultiSelect.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ Playground.args = {
clearSelectionDescription: 'Total items selected: ',
useTitleInItem: false,
clearSelectionText: 'To clear selection, press Delete or Backspace,',
selectAll: false,
selectAllItemText: 'All options',
};

Playground.argTypes = {
Expand Down Expand Up @@ -383,6 +385,60 @@ export const _Controlled = () => {
);
};

const itemsWithSelectAll = [
{
id: 'downshift-1-item-0',
text: 'Editor',
},
{
id: 'downshift-1-item-1',
text: 'Owner',
},
{
id: 'downshift-1-item-2',
text: 'Uploader',
},
{
id: 'downshift-1-item-3',
text: 'Reader - a disabled item',
disabled: true,
},
{
id: 'select-all',
text: 'All roles',
isSelectAll: true,
},
];

export const SelectAll = () => {
const [label, setLabel] = useState('Choose options');

const onChange = (value) => {
if (value.selectedItems.length == 1) {
setLabel('Option selected');
} else if (value.selectedItems.length > 1) {
setLabel('Options selected');
} else {
setLabel('Choose options');
}
};

return (
<div style={{ width: 300 }}>
<MultiSelect
label={label}
id="carbon-multiselect-example"
titleText="Multiselect title"
helperText="This is helper text"
items={itemsWithSelectAll}
itemToString={(item) => (item ? item.text : '')}
selectionFeedback="top-after-reopen"
onChange={onChange}
/>
</div>
);
};

const aiLabel = (
<AILabel className="slug-container">
<AILabelContent>
Expand Down
116 changes: 69 additions & 47 deletions packages/react/src/components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import PropTypes from 'prop-types';
import React, {
ForwardedRef,
useContext,
useRef,
useState,
useMemo,
ReactNode,
Expand All @@ -43,6 +42,8 @@ import { keys, match } from '../../internal/keyboard';
import { usePrefix } from '../../internal/usePrefix';
import { FormContext } from '../FluidForm';
import { ListBoxProps } from '../ListBox/ListBox';
import Checkbox from '../Checkbox';
import type { InternationalProps } from '../../types/common';
import type { TranslateWithId } from '../../types/common';
import { noopFn } from '../../internal/noopFn';
import {
Expand Down Expand Up @@ -331,6 +332,26 @@ const MultiSelect = React.forwardRef(
}: MultiSelectProps<ItemType>,
ref: ForwardedRef<HTMLButtonElement>
) => {
const filteredItems = useMemo(() => {
return items.filter((item) => {
if (typeof item === 'object' && item !== null) {
for (const key in item) {
if (Object.hasOwn(item, key) && item[key] === undefined) {
return false; // Return false if any property has an undefined value
}
}
}
return true; // Return true if item is not an object with undefined values
});
}, [items]);

let selectAll = filteredItems.some((item) => (item as any).isSelectAll);
if ((selected ?? []).length > 0 && selectAll) {
console.warn(
'Warning: `selectAll` should not be used when `selectedItems` is provided. Please pass either `selectAll` or `selectedItems`, not both.'
);
selectAll = false;
}
const prefix = usePrefix();
const { isFluid } = useContext(FormContext);
const multiSelectInstanceId = useId();
Expand All @@ -340,16 +361,6 @@ const MultiSelect = React.forwardRef(
const [prevOpenProp, setPrevOpenProp] = useState(open);
const [topItems, setTopItems] = useState([]);
const [itemsCleared, setItemsCleared] = useState(false);
const {
selectedItems: controlledSelectedItems,
onItemChange,
clearSelection,
} = useSelection({
disabled,
initialSelectedItems,
onChange,
selectedItems: selected,
});

const { refs, floatingStyles, middlewareData } = useFloating(
autoAlign
Expand Down Expand Up @@ -395,19 +406,25 @@ const MultiSelect = React.forwardRef(
}
}, [autoAlign, floatingStyles, refs.floating, middlewareData, open]);

// Filter out items with an object having undefined values
const filteredItems = useMemo(() => {
return items.filter((item) => {
if (typeof item === 'object' && item !== null) {
for (const key in item) {
if (Object.hasOwn(item, key) && item[key] === undefined) {
return false; // Return false if any property has an undefined value
}
}
}
return true; // Return true if item is not an object with undefined values
});
}, [items]);
const {
selectedItems: controlledSelectedItems,
onItemChange,
clearSelection,
} = useSelection({
disabled,
initialSelectedItems,
onChange,
selectedItems: selected,
selectAll,
filteredItems,
});

const sortOptions = {
selectedItems: controlledSelectedItems,
itemToString,
compareItems,
locale,
};

const selectProps: UseSelectProps<ItemType> = {
stateReducer,
Expand All @@ -424,7 +441,7 @@ const MultiSelect = React.forwardRef(
);
},
selectedItem: controlledSelectedItems,
items: filteredItems,
items: filteredItems as ItemType[],
isItemDisabled(item, _index) {
return (item as any).disabled;
},
Expand Down Expand Up @@ -534,19 +551,13 @@ const MultiSelect = React.forwardRef(
selectedItems && selectedItems.length > 0,
[`${prefix}--list-box--up`]: direction === 'top',
[`${prefix}--multi-select--readonly`]: readOnly,
[`${prefix}--multi-select--selectall`]: selectAll,
});

// needs to be capitalized for react to render it correctly
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const ItemToElement = itemToElement!;

const sortOptions = {
selectedItems: controlledSelectedItems,
itemToString,
compareItems,
locale,
};

if (selectionFeedback === 'fixed') {
sortOptions.selectedItems = [];
} else if (selectionFeedback === 'top-after-reopen') {
Expand Down Expand Up @@ -598,7 +609,7 @@ const MultiSelect = React.forwardRef(
} else {
return {
...changes,
highlightedIndex: props.items.indexOf(highlightedIndex),
highlightedIndex: filteredItems.indexOf(highlightedIndex),
};
}
case ToggleButtonKeyDownArrowDown:
Expand Down Expand Up @@ -669,13 +680,17 @@ const MultiSelect = React.forwardRef(
selectedItems.length > 0 &&
selectedItems.map((item) => (item as selectedItemType)?.text);

const selectedItemsLength = selectAll
? selectedItems.filter((item: any) => !item.isSelectAll).length
: selectedItems.length;

// Memoize the value of getMenuProps to avoid an infinite loop
const menuProps = useMemo(
() =>
getMenuProps({
ref: autoAlign ? refs.setFloating : null,
}),
[autoAlign]
[autoAlign, getMenuProps, refs.setFloating]
);

return (
Expand Down Expand Up @@ -720,7 +735,7 @@ const MultiSelect = React.forwardRef(
clearSelection={
!disabled && !readOnly ? clearSelection : noopFn
}
selectionCount={selectedItems.length}
selectionCount={selectedItemsLength}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
translateWithId={translateWithId!}
disabled={disabled}
Expand Down Expand Up @@ -751,7 +766,6 @@ const MultiSelect = React.forwardRef(
</div>
<ListBox.Menu {...menuProps}>
{isOpen &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sortItems!(
filteredItems,
sortOptions as SortItemsOptions<ItemType>
Expand All @@ -760,6 +774,11 @@ const MultiSelect = React.forwardRef(
selectedItems.filter((selected) => isEqual(selected, item))
.length > 0;

const isIndeterminate =
selectedItems.length !== 0 &&
item['isSelectAll'] &&
!isChecked;

const itemProps = getItemProps({
item,
// we don't want Downshift to set aria-selected for us
Expand All @@ -771,24 +790,27 @@ const MultiSelect = React.forwardRef(
return (
<ListBox.MenuItem
key={itemProps.id}
isActive={isChecked}
isActive={isChecked && !item['isSelectAll']}
aria-label={itemText}
isHighlighted={highlightedIndex === index}
title={itemText}
disabled={itemProps['aria-disabled']}
{...itemProps}>
<div className={`${prefix}--checkbox-wrapper`}>
<span
<Checkbox
id={`${itemProps.id}__checkbox`}
labelText={
itemToElement ? (
<ItemToElement key={itemProps.id} {...item} />
) : (
itemText
)
}
checked={isChecked}
title={useTitleInItem ? itemText : undefined}
className={`${prefix}--checkbox-label`}
data-contained-checkbox-state={isChecked}
id={`${itemProps.id}__checkbox`}>
{itemToElement ? (
<ItemToElement key={itemProps.id} {...item} />
) : (
itemText
)}
</span>
indeterminate={isIndeterminate}
disabled={disabled}
/>
</div>
</ListBox.MenuItem>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import React from 'react';
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FilterableMultiSelect from '../FilterableMultiSelect';
import {
Expand All @@ -15,12 +15,11 @@ import {
findMenuIconNode,
generateItems,
generateGenericItem,
waitForPosition,
} from '../../ListBox/test-helpers';
import { AILabel } from '../../AILabel';

const prefix = 'cds';

const waitForPosition = () => act(async () => {});
const openMenu = async () => {
await userEvent.click(screen.getByRole('combobox'));
};
Expand Down
Loading

0 comments on commit 20c5abc

Please sign in to comment.