From faaa4a7683f3b053188f411befde84405318fb23 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 6 Jul 2026 19:39:31 +0300 Subject: [PATCH 1/2] feat(SelectNext): add `isReadOnly` support --- .../src/components/SelectNext/Select.mdx | 8 + .../components/SelectNext/Select.module.css | 6 + .../components/SelectNext/Select.stories.tsx | 25 +- .../src/components/SelectNext/Select.tsx | 74 +++++- .../SelectNext/__tests__/Select.test.tsx | 231 +++++++++++++++++- .../__tests__/SelectMultiple.test.tsx | 86 ++++++- .../src/components/SelectNext/types.ts | 3 + .../SelectedTags/SelectedTagsMultiline.tsx | 5 +- .../SelectedTags/SelectedTagsResponsive.tsx | 5 +- 9 files changed, 425 insertions(+), 18 deletions(-) diff --git a/packages/components/src/components/SelectNext/Select.mdx b/packages/components/src/components/SelectNext/Select.mdx index 1bfcb42aa..8b6ee2c8f 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 687778bc6..359ec7e94 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 af3e02dff..a216e39a5 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; @@ -406,6 +406,7 @@ export const MultipleSelection: Story = { selectionMode="multiple" placeholder="Select an option" style={{ inlineSize: 'inherit' }} + isDisabled > {(item) => {item.name}} @@ -474,10 +475,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 b433ffbcf..953c8ca89 100644 --- a/packages/components/src/components/SelectNext/Select.tsx +++ b/packages/components/src/components/SelectNext/Select.tsx @@ -9,6 +9,8 @@ import { useControlledState, mergeProps, useElementSize, + type Key, + clsx, } from '@koobiq/react-core'; import { IconChevronDownS16 } from '@koobiq/react-icons'; import { @@ -62,6 +64,9 @@ import { } from './index'; import s from './Select.module.css'; +type SelectControlledValue = Key | readonly Key[] | null; +type SelectControlledChangeValue = Key | Key[] | null; + function SelectInner({ state: inState, props, @@ -94,6 +99,7 @@ function SelectInner({ isRequired, onLoadMore, isDisabled, + isReadOnly, fullWidth, className, isLoading, @@ -141,12 +147,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 +185,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 +237,7 @@ function SelectInner({ const clearButtonProps = mergeProps( { isClearable, + isDisabled: isReadOnly || isDisabled, onPress: handleClear, className: s.clearButton, isHidden: clearButtonIsHidden, @@ -249,8 +259,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 +367,8 @@ function SelectInner({ {renderValue(inState, { isInvalid, - isDisabled: props.isDisabled, + isDisabled, + isReadOnly, isRequired: props.isRequired, })} @@ -385,22 +400,67 @@ 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 defaultValue = + (inProps.defaultValue as SelectControlledValue | undefined) ?? + (inProps.selectionMode === 'multiple' ? [] : null); + + const [value, setValue] = useControlledState< + SelectControlledValue, + SelectControlledChangeValue + >( + inProps.value as SelectControlledValue | undefined, + defaultValue, + inProps.onChange as + | ((value: SelectControlledChangeValue) => void) + | undefined + ); + + const handleChange = useCallback( + (value: SelectControlledChangeValue) => { + 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; + }, + setValue() { + return undefined; + }, + setSelectedKey() { + 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 3afbccf8b..46ff7a1e5 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 40c72bf7e..261c13c9b 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 116c3a068..0fc371a33 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 7d06f8105..7cbdf7684 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 1b10a3631..574c23b96 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, }, }, }; From 85dbf6b091572689ae7aa4b0864739f8150fc35f Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 7 Jul 2026 10:13:45 +0300 Subject: [PATCH 2/2] fix: code review --- .../components/SelectNext/Select.stories.tsx | 1 - .../src/components/SelectNext/Select.tsx | 37 ++++++++----------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/components/src/components/SelectNext/Select.stories.tsx b/packages/components/src/components/SelectNext/Select.stories.tsx index a216e39a5..1bf2c1784 100644 --- a/packages/components/src/components/SelectNext/Select.stories.tsx +++ b/packages/components/src/components/SelectNext/Select.stories.tsx @@ -406,7 +406,6 @@ export const MultipleSelection: Story = { selectionMode="multiple" placeholder="Select an option" style={{ inlineSize: 'inherit' }} - isDisabled > {(item) => {item.name}} diff --git a/packages/components/src/components/SelectNext/Select.tsx b/packages/components/src/components/SelectNext/Select.tsx index 953c8ca89..9ad000211 100644 --- a/packages/components/src/components/SelectNext/Select.tsx +++ b/packages/components/src/components/SelectNext/Select.tsx @@ -9,7 +9,6 @@ import { useControlledState, mergeProps, useElementSize, - type Key, clsx, } from '@koobiq/react-core'; import { IconChevronDownS16 } from '@koobiq/react-icons'; @@ -31,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'; @@ -64,9 +63,6 @@ import { } from './index'; import s from './Select.module.css'; -type SelectControlledValue = Key | readonly Key[] | null; -type SelectControlledChangeValue = Key | Key[] | null; - function SelectInner({ state: inState, props, @@ -405,23 +401,26 @@ function StandaloneSelect< const isDisabled = inProps?.isDisabled ?? formIsDisabled; const isReadOnly = inProps?.isReadOnly ?? formIsReadOnly; + const { + value: valueProp, + defaultValue: defaultValueProp, + onChange, + } = inProps as SelectNextProps; + const defaultValue = - (inProps.defaultValue as SelectControlledValue | undefined) ?? - (inProps.selectionMode === 'multiple' ? [] : null); + defaultValueProp ?? (inProps.selectionMode === 'multiple' ? [] : null); - const [value, setValue] = useControlledState< - SelectControlledValue, - SelectControlledChangeValue - >( - inProps.value as SelectControlledValue | undefined, + // 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, - inProps.onChange as - | ((value: SelectControlledChangeValue) => void) - | undefined + onChange ); const handleChange = useCallback( - (value: SelectControlledChangeValue) => { + (value: ChangeValueType) => { if (isReadOnly) return; setValue(value); @@ -447,12 +446,6 @@ function StandaloneSelect< toggle() { return undefined; }, - setValue() { - return undefined; - }, - setSelectedKey() { - return undefined; - }, } satisfies SelectState) : state;