Skip to content
Merged
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
3 changes: 2 additions & 1 deletion core/src/components/cat-select-demo/cat-select-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
})
};
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions core/src/components/cat-select/cat-select.e2e.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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(<cat-select label="Label" />);
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<HTMLElement>(
'.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();
});
});
52 changes: 52 additions & 0 deletions core/src/components/cat-select/cat-select.screenshot.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div style={{ padding: '16px', minHeight: '300px' }}>
<cat-select label="Label"></cat-select>
</div>
);

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(
<div style={{ padding: '16px', minHeight: '300px' }}>
<cat-select label="Label" multiple></cat-select>
</div>
);

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();
});
});
13 changes: 13 additions & 0 deletions core/src/components/cat-select/cat-select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
92 changes: 91 additions & 1 deletion core/src/components/cat-select/cat-select.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(<cat-select label="Label" />);
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<HTMLElement>(
'.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(<cat-select label="Label" />);
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(<cat-select label="Label" multiple />);
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(<cat-select label="Label" />);
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);
});
});
});
65 changes: 49 additions & 16 deletions core/src/components/cat-select/cat-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface Page<T> {
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;
Expand Down Expand Up @@ -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));

Expand All @@ -886,15 +889,17 @@ export class CatSelect {
return (
<li
role="option"
class="select-option"
class={{ 'select-option': true, 'select-option-disabled': isOptionDisabled }}
id={`select-${this.id}-option-${i}`}
aria-selected={isOptionSelected ? 'true' : 'false'}
aria-disabled={isOptionDisabled ? 'true' : undefined}
key={item.item.id}
>
{this.multiple ? (
<cat-checkbox
class={{ 'select-option-active': this.state.activeOptionIndex === i }}
checked={isOptionSelected}
disabled={isOptionDisabled}
tabIndex={-1}
labelLeft
onFocus={() => this.input?.focus()}
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 + ')' : '';
}
Expand Down
Loading