From 23eae758a1ef1a3d6e032dc72d388ec91f8a7c60 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Thu, 29 Oct 2026 15:48:06 +0100 Subject: [PATCH 1/4] refactor: Move Select type-ahead hook to listbox Moves useNativeSearch from select/utils to internal/components/listbox as useTypeAhead, and renames its useInteractiveGroups option to interactiveGroups. No behavior change. --- .../listbox/__tests__/use-type-ahead.test.ts} | 12 +++---- .../components/listbox/use-type-ahead.ts} | 31 ++++++++----------- src/multiselect/use-multiselect.tsx | 6 ++-- src/select/internal.tsx | 4 +-- 4 files changed, 24 insertions(+), 29 deletions(-) rename src/{select/__tests__/use-native-search.test.ts => internal/components/listbox/__tests__/use-type-ahead.test.ts} (80%) rename src/{select/utils/use-native-search.ts => internal/components/listbox/use-type-ahead.ts} (76%) diff --git a/src/select/__tests__/use-native-search.test.ts b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts similarity index 80% rename from src/select/__tests__/use-native-search.test.ts rename to src/internal/components/listbox/__tests__/use-type-ahead.test.ts index 717360cec76..01f9a14d6b4 100644 --- a/src/select/__tests__/use-native-search.test.ts +++ b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts @@ -1,9 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { act, renderHook } from '../../__tests__/render-hook'; -import type { DropdownOption } from '../../internal/components/option/interfaces'; -import { useNativeSearch } from '../utils/use-native-search'; +import { act, renderHook } from '../../../../__tests__/render-hook'; +import type { DropdownOption } from '../../option/interfaces'; +import { useTypeAhead } from '../use-type-ahead'; const options: DropdownOption[] = [ { option: { label: 'option 1', value: 'option1' } }, @@ -19,8 +19,8 @@ const initialProps = { highlightedOption: null, }; -describe('useNativeSearch', () => { - const hook = renderHook(useNativeSearch, { +describe('useTypeAhead', () => { + const hook = renderHook(useTypeAhead, { initialProps: initialProps, }); @@ -47,7 +47,7 @@ describe('useNativeSearch', () => { }); test('should not highlight an option when isEnabled is false', () => { - const hook = renderHook(useNativeSearch, { + const hook = renderHook(useTypeAhead, { initialProps: { ...initialProps, isEnabled: false, diff --git a/src/select/utils/use-native-search.ts b/src/internal/components/listbox/use-type-ahead.ts similarity index 76% rename from src/select/utils/use-native-search.ts rename to src/internal/components/listbox/use-type-ahead.ts index 169ccf00415..8571cc7c9ad 100644 --- a/src/select/utils/use-native-search.ts +++ b/src/internal/components/listbox/use-type-ahead.ts @@ -2,33 +2,28 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useRef } from 'react'; -import type { DropdownOption } from '../../internal/components/option/interfaces'; -import { - filterOptions, - isGroup, - isGroupInteractive, - isInteractive, -} from '../../internal/components/option/utils/filter-options'; -import { useDebounceCallback } from '../../internal/hooks/use-debounce-callback'; -import type { OptionDefinition, OptionGroup } from '../../types/option'; +import type { OptionDefinition, OptionGroup } from '../../../types/option'; +import { useDebounceCallback } from '../../hooks/use-debounce-callback'; +import type { DropdownOption } from '../option/interfaces'; +import { filterOptions, isGroup, isGroupInteractive, isInteractive } from '../option/utils/filter-options'; const isRepeatedChar = (str: string) => str.split('').every(c => c === str[0]); -interface UseNativeSearchProps { +interface UseTypeAheadProps { isEnabled: boolean; options: ReadonlyArray; highlightOption: (option: DropdownOption) => void; highlightedOption: OptionDefinition | undefined | null; - useInteractiveGroups?: boolean; + interactiveGroups?: boolean; } function findMatchingOption( options: ReadonlyArray, searchText: string, currentHighlight: OptionDefinition | OptionGroup | undefined | null, - useInteractiveGroups?: boolean + interactiveGroups?: boolean ) { - const interactivityCheck = useInteractiveGroups ? isGroupInteractive : isInteractive; + const interactivityCheck = interactiveGroups ? isGroupInteractive : isInteractive; const filter = (searchText: string) => filterOptions(options, searchText, true).filter(option => interactivityCheck(option)); const matchingOptions = filter(searchText); @@ -42,7 +37,7 @@ function findMatchingOption( const matchingOptions = filter(searchText[0]); if (matchingOptions.length > 0) { - //handling OptionGroups when useInteractiveGroups is set + //handling OptionGroups when interactiveGroups is set let active: number = isGroup(currentHighlight) ? matchingOptions.map(({ option }) => option).indexOf(currentHighlight) : matchingOptions @@ -65,13 +60,13 @@ function findMatchingOption( return null; } -export function useNativeSearch({ +export function useTypeAhead({ isEnabled, options, highlightOption, highlightedOption, - useInteractiveGroups, -}: UseNativeSearchProps) { + interactiveGroups, +}: UseTypeAheadProps) { const value = useRef(''); const delayedResetValue = useDebounceCallback(() => (value.current = ''), 500); @@ -91,7 +86,7 @@ export function useNativeSearch({ const newValue = value.current + key; value.current = newValue; - const nextHighlight = findMatchingOption(options, newValue, highlightedOption, useInteractiveGroups); + const nextHighlight = findMatchingOption(options, newValue, highlightedOption, interactiveGroups); if (nextHighlight) { highlightOption(nextHighlight); } diff --git a/src/multiselect/use-multiselect.tsx b/src/multiselect/use-multiselect.tsx index d3b979dba76..ca9fffa6fcc 100644 --- a/src/multiselect/use-multiselect.tsx +++ b/src/multiselect/use-multiselect.tsx @@ -9,6 +9,7 @@ import { useInternalI18n } from '../i18n/context'; import { useDropdownStatus } from '../internal/components/dropdown-status'; import { multiSelection } from '../internal/components/listbox/selection/multi-selection'; import { useAnnouncement } from '../internal/components/listbox/use-announcement'; +import { useTypeAhead } from '../internal/components/listbox/use-type-ahead'; import { checkOptionValueField } from '../internal/components/listbox/utils/check-option-value-field.js'; import { DropdownOption } from '../internal/components/option/interfaces'; import { prepareOptions } from '../internal/components/option/utils/prepare-options'; @@ -17,7 +18,6 @@ import { SomeRequired } from '../internal/types'; import { joinStrings } from '../internal/utils/strings'; import { SelectListProps } from '../select/parts/plain-list'; import { useLoadItems } from '../select/utils/use-load-items'; -import { useNativeSearch } from '../select/utils/use-native-search'; import { useSelect } from '../select/utils/use-select'; import { TokenGroupProps } from '../token-group/interfaces'; import { DropdownStatusProps } from '../types/dropdown-status'; @@ -171,12 +171,12 @@ export function useMultiselect({ dropdownRole, }); - const wrapperOnKeyDown = useNativeSearch({ + const wrapperOnKeyDown = useTypeAhead({ isEnabled: filteringType === 'none' && isOpen, options: visibleOptions, highlightOption: highlightOption, highlightedOption: highlightedOption?.option, - useInteractiveGroups, + interactiveGroups: useInteractiveGroups, }); const isEmpty = !options || options.length === 0; diff --git a/src/select/internal.tsx b/src/select/internal.tsx index 337233b338f..6fae5b2f819 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -13,6 +13,7 @@ import DropdownFooter from '../internal/components/dropdown-footer'; import { useDropdownStatus } from '../internal/components/dropdown-status'; import { singleSelection } from '../internal/components/listbox/selection/single-selection'; import { useAnnouncement } from '../internal/components/listbox/use-announcement'; +import { useTypeAhead } from '../internal/components/listbox/use-type-ahead'; import { checkOptionValueField } from '../internal/components/listbox/utils/check-option-value-field'; import { prepareOptions } from '../internal/components/option/utils/prepare-options.js'; import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; @@ -32,7 +33,6 @@ import Trigger from './parts/trigger'; import VirtualList from './parts/virtual-list'; import { composeDropdownContent } from './utils/dropdown-customization'; import { useLoadItems } from './utils/use-load-items'; -import { useNativeSearch } from './utils/use-native-search'; import { useSelect } from './utils/use-select'; import styles from './styles.css.js'; @@ -171,7 +171,7 @@ const InternalSelect = React.forwardRef( dropdownRole, }); - const handleNativeSearch = useNativeSearch({ + const handleNativeSearch = useTypeAhead({ isEnabled: filteringType === 'none' && !readOnly, options: filteredOptions, highlightOption: !isOpen ? selectOption : highlightOption, From 586bfedd283839abe9367df0ee4257e8a88fd093 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Thu, 29 Oct 2026 15:52:19 +0100 Subject: [PATCH 2/4] refactor: Share one option item between Select and Multiselect Merges select/parts/item.tsx and select/parts/multiselect-item.tsx into internal/components/listbox/parts/item.tsx. The row is chosen with selectionType: 'single' renders today's Select row, 'multi' renders today's Multiselect row (checkbox, interactive groups, select-all, afterHeader). render-options passes selectionType instead of picking a component, and the hasCheckbox/checkboxes plumbing is removed because Select never rendered checkboxes and Multiselect always did. Styles still come from select/parts/styles.scss, so class names and test-utils selectors are unchanged. Dev pages: item.permutations maps hasCheckbox to selectionType, and list-with-sticky-item uses selectionType="multi" (it now shows the checkbox every multi row renders). --- pages/dropdown/list-with-sticky-item.page.tsx | 2 +- pages/select/item.permutations.page.tsx | 11 +- .../listbox/__tests__/get-item-props.test.ts | 9 +- .../listbox/parts/get-item-props.ts | 4 +- .../components/listbox/parts/item.tsx | 371 ++++++++++++++++++ .../listbox/parts/render-options.tsx | 17 +- src/multiselect/embedded.tsx | 3 +- src/multiselect/internal.tsx | 3 +- src/select/parts/item.tsx | 218 ---------- src/select/parts/multiselect-item.tsx | 225 ----------- src/select/parts/plain-list.tsx | 10 +- src/select/parts/virtual-list.tsx | 6 +- src/select/utils/use-select.ts | 2 +- 13 files changed, 395 insertions(+), 486 deletions(-) create mode 100644 src/internal/components/listbox/parts/item.tsx delete mode 100644 src/select/parts/item.tsx delete mode 100644 src/select/parts/multiselect-item.tsx diff --git a/pages/dropdown/list-with-sticky-item.page.tsx b/pages/dropdown/list-with-sticky-item.page.tsx index 1b295f588e1..26b08035df2 100644 --- a/pages/dropdown/list-with-sticky-item.page.tsx +++ b/pages/dropdown/list-with-sticky-item.page.tsx @@ -95,7 +95,7 @@ export default function MultiselectPage() { filteringValue={''} firstOptionSticky={true} highlightType={{ type: 'mouse', moveFocus: false }} - useInteractiveGroups={true} + selectionType="multi" /> } /> diff --git a/pages/select/item.permutations.page.tsx b/pages/select/item.permutations.page.tsx index 586d836ac78..0945548ca8d 100644 --- a/pages/select/item.permutations.page.tsx +++ b/pages/select/item.permutations.page.tsx @@ -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'; @@ -100,7 +99,7 @@ const permutations = createPermutations>([ highlighted: [false, true], selected: [false, true], highlightType: ['keyboard'], - hasCheckbox: [true], + selectionType: ['multi'], }, { option: [options.complexOption], @@ -126,21 +125,21 @@ const permutations = createPermutations>([ }, { 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'], }, ]); diff --git a/src/internal/components/listbox/__tests__/get-item-props.test.ts b/src/internal/components/listbox/__tests__/get-item-props.test.ts index a8883b4d2b2..8dce93d1420 100644 --- a/src/internal/components/listbox/__tests__/get-item-props.test.ts +++ b/src/internal/components/listbox/__tests__/get-item-props.test.ts @@ -56,7 +56,6 @@ const defaultItemProps = { index: 1, getOptionProps: getOptionProps(false, false), filteringValue: 'child', - checkboxes: false, }; describe('getItemProps', () => { @@ -68,7 +67,6 @@ describe('getItemProps', () => { 'data-test-index': 1, 'data-mouse-target': 1, filteringValue: 'child', - hasCheckbox: false, highlighted: false, key: 1, option: { @@ -79,10 +77,9 @@ describe('getItemProps', () => { }); }); - test('should return props for the first child in the first group with checkboxes and keyboard', () => { + test('should return props for the first child in the first group with keyboard', () => { const props = getItemProps({ ...defaultItemProps, - checkboxes: true, }); expect(props).toEqual({ 'data-child-index': 1, @@ -90,7 +87,6 @@ describe('getItemProps', () => { 'data-test-index': 1, 'data-mouse-target': 1, filteringValue: 'child', - hasCheckbox: true, highlighted: false, key: 1, option: { @@ -112,7 +108,6 @@ describe('getItemProps', () => { 'data-test-index': 1, 'data-mouse-target': 1, filteringValue: 'child', - hasCheckbox: false, highlighted: true, key: 1, option: { @@ -136,7 +131,6 @@ describe('getItemProps', () => { 'data-test-index': 1, 'data-mouse-target': 1, filteringValue: 'child', - hasCheckbox: false, highlighted: false, key: 1, option: { @@ -159,7 +153,6 @@ describe('getItemProps', () => { 'data-mouse-target': 3, 'data-test-index': 3, filteringValue: 'child', - hasCheckbox: false, highlighted: false, key: 3, option: { diff --git a/src/internal/components/listbox/parts/get-item-props.ts b/src/internal/components/listbox/parts/get-item-props.ts index 03400ddf8ca..2c48211187f 100644 --- a/src/internal/components/listbox/parts/get-item-props.ts +++ b/src/internal/components/listbox/parts/get-item-props.ts @@ -8,16 +8,14 @@ interface ItemProps { index: number; getOptionProps: any; filteringValue: string; - checkboxes: boolean; } -export const getItemProps = ({ option, index, getOptionProps, filteringValue, checkboxes = false }: ItemProps) => { +export const getItemProps = ({ option, index, getOptionProps, filteringValue }: ItemProps) => { const optionProps = getOptionProps(option, index); optionProps.filteringValue = filteringValue; const { inGroupIndex, groupIndex, throughIndex } = getTestOptionIndexes(option) || {}; return { ...optionProps, - hasCheckbox: checkboxes, ['data-group-index']: groupIndex, ['data-child-index']: inGroupIndex, ['data-test-index']: throughIndex, diff --git a/src/internal/components/listbox/parts/item.tsx b/src/internal/components/listbox/parts/item.tsx new file mode 100644 index 00000000000..8a0f5b4c4f2 --- /dev/null +++ b/src/internal/components/listbox/parts/item.tsx @@ -0,0 +1,371 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; + +import InternalIcon from '../../../../icon/internal.js'; +import { MultiselectProps } from '../../../../multiselect/interfaces'; +import { SelectProps } from '../../../../select/interfaces.js'; +import Tooltip from '../../../../tooltip/internal.js'; +import { OptionDefinition, OptionGroup } from '../../../../types/option'; +import { getBaseProps } from '../../../base-component'; +import useHiddenDescription from '../../../hooks/use-hidden-description'; +import CheckboxIcon from '../../checkbox-icon'; +import Option from '../../option'; +import { DropdownOption } from '../../option/interfaces'; +import { HighlightType } from '../../options-list/utils/use-highlight-option.js'; +import SelectableItem from '../../selectable-item'; +import { SelectionType } from '../selection/interfaces'; + +import styles from '../../../../select/parts/styles.css.js'; + +export interface ItemProps { + index: number; + virtualIndex?: number; + option: DropdownOption; + /** + * `single` renders a check mark on the selected option (Select). + * `multi` renders a checkbox, interactive groups and the select-all row (Multiselect). + */ + selectionType?: SelectionType; + highlighted?: boolean; + selected?: boolean; + indeterminate?: boolean; + filteringValue?: string; + virtualPosition?: number; + padBottom?: boolean; + isNextSelected?: boolean; + isPreviousSelected?: boolean; + screenReaderContent?: string; + ariaPosinset?: number; + ariaSetsize?: number; + highlightType?: HighlightType['type']; + withScrollbar?: boolean; + sticky?: boolean; + renderOption?: SelectProps.SelectOptionItemRenderer | MultiselectProps.MultiselectOptionItemRenderer; + parentProps?: ItemParentProps; +} + +export interface ItemParentProps { + index: number; + virtualIndex?: number; + option: DropdownOption; + disabled: boolean; + indeterminate?: boolean; + highlighted?: boolean; + selected?: boolean; +} + +interface MultiselectItemParentProps extends ItemParentProps { + indeterminate: boolean; + highlighted: boolean; + selected: boolean; +} + +const toSelectOptionGroupItem = (props: ItemParentProps): SelectProps.SelectOptionGroupItem => { + return { + type: 'group', + index: props.virtualIndex ?? props.index, + option: props.option.option as OptionGroup, + disabled: props.disabled, + }; +}; + +const toSelectOptionItem = (props: { + index: number; + virtualIndex?: number; + option: DropdownOption; + disabled: boolean; + selected: boolean; + highlighted: boolean; + parentProps?: ItemParentProps; +}): SelectProps.SelectOptionItem => { + return { + type: 'item', + index: props.virtualIndex ?? props.index, + option: props.option.option as OptionDefinition, + selected: props.selected, + highlighted: props.highlighted, + disabled: props.disabled, + parent: props.parentProps + ? toSelectOptionGroupItem({ + index: props.parentProps?.index, + virtualIndex: props.parentProps?.virtualIndex, + option: props.parentProps?.option, + disabled: props.disabled, + }) + : null, + }; +}; + +const toMultiselectOptionGroupItem = ( + props: MultiselectItemParentProps +): MultiselectProps.MultiselectOptionGroupItem => { + return { + type: 'group', + index: props.virtualIndex ?? props.index, + option: props.option.option as OptionGroup, + indeterminate: props.indeterminate ?? false, + selected: props.selected, + highlighted: props.highlighted, + disabled: props.disabled, + }; +}; + +const toMultiselectOptionItem = (props: { + index: number; + virtualIndex?: number; + option: DropdownOption; + disabled: boolean; + selected: boolean; + highlighted: boolean; + parentProps?: ItemParentProps; +}): MultiselectProps.MultiselectOptionItem => { + return { + type: 'item', + index: props.virtualIndex ?? props.index, + option: props.option.option as OptionDefinition, + selected: props.selected, + highlighted: props.highlighted, + disabled: props.disabled, + parent: props.parentProps + ? toMultiselectOptionGroupItem({ + index: props.parentProps?.index, + virtualIndex: props.parentProps?.virtualIndex, + option: props.parentProps?.option, + disabled: props.disabled, + highlighted: props.parentProps?.highlighted ?? false, + indeterminate: props.parentProps?.indeterminate ?? false, + selected: props.parentProps?.selected ?? false, + }) + : null, + }; +}; + +const Item = ( + { + index, + virtualIndex, + option, + selectionType = 'single', + highlighted, + selected, + indeterminate, + filteringValue, + virtualPosition, + padBottom, + isNextSelected, + isPreviousSelected, + screenReaderContent, + ariaPosinset, + ariaSetsize, + highlightType, + withScrollbar, + sticky, + renderOption, + parentProps, + ...restProps + }: ItemProps, + ref: React.Ref +) => { + const baseProps = getBaseProps(restProps); + const isMulti = selectionType === 'multi'; + const isParent = option.type === 'parent'; + const isChild = option.type === 'child'; + const isSelectAll = option.type === 'select-all'; + const wrappedOption: OptionDefinition = option.option; + const disabled = option.disabled || wrappedOption.disabled; + const disabledReasonSource = isMulti + ? option.disabledReason || wrappedOption.disabledReason + : wrappedOption.disabledReason; + const disabledReason = disabled && disabledReasonSource ? disabledReasonSource : ''; + const isDisabledWithReason = !!disabledReason; + const internalRef = useRef(null); + const { descriptionEl, descriptionId } = useHiddenDescription(disabledReason); + const [canShowTooltip, setCanShowTooltip] = useState(true); + useEffect(() => setCanShowTooltip(true), [highlighted]); + const mergedRef = useMergeRefs(ref, internalRef); + + const getSelectItemProps = (option: DropdownOption): SelectProps.SelectItem => { + if (option.type === 'parent') { + return toSelectOptionGroupItem({ + option: option, + index: index, + virtualIndex: virtualIndex, + disabled: !!disabled, + }); + } else { + return toSelectOptionItem({ + option: option, + index: index, + virtualIndex: virtualIndex, + disabled: !!disabled, + highlighted: !!highlighted, + selected: !!selected, + parentProps: parentProps, + }); + } + }; + + const getMultiselectItemProps = (option: DropdownOption): MultiselectProps.MultiselectItem => { + if (option.type === 'parent') { + return toMultiselectOptionGroupItem({ + option: option, + index: index, + virtualIndex: virtualIndex, + disabled: !!disabled, + highlighted: !!highlighted, + selected: !!selected, + indeterminate: indeterminate ?? false, + }); + } else if (option.type === 'select-all') { + return { + type: 'select-all', + option: option.option as OptionDefinition, + indeterminate: indeterminate ?? false, + selected: !!selected, + highlighted: !!highlighted, + }; + } else { + return toMultiselectOptionItem({ + option: option, + index: index, + virtualIndex: virtualIndex, + disabled: !!disabled, + highlighted: !!highlighted, + selected: !!selected, + parentProps: parentProps, + }); + } + }; + + const renderOptionWrapper = (option: DropdownOption) => { + if (!renderOption) { + return null; + } + if (isMulti) { + return (renderOption as MultiselectProps.MultiselectOptionItemRenderer)({ + item: getMultiselectItemProps(option), + filterText: filteringValue, + }); + } + return (renderOption as SelectProps.SelectOptionItemRenderer)({ + item: getSelectItemProps(option), + filterText: filteringValue, + }); + }; + + const renderResult = renderOptionWrapper(option); + + const optionElement = ( +