Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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<OptionDefinition | OptionGroup>) => flattenOptions(options).flatOptions;
const findByValue = (list: ReadonlyArray<DropdownOption>, value: string | undefined) =>
list.find(item => item.type !== 'parent' && (item.option as OptionDefinition).value === value)!;
const findGroup = (list: ReadonlyArray<DropdownOption>) => list.find(item => item.type === 'parent')!;
const values = (options: ReadonlyArray<OptionDefinition>) => 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 });
});
});
Original file line number Diff line number Diff line change
@@ -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<OptionDefinition | OptionGroup>) => flattenOptions(options).flatOptions;
const findByValue = (list: ReadonlyArray<DropdownOption>, value: string | undefined) =>
list.find(item => (item.option as OptionDefinition).value === value)!;
const findGroup = (list: ReadonlyArray<DropdownOption>) => 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 });
});
});
34 changes: 34 additions & 0 deletions src/internal/components/listbox/selection/interfaces.ts
Original file line number Diff line number Diff line change
@@ -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<OptionDefinition>;

/** 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<OptionDefinition>;
}
91 changes: 91 additions & 0 deletions src/internal/components/listbox/selection/multi-selection.ts
Original file line number Diff line number Diff line change
@@ -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<DropdownOption>;
/**
* 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<DropdownOption>;
/** Whether group headers can be highlighted and chosen. */
interactiveGroups?: boolean;
}

const nonParentOptions = (options: ReadonlyArray<DropdownOption>) =>
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<OptionDefinition>,
{ options, allOptions = options, interactiveGroups = false }: MultiSelectionConfig
): SelectionModel {
const selectedValues = new Set<string | undefined>(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<OptionDefinition>) => {
// 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]);
},
};
}
31 changes: 31 additions & 0 deletions src/internal/components/listbox/selection/single-selection.ts
Original file line number Diff line number Diff line change
@@ -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<OptionDefinition>): 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],
};
}
Loading
Loading