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
157 changes: 157 additions & 0 deletions packages/optimus-ui/src/multiselect/multiselect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,163 @@ describe('MultiSelect', () => {
});
});

describe('Tab Key Behavior', () => {
const dispatchTab = async (options: KeyboardEventInit = {}) => {
const keyEvent = new KeyboardEvent('keydown', { code: 'Tab', ...options });
spyOn(keyEvent, 'preventDefault');

multiSelect.onKeyDown(keyEvent);
await fixture.whenStable();
fixture.detectChanges();

return keyEvent;
};

describe('without focusable elements in the overlay', () => {
beforeEach(async () => {
component.filter = false;
multiSelect.showHeader = false;
fixture.detectChanges();

multiSelect.show();
await fixture.whenStable();
fixture.detectChanges();
});

it('should have no focusable elements when filter and header are disabled', () => {
expect(multiSelect.hasFocusableElements()).toBe(false);
});

it('should not select the focused option (#395)', async () => {
spyOn(component, 'onSelectionChange');
multiSelect.focusedOptionIndex.set(2);

await dispatchTab();

expect(multiSelect.modelValue()).toEqual([]);
expect(component.onSelectionChange).not.toHaveBeenCalled();
});

it('should keep previously selected options untouched', async () => {
multiSelect.onOptionSelect({ originalEvent: new MouseEvent('click'), option: component.options[0] });
await fixture.whenStable();
fixture.detectChanges();

spyOn(component, 'onSelectionChange');
multiSelect.focusedOptionIndex.set(2);

await dispatchTab();

expect(multiSelect.modelValue()).toEqual([component.options[0]]);
expect(component.onSelectionChange).not.toHaveBeenCalled();
});

it('should not deselect an already selected focused option', async () => {
multiSelect.onOptionSelect({ originalEvent: new MouseEvent('click'), option: component.options[1] });
await fixture.whenStable();
fixture.detectChanges();

multiSelect.focusedOptionIndex.set(1);

await dispatchTab();

expect(multiSelect.modelValue()).toEqual([component.options[1]]);
});

it('should close the overlay without preventing the default tab navigation', async () => {
multiSelect.focusedOptionIndex.set(2);

const keyEvent = await dispatchTab();

expect(multiSelect.overlayVisible).toBe(false);
expect(keyEvent.preventDefault).not.toHaveBeenCalled();
});

it('should not select anything when no option is focused', async () => {
spyOn(component, 'onSelectionChange');
multiSelect.focusedOptionIndex.set(-1);

await dispatchTab();

expect(multiSelect.modelValue()).toEqual([]);
expect(component.onSelectionChange).not.toHaveBeenCalled();
expect(multiSelect.overlayVisible).toBe(false);
});

it('should be a no-op while the overlay is closed', async () => {
multiSelect.hide();
await fixture.whenStable();
fixture.detectChanges();

spyOn(component, 'onSelectionChange');
multiSelect.focusedOptionIndex.set(2);

const keyEvent = await dispatchTab();

expect(multiSelect.modelValue()).toEqual([]);
expect(component.onSelectionChange).not.toHaveBeenCalled();
expect(multiSelect.overlayVisible).toBe(false);
expect(keyEvent.preventDefault).not.toHaveBeenCalled();
});
});

describe('with focusable elements in the overlay', () => {
beforeEach(async () => {
component.filter = true;
multiSelect.showHeader = true;
fixture.detectChanges();

multiSelect.show();
await fixture.whenStable();
fixture.detectChanges();
});

it('should move focus to the first hidden focusable element and keep the overlay open', async () => {
spyOn(component, 'onSelectionChange');
const firstHiddenFocusableElement = multiSelect.firstHiddenFocusableElementOnOverlay!.nativeElement;
spyOn(firstHiddenFocusableElement, 'focus');
multiSelect.focusedOptionIndex.set(2);

const keyEvent = await dispatchTab();

expect(firstHiddenFocusableElement.focus).toHaveBeenCalled();
expect(keyEvent.preventDefault).toHaveBeenCalled();
expect(multiSelect.overlayVisible).toBe(true);
expect(multiSelect.modelValue()).toEqual([]);
expect(component.onSelectionChange).not.toHaveBeenCalled();
});

it('should move focus to the last hidden focusable element on shift+tab', async () => {
const lastHiddenFocusableElement = multiSelect.lastHiddenFocusableElementOnOverlay!.nativeElement;
spyOn(lastHiddenFocusableElement, 'focus');
multiSelect.focusedOptionIndex.set(2);

const keyEvent = await dispatchTab({ shiftKey: true });

expect(lastHiddenFocusableElement.focus).toHaveBeenCalled();
expect(keyEvent.preventDefault).toHaveBeenCalled();
expect(multiSelect.modelValue()).toEqual([]);
});

it('should not select the focused option when tab is pressed inside the filter input', async () => {
spyOn(component, 'onSelectionChange');
multiSelect.focusedOptionIndex.set(2);

const keyEvent = new KeyboardEvent('keydown', { code: 'Tab' });
spyOn(keyEvent, 'preventDefault');

multiSelect.onFilterKeyDown(keyEvent);
await fixture.whenStable();
fixture.detectChanges();

expect(multiSelect.modelValue()).toEqual([]);
expect(component.onSelectionChange).not.toHaveBeenCalled();
expect(multiSelect.overlayVisible).toBe(true);
expect(keyEvent.preventDefault).not.toHaveBeenCalled();
});
});
});

describe('Accessibility', () => {
beforeEach(() => {
fixture.detectChanges();
Expand Down
8 changes: 1 addition & 7 deletions packages/optimus-ui/src/multiselect/multiselect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1825,19 +1825,13 @@ export class MultiSelect extends BaseEditableHolder<MultiSelectPassThrough> {
}
}

onTabKey(event, pressedInInputText = false) {
onTabKey(event: KeyboardEvent, pressedInInputText = false) {
if (!pressedInInputText) {
if (this.overlayVisible && this.hasFocusableElements()) {
focus(event.shiftKey ? this.lastHiddenFocusableElementOnOverlay?.nativeElement : this.firstHiddenFocusableElementOnOverlay?.nativeElement);

event.preventDefault();
} else {
if (this.focusedOptionIndex() !== -1) {
const option = this.visibleOptions()[this.focusedOptionIndex()];

!this.isSelected(option) && this.onOptionSelect({ originalEvent: event, option });
}

this.overlayVisible && this.hide(this.filter);
}
}
Expand Down
Loading