diff --git a/packages/react/src/components/ListBox/next/ListBoxSelection.tsx b/packages/react/src/components/ListBox/next/ListBoxSelection.tsx index 726c84810d8a..9e6059b5ef2d 100644 --- a/packages/react/src/components/ListBox/next/ListBoxSelection.tsx +++ b/packages/react/src/components/ListBox/next/ListBoxSelection.tsx @@ -32,6 +32,7 @@ const defaultTranslations: Record = { function defaultTranslateWithId(id: TranslationKey): string { return defaultTranslations[id]; } + export interface ListBoxSelectionProps { /** * Specify a function to be invoked when a user interacts with the clear @@ -57,6 +58,11 @@ export interface ListBoxSelectionProps { * Specify whether or not the clear selection element should be disabled */ disabled?: boolean; + /** + * Whether or not the listbox is readonly + */ + readOnly?: boolean; + /** * Specify an optional `onClearSelection` handler that is called when the underlying * element is cleared @@ -86,6 +92,7 @@ function ListBoxSelection({ selectionCount, translateWithId: t = defaultTranslateWithId, disabled, + readOnly, onClearSelection, ...rest }: ListBoxSelectionProps) { @@ -106,7 +113,7 @@ function ListBoxSelection({ function onClick(event: React.MouseEvent) { event.stopPropagation(); - if (disabled) { + if (disabled || readOnly) { return; } clearSelection(event); @@ -126,11 +133,12 @@ function ListBoxSelection({ @@ -142,11 +150,12 @@ function ListBoxSelection({ {...rest} aria-label={description} className={className} - disabled={disabled} + disabled={disabled || readOnly} onClick={onClick} tabIndex={-1} title={description} - type="button"> + type="button" + aria-disabled={readOnly ? true : undefined}> ); @@ -164,6 +173,11 @@ ListBoxSelection.propTypes = { */ disabled: PropTypes.bool, + /** + * Whether or not the listbox is readonly + */ + readOnly: PropTypes.bool, + /** * Specify an optional `onClearSelection` handler that is called when the underlying * element is cleared diff --git a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx index 368ba6278f61..c7df0543ab0d 100644 --- a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx @@ -258,6 +258,11 @@ export interface FilterableMultiSelectProps */ placeholder?: string; + /** + * Whether or not the filterable multiselect is readonly + */ + readOnly?: boolean; + /** * Specify feedback (mode) of the selection. * `top`: selected item jumps to top @@ -335,6 +340,7 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< onChange, onMenuChange, placeholder, + readOnly, titleText, type, selectionFeedback = 'top-after-reopen', @@ -505,9 +511,11 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< }; function handleMenuChange(forceIsOpen: boolean): void { - const nextIsOpen = forceIsOpen ?? !isOpen; - setIsOpen(nextIsOpen); - validateHighlightFocus(); + if (!readOnly) { + const nextIsOpen = forceIsOpen ?? !isOpen; + setIsOpen(nextIsOpen); + validateHighlightFocus(); + } } useEffect(() => { @@ -689,6 +697,7 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< [`${prefix}--multi-select--selected`]: controlledSelectedItems?.length > 0, [`${prefix}--multi-select--filterable--input-focused`]: inputFocused, + [`${prefix}--multi-select--readonly`]: readOnly, } ); @@ -798,6 +807,28 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< } }; + const mergedRef = mergeRefs(textInput, inputProps.ref); + + const readOnlyEventHandlers = readOnly + ? { + onClick: (evt: React.MouseEvent) => { + // NOTE: does not prevent click + evt.preventDefault(); + // focus on the element as per readonly input behavior + if (mergedRef.current !== undefined) { + mergedRef.current.focus(); + } + }, + onKeyDown: (evt: React.KeyboardEvent) => { + const selectAccessKeys = ['ArrowDown', 'ArrowUp', ' ', 'Enter']; + // This prevents the select from opening for the above keys + if (selectAccessKeys.includes(evt.key)) { + evt.preventDefault(); + } + }, + } + : {}; + const clearSelectionContent = controlledSelectedItems.length > 0 ? ( @@ -832,13 +863,14 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< invalidText={invalidText} warn={warn} warnText={warnText} - isOpen={isOpen} + isOpen={!readOnly && isOpen} size={size}>
{controlledSelectedItems.length > 0 && ( { clearSelection(); if (textInput.current) { @@ -853,7 +885,9 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< {invalid && ( @@ -868,6 +902,7 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< clearSelection={clearInputValue} disabled={disabled} translateWithId={translateWithId} + readOnly={readOnly} onMouseUp={(event: MouseEvent) => { // If we do not stop this event from propagating, // it seems like Downshift takes our event and diff --git a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js index a6af8ad0e00d..0317c7940f95 100644 --- a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js +++ b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js @@ -7,6 +7,7 @@ import React from 'react'; import { act, render, screen } from '@testing-library/react'; +import { getByText } from '@carbon/test-utils/dom'; import userEvent from '@testing-library/user-event'; import FilterableMultiSelect from '../FilterableMultiSelect'; import { @@ -63,6 +64,28 @@ describe('FilterableMultiSelect', () => { expect(mockProps.onMenuChange).toHaveBeenCalledWith(false); }); + it('should not be interactive if readonly', async () => { + const items = generateItems(4, generateGenericItem); + const label = 'test-label'; + const { container } = render( + + ); + await waitForPosition(); + + // eslint-disable-next-line testing-library/prefer-screen-queries + const labelNode = getByText(container, label); + await userEvent.click(labelNode); + + expect( + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + container.querySelector('[aria-expanded="true"][aria-haspopup="listbox"]') + ).toBeFalsy(); + }); it('should initially have the menu open when open prop is provided', async () => { render(); await waitForPosition();