Skip to content

Commit 974b177

Browse files
committed
fix: Let type-ahead reach disabled options in Select, Multiselect and OptionList
Up and Down reach disabled options, so their disabled state and reason are announced, but type-ahead skipped them. Type-ahead now matches the same options as Up and Down, including disabled ones. Choosing a disabled option is still ignored. When the Select dropdown is closed, type-ahead selects the match instead of highlighting it, so it keeps skipping disabled options there (skipDisabled), as a native select does.
1 parent 917736c commit 974b177

5 files changed

Lines changed: 83 additions & 33 deletions

File tree

‎src/internal/components/listbox/__tests__/use-type-ahead.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,31 @@ describe('useTypeAhead', () => {
6161

6262
expect(highlightOption).not.toHaveBeenCalled();
6363
});
64+
65+
describe('disabled options', () => {
66+
// "beta" is disabled, so "b" matches it first, then "bravo".
67+
const withDisabled: DropdownOption[] = [
68+
{ option: { label: 'alpha', value: 'alpha' } },
69+
{ option: { label: 'beta', value: 'beta' }, disabled: true },
70+
{ option: { label: 'bravo', value: 'bravo' } },
71+
];
72+
73+
test('reaches a disabled option, like Up and Down', () => {
74+
const highlightOption = jest.fn();
75+
const hook = renderHook(useTypeAhead, {
76+
initialProps: { ...initialProps, options: withDisabled, highlightOption },
77+
});
78+
act(() => hook.result.current({ key: 'b' } as React.KeyboardEvent));
79+
expect(highlightOption).toHaveBeenCalledWith(withDisabled[1]);
80+
});
81+
82+
test('skips a disabled option with skipDisabled', () => {
83+
const highlightOption = jest.fn();
84+
const hook = renderHook(useTypeAhead, {
85+
initialProps: { ...initialProps, options: withDisabled, highlightOption, skipDisabled: true },
86+
});
87+
act(() => hook.result.current({ key: 'b' } as React.KeyboardEvent));
88+
expect(highlightOption).toHaveBeenCalledWith(withDisabled[2]);
89+
});
90+
});
6491
});

‎src/internal/components/listbox/use-type-ahead.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import React, { useRef } from 'react';
55
import type { OptionDefinition, OptionGroup } from '../../../types/option';
66
import { useDebounceCallback } from '../../hooks/use-debounce-callback';
77
import type { DropdownOption } from '../option/interfaces';
8-
import { filterOptions, isGroup, isGroupInteractive, isInteractive } from '../option/utils/filter-options';
8+
import { filterOptions, isGroup } from '../option/utils/filter-options';
9+
import { isHighlightable } from './utils/option-props';
910

1011
const isRepeatedChar = (str: string) => str.split('').every(c => c === str[0]);
1112

@@ -15,17 +16,23 @@ interface UseTypeAheadProps {
1516
highlightOption: (option: DropdownOption) => void;
1617
highlightedOption: OptionDefinition | undefined | null;
1718
interactiveGroups?: boolean;
19+
/**
20+
* Skips disabled options. By default type-ahead reaches disabled options, like Up and Down, so their disabled
21+
* state and reason are announced. Use it when type-ahead selects the match instead of highlighting it.
22+
*/
23+
skipDisabled?: boolean;
1824
}
1925

2026
function findMatchingOption(
2127
options: ReadonlyArray<DropdownOption>,
2228
searchText: string,
2329
currentHighlight: OptionDefinition | OptionGroup | undefined | null,
24-
interactiveGroups?: boolean
30+
interactiveGroups = false,
31+
skipDisabled = false
2532
) {
26-
const interactivityCheck = interactiveGroups ? isGroupInteractive : isInteractive;
27-
const filter = (searchText: string) =>
28-
filterOptions(options, searchText, true).filter(option => interactivityCheck(option));
33+
const isMatchable = (option: DropdownOption) =>
34+
isHighlightable(option, interactiveGroups) && !(skipDisabled && option.disabled);
35+
const filter = (searchText: string) => filterOptions(options, searchText, true).filter(isMatchable);
2936
const matchingOptions = filter(searchText);
3037

3138
if (matchingOptions.length === 1) {
@@ -66,6 +73,7 @@ export function useTypeAhead({
6673
highlightOption,
6774
highlightedOption,
6875
interactiveGroups,
76+
skipDisabled,
6977
}: UseTypeAheadProps) {
7078
const value = useRef('');
7179

@@ -86,7 +94,7 @@ export function useTypeAhead({
8694
const newValue = value.current + key;
8795
value.current = newValue;
8896

89-
const nextHighlight = findMatchingOption(options, newValue, highlightedOption, interactiveGroups);
97+
const nextHighlight = findMatchingOption(options, newValue, highlightedOption, interactiveGroups, skipDisabled);
9098
if (nextHighlight) {
9199
highlightOption(nextHighlight);
92100
}

‎src/option-list/__tests__/keyboard.test.tsx‎

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33
import React from 'react';
4-
import { act, fireEvent } from '@testing-library/react';
4+
import { fireEvent } from '@testing-library/react';
55

66
import { KeyCode } from '@cloudscape-design/component-toolkit/internal';
77

8-
import {
9-
focusList,
10-
groupedOptions,
11-
highlightedValue,
12-
pressKey,
13-
renderOptionList,
14-
Stateful,
15-
withDisabled,
16-
} from './common';
8+
import { focusList, groupedOptions, highlightedValue, pressKey, renderOptionList, Stateful } from './common';
179

1810
const typeKey = (listbox: HTMLElement, key: string) => fireEvent.keyDown(listbox, { key });
1911

@@ -81,23 +73,6 @@ describe('OptionList keyboard', () => {
8173
expect(listbox).toHaveAttribute('aria-activedescendant', wrapper.findOptionByValue('c')!.getElement().id);
8274
});
8375

84-
test('typing does not highlight a disabled option', () => {
85-
jest.useFakeTimers();
86-
try {
87-
const { wrapper, listbox } = renderOptionList(<Stateful options={withDisabled} />);
88-
focusList(listbox);
89-
// Only the disabled "Second" starts with "s", so the highlight stays on the first option.
90-
typeKey(listbox, 's');
91-
expect(highlightedValue(wrapper)).toBe('a');
92-
// After the type-ahead resets, an enabled match is still highlighted.
93-
act(() => jest.advanceTimersByTime(1000));
94-
typeKey(listbox, 't');
95-
expect(highlightedValue(wrapper)).toBe('c');
96-
} finally {
97-
jest.useRealTimers();
98-
}
99-
});
100-
10176
test('multi: typing can highlight a group header', () => {
10277
const { wrapper, listbox } = renderOptionList(<Stateful selectionType="multi" options={groupedOptions} />);
10378
focusList(listbox);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import * as React from 'react';
4+
import { fireEvent, render } from '@testing-library/react';
5+
6+
import Select, { SelectProps } from '../../../lib/components/select';
7+
import createWrapper from '../../../lib/components/test-utils/dom';
8+
9+
// "Beta" is disabled, so "b" matches it first, then "Bravo".
10+
const options: SelectProps.Options = [
11+
{ value: 'alpha', label: 'Alpha' },
12+
{ value: 'beta', label: 'Beta', disabled: true },
13+
{ value: 'bravo', label: 'Bravo' },
14+
];
15+
16+
function renderSelect() {
17+
const onChange = jest.fn();
18+
const { container } = render(
19+
<Select selectedOption={null} options={options} onChange={event => onChange(event.detail.selectedOption)} />
20+
);
21+
return { wrapper: createWrapper(container).findSelect()!, onChange };
22+
}
23+
24+
describe('Select type-ahead', () => {
25+
test('when closed, selects the first enabled match and skips disabled options', () => {
26+
const { wrapper, onChange } = renderSelect();
27+
fireEvent.keyDown(wrapper.findTrigger().getElement(), { key: 'b' });
28+
expect(onChange).toHaveBeenCalledWith(options[2]);
29+
});
30+
31+
test('when open, highlights the first match, including a disabled option', () => {
32+
const { wrapper, onChange } = renderSelect();
33+
wrapper.openDropdown();
34+
fireEvent.keyDown(wrapper.findDropdown().findOptionsContainer()!.getElement(), { key: 'b' });
35+
expect(wrapper.findDropdown().findHighlightedOption()!.getElement()).toHaveTextContent('Beta');
36+
expect(onChange).not.toHaveBeenCalled();
37+
});
38+
});

‎src/select/internal.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ const InternalSelect = React.forwardRef(
188188
options: filteredOptions,
189189
highlightOption: !isOpen ? selectOption : highlightOption,
190190
highlightedOption: !isOpen ? selectedOption : highlightedOption?.option,
191+
// When closed, type-ahead selects the match, so it skips disabled options as a native select does.
192+
skipDisabled: !isOpen,
191193
});
192194

193195
const selectAriaLabelId = useUniqueId('select-arialabel-');

0 commit comments

Comments
 (0)