From 3e33be34e9dc520187ebe488e35a244c59146533 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 13:41:49 +0000 Subject: [PATCH 1/2] feat: add collapsible option groups to Select and Multiselect Adds an opt-in `collapsibleGroups` property to Select and Multiselect. When enabled, OptionGroup headers become expand/collapse disclosure controls (aria-expanded, Enter/Space and Left/Right arrow support), and collapsing a group hides its child options. Groups are expanded by default and the behaviour is fully backward compatible when the prop is omitted. --- pages/select/collapsible-groups.page.tsx | 86 +++++++++++ src/internal/components/option/interfaces.ts | 6 + .../option/utils/collapse-options.ts | 63 +++++++++ .../components/selectable-item/index.tsx | 9 +- .../components/selectable-item/interfaces.ts | 4 + .../__tests__/collapsible-groups.test.tsx | 80 +++++++++++ src/multiselect/internal.tsx | 2 + src/multiselect/use-multiselect.tsx | 39 ++++- .../__tests__/collapsible-groups.test.tsx | 133 ++++++++++++++++++ src/select/interfaces.ts | 11 ++ src/select/internal.tsx | 41 +++++- src/select/parts/item.tsx | 9 ++ src/select/parts/multiselect-item.tsx | 12 +- src/select/parts/styles.scss | 7 + src/select/utils/use-select.ts | 58 +++++++- 15 files changed, 547 insertions(+), 13 deletions(-) create mode 100644 pages/select/collapsible-groups.page.tsx create mode 100644 src/internal/components/option/utils/collapse-options.ts create mode 100644 src/multiselect/__tests__/collapsible-groups.test.tsx create mode 100644 src/select/__tests__/collapsible-groups.test.tsx diff --git a/pages/select/collapsible-groups.page.tsx b/pages/select/collapsible-groups.page.tsx new file mode 100644 index 0000000000..13c7804bd2 --- /dev/null +++ b/pages/select/collapsible-groups.page.tsx @@ -0,0 +1,86 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import Box from '~components/box'; +import Multiselect, { MultiselectProps } from '~components/multiselect'; +import Select, { SelectProps } from '~components/select'; +import SpaceBetween from '~components/space-between'; + +const options: Array = [ + { + label: 'Fruits', + options: [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'orange', label: 'Orange' }, + ], + }, + { + label: 'Vegetables', + options: [ + { value: 'carrot', label: 'Carrot' }, + { value: 'potato', label: 'Potato' }, + { value: 'onion', label: 'Onion', disabled: true }, + ], + }, + { value: 'other', label: 'Other (ungrouped)' }, + { + label: 'Dairy', + options: [ + { value: 'milk', label: 'Milk' }, + { value: 'cheese', label: 'Cheese' }, + ], + }, +]; + +export default function CollapsibleGroupsPage() { + const [selectedOption, setSelectedOption] = React.useState(null); + const [selectedOptions, setSelectedOptions] = React.useState>([]); + + return ( +
+ +

Select / Multiselect collapsible groups

+ +
+ Select with collapsibleGroups + + + + ); + const wrapper = createWrapper(container).findSelect()!; + return { wrapper, onChange }; +} + +describe('Select collapsibleGroups', () => { + test('does not add aria-expanded to group headers by default (backward compatible)', () => { + const { wrapper } = renderSelect(); + wrapper.openDropdown(); + const dropdown = wrapper.findDropdown()!; + expect(dropdown.findGroup(1)!.getElement()).not.toHaveAttribute('aria-expanded'); + // group header keeps the presentation role when not collapsible + expect(dropdown.findGroup(1)!.getElement()).toHaveAttribute('role', 'presentation'); + }); + + test('renders group headers as expanded disclosure controls when enabled', () => { + const { wrapper } = renderSelect({ collapsibleGroups: true }); + wrapper.openDropdown(); + const dropdown = wrapper.findDropdown()!; + expect(dropdown.findGroup(1)!.getElement()).toHaveAttribute('aria-expanded', 'true'); + expect(dropdown.findGroup(1)!.getElement()).toHaveAttribute('role', 'option'); + // children of an expanded group are visible + expect(dropdown.findOptionInGroup(1, 1)!.getElement()).toHaveTextContent('A1'); + expect(dropdown.findOptionInGroup(1, 2)!.getElement()).toHaveTextContent('A2'); + }); + + test('collapses and expands a group when its header is activated', () => { + const { wrapper } = renderSelect({ collapsibleGroups: true }); + wrapper.openDropdown(); + const dropdown = wrapper.findDropdown()!; + + // collapse group A by clicking its header + dropdown.findGroup(1)!.fireEvent(new MouseEvent('mouseup', { bubbles: true })); + expect(wrapper.findDropdown()!.findGroup(1)!.getElement()).toHaveAttribute('aria-expanded', 'false'); + expect(wrapper.findDropdown()!.findOptionInGroup(1, 1)).toBeNull(); + // other groups remain unaffected + expect(wrapper.findDropdown()!.findOptionInGroup(2, 1)!.getElement()).toHaveTextContent('B1'); + + // expand it again + wrapper + .findDropdown()! + .findGroup(1)! + .fireEvent(new MouseEvent('mouseup', { bubbles: true })); + expect(wrapper.findDropdown()!.findGroup(1)!.getElement()).toHaveAttribute('aria-expanded', 'true'); + expect(wrapper.findDropdown()!.findOptionInGroup(1, 1)!.getElement()).toHaveTextContent('A1'); + }); + + test('activating a group header does not select an option', () => { + const { wrapper, onChange } = renderSelect({ collapsibleGroups: true }); + wrapper.openDropdown(); + wrapper + .findDropdown()! + .findGroup(1)! + .fireEvent(new MouseEvent('mouseup', { bubbles: true })); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('child options remain selectable when groups are collapsible', () => { + const { wrapper, onChange } = renderSelect({ collapsibleGroups: true }); + wrapper.openDropdown(); + wrapper + .findDropdown()! + .findOptionInGroup(1, 1)! + .fireEvent(new MouseEvent('mouseup', { bubbles: true })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ selectedOption: { label: 'A1', value: 'a1' } }) }) + ); + }); + + test('collapsed children are removed from the filtered result but headers stay', () => { + const { wrapper } = renderSelect({ collapsibleGroups: true }); + wrapper.openDropdown(); + const dropdown = wrapper.findDropdown()!; + const optionCountExpanded = dropdown.findOptions().length; + + dropdown.findGroup(1)!.fireEvent(new MouseEvent('mouseup', { bubbles: true })); + // two child options of group A disappear + expect(wrapper.findDropdown()!.findOptions().length).toBe(optionCountExpanded - 2); + // both group headers are still present + expect(wrapper.findDropdown()!.findGroups()).toHaveLength(2); + }); + + test('Right/Left arrow keys expand and collapse the highlighted group header', () => { + const { wrapper } = renderSelect({ collapsibleGroups: true, filteringType: 'none' }); + wrapper.openDropdown(); + const menu = wrapper.findDropdown()!.findOptionsContainer()!; + + // On open, the first option ("First") is highlighted; one ArrowDown moves to the "Group A" header. + menu.keydown(KeyCode.down); + // collapse with ArrowLeft + menu.keydown(KeyCode.left); + expect(wrapper.findDropdown()!.findGroup(1)!.getElement()).toHaveAttribute('aria-expanded', 'false'); + // expand with ArrowRight + wrapper.findDropdown()!.findOptionsContainer()!.keydown(KeyCode.right); + expect(wrapper.findDropdown()!.findGroup(1)!.getElement()).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/src/select/interfaces.ts b/src/select/interfaces.ts index 410cb70099..94dcc5554e 100644 --- a/src/select/interfaces.ts +++ b/src/select/interfaces.ts @@ -149,6 +149,17 @@ export interface BaseSelectProps * user from both modifying the value and opening the dropdown. A read-only control is still focusable. */ readOnly?: boolean; + + /** + * When set to `true`, option groups (defined via `OptionGroup` objects in `options`) + * become collapsible. The group header renders an expand/collapse control with + * `aria-expanded`, and collapsing a group hides its child options. All groups are + * expanded by default. + * + * This property is opt-in and backward compatible: when omitted or `false`, groups + * behave exactly as before. + */ + collapsibleGroups?: boolean; } export interface SelectProps extends BaseSelectProps { diff --git a/src/select/internal.tsx b/src/select/internal.tsx index a9e838360c..94f94b9be9 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; import { useMergeRefs, useResizeObserver, useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; @@ -11,6 +11,8 @@ import { getBaseProps } from '../internal/base-component'; import { getBreakpointValue } from '../internal/breakpoints'; import DropdownFooter from '../internal/components/dropdown-footer'; import { useDropdownStatus } from '../internal/components/dropdown-status'; +import { DropdownOption } from '../internal/components/option/interfaces'; +import { applyGroupCollapse, getGroupKey } from '../internal/components/option/utils/collapse-options.js'; import { prepareOptions } from '../internal/components/option/utils/prepare-options.js'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireNonCancelableEvent } from '../internal/events'; @@ -68,6 +70,7 @@ const InternalSelect = React.forwardRef( virtualScroll, expandToViewport, autoFocus, + collapsibleGroups = false, __inFilteringToken, __internalRootRef, renderOption, @@ -99,12 +102,40 @@ const InternalSelect = React.forwardRef( const [filteringValue, setFilteringValue] = useState(''); + const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()); + const toggleGroupExpanded = useCallback( + (option: DropdownOption) => { + const groupIndex = getGroupKey(options, option.option as OptionGroup); + if (groupIndex === -1) { + return; + } + setCollapsedGroups(prev => { + const next = new Set(prev); + if (next.has(groupIndex)) { + next.delete(groupIndex); + } else { + next.add(groupIndex); + } + return next; + }); + }, + [options] + ); + const { filteredOptions, parentMap, totalCount, matchesCount } = prepareOptions( options, filteringType, filteringValue ); + const displayOptions = useMemo( + () => + collapsibleGroups + ? applyGroupCollapse({ options, filteredOptions, parentMap, collapsedGroups }) + : filteredOptions, + [collapsibleGroups, options, filteredOptions, parentMap, collapsedGroups] + ); + const rootRef = useRef(null); const triggerRef = useRef(null); const [triggerWidth, setTriggerWidth] = useState(null); @@ -134,7 +165,7 @@ const InternalSelect = React.forwardRef( } = useSelect({ selectedOptions: selectedOption ? [selectedOption] : [], updateSelectedOption: option => fireNonCancelableEvent(onChange, { selectedOption: option }), - options: filteredOptions, + options: displayOptions, filteringType, onBlur, onFocus, @@ -142,11 +173,13 @@ const InternalSelect = React.forwardRef( fireLoadItems, setFilteringValue, statusType, + collapsibleGroups, + onToggleGroupExpanded: toggleGroupExpanded, }); const handleNativeSearch = useNativeSearch({ isEnabled: filteringType === 'none' && !readOnly, - options: filteredOptions, + options: displayOptions, highlightOption: !isOpen ? selectOption : highlightOption, highlightedOption: !isOpen ? selectedOption : highlightedOption?.option, }); @@ -284,7 +317,7 @@ const InternalSelect = React.forwardRef( renderOption={renderOption} menuProps={menuProps} getOptionProps={getOptionProps} - filteredOptions={filteredOptions} + filteredOptions={displayOptions} filteringValue={filteringValue} ref={scrollToIndex} hasDropdownStatus={dropdownStatus.content !== null} diff --git a/src/select/parts/item.tsx b/src/select/parts/item.tsx index 3fd1be3ba8..d621ef40de 100644 --- a/src/select/parts/item.tsx +++ b/src/select/parts/item.tsx @@ -112,6 +112,8 @@ const Item = ( const isParent = option.type === 'parent'; const isChild = option.type === 'child'; + const collapsible = !!option.collapsible; + const isExpanded = option.expanded; const wrappedOption: OptionDefinition = option.option; const disabled = option.disabled || wrappedOption.disabled; const disabledReason = disabled && wrappedOption.disabledReason ? wrappedOption.disabledReason : ''; @@ -164,6 +166,8 @@ const Item = ( disabled={option.disabled} isParent={isParent} isChild={isChild} + collapsible={collapsible} + isExpanded={isExpanded} ref={useMergeRefs(ref, internalRef)} virtualPosition={virtualPosition} padBottom={padBottom} @@ -178,6 +182,11 @@ const Item = ( {...baseProps} >
+ {isParent && collapsible && ( + + )} {!renderResult && hasCheckbox && !isParent && (
diff --git a/src/select/parts/multiselect-item.tsx b/src/select/parts/multiselect-item.tsx index c6054ce3e9..5aa730c696 100644 --- a/src/select/parts/multiselect-item.tsx +++ b/src/select/parts/multiselect-item.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; +import InternalIcon from '../../icon/internal.js'; import { getBaseProps } from '../../internal/base-component'; import CheckboxIcon from '../../internal/components/checkbox-icon'; import Option from '../../internal/components/option'; @@ -100,6 +101,8 @@ const MultiSelectItem = ( const isParent = option.type === 'parent'; const isChild = option.type === 'child'; const isSelectAll = option.type === 'select-all'; + const collapsible = !!option.collapsible; + const isExpanded = option.expanded; const wrappedOption: OptionDefinition = option.option; const disabled = option.disabled || wrappedOption.disabled; const disabledReason = @@ -170,6 +173,8 @@ const MultiSelectItem = ( isParent={isParent} isChild={isChild} isSelectAll={isSelectAll} + collapsible={collapsible} + isExpanded={isExpanded} highlightType={highlightType} ref={useMergeRefs(ref, internalRef)} virtualPosition={virtualPosition} @@ -186,7 +191,12 @@ const MultiSelectItem = ( {...baseProps} >
- {!renderResult && hasCheckbox && ( + {isParent && collapsible && ( + + )} + {!renderResult && hasCheckbox && !(isParent && collapsible) && (
.expand-icon { + display: flex; + align-items: center; + margin-inline-end: awsui.$space-xxs; + color: awsui.$color-text-dropdown-group-label; + } } .option-group { diff --git a/src/select/utils/use-select.ts b/src/select/utils/use-select.ts index 49aed7077e..c852e58e4b 100644 --- a/src/select/utils/use-select.ts +++ b/src/select/utils/use-select.ts @@ -17,8 +17,9 @@ import { useOpenState } from '../../internal/components/options-list/utils/use-o import { fireNonCancelableEvent } from '../../internal/events'; import useForwardFocus from '../../internal/hooks/forward-focus'; import { usePrevious } from '../../internal/hooks/use-previous'; +import { KeyCode } from '../../internal/keycode'; import { DropdownStatusProps } from '../../types/dropdown-status'; -import { NonCancelableEventHandler } from '../../types/events'; +import { BaseKeyDetail, CancelableEventHandler, NonCancelableEventHandler } from '../../types/events'; import { OptionDefinition, OptionGroup } from '../../types/option'; import { FilterProps } from '../parts/filter'; import { ItemProps } from '../parts/item'; @@ -44,6 +45,8 @@ interface UseSelectProps { isAllSelected?: boolean; isSomeSelected?: boolean; toggleAll?: () => void; + collapsibleGroups?: boolean; + onToggleGroupExpanded?: (option: DropdownOption) => void; } export interface SelectTriggerProps extends ButtonTriggerProps { @@ -67,10 +70,16 @@ export function useSelect({ isAllSelected, isSomeSelected, toggleAll, + collapsibleGroups = false, + onToggleGroupExpanded, }: UseSelectProps) { const interactivityCheck = useInteractiveGroups ? isGroupInteractive : isInteractive; - const isHighlightable = (option?: DropdownOption) => !!option && (useInteractiveGroups || option.type !== 'parent'); + const isCollapsibleParent = (option?: DropdownOption) => + collapsibleGroups && !!option && option.type === 'parent' && !!option.collapsible; + + const isHighlightable = (option?: DropdownOption) => + !!option && (useInteractiveGroups || collapsibleGroups || option.type !== 'parent'); const filterRef = useRef(null); const triggerRef = useRef(null); @@ -128,8 +137,24 @@ export function useSelect({ } }; + const toggleGroupExpanded = (option?: DropdownOption) => { + const optionToToggle = option || highlightedOption; + if (!isCollapsibleParent(optionToToggle) || !onToggleGroupExpanded) { + return false; + } + onToggleGroupExpanded(optionToToggle!); + return true; + }; + const selectOption = (option?: DropdownOption) => { const optionToSelect = option || highlightedOption; + // When groups are collapsible, activating a group header toggles its expanded + // state instead of selecting it. This takes precedence over the interactive + // group (select-all) behaviour used by Multiselect. + if (isCollapsibleParent(optionToSelect)) { + toggleGroupExpanded(optionToSelect); + return; + } if (!optionToSelect || !interactivityCheck(optionToSelect)) { return; } @@ -144,7 +169,10 @@ export function useSelect({ const activeKeyDownHandler = useMenuKeyboard({ goUp: () => { if ( - (!useInteractiveGroups && highlightedOption?.type === 'child' && highlightedIndex === 1) || + (!useInteractiveGroups && + !collapsibleGroups && + highlightedOption?.type === 'child' && + highlightedIndex === 1) || highlightedIndex === 0 ) { goEndWithKeyboard(); @@ -172,6 +200,26 @@ export function useSelect({ preventNativeSpace: !hasFilter || (highlightedOption && highlightType.type === 'keyboard'), }); + // Left/Right arrow keys collapse/expand the highlighted group header, matching the + // disclosure keyboard pattern. Enter/Space (handled via selectOption) also toggles. + const collapsibleKeyDownHandler: CancelableEventHandler = event => { + if (collapsibleGroups && isCollapsibleParent(highlightedOption)) { + if (event.detail.keyCode === KeyCode.right && !highlightedOption!.expanded) { + event.preventDefault(); + toggleGroupExpanded(highlightedOption); + return; + } + if (event.detail.keyCode === KeyCode.left && highlightedOption!.expanded) { + event.preventDefault(); + toggleGroupExpanded(highlightedOption); + return; + } + } + activeKeyDownHandler(event); + }; + + const menuKeyDownHandler = collapsibleGroups ? collapsibleKeyDownHandler : activeKeyDownHandler; + const triggerKeyDownHandler = useTriggerKeyboard({ openDropdown: () => openDropdown(true), goHome: goHomeWithKeyboard, @@ -215,7 +263,7 @@ export function useSelect({ return { ref: filterRef, - onKeyDown: activeKeyDownHandler, + onKeyDown: menuKeyDownHandler, onChange: event => { setFilteringValue(event.detail.value); resetHighlightWithKeyboard(); @@ -249,7 +297,7 @@ export function useSelect({ statusType, }; if (!hasFilter) { - menuProps.onKeyDown = activeKeyDownHandler; + menuProps.onKeyDown = menuKeyDownHandler; menuProps.nativeAttributes = { 'aria-activedescendant': highlightedOptionId, }; From 27064c912def80599442ce96b60569969127a5f7 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 15:57:11 +0000 Subject: [PATCH 2/2] test: regenerate documenter snapshot for select collapsible groups --- .../__snapshots__/documenter.test.ts.snap | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..634629feda 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -19737,6 +19737,18 @@ To use it correctly, define an ID for the element you want to use as label and s "optional": true, "type": "string", }, + { + "description": "When set to \`true\`, option groups (defined via \`OptionGroup\` objects in \`options\`) +become collapsible. The group header renders an expand/collapse control with +\`aria-expanded\`, and collapsing a group hides its child options. All groups are +expanded by default. + +This property is opt-in and backward compatible: when omitted or \`false\`, groups +behave exactly as before.", + "name": "collapsibleGroups", + "optional": true, + "type": "boolean", + }, { "description": "Specifies the ID for the trigger component. It uses an automatically generated ID by default.", "name": "controlId", @@ -26071,6 +26083,18 @@ To use it correctly, define an ID for the element you want to use as label and s "optional": true, "type": "string", }, + { + "description": "When set to \`true\`, option groups (defined via \`OptionGroup\` objects in \`options\`) +become collapsible. The group header renders an expand/collapse control with +\`aria-expanded\`, and collapsing a group hides its child options. All groups are +expanded by default. + +This property is opt-in and backward compatible: when omitted or \`false\`, groups +behave exactly as before.", + "name": "collapsibleGroups", + "optional": true, + "type": "boolean", + }, { "description": "Specifies the ID for the trigger component. It uses an automatically generated ID by default.", "name": "controlId",