diff --git a/pages/multiselect/custom-render-option.page.tsx b/pages/multiselect/custom-render-option.page.tsx new file mode 100644 index 0000000000..647d489f53 --- /dev/null +++ b/pages/multiselect/custom-render-option.page.tsx @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import { Multiselect, MultiselectProps } from '~components'; +import Box from '~components/box'; +import CheckboxIcon from '~components/internal/components/checkbox-icon'; +import { SelectProps } from '~components/select'; + +import ScreenshotArea from '../utils/screenshot-area'; +import { i18nStrings } from './constants'; +const lotsOfOptions: SelectProps.Options = [...Array(100)].map((_, index) => ({ + value: `Option ${index}`, + label: `Option ${index}`, +})); +const options: SelectProps.Options = [ + { value: 'first', label: 'Simple' }, + { value: 'second', label: 'With small icon', iconName: 'folder' }, + { + value: 'third', + label: 'With big icon icon', + description: 'Very big option', + iconName: 'heart', + disabled: true, + disabledReason: 'disabled reason', + tags: ['Cool', 'Intelligent', 'Cat'], + }, + { + label: 'Option group', + options: [ + { value: 'forth', label: 'Nested option' }, + { value: 'forth0', label: 'Nested option' }, + ], + disabledReason: 'disabled reason', + }, + { + label: 'Option group', + options: [{ value: 'forth2', label: 'Nested option' }], + disabledReason: 'disabled reason', + }, + { label: 'Last option', disabled: true, disabledReason: 'disabled reason' }, + ...lotsOfOptions, +]; + +export default function SelectPage() { + const [selectedOptions, setSelectedOptions] = React.useState([]); + const renderOptionItem: MultiselectProps.MultiselectOptionItemRenderer = ({ item }) => { + if (item.type === 'child') { + return
{item.option.label}
; + } else { + return ( +
+ + {item.option.label} +
+ ); + } + }; + + return ( + + Select with custom item renderer + +
+ { + setSelectedOptions(event.detail.selectedOptions); + }} + options={options} + /> +
+
+
+ ); +} diff --git a/pages/select/custom-render-option.page.tsx b/pages/select/custom-render-option.page.tsx new file mode 100644 index 0000000000..dedc4fb827 --- /dev/null +++ b/pages/select/custom-render-option.page.tsx @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { useState } from 'react'; + +import Box from '~components/box'; +import Select, { SelectProps } from '~components/select'; + +import ScreenshotArea from '../utils/screenshot-area'; + +const extraOptions = [...Array(10).keys()].map(n => { + const numberToDisplay = (n + 5).toString(); + return { + value: numberToDisplay, + label: `Option ${n + 5}`, + }; +}); + +const options: SelectProps.Options = [ + { value: 'first', label: 'Simple' }, + { value: 'second', label: 'With small icon', iconName: 'folder' }, + { + value: 'third', + label: 'With big icon icon', + description: 'Very big option', + iconName: 'heart', + disabled: true, + disabledReason: 'disabled reason', + tags: ['Cool', 'Intelligent', 'Cat'], + }, + { + label: 'Option group', + options: [{ value: 'forth', label: 'Nested option' }], + disabledReason: 'disabled reason', + }, + { + label: 'Option group', + options: [{ value: 'forth2', label: 'Nested option' }], + disabledReason: 'disabled reason', + }, + ...extraOptions, + { label: 'Last option', disabled: true, disabledReason: 'disabled reason' }, +]; + +export default function SelectPage() { + const [selectedOption, setSelectedOption] = useState(null); + const renderOptionItem: SelectProps.SelectOptionItemRenderer = ({ item, filterText }) => { + return ( +
+ {item.option.label} + {filterText} +
+ ); + }; + + return ( + + Select with custom item renderer + +
+ {}} options={props?.options ?? []} {...props} /> + ); + return createWrapper(container).findSelect()!; + } + + test('renders custom option content', () => { + const renderOption = jest.fn(() =>
Custom
); + const wrapper = renderSelect({ options: defaultOptions, renderOption }); + wrapper.openDropdown(); + expect(renderOption).toHaveBeenCalled(); + const elementWrapper = wrapper.findDropdown().findOption(1)!.findCustomContent(); + expect(elementWrapper).not.toBeNull(); + expect(elementWrapper.getElement()).toHaveTextContent('Custom'); + }); + test('renders no custom option content when no renderOption specified', () => { + const wrapper = renderSelect({ options: defaultOptions }); + wrapper.openDropdown(); + expect(wrapper.findDropdown().findOption(1)!.findCustomContent()).toBeNull(); + }); + test('receives correct item properties for child option', () => { + const renderOption = jest.fn(() =>
Custom
); + const childOption = { label: 'Test', value: '1' }; + const wrapper = renderSelect({ + options: [childOption], + renderOption, + }); + wrapper.openDropdown(); + expect(renderOption).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ + option: expect.objectContaining(childOption), + selected: false, + highlighted: false, + disabled: false, + type: 'child', + }), + }) + ); + }); + test('receives correct item properties for parent option', () => { + const renderOption = jest.fn(() =>
Custom
); + const groupOption = { label: 'Group', value: 'g1', options: [{ label: 'Child', value: 'c1' }] }; + const wrapper = renderSelect({ + options: [groupOption], + renderOption, + }); + wrapper.openDropdown(); + expect(renderOption).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ + option: expect.objectContaining(groupOption), + selected: false, + highlighted: false, + disabled: false, + type: 'parent', + }), + }) + ); + }); + test('reflects highlighted state', () => { + const renderOption = jest.fn(props =>
{props.item.highlighted ? 'highlighted' : 'normal'}
); + const wrapper = renderSelect({ options: [{ label: 'First', value: '1' }], renderOption }); + wrapper.openDropdown(); + wrapper.findDropdown().findOptionsContainer()!.keydown(KeyCode.down); + expect(wrapper.findDropdown().getElement().textContent).toContain('highlighted'); + }); + test('reflects selected state', () => { + const renderOption = jest.fn(props =>
{props.item.selected ? 'selected' : 'not-selected'}
); + const option = { label: 'Test', value: '1' }; + const wrapper = renderSelect({ + options: [option], + selectedOption: option, + renderOption, + }); + wrapper.openDropdown(); + expect(renderOption).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ selected: true }), + }) + ); + }); + test('renders children within groups correctly', () => { + const renderOption = jest.fn(props => ( +
+ {props.item.type}-{props.item.option.label} +
+ )); + const wrapper = renderSelect({ + options: [{ label: 'Group', options: [{ label: 'Child', value: 'c1' }] }], + renderOption, + }); + wrapper.openDropdown(); + expect(renderOption).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ type: 'child' }), + }) + ); + }); + test('reflects disabled state', () => { + const renderOption = jest.fn(props =>
{props.item.disabled ? 'disabled' : 'enabled'}
); + const wrapper = renderSelect({ + options: [{ label: 'Test', value: '1', disabled: true }], + renderOption, + }); + wrapper.openDropdown(); + expect(renderOption).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ + disabled: true, + }), + }) + ); + }); + test('allows selection with custom rendered options', () => { + const onChange = jest.fn(); + const renderOption = jest.fn(props =>
{props.item.option.value}
); + const wrapper = renderSelect({ + options: [ + { label: 'Test', value: '1' }, + { label: 'Test 2', value: '2' }, + ], + renderOption, + onChange, + }); + wrapper.openDropdown(); + wrapper.selectOptionByValue('2'); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { selectedOption: expect.objectContaining({ value: '2' }) } }) + ); + }); +}); diff --git a/src/select/index.tsx b/src/select/index.tsx index 49caa29a56..c4dcfb776f 100644 --- a/src/select/index.tsx +++ b/src/select/index.tsx @@ -23,6 +23,7 @@ const Select = React.forwardRef( filteringType = 'none', statusType = 'finished', triggerVariant = 'label', + renderOption, ...restProps }: SelectProps, ref: React.Ref @@ -54,6 +55,7 @@ const Select = React.forwardRef( return ( ; + interface BaseSelectItem { + index: number | null; + disabled: boolean; + highlighted: boolean; + selected: boolean; + } + export type SelectOptionItem = BaseSelectItem & { + type: 'child'; + option: Option; + }; + export type SelectOptionGroupItem = BaseSelectItem & { + type: 'parent'; + option: OptionGroup; + }; + export type SelectOptionItemRenderer = (props: { + item: SelectOptionItem | SelectOptionGroupItem; + filterText?: string; + }) => ReactNode | null; export type LoadItemsDetail = OptionsLoadItemsDetail; diff --git a/src/select/internal.tsx b/src/select/internal.tsx index e0da536b00..a3aba161b6 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -68,6 +68,7 @@ const InternalSelect = React.forwardRef( autoFocus, __inFilteringToken, __internalRootRef, + renderOption, ...restProps }: InternalSelectProps, externalRef: React.Ref @@ -263,6 +264,7 @@ const InternalSelect = React.forwardRef( ) : null } + renderOption={renderOption} menuProps={menuProps} getOptionProps={getOptionProps} filteredOptions={filteredOptions} diff --git a/src/select/parts/item.tsx b/src/select/parts/item.tsx index 485d5a3e7f..5cf1dd1008 100644 --- a/src/select/parts/item.tsx +++ b/src/select/parts/item.tsx @@ -9,15 +9,19 @@ import InternalIcon from '../../icon/internal.js'; import { getBaseProps } from '../../internal/base-component'; import CheckboxIcon from '../../internal/components/checkbox-icon'; import Option from '../../internal/components/option'; -import { DropdownOption, OptionDefinition } from '../../internal/components/option/interfaces'; +import { DropdownOption, OptionDefinition, OptionGroup } from '../../internal/components/option/interfaces'; +import { getTestOptionIndexes } from '../../internal/components/options-list/utils/test-indexes'; import { HighlightType } from '../../internal/components/options-list/utils/use-highlight-option.js'; import SelectableItem from '../../internal/components/selectable-item'; import Tooltip from '../../internal/components/tooltip'; import useHiddenDescription from '../../internal/hooks/use-hidden-description'; +import { SelectProps } from '../interfaces'; import styles from './styles.css.js'; -export interface ItemProps { +export interface ItemProps { + index?: number; + virtualIndex?: number; option: DropdownOption; highlighted?: boolean; selected?: boolean; @@ -33,10 +37,13 @@ export interface ItemProps { highlightType?: HighlightType['type']; withScrollbar?: boolean; sticky?: boolean; + renderOption?: T; } const Item = ( { + index, + virtualIndex, option, highlighted, selected, @@ -52,6 +59,7 @@ const Item = ( highlightType, withScrollbar, sticky, + renderOption, ...restProps }: ItemProps, ref: React.Ref @@ -71,8 +79,47 @@ const Item = ( const [canShowTooltip, setCanShowTooltip] = useState(true); useEffect(() => setCanShowTooltip(true), [highlighted]); + const { throughIndex, inGroupIndex, groupIndex } = getTestOptionIndexes(option) || {}; + const globalIndex = virtualIndex ?? index ?? null; + + const renderOptionWrapper = (option: DropdownOption) => { + if (!renderOption) { + return null; + } + + const baseItem = { + index: globalIndex, + selected: !!selected, + highlighted: !!highlighted, + disabled: !!disabled, + }; + + let item: SelectProps.SelectOptionItem | SelectProps.SelectOptionGroupItem; + + switch (option.type) { + case 'parent': + item = { + type: 'parent', + option: option.option as OptionGroup, + ...baseItem, + }; + break; + case 'child': + default: + item = { type: 'child', option: option.option as OptionDefinition, ...baseItem }; + break; + } + + return renderOption({ item, filterText: filteringValue }); + }; + const renderResult = renderOptionWrapper(option); + return (
- {hasCheckbox && !isParent && ( + {!renderResult && hasCheckbox && !isParent && (
)} -