diff --git a/core/src/components/cat-select-demo/cat-select-demo.tsx b/core/src/components/cat-select-demo/cat-select-demo.tsx
index 3bc39268..156ceb75 100644
--- a/core/src/components/cat-select-demo/cat-select-demo.tsx
+++ b/core/src/components/cat-select-demo/cat-select-demo.tsx
@@ -294,7 +294,8 @@ export class CatSelectTest {
avatar: {
src: `https://picsum.photos/id/${Math.floor(Math.random() * 100)}/200`,
round: true
- }
+ },
+ disabled: country.id === '2'
})
};
}
diff --git a/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-multiple-select-1-chromium-darwin.png b/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-multiple-select-1-chromium-darwin.png
new file mode 100644
index 00000000..5b9c2050
Binary files /dev/null and b/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-multiple-select-1-chromium-darwin.png differ
diff --git a/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-multiple-select-1-chromium-linux.png b/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-multiple-select-1-chromium-linux.png
new file mode 100644
index 00000000..0d35fae9
Binary files /dev/null and b/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-multiple-select-1-chromium-linux.png differ
diff --git a/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-single-select-1-chromium-darwin.png b/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-single-select-1-chromium-darwin.png
new file mode 100644
index 00000000..990381fd
Binary files /dev/null and b/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-single-select-1-chromium-darwin.png differ
diff --git a/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-single-select-1-chromium-linux.png b/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-single-select-1-chromium-linux.png
new file mode 100644
index 00000000..52fb0861
Binary files /dev/null and b/core/src/components/cat-select/__screenshots__/cat-select.screenshot.tsx/cat-select-screenshot-renders-disabled-option-for-single-select-1-chromium-linux.png differ
diff --git a/core/src/components/cat-select/cat-select.e2e.tsx b/core/src/components/cat-select/cat-select.e2e.tsx
index b2847a50..4f9491f4 100644
--- a/core/src/components/cat-select/cat-select.e2e.tsx
+++ b/core/src/components/cat-select/cat-select.e2e.tsx
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { render, waitForStable, h } from '@stencil/vitest';
+import { userEvent } from '@vitest/browser/context';
import { of } from 'rxjs';
describe('cat-select', () => {
@@ -27,4 +28,34 @@ describe('cat-select', () => {
await waitForStable(root);
expect(changeSpy).not.toHaveReceivedEvent();
});
+
+ it('should not select a disabled option when clicked', async () => {
+ const { root, spyOnEvent } = await render();
+ const changeSpy = spyOnEvent('catChange');
+ (root as HTMLCatSelectElement).connect({
+ resolve: () => of([]),
+ retrieve: () =>
+ of({
+ content: [
+ { id: 'option1', label: 'Option 1', disabled: true },
+ { id: 'option2', label: 'Option 2' }
+ ],
+ last: true
+ }),
+ render: (item: { label: string; disabled?: boolean }) => ({ label: item.label, disabled: item.disabled })
+ });
+ await waitForStable(root);
+
+ await userEvent.click(root.shadowRoot!.querySelector('.select-wrapper')!);
+ await waitForStable(root);
+
+ const disabledOption = root.shadowRoot!.querySelector(
+ '.select-option-disabled .select-option-single'
+ )!;
+ await userEvent.click(disabledOption, { force: true });
+ await waitForStable(root);
+
+ expect(changeSpy).not.toHaveReceivedEvent();
+ expect((root as HTMLCatSelectElement).value).toBeFalsy();
+ });
});
diff --git a/core/src/components/cat-select/cat-select.screenshot.tsx b/core/src/components/cat-select/cat-select.screenshot.tsx
new file mode 100644
index 00000000..62fa4eae
--- /dev/null
+++ b/core/src/components/cat-select/cat-select.screenshot.tsx
@@ -0,0 +1,52 @@
+import { describe, it, expect } from 'vitest';
+import { render, h, waitForStable } from '@stencil/vitest';
+import { userEvent } from '@vitest/browser/context';
+import { objectArrayConnector } from './connectors';
+
+describe('cat-select screenshot', () => {
+ it('renders disabled option for single select', async () => {
+ const { root } = await render(
+
+
+
+ );
+
+ const select = root.querySelector('cat-select') as HTMLCatSelectElement;
+ await select.connect(
+ objectArrayConnector([
+ { id: 'option1', label: 'Option 1' },
+ { id: 'option2', label: 'Option 2', disabled: true },
+ { id: 'option3', label: 'Option 3' }
+ ])
+ );
+ await waitForStable(select);
+
+ await userEvent.click(select.shadowRoot!.querySelector('.select-wrapper')!);
+ await waitForStable(select);
+
+ await expect(root).toMatchScreenshot();
+ });
+
+ it('renders disabled option for multiple select', async () => {
+ const { root } = await render(
+
+
+
+ );
+
+ const select = root.querySelector('cat-select') as HTMLCatSelectElement;
+ await select.connect(
+ objectArrayConnector([
+ { id: 'option1', label: 'Option 1' },
+ { id: 'option2', label: 'Option 2', disabled: true },
+ { id: 'option3', label: 'Option 3' }
+ ])
+ );
+ await waitForStable(select);
+
+ await userEvent.click(select.shadowRoot!.querySelector('.select-wrapper')!);
+ await waitForStable(select);
+
+ await expect(root).toMatchScreenshot();
+ });
+});
diff --git a/core/src/components/cat-select/cat-select.scss b/core/src/components/cat-select/cat-select.scss
index 6c2c7864..f588292b 100644
--- a/core/src/components/cat-select/cat-select.scss
+++ b/core/src/components/cat-select/cat-select.scss
@@ -253,6 +253,19 @@ cat-spinner {
background-color: cat-token('color.theme.secondary.bg', 0.05);
}
+.select-option-disabled {
+ color: cat-token('color.ui.font.muted');
+
+ &:hover {
+ background-color: transparent;
+ }
+
+ .select-option-single {
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+}
+
.select-option-active {
outline: 2px solid cat-token('color.ui.border.focus');
outline-offset: -2px;
diff --git a/core/src/components/cat-select/cat-select.spec.tsx b/core/src/components/cat-select/cat-select.spec.tsx
index fafdc515..da11f79f 100644
--- a/core/src/components/cat-select/cat-select.spec.tsx
+++ b/core/src/components/cat-select/cat-select.spec.tsx
@@ -31,7 +31,7 @@ vi.mock('@floating-ui/dom', () => ({
}));
import './cat-select';
-import { stringArrayConnector } from './connectors';
+import { objectArrayConnector, stringArrayConnector } from './connectors';
import { of, Subject } from 'rxjs';
describe('cat-select', () => {
@@ -403,4 +403,94 @@ describe('cat-select', () => {
});
});
});
+
+ describe('disabled options', () => {
+ it('should not select a disabled option on click in single-select mode', async () => {
+ const { root, waitForChanges, instance } = await render();
+ await instance.connect(
+ objectArrayConnector([
+ { id: 'option1', label: 'Option 1', disabled: true },
+ { id: 'option2', label: 'Option 2' }
+ ])
+ );
+
+ const trigger = root?.shadowRoot?.querySelector('.select-wrapper');
+ trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await waitForChanges();
+
+ const disabledOption = root?.shadowRoot?.querySelector(
+ '.select-option-disabled .select-option-single'
+ );
+ disabledOption?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await waitForChanges();
+
+ expect(instance['state'].selection).toHaveLength(0);
+ });
+
+ it('should mark a disabled option with select-option-disabled class and aria-disabled', async () => {
+ const { root, waitForChanges, instance } = await render();
+ await instance.connect(
+ objectArrayConnector([
+ { id: 'option1', label: 'Option 1', disabled: true },
+ { id: 'option2', label: 'Option 2' }
+ ])
+ );
+
+ const trigger = root?.shadowRoot?.querySelector('.select-wrapper');
+ trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await waitForChanges();
+
+ const options = root?.shadowRoot?.querySelectorAll('li.select-option');
+ expect(options?.[0].classList.contains('select-option-disabled')).toBe(true);
+ expect(options?.[0].getAttribute('aria-disabled')).toBe('true');
+ expect(options?.[1].classList.contains('select-option-disabled')).toBe(false);
+ expect(options?.[1].hasAttribute('aria-disabled')).toBe(false);
+ });
+
+ it('should not toggle a disabled option in multiple-select mode', async () => {
+ const { root, waitForChanges, instance } = await render();
+ await instance.connect(
+ objectArrayConnector([
+ { id: 'option1', label: 'Option 1', disabled: true },
+ { id: 'option2', label: 'Option 2' }
+ ])
+ );
+
+ const trigger = root?.shadowRoot?.querySelector('.select-wrapper');
+ trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await waitForChanges();
+
+ const checkbox = root?.shadowRoot?.querySelector('cat-checkbox');
+ expect(checkbox?.getAttribute('disabled')).not.toBeNull();
+
+ instance['toggle']({ item: { id: 'option1' }, render: { label: 'Option 1', disabled: true } });
+ await waitForChanges();
+
+ expect(instance['state'].selection).toHaveLength(0);
+ });
+
+ it('should skip disabled options when navigating with arrow keys', async () => {
+ const { instance, waitForChanges } = await render();
+ await instance.connect(
+ objectArrayConnector([
+ { id: 'option1', label: 'Option 1' },
+ { id: 'option2', label: 'Option 2', disabled: true },
+ { id: 'option3', label: 'Option 3' }
+ ])
+ );
+
+ instance['show']();
+ await waitForChanges();
+ instance['patchState']({ activeOptionIndex: 0 });
+
+ instance['onArrowKeyDown']({
+ key: 'ArrowDown',
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn()
+ } as unknown as KeyboardEvent);
+ await waitForChanges();
+
+ expect(instance['state'].activeOptionIndex).toBe(2);
+ });
+ });
});
diff --git a/core/src/components/cat-select/cat-select.tsx b/core/src/components/cat-select/cat-select.tsx
index 542adf2e..ce988cce 100644
--- a/core/src/components/cat-select/cat-select.tsx
+++ b/core/src/components/cat-select/cat-select.tsx
@@ -35,6 +35,8 @@ export interface Page {
export interface RenderInfo {
label: string;
description?: string;
+ /** Marks the option as disabled, preventing it from being selected or focused via keyboard navigation. */
+ disabled?: boolean;
avatar?: {
src?: string;
round?: boolean;
@@ -872,6 +874,7 @@ export class CatSelect {
private get optionsList() {
return this.state.options.map((item, i) => {
const isTagOption = this.tags && item.item.id === `select-${this.id}-option-tag`;
+ const isOptionDisabled = !!item.render.disabled;
const isOptionSelected = this.isSelected(item.item.id) || (this.tags && this.isTagSelected(item.render.label));
@@ -886,15 +889,17 @@ export class CatSelect {
return (
{this.multiple ? (
this.input?.focus()}
@@ -931,7 +936,12 @@ export class CatSelect {
'select-option-active': this.state.activeOptionIndex === i
}}
onFocus={() => this.input?.focus()}
- onClick={() => (isTagOption ? this.createTag(item.render.label) : this.select(item))}
+ onClick={() => {
+ if (isOptionDisabled) {
+ return;
+ }
+ isTagOption ? this.createTag(item.render.label) : this.select(item);
+ }}
tabIndex={-1}
>
{item.render.avatar ? (
@@ -1033,6 +1043,9 @@ export class CatSelect {
}
private select(item: { item: Item; render: RenderInfo }) {
+ if (item.render.disabled) {
+ return;
+ }
if (!this.isSelected(item.item.id)) {
let newSelection;
if (this.multiple) {
@@ -1071,6 +1084,9 @@ export class CatSelect {
}
private toggle(item: { item: Item; render: RenderInfo }) {
+ if (item.render.disabled) {
+ return;
+ }
this.isSelected(item.item.id)
? this.deselect(item.item.id)
: this.tags && this.isTagSelected(item.render.label)
@@ -1194,24 +1210,28 @@ export class CatSelect {
this.input?.focus();
switch (event.key) {
- case 'ArrowDown':
+ case 'ArrowDown': {
preventDefault = true;
- this.state.isOpen
- ? this.patchState({
- activeOptionIndex: Math.min(this.state.activeOptionIndex + 1, this.state.options.length - 1),
- activeSelectionIndex: -1
- })
- : this.show();
+ if (this.state.isOpen) {
+ const nextIndex = this.findEnabledOptionIndex(1);
+ if (nextIndex < this.state.options.length) {
+ this.patchState({ activeOptionIndex: nextIndex, activeSelectionIndex: -1 });
+ }
+ } else {
+ this.show();
+ }
break;
- case 'ArrowUp':
+ }
+ case 'ArrowUp': {
preventDefault = true;
- this.state.activeOptionIndex >= 0
- ? this.patchState({
- activeOptionIndex: Math.max(this.state.activeOptionIndex - 1, -1),
- activeSelectionIndex: -1
- })
- : this.hide();
+ if (this.state.activeOptionIndex >= 0) {
+ const prevIndex = Math.max(this.findEnabledOptionIndex(-1), -1);
+ this.patchState({ activeOptionIndex: prevIndex, activeSelectionIndex: -1 });
+ } else {
+ this.hide();
+ }
break;
+ }
case 'ArrowLeft':
if (this.input?.selectionStart === 0) {
preventDefault = true;
@@ -1241,6 +1261,19 @@ export class CatSelect {
}
}
+ /**
+ * Finds the next option index relative to `activeOptionIndex`, moving in `direction`, that is not disabled.
+ * Returns an out-of-bounds index (< 0 or >= options.length) if no enabled option is found.
+ */
+ private findEnabledOptionIndex(direction: 1 | -1): number {
+ const options = this.state.options;
+ let index = this.state.activeOptionIndex + direction;
+ while (index >= 0 && index < options.length && options[index].render.disabled) {
+ index += direction;
+ }
+ return index;
+ }
+
private get tagTextHelp() {
return this.tagHint && !this.isTagSelected(this.state.term) ? ' (' + this.tagHint + ')' : '';
}