From f65039fddd41e0339cb280fc00799dec2d0dfa62 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Wed, 21 Oct 2026 18:31:02 +0200 Subject: [PATCH 1/5] refactor: Add shared listbox selection models Add single and multi selection models for the shared listbox under src/internal/components/listbox/selection. Nothing calls them yet: OptionList adopts them in PR 3, Select and Multiselect in PRs 4 and 5. The models use DropdownOption; there is no type rename in this stack. The models match today's use-select / use-multiselect behaviour for re-select, group state (including the empty-value quirk), toggle order, disabled options, group toggle, select-all state and toggle, and token dismiss. Differences and caller contracts: - The models do not gate input. Callers must keep the isInteractive / isGroupInteractive checks and read-only handling in front of them. - singleSelection uses only the first selected option (Select passes at most one, so this is not observable). - Group toggle and select-all read OptionDefinition.disabled, not the wrapper disabled set on children of a disabled group (same as today). - allOptions defaults to options. Filtering callers must pass the full list, or select-all state and select-all on use only visible options. - Row-level selected matching stays with the caller. --- .../__tests__/multi-selection.test.ts | 161 ++++++++++++++++++ .../__tests__/single-selection.test.ts | 61 +++++++ .../listbox/selection/interfaces.ts | 32 ++++ .../listbox/selection/multi-selection.ts | 91 ++++++++++ .../listbox/selection/single-selection.ts | 35 ++++ 5 files changed, 380 insertions(+) create mode 100644 src/internal/components/listbox/selection/__tests__/multi-selection.test.ts create mode 100644 src/internal/components/listbox/selection/__tests__/single-selection.test.ts create mode 100644 src/internal/components/listbox/selection/interfaces.ts create mode 100644 src/internal/components/listbox/selection/multi-selection.ts create mode 100644 src/internal/components/listbox/selection/single-selection.ts 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..08977afdcc --- /dev/null +++ b/src/internal/components/listbox/selection/__tests__/multi-selection.test.ts @@ -0,0 +1,161 @@ +// 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 option lists; the list builds it separately and feeds it to the model. +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 }); + // The disabled child 'b' is excluded from the toggle set. + 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..b251c63158 --- /dev/null +++ b/src/internal/components/listbox/selection/__tests__/single-selection.test.ts @@ -0,0 +1,61 @@ +// 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' }]); + // Single select has no toggle-off: choosing the selected option returns it, still selected. + 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', () => { + // Baseline Select built `__selectedValuesSet` from `!!item.value`, so an empty-string value never counts. + 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..cb08892750 --- /dev/null +++ b/src/internal/components/listbox/selection/interfaces.ts @@ -0,0 +1,32 @@ +// 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. Covers what the `useInteractiveGroups`, `checkboxes` and + * `isMultiSelect` flags express today. 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; + + /** For group headers and select-all. */ + getGroupState(option: DropdownOption): GroupState; + /** Returns the selection that results from the user choosing `option`. */ + 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..004852cd1a --- /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 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..8dfe9bcf4f --- /dev/null +++ b/src/internal/components/listbox/selection/single-selection.ts @@ -0,0 +1,35 @@ +// 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; + // Match the pre-refactor Select behaviour: a child with a falsy value is never counted, even when the + // selected value is also falsy (baseline `use-select.ts` built `__selectedValuesSet` from `!!item.value`). + const totalSelected = + selectedValue === undefined + ? 0 + : 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], + }; +} From dd58ebb4e0181a17f269868766684edb34e2e27d Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Fri, 23 Oct 2026 16:29:49 +0200 Subject: [PATCH 2/5] refactor: Use selection models in Multiselect Multiselect's toggle, group toggle and select-all now go through multiSelection. Behaviour is unchanged, proven by the unchanged Multiselect unit and integration tests. The caller still gates disabled, read-only and non-interactive options before calling getNextSelection. --- .../listbox/selection/interfaces.ts | 5 +- .../__tests__/selection-order.test.tsx | 70 +++++++++++++++++++ src/multiselect/use-multiselect.tsx | 61 ++++------------ 3 files changed, 86 insertions(+), 50 deletions(-) create mode 100644 src/multiselect/__tests__/selection-order.test.tsx diff --git a/src/internal/components/listbox/selection/interfaces.ts b/src/internal/components/listbox/selection/interfaces.ts index cb08892750..c420605902 100644 --- a/src/internal/components/listbox/selection/interfaces.ts +++ b/src/internal/components/listbox/selection/interfaces.ts @@ -27,6 +27,9 @@ export interface SelectionModel { /** For group headers and select-all. */ getGroupState(option: DropdownOption): GroupState; - /** Returns the selection that results from the user choosing `option`. */ + /** + * 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/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..db764bb99c 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, { useCallback, 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,64 +122,28 @@ 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 selection = multiSelection(selectedOptions, { + options: filteredOptions, + // Includes visible and non-visible (filtered out) options + allOptions: flatOptions, + interactiveGroups: useInteractiveGroups, + }); 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 isAllSelected = selection.getGroupState(selectAllOption).selected; 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: selection.getNextSelection({ option }), }); }, - [selectedOptions, onChange, filteredNonParentOptions] + [selection, onChange] ); const scrollToIndex = useRef(null); From 1e7c17212ca6671f7229771ce75910431cfc631d Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Fri, 23 Oct 2026 17:09:50 +0200 Subject: [PATCH 3/5] refactor: Read Multiselect group state from selection model useSelect accepts an optional selection model. Multiselect passes it, so group and select-all state come from the model. Select is unchanged and keeps its own path until it moves to the engine. Tidy-ups: replace the stale disabled-option comment in useMultiselect with a pointer to the model, and make updateSelectedOption a plain function (its useCallback never memoized because the model is rebuilt every render). The isAllSelected / isSomeSelected props are removed; no caller needs them now that the model provides select-all state. --- .../__tests__/group-state.test.tsx | 199 ++++++++++++++++++ src/multiselect/use-multiselect.tsx | 21 +- src/select/utils/use-select.ts | 44 ++-- 3 files changed, 232 insertions(+), 32 deletions(-) create mode 100644 src/multiselect/__tests__/group-state.test.tsx diff --git a/src/multiselect/__tests__/group-state.test.tsx b/src/multiselect/__tests__/group-state.test.tsx new file mode 100644 index 0000000000..8ed2cc0110 --- /dev/null +++ b/src/multiselect/__tests__/group-state.test.tsx @@ -0,0 +1,199 @@ +// 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 calls = renderOption.mock.calls.filter(([{ item }]) => item.type === type); + const item = calls[calls.length - 1][0].item; + 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/use-multiselect.tsx b/src/multiselect/use-multiselect.tsx index db764bb99c..b8a3270fc1 100644 --- a/src/multiselect/use-multiselect.tsx +++ b/src/multiselect/use-multiselect.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; @@ -128,23 +128,15 @@ export function useMultiselect({ allOptions: flatOptions, interactiveGroups: useInteractiveGroups, }); - const isSomeSelected = selectedOptions.length > 0; - const isAllSelected = selection.getGroupState(selectAllOption).selected; const toggleAll = () => { 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 - fireNonCancelableEvent(onChange, { - selectedOptions: selection.getNextSelection({ option }), - }); - }, - [selection, onChange] - ); + // Whether choosing an option selects or deselects it (disabled options included) is decided by the model. + const updateSelectedOption = (option: OptionDefinition | OptionGroup) => { + fireNonCancelableEvent(onChange, { selectedOptions: selection.getNextSelection({ option }) }); + }; const scrollToIndex = useRef(null); const { @@ -175,8 +167,7 @@ export function useMultiselect({ useInteractiveGroups, statusType, embedded, - isAllSelected, - isSomeSelected, + selection, toggleAll, dropdownRole, }); diff --git a/src/select/utils/use-select.ts b/src/select/utils/use-select.ts index 1f205331c6..2a156fb009 100644 --- a/src/select/utils/use-select.ts +++ b/src/select/utils/use-select.ts @@ -7,6 +7,7 @@ import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import { InternalDropdownProps } from '../../dropdown/internal'; import { ButtonTriggerProps } from '../../internal/components/button-trigger'; +import { GroupState, SelectionModel } from '../../internal/components/listbox/selection/interfaces'; import { connectOptionsByValue } from '../../internal/components/listbox/utils/connect-options'; import { DropdownOption } from '../../internal/components/option/interfaces'; import { isGroup, isGroupInteractive, isInteractive } from '../../internal/components/option/utils/filter-options'; @@ -42,8 +43,8 @@ interface UseSelectProps { setFilteringValue?: (filteringText: string) => void; useInteractiveGroups?: boolean; statusType: DropdownStatusProps.StatusType; - isAllSelected?: boolean; - isSomeSelected?: boolean; + /** When provided, group and select-all checkbox state come from it. Select doesn't pass one. */ + selection?: SelectionModel; toggleAll?: () => void; dropdownRole?: SelectProps.DropdownRole; } @@ -66,8 +67,7 @@ export function useSelect({ setFilteringValue, useInteractiveGroups = false, statusType, - isAllSelected, - isSomeSelected, + selection, toggleAll, dropdownRole, }: UseSelectProps) { @@ -271,31 +271,41 @@ export function useSelect({ } return menuProps; }; - const getGroupState = (option: OptionGroup) => { - const totalSelected = option.options.filter(item => !!item.value && __selectedValuesSet.has(item.value)).length; + const getOptionGroupState = (group: OptionGroup): GroupState => { + const totalSelected = group.options.filter(item => !!item.value && __selectedValuesSet.has(item.value)).length; const hasSelected = totalSelected > 0; - const allSelected = totalSelected === option.options.length; + const allSelected = totalSelected === group.options.length; return { selected: hasSelected && allSelected && useInteractiveGroups, indeterminate: hasSelected && !allSelected, }; }; + // Only called for group rows and select-all. + const getGroupState = (option: DropdownOption): GroupState => { + if (selection) { + return selection.getGroupState(option); + } + return isGroup(option.option) ? getOptionGroupState(option.option) : { selected: false, indeterminate: false }; + }; + const getOptionProps = (option: DropdownOption, index: number) => { const isSelectAll = option.type === 'select-all'; const highlighted = option === highlightedOption; - const groupState = isGroup(option.option) ? getGroupState(option.option) : undefined; - const selected = isSelectAll ? isAllSelected : __selectedOptionsSet.has(option) || !!groupState?.selected; - const nextOption = options[index + 1]?.option; + const groupState = isSelectAll || isGroup(option.option) ? getGroupState(option) : undefined; + const selected = isSelectAll ? !!groupState?.selected : __selectedOptionsSet.has(option) || !!groupState?.selected; + // Neighbours stay gated on `isGroup`: select-all is never a selected neighbour, so the row below it + // doesn't get the previous-item-selected style. + const nextOption = options[index + 1]; const isNextSelected = - !!nextOption && isGroup(nextOption) + !!nextOption && isGroup(nextOption.option) ? getGroupState(nextOption).selected - : __selectedOptionsSet.has(options[index + 1]); - const previousOption = options[index - 1]?.option; + : __selectedOptionsSet.has(nextOption); + const previousOption = options[index - 1]; const isPreviousSelected = - !!previousOption && isGroup(previousOption) + !!previousOption && isGroup(previousOption.option) ? getGroupState(previousOption).selected - : __selectedOptionsSet.has(options[index - 1]); + : __selectedOptionsSet.has(previousOption); const optionProps: any = { key: index, option, @@ -303,7 +313,7 @@ export function useSelect({ selected, isNextSelected, isPreviousSelected, - indeterminate: !!groupState?.indeterminate || (isSelectAll && !isAllSelected && isSomeSelected), + indeterminate: !!groupState?.indeterminate, ['data-mouse-target']: isHighlightable(option) ? index : -1, id: getOptionId(menuId, index), }; @@ -360,7 +370,7 @@ export function useSelect({ useForwardFocus(externalRef, triggerRef as React.RefObject); const highlightedGroupSelected = - !!highlightedOption && isGroup(highlightedOption.option) && getGroupState(highlightedOption.option).selected; + !!highlightedOption && isGroup(highlightedOption.option) && getGroupState(highlightedOption).selected; const announceSelected = !!highlightedOption && (__selectedOptionsSet.has(highlightedOption) || highlightedGroupSelected); From adf4afd96ef30c23c2c8aad84711681f9ddd7242 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Fri, 23 Oct 2026 17:28:43 +0200 Subject: [PATCH 4/5] refactor: Read Select group state from selection model Select now passes singleSelection(selectedOptions) to useSelect, so group state for both Select and Multiselect comes from the selection models. useSelect's own group-state formula (getOptionGroupState and its values set) is removed. The selection prop stays optional and defaults to singleSelection(selectedOptions), because use-select.test.ts calls the hook without it. The default is the same model Select passes, so there is one source of group state. Behaviour is unchanged: existing tests pass unmodified, and the new group-state tests pass on both the old and new code. --- src/select/__tests__/group-state.test.tsx | 133 ++++++++++++++++++++++ src/select/internal.tsx | 5 +- src/select/utils/use-select.ts | 30 +---- 3 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 src/select/__tests__/group-state.test.tsx diff --git a/src/select/__tests__/group-state.test.tsx b/src/select/__tests__/group-state.test.tsx new file mode 100644 index 0000000000..d8f2198273 --- /dev/null +++ b/src/select/__tests__/group-state.test.tsx @@ -0,0 +1,133 @@ +// 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 { 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, + withSelection = true +) => { + const { flatOptions } = flattenOptions(options); + const hook = renderHook(useSelect, { + initialProps: { + selectedOptions, + selection: withSelection ? singleSelection(selectedOptions) : undefined, + 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); + 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('omitting selection gives the same option props as passing single selection', () => { + const options = [{ value: 'top' }, { label: 'Group', options: values('a', 'b', 'c') }]; + const selectedOptions = values('b'); + const withModel = renderUseSelect(options, selectedOptions, true); + const withoutModel = renderUseSelect(options, selectedOptions, false); + // 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(withoutModel)).toEqual(propsWithoutId(withModel)); + }); +}); + +describe('Select group row state', () => { + const renderSelect = (props: Partial) => { + const { container } = render(