diff --git a/src/internal/components/listbox/selection/__tests__/multi-selection.test.ts b/src/internal/components/listbox/selection/__tests__/multi-selection.test.ts new file mode 100644 index 0000000000..13c27f2670 --- /dev/null +++ b/src/internal/components/listbox/selection/__tests__/multi-selection.test.ts @@ -0,0 +1,160 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { OptionDefinition, OptionGroup } from '../../../../../types/option'; +import { DropdownOption } from '../../../option/interfaces'; +import { flattenOptions } from '../../../option/utils/flatten-options'; +import { multiSelection } from '../multi-selection'; + +const flatten = (options: ReadonlyArray) => flattenOptions(options).flatOptions; +const findByValue = (list: ReadonlyArray, value: string | undefined) => + list.find(item => item.type !== 'parent' && (item.option as OptionDefinition).value === value)!; +const findGroup = (list: ReadonlyArray) => list.find(item => item.type === 'parent')!; +const values = (options: ReadonlyArray) => options.map(option => option.value).sort(); + +// Select-all is not part of the flattened options; the list adds it separately. +const selectAll: DropdownOption = { type: 'select-all', option: { label: 'Select all' } }; + +describe('multiSelection', () => { + test('choosing an unselected option adds it, choosing a selected option removes it', () => { + const list = flatten([{ value: '1' }, { value: '2' }]); + const added = multiSelection([{ value: '1' }], { options: list }).getNextSelection(findByValue(list, '2')); + expect(values(added)).toEqual(['1', '2']); + const removed = multiSelection([{ value: '1' }, { value: '2' }], { options: list }).getNextSelection( + findByValue(list, '2') + ); + expect(values(removed)).toEqual(['1']); + }); + + test('an option with an empty-string value toggles like any other option', () => { + const list = flatten([{ value: '' }, { value: '2' }]); + const added = multiSelection([], { options: list }).getNextSelection(findByValue(list, '')); + expect(values(added)).toEqual(['']); + const removed = multiSelection([{ value: '' }], { options: list }).getNextSelection(findByValue(list, '')); + expect(removed).toEqual([]); + }); + + test('toggling a group only toggles its enabled children', () => { + const list = flatten([{ label: 'Group', options: [{ value: 'a' }, { value: 'b', disabled: true }] }]); + const model = multiSelection([], { options: list, interactiveGroups: true }); + expect(values(model.getNextSelection(findGroup(list)))).toEqual(['a']); + }); + + test('toggling a group only affects children that are currently visible', () => { + const allList = flatten([{ label: 'Group', options: [{ value: 'a' }, { value: 'b' }] }]); + const visibleList = flatten([{ label: 'Group', options: [{ value: 'a' }] }]); + const model = multiSelection([], { options: visibleList, allOptions: allList, interactiveGroups: true }); + // 'b' is filtered out of the visible options, so toggling the group leaves it untouched. + expect(values(model.getNextSelection(findGroup(visibleList)))).toEqual(['a']); + }); + + test('select-all is checked only when every option in allOptions is selected, including filtered-out ones', () => { + const allList = flatten([{ value: '1' }, { value: '2' }]); + const visibleList = flatten([{ value: '1' }]); + // Only the visible option is selected; the hidden option '2' is not, so select-all is not fully checked. + const partial = multiSelection([{ value: '1' }], { + options: visibleList, + allOptions: allList, + interactiveGroups: true, + }); + expect(partial.getGroupState(selectAll)).toEqual({ selected: false, indeterminate: true }); + + const full = multiSelection([{ value: '1' }, { value: '2' }], { + options: visibleList, + allOptions: allList, + interactiveGroups: true, + }); + expect(full.getGroupState(selectAll)).toEqual({ selected: true, indeterminate: false }); + }); + + test('toggling select-all changes only visible enabled options and keeps hidden selections', () => { + const allList = flatten([{ value: '1' }, { value: '2' }, { value: '3', disabled: true }]); + const visibleList = flatten([{ value: '1' }, { value: '3', disabled: true }]); + // '2' is a hidden selection; '3' is a visible but disabled option. + const model = multiSelection([{ value: '2' }], { + options: visibleList, + allOptions: allList, + interactiveGroups: true, + }); + const next = model.getNextSelection(selectAll); + // Hidden '2' is kept, visible enabled '1' is added, visible disabled '3' is left unchanged. + expect(values(next)).toEqual(['1', '2']); + }); + + test('toggling select-all off keeps hidden selections and clears the visible enabled options', () => { + const allList = flatten([{ value: '1' }, { value: '2' }]); + const visibleList = flatten([{ value: '1' }]); + // Everything visible is already selected, so toggling select-all deselects the visible options only. + const model = multiSelection([{ value: '1' }, { value: '2' }], { + options: visibleList, + allOptions: allList, + interactiveGroups: true, + }); + expect(values(model.getNextSelection(selectAll))).toEqual(['2']); + }); + + test('a group is indeterminate when some but not all children are selected', () => { + const list = flatten([{ label: 'Group', options: [{ value: 'a' }, { value: 'b' }] }]); + const model = multiSelection([{ value: 'a' }], { options: list, interactiveGroups: true }); + expect(model.getGroupState(findGroup(list))).toEqual({ selected: false, indeterminate: true }); + }); + + test('a group is selected when all children are selected', () => { + const list = flatten([{ label: 'Group', options: [{ value: 'a' }, { value: 'b' }] }]); + const model = multiSelection([{ value: 'a' }, { value: 'b' }], { options: list, interactiveGroups: true }); + expect(model.getGroupState(findGroup(list))).toEqual({ selected: true, indeterminate: false }); + }); + + test('choosing a disabled option that is not selected leaves the selection unchanged', () => { + const list = flatten([{ value: '1' }, { value: '2', disabled: true }]); + const model = multiSelection([{ value: '1' }], { options: list }); + expect(model.getNextSelection(findByValue(list, '2'))).toEqual([{ value: '1' }]); + }); + + test('deselecting a group keeps its selected disabled children', () => { + const list = flatten([{ label: 'Group', options: [{ value: 'a' }, { value: 'b', disabled: true }] }]); + const model = multiSelection([{ value: 'a' }, { value: 'b', disabled: true }], { + options: list, + interactiveGroups: true, + }); + expect(values(model.getNextSelection(findGroup(list)))).toEqual(['b']); + }); + + test('deselecting a group keeps selections outside the group', () => { + const list = flatten([{ value: 'x' }, { label: 'Group', options: [{ value: 'a' }, { value: 'b' }] }]); + const model = multiSelection([{ value: 'x' }, { value: 'a' }, { value: 'b' }], { + options: list, + interactiveGroups: true, + }); + expect(values(model.getNextSelection(findGroup(list)))).toEqual(['x']); + }); + + test('newly chosen options are appended after the existing selection', () => { + const list = flatten([{ value: '1' }, { value: '2' }, { value: '3' }]); + const model = multiSelection([{ value: '3' }], { options: list }); + expect(model.getNextSelection(findByValue(list, '1')).map(option => option.value)).toEqual(['3', '1']); + }); + + test('select-all turns off when every visible enabled option is selected, even if a disabled one is not', () => { + const list = flatten([{ value: '1' }, { value: '2', disabled: true }]); + const model = multiSelection([{ value: '1' }], { options: list, interactiveGroups: true }); + expect(model.getNextSelection(selectAll)).toEqual([]); + }); + + test('removing an option that is not in the visible list deselects it', () => { + const list = flatten([{ value: '1' }]); + const model = multiSelection([{ value: '1' }, { value: '9' }], { options: list }); + expect(values(model.getNextSelection({ option: { value: '9' } }))).toEqual(['1']); + }); + + test('a fully selected group is not shown as selected when groups are not interactive', () => { + const list = flatten([{ label: 'Group', options: [{ value: 'a' }, { value: 'b' }] }]); + const model = multiSelection([{ value: 'a' }, { value: 'b' }], { options: list }); + expect(model.getGroupState(findGroup(list))).toEqual({ selected: false, indeterminate: false }); + }); + + test('a selected child with an empty-string value is not counted as a group selection', () => { + const list = flatten([{ label: 'Group', options: [{ value: '' }, { value: 'b' }] }]); + const model = multiSelection([{ value: '' }], { options: list, interactiveGroups: true }); + expect(model.getGroupState(findGroup(list))).toEqual({ selected: false, indeterminate: false }); + }); +}); diff --git a/src/internal/components/listbox/selection/__tests__/single-selection.test.ts b/src/internal/components/listbox/selection/__tests__/single-selection.test.ts new file mode 100644 index 0000000000..e61a3ec483 --- /dev/null +++ b/src/internal/components/listbox/selection/__tests__/single-selection.test.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { OptionDefinition, OptionGroup } from '../../../../../types/option'; +import { DropdownOption } from '../../../option/interfaces'; +import { flattenOptions } from '../../../option/utils/flatten-options'; +import { singleSelection } from '../single-selection'; + +const flatten = (options: ReadonlyArray) => flattenOptions(options).flatOptions; +const findByValue = (list: ReadonlyArray, value: string | undefined) => + list.find(item => (item.option as OptionDefinition).value === value)!; +const findGroup = (list: ReadonlyArray) => list.find(item => item.type === 'parent')!; + +describe('singleSelection', () => { + test('choosing an option returns only that option as the next selection', () => { + const list = flatten([{ value: '1' }, { value: '2' }]); + const model = singleSelection([]); + expect(model.getNextSelection(findByValue(list, '2'))).toEqual([{ value: '2' }]); + }); + + test('re-choosing the already selected option keeps it selected', () => { + const list = flatten([{ value: '1' }, { value: '2' }]); + const model = singleSelection([{ value: '2' }]); + expect(model.getNextSelection(findByValue(list, '2'))).toEqual([{ value: '2' }]); + }); + + test('a selected child with an empty-string value is not counted as a group selection', () => { + const list = flatten([{ label: 'Group', options: [{ value: '' }, { value: 'b' }] }]); + const model = singleSelection([{ value: '' }]); + expect(model.getGroupState(findGroup(list))).toEqual({ selected: false, indeterminate: false }); + }); + + test('a group with some but not all children selected is indeterminate and not selected', () => { + const list = flatten([{ label: 'Group', options: [{ value: 'a' }, { value: 'b' }] }]); + const model = singleSelection([{ value: 'a' }]); + expect(model.getGroupState(findGroup(list))).toEqual({ selected: false, indeterminate: true }); + }); + + test('a group whose only child is the selected one is not indeterminate (all children selected)', () => { + const list = flatten([{ label: 'Group', options: [{ value: 'a' }] }]); + const model = singleSelection([{ value: 'a' }]); + expect(model.getGroupState(findGroup(list))).toEqual({ selected: false, indeterminate: false }); + }); + + test('a non-group option has no group state', () => { + const list = flatten([{ value: '1' }]); + const model = singleSelection([{ value: '1' }]); + expect(model.getGroupState(findByValue(list, '1'))).toEqual({ selected: false, indeterminate: false }); + }); + + test('only the first selected option is kept', () => { + expect(singleSelection([{ value: '1' }, { value: '2' }]).selectedOptions).toEqual([{ value: '1' }]); + }); + + test('a selected option without a value does not mark its group as indeterminate', () => { + const list = flatten([{ label: 'Group', options: [{ label: 'No value' }, { value: 'b' }] }]); + const model = singleSelection([{ label: 'No value' }]); + expect(model.getGroupState(findGroup(list))).toEqual({ selected: false, indeterminate: false }); + }); +}); diff --git a/src/internal/components/listbox/selection/interfaces.ts b/src/internal/components/listbox/selection/interfaces.ts new file mode 100644 index 0000000000..3c5524bf4d --- /dev/null +++ b/src/internal/components/listbox/selection/interfaces.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { OptionDefinition } from '../../../../types/option'; +import { DropdownOption } from '../../option/interfaces'; + +export type SelectionType = 'single' | 'multi'; + +export interface GroupState { + selected: boolean; + indeterminate: boolean; +} + +/** + * Describes how a list selects options. Implementations are pure functions of their inputs. + */ +export interface SelectionModel { + /** Drives aria-multiselectable and the row style. */ + selectionType: SelectionType; + /** Whether group headers (and select-all) can be highlighted and chosen. */ + interactiveGroups: boolean; + /** + * The selected options. Rows are matched to them by `value` (the first option with a matching value wins), + * so this is the single source of truth for which rows render as selected. + */ + selectedOptions: ReadonlyArray; + + /** The checkbox state of a group header or select-all row. Other options are neither selected nor indeterminate. */ + getGroupState(option: DropdownOption): GroupState; + /** + * Returns the selection that results from the user choosing `option`. + * Does not check whether `option` is disabled, read-only or non-interactive; callers must do that first. + */ + getNextSelection(option: DropdownOption): ReadonlyArray; +} diff --git a/src/internal/components/listbox/selection/multi-selection.ts b/src/internal/components/listbox/selection/multi-selection.ts new file mode 100644 index 0000000000..9e2d845fdf --- /dev/null +++ b/src/internal/components/listbox/selection/multi-selection.ts @@ -0,0 +1,91 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { OptionDefinition, OptionGroup } from '../../../../types/option'; +import { DropdownOption } from '../../option/interfaces'; +import { isGroup } from '../../option/utils/filter-options'; +import { findOptionIndex } from '../utils/connect-options'; +import { GroupState, SelectionModel } from './interfaces'; + +interface MultiSelectionConfig { + /** The options the user can currently see. Choosing a group toggles only its visible, enabled children. */ + options: ReadonlyArray; + /** + * All options, including the ones hidden by filtering. Select-all keeps hidden selected options and + * reports "all selected" against this list. Defaults to `options`. + */ + allOptions?: ReadonlyArray; + /** Whether group headers can be highlighted and chosen. */ + interactiveGroups?: boolean; +} + +const nonParentOptions = (options: ReadonlyArray) => + options + .filter(item => item.type !== 'parent' && item.type !== 'select-all') + .map(item => item.option as OptionDefinition); + +/** + * Any number of options. Choosing an option toggles it. Choosing a group toggles its visible, enabled + * children. Choosing select-all toggles every visible, enabled option. + */ +export function multiSelection( + selectedOptions: ReadonlyArray, + { options, allOptions = options, interactiveGroups = false }: MultiSelectionConfig +): SelectionModel { + const selectedValues = new Set(selectedOptions.map(option => option.value)); + const visible = nonParentOptions(options); + const all = nonParentOptions(allOptions); + + const isSomeSelected = selectedOptions.length > 0; + const isAllSelected = all.every(option => selectedValues.has(option.value)); + const isAllVisibleSelectableSelected = + isSomeSelected && visible.every(option => option.disabled || selectedValues.has(option.value)); + + const getOptionGroupState = (group: OptionGroup): GroupState => { + const totalSelected = group.options.filter(item => !!item.value && selectedValues.has(item.value)).length; + const hasSelected = totalSelected > 0; + const allSelected = totalSelected === group.options.length; + return { selected: hasSelected && allSelected && interactiveGroups, indeterminate: hasSelected && !allSelected }; + }; + + const getGroupState = (option: DropdownOption): GroupState => { + if (option.type === 'select-all') { + return { selected: isAllSelected, indeterminate: !isAllSelected && isSomeSelected }; + } + return isGroup(option.option) ? getOptionGroupState(option.option) : { selected: false, indeterminate: false }; + }; + + const toggleAll = () => { + const visibleValues = new Set(visible.map(option => option.value)); + return isAllVisibleSelectableSelected + ? selectedOptions.filter(option => !visibleValues.has(option.value)) + : all.filter(({ disabled, value }) => selectedValues.has(value) || (!disabled && visibleValues.has(value))); + }; + + const toggle = (toToggle: ReadonlyArray) => { + // Disabled options count as selected, so a group with a disabled child doesn't get stuck in one direction. + const allSelected = toToggle.every(item => findOptionIndex(selectedOptions, item) > -1 || item.disabled); + if (allSelected) { + return selectedOptions.filter(option => findOptionIndex(toToggle, option) === -1); + } + return selectedOptions.filter(item => findOptionIndex(toToggle, item) === -1).concat(toToggle); + }; + + return { + selectionType: 'multi', + interactiveGroups, + selectedOptions, + getGroupState, + getNextSelection: (option: DropdownOption) => { + if (option.type === 'select-all') { + return toggleAll(); + } + if (isGroup(option.option)) { + const visibleChildren = option.option.options.filter( + item => findOptionIndex(visible, item) > -1 && !item.disabled + ); + return toggle(visibleChildren); + } + return toggle([option.option]); + }, + }; +} diff --git a/src/internal/components/listbox/selection/single-selection.ts b/src/internal/components/listbox/selection/single-selection.ts new file mode 100644 index 0000000000..9e5863fdca --- /dev/null +++ b/src/internal/components/listbox/selection/single-selection.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { OptionDefinition } from '../../../../types/option'; +import { DropdownOption } from '../../option/interfaces'; +import { isGroup } from '../../option/utils/filter-options'; +import { SelectionModel } from './interfaces'; + +/** + * One option at a time. Choosing the selected option again keeps it selected. + * Only the first entry of `selectedOptions` is used. Group headers are never selectable. + */ +export function singleSelection(selectedOptions: ReadonlyArray): SelectionModel { + const selected = selectedOptions.slice(0, 1); + const selectedValue = selected[0]?.value; + + return { + selectionType: 'single', + interactiveGroups: false, + selectedOptions: selected, + getGroupState: (option: DropdownOption) => { + if (!isGroup(option.option)) { + return { selected: false, indeterminate: false }; + } + const children = option.option.options; + // A child with a falsy value never counts as selected, even when the selected value is also falsy. + const totalSelected = children.filter(child => !!child.value && child.value === selectedValue).length; + return { selected: false, indeterminate: totalSelected > 0 && totalSelected !== children.length }; + }, + getNextSelection: (option: DropdownOption) => [option.option as OptionDefinition], + }; +} diff --git a/src/multiselect/__tests__/group-state.test.tsx b/src/multiselect/__tests__/group-state.test.tsx new file mode 100644 index 0000000000..016efb25ca --- /dev/null +++ b/src/multiselect/__tests__/group-state.test.tsx @@ -0,0 +1,200 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import Multiselect from '../../../lib/components/multiselect'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { MultiselectProps } from '../interfaces'; + +import selectableStyles from '../../../lib/components/internal/components/selectable-item/styles.css.js'; + +const renderMultiselect = (props: Partial) => { + const { container } = render( null} {...props} />); + return createWrapper(container).findMultiselect()!; +}; + +const createRenderOption = () => + jest.fn(() =>
Custom
); + +const lastRenderedItem = (renderOption: ReturnType, type: string) => { + const items = renderOption.mock.calls.map(([{ item }]) => item).filter(item => item.type === type); + expect(items.length).toBeGreaterThan(0); + const item = items[items.length - 1]; + return { selected: item.selected, indeterminate: item.type === 'item' ? undefined : item.indeterminate }; +}; + +const rowOf = (element: HTMLElement) => element.closest(`.${selectableStyles['selectable-item']}`)!; + +const values = (...values: string[]) => values.map(value => ({ value })); + +describe('Multiselect select-all checkbox state', () => { + test.each([ + { name: 'all selected', options: values('1', '2'), selected: values('1', '2'), expected: [true, false] }, + { name: 'some selected', options: values('1', '2'), selected: values('1'), expected: [false, true] }, + { name: 'none selected', options: values('1', '2'), selected: [], expected: [false, false] }, + { + name: 'all selected, some filtered out', + options: values('apple', 'banana'), + selected: values('apple', 'banana'), + filteringText: 'apple', + expected: [true, false], + }, + { + name: 'all visible selected, a filtered out one is not', + options: values('apple', 'banana'), + selected: values('apple'), + filteringText: 'apple', + expected: [false, true], + }, + { + name: 'enabled selected, disabled not', + options: [{ value: '1', disabled: true }, { value: '2' }], + selected: values('2'), + expected: [false, true], + }, + { + name: 'enabled and disabled selected', + options: [{ value: '1', disabled: true }, { value: '2' }], + selected: values('1', '2'), + expected: [true, false], + }, + { name: 'selected value not in options', options: values('1'), selected: values('x'), expected: [false, true] }, + ])('$name', ({ options, selected, filteringText, expected: [expectedSelected, expectedIndeterminate] }) => { + const renderOption = createRenderOption(); + const wrapper = renderMultiselect({ + options, + selectedOptions: selected, + enableSelectAll: true, + filteringType: filteringText ? 'auto' : 'none', + renderOption, + }); + wrapper.openDropdown(); + if (filteringText) { + wrapper.findFilteringInput()!.setInputValue(filteringText); + } + + expect(lastRenderedItem(renderOption, 'select-all')).toEqual({ + selected: expectedSelected, + indeterminate: expectedIndeterminate, + }); + expect(wrapper.findDropdown().findSelectAll()!.getElement()).toHaveAttribute( + 'aria-checked', + String(expectedSelected) + ); + }); + + test('is not rendered when there are no options', () => { + const renderOption = createRenderOption(); + const wrapper = renderMultiselect({ + options: [], + selectedOptions: values('x'), + enableSelectAll: true, + renderOption, + }); + wrapper.openDropdown(); + + expect(renderOption.mock.calls.some(([{ item }]) => item.type === 'select-all')).toBe(false); + expect(wrapper.findDropdown().findSelectAll()).toBeNull(); + }); +}); + +describe('Multiselect group checkbox state', () => { + test.each([ + { + name: 'all children selected', + children: values('a', 'b'), + selected: values('a', 'b'), + expected: [true, false, 'true'], + }, + { + name: 'some children selected', + children: values('a', 'b'), + selected: values('a'), + expected: [false, true, 'mixed'], + }, + { + name: 'enabled children selected, a disabled child not', + children: [{ value: 'a' }, { value: 'b', disabled: true }], + selected: values('a'), + expected: [false, true, 'mixed'], + }, + ])('$name', ({ children, selected, expected: [expectedSelected, expectedIndeterminate, ariaChecked] }) => { + const renderOption = createRenderOption(); + const wrapper = renderMultiselect({ + options: [{ label: 'Group', options: children }], + selectedOptions: selected, + renderOption, + }); + wrapper.openDropdown(); + + expect(lastRenderedItem(renderOption, 'group')).toEqual({ + selected: expectedSelected, + indeterminate: expectedIndeterminate, + }); + expect(wrapper.findDropdown().findGroup(1)!.getElement()).toHaveAttribute('aria-checked', ariaChecked); + }); +}); + +describe('Multiselect selected neighbour styling', () => { + test('a row next to a fully selected group is styled as having a selected neighbour', () => { + const wrapper = renderMultiselect({ + options: [{ value: 'c' }, { label: 'Group', options: values('a', 'b') }], + selectedOptions: values('a', 'b'), + }); + wrapper.openDropdown(); + + const itemRow = rowOf(wrapper.findDropdown().findOptionByValue('c')!.getElement()); + expect(itemRow).toHaveClass(selectableStyles['next-item-selected']); + const childRow = rowOf(wrapper.findDropdown().findOptionInGroup(1, 1)!.getElement()); + expect(childRow).toHaveClass(selectableStyles['previous-item-selected']); + }); + + test('the row after select-all is not styled as having a selected neighbour', () => { + const wrapper = renderMultiselect({ + options: values('1', '2'), + selectedOptions: values('1', '2'), + enableSelectAll: true, + }); + wrapper.openDropdown(); + + const firstRow = rowOf(wrapper.findDropdown().findOptionByValue('1')!.getElement()); + expect(firstRow).not.toHaveClass(selectableStyles['previous-item-selected']); + }); +}); + +describe('Multiselect highlighted row announcement', () => { + const highlight = (wrapper: ReturnType, target: HTMLElement) => { + for (let i = 0; i < 5 && wrapper.findDropdown().findHighlightedOption()?.getElement() !== target; i++) { + wrapper.findDropdown().findOptionsContainer()!.keydown(KeyCode.up); + } + expect(wrapper.findDropdown().findHighlightedOption()!.getElement()).toBe(target); + }; + + test('announces a fully selected group as selected', () => { + const wrapper = renderMultiselect({ + options: [{ label: 'Group', options: values('a', 'b') }], + selectedOptions: values('a', 'b'), + selectedAriaLabel: 'Selected', + }); + wrapper.openDropdown(); + highlight(wrapper, wrapper.findDropdown().findGroup(1)!.getElement()); + + expect(wrapper.findDropdown().findHighlightedAriaLiveRegion()!.getElement().textContent).toMatch(/^Selected/); + }); + + test('does not announce select-all as selected', () => { + const wrapper = renderMultiselect({ + options: values('1', '2'), + selectedOptions: values('1', '2'), + enableSelectAll: true, + selectedAriaLabel: 'Selected', + }); + wrapper.openDropdown(); + highlight(wrapper, wrapper.findDropdown().findSelectAll()!.getElement()); + + expect(wrapper.findDropdown().findHighlightedAriaLiveRegion()!.getElement().textContent).not.toMatch(/^Selected/); + }); +}); diff --git a/src/multiselect/__tests__/selection-order.test.tsx b/src/multiselect/__tests__/selection-order.test.tsx new file mode 100644 index 0000000000..47ae654215 --- /dev/null +++ b/src/multiselect/__tests__/selection-order.test.tsx @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import Multiselect from '../../../lib/components/multiselect'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { MultiselectProps } from '../interfaces'; + +const options: MultiselectProps.Options = [ + { label: 'First', value: '1' }, + { + label: 'Group', + value: 'group', + options: [ + { label: 'Apple', value: 'a' }, + { label: 'Banana', value: 'b' }, + { label: 'Avocado', value: 'c' }, + { label: 'Apricot', value: 'd', disabled: true }, + ], + }, +]; + +const renderMultiselect = (props: Partial) => { + const { container } = render( null} {...props} />); + return createWrapper(container).findMultiselect()!; +}; + +const selectedValues = (onChange: jest.Mock) => + onChange.mock.calls[0][0].detail.selectedOptions.map((option: MultiselectProps.Option) => option.value); + +describe('Multiselect selection', () => { + test('choosing a group while filtering selects only its visible enabled options', () => { + const onChange = jest.fn(); + const wrapper = renderMultiselect({ onChange, filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('A'); + wrapper.selectOptionByValue('group'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(selectedValues(onChange)).toEqual(['a', 'b', 'c']); + + onChange.mockClear(); + wrapper.findFilteringInput()!.setInputValue('Ap'); + wrapper.selectOptionByValue('group'); + expect(selectedValues(onChange)).toEqual(['a']); + }); + + test('choosing a partially selected group appends all its enabled options after other selections', () => { + const onChange = jest.fn(); + const wrapper = renderMultiselect({ + onChange, + selectedOptions: [ + { label: 'Banana', value: 'b' }, + { label: 'First', value: '1' }, + ], + }); + wrapper.openDropdown(); + wrapper.selectOptionByValue('group'); + expect(selectedValues(onChange)).toEqual(['1', 'a', 'b', 'c']); + }); + + test('does not render select all when filtering matches no options', () => { + const wrapper = renderMultiselect({ enableSelectAll: true, filteringType: 'auto' }); + wrapper.openDropdown(); + expect(wrapper.findDropdown().findSelectAll()).not.toBeNull(); + wrapper.findFilteringInput()!.setInputValue('no such option'); + expect(wrapper.findDropdown().findSelectAll()).toBeNull(); + expect(wrapper.findDropdown().findOptions()).toHaveLength(0); + }); +}); diff --git a/src/multiselect/use-multiselect.tsx b/src/multiselect/use-multiselect.tsx index 508a644688..d3b979dba7 100644 --- a/src/multiselect/use-multiselect.tsx +++ b/src/multiselect/use-multiselect.tsx @@ -1,17 +1,16 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { useInternalI18n } from '../i18n/context'; import { useDropdownStatus } from '../internal/components/dropdown-status'; +import { multiSelection } from '../internal/components/listbox/selection/multi-selection'; import { useAnnouncement } from '../internal/components/listbox/use-announcement'; import { checkOptionValueField } from '../internal/components/listbox/utils/check-option-value-field.js'; -import { findOptionIndex } from '../internal/components/listbox/utils/connect-options'; import { DropdownOption } from '../internal/components/option/interfaces'; -import { isGroup } from '../internal/components/option/utils/filter-options'; import { prepareOptions } from '../internal/components/option/utils/prepare-options'; import { fireNonCancelableEvent } from '../internal/events'; import { SomeRequired } from '../internal/types'; @@ -123,65 +122,20 @@ export function useMultiselect({ const visibleOptions = enableSelectAll && filteredOptions.length ? [selectAllOption, ...filteredOptions] : filteredOptions; - // Includes visible and non-visible (filtered out) options - const allNonParentOptions = flatOptions.filter(item => item.type !== 'parent').map(option => option.option); - - const filteredNonParentOptions = filteredOptions.filter(item => item.type !== 'parent').map(item => item.option); - - const selectedValues = useMemo(() => new Set(selectedOptions.map(option => option.value)), [selectedOptions]); - - const isSomeSelected = selectedOptions.length > 0; - const isAllVisibleSelectableSelected = - isSomeSelected && filteredNonParentOptions.every(option => option.disabled || selectedValues.has(option.value)); - const isAllSelected = allNonParentOptions.every(option => selectedValues.has(option.value)); + const selection = multiSelection(selectedOptions, { + options: filteredOptions, + // Includes visible and non-visible (filtered out) options + allOptions: flatOptions, + interactiveGroups: useInteractiveGroups, + }); const toggleAll = () => { - const filteredNonParentOptionValues = new Set(filteredNonParentOptions.map(option => option.value)); - fireNonCancelableEvent(onChange, { - selectedOptions: isAllVisibleSelectableSelected - ? selectedOptions.filter(option => !filteredNonParentOptionValues.has(option.value)) - : allNonParentOptions.filter( - ({ disabled, value }) => - selectedValues.has(value) || (!disabled && filteredNonParentOptionValues.has(value)) - ), - }); + fireNonCancelableEvent(onChange, { selectedOptions: selection.getNextSelection(selectAllOption) }); }; - const updateSelectedOption = useCallback( - (option: OptionDefinition | OptionGroup) => { - // switch between selection and deselection behavior, ignores disabled options to prevent - // getting stuck on one behavior when an option is disabled and its state cannot be changed - const isAllChildrenSelected = (optionsArray: OptionDefinition[]) => - optionsArray.every(item => findOptionIndex(selectedOptions, item) > -1 || item.disabled); - const intersection = (visibleOptions: OptionDefinition[], options: OptionDefinition[]) => - visibleOptions.filter(item => findOptionIndex(options, item) > -1 && !item.disabled); - const union = (visibleOptions: OptionDefinition[], options: OptionDefinition[]) => - visibleOptions.filter(item => findOptionIndex(options, item) === -1).concat(options); - const select = (options: OptionDefinition[], selectedOptions: OptionDefinition[]) => { - return union(selectedOptions, options); - }; - const unselect = (options: OptionDefinition[], selectedOptions: OptionDefinition[]) => { - return selectedOptions.filter(option => findOptionIndex(options, option) === -1); - }; - let newSelectedOptions = [...selectedOptions]; - - if (isGroup(option)) { - const visibleOptions = intersection([...option.options], filteredNonParentOptions); - newSelectedOptions = isAllChildrenSelected(visibleOptions) - ? unselect(visibleOptions, newSelectedOptions) - : select(visibleOptions, newSelectedOptions); - } else { - newSelectedOptions = isAllChildrenSelected([option]) - ? unselect([option], newSelectedOptions) - : select([option], newSelectedOptions); - } - - fireNonCancelableEvent(onChange, { - selectedOptions: newSelectedOptions, - }); - }, - [selectedOptions, onChange, filteredNonParentOptions] - ); + const updateSelectedOption = (option: OptionDefinition | OptionGroup) => { + fireNonCancelableEvent(onChange, { selectedOptions: selection.getNextSelection({ option }) }); + }; const scrollToIndex = useRef(null); const { @@ -212,8 +166,7 @@ export function useMultiselect({ useInteractiveGroups, statusType, embedded, - isAllSelected, - isSomeSelected, + selection, toggleAll, dropdownRole, }); diff --git a/src/select/__tests__/group-state.test.tsx b/src/select/__tests__/group-state.test.tsx new file mode 100644 index 0000000000..5646a4f287 --- /dev/null +++ b/src/select/__tests__/group-state.test.tsx @@ -0,0 +1,134 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { createRef } from 'react'; +import { render } from '@testing-library/react'; + +import Select from '../../../lib/components/select'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { renderHook } from '../../__tests__/render-hook'; +import { SelectionModel } from '../../internal/components/listbox/selection/interfaces'; +import { singleSelection } from '../../internal/components/listbox/selection/single-selection'; +import { flattenOptions } from '../../internal/components/option/utils/flatten-options'; +import { OptionDefinition, OptionGroup } from '../../types/option'; +import { SelectProps } from '../interfaces'; +import { useSelect } from '../utils/use-select'; + +import selectableStyles from '../../../lib/components/internal/components/selectable-item/styles.css.js'; + +const values = (...values: string[]) => values.map(value => ({ value })); + +const renderUseSelect = ( + options: ReadonlyArray, + selectedOptions: ReadonlyArray, + selection?: SelectionModel +) => { + const { flatOptions } = flattenOptions(options); + const hook = renderHook(useSelect, { + initialProps: { + selectedOptions, + selection, + updateSelectedOption: () => {}, + options: flatOptions, + externalRef: createRef(), + filteringType: 'none', + fireLoadItems: () => {}, + setFilteringValue: () => {}, + statusType: 'finished' as const, + }, + }); + const { getOptionProps } = hook.result.current; + return { flatOptions, getOptionProps }; +}; + +const groupStates = ( + options: ReadonlyArray, + selectedOptions: ReadonlyArray +) => { + const { flatOptions, getOptionProps } = renderUseSelect(options, selectedOptions, singleSelection(selectedOptions)); + return flatOptions + .map((option, index) => ({ option, props: getOptionProps(option, index) })) + .filter(({ option }) => option.type === 'parent') + .map(({ props }) => ({ selected: props.selected, indeterminate: props.indeterminate })); +}; + +describe('useSelect group state with single selection', () => { + test('a group with one of several children selected is indeterminate, not selected', () => { + expect(groupStates([{ label: 'Group', options: values('a', 'b', 'c') }], values('b'))).toEqual([ + { selected: false, indeterminate: true }, + ]); + }); + + test('the same value in two groups makes both groups indeterminate', () => { + expect( + groupStates( + [ + { label: 'Group 1', options: values('a', 'shared') }, + { label: 'Group 2', options: values('shared', 'b') }, + ], + values('shared') + ) + ).toEqual([ + { selected: false, indeterminate: true }, + { selected: false, indeterminate: true }, + ]); + }); + + test('a selected value that is not in the options leaves groups unselected', () => { + expect(groupStates([{ label: 'Group', options: values('a', 'b') }], values('x'))).toEqual([ + { selected: false, indeterminate: false }, + ]); + }); + + test('defaults to single selection over selectedOptions when no selection model is passed', () => { + const options = [{ value: 'top' }, { label: 'Group', options: values('a', 'b', 'c') }]; + const selectedOptions = values('b'); + const explicit = renderUseSelect(options, selectedOptions, singleSelection(selectedOptions)); + const defaulted = renderUseSelect(options, selectedOptions); + // Ids come from a per-hook unique id, so they're left out of the comparison. + const propsWithoutId = ({ getOptionProps, flatOptions }: ReturnType) => + flatOptions.map((option, index) => { + const props = { ...getOptionProps(option, index) }; + delete props.id; + return props; + }); + expect(propsWithoutId(defaulted)).toEqual(propsWithoutId(explicit)); + }); +}); + +describe('Select group row state', () => { + const renderSelect = (props: Partial) => { + const { container } = render(