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 + ')' : ''; }