From 06cb0427ab2a0dcf84523d97a84106065fd17e17 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 22 Jul 2026 13:24:17 +0300 Subject: [PATCH 1/4] feat(app-switcher): add keyboard navigation (#DS-4246) --- .../app-switcher/app-switcher-dropdown-app.ts | 5 +- .../app-switcher-dropdown-site.ts | 5 +- .../app-switcher/app-switcher.en.md | 15 + .../components/app-switcher/app-switcher.html | 10 +- .../app-switcher/app-switcher.ru.md | 15 + .../app-switcher/app-switcher.spec.ts | 458 ++++++++++++++++++ .../components/app-switcher/app-switcher.ts | 199 +++++++- .../app-switcher/e2e.playwright-spec.ts | 71 +++ .../kbq-app-switcher-list-item.scss | 5 +- .../kbq-app-switcher-list-item.ts | 3 + .../components/app-switcher.api.md | 11 +- 11 files changed, 786 insertions(+), 11 deletions(-) diff --git a/packages/components/app-switcher/app-switcher-dropdown-app.ts b/packages/components/app-switcher/app-switcher-dropdown-app.ts index 764be0aa92..4165c55025 100644 --- a/packages/components/app-switcher/app-switcher-dropdown-app.ts +++ b/packages/components/app-switcher/app-switcher-dropdown-app.ts @@ -46,7 +46,10 @@ import { KbqAppSwitcherApp } from './app-switcher'; encapsulation: ViewEncapsulation.None, host: { class: 'kbq-app-switcher-dropdown-app', - '[class.kbq-dropdown-item]': 'false' + role: 'menuitem', + '[class.kbq-dropdown-item]': 'false', + '[attr.tabindex]': 'getTabIndex()', + '[attr.aria-haspopup]': "isNested ? 'menu' : null" }, exportAs: 'kbqAppSwitcherDropdownApp' }) diff --git a/packages/components/app-switcher/app-switcher-dropdown-site.ts b/packages/components/app-switcher/app-switcher-dropdown-site.ts index 9decb57579..4cedcbc4e3 100644 --- a/packages/components/app-switcher/app-switcher-dropdown-site.ts +++ b/packages/components/app-switcher/app-switcher-dropdown-site.ts @@ -36,7 +36,10 @@ import { KbqAppSwitcherSite } from './app-switcher'; encapsulation: ViewEncapsulation.None, host: { class: 'kbq-app-switcher-dropdown-site', - '[class.kbq-dropdown-item]': 'false' + role: 'menuitem', + '[class.kbq-dropdown-item]': 'false', + '[attr.tabindex]': 'getTabIndex()', + '[attr.aria-haspopup]': "isNested ? 'menu' : null" }, exportAs: 'kbqAppSwitcherDropdownSite' }) diff --git a/packages/components/app-switcher/app-switcher.en.md b/packages/components/app-switcher/app-switcher.en.md index 70741e6441..1bbb0cbd87 100644 --- a/packages/components/app-switcher/app-switcher.en.md +++ b/packages/components/app-switcher/app-switcher.en.md @@ -22,3 +22,18 @@ When the system has multiple platforms, the applications of the selected platfor Search appears when more than 7 applications are available. Results are displayed as a flat list. + +### Keyboard navigation + +The list of applications can be operated entirely from the keyboard. + +|
Key
| Action | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| / | Move focus to the previous/next item. | +| Home / End | Move focus to the first/last item. | +| | Expand an application group, or open the flyout of another platform. | +| | Collapse a group, move from an alias to its group, or close a platform flyout. | +| Enter / Space | Open the focused application, or expand/collapse a group. | +| Esc | Close the switcher. | + +Typing the first letters of an item jumps to it. When search is available, moves focus from the search field into the list, and on the first item returns focus to the search field. diff --git a/packages/components/app-switcher/app-switcher.html b/packages/components/app-switcher/app-switcher.html index 8da1f9c587..fa29f3ec2e 100644 --- a/packages/components/app-switcher/app-switcher.html +++ b/packages/components/app-switcher/app-switcher.html @@ -7,7 +7,6 @@ autocomplete="off" kbqInput [formControl]="searchControl" - (keydown.escape)="escapeHandler()" /> @@ -16,7 +15,7 @@ } -
+
@if (!searchControl.getRawValue()) {
@if (trigger.sitesMode) { @@ -53,7 +52,7 @@ @for (alias of app.aliases; track alias) {
} } @@ -99,7 +101,9 @@ [kbqDropdownTriggerFor]="dropdownWithGroupedApps" [offsetX]="0" [class.kbq-pressed]="dropdownWithGroupedAppsTrigger.opened" + [attr.aria-expanded]="dropdownWithGroupedAppsTrigger.opened" (mouseenter)="activeApp = app" + (focus)="activeApp = app" >
} @else { + +### Управление с клавиатуры + +Список приложений полностью доступен для управления с клавиатуры. + +|
Клавиша
| Действие | +| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| / | Переместить фокус на предыдущий/следующий элемент. | +| Home / End | Переместить фокус на первый/последний элемент. | +| | Развернуть группу приложений или открыть подменю другой площадки. | +| | Свернуть группу, перейти от алиаса к его группе или закрыть подменю площадки. | +| Enter / Space | Открыть приложение в фокусе или развернуть/свернуть группу. | +| Esc | Закрыть переключатель. | + +Ввод первых букв названия переходит к соответствующему элементу. Когда доступен поиск, перемещает фокус из поля поиска в список, а на первом элементе возвращает фокус в поле поиска. diff --git a/packages/components/app-switcher/app-switcher.spec.ts b/packages/components/app-switcher/app-switcher.spec.ts index b59b38fb08..15bbb523cf 100644 --- a/packages/components/app-switcher/app-switcher.spec.ts +++ b/packages/components/app-switcher/app-switcher.spec.ts @@ -1,9 +1,24 @@ +import { Directionality } from '@angular/cdk/bidi'; import { OverlayContainer } from '@angular/cdk/overlay'; import { IMAGE_LOADER, ImageLoaderConfig } from '@angular/common'; import { Component, Provider, Type } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, inject, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + DOWN_ARROW, + END, + ENTER, + ESCAPE, + HOME, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + UP_ARROW, + createKeyboardEvent, + dispatchKeyboardEvent +} from '@koobiq/components/core'; +import { of } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { TestScheduler } from 'rxjs/testing'; import { @@ -14,6 +29,7 @@ import { KbqAppSwitcherTrigger, defaultGroupBy } from './app-switcher'; +import { KbqAppSwitcherDropdownApp } from './app-switcher-dropdown-app'; import { KbqAppSwitcherDropdownSite } from './app-switcher-dropdown-site'; import { KbqAppSwitcherModule } from './app-switcher.module'; import { KbqAppSwitcherListItem } from './kbq-app-switcher-list-item'; @@ -33,6 +49,16 @@ const BIG_SITE: KbqAppSwitcherSite = { apps: Array.from({ length: 8 }, (_, i) => ({ id: i, name: `App ${i}`, link: `/app/${i}` })) }; +// A type needs MORE than KBQ_MIN_NUMBER_OF_APPS_TO_ENABLE_GROUPING (3) apps to become a collapsible group. +const GROUP_APPS: KbqAppSwitcherApp[] = Array.from({ length: 4 }, (_, i) => ({ + id: `g${i}`, + name: `Grouped ${i}`, + type: 'Group', + link: `/g/${i}` +})); + +const GROUP_SITE: KbqAppSwitcherSite = { id: 'grp', name: 'Group Site', apps: [...GROUP_APPS, APP_UNTYPED] }; + describe('KbqAppSwitcher', () => { let testScheduler: TestScheduler; @@ -733,6 +759,427 @@ describe('KbqAppSwitcher', () => { expect(searchResult).toBeTruthy(); })); }); + + describe('Keyboard navigation', () => { + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + + beforeEach(() => { + testScheduler = new TestScheduler((act, exp) => expect(act).toEqual(exp)); + }); + + afterEach(() => overlayContainer?.ngOnDestroy()); + + const open = ( + component: Type, + providers: Provider[] = [] + ): { fixture: ComponentFixture; popup: KbqAppSwitcherComponent } => { + const fixture = createComponent(component, providers); + + overlayContainer = TestBed.inject(OverlayContainer); + overlayContainerElement = overlayContainer.getContainerElement(); + + const trigger = getTrigger(fixture); + + trigger.show(); + tick(); + fixture.detectChanges(); + + return { fixture, popup: trigger['instance'] as KbqAppSwitcherComponent }; + }; + + const getHost = () => overlayContainerElement.querySelector('.kbq-app-switcher') as HTMLElement; + const keyManagerOf = (popup: KbqAppSwitcherComponent) => popup['keyManager']; + const menuItemsOf = (popup: KbqAppSwitcherComponent) => popup['menuItems']; + + describe('roving focus (flat list + other sites)', () => { + it('focuses the first menu item when the popup opens without a search field', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + + expect(keyManagerOf(popup).activeItemIndex).toBe(0); + })); + + it('marks the keyboard-focused item with the cdk-keyboard-focused class (drives the focus ring)', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + + dispatchKeyboardEvent(getHost(), 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + const active = keyManagerOf(popup).activeItem!.getHostElement(); + + expect(active.classList.contains('cdk-keyboard-focused')).toBe(true); + })); + + it('moves to the next/previous item on ArrowDown/ArrowUp', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const manager = keyManagerOf(popup); + const items = () => overlayContainerElement.querySelectorAll('.kbq-app-switcher-list-item'); + + dispatchKeyboardEvent(getHost(), 'keydown', DOWN_ARROW); + fixture.detectChanges(); + expect(manager.activeItemIndex).toBe(1); + expect(document.activeElement).toBe(items()[1]); + + dispatchKeyboardEvent(getHost(), 'keydown', UP_ARROW); + fixture.detectChanges(); + expect(manager.activeItemIndex).toBe(0); + expect(document.activeElement).toBe(items()[0]); + })); + + it('steps from the last flat app onto the first other-site row', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const manager = keyManagerOf(popup); + + // SITE_A contributes 3 flat items; the next item down is the SITE_B row. + manager.setActiveItem(2); + dispatchKeyboardEvent(getHost(), 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(3); + expect(manager.activeItem).toBeInstanceOf(KbqAppSwitcherDropdownSite); + })); + + it('does not wrap past the last item', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const manager = keyManagerOf(popup); + const last = menuItemsOf(popup).length - 1; + + manager.setActiveItem(last); + dispatchKeyboardEvent(getHost(), 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(last); + })); + + it('does not wrap before the first item', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const manager = keyManagerOf(popup); + + manager.setActiveItem(0); + dispatchKeyboardEvent(getHost(), 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(0); + })); + + it('jumps to the last/first item on End/Home', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const manager = keyManagerOf(popup); + const last = menuItemsOf(popup).length - 1; + + dispatchKeyboardEvent(getHost(), 'keydown', END); + fixture.detectChanges(); + expect(manager.activeItemIndex).toBe(last); + + dispatchKeyboardEvent(getHost(), 'keydown', HOME); + fixture.detectChanges(); + expect(manager.activeItemIndex).toBe(0); + })); + }); + + describe('activation', () => { + it('activates the focused link on Enter', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const manager = keyManagerOf(popup); + + manager.setActiveItem(0); + const clickSpy = jest.spyOn(manager.activeItem!.getHostElement(), 'click').mockImplementation(() => {}); + const event = createKeyboardEvent('keydown', ENTER, getHost()); + const preventSpy = jest.spyOn(event, 'preventDefault'); + + getHost().dispatchEvent(event); + fixture.detectChanges(); + + expect(clickSpy).toHaveBeenCalled(); + expect(preventSpy).toHaveBeenCalled(); + })); + + it('treats Space as a typeahead character instead of activation while a search sequence is in progress', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const manager = keyManagerOf(popup); + + manager.setActiveItem(0); + const clickSpy = jest.spyOn(manager.activeItem!.getHostElement(), 'click').mockImplementation(() => {}); + + jest.spyOn(manager, 'isTyping').mockReturnValue(true); + + dispatchKeyboardEvent(getHost(), 'keydown', SPACE); + fixture.detectChanges(); + + expect(clickSpy).not.toHaveBeenCalled(); + })); + + it('activates the focused link on Space', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const manager = keyManagerOf(popup); + + manager.setActiveItem(1); + const clickSpy = jest.spyOn(manager.activeItem!.getHostElement(), 'click').mockImplementation(() => {}); + + dispatchKeyboardEvent(getHost(), 'keydown', SPACE); + fixture.detectChanges(); + + expect(clickSpy).toHaveBeenCalled(); + })); + + it('closes the popup on Escape from a focused item', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const hideSpy = jest.spyOn(popup, 'hide'); + + keyManagerOf(popup).setActiveItem(0); + dispatchKeyboardEvent(getHost(), 'keydown', ESCAPE); + fixture.detectChanges(); + + expect(hideSpy).toHaveBeenCalledWith(0); + })); + }); + + describe('app groups', () => { + it('collapses an expanded group header on the collapse key (Left in LTR)', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherGrouped); + const header = keyManagerOf(popup).activeItem as KbqAppSwitcherListItem; + + expect(header.toggle()).toBe(true); + expect(header.collapsed).toBe(false); + const expandedLength = menuItemsOf(popup).length; + + dispatchKeyboardEvent(getHost(), 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(header.collapsed).toBe(true); + expect(menuItemsOf(popup).length).toBeLessThan(expandedLength); + })); + + it('expands a collapsed group header on the expand key (Right in LTR)', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherGrouped); + const header = keyManagerOf(popup).activeItem as KbqAppSwitcherListItem; + + // Collapse first via the keyboard so the OnPush overlay actually re-renders. + dispatchKeyboardEvent(getHost(), 'keydown', LEFT_ARROW); + fixture.detectChanges(); + expect(header.collapsed).toBe(true); + const collapsedLength = menuItemsOf(popup).length; + + dispatchKeyboardEvent(getHost(), 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(header.collapsed).toBe(false); + expect(menuItemsOf(popup).length).toBeGreaterThan(collapsedLength); + })); + + it('toggles the group header on Enter', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherGrouped); + const header = keyManagerOf(popup).activeItem as KbqAppSwitcherListItem; + + expect(header.collapsed).toBe(false); + + dispatchKeyboardEvent(getHost(), 'keydown', ENTER); + fixture.detectChanges(); + + expect(header.collapsed).toBe(true); + })); + + it('moves focus from a nested alias back to its group header on the collapse key', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherGrouped); + const manager = keyManagerOf(popup); + + // index 0 = group header, indices 1..N = its aliases. + manager.setActiveItem(2); + expect(manager.activeItem).toBeInstanceOf(KbqAppSwitcherListItem); + + dispatchKeyboardEvent(getHost(), 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(0); + })); + + it('inverts the expand/collapse keys in RTL', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherGrouped, [ + { provide: Directionality, useValue: { value: 'rtl', change: of() } } + ]); + const header = keyManagerOf(popup).activeItem as KbqAppSwitcherListItem; + + expect(header.collapsed).toBe(false); + + // In RTL, Right collapses and Left expands. + dispatchKeyboardEvent(getHost(), 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + expect(header.collapsed).toBe(true); + + dispatchKeyboardEvent(getHost(), 'keydown', LEFT_ARROW); + fixture.detectChanges(); + expect(header.collapsed).toBe(false); + })); + }); + + describe('search field handoff', () => { + const getSearchInput = () => overlayContainerElement.querySelector('input[kbqinput]') as HTMLInputElement; + + it('moves focus from the search field into the list on ArrowDown', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherWithSearch); + + dispatchKeyboardEvent(getSearchInput(), 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(keyManagerOf(popup).activeItemIndex).toBe(0); + })); + + it('returns focus to the search field on ArrowUp from the first item', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherWithSearch); + const input = getSearchInput(); + + keyManagerOf(popup).setActiveItem(0); + dispatchKeyboardEvent(getHost(), 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(keyManagerOf(popup).activeItemIndex).toBe(-1); + expect(document.activeElement).toBe(input); + })); + + it('does nothing when a non-ArrowDown key is pressed in the search field', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherWithSearch); + const input = getSearchInput(); + + dispatchKeyboardEvent(input, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(keyManagerOf(popup).activeItemIndex).toBe(-1); + expect(document.activeElement).toBe(input); + })); + }); + + describe('focus tracking for elements outside the roving menu', () => { + it('syncs the active item when focus moves to a tracked menu item via Tab or click', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const manager = keyManagerOf(popup); + const secondItem = overlayContainerElement.querySelectorAll( + '.kbq-app-switcher-list-item' + )[1] as HTMLElement; + + expect(manager.activeItemIndex).toBe(0); + + secondItem.focus(); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(1); + })); + + it('clears the active item when focus moves to a focusable element outside menuItems, so Space does not act on a stale item afterwards', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherWithSearch); + const manager = keyManagerOf(popup); + const input = overlayContainerElement.querySelector('input[kbqinput]') as HTMLInputElement; + + // A typed query is required for the cleaner button to render. + popup.searchControl.setValue('App 0'); + tick(); + fixture.detectChanges(); + + dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + expect(manager.activeItemIndex).toBe(0); + + const clickSpy = jest.spyOn(manager.activeItem!.getHostElement(), 'click').mockImplementation(() => {}); + const cleaner = overlayContainerElement.querySelector('.kbq-cleaner') as HTMLElement; + + expect(cleaner).toBeTruthy(); + cleaner.focus(); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(-1); + + dispatchKeyboardEvent(cleaner, 'keydown', SPACE); + fixture.detectChanges(); + + expect(clickSpy).not.toHaveBeenCalled(); + })); + }); + + describe('ARIA roles', () => { + it('exposes the item list as a vertical menu', fakeAsync(() => { + open(AppSwitcherMultiSite); + const menu = overlayContainerElement.querySelector('[role="menu"]') as HTMLElement; + + expect(menu).toBeTruthy(); + expect(menu.getAttribute('aria-orientation')).toBe('vertical'); + })); + + it('does not expose the search field as a descendant of the menu role (invalid for role="menu")', fakeAsync(() => { + open(AppSwitcherWithSearch); + const menu = overlayContainerElement.querySelector('[role="menu"]') as HTMLElement; + const input = overlayContainerElement.querySelector('input[kbqinput]') as HTMLElement; + + expect(menu).toBeTruthy(); + expect(input).toBeTruthy(); + expect(menu.contains(input)).toBe(false); + })); + + it('exposes list items as focusable menuitems', fakeAsync(() => { + open(AppSwitcherMultiSite); + const item = overlayContainerElement.querySelector('.kbq-app-switcher-list-item') as HTMLElement; + + expect(item.getAttribute('role')).toBe('menuitem'); + expect(item.getAttribute('tabindex')).toBe('0'); + })); + + it('exposes other-site rows as focusable menuitems with a collapsed popup', fakeAsync(() => { + open(AppSwitcherMultiSite); + const row = overlayContainerElement.querySelector('.kbq-app-switcher-dropdown-site') as HTMLElement; + + expect(row.getAttribute('role')).toBe('menuitem'); + expect(row.getAttribute('tabindex')).toBe('0'); + expect(row.getAttribute('aria-haspopup')).toBe('menu'); + expect(row.getAttribute('aria-expanded')).toBe('false'); + })); + + it('exposes flyout app rows as focusable menuitems', fakeAsync(() => { + const { fixture } = open(AppSwitcherMultiSite); + const siteRow = overlayContainerElement.querySelector('.kbq-app-switcher-dropdown-site') as HTMLElement; + + // Focusing the row first (as real keyboard use would) populates `activeSite`, which the + // flyout's content is bound to - without it the panel would open with no apps to show. + siteRow.focus(); + fixture.detectChanges(); + dispatchKeyboardEvent(siteRow, 'keydown', ENTER); + tick(); + fixture.detectChanges(); + + const appRow = overlayContainerElement.querySelector('.kbq-app-switcher-dropdown-app') as HTMLElement; + + expect(appRow).toBeTruthy(); + expect(appRow.getAttribute('role')).toBe('menuitem'); + expect(appRow.getAttribute('tabindex')).toBe('0'); + })); + }); + + describe('roving menu composition', () => { + it('only contains flat list items and other-site rows', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + const items = menuItemsOf(popup).toArray(); + + expect(items).toHaveLength(4); + expect( + items.every( + (item) => item instanceof KbqAppSwitcherListItem || item instanceof KbqAppSwitcherDropdownSite + ) + ).toBe(true); + expect(items.some((item) => item instanceof KbqAppSwitcherDropdownApp)).toBe(false); + })); + + it('opens the site flyout without growing the roving menu', fakeAsync(() => { + const { fixture, popup } = open(AppSwitcherMultiSite); + const before = menuItemsOf(popup).length; + const siteRow = overlayContainerElement.querySelector('.kbq-app-switcher-dropdown-site') as HTMLElement; + + dispatchKeyboardEvent(siteRow, 'keydown', ENTER); + tick(); + fixture.detectChanges(); + + // Confirms the flyout actually opened (not just that the roving-menu length is unaffected). + expect(siteRow.getAttribute('aria-expanded')).toBe('true'); + expect(menuItemsOf(popup).length).toBe(before); + })); + }); + }); }); @Component({ @@ -793,6 +1240,17 @@ class AppSwitcherWithSearch { bigSite: KbqAppSwitcherSite = { ...BIG_SITE, apps: [...BIG_SITE.apps] }; } +@Component({ + selector: 'app-switcher-grouped', + imports: [KbqAppSwitcherModule], + template: ` + + ` +}) +class AppSwitcherGrouped { + site: KbqAppSwitcherSite = { ...GROUP_SITE, apps: [...GROUP_SITE.apps] }; +} + @Component({ selector: 'list-item-host', imports: [KbqAppSwitcherListItem], diff --git a/packages/components/app-switcher/app-switcher.ts b/packages/components/app-switcher/app-switcher.ts index 61a6c7f771..bc6b7c8dcb 100644 --- a/packages/components/app-switcher/app-switcher.ts +++ b/packages/components/app-switcher/app-switcher.ts @@ -1,3 +1,4 @@ +import { Directionality } from '@angular/cdk/bidi'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { CdkScrollable, @@ -16,10 +17,13 @@ import { EventEmitter, InjectionToken, Input, + OnDestroy, OnInit, Output, + QueryList, TemplateRef, Type, + ViewChildren, ViewEncapsulation, booleanAttribute, inject, @@ -31,26 +35,35 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { + DOWN_ARROW, + ENTER, + ESCAPE, + FocusKeyManager, KBQ_LOCALE_SERVICE, KbqOptionModule, KbqPopUp, KbqPopUpPlacementValues, KbqPopUpSizeValues, KbqPopUpTrigger, + LEFT_ARROW, POSITION_TO_CSS_MAP, PopUpPlacements, PopUpSizes, PopUpTriggers, + RIGHT_ARROW, + SPACE, + UP_ARROW, applyPopupMargins, ruRULocaleData } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; -import { KbqDropdownModule, KbqDropdownTrigger } from '@koobiq/components/dropdown'; +import { KbqDropdownItem, KbqDropdownModule, KbqDropdownTrigger } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInput, KbqInputModule } from '@koobiq/components/input'; import { defaultOffsetYWithArrow } from '@koobiq/components/popover'; import { KbqScrollbarModule } from '@koobiq/components/scrollbar'; import { Subscription, merge } from 'rxjs'; +import { startWith } from 'rxjs/operators'; import { kbqAppSwitcherAnimations } from './app-switcher-animations'; import { KbqAppSwitcherDropdownApp } from './app-switcher-dropdown-app'; import { KbqAppSwitcherDropdownSite } from './app-switcher-dropdown-site'; @@ -155,12 +168,14 @@ export const KBQ_APP_SWITCHER_CONFIGURATION = new InjectionToken('KbqAppSwitcher changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { - class: 'kbq-app-switcher' + class: 'kbq-app-switcher', + '(keydown)': 'keydownHandler($event)', + '(focusin)': 'focusinHandler($event)' }, animations: [kbqAppSwitcherAnimations.state], preserveWhitespaces: false }) -export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit { +export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, OnDestroy { /** @docs-private */ protected readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); @@ -202,6 +217,25 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit { /** @docs-private */ readonly otherSites = viewChild.required('otherSites'); + /** @docs-private */ + @ViewChildren(KbqDropdownItem) protected allItems: QueryList; + + /** Roving-focus manager over the inline menu items (flat app rows and other-site rows). */ + protected keyManager: FocusKeyManager; + + /** @docs-private */ + private readonly menuItems = new QueryList(); + + /** @docs-private */ + private readonly dir = inject(Directionality, { optional: true }); + + /** + * CSS class marking a nested alias row, bound in the template (see app-switcher.html). Kept as a + * single named constant, rather than a literal repeated in both places, so handleGroupHorizontal's + * classList check can't silently drift from the template's class binding if the class is renamed. + */ + protected readonly nestedAliasClass = 'kbq-app-switcher-site_nested'; + constructor() { super(); @@ -215,8 +249,28 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit { ngAfterViewInit() { const input = this.input(); + // Build the roving menu from the inline (static-content) items: the flat app rows, expanded + // aliases and search results (`KbqAppSwitcherListItem`) plus the other-site rows + // (`KbqAppSwitcherDropdownSite`). The apps inside the site flyouts (`KbqAppSwitcherDropdownApp`) + // live in their own overlay dropdowns, which drive their own key manager, so they are excluded. + this.allItems.changes.pipe(startWith(null), takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.menuItems.reset( + this.allItems.filter( + (item) => item instanceof KbqAppSwitcherListItem || item instanceof KbqAppSwitcherDropdownSite + ) + ); + this.menuItems.notifyOnChanges(); + }); + + this.keyManager = new FocusKeyManager(this.menuItems) + .withVerticalOrientation() + .withHomeAndEnd() + .withTypeAhead(); + if (input) { input.focus(); + } else { + this.keyManager.setFirstItemActive(); } this.visibleChange.subscribe((state) => { @@ -230,7 +284,19 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit { } }); - this.searchControl.valueChanges.subscribe((value) => (this.filteredSites = this.filterSites(value))); + this.searchControl.valueChanges.subscribe((value) => { + this.filteredSites = this.filterSites(value); + // Switching between the flat list and the search results swaps the rendered items; + // drop any stale active item so navigation restarts from the top. + this.keyManager?.setActiveItem(-1); + }); + } + + /** Releases the internal roving-focus menu QueryList. */ + ngOnDestroy(): void { + this.menuItems.destroy(); + + super.ngOnDestroy(); } /** @docs-private */ @@ -248,6 +314,131 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit { this.hide(0); } + /** Handles keyboard navigation across the app-switcher menu items. + * @docs-private */ + protected keydownHandler(event: KeyboardEvent): void { + const keyCode = event.keyCode; + + if (keyCode === ESCAPE) { + this.escapeHandler(); + + return; + } + + // Keep the search field's native typing/caret behaviour; only ArrowDown moves into the list. + if (this.eventFromInput(event)) { + if (keyCode === DOWN_ARROW) { + event.preventDefault(); + this.keyManager.setFocusOrigin('keyboard').setFirstItemActive(); + } + + return; + } + + // Any key past this point is a keyboard interaction on a menu item, so show the focus ring. + this.keyManager.setFocusOrigin('keyboard'); + + const activeItem = this.keyManager.activeItem; + + if ((keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW) && this.handleGroupHorizontal(keyCode, activeItem)) { + event.preventDefault(); + + return; + } + + if ((keyCode === ENTER || keyCode === SPACE) && activeItem && !this.keyManager.isTyping()) { + event.preventDefault(); + + // A group header toggles its aliases; any other item activates its underlying link. + if (activeItem instanceof KbqAppSwitcherListItem && activeItem.toggle()) { + activeItem.collapsed = !activeItem.collapsed; + this.markForCheck(); + } else { + activeItem.getHostElement().click(); + } + + return; + } + + // ArrowUp on the first item returns focus to the search field (no wrap-around). + if (keyCode === UP_ARROW && this.keyManager.activeItemIndex === 0 && this.input()) { + event.preventDefault(); + this.keyManager.setActiveItem(-1); + this.input()!.focus(); + + return; + } + + this.keyManager.onKeydown(event); + } + + /** + * Expands/collapses an app group (or steps from an alias back to its header) on Left/Right, + * honouring the current text direction. Returns whether the key was consumed. + */ + private handleGroupHorizontal(keyCode: number, activeItem: KbqDropdownItem | null): boolean { + if (!(activeItem instanceof KbqAppSwitcherListItem)) { + return false; + } + + const rtl = this.dir?.value === 'rtl'; + const expandKey = rtl ? LEFT_ARROW : RIGHT_ARROW; + const collapseKey = rtl ? RIGHT_ARROW : LEFT_ARROW; + + if (activeItem.toggle()) { + if (keyCode === expandKey && activeItem.collapsed) { + activeItem.collapsed = false; + this.markForCheck(); + + return true; + } + + if (keyCode === collapseKey && !activeItem.collapsed) { + activeItem.collapsed = true; + this.markForCheck(); + + return true; + } + + return false; + } + + // Collapse key on a nested alias row moves focus back to its parent group header. + if (keyCode === collapseKey && activeItem.getHostElement().classList.contains(this.nestedAliasClass)) { + const items = this.menuItems.toArray(); + + for (let index = this.keyManager.activeItemIndex - 1; index >= 0; index--) { + const candidate = items[index]; + + if (candidate instanceof KbqAppSwitcherListItem && candidate.toggle()) { + this.keyManager.setActiveItem(index); + + return true; + } + } + } + + return false; + } + + /** Whether the keyboard event originated from the search input. */ + private eventFromInput(event: KeyboardEvent): boolean { + return !!(event.target as HTMLElement)?.attributes.getNamedItem('kbqinput'); + } + + /** Syncs the key manager's active item with focus that arrives via Tab, click or a closing flyout. + * Clears the active item (index -1) when focus lands on a focusable element that isn't one of + * `menuItems` (e.g. the search field's clear button) - otherwise a stale `activeItem` from before + * the focus move would keep receiving Enter/Space/arrow-key actions meant for the new target. + * @docs-private */ + protected focusinHandler(event: FocusEvent): void { + if (!this.keyManager) return; + + const index = this.menuItems.toArray().findIndex((item) => item.getHostElement() === event.target); + + this.keyManager.updateActiveItem(index); + } + /** @docs-private */ selectAppInSite(site: KbqAppSwitcherSite, app: KbqAppSwitcherApp) { this.trigger.selectedSite = site; diff --git a/packages/components/app-switcher/e2e.playwright-spec.ts b/packages/components/app-switcher/e2e.playwright-spec.ts index 461a2a4af5..83a04f5778 100644 --- a/packages/components/app-switcher/e2e.playwright-spec.ts +++ b/packages/components/app-switcher/e2e.playwright-spec.ts @@ -29,4 +29,75 @@ test.describe('KbqAppSwitcherModule', () => { await expect(getScreenshotTarget(locator)).toHaveScreenshot('02-dark.png'); }); }); + + test.describe('keyboard navigation', () => { + const focusedItem = (page: Page) => page.locator('.kbq-app-switcher-list-item.cdk-keyboard-focused').first(); + + const openStates = async (page: Page) => { + await page.goto('/E2eAppSwitcherStates'); + await page.getByTestId('e2eAppSwitcherStates').getByRole('button').click(); + await expect(page.locator('.kbq-app-switcher')).toBeVisible(); + }; + + test('ArrowDown then ArrowUp returns focus to the same item', async ({ page }) => { + await openStates(page); + + await page.keyboard.press('ArrowDown'); + const afterDown = await focusedItem(page).textContent(); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowUp'); + + expect(await focusedItem(page).textContent()).toBe(afterDown); + }); + + test('the keyboard-focused item renders a visible focus ring', async ({ page }) => { + await openStates(page); + + await page.keyboard.press('ArrowDown'); + + // The list item has no base border, so the ring is drawn with an inset box-shadow. + const ring = await focusedItem(page).evaluate((el) => getComputedStyle(el).boxShadow); + + expect(ring).not.toBe('none'); + expect(ring).toContain('inset'); + }); + + test('End and Home move to the last and first item', async ({ page }) => { + await openStates(page); + + await page.keyboard.press('End'); + const lastText = await page.locator('.kbq-app-switcher-list-item').last().textContent(); + + expect(await focusedItem(page).textContent()).toBe(lastText); + + await page.keyboard.press('Home'); + const firstText = await page.locator('.kbq-app-switcher-list-item').first().textContent(); + + expect(await focusedItem(page).textContent()).toBe(firstText); + }); + + test('Escape closes the switcher', async ({ page }) => { + await openStates(page); + + await page.keyboard.press('Escape'); + + await expect(page.locator('.kbq-app-switcher')).toBeHidden(); + }); + + test('ArrowLeft collapses an app group', async ({ page }) => { + await page.goto('/E2eAppSwitcherWithSitesStates'); + await page.getByTestId('e2eAppSwitcherWithSitesStates').getByRole('button').click(); + await expect(page.locator('.kbq-app-switcher')).toBeVisible(); + + // The search field is focused first; ArrowDown moves onto the first group header. + await page.keyboard.press('ArrowDown'); + const header = page.locator('.kbq-app-switcher-list-item[aria-expanded]').first(); + + await expect(header).toHaveAttribute('aria-expanded', 'true'); + + await page.keyboard.press('ArrowLeft'); + await expect(header).toHaveAttribute('aria-expanded', 'false'); + }); + }); }); diff --git a/packages/components/app-switcher/kbq-app-switcher-list-item.scss b/packages/components/app-switcher/kbq-app-switcher-list-item.scss index 65f4f1d7a4..f94756ac38 100644 --- a/packages/components/app-switcher/kbq-app-switcher-list-item.scss +++ b/packages/components/app-switcher/kbq-app-switcher-list-item.scss @@ -161,7 +161,10 @@ &.cdk-keyboard-focused { outline: none; - border-color: var(--kbq-list-states-focused-focus-outline-color); + // Inset ring instead of `border-color`: the list item has no base border to colour, and adding + // one would shift the nested-alias connector line (`:after`). This renders the same visual ring + // without touching layout or the connector geometry. + box-shadow: inset 0 0 0 var(--kbq-size-3xs) var(--kbq-list-states-focused-focus-outline-color); } } } diff --git a/packages/components/app-switcher/kbq-app-switcher-list-item.ts b/packages/components/app-switcher/kbq-app-switcher-list-item.ts index d0df8b2661..7b6554178d 100644 --- a/packages/components/app-switcher/kbq-app-switcher-list-item.ts +++ b/packages/components/app-switcher/kbq-app-switcher-list-item.ts @@ -52,7 +52,10 @@ import { KbqAppSwitcherApp } from './app-switcher'; encapsulation: ViewEncapsulation.None, host: { class: 'kbq-app-switcher-list-item', + role: 'menuitem', '[class.kbq-dropdown-item]': 'false', + '[attr.tabindex]': 'getTabIndex()', + '[attr.aria-expanded]': 'toggle() ? !collapsed : null', '(click)': 'clickHandler($event)' }, exportAs: 'kbqAppSwitcherApp' diff --git a/tools/public_api_guard/components/app-switcher.api.md b/tools/public_api_guard/components/app-switcher.api.md index 77e966a214..86e4304d71 100644 --- a/tools/public_api_guard/components/app-switcher.api.md +++ b/tools/public_api_guard/components/app-switcher.api.md @@ -10,6 +10,7 @@ import * as _angular_platform_browser from '@angular/platform-browser'; import { AnimationTriggerMetadata } from '@angular/animations'; import { CdkScrollable } from '@angular/cdk/overlay'; import { EventEmitter } from '@angular/core'; +import { FocusKeyManager } from '@koobiq/components/core'; import { FormControl } from '@angular/forms'; import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; @@ -21,9 +22,11 @@ import { KbqPopUpPlacementValues } from '@koobiq/components/core'; import { KbqPopUpSizeValues } from '@koobiq/components/core'; import { KbqPopUpTrigger } from '@koobiq/components/core'; import * as _koobiq_components_core from '@koobiq/components/core'; +import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; import { Overlay } from '@angular/cdk/overlay'; import { OverlayConfig } from '@angular/cdk/overlay'; +import { QueryList } from '@angular/core'; import * as rxjs from 'rxjs'; import { ScrollStrategy } from '@angular/cdk/overlay'; import { Subscription } from 'rxjs'; @@ -83,22 +86,28 @@ export interface KbqAppSwitcherApp { } // @public -export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit { +export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, OnDestroy { constructor(); protected activeApp: KbqAppSwitcherApp; protected activeSite: KbqAppSwitcherSite; + protected allItems: QueryList; // (undocumented) configuration: any; escapeHandler(): void; // (undocumented) readonly externalConfiguration: unknown; filteredSites: KbqAppSwitcherSite[]; + protected focusinHandler(event: FocusEvent): void; readonly input: i0.Signal; isTrapFocus: boolean; + protected keydownHandler(event: KeyboardEvent): void; + protected keyManager: FocusKeyManager; get localeData(): any; protected readonly localeService: _koobiq_components_core.KbqLocaleService | null; + protected readonly nestedAliasClass = "kbq-app-switcher-site_nested"; // (undocumented) ngAfterViewInit(): void; + ngOnDestroy(): void; readonly otherSites: i0.Signal; prefix: string; readonly searchControl: FormControl; From 2c27c8394a6eaa8060830e3bdc24e7a76bae6172 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 22 Jul 2026 16:12:26 +0300 Subject: [PATCH 2/4] fix: linter error --- packages/components/app-switcher/app-switcher.html | 4 ++-- tools/cspell-locales/ru.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/components/app-switcher/app-switcher.html b/packages/components/app-switcher/app-switcher.html index fa29f3ec2e..5d3d7fa932 100644 --- a/packages/components/app-switcher/app-switcher.html +++ b/packages/components/app-switcher/app-switcher.html @@ -52,8 +52,8 @@ @for (alias of app.aliases; track alias) {
@@ -78,9 +78,9 @@ @for (site of trigger.sites; track site) { @if (site.id !== trigger.selectedSite?.id) {
Date: Wed, 22 Jul 2026 20:19:17 +0300 Subject: [PATCH 3/4] fix: e2e --- .../components/app-switcher/app-switcher.ts | 24 +++++++++++++------ .../app-switcher/e2e.playwright-spec.ts | 20 ++++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/components/app-switcher/app-switcher.ts b/packages/components/app-switcher/app-switcher.ts index bc6b7c8dcb..df1dd2f271 100644 --- a/packages/components/app-switcher/app-switcher.ts +++ b/packages/components/app-switcher/app-switcher.ts @@ -249,6 +249,14 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, ngAfterViewInit() { const input = this.input(); + this.keyManager = new FocusKeyManager(this.menuItems) + .withVerticalOrientation() + .withHomeAndEnd() + .withTypeAhead(); + + // Focus the first item only once, and only after it is actually rendered (see below). + let initialItemFocused = false; + // Build the roving menu from the inline (static-content) items: the flat app rows, expanded // aliases and search results (`KbqAppSwitcherListItem`) plus the other-site rows // (`KbqAppSwitcherDropdownSite`). The apps inside the site flyouts (`KbqAppSwitcherDropdownApp`) @@ -260,17 +268,19 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, ) ); this.menuItems.notifyOnChanges(); - }); - this.keyManager = new FocusKeyManager(this.menuItems) - .withVerticalOrientation() - .withHomeAndEnd() - .withTypeAhead(); + // When there is no search field, focus the first item on open. The static-content rows live + // inside a `kbq-dropdown` that only materialises after this component's `ngAfterViewInit`, so + // focusing eagerly there races the render and silently no-ops in production builds — wait for + // the items to appear in the query instead. + if (!input && !initialItemFocused && this.menuItems.length) { + initialItemFocused = true; + this.keyManager.setFirstItemActive(); + } + }); if (input) { input.focus(); - } else { - this.keyManager.setFirstItemActive(); } this.visibleChange.subscribe((state) => { diff --git a/packages/components/app-switcher/e2e.playwright-spec.ts b/packages/components/app-switcher/e2e.playwright-spec.ts index 83a04f5778..1fb87cc847 100644 --- a/packages/components/app-switcher/e2e.playwright-spec.ts +++ b/packages/components/app-switcher/e2e.playwright-spec.ts @@ -31,12 +31,18 @@ test.describe('KbqAppSwitcherModule', () => { }); test.describe('keyboard navigation', () => { - const focusedItem = (page: Page) => page.locator('.kbq-app-switcher-list-item.cdk-keyboard-focused').first(); + // The e2e host always renders one inline (used by the screenshot tests); + // clicking the trigger opens a second instance in the CDK overlay. Scope every locator to the + // overlay panel so it never ambiguously matches the always-present inline instance. + const overlay = (page: Page) => page.locator('.kbq-app-switcher__panel'); + const listItem = (page: Page) => overlay(page).locator('.kbq-app-switcher-list-item'); + const focusedItem = (page: Page) => + overlay(page).locator('.kbq-app-switcher-list-item.cdk-keyboard-focused').first(); const openStates = async (page: Page) => { await page.goto('/E2eAppSwitcherStates'); await page.getByTestId('e2eAppSwitcherStates').getByRole('button').click(); - await expect(page.locator('.kbq-app-switcher')).toBeVisible(); + await expect(overlay(page)).toBeVisible(); }; test('ArrowDown then ArrowUp returns focus to the same item', async ({ page }) => { @@ -67,12 +73,12 @@ test.describe('KbqAppSwitcherModule', () => { await openStates(page); await page.keyboard.press('End'); - const lastText = await page.locator('.kbq-app-switcher-list-item').last().textContent(); + const lastText = await listItem(page).last().textContent(); expect(await focusedItem(page).textContent()).toBe(lastText); await page.keyboard.press('Home'); - const firstText = await page.locator('.kbq-app-switcher-list-item').first().textContent(); + const firstText = await listItem(page).first().textContent(); expect(await focusedItem(page).textContent()).toBe(firstText); }); @@ -82,17 +88,17 @@ test.describe('KbqAppSwitcherModule', () => { await page.keyboard.press('Escape'); - await expect(page.locator('.kbq-app-switcher')).toBeHidden(); + await expect(overlay(page)).toBeHidden(); }); test('ArrowLeft collapses an app group', async ({ page }) => { await page.goto('/E2eAppSwitcherWithSitesStates'); await page.getByTestId('e2eAppSwitcherWithSitesStates').getByRole('button').click(); - await expect(page.locator('.kbq-app-switcher')).toBeVisible(); + await expect(overlay(page)).toBeVisible(); // The search field is focused first; ArrowDown moves onto the first group header. await page.keyboard.press('ArrowDown'); - const header = page.locator('.kbq-app-switcher-list-item[aria-expanded]').first(); + const header = overlay(page).locator('.kbq-app-switcher-list-item[aria-expanded]').first(); await expect(header).toHaveAttribute('aria-expanded', 'true'); From ede71189a4b943135cc52b0836fdbc86318b0853 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 23 Jul 2026 14:50:08 +0300 Subject: [PATCH 4/4] fix: after review --- .../app-switcher-dropdown-app.scss | 9 +- .../app-switcher-dropdown-site.scss | 7 +- .../app-switcher/app-switcher.spec.ts | 103 ++++++++++++++++++ .../components/app-switcher/app-switcher.ts | 40 ++++++- .../app-switcher/e2e.playwright-spec.ts | 39 ++++++- .../kbq-app-switcher-list-item.scss | 17 +-- .../components/app-switcher.api.md | 1 + 7 files changed, 188 insertions(+), 28 deletions(-) diff --git a/packages/components/app-switcher/app-switcher-dropdown-app.scss b/packages/components/app-switcher/app-switcher-dropdown-app.scss index 65d113fda8..ac7c131265 100644 --- a/packages/components/app-switcher/app-switcher-dropdown-app.scss +++ b/packages/components/app-switcher/app-switcher-dropdown-app.scss @@ -9,7 +9,7 @@ min-height: 50px; border-radius: var(--kbq-size-xs); - // base transparent border so the .cdk-keyboard-focused border-color renders a focus ring (matches list-item-base) + // transparent border to keep the box model aligned with the other list rows (matches kbq-list-item-base) border: var(--kbq-size-3xs) solid transparent; @include common.user-select(none); @@ -59,12 +59,9 @@ color: var(--kbq-list-default-text-color); &.kbq-dropdown-item_highlighted, - &:hover { - background: var(--kbq-states-background-transparent-active); - } - + &:hover, &.cdk-keyboard-focused { - border-color: var(--kbq-list-states-focused-focus-outline-color); + background: var(--kbq-states-background-transparent-active); } .kbq-app-switcher-dropdown-app__caption { diff --git a/packages/components/app-switcher/app-switcher-dropdown-site.scss b/packages/components/app-switcher/app-switcher-dropdown-site.scss index fa4b5a1f7e..0d760f1945 100644 --- a/packages/components/app-switcher/app-switcher-dropdown-site.scss +++ b/packages/components/app-switcher/app-switcher-dropdown-site.scss @@ -33,12 +33,9 @@ color: var(--kbq-list-default-text-color); &.kbq-dropdown-item_highlighted, - &:hover { - background: var(--kbq-states-background-transparent-active); - } - + &:hover, &.cdk-keyboard-focused { - border-color: var(--kbq-list-states-focused-focus-outline-color); + background: var(--kbq-states-background-transparent-active); } } } diff --git a/packages/components/app-switcher/app-switcher.spec.ts b/packages/components/app-switcher/app-switcher.spec.ts index 15bbb523cf..8c52503edc 100644 --- a/packages/components/app-switcher/app-switcher.spec.ts +++ b/packages/components/app-switcher/app-switcher.spec.ts @@ -14,6 +14,7 @@ import { LEFT_ARROW, RIGHT_ARROW, SPACE, + TAB, UP_ARROW, createKeyboardEvent, dispatchKeyboardEvent @@ -792,6 +793,108 @@ describe('KbqAppSwitcher', () => { const keyManagerOf = (popup: KbqAppSwitcherComponent) => popup['keyManager']; const menuItemsOf = (popup: KbqAppSwitcherComponent) => popup['menuItems']; + describe('close on focus-out', () => { + it('hides when focus leaves the popup entirely', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + const hideSpy = jest.spyOn(popup, 'hide').mockImplementation(() => {}); + const outside = document.createElement('button'); + + document.body.appendChild(outside); + getHost().dispatchEvent(new FocusEvent('focusout', { relatedTarget: outside, bubbles: true })); + + expect(hideSpy).toHaveBeenCalledWith(0); + outside.remove(); + })); + + it('returns focus to the trigger when focus leaves', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + + jest.spyOn(popup, 'hide').mockImplementation(() => {}); + const focusSpy = jest.spyOn(popup.trigger, 'focus').mockImplementation(() => {}); + const outside = document.createElement('button'); + + document.body.appendChild(outside); + getHost().dispatchEvent(new FocusEvent('focusout', { relatedTarget: outside, bubbles: true })); + + expect(focusSpy).toHaveBeenCalled(); + outside.remove(); + })); + + it('closes and focuses the trigger on Tab, preventing the native focus move into browser chrome', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + + jest.spyOn(popup, 'hide').mockImplementation(() => {}); + const focusSpy = jest.spyOn(popup.trigger, 'focus').mockImplementation(() => {}); + const event = createKeyboardEvent('keydown', TAB, getHost()); + const preventSpy = jest.spyOn(event, 'preventDefault'); + + popup['keydownHandler'](event); + + expect(preventSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + })); + + it('stops Escape from bubbling into overlays opened earlier', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + + jest.spyOn(popup, 'hide').mockImplementation(() => {}); + const event = createKeyboardEvent('keydown', ESCAPE, getHost()); + const preventSpy = jest.spyOn(event, 'preventDefault'); + const stopSpy = jest.spyOn(event, 'stopPropagation'); + + popup['keydownHandler'](event); + + expect(preventSpy).toHaveBeenCalled(); + expect(stopSpy).toHaveBeenCalled(); + })); + + it('ignores keys safely before the key manager is initialized', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + + // The host `(keydown)` binding is live before `ngAfterViewInit` builds the key manager. + (popup as any).keyManager = undefined; + + expect(() => + popup['keydownHandler'](createKeyboardEvent('keydown', DOWN_ARROW, getHost())) + ).not.toThrow(); + })); + + it('hides when focus is lost with no related target', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + const hideSpy = jest.spyOn(popup, 'hide').mockImplementation(() => {}); + + getHost().dispatchEvent(new FocusEvent('focusout', { relatedTarget: null, bubbles: true })); + + expect(hideSpy).toHaveBeenCalledWith(0); + })); + + it('stays open while focus moves between menu items', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + const hideSpy = jest.spyOn(popup, 'hide').mockImplementation(() => {}); + const item = getHost().querySelector('.kbq-app-switcher-list-item') as HTMLElement; + + getHost().dispatchEvent(new FocusEvent('focusout', { relatedTarget: item, bubbles: true })); + + expect(hideSpy).not.toHaveBeenCalled(); + })); + + it('stays open while focus moves into a site flyout overlay', fakeAsync(() => { + const { popup } = open(AppSwitcherMultiSite); + const hideSpy = jest.spyOn(popup, 'hide').mockImplementation(() => {}); + const flyoutPane = document.createElement('div'); + const flyoutItem = document.createElement('a'); + + // Flyouts open in a separate overlay whose pane carries the `.kbq-app-switcher-sites` class. + flyoutPane.className = 'kbq-app-switcher-sites'; + flyoutPane.appendChild(flyoutItem); + document.body.appendChild(flyoutPane); + getHost().dispatchEvent(new FocusEvent('focusout', { relatedTarget: flyoutItem, bubbles: true })); + + expect(hideSpy).not.toHaveBeenCalled(); + flyoutPane.remove(); + })); + }); + describe('roving focus (flat list + other sites)', () => { it('focuses the first menu item when the popup opens without a search field', fakeAsync(() => { const { popup } = open(AppSwitcherMultiSite); diff --git a/packages/components/app-switcher/app-switcher.ts b/packages/components/app-switcher/app-switcher.ts index df1dd2f271..38c2919fd1 100644 --- a/packages/components/app-switcher/app-switcher.ts +++ b/packages/components/app-switcher/app-switcher.ts @@ -52,6 +52,7 @@ import { PopUpTriggers, RIGHT_ARROW, SPACE, + TAB, UP_ARROW, applyPopupMargins, ruRULocaleData @@ -170,7 +171,8 @@ export const KBQ_APP_SWITCHER_CONFIGURATION = new InjectionToken('KbqAppSwitcher host: { class: 'kbq-app-switcher', '(keydown)': 'keydownHandler($event)', - '(focusin)': 'focusinHandler($event)' + '(focusin)': 'focusinHandler($event)', + '(focusout)': 'focusoutHandler($event)' }, animations: [kbqAppSwitcherAnimations.state], preserveWhitespaces: false @@ -330,11 +332,21 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, const keyCode = event.keyCode; if (keyCode === ESCAPE) { + // Stop here so Escape closes only this popup instead of bubbling into overlays opened + // earlier (mirrors KbqDropdown.handleKeydown). + event.preventDefault(); + event.stopPropagation(); this.escapeHandler(); return; } + // The host `(keydown)` binding is live before `ngAfterViewInit` builds the key manager; until + // then there is nothing to navigate, so ignore the key (mirrors `focusinHandler`). + if (!this.keyManager) { + return; + } + // Keep the search field's native typing/caret behaviour; only ArrowDown moves into the list. if (this.eventFromInput(event)) { if (keyCode === DOWN_ARROW) { @@ -345,6 +357,17 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, return; } + // Tab leaves the menu (WAI-ARIA menu pattern): close and move focus to the trigger ourselves. + // Preventing the default stops the browser from first shifting focus into its own chrome, which + // would leave the real focus out of sync with the ring shown on the trigger. + if (keyCode === TAB) { + event.preventDefault(); + this.hide(0); + this.trigger.focus(); + + return; + } + // Any key past this point is a keyboard interaction on a menu item, so show the focus ring. this.keyManager.setFocusOrigin('keyboard'); @@ -449,6 +472,21 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, this.keyManager.updateActiveItem(index); } + /** Closes the popup once focus leaves the menu and its site flyouts, mirroring the dropdown. + * @docs-private */ + protected focusoutHandler(event: FocusEvent): void { + const next = event.relatedTarget as HTMLElement | null; + + // Keep open while focus stays inside the popup or moves into a site flyout overlay + // (flyouts are separate overlays whose panes carry the `.kbq-app-switcher-sites` class). + if (!next || !next.closest('.kbq-app-switcher, .kbq-app-switcher-sites')) { + this.hide(0); + // Return focus to the trigger (the same landing spot as Shift+Tab), instead of + // leaving it wherever it was heading. + this.trigger.focus(); + } + } + /** @docs-private */ selectAppInSite(site: KbqAppSwitcherSite, app: KbqAppSwitcherApp) { this.trigger.selectedSite = site; diff --git a/packages/components/app-switcher/e2e.playwright-spec.ts b/packages/components/app-switcher/e2e.playwright-spec.ts index 1fb87cc847..2eec20a28f 100644 --- a/packages/components/app-switcher/e2e.playwright-spec.ts +++ b/packages/components/app-switcher/e2e.playwright-spec.ts @@ -57,16 +57,22 @@ test.describe('KbqAppSwitcherModule', () => { expect(await focusedItem(page).textContent()).toBe(afterDown); }); - test('the keyboard-focused item renders a visible focus ring', async ({ page }) => { + test('the keyboard-focused item looks like hover with no ring', async ({ page }) => { await openStates(page); await page.keyboard.press('ArrowDown'); + const focused = focusedItem(page); - // The list item has no base border, so the ring is drawn with an inset box-shadow. - const ring = await focusedItem(page).evaluate((el) => getComputedStyle(el).boxShadow); + // Focus is a background fill only — no ring/border/box-shadow, like the dropdown. + expect(await focused.evaluate((el) => getComputedStyle(el).boxShadow)).toBe('none'); - expect(ring).not.toBe('none'); - expect(ring).toContain('inset'); + // The focus background matches a hovered sibling's background. + const focusBackground = await focused.evaluate((el) => getComputedStyle(el).backgroundColor); + const sibling = listItem(page).nth(2); + + await sibling.hover(); + + expect(await sibling.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe(focusBackground); }); test('End and Home move to the last and first item', async ({ page }) => { @@ -91,6 +97,29 @@ test.describe('KbqAppSwitcherModule', () => { await expect(overlay(page)).toBeHidden(); }); + test('closes and returns focus to the trigger when focus leaves the menu', async ({ page }) => { + await openStates(page); + await page.keyboard.press('End'); // focus the last item + await page.keyboard.press('Tab'); // Tab out of the menu + + await expect(overlay(page)).toBeHidden(); + await expect(page.getByTestId('e2eAppSwitcherStates').getByRole('button')).toBeFocused(); + }); + + test('opening a site flyout keeps the menu open', async ({ page }) => { + await page.goto('/E2eAppSwitcherWithSitesStates'); + await page.getByTestId('e2eAppSwitcherWithSitesStates').getByRole('button').click(); + await expect(overlay(page)).toBeVisible(); + + await page.keyboard.press('ArrowDown'); // from the search field into the list + await page.keyboard.press('End'); // the last menu item is an other-site row + await page.keyboard.press('ArrowRight'); // open that site's flyout + + // The flyout opened and the main popup stayed open — focus into a flyout must not close it. + await expect(page.locator('.kbq-app-switcher-dropdown-app').first()).toBeVisible(); + await expect(overlay(page)).toBeVisible(); + }); + test('ArrowLeft collapses an app group', async ({ page }) => { await page.goto('/E2eAppSwitcherWithSitesStates'); await page.getByTestId('e2eAppSwitcherWithSitesStates').getByRole('button').click(); diff --git a/packages/components/app-switcher/kbq-app-switcher-list-item.scss b/packages/components/app-switcher/kbq-app-switcher-list-item.scss index f94756ac38..9a155b8c78 100644 --- a/packages/components/app-switcher/kbq-app-switcher-list-item.scss +++ b/packages/components/app-switcher/kbq-app-switcher-list-item.scss @@ -118,7 +118,8 @@ .kbq-app-switcher-list-item { &:hover, &.kbq-hover, - &.kbq-focused { + &.kbq-focused, + &.cdk-keyboard-focused { &:has(.kbq-app-switcher-list-item__toggle) .kbq-app-switcher-list-item__icon { visibility: hidden; } @@ -129,7 +130,8 @@ } &:hover, - &.kbq-hover { + &.kbq-hover, + &.cdk-keyboard-focused { background: var(--kbq-states-background-transparent-hover); & .kbq-app-switcher-list-item__toggle { @@ -146,7 +148,8 @@ background: var(--kbq-background-theme-less); &:hover, - &.kbq-hover { + &.kbq-hover, + &.cdk-keyboard-focused { background: var(--kbq-states-background-theme-less-hover); } @@ -158,14 +161,6 @@ background: var(--kbq-list-default-container-background); color: var(--kbq-list-default-text-color); - - &.cdk-keyboard-focused { - outline: none; - // Inset ring instead of `border-color`: the list item has no base border to colour, and adding - // one would shift the nested-alias connector line (`:after`). This renders the same visual ring - // without touching layout or the connector geometry. - box-shadow: inset 0 0 0 var(--kbq-size-3xs) var(--kbq-list-states-focused-focus-outline-color); - } } } diff --git a/tools/public_api_guard/components/app-switcher.api.md b/tools/public_api_guard/components/app-switcher.api.md index 86e4304d71..cea29834d7 100644 --- a/tools/public_api_guard/components/app-switcher.api.md +++ b/tools/public_api_guard/components/app-switcher.api.md @@ -98,6 +98,7 @@ export class KbqAppSwitcherComponent extends KbqPopUp implements AfterViewInit, readonly externalConfiguration: unknown; filteredSites: KbqAppSwitcherSite[]; protected focusinHandler(event: FocusEvent): void; + protected focusoutHandler(event: FocusEvent): void; readonly input: i0.Signal; isTrapFocus: boolean; protected keydownHandler(event: KeyboardEvent): void;