From 2c32cf58b512d6cccd00fb3712b30cea07cf6d81 Mon Sep 17 00:00:00 2001 From: jase88 <804836+jase88@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:29:42 +0200 Subject: [PATCH] fix(multiselect): do not select focused option on tab Tab is a focus-navigation key per WAI-ARIA Authoring Practices: it moves focus and closes the popup without changing selection. Selecting the focused option on Tab made tabbing away behave like Enter/Space. Closes #395 --- .../src/multiselect/multiselect.spec.ts | 157 ++++++++++++++++++ .../optimus-ui/src/multiselect/multiselect.ts | 8 +- 2 files changed, 158 insertions(+), 7 deletions(-) diff --git a/packages/optimus-ui/src/multiselect/multiselect.spec.ts b/packages/optimus-ui/src/multiselect/multiselect.spec.ts index 81996a0fed..cbd7772814 100644 --- a/packages/optimus-ui/src/multiselect/multiselect.spec.ts +++ b/packages/optimus-ui/src/multiselect/multiselect.spec.ts @@ -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(); diff --git a/packages/optimus-ui/src/multiselect/multiselect.ts b/packages/optimus-ui/src/multiselect/multiselect.ts index 03384593b0..a8eefdf2bc 100755 --- a/packages/optimus-ui/src/multiselect/multiselect.ts +++ b/packages/optimus-ui/src/multiselect/multiselect.ts @@ -1825,19 +1825,13 @@ export class MultiSelect extends BaseEditableHolder { } } - 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); } }