diff --git a/pages/dropdown/list-with-sticky-item.page.tsx b/pages/dropdown/list-with-sticky-item.page.tsx index 1b295f588e1..a4c220231b9 100644 --- a/pages/dropdown/list-with-sticky-item.page.tsx +++ b/pages/dropdown/list-with-sticky-item.page.tsx @@ -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'; @@ -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..fb7f3a041a0 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,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: { @@ -112,7 +88,6 @@ describe('getItemProps', () => { 'data-test-index': 1, 'data-mouse-target': 1, filteringValue: 'child', - hasCheckbox: false, highlighted: true, key: 1, option: { @@ -136,7 +111,6 @@ describe('getItemProps', () => { 'data-test-index': 1, 'data-mouse-target': 1, filteringValue: 'child', - hasCheckbox: false, highlighted: false, key: 1, option: { @@ -159,7 +133,6 @@ describe('getItemProps', () => { 'data-mouse-target': 3, 'data-test-index': 3, filteringValue: 'child', - hasCheckbox: false, highlighted: false, key: 3, option: { 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/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 = ( +