diff --git a/packages/components/src/components/SelectNext/Select.mdx b/packages/components/src/components/SelectNext/Select.mdx index 1bfcb42a..8b6ee2c8 100644 --- a/packages/components/src/components/SelectNext/Select.mdx +++ b/packages/components/src/components/SelectNext/Select.mdx @@ -163,6 +163,14 @@ When the select component is disabled, it cannot be interacted with. +### Read only + +The `isReadOnly` prop makes the selection immutable while keeping Select +focusable. The dropdown cannot be opened, so the selected value is displayed but +cannot be changed. + + + ### Clear button If you pass the `isClearable` prop to the select, it will have a clear diff --git a/packages/components/src/components/SelectNext/Select.module.css b/packages/components/src/components/SelectNext/Select.module.css index 687778bc..359ec7e9 100644 --- a/packages/components/src/components/SelectNext/Select.module.css +++ b/packages/components/src/components/SelectNext/Select.module.css @@ -1,3 +1,9 @@ +.base[data-readonly] { + .chevron { + color: var(--kbq-states-icon-disabled); + } +} + /* addons */ .addon { pointer-events: none; diff --git a/packages/components/src/components/SelectNext/Select.stories.tsx b/packages/components/src/components/SelectNext/Select.stories.tsx index af3e02df..1bf2c178 100644 --- a/packages/components/src/components/SelectNext/Select.stories.tsx +++ b/packages/components/src/components/SelectNext/Select.stories.tsx @@ -32,7 +32,7 @@ const meta = { 'Select.ItemAddon': Select.ItemAddon, }, argTypes: {}, - tags: ['status:updated', 'date:2026-07-02'], + tags: ['status:updated', 'date:2026-07-06'], } satisfies Meta; export default meta; @@ -474,10 +474,32 @@ export const Disabled: Story = { items={options} caption="disabled" label="Attack type" + defaultValue={[1]} selectionMode="multiple" style={{ inlineSize: 200 }} placeholder="Select an option" isDisabled + isClearable + > + {(item) => {item.name}} + + ); + }, +}; + +export const ReadOnly: Story = { + render: function Render() { + return ( + diff --git a/packages/components/src/components/SelectNext/Select.tsx b/packages/components/src/components/SelectNext/Select.tsx index b433ffbc..9ad00021 100644 --- a/packages/components/src/components/SelectNext/Select.tsx +++ b/packages/components/src/components/SelectNext/Select.tsx @@ -9,6 +9,7 @@ import { useControlledState, mergeProps, useElementSize, + clsx, } from '@koobiq/react-core'; import { IconChevronDownS16 } from '@koobiq/react-icons'; import { @@ -29,7 +30,7 @@ import type { BaseCollection, SelectStateOptions, } from '@koobiq/react-primitives'; -import type { SelectionMode } from '@react-types/select'; +import type { SelectionMode, ChangeValueType } from '@react-types/select'; import { Divider } from '../Divider'; import { useForm } from '../Form'; @@ -94,6 +95,7 @@ function SelectInner({ isRequired, onLoadMore, isDisabled, + isReadOnly, fullWidth, className, isLoading, @@ -141,12 +143,14 @@ function SelectInner({ setInputValue, ]); - const clearButtonIsHidden = isDisabled || !inState.selectedItems.length; + const clearButtonIsHidden = !inState.selectedItems.length; const handleClear = useCallback(() => { + if (isReadOnly) return; + inState.selectionManager.setSelectedKeys(new Set()); onClear?.(); - }, [onClear, inState]); + }, [isReadOnly, onClear, inState]); const { menuProps, @@ -177,8 +181,9 @@ function SelectInner({ 'data-testid': testId, 'data-invalid': isInvalid || undefined, 'data-disabled': isDisabled || undefined, + 'data-readonly': isReadOnly || undefined, 'data-required': isRequired || undefined, - className, + className: clsx(s.base, className), fullWidth, labelPlacement, labelAlign, @@ -228,6 +233,7 @@ function SelectInner({ const clearButtonProps = mergeProps( { isClearable, + isDisabled: isReadOnly || isDisabled, onPress: handleClear, className: s.clearButton, isHidden: clearButtonIsHidden, @@ -249,8 +255,12 @@ function SelectInner({ startAddon, onMouseDown: (e) => { if (e.currentTarget !== e.target || isDisabled) return; + e.preventDefault(); listBoxRef?.current?.focus(); + + if (isReadOnly) return; + inState.open(); }, endAddon: ( @@ -353,7 +363,8 @@ function SelectInner({ {renderValue(inState, { isInvalid, - isDisabled: props.isDisabled, + isDisabled, + isReadOnly, isRequired: props.isRequired, })} @@ -385,22 +396,64 @@ function StandaloneSelect< }) { const props = { ...inProps, collection, children: null, items: null }; - const { isDisabled: formIsDisabled } = useForm(); + const { isDisabled: formIsDisabled, isReadOnly: formIsReadOnly } = useForm(); const isDisabled = inProps?.isDisabled ?? formIsDisabled; + const isReadOnly = inProps?.isReadOnly ?? formIsReadOnly; + + const { + value: valueProp, + defaultValue: defaultValueProp, + onChange, + } = inProps as SelectNextProps; + + const defaultValue = + defaultValueProp ?? (inProps.selectionMode === 'multiple' ? [] : null); + + // useSelectState has no isReadOnly support, so the value is lifted here and + // always passed down as controlled — selection updates can then be dropped + // in handleChange before they reach the state. + const [value, setValue] = useControlledState( + valueProp, + defaultValue, + onChange + ); + + const handleChange = useCallback( + (value: ChangeValueType) => { + if (isReadOnly) return; + + setValue(value); + }, + [isReadOnly, setValue] + ); const state = useSelectState( removeDataAttributes({ ...props, + value, + onChange: handleChange, isDisabled, } as unknown as SelectStateOptions) ); + const selectState = isReadOnly + ? ({ + ...state, + open() { + return undefined; + }, + toggle() { + return undefined; + }, + } satisfies SelectState) + : state; + return ( ); } diff --git a/packages/components/src/components/SelectNext/__tests__/Select.test.tsx b/packages/components/src/components/SelectNext/__tests__/Select.test.tsx index 3afbccf8..46ff7a1e 100644 --- a/packages/components/src/components/SelectNext/__tests__/Select.test.tsx +++ b/packages/components/src/components/SelectNext/__tests__/Select.test.tsx @@ -34,6 +34,7 @@ describe('Select', () => { const getRoot = () => screen.getByTestId('root'); const getControl = () => screen.getByTestId('control'); const getPopover = () => screen.getByTestId('popover'); + const queryPopover = () => screen.queryByTestId('popover'); const getClearButton = () => screen.queryByLabelText('clear-button'); const getGroup = () => screen.getByTestId('group'); const getOptions = () => screen.getAllByRole('option'); @@ -253,6 +254,11 @@ describe('Select', () => { expect(getRoot()).toHaveAttribute('data-disabled', 'true'); }); + it('check the isReadOnly prop', () => { + render(); expect(getRoot()).toHaveAttribute('data-required', 'true'); @@ -264,6 +270,215 @@ describe('Select', () => { }); }); + describe('read only', () => { + it('should remain focusable but not open the dropdown', async () => { + const onOpenChange = vi.fn(); + + render( + + ); + + await userEvent.tab(); + await userEvent.click(getControl()); + + expect(getRoot()).toHaveAttribute('data-readonly', 'true'); + expect(getControl()).toHaveFocus(); + expect(queryPopover()).not.toBeInTheDocument(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('should not open the dropdown via keyboard', async () => { + const onOpenChange = vi.fn(); + + render( + + ); + + await userEvent.tab(); + + await userEvent.keyboard('{Enter}'); + await userEvent.keyboard(' '); + await userEvent.keyboard('{ArrowDown}'); + + expect(queryPopover()).not.toBeInTheDocument(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('should not change the selection with closed keyboard selection shortcuts', async () => { + const onChange = vi.fn(); + + render( + + ); + + await userEvent.tab(); + + await userEvent.keyboard('{ArrowLeft}1'); + + expect(onChange).not.toHaveBeenCalled(); + expect(getControl()).toHaveTextContent('2'); + }); + + it('should not change the selection when an option is clicked', async () => { + const onChange = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('option', { name: '2' })); + + expect(onChange).not.toHaveBeenCalled(); + expect(getControl()).toHaveTextContent('1'); + }); + + it('should render a disabled clear button', async () => { + const onChange = vi.fn(); + const onClear = vi.fn(); + + render( + + ); + + const clearButton = getClearButton(); + + expect(clearButton).toBeInTheDocument(); + expect(clearButton).not.toHaveAttribute('aria-hidden', 'true'); + expect(clearButton).toBeDisabled(); + + if (clearButton) await userEvent.click(clearButton); + + expect(onChange).not.toHaveBeenCalled(); + expect(onClear).not.toHaveBeenCalled(); + expect(getControl()).toHaveTextContent('1'); + }); + + it('should update when the controlled value changes externally', () => { + const { rerender } = render( + + ); + + rerender( + + ); + + expect(getControl()).toHaveTextContent('2'); + }); + + it('should pass isReadOnly to renderValue', () => { + render( + + ); + + expect(screen.getByTestId('read-only-state')).toHaveTextContent('true'); + }); + + it('should inherit isReadOnly from Form', async () => { + const onChange = vi.fn(); + + render( +
+ +
+ ); + + await userEvent.click(screen.getByRole('option', { name: '2' })); + + expect(getRoot()).toHaveAttribute('data-readonly', 'true'); + expect(onChange).not.toHaveBeenCalled(); + expect(getControl()).toHaveTextContent('1'); + }); + + it('should override Form isReadOnly with false', async () => { + const onChange = vi.fn(); + + render( +
+ +
+ ); + + await userEvent.click(screen.getByRole('option', { name: '2' })); + + expect(getRoot()).not.toHaveAttribute('data-readonly', 'true'); + expect(onChange).toHaveBeenCalledWith('2'); + expect(getControl()).toHaveTextContent('2'); + }); + }); + describe('clear button', () => { it('should render clear button only when a value is selected', () => { const { rerender } = render( @@ -338,7 +553,7 @@ describe('Select', () => { expect(onClear).toHaveBeenCalledTimes(1); }); - it('should NOT render clear button when Select is disabled', async () => { + it('should render a disabled clear button when Select is disabled', async () => { const onClear = vi.fn(); render( @@ -355,7 +570,15 @@ describe('Select', () => { ); - expect(getClearButton()).toHaveAttribute('aria-hidden', 'true'); + const clearButton = getClearButton(); + + expect(clearButton).toBeInTheDocument(); + expect(clearButton).not.toHaveAttribute('aria-hidden', 'true'); + expect(clearButton).toBeDisabled(); + + if (clearButton) await userEvent.click(clearButton); + + expect(onClear).not.toHaveBeenCalled(); }); }); @@ -464,7 +687,7 @@ describe('Select', () => { ); const input = getSearchInput(); - input.focus(); + await userEvent.click(input); const [opt1, opt2] = getOptions(); @@ -497,7 +720,7 @@ describe('Select', () => { ); const input = getSearchInput(); - input.focus(); + await userEvent.click(input); await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}'); diff --git a/packages/components/src/components/SelectNext/__tests__/SelectMultiple.test.tsx b/packages/components/src/components/SelectNext/__tests__/SelectMultiple.test.tsx index 40c72bf7..261c13c9 100644 --- a/packages/components/src/components/SelectNext/__tests__/SelectMultiple.test.tsx +++ b/packages/components/src/components/SelectNext/__tests__/SelectMultiple.test.tsx @@ -84,6 +84,83 @@ describe('Select_multiple', () => { expect(onChange).toBeCalledTimes(0); }); + it('should not update selection when an item is clicked in read-only state', async () => { + const onChange = vi.fn(); + + render( + renderComponent({ + value: [1, 3], + onChange, + isReadOnly: true, + }) + ); + + await userEvent.click(screen.getByTestId('option-2')); + + const selectedItems = screen.getByLabelText('Selected items'); + + expect(onChange).not.toHaveBeenCalled(); + expect(selectedItems).toHaveTextContent('1'); + expect(selectedItems).toHaveTextContent('3'); + expect(selectedItems).not.toHaveTextContent('2'); + }); + + it('should render a disabled clear button in read-only state', async () => { + const onChange = vi.fn(); + const onClear = vi.fn(); + + render( + renderComponent({ + value: [1, 3], + onChange, + onClear, + isClearable: true, + isReadOnly: true, + slotProps: { + clearButton: { + 'aria-label': 'clear-button', + }, + }, + }) + ); + + const clearButton = screen.getByLabelText('clear-button'); + + expect(clearButton).toBeInTheDocument(); + expect(clearButton).not.toHaveAttribute('aria-hidden', 'true'); + expect(clearButton).toBeDisabled(); + + await userEvent.click(clearButton); + + expect(onChange).not.toHaveBeenCalled(); + expect(onClear).not.toHaveBeenCalled(); + }); + + it.each(['responsive', 'multiline'] as const)( + 'should render disabled tag remove actions with %s overflow in read-only state', + (selectedTagsOverflow) => { + render( + renderComponent({ + value: [1, 3], + defaultOpen: false, + isReadOnly: true, + selectedTagsOverflow, + }) + ); + + const removeButtons = within( + screen.getByLabelText('Selected items') + ).getAllByRole('button', { hidden: true }); + + expect(removeButtons).toHaveLength(2); + + removeButtons.forEach((button) => { + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveAttribute('data-disabled', 'true'); + }); + } + ); + it('should render default tags with the selected item text when renderTag is not provided', () => { render(renderComponent({ value: [1, 3] })); @@ -97,8 +174,13 @@ describe('Select_multiple', () => { render( renderComponent({ value: [1, 3], - renderTag: (item, tagProps) => ( -
+ renderTag: (item, { className, ref, 'aria-hidden': ariaHidden }) => ( +
{item.key}
), diff --git a/packages/components/src/components/SelectNext/types.ts b/packages/components/src/components/SelectNext/types.ts index 116c3a06..0fc371a3 100644 --- a/packages/components/src/components/SelectNext/types.ts +++ b/packages/components/src/components/SelectNext/types.ts @@ -68,6 +68,8 @@ export type SelectNextProps< className?: string; /** Whether the field can be emptied. */ isClearable?: boolean; + /** Whether the selection can be focused but not changed by the user. */ + isReadOnly?: boolean; /** Addon placed before the children. */ startAddon?: ReactNode; /** Addon placed after the children. */ @@ -108,6 +110,7 @@ export type SelectNextProps< states: { isInvalid?: boolean; isDisabled?: boolean; + isReadOnly?: boolean; isRequired?: boolean; } ) => ReactNode; diff --git a/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx b/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx index 7d06f810..7cbdf768 100644 --- a/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx +++ b/packages/components/src/components/SelectedTags/SelectedTagsMultiline.tsx @@ -37,15 +37,16 @@ export function SelectedTagsMultiline({ }; const tagProps: TagProps = { - isDisabled, className: s.tag, variant: isInvalid ? 'error-fade' : 'contrast-fade', - allowsRemoving: !isReadOnly, + allowsRemoving: true, + isDisabled, slotProps: { removeIcon: { as: 'div', tabIndex: undefined, onPress: onRemove, + isDisabled: isReadOnly || isDisabled, }, }, }; diff --git a/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx b/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx index 1b10a363..574c23b9 100644 --- a/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx +++ b/packages/components/src/components/SelectedTags/SelectedTagsResponsive.tsx @@ -53,15 +53,16 @@ export function SelectedTagsResponsive({ const tagProps: TagProps = { ref: itemsRefs[i], className: s.tag, - isDisabled, 'aria-hidden': !visibleMap[i] || undefined, - allowsRemoving: !isReadOnly, + allowsRemoving: true, + isDisabled, variant: isInvalid ? 'error-fade' : 'contrast-fade', slotProps: { removeIcon: { as: 'div', tabIndex: undefined, onPress: onRemove, + isDisabled: isReadOnly || isDisabled, }, }, };