Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions pages/select/collapsible-groups.page.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectProps.Option | SelectProps.OptionGroup> = [
{
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<SelectProps.Option | null>(null);
const [selectedOptions, setSelectedOptions] = React.useState<ReadonlyArray<MultiselectProps.Option>>([]);

return (
<article>
<Box padding="l">
<h1>Select / Multiselect collapsible groups</h1>
<SpaceBetween size="l">
<div>
<Box variant="h2">Select with collapsibleGroups</Box>
<Box margin={{ bottom: 'xxs' }} color="text-label">
<label htmlFor="collapsible-select">Choose item</label>
</Box>
<Select
controlId="collapsible-select"
collapsibleGroups={true}
filteringType="auto"
filteringPlaceholder="Find item"
filteringAriaLabel="Find item"
options={options}
selectedOption={selectedOption}
placeholder="Choose an item"
ariaLabel="Choose item"
selectedAriaLabel="Selected"
onChange={({ detail }) => setSelectedOption(detail.selectedOption)}
/>
</div>

<div>
<Box variant="h2">Multiselect with collapsibleGroups</Box>
<Box margin={{ bottom: 'xxs' }} color="text-label">
<label htmlFor="collapsible-multiselect">Choose items</label>
</Box>
<Multiselect
controlId="collapsible-multiselect"
collapsibleGroups={true}
options={options}
selectedOptions={selectedOptions}
placeholder="Choose items"
ariaLabel="Choose items"
deselectAriaLabel={option => `Remove ${option.label}`}
onChange={({ detail }) => setSelectedOptions(detail.selectedOptions)}
/>
</div>
</SpaceBetween>
</Box>
</article>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/internal/components/option/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export interface DropdownOption {
disabledReason?: string;
option: OptionDefinition | OptionGroup;
afterHeader?: boolean;
// Set on `parent` options when `collapsibleGroups` is enabled. Indicates the
// group header renders an expand/collapse affordance.
collapsible?: boolean;
// Expanded state for a collapsible `parent` option. When `false`, the group's
// child options are omitted from the rendered list.
expanded?: boolean;
}

export interface OptionProps extends BaseComponentProps {
Expand Down
63 changes: 63 additions & 0 deletions src/internal/components/option/utils/collapse-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { SelectProps } from '../../../../select/interfaces';
import { OptionGroup } from '../../../../types/option';
import { generateTestIndexes } from '../../options-list/utils/test-indexes';
import { DropdownOption } from '../interfaces';

/**
* Resolves a stable key for a group so its expanded/collapsed state can be tracked
* across renders. The top-level index within `options` is used because it is stable
* regardless of whether the consumer recreates the options array on every render.
*/
export function getGroupKey(options: SelectProps.Options, group: OptionGroup): number {
return options.indexOf(group);
}

interface ApplyGroupCollapseProps {
options: SelectProps.Options;
filteredOptions: ReadonlyArray<DropdownOption>;
parentMap: Map<DropdownOption, DropdownOption>;
collapsedGroups: ReadonlySet<number>;
}

/**
* Given the flattened + filtered dropdown options, marks each `parent` option as
* collapsible, sets its expanded state, and removes the child options of any
* collapsed group. Test indexes are regenerated so test-utils keep working for the
* visible subset.
*
* The `parent` DropdownOption objects are recreated on every render by
* `flattenOptions`, so mutating them here is safe.
*/
export function applyGroupCollapse({
options,
filteredOptions,
parentMap,
collapsedGroups,
}: ApplyGroupCollapseProps): DropdownOption[] {
const result: DropdownOption[] = [];
let currentGroupCollapsed = false;

for (const option of filteredOptions) {
if (option.type === 'parent') {
const groupIndex = getGroupKey(options, option.option as OptionGroup);
const collapsed = groupIndex !== -1 && collapsedGroups.has(groupIndex);
currentGroupCollapsed = collapsed;
option.collapsible = true;
option.expanded = !collapsed;
result.push(option);
} else if (option.type === 'child') {
if (!currentGroupCollapsed) {
result.push(option);
}
} else {
currentGroupCollapsed = false;
result.push(option);
}
}

generateTestIndexes(result, parentMap.get.bind(parentMap));

return result;
}
9 changes: 8 additions & 1 deletion src/internal/components/selectable-item/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const SelectableItem = (
sticky,
afterHeader,
withScrollbar,
collapsible,
isExpanded,
...restProps
}: SelectableItemProps,
ref: React.Ref<HTMLDivElement>
Expand Down Expand Up @@ -100,12 +102,17 @@ const SelectableItem = (

const a11yProperties: Record<string, string | number | boolean | undefined> = {};

if (isParent && ariaChecked === undefined) {
if (isParent && ariaChecked === undefined && !collapsible) {
a11yProperties.role = 'presentation';
} else {
a11yProperties.role = 'option';
a11yProperties['aria-disabled'] = disabled;

// Collapsible group headers act as disclosure controls, so expose their expanded state.
if (collapsible) {
a11yProperties['aria-expanded'] = !!isExpanded;
}

if (ariaSelected !== undefined) {
a11yProperties['aria-selected'] = ariaSelected;
}
Expand Down
4 changes: 4 additions & 0 deletions src/internal/components/selectable-item/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export type SelectableItemProps = BaseComponentProps & {
withScrollbar?: boolean;
ariaSelected?: boolean | never;
ariaChecked?: boolean | 'mixed' | never;
// When true, the item is a collapsible group header and exposes `aria-expanded`.
collapsible?: boolean;
// Expanded state of a collapsible group header.
isExpanded?: boolean;
};

export interface ItemDataAttributes {
Expand Down
80 changes: 80 additions & 0 deletions src/multiselect/__tests__/collapsible-groups.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { render } from '@testing-library/react';

import Multiselect, { MultiselectProps } from '../../../lib/components/multiselect';
import createWrapper from '../../../lib/components/test-utils/dom';

const options: MultiselectProps.Options = [
{
label: 'Group A',
options: [
{ label: 'A1', value: 'a1' },
{ label: 'A2', value: 'a2' },
],
},
{
label: 'Group B',
options: [
{ label: 'B1', value: 'b1' },
{ label: 'B2', value: 'b2' },
],
},
];

const defaultProps: MultiselectProps = {
options,
selectedOptions: [],
onChange: () => {},
};

function renderMultiselect(props?: Partial<MultiselectProps>) {
const onChange = jest.fn();
const { container } = render(<Multiselect {...defaultProps} onChange={onChange} {...props} />);
const wrapper = createWrapper(container).findMultiselect()!;
return { wrapper, onChange };
}

describe('Multiselect collapsibleGroups', () => {
test('does not add aria-expanded to group headers by default', () => {
const { wrapper } = renderMultiselect();
wrapper.openDropdown();
expect(wrapper.findDropdown()!.findGroup(1)!.getElement()).not.toHaveAttribute('aria-expanded');
});

test('renders group headers as expanded disclosure controls when enabled', () => {
const { wrapper } = renderMultiselect({ collapsibleGroups: true });
wrapper.openDropdown();
const dropdown = wrapper.findDropdown()!;
expect(dropdown.findGroup(1)!.getElement()).toHaveAttribute('aria-expanded', 'true');
expect(dropdown.findOptionInGroup(1, 1)!.getElement()).toHaveTextContent('A1');
});

test('collapses a group and hides its children when the header is activated', () => {
const { wrapper, onChange } = renderMultiselect({ collapsibleGroups: true });
wrapper.openDropdown();
wrapper
.findDropdown()!
.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();
// toggling collapse must not change selection (does not select-all the group)
expect(onChange).not.toHaveBeenCalled();
});

test('individual options remain selectable when groups are collapsible', () => {
const { wrapper, onChange } = renderMultiselect({ collapsibleGroups: true });
wrapper.openDropdown();
wrapper
.findDropdown()!
.findOptionInGroup(2, 1)!
.fireEvent(new MouseEvent('mouseup', { bubbles: true }));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
detail: expect.objectContaining({ selectedOptions: [{ label: 'B1', value: 'b1' }] }),
})
);
});
});
2 changes: 2 additions & 0 deletions src/multiselect/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const InternalMultiselect = React.forwardRef(
__internalRootRef,
autoFocus,
enableSelectAll,
collapsibleGroups,
renderOption,
...restProps
}: InternalMultiselectProps,
Expand Down Expand Up @@ -90,6 +91,7 @@ const InternalMultiselect = React.forwardRef(
setFilteringValue,
externalRef,
enableSelectAll,
collapsibleGroups,
i18nStrings,
...restProps,
});
Expand Down
Loading
Loading