Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/components/src/components/SelectNext/Select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,14 @@ When the select component is disabled, it cannot be interacted with.

<Story of={Stories.Disabled} />

### 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.

<Story of={Stories.ReadOnly} />

### Clear button

If you pass the `isClearable` prop to the select, it will have a clear
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
.base[data-readonly] {
.chevron {
color: var(--kbq-states-icon-disabled);
}
}

/* addons */
.addon {
pointer-events: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Select>;

export default meta;
Expand Down Expand Up @@ -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) => <Select.Item id={item.id}>{item.name}</Select.Item>}
</Select>
);
},
};

export const ReadOnly: Story = {
render: function Render() {
return (
<Select
items={options}
caption="read only"
defaultValue={[1]}
label="Attack type"
selectionMode="multiple"
style={{ inlineSize: 200 }}
placeholder="Select an option"
isClearable
isReadOnly
>
{(item) => <Select.Item id={item.id}>{item.name}</Select.Item>}
</Select>
Expand Down
69 changes: 61 additions & 8 deletions packages/components/src/components/SelectNext/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useControlledState,
mergeProps,
useElementSize,
clsx,
} from '@koobiq/react-core';
import { IconChevronDownS16 } from '@koobiq/react-icons';
import {
Expand All @@ -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';
Expand Down Expand Up @@ -94,6 +95,7 @@ function SelectInner<T extends object, M extends SelectionMode = 'single'>({
isRequired,
onLoadMore,
isDisabled,
isReadOnly,
fullWidth,
className,
isLoading,
Expand Down Expand Up @@ -141,12 +143,14 @@ function SelectInner<T extends object, M extends SelectionMode = 'single'>({
setInputValue,
]);

const clearButtonIsHidden = isDisabled || !inState.selectedItems.length;
const clearButtonIsHidden = !inState.selectedItems.length;
Comment thread
KamilEmeleev marked this conversation as resolved.

const handleClear = useCallback(() => {
if (isReadOnly) return;

inState.selectionManager.setSelectedKeys(new Set());
onClear?.();
}, [onClear, inState]);
}, [isReadOnly, onClear, inState]);

const {
menuProps,
Expand Down Expand Up @@ -177,8 +181,9 @@ function SelectInner<T extends object, M extends SelectionMode = 'single'>({
'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,
Expand Down Expand Up @@ -228,6 +233,7 @@ function SelectInner<T extends object, M extends SelectionMode = 'single'>({
const clearButtonProps = mergeProps(
{
isClearable,
isDisabled: isReadOnly || isDisabled,
onPress: handleClear,
className: s.clearButton,
isHidden: clearButtonIsHidden,
Expand All @@ -249,8 +255,12 @@ function SelectInner<T extends object, M extends SelectionMode = 'single'>({
startAddon,
onMouseDown: (e) => {
if (e.currentTarget !== e.target || isDisabled) return;

e.preventDefault();
listBoxRef?.current?.focus();

if (isReadOnly) return;

inState.open();
},
endAddon: (
Expand Down Expand Up @@ -353,7 +363,8 @@ function SelectInner<T extends object, M extends SelectionMode = 'single'>({
<FormField.Select {...controlProps}>
{renderValue(inState, {
isInvalid,
isDisabled: props.isDisabled,
isDisabled,
isReadOnly,
isRequired: props.isRequired,
})}
</FormField.Select>
Expand Down Expand Up @@ -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<T, SelectionMode>;
Comment thread
KamilEmeleev marked this conversation as resolved.

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<SelectionMode>) => {
if (isReadOnly) return;

setValue(value);
},
[isReadOnly, setValue]
);
Comment thread
KamilEmeleev marked this conversation as resolved.

const state = useSelectState<T, M>(
removeDataAttributes({
...props,
value,
onChange: handleChange,
isDisabled,
} as unknown as SelectStateOptions<T, M>)
);

const selectState = isReadOnly
? ({
...state,
open() {
return undefined;
},
toggle() {
return undefined;
},
} satisfies SelectState<T, M>)
: state;

return (
<SelectInner
state={state}
state={selectState}
listBoxRef={listBoxRef}
props={{ ...props, isDisabled }}
props={{ ...props, isDisabled, isReadOnly }}
/>
);
}
Expand Down
Loading
Loading