Skip to content
Open
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
4 changes: 2 additions & 2 deletions pages/dropdown/list-with-sticky-item.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import React, { useContext, useRef, useState } from 'react';
import Box from '~components/box';
import Button from '~components/button';
import Dropdown from '~components/dropdown';
import PlainList from '~components/internal/components/listbox/parts/plain-list';
import { DropdownOption } from '~components/internal/components/option/interfaces';
import PlainList from '~components/select/parts/plain-list';
import VirtualList from '~components/select/parts/virtual-list';
import SpaceBetween from '~components/space-between';

Expand Down Expand Up @@ -95,7 +95,7 @@ export default function MultiselectPage() {
filteringValue={''}
firstOptionSticky={true}
highlightType={{ type: 'mouse', moveFocus: false }}
useInteractiveGroups={true}
selectionType="multi"
/>
}
/>
Expand Down
11 changes: 5 additions & 6 deletions pages/select/item.permutations.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
import React from 'react';

import Item, { ItemProps } from '~components/internal/components/listbox/parts/item';
import { DropdownOption } from '~components/internal/components/option/interfaces';
import Item from '~components/select/parts/item';
import { ItemProps } from '~components/select/parts/item';

import createPermutations from '../utils/permutations';
import PermutationsView from '../utils/permutations-view';
Expand Down Expand Up @@ -100,7 +99,7 @@ const permutations = createPermutations<Omit<ItemProps, 'index'>>([
highlighted: [false, true],
selected: [false, true],
highlightType: ['keyboard'],
hasCheckbox: [true],
selectionType: ['multi'],
},
{
option: [options.complexOption],
Expand All @@ -126,21 +125,21 @@ const permutations = createPermutations<Omit<ItemProps, 'index'>>([
},
{
option: [options.complexDisabledOptionWithCustomSvg],
hasCheckbox: [true],
selectionType: ['multi'],
},
{
option: [options.simpleOption, options.complexOption],
highlighted: [false, true],
highlightType: ['keyboard'],
selected: [true],
hasCheckbox: [false],
selectionType: ['single'],
},
{
option: [options.complexOptionGroup],
highlighted: [false],
highlightType: ['keyboard'],
selected: [true],
hasCheckbox: [false, true],
selectionType: ['single', 'multi'],
},
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ const defaultItemProps = {
index: 1,
getOptionProps: getOptionProps(false, false),
filteringValue: 'child',
checkboxes: false,
};

describe('getItemProps', () => {
Expand All @@ -68,29 +67,6 @@ describe('getItemProps', () => {
'data-test-index': 1,
'data-mouse-target': 1,
filteringValue: 'child',
hasCheckbox: false,
highlighted: false,
key: 1,
option: {
option: { label: 'Child 1' },
type: 'child',
},
selected: false,
});
});

test('should return props for the first child in the first group with checkboxes and keyboard', () => {
const props = getItemProps({
...defaultItemProps,
checkboxes: true,
});
expect(props).toEqual({
'data-child-index': 1,
'data-group-index': 1,
'data-test-index': 1,
'data-mouse-target': 1,
filteringValue: 'child',
hasCheckbox: true,
highlighted: false,
key: 1,
option: {
Expand All @@ -112,7 +88,6 @@ describe('getItemProps', () => {
'data-test-index': 1,
'data-mouse-target': 1,
filteringValue: 'child',
hasCheckbox: false,
highlighted: true,
key: 1,
option: {
Expand All @@ -136,7 +111,6 @@ describe('getItemProps', () => {
'data-test-index': 1,
'data-mouse-target': 1,
filteringValue: 'child',
hasCheckbox: false,
highlighted: false,
key: 1,
option: {
Expand All @@ -159,7 +133,6 @@ describe('getItemProps', () => {
'data-mouse-target': 3,
'data-test-index': 3,
filteringValue: 'child',
hasCheckbox: false,
highlighted: false,
key: 3,
option: {
Expand Down
83 changes: 83 additions & 0 deletions src/internal/components/listbox/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// 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 { getListboxNavigation } from '../utils/navigation';

const flatten = (options: ReadonlyArray<OptionDefinition | OptionGroup>) => flattenOptions(options).flatOptions;

const flat = flatten([{ value: '1' }, { value: '2' }, { value: '3' }]);
// [header A, a1, a2, header B, b1]
const grouped = flatten([
{ label: 'A', options: [{ value: 'a1' }, { value: 'a2' }] },
{ label: 'B', options: [{ value: 'b1' }] },
]);
// [1, header Empty]
const endsWithEmptyGroup = flatten([{ value: '1' }, { label: 'Empty', options: [] }]);

function setup(options: ReadonlyArray<DropdownOption>, highlightedIndex: number, interactiveGroups = false) {
const moveHighlight = jest.fn();
const goHome = jest.fn();
const goEnd = jest.fn();
const navigation = getListboxNavigation({
options,
highlightedIndex,
highlightedOption: options[highlightedIndex],
interactiveGroups,
moveHighlight,
goHome,
goEnd,
});
return { ...navigation, moveHighlight, goHome, goEnd };
}

describe('getListboxNavigation', () => {
test('moves the highlight by one option', () => {
const { goUp, goDown, moveHighlight } = setup(flat, 1);
goDown();
expect(moveHighlight).toHaveBeenLastCalledWith(1);
goUp();
expect(moveHighlight).toHaveBeenLastCalledWith(-1);
});

test('wraps from the first option to the end and from the last option to the start', () => {
const first = setup(flat, 0);
first.goUp();
expect(first.goEnd).toHaveBeenCalledTimes(1);
expect(first.moveHighlight).not.toHaveBeenCalled();

const last = setup(flat, 2);
last.goDown();
expect(last.goHome).toHaveBeenCalledTimes(1);
expect(last.moveHighlight).not.toHaveBeenCalled();
});

test('wraps up from the first child of a leading group when groups are not interactive', () => {
const { goUp, goEnd, moveHighlight } = setup(grouped, 1);
goUp();
expect(goEnd).toHaveBeenCalledTimes(1);
expect(moveHighlight).not.toHaveBeenCalled();
});

test('moves up to the leading group header when groups are interactive', () => {
const { goUp, goEnd, moveHighlight } = setup(grouped, 1, true);
goUp();
expect(goEnd).not.toHaveBeenCalled();
expect(moveHighlight).toHaveBeenCalledWith(-1);
});

test('wraps down only from the last row, even when it is not highlightable', () => {
const { goDown, goHome, moveHighlight } = setup(endsWithEmptyGroup, 0);
goDown();
expect(goHome).not.toHaveBeenCalled();
expect(moveHighlight).toHaveBeenCalledWith(1);
});

test('moves down from no highlight', () => {
const { goDown, goHome, moveHighlight } = setup(flat, -1);
goDown();
expect(goHome).not.toHaveBeenCalled();
expect(moveHighlight).toHaveBeenCalledWith(1);
});
});
168 changes: 168 additions & 0 deletions src/internal/components/listbox/__tests__/option-props.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// 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 { getOptionId } from '../../options-list/utils/use-ids';
import { multiSelection } from '../selection/multi-selection';
import { singleSelection } from '../selection/single-selection';
import { connectOptionsByValue } from '../utils/connect-options';
import {
getListboxOptionProps,
getListMouseHandlers,
getSelectableOption,
isAnnouncedSelected,
isHighlightable,
} from '../utils/option-props';

const flatten = (options: ReadonlyArray<OptionDefinition | OptionGroup>) => flattenOptions(options).flatOptions;
const selectedSetOf = (options: ReadonlyArray<DropdownOption>, selected: ReadonlyArray<OptionDefinition>) =>
new Set(connectOptionsByValue(options, selected));

describe('isHighlightable', () => {
test('group headers are highlightable only with interactive groups', () => {
const header: DropdownOption = { type: 'parent', option: { label: 'Group', options: [] } };
expect(isHighlightable(header, false)).toBe(false);
expect(isHighlightable(header, true)).toBe(true);
expect(isHighlightable({ option: { value: '1' } }, false)).toBe(true);
expect(isHighlightable(undefined, true)).toBe(false);
});
});

describe('getListboxOptionProps', () => {
test('single selection marks the selected option and highlight', () => {
const options = flatten([{ value: '1' }, { value: '2' }]);
const selected = [{ value: '2' }];
const context = {
options,
selectedSet: selectedSetOf(options, selected),
selection: singleSelection(selected),
highlightedOption: options[0],
interactiveGroups: false,
listId: 'list',
};
expect(getListboxOptionProps(options[0], 0, context)).toEqual({
key: 0,
option: options[0],
highlighted: true,
selected: false,
isNextSelected: true,
isPreviousSelected: false,
indeterminate: false,
'data-mouse-target': 0,
id: getOptionId('list', 0),
});
expect(getListboxOptionProps(options[1], 1, context)).toMatchObject({ highlighted: false, selected: true });
});

test('multi selection uses group state for headers and group neighbours', () => {
const options = flatten([
{ label: 'A', options: [{ value: 'a1' }, { value: 'a2' }] },
{ label: 'B', options: [{ value: 'b1' }] },
]);
const selected = [{ value: 'a1' }, { value: 'b1' }];
const context = {
options,
selectedSet: selectedSetOf(options, selected),
selection: multiSelection(selected, { options, interactiveGroups: true }),
highlightedOption: undefined,
interactiveGroups: true,
listId: 'list',
};
// [A, a1, a2, B, b1]
expect(getListboxOptionProps(options[0], 0, context)).toMatchObject({ selected: false, indeterminate: true });
expect(getListboxOptionProps(options[3], 3, context)).toMatchObject({ selected: true, indeterminate: false });
// a2's next neighbour is the fully selected group B; its previous neighbour a1 is selected.
expect(getListboxOptionProps(options[2], 2, context)).toMatchObject({
selected: false,
isNextSelected: true,
isPreviousSelected: true,
});
});

test('select-all gets group state but never counts as a selected neighbour', () => {
const items = flatten([{ value: '1' }]);
const selectAll: DropdownOption = { type: 'select-all', option: { label: 'Select all' } };
const options = [selectAll, ...items];
const selected = [{ value: '1' }];
const context = {
options,
selectedSet: selectedSetOf(items, selected),
selection: multiSelection(selected, { options: items }),
highlightedOption: undefined,
interactiveGroups: true,
listId: 'list',
};
expect(getListboxOptionProps(selectAll, 0, context)).toMatchObject({ selected: true, isNextSelected: true });
expect(getListboxOptionProps(options[1], 1, context)).toMatchObject({ selected: true, isPreviousSelected: false });
});

test('non-highlightable group headers get data-mouse-target -1', () => {
const options = flatten([{ label: 'A', options: [{ value: 'a1' }] }]);
const context = {
options,
selectedSet: new Set<DropdownOption>(),
selection: singleSelection([]),
highlightedOption: undefined,
interactiveGroups: false,
listId: 'list',
};
expect(getListboxOptionProps(options[0], 0, context)['data-mouse-target']).toBe(-1);
expect(getListboxOptionProps(options[1], 1, context)['data-mouse-target']).toBe(1);
});
});

describe('isAnnouncedSelected', () => {
test('announces selected options and fully selected groups', () => {
const options = flatten([{ label: 'A', options: [{ value: 'a1' }] }, { value: '2' }]);
const selected = [{ value: 'a1' }];
const selectedSet = selectedSetOf(options, selected);
const selection = multiSelection(selected, { options, interactiveGroups: true });
expect(isAnnouncedSelected(undefined, selectedSet, selection)).toBe(false);
expect(isAnnouncedSelected(options[0], selectedSet, selection)).toBe(true);
expect(isAnnouncedSelected(options[1], selectedSet, selection)).toBe(true);
expect(isAnnouncedSelected(options[2], selectedSet, selection)).toBe(false);
});
});

describe('getSelectableOption', () => {
const isEnabled = (option: DropdownOption) => !option.option.disabled;
const first: DropdownOption = { option: { value: '1' } };
const highlighted: DropdownOption = { option: { value: '2' } };
const disabled: DropdownOption = { option: { value: '3', disabled: true } };

test('returns the given option, or the highlighted option when omitted', () => {
expect(getSelectableOption(first, highlighted, isEnabled)).toBe(first);
expect(getSelectableOption(undefined, highlighted, isEnabled)).toBe(highlighted);
});

test('returns undefined when there is nothing to choose or it is not interactive', () => {
expect(getSelectableOption(undefined, undefined, isEnabled)).toBeUndefined();
expect(getSelectableOption(disabled, highlighted, isEnabled)).toBeUndefined();
expect(getSelectableOption(undefined, disabled, isEnabled)).toBeUndefined();
});
});

describe('getListMouseHandlers', () => {
const options = flatten([{ value: '1' }, { value: '2' }]);

test('selects and highlights the option under the pointer', () => {
const selectOption = jest.fn();
const setHighlightedIndexWithMouse = jest.fn();
const handlers = getListMouseHandlers({ options, selectOption, setHighlightedIndexWithMouse });
handlers.onMouseUp(1);
handlers.onMouseMove(0);
expect(selectOption).toHaveBeenCalledWith(options[1]);
expect(setHighlightedIndexWithMouse).toHaveBeenCalledWith(0);
});

test('ignores pointer events outside a highlightable option', () => {
const selectOption = jest.fn();
const setHighlightedIndexWithMouse = jest.fn();
const handlers = getListMouseHandlers({ options, selectOption, setHighlightedIndexWithMouse });
handlers.onMouseUp(-1);
handlers.onMouseMove(-1);
expect(selectOption).not.toHaveBeenCalled();
expect(setHighlightedIndexWithMouse).not.toHaveBeenCalled();
});
});
Loading
Loading