diff --git a/pages/dropdown/list-with-sticky-item.page.tsx b/pages/dropdown/list-with-sticky-item.page.tsx index a4aeb4c86d..4175818f43 100644 --- a/pages/dropdown/list-with-sticky-item.page.tsx +++ b/pages/dropdown/list-with-sticky-item.page.tsx @@ -90,7 +90,7 @@ export default function MultiselectPage() { > ({ option: { ...option }, key: index, open })} + getOptionProps={(option, index) => ({ option: { ...option }, key: index, index: index, open })} filteredOptions={options} filteringValue={''} firstOptionSticky={true} diff --git a/pages/multiselect/custom-render-option.page.tsx b/pages/multiselect/custom-render-option.page.tsx new file mode 100644 index 0000000000..8ee90983e1 --- /dev/null +++ b/pages/multiselect/custom-render-option.page.tsx @@ -0,0 +1,67 @@ +// 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 { SelectProps } from '~components/select'; + +import { SimplePage } from '../app/templates'; +import { i18nStrings } from './constants'; +const lotsOfOptions: SelectProps.Options = [...Array(50)].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: false, + disabledReason: 'disabled reason', + tags: ['Cool', 'Intelligent', 'Cat'], + }, + { + label: 'Option group', + options: [{ value: 'forth', label: 'Nested option' }], + disabledReason: 'disabled reason', + }, + ...lotsOfOptions, + { label: 'Last option', disabled: false, disabledReason: 'disabled reason' }, +]; + +export default function SelectPage() { + const [selectedOptions, setSelectedOptions] = React.useState([]); + const renderOptionItem: MultiselectProps.MultiselectOptionItemRenderer = ({ item }) => { + if (item.type === 'select-all') { + return
Select all? {item.selected ? 'Yes' : 'No'}
; + } else if (item.type === 'group') { + return
Group: {item.option.label}
; + } else if (item.type === 'item') { + return
Item: {item.option.label}
; + } + + return null; + }; + + return ( + +
+ { + 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..bda28acea0 --- /dev/null +++ b/pages/select/custom-render-option.page.tsx @@ -0,0 +1,67 @@ +// 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 Select, { SelectProps } from '~components/select'; + +import { SimplePage } from '../app/templates'; + +const lotsOfOptions = [...Array(50).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', + }, + ...lotsOfOptions, + { label: 'Last option', disabled: true, disabledReason: 'disabled reason' }, +]; + +export default function SelectPage() { + const [selectedOption, setSelectedOption] = useState(null); + const renderOption: SelectProps.SelectOptionItemRenderer = ({ item }) => { + if (item.type === 'trigger') { + return
Trigger: {item.option.label}
; + } else if (item.type === 'group') { + return
Group: {item.option.label}
; + } else if (item.type === 'item') { + return
Item: {item.option.label}
; + } + return null; + }; + + return ( + +
+ {}} 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)!.getElement(); + expect(elementWrapper).not.toBeNull(); + expect(elementWrapper).toHaveTextContent('Custom'); + }); + + test('receives correct item properties for item option', () => { + const renderOption = jest.fn(() =>
Custom
); + const itemOption = { label: 'Test', value: '1' }; + const wrapper = renderSelect({ + options: [itemOption], + renderOption, + }); + wrapper.openDropdown(); + expect(renderOption).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ + option: expect.objectContaining(itemOption), + selected: false, + highlighted: false, + disabled: false, + type: 'item', + }), + }) + ); + }); + test('receives correct item properties for group 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), + disabled: false, + type: 'group', + }), + }) + ); + }); + + test('receives correct item properties for trigger option', () => { + const renderOption = jest.fn(() =>
Custom
); + const selectedOption = { label: 'Test', value: '1' }; + const wrapper = renderSelect({ + options: [selectedOption], + selectedOption, + triggerVariant: 'option', + renderOption, + }); + const triggerWrapper = wrapper.findTrigger()!; + expect(triggerWrapper).not.toBeNull(); + expect(triggerWrapper.getElement()).toHaveTextContent('Custom'); + expect(renderOption).toHaveBeenCalledWith( + expect.objectContaining({ + filterText: undefined, + item: expect.objectContaining({ + type: 'trigger', + option: expect.objectContaining(selectedOption), + }), + }) + ); + }); + + 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: 'item' }), + }) + ); + }); + + test('receives correct parent attribute for child item in group', () => { + const renderOption = jest.fn(() =>
Custom
); + const groupOption = { label: 'Parent Group', value: 'g1', options: [{ label: 'Child Item', value: 'c1' }] }; + const wrapper = renderSelect({ + options: [groupOption], + renderOption, + }); + wrapper.openDropdown(); + + // Verify that the child item receives the correct parent attribute + expect(renderOption).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ + type: 'item', + option: expect.objectContaining({ label: 'Child Item', value: 'c1' }), + parent: expect.objectContaining({ + type: 'group', + option: expect.objectContaining(groupOption), + }), + }), + }) + ); + }); + 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/__tests__/trigger.test.tsx b/src/select/__tests__/trigger.test.tsx index 0997f29654..571ae3d19c 100644 --- a/src/select/__tests__/trigger.test.tsx +++ b/src/select/__tests__/trigger.test.tsx @@ -165,4 +165,99 @@ describe('Trigger component', () => { expect(buttonTriggerEl).toHaveFocus(); }); }); + + describe('Custom content with renderOption', () => { + const mockRenderOption = jest.fn(); + const selectedOption = { + value: 'custom-option', + label: 'Custom Option', + description: 'A custom option for testing', + }; + + beforeEach(() => { + mockRenderOption.mockClear(); + }); + + test('should call renderOption with trigger item when triggerVariant is option', () => { + mockRenderOption.mockReturnValue(
Custom Trigger Content
); + + const wrapper = renderComponent({ + ...defaultProps, + selectedOption, + triggerVariant: 'option', + renderOption: mockRenderOption, + }); + + expect(mockRenderOption).toHaveBeenCalledWith({ + filterText: undefined, + item: { + type: 'trigger', + option: selectedOption, + }, + }); + + const buttonTriggerEl = wrapper.getElement(); + expect(buttonTriggerEl.querySelector('[data-testid="custom-trigger-content"]')).toBeTruthy(); + }); + + test('should render custom content in trigger when renderOption returns JSX', () => { + const customContent = ( +
+ + Prefix: {selectedOption.label} - {selectedOption.description} + +
+ ); + mockRenderOption.mockReturnValue(customContent); + + const wrapper = renderComponent({ + ...defaultProps, + selectedOption, + triggerVariant: 'option', + renderOption: mockRenderOption, + }); + + const buttonTriggerEl = wrapper.getElement(); + const customTriggerEl = buttonTriggerEl.querySelector('[data-testid="custom-trigger"]'); + + expect(customTriggerEl).toBeTruthy(); + expect(customTriggerEl).toHaveTextContent('Prefix: Custom Option - A custom option for testing'); + }); + + test('should fall back to default option rendering when renderOption returns null', () => { + mockRenderOption.mockReturnValue(null); + + const wrapper = renderComponent({ + ...defaultProps, + selectedOption, + triggerVariant: 'option', + renderOption: mockRenderOption, + }); + + const buttonTriggerEl = wrapper.getElement(); + expect(buttonTriggerEl).toHaveTextContent('Custom Option'); + expect(mockRenderOption).toHaveBeenCalledWith({ + filterText: undefined, + item: { + type: 'trigger', + option: selectedOption, + }, + }); + }); + + test('should not call renderOption when triggerVariant is label', () => { + mockRenderOption.mockReturnValue(
Should not render
); + + const wrapper = renderComponent({ + ...defaultProps, + selectedOption, + triggerVariant: 'label', + renderOption: mockRenderOption, + }); + + expect(mockRenderOption).not.toHaveBeenCalled(); + const buttonTriggerEl = wrapper.getElement(); + expect(buttonTriggerEl).toHaveTextContent('Custom Option'); + }); + }); }); 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 ( ; + export interface SelectOptionItem { + type: 'item'; + index: number; + option: Option; + highlighted: boolean; + selected: boolean; + disabled: boolean; + parent: SelectOptionGroupItem | null; + } + export interface SelectOptionGroupItem { + type: 'group'; + index: number; + option: OptionGroup; + disabled: boolean; + } + export interface SelectTriggerOptionItem { + type: 'trigger'; + option: Option; + } + export type SelectItem = SelectOptionItem | SelectOptionGroupItem | SelectTriggerOptionItem; + export type SelectOptionItemRenderer = (props: { item: SelectItem; filterText?: string }) => ReactNode | null; + export type LoadItemsDetail = OptionsLoadItemsDetail; export interface ChangeDetail { diff --git a/src/select/internal.tsx b/src/select/internal.tsx index e0da536b00..50f29519ac 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 @@ -163,6 +164,7 @@ const InternalSelect = React.forwardRef( const trigger = ( ) : 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..cbb9deb208 100644 --- a/src/select/parts/item.tsx +++ b/src/select/parts/item.tsx @@ -9,15 +9,18 @@ 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 { 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 +36,56 @@ export interface ItemProps { highlightType?: HighlightType['type']; withScrollbar?: boolean; sticky?: boolean; + renderOption?: T; + parentProps?: ItemParentProps; } +export interface ItemParentProps { + index: number; + virtualIndex?: number; + option: DropdownOption; + disabled: 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 Item = ( { + index, + virtualIndex, option, highlighted, selected, @@ -52,6 +101,8 @@ const Item = ( highlightType, withScrollbar, sticky, + renderOption, + parentProps, ...restProps }: ItemProps, ref: React.Ref @@ -71,8 +122,39 @@ const Item = ( const [canShowTooltip, setCanShowTooltip] = useState(true); useEffect(() => setCanShowTooltip(true), [highlighted]); + 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 renderOptionWrapper = (option: DropdownOption) => { + if (!renderOption) { + return null; + } + + return renderOption({ item: getSelectItemProps(option), filterText: filteringValue }); + }; + const renderResult = renderOptionWrapper(option); + return (
- {hasCheckbox && !isParent && ( + {!renderResult && hasCheckbox && !isParent && (
)}