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
161 changes: 154 additions & 7 deletions pages/option-list/simple.page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';
import React, { useContext, useRef, useState } from 'react';

import Box from '~components/box';
import Button from '~components/button';
import FormField from '~components/form-field';
import Header from '~components/header';
import Icon from '~components/icon';
import OptionList, { OptionListProps } from '~components/option-list';
import SegmentedControl from '~components/segmented-control';
import SpaceBetween from '~components/space-between';

import AppContext, { AppContextType } from '../app/app-context';
import { SimplePage } from '../app/templates';

/**
* Options selected when the page loads:
* - `first` and `last`: the first or last option of each list.
* - `two`: the second and fourth options, given in reverse order so selection order differs from list order.
* Multi selection only; single-selection lists start empty.
*/
type Preselected = 'none' | 'first' | 'last' | 'two';

type PageContext = React.Context<AppContextType<{ preselected?: Preselected }>>;

const goals: OptionListProps.Option[] = [
{
value: 'cost',
Expand Down Expand Up @@ -41,24 +55,90 @@ const services: OptionListProps.Options = [
},
];

/**
* Labels share first letters within and across groups, so repeated letters cycle through matches, and a
* child's first letter differs from its group's (typing `e` should reach "EC2", not "Compute").
*/
const typeAheadServices: OptionListProps.Options = [
{
value: 'compute',
label: 'Compute',
options: [
{ value: 'ec2', label: 'EC2' },
{ value: 'elastic-beanstalk', label: 'Elastic Beanstalk' },
{ value: 'lambda', label: 'Lambda' },
],
},
{
value: 'containers',
label: 'Containers',
options: [
{ value: 'ecs', label: 'ECS' },
{ value: 'eks', label: 'EKS' },
{ value: 'fargate', label: 'Fargate' },
],
},
{
value: 'storage',
label: 'Storage',
options: [
{ value: 's3', label: 'S3' },
{ value: 'efs', label: 'EFS' },
{ value: 'glacier', label: 'Glacier' },
],
},
];

const groupKey: keyof OptionListProps.OptionGroup = 'options';

const leafOptions = (options: OptionListProps.Options): OptionListProps.Option[] =>
options.flatMap(option => (groupKey in option ? [...(option as OptionListProps.OptionGroup).options] : [option]));

function initialSelection(
options: OptionListProps.Options,
selectionType: OptionListProps.SelectionType,
preselected: Preselected
): ReadonlyArray<OptionListProps.Option> {
const leaves = leafOptions(options);
switch (preselected) {
case 'first':
return [leaves[0]];
case 'last':
return [leaves[leaves.length - 1]];
case 'two':
return selectionType === 'multi' ? [leaves[3], leaves[1]] : [];
default:
return [];
}
}

function Section({
id,
title,
description,
options,
selectionType,
preselected,
listRef,
}: {
id: string;
title: string;
description?: string;
options: OptionListProps.Options;
selectionType: OptionListProps.SelectionType;
preselected: Preselected;
listRef: React.RefObject<OptionListProps.Ref>;
}) {
const [selectedOptions, setSelectedOptions] = useState<ReadonlyArray<OptionListProps.Option>>([]);
const [selectedOptions, setSelectedOptions] = useState(() => initialSelection(options, selectionType, preselected));
return (
<SpaceBetween size="xs">
{/* Header's `id` is on its root element, so a header description would become part of the list's name. */}
<Header variant="h2" id={`${id}-heading`}>
{title}
</Header>
{description && <Box variant="p">{description}</Box>}
<OptionList
ref={listRef}
data-testid={id}
ariaLabelledby={`${id}-heading`}
options={options}
Expand All @@ -74,13 +154,80 @@ function Section({
}

export default function OptionListSimplePage() {
const { urlParams, setUrlParams } = useContext(AppContext as PageContext);
const preselected = urlParams.preselected ?? 'none';

const singleRef = useRef<OptionListProps.Ref>(null);
const multiRef = useRef<OptionListProps.Ref>(null);
const groupsRef = useRef<OptionListProps.Ref>(null);
const typeAheadRef = useRef<OptionListProps.Ref>(null);

return (
<SimplePage title="Option list" screenshotArea={{}}>
<SimplePage
title="Option list"
settings={
<SpaceBetween size="m">
<FormField label="Already selected">
<SegmentedControl
selectedId={preselected}
onChange={({ detail }) => setUrlParams({ preselected: detail.selectedId as Preselected })}
options={[
{ id: 'none', text: 'None' },
{ id: 'first', text: 'First' },
{ id: 'last', text: 'Last' },
{ id: 'two', text: 'Second and fourth (multi only)' },
]}
/>
</FormField>
{/* Moves focus with `ref.focus()`, the way a builder's own dropdown does when it opens. */}
<FormField label="Move focus into a list">
<SpaceBetween direction="horizontal" size="xs">
<Button onClick={() => singleRef.current?.focus()}>Focus single selection</Button>
<Button onClick={() => multiRef.current?.focus()}>Focus multi selection</Button>
<Button onClick={() => groupsRef.current?.focus()}>Focus multi selection with groups</Button>
<Button onClick={() => typeAheadRef.current?.focus()}>Focus type-ahead</Button>
</SpaceBetween>
</FormField>
</SpaceBetween>
}
screenshotArea={{}}
>
<div style={{ maxInlineSize: 520 }}>
<SpaceBetween size="xl">
<Section id="single" title="Single selection" options={goals} selectionType="single" />
<Section id="multi" title="Multi selection" options={goals} selectionType="multi" />
<Section id="groups" title="Multi selection with groups" options={services} selectionType="multi" />
{/* Remount when the setting changes so the initial selection follows it. */}
<SpaceBetween size="xl" key={preselected}>
<Section
id="single"
title="Single selection"
options={goals}
selectionType="single"
preselected={preselected}
listRef={singleRef}
/>
<Section
id="multi"
title="Multi selection"
options={goals}
selectionType="multi"
preselected={preselected}
listRef={multiRef}
/>
<Section
id="groups"
title="Multi selection with groups"
options={services}
selectionType="multi"
preselected={preselected}
listRef={groupsRef}
/>
<Section
id="type-ahead"
title="Type-ahead"
description="Type letters to move the highlight. Wait about a second between searches."
options={typeAheadServices}
selectionType="multi"
preselected={preselected}
listRef={typeAheadRef}
/>
</SpaceBetween>
</div>
</SimplePage>
Expand Down
19 changes: 17 additions & 2 deletions src/internal/components/listbox/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const grouped = flatten([
]);
// [1, header Empty]
const endsWithEmptyGroup = flatten([{ value: '1' }, { label: 'Empty', options: [] }]);
// [header Empty, 1]
const startsWithEmptyGroup = flatten([{ label: 'Empty', options: [] }, { value: '1' }]);

function setup(options: ReadonlyArray<DropdownOption>, highlightedIndex: number, interactiveGroups = false) {
const moveHighlight = jest.fn();
Expand All @@ -23,7 +25,6 @@ function setup(options: ReadonlyArray<DropdownOption>, highlightedIndex: number,
const navigation = getListboxNavigation({
options,
highlightedIndex,
highlightedOption: options[highlightedIndex],
interactiveGroups,
moveHighlight,
goHome,
Expand Down Expand Up @@ -67,9 +68,23 @@ describe('getListboxNavigation', () => {
expect(moveHighlight).toHaveBeenCalledWith(-1);
});

test('wraps down only from the last row, even when it is not highlightable', () => {
test('wraps down from the last highlightable option when the list ends with an empty group', () => {
const { goDown, goHome, moveHighlight } = setup(endsWithEmptyGroup, 0);
goDown();
expect(goHome).toHaveBeenCalledTimes(1);
expect(moveHighlight).not.toHaveBeenCalled();
});

test('wraps up from the first highlightable option when the list starts with an empty group', () => {
const { goUp, goEnd, moveHighlight } = setup(startsWithEmptyGroup, 1);
goUp();
expect(goEnd).toHaveBeenCalledTimes(1);
expect(moveHighlight).not.toHaveBeenCalled();
});

test('moves down to a trailing empty group header when groups are interactive', () => {
const { goDown, goHome, moveHighlight } = setup(endsWithEmptyGroup, 0, true);
goDown();
expect(goHome).not.toHaveBeenCalled();
expect(moveHighlight).toHaveBeenCalledWith(1);
});
Expand Down
103 changes: 103 additions & 0 deletions src/internal/components/listbox/__tests__/use-type-ahead.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,107 @@ describe('useTypeAhead', () => {

expect(highlightOption).not.toHaveBeenCalled();
});

test('ignores keys that were already handled by another handler', () => {
const hook = renderHook(useTypeAhead, { initialProps });
act(() => {
hook.result.current({ key: ' ', defaultPrevented: true } as React.KeyboardEvent);
});
expect(highlightOption).not.toHaveBeenCalled();
act(() => {
hook.result.current({ key: 'o' } as React.KeyboardEvent);
});
expect(highlightOption).toHaveBeenCalledWith(options[0]);
});

test('cycles from a highlighted option that is an equal copy rather than the same object', () => {
const hook = renderHook(useTypeAhead, {
initialProps: { ...initialProps, highlightedOption: { label: 'option 1', value: 'option1' } },
});
act(() => {
hook.result.current({ key: 'o' } as React.KeyboardEvent);
});
act(() => {
hook.result.current({ key: 'o' } as React.KeyboardEvent);
});
expect(highlightOption).toHaveBeenLastCalledWith(options[1]);
});

describe('with groups', () => {
const groupedOptions: DropdownOption[] = [
{ type: 'parent', option: { label: 'Compute', options: [] } },
{ type: 'child', option: { label: 'EC2', value: 'ec2' } },
{ type: 'child', option: { label: 'Lambda', value: 'lambda' } },
{ type: 'child', option: { label: 'Fargate', value: 'fargate' } },
{ type: 'parent', option: { label: 'Storage', options: [] } },
{ type: 'child', option: { label: 'S3', value: 's3' } },
];
const renderGroupedHook = (highlightedOption: DropdownOption['option'] | null = null) =>
renderHook(useTypeAhead, {
initialProps: { ...initialProps, options: groupedOptions, highlightedOption, interactiveGroups: true },
});

test('with interactive groups, highlights the matching child rather than its non-matching group', () => {
const hook = renderGroupedHook();
act(() => {
hook.result.current({ key: 'e' } as React.KeyboardEvent);
});
expect(highlightOption).toHaveBeenCalledTimes(1);
expect(highlightOption).toHaveBeenCalledWith(groupedOptions[1]);
});

test('with interactive groups, highlights a group whose own label matches', () => {
const hook = renderGroupedHook();
act(() => {
hook.result.current({ key: 'c' } as React.KeyboardEvent);
});
expect(highlightOption).toHaveBeenCalledWith(groupedOptions[0]);
});

test('with interactive groups, does not cycle into children of a matching group when their own labels do not match', () => {
const hook = renderGroupedHook(groupedOptions[0].option);
act(() => {
hook.result.current({ key: 'c' } as React.KeyboardEvent);
});
act(() => {
hook.result.current({ key: 'c' } as React.KeyboardEvent);
});
// Only the Compute group itself starts with "c", so repeating the key keeps it highlighted.
expect(highlightOption).toHaveBeenCalledTimes(2);
expect(highlightOption).toHaveBeenLastCalledWith(groupedOptions[0]);
});

test('with interactive groups, cycles from the highlighted option when its group has the same value', () => {
const sharedValueOptions: DropdownOption[] = [
{ type: 'parent', option: { label: 'Compute', value: 'shared', options: [] } },
{ type: 'child', option: { label: 'Cloud', value: 'shared' } },
{ type: 'child', option: { label: 'Cache', value: 'cache' } },
];
const hook = renderHook(useTypeAhead, {
initialProps: {
...initialProps,
options: sharedValueOptions,
highlightedOption: sharedValueOptions[1].option,
interactiveGroups: true,
},
});
act(() => {
hook.result.current({ key: 'c' } as React.KeyboardEvent);
});
act(() => {
hook.result.current({ key: 'c' } as React.KeyboardEvent);
});
expect(highlightOption).toHaveBeenLastCalledWith(sharedValueOptions[2]);
});

test('without interactive groups, does not highlight children of a group whose label matches', () => {
const hook = renderHook(useTypeAhead, {
initialProps: { ...initialProps, options: groupedOptions },
});
act(() => {
hook.result.current({ key: 'c' } as React.KeyboardEvent);
});
expect(highlightOption).not.toHaveBeenCalled();
});
});
});
9 changes: 5 additions & 4 deletions src/internal/components/listbox/parts/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ const toSelectOptionItem = (props: {
index: props.parentProps?.index,
virtualIndex: props.parentProps?.virtualIndex,
option: props.parentProps?.option,
disabled: props.disabled,
disabled: props.parentProps.disabled,
})
: null,
};
Expand Down Expand Up @@ -137,7 +137,7 @@ const toMultiselectOptionItem = (props: {
index: props.parentProps?.index,
virtualIndex: props.parentProps?.virtualIndex,
option: props.parentProps?.option,
disabled: props.disabled,
disabled: props.parentProps.disabled,
highlighted: props.parentProps?.highlighted ?? false,
indeterminate: props.parentProps?.indeterminate ?? false,
selected: props.parentProps?.selected ?? false,
Expand Down Expand Up @@ -273,10 +273,11 @@ const Item = (
/>
);

const disabledReasonElement = !renderResult && isDisabledWithReason && (
const disabledReasonElement = isDisabledWithReason && (
<>
{/* Stays mounted with custom content, so aria-describedby always points to an element. */}
{descriptionEl}
{highlighted && canShowTooltip && (
{!renderResult && highlighted && canShowTooltip && (
<Tooltip
className={styles['disabled-reason-tooltip']}
getTrack={() => internalRef.current}
Expand Down
1 change: 0 additions & 1 deletion src/internal/components/listbox/use-listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ export function useListbox({
...getListboxNavigation({
options,
highlightedIndex,
highlightedOption,
interactiveGroups,
moveHighlight,
goHome,
Expand Down
Loading
Loading