Skip to content

Commit 715a06c

Browse files
authored
feat(filterable-multiselect): New SelectAll Feature (#19884)
* feat(filterable-multi-select): new selectall feature and story * fix(testing): bug fix to pass ci * fix(select-all): hide select-all option when filtered items are disabled * feat(select-all): add test cases * fix(select-all): cursor padding * chore: cleanup * chore: cleanup fix
1 parent ef9f816 commit 715a06c

File tree

4 files changed

+268
-29
lines changed

4 files changed

+268
-29
lines changed

packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import PropTypes from 'prop-types';
2121
import React, {
2222
cloneElement,
2323
forwardRef,
24+
useCallback,
2425
useContext,
2526
useEffect,
2627
useLayoutEffect,
@@ -46,6 +47,7 @@ import ListBox, {
4647
type ListBoxSize,
4748
type ListBoxType,
4849
} from '../ListBox';
50+
import Checkbox from '../Checkbox';
4951
import { ListBoxTrigger, ListBoxSelection } from '../ListBox/next';
5052
import { match, keys } from '../../internal/keyboard';
5153
import { defaultItemToString } from './tools/itemToString';
@@ -389,17 +391,73 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
389391
);
390392
const [inputFocused, setInputFocused] = useState<boolean>(false);
391393

394+
const filteredItems = useMemo(
395+
() => filterItems(items, { itemToString, inputValue }),
396+
[items, inputValue, itemToString, filterItems]
397+
);
398+
399+
const nonSelectAllItems = useMemo(
400+
() => filteredItems.filter((item) => !(item as any).isSelectAll),
401+
[filteredItems]
402+
);
403+
let selectAll = filteredItems.some((item) => (item as any).isSelectAll);
404+
if ((selected ?? []).length > 0 && selectAll) {
405+
console.warn(
406+
'Warning: `selectAll` should not be used when `selectedItems` is provided. Please pass either `selectAll` or `selectedItems`, not both.'
407+
);
408+
selectAll = false;
409+
}
410+
392411
const {
393412
selectedItems: controlledSelectedItems,
394413
onItemChange,
395414
clearSelection,
415+
toggleAll,
396416
} = useSelection({
397417
disabled,
398418
initialSelectedItems,
399419
onChange,
400420
selectedItems: selected,
421+
selectAll,
422+
filteredItems,
401423
});
402424

425+
const selectAllStatus = useMemo(() => {
426+
const selectable = nonSelectAllItems.filter(
427+
(item) => !(item as any).disabled
428+
);
429+
430+
const nonSelectedCount = selectable.filter(
431+
(item) => !controlledSelectedItems.some((sel) => isEqual(sel, item))
432+
).length;
433+
434+
const totalCount = selectable.length;
435+
return {
436+
checked: totalCount > 0 && nonSelectedCount === 0,
437+
indeterminate: nonSelectedCount > 0 && nonSelectedCount < totalCount,
438+
};
439+
}, [controlledSelectedItems, nonSelectAllItems]);
440+
441+
const handleSelectAllClick = useCallback(() => {
442+
const selectable = nonSelectAllItems.filter((i) => !(i as any).disabled);
443+
const { checked, indeterminate } = selectAllStatus;
444+
445+
// clear all options if select-all state is checked or indeterminate
446+
if (checked || indeterminate) {
447+
const remainingSelectedItems = controlledSelectedItems.filter(
448+
(sel) => !filteredItems.some((e) => isEqual(e, sel))
449+
);
450+
toggleAll(remainingSelectedItems);
451+
452+
// select all options if select-all state is empty
453+
} else {
454+
const toSelect = selectable.filter(
455+
(e) => !controlledSelectedItems.some((sel) => isEqual(sel, e))
456+
);
457+
toggleAll([...controlledSelectedItems, ...toSelect]);
458+
}
459+
}, [nonSelectAllItems, selectAllStatus, controlledSelectedItems, toggleAll]);
460+
403461
const { refs, floatingStyles, middlewareData } = useFloating(
404462
autoAlign
405463
? {
@@ -455,7 +513,14 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
455513
// memoize sorted items to reduce unnecessary expensive sort on rerender
456514
const sortedItems = useMemo(() => {
457515
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
458-
return sortItems!(filterItems(items, { itemToString, inputValue }), {
516+
const selectAllItem = items.find((item) => (item as any).isSelectAll);
517+
518+
const selectableRealItems = nonSelectAllItems.filter(
519+
(item) => !(item as any).disabled
520+
);
521+
522+
// Sort only non-select-all items, select-all item must stay at the top
523+
const sortedReal = sortItems!(nonSelectAllItems, {
459524
selectedItems: {
460525
top: controlledSelectedItems,
461526
fixed: [],
@@ -465,6 +530,12 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
465530
compareItems,
466531
locale,
467532
});
533+
534+
// Only show select-all-item if there exist non-disabled filtered items to select
535+
if (selectAllItem && selectableRealItems.length > 0) {
536+
return [selectAllItem, ...sortedReal];
537+
}
538+
return sortedReal;
468539
}, [
469540
items,
470541
inputValue,
@@ -474,6 +545,8 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
474545
itemToString,
475546
compareItems,
476547
locale,
548+
sortItems,
549+
nonSelectAllItems,
477550
]);
478551

479552
const inline = type === 'inline';
@@ -614,14 +687,23 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
614687
}
615688
switch (type) {
616689
case InputKeyDownEnter:
690+
if (sortedItems.length === 0) {
691+
return changes;
692+
}
617693
if (changes.selectedItem && changes.selectedItem.disabled !== true) {
618-
onItemChange(changes.selectedItem);
694+
if (changes.selectedItem.isSelectAll) {
695+
handleSelectAllClick();
696+
} else {
697+
onItemChange(changes.selectedItem);
698+
}
619699
}
620700
setHighlightedIndex(changes.selectedItem);
621701

622702
return { ...changes, highlightedIndex: state.highlightedIndex };
623703
case ItemClick:
624-
if (changes.selectedItem) {
704+
if (changes.selectedItem.isSelectAll) {
705+
handleSelectAllClick();
706+
} else {
625707
onItemChange(changes.selectedItem);
626708
}
627709
setHighlightedIndex(changes.selectedItem);
@@ -752,6 +834,11 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
752834
? cloneElement(candidate, { size: 'mini' })
753835
: null;
754836

837+
// exclude the select-all item from the count
838+
const selectedItemsLength = controlledSelectedItems.filter(
839+
(item: any) => !(item as any).isSelectAll
840+
).length;
841+
755842
const className = cx(
756843
`${prefix}--multi-select`,
757844
`${prefix}--combo-box`,
@@ -765,6 +852,7 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
765852
controlledSelectedItems?.length > 0,
766853
[`${prefix}--multi-select--filterable--input-focused`]: inputFocused,
767854
[`${prefix}--multi-select--readonly`]: readOnly,
855+
[`${prefix}--multi-select--selectall`]: selectAll,
768856
}
769857
);
770858

@@ -938,7 +1026,7 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
9381026
textInput.current.focus();
9391027
}
9401028
}}
941-
selectionCount={controlledSelectedItems.length}
1029+
selectionCount={selectedItemsLength}
9421030
translateWithId={translateWithId}
9431031
disabled={disabled}
9441032
/>
@@ -993,10 +1081,17 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
9931081
<ListBox.Menu {...menuProps}>
9941082
{isOpen
9951083
? sortedItems.map((item, index) => {
996-
const isChecked =
997-
controlledSelectedItems.filter((selected) =>
998-
isEqual(selected, item)
999-
).length > 0;
1084+
let isChecked: boolean;
1085+
let isIndeterminate = false;
1086+
if ((item as any).isSelectAll) {
1087+
isChecked = selectAllStatus.checked;
1088+
isIndeterminate = selectAllStatus.indeterminate;
1089+
} else {
1090+
isChecked =
1091+
controlledSelectedItems.filter((selected) =>
1092+
isEqual(selected, item)
1093+
).length > 0;
1094+
}
10001095
const itemProps = getItemProps({
10011096
item,
10021097
['aria-selected']: isChecked,
@@ -1017,23 +1112,27 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
10171112
<ListBox.MenuItem
10181113
key={itemProps.id}
10191114
aria-label={itemText}
1020-
isActive={isChecked}
1115+
isActive={isChecked && !item['isSelectAll']}
10211116
isHighlighted={highlightedIndex === index}
10221117
title={itemText}
10231118
disabled={disabled}
10241119
{...modifiedItemProps}>
10251120
<div className={`${prefix}--checkbox-wrapper`}>
1026-
<span
1121+
<Checkbox
1122+
id={`${itemProps.id}-item`}
1123+
labelText={
1124+
ItemToElement ? (
1125+
<ItemToElement key={itemProps.id} {...item} />
1126+
) : (
1127+
itemText
1128+
)
1129+
}
1130+
checked={isChecked}
10271131
title={useTitleInItem ? itemText : undefined}
1028-
className={`${prefix}--checkbox-label`}
1029-
data-contained-checkbox-state={isChecked}
1030-
id={`${itemProps.id}-item`}>
1031-
{ItemToElement ? (
1032-
<ItemToElement key={itemProps.id} {...item} />
1033-
) : (
1034-
itemText
1035-
)}
1036-
</span>
1132+
indeterminate={isIndeterminate}
1133+
disabled={disabled}
1134+
tabIndex={-1}
1135+
/>
10371136
</div>
10381137
</ListBox.MenuItem>
10391138
);

packages/react/src/components/MultiSelect/MultiSelect.stories.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,25 @@ export const Filterable = (args) => {
368368
);
369369
};
370370

371+
export const FilterableWithSelectAll = (args) => {
372+
return (
373+
<div
374+
style={{
375+
width: 300,
376+
}}>
377+
<FilterableMultiSelect
378+
id="carbon-multiselect-example-3"
379+
titleText="FilterableMultiSelect title"
380+
helperText="This is helper text"
381+
items={itemsWithSelectAll}
382+
itemToString={(item) => (item ? item.text : '')}
383+
selectionFeedback="top-after-reopen"
384+
{...args}
385+
/>
386+
</div>
387+
);
388+
};
389+
371390
Filterable.argTypes = {
372391
onChange: {
373392
action: 'onChange',

0 commit comments

Comments
 (0)