diff --git a/.changeset/rude-cougars-battle.md b/.changeset/rude-cougars-battle.md new file mode 100644 index 00000000000..74f0963ef78 --- /dev/null +++ b/.changeset/rude-cougars-battle.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Add new SelectPanel `Select all` feature diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Default-forced-colors-dark-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Default-forced-colors-dark-modern-action-list--true-linux.png index 3d0c98a575b..e5c8ed038a0 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Default-forced-colors-dark-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Default-forced-colors-dark-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-colorblind-linux.png new file mode 100644 index 00000000000..a31eef5d65d Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-colorblind-modern-action-list--true-linux.png new file mode 100644 index 00000000000..d8f0ccaf3fb Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-colorblind-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-dimmed-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-dimmed-linux.png new file mode 100644 index 00000000000..9ff6713a4a4 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-dimmed-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-dimmed-modern-action-list--true-linux.png new file mode 100644 index 00000000000..48f85620033 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-dimmed-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-high-contrast-linux.png new file mode 100644 index 00000000000..eeaba7dd70a Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-high-contrast-modern-action-list--true-linux.png new file mode 100644 index 00000000000..8dee256a7fd Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-high-contrast-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-linux.png new file mode 100644 index 00000000000..106f3a480d8 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-modern-action-list--true-linux.png new file mode 100644 index 00000000000..d8f0ccaf3fb Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-tritanopia-linux.png new file mode 100644 index 00000000000..106f3a480d8 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-tritanopia-modern-action-list--true-linux.png new file mode 100644 index 00000000000..d8f0ccaf3fb Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-dark-tritanopia-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-colorblind-linux.png new file mode 100644 index 00000000000..98626a1187e Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-colorblind-modern-action-list--true-linux.png new file mode 100644 index 00000000000..2b743d67d39 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-colorblind-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-high-contrast-linux.png new file mode 100644 index 00000000000..dcc71b98336 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-high-contrast-modern-action-list--true-linux.png new file mode 100644 index 00000000000..9fd5b709689 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-high-contrast-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-linux.png new file mode 100644 index 00000000000..98626a1187e Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-modern-action-list--true-linux.png new file mode 100644 index 00000000000..2b743d67d39 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-tritanopia-linux.png new file mode 100644 index 00000000000..98626a1187e Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-tritanopia-modern-action-list--true-linux.png new file mode 100644 index 00000000000..2b743d67d39 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-With-Select-All-light-tritanopia-modern-action-list--true-linux.png differ diff --git a/e2e/components/SelectPanel.test.ts b/e2e/components/SelectPanel.test.ts index 9c357865ba1..6cf9d93b908 100644 --- a/e2e/components/SelectPanel.test.ts +++ b/e2e/components/SelectPanel.test.ts @@ -21,6 +21,7 @@ const scenarios = matrix({ id: 'components-selectpanel-features--with-placeholder-for-search-input', name: 'With Placeholder for Search Input', }, + {id: 'components-selectpanel-features--with-select-all', name: 'With Select All'}, {id: 'components-selectpanel-examples--above-tall-body', name: 'Above Tall Body'}, {id: 'components-selectpanel-examples--height-variations-and-scroll', name: 'Height Variations and Scroll'}, { diff --git a/packages/react/src/FilteredActionList/FilteredActionList.module.css b/packages/react/src/FilteredActionList/FilteredActionList.module.css index 0360050389c..6a1914c59a8 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.module.css +++ b/packages/react/src/FilteredActionList/FilteredActionList.module.css @@ -13,3 +13,23 @@ } } } + +.SelectAllContainer { + display: flex; + align-items: center; + padding-block: var(--base-size-4); + padding-inline: var(--base-size-16); + background: var(--bgColor-muted); + border-bottom: var(--borderWidth-thin) solid var(--borderColor-default); +} + +.SelectAllCheckbox { + /* -1px hack to offset 1px border-bottom causing uneven alignment */ + /* stylelint-disable-next-line primer/spacing */ + margin: var(--base-size-4) var(--base-size-8) calc(var(--base-size-4) - 1px) 0; +} + +.SelectAllLabel { + font-size: var(--text-body-size-medium); + color: var(--fgColor-muted); +} diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx index 7d0ef873a77..cec49c9ee8f 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx @@ -24,6 +24,8 @@ import { } from './FilteredActionListLoaders' import {announce} from '@primer/live-region-element' import {debounce} from '@github/mini-throttle' +import classes from './FilteredActionList.module.css' +import Checkbox from '../Checkbox' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -42,6 +44,7 @@ export interface FilteredActionListProps inputRef?: React.RefObject className?: string announcementsEnabled?: boolean + onSelectAllChange?: (checked: boolean) => void } const StyledHeader = styled.div` @@ -125,6 +128,7 @@ export function FilteredActionList({ sx, className, announcementsEnabled = false, + onSelectAllChange, ...listProps }: FilteredActionListProps): JSX.Element { const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '') @@ -137,6 +141,9 @@ export function FilteredActionList({ [onFilterChange, setInternalFilterValue], ) + const selectAllChecked = items.length > 0 && items.every(item => item.selected) + const selectAllIndeterminate = !selectAllChecked && items.some(item => item.selected) + const scrollContainerRef = useRef(null) const [listContainerElement, setListContainerElement] = useState(null) const inputRef = useProvidedRefOrCreate(providedInputRef) @@ -144,6 +151,7 @@ export function FilteredActionList({ const activeDescendantRef = useRef() const listId = useId() const inputDescriptionTextId = useId() + const selectAllLabelText = selectAllChecked ? 'Deselect all' : 'Select all' const onInputKeyPress: KeyboardEventHandler = useCallback( event => { if (event.key === 'Enter' && activeDescendantRef.current) { @@ -219,6 +227,15 @@ export function FilteredActionList({ useScrollFlash(scrollContainerRef) + const handleSelectAllChange = useCallback( + (e: React.ChangeEvent) => { + if (onSelectAllChange) { + onSelectAllChange(e.target.checked) + } + }, + [onSelectAllChange], + ) + return ( Items will be filtered as you type + {onSelectAllChange !== undefined && ( +
+ + +
+ )} {loading && scrollContainerRef.current && loadingType.appearsInBody ? ( diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx index 146318b15ca..1568cad1032 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx @@ -20,6 +20,7 @@ import type {SxProp} from '../sx' import type {FilteredActionListLoadingType} from './FilteredActionListLoaders' import {FilteredActionListLoadingTypes, FilteredActionListBodyLoader} from './FilteredActionListLoaders' import classes from './FilteredActionList.module.css' +import Checkbox from '../Checkbox' import {isValidElementType} from 'react-is' import type {RenderItemFn} from '../deprecated/ActionList/List' @@ -45,6 +46,7 @@ export interface FilteredActionListProps className?: string announcementsEnabled?: boolean fullScreenOnNarrow?: boolean + onSelectAllChange?: (checked: boolean) => void } const StyledHeader = styled.div` @@ -70,6 +72,7 @@ export function FilteredActionList({ className, announcementsEnabled = true, fullScreenOnNarrow, + onSelectAllChange, ...listProps }: FilteredActionListProps): JSX.Element { const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '') @@ -88,6 +91,11 @@ export function FilteredActionList({ const activeDescendantRef = useRef() const listId = useId() const inputDescriptionTextId = useId() + + const selectAllChecked = items.length > 0 && items.every(item => item.selected) + const selectAllIndeterminate = !selectAllChecked && items.some(item => item.selected) + + const selectAllLabelText = selectAllChecked ? 'Deselect all' : 'Select all' const onInputKeyPress: KeyboardEventHandler = useCallback( event => { if (event.key === 'Enter' && activeDescendantRef.current) { @@ -150,6 +158,15 @@ export function FilteredActionList({ useAnnouncements(items, {current: listContainerElement}, inputRef, announcementsEnabled, loading) useScrollFlash(scrollContainerRef) + const handleSelectAllChange = useCallback( + (e: React.ChangeEvent) => { + if (onSelectAllChange) { + onSelectAllChange(e.target.checked) + } + }, + [onSelectAllChange], + ) + function getItemListForEachGroup(groupId: string) { const itemsInGroup = [] for (const item of items) { @@ -232,6 +249,20 @@ export function FilteredActionList({ /> Items will be filtered as you type + {onSelectAllChange !== undefined && ( +
+ + +
+ )}
{getBodyContent()}
diff --git a/packages/react/src/SelectPanel/SelectPanel.docs.json b/packages/react/src/SelectPanel/SelectPanel.docs.json index 4a9fbde8160..3bf0f6125d6 100644 --- a/packages/react/src/SelectPanel/SelectPanel.docs.json +++ b/packages/react/src/SelectPanel/SelectPanel.docs.json @@ -184,6 +184,12 @@ "description": "Whether to display the selected items at the top of the list", "default": "true" }, + { + "name": "showSelectAll", + "type": "boolean", + "defaultValue": "false", + "description": "When `true` and on a multi-select SelectPanel, displays a 'Select all' checkbox that allows users to select or deselect all visible items at once. The checkbox label automatically toggles between 'Select all' and 'Deselect all' based on the current selection state, and shows an indeterminate state when some items are selected." + }, { "name": "disableFullscreenOnNarrow", "type": "boolean", diff --git a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx index 70039771288..0985e40e563 100644 --- a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx @@ -888,3 +888,44 @@ export const WithInactiveItems = () => { ) } + +export const WithSelectAll = () => { + const [selected, setSelected] = useState([]) + const [filter, setFilter] = useState('') + const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + + const [open, setOpen] = useState(false) + + return ( + + Labels + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + width="medium" + showSelectAll={true} + message={ + filteredItems.length === 0 + ? { + variant: 'empty', + title: `No language found for \`${filter}\``, + body: 'Adjust your search term to find other languages', + } + : undefined + } + /> + + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 99b66c1e9ea..e48731ac84b 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -1210,6 +1210,217 @@ for (const useModernActionList of [false, true]) { expect(responsiveCloseButton).not.toBeInTheDocument() }) }) + + describe('Select all', () => { + function SelectAllSelectPanel({showSelectAll = true}: {showSelectAll?: boolean} = {}) { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } + + return ( + + { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + showSelectAll={showSelectAll} + /> + + ) + } + + it('should render a Select All checkbox when showSelectAll is true', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + expect(screen.getByText('Select all')).toBeInTheDocument() + expect(screen.getByRole('checkbox', {name: 'Select all'})).toBeInTheDocument() + expect(screen.getByRole('checkbox', {name: 'Select all'})).not.toBeChecked() + }) + + it('should not render a Select All checkbox when showSelectAll is false', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + expect(screen.queryByText('Select all')).not.toBeInTheDocument() + expect(screen.queryByRole('checkbox', {name: 'Select all'})).not.toBeInTheDocument() + }) + + it('should select all items when the Select All checkbox is clicked', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + + // All options should now be selected + for (const item of items) { + expect(screen.getByRole('option', {name: item.text})).toHaveAttribute('aria-selected', 'true') + } + }) + + it('should deselect all items when the Deselect All checkbox is clicked', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + // First select all + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + + // Then deselect all + await user.click(screen.getByRole('checkbox', {name: 'Deselect all'})) + + // All options should now be deselected + for (const item of items) { + if (item.text) { + expect(screen.getByRole('option', {name: item.text})).toHaveAttribute('aria-selected', 'false') + } + } + }) + + it('should update Select All checkbox to indeterminate state when some items (but not all) are selected', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + // Select only one item + await user.click(screen.getByText('item one')) + + // Check that Select All is in indeterminate state + const selectAllCheckbox = screen.getByRole('checkbox', {name: 'Select all'}) + expect(selectAllCheckbox).not.toBeChecked() + expect(selectAllCheckbox).toHaveProperty('indeterminate', true) + }) + + it('should update Select All checkbox to checked when all items are selected manually', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + // Select all items individually + for (const item of items) { + if (item.text) { + await user.click(screen.getByText(item.text)) + } + } + + // Check that Deselect All is checked + expect(screen.getByRole('checkbox', {name: 'Deselect all'})).toBeChecked() + }) + + it('should update Select All checkbox label to "Deselect all" when all items are selected', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + // Select all items + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + + // Check that the label has changed to "Deselect all" + expect(screen.getByText('Deselect all')).toBeInTheDocument() + expect(screen.getByRole('checkbox', {name: 'Deselect all'})).toBeInTheDocument() + }) + + it('should apply Select All only to filtered items and maintain selection state when filters are cleared', async () => { + const user = userEvent.setup() + + function FilterableSelectAllPanel() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } + + return ( + + item.text?.includes(filter))} + placeholder="Select items" + placeholderText="Filter items" + selected={selected} + onSelectedChange={onSelectedChange} + filterValue={filter} + onFilterChange={value => { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + showSelectAll={true} + /> + + ) + } + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + // Filter to only show "item one" + await user.type(screen.getByLabelText('Filter items'), 'one') + + // Only "item one" should be visible + expect(screen.getAllByRole('option')).toHaveLength(1) + expect(screen.getByText('item one')).toBeInTheDocument() + + // Select all (which is just the one visible item) + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + + // The visible item should be selected + expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') + + // Clear the filter + await user.clear(screen.getByLabelText('Filter items')) + + // Now all items should be visible, but only "item one" should be selected + expect(screen.getAllByRole('option')).toHaveLength(3) + expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('option', {name: 'item two'})).toHaveAttribute('aria-selected', 'false') + expect(screen.getByRole('option', {name: 'item three'})).toHaveAttribute('aria-selected', 'false') + + // Select All checkbox should be in indeterminate state + const selectAllCheckbox = screen.getByRole('checkbox', {name: 'Select all'}) + expect(selectAllCheckbox).not.toBeChecked() + expect(selectAllCheckbox).toHaveProperty('indeterminate', true) + }) + }) }) }) } diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 48cf750ad22..a83e4495773 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -107,6 +107,7 @@ interface SelectPanelBaseProps { * @default undefined (uses feature flag default) */ disableFullscreenOnNarrow?: boolean + showSelectAll?: boolean } // onCancel is optional with variant=anchored, but required with variant=modal @@ -182,6 +183,7 @@ function Panel({ showSelectedOptionsFirst = true, disableFullscreenOnNarrow, align, + showSelectAll = false, ...listProps }: SelectPanelProps): JSX.Element { const titleId = useId() @@ -302,6 +304,29 @@ function Panel({ ], ) + const handleSelectAllChange = useCallback( + (checked: boolean) => { + // Exit early if not in multi-select mode + if (!isMultiSelectVariant(selected)) { + return + } + + const multiSelectOnChange = onSelectedChange as SelectPanelMultiSelection['onSelectedChange'] + const selectedArray = selected as ItemInput[] + + const selectedItemsNotInFilteredView = selectedArray.filter( + (selectedItem: ItemInput) => !items.some(item => areItemsEqual(item, selectedItem)), + ) + + if (checked) { + multiSelectOnChange([...selectedItemsNotInFilteredView, ...items]) + } else { + multiSelectOnChange(selectedItemsNotInFilteredView) + } + }, + [items, onSelectedChange, selected], + ) + // disable body scroll when the panel is open on narrow screens useEffect(() => { if (open && isNarrowScreenSize && usingFullScreenOnNarrow) { @@ -792,6 +817,7 @@ function Panel({ textInputProps={extendedTextInputProps} loading={loading || (isLoading && !message)} loadingType={loadingType()} + onSelectAllChange={showSelectAll ? handleSelectAllChange : undefined} // hack because the deprecated ActionList does not support this prop {...{ message: getMessage(),