diff --git a/libs/design/breadcrumb/src/breadcrumb-menu-item/breadcrumb-menu-item.directive.ts b/libs/design/breadcrumb/src/breadcrumb-menu-item/breadcrumb-menu-item.directive.ts index 0a0ea52904..9a8b7ad836 100644 --- a/libs/design/breadcrumb/src/breadcrumb-menu-item/breadcrumb-menu-item.directive.ts +++ b/libs/design/breadcrumb/src/breadcrumb-menu-item/breadcrumb-menu-item.directive.ts @@ -1,4 +1,8 @@ -import { FocusableOption } from '@angular/cdk/a11y'; +import { + FocusableOption, + FocusMonitor, + FocusOrigin, +} from '@angular/cdk/a11y'; import { AfterViewInit, Directive, @@ -29,6 +33,7 @@ export class DaffBreadcrumbMenuItemDirective implements FocusableOption, AfterVi constructor( private _viewContainerRef: ViewContainerRef, private _menuService: DaffMenuService, + private _focusMonitor: FocusMonitor, ) {} /** @@ -38,10 +43,23 @@ export class DaffBreadcrumbMenuItemDirective implements FocusableOption, AfterVi this._focusableElement = this._findFocusableElement(); this._focusableElement?.classList.add('daff-menu-item'); // For styling this._focusableElement?.addEventListener('click', this._clickHandler); + + if (this._focusableElement) { + this._focusMonitor.monitor(this._focusableElement, false); + } } - focus() { - this._focusableElement?.focus(); // Allows `FocusKeyManager` to focus on the element + // Allows `FocusKeyManager` to focus on the element + focus(origin?: FocusOrigin, options?: FocusOptions) { + if (!this._focusableElement) { + return; + } + + if (origin) { + this._focusMonitor.focusVia(this._focusableElement, origin, options); + } else { + this._focusableElement.focus(options); + } } private _findFocusableElement(): HTMLElement | null { @@ -52,5 +70,9 @@ export class DaffBreadcrumbMenuItemDirective implements FocusableOption, AfterVi ngOnDestroy() { this._focusableElement?.removeEventListener('click', this._clickHandler); this._focusableElement?.classList.remove('daff-menu-item'); + + if (this._focusableElement) { + this._focusMonitor.stopMonitoring(this._focusableElement); + } } } diff --git a/libs/design/menu/src/menu-activator/menu-activator.component.spec.ts b/libs/design/menu/src/menu-activator/menu-activator.component.spec.ts index ee8bb5dba9..23b4cd8f86 100644 --- a/libs/design/menu/src/menu-activator/menu-activator.component.spec.ts +++ b/libs/design/menu/src/menu-activator/menu-activator.component.spec.ts @@ -90,6 +90,67 @@ describe('@daffodil/design/menu | DaffMenuActivatorDirective', () => { jasmine.anything(), jasmine.anything(), jasmine.objectContaining({ xPosition: 'after', yPosition: 'below' }), + jasmine.anything(), + ); + }); + + it('should open the menu with a program origin when nothing preceded the click', () => { + const menuService = de.injector.get(DaffMenuService); + spyOn(menuService, 'open'); + + de.nativeElement.click(); + + expect(menuService.open).toHaveBeenCalledWith( + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + 'program', + ); + }); + + it('should open the menu with a mouse origin when the click follows a mousedown', () => { + const menuService = de.injector.get(DaffMenuService); + spyOn(menuService, 'open'); + + de.nativeElement.dispatchEvent(new MouseEvent('mousedown', { button: 0 })); + de.nativeElement.click(); + + expect(menuService.open).toHaveBeenCalledWith( + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + 'mouse', + ); + }); + + it('should open the menu with a keyboard origin when the click follows an Enter keydown', () => { + const menuService = de.injector.get(DaffMenuService); + spyOn(menuService, 'open'); + + de.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + de.nativeElement.click(); + + expect(menuService.open).toHaveBeenCalledWith( + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + 'keyboard', + ); + }); + + it('should not carry the origin over to the next open', () => { + const menuService = de.injector.get(DaffMenuService); + spyOn(menuService, 'open'); + + de.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + de.nativeElement.click(); + de.nativeElement.click(); + + expect(menuService.open).toHaveBeenCalledWith( + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + 'program', ); }); @@ -143,6 +204,7 @@ describe('@daffodil/design/menu | DaffMenuActivatorDirective | With Custom Posit jasmine.anything(), jasmine.anything(), jasmine.objectContaining({ xPosition: 'before', yPosition: 'above' }), + jasmine.anything(), ); }); }); diff --git a/libs/design/menu/src/menu-activator/menu-activator.component.ts b/libs/design/menu/src/menu-activator/menu-activator.component.ts index 7849449142..e7de825345 100644 --- a/libs/design/menu/src/menu-activator/menu-activator.component.ts +++ b/libs/design/menu/src/menu-activator/menu-activator.component.ts @@ -1,3 +1,4 @@ +import { FocusOrigin } from '@angular/cdk/a11y'; import { ChangeDetectorRef, computed, @@ -35,6 +36,8 @@ import { DaffMenuService } from '../services/menu.service'; selector: '[daffMenuActivator]', host: { '(click)': 'onClick($event)', + '(mousedown)': '_onMousedown($event)', + '(keydown)': '_onKeydown($event)', 'aria-haspopup': 'menu', '[attr.aria-expanded]': 'ariaExpanded', '[attr.aria-controls]': '_open ? menuId() : null', @@ -48,6 +51,7 @@ export class DaffMenuActivatorDirective implements OnDestroy { private _destroyed$ = new Subject(); private _defaultMenuId = daffNextMenuId(); + private _openedBy: FocusOrigin = null; protected _open: boolean; readonly isOpen = signal(false); @@ -116,11 +120,35 @@ export class DaffMenuActivatorDirective implements OnDestroy { this.viewContainerRef.element.nativeElement.focus(); } + /** + * @docs-private + * + * A touch tap fires `mousedown` too, so this covers pointer interaction generally. + */ + _onMousedown(event: MouseEvent) { + this._openedBy = event.button === 0 ? 'mouse' : null; + } + + /** + * @docs-private + */ + _onKeydown(event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + this._openedBy = 'keyboard'; + } + } + /** * @docs-private */ onClick(event: MouseEvent) { event.preventDefault(); - this.service.open(this.viewContainerRef, this.daffMenuActivator(), { menuId: this.menuId(), xPosition: this.xPosition(), yPosition: this.yPosition() }); + this.service.open( + this.viewContainerRef, + this.daffMenuActivator(), + { menuId: this.menuId(), xPosition: this.xPosition(), yPosition: this.yPosition() }, + this._openedBy ?? 'program', + ); + this._openedBy = null; } } diff --git a/libs/design/menu/src/menu-item/menu-item.component.ts b/libs/design/menu/src/menu-item/menu-item.component.ts index 496b8d6e1c..e67378dd04 100644 --- a/libs/design/menu/src/menu-item/menu-item.component.ts +++ b/libs/design/menu/src/menu-item/menu-item.component.ts @@ -1,10 +1,16 @@ /* eslint-disable quote-props */ -import { FocusableOption } from '@angular/cdk/a11y'; import { + FocusableOption, + FocusMonitor, + FocusOrigin, +} from '@angular/cdk/a11y'; +import { + AfterViewInit, Component, ChangeDetectionStrategy, ContentChild, ElementRef, + OnDestroy, } from '@angular/core'; import { DaffPrefixDirective } from '@daffodil/design'; @@ -35,7 +41,7 @@ import { DaffMenuService } from '../services/menu.service'; providers: [provideDaffMenuItemToken(DaffMenuItemComponent)], }) -export class DaffMenuItemComponent implements FocusableOption { +export class DaffMenuItemComponent implements FocusableOption, AfterViewInit, OnDestroy { /** * @docs-private */ @@ -44,8 +50,25 @@ export class DaffMenuItemComponent implements FocusableOption { constructor( private _elementRef: ElementRef, private _menuService: DaffMenuService, + private _focusMonitor: FocusMonitor, ) {} + /** + * @docs-private + * + * Monitoring the item is what lets `focus` mark it as keyboard focused. + */ + ngAfterViewInit() { + this._focusMonitor.monitor(this._elementRef, false); + } + + /** + * @docs-private + */ + ngOnDestroy() { + this._focusMonitor.stopMonitoring(this._elementRef); + } + /** * @docs-private */ @@ -54,9 +77,13 @@ export class DaffMenuItemComponent implements FocusableOption { } /** - * Focus the menu item. + * Focus the menu item. The item only takes on focus styling when `origin` is `keyboard`. */ - focus() { - this._elementRef.nativeElement.focus(); + focus(origin?: FocusOrigin, options?: FocusOptions) { + if (origin) { + this._focusMonitor.focusVia(this._elementRef, origin, options); + } else { + this._elementRef.nativeElement.focus(options); + } } } diff --git a/libs/design/menu/src/menu-theme.scss b/libs/design/menu/src/menu-theme.scss index 3cc970fe81..630b8326dc 100644 --- a/libs/design/menu/src/menu-theme.scss +++ b/libs/design/menu/src/menu-theme.scss @@ -1,3 +1,4 @@ +// stylelint-disable selector-class-pattern @use '../../scss/theming' as *; @mixin daff-menu-theme($theme) { @@ -23,7 +24,7 @@ } } - &:focus { + &.cdk-keyboard-focused { background: rgba(daff-color($neutral, 20), 0.5); } } @@ -45,7 +46,7 @@ } } - &:focus { + &.cdk-keyboard-focused { background: rgba(daff-color($neutral, 20), 0.08); } } diff --git a/libs/design/menu/src/menu/menu.component.ts b/libs/design/menu/src/menu/menu.component.ts index 2b7e02d205..cd2cd0e3f1 100644 --- a/libs/design/menu/src/menu/menu.component.ts +++ b/libs/design/menu/src/menu/menu.component.ts @@ -123,10 +123,12 @@ export class DaffMenuComponent implements AfterContentInit, AfterViewInit { * @docs-private */ ngAfterViewInit() { - // Set focus to the first menu item when menu opens - if (this._items.length > 0) { - this._keyManager.setFirstItemActive(); - this._keyManager.setActiveItem(0); + // Focus the first menu item when the menu opens. The origin decides how that focus looks. + this._keyManager.setFocusOrigin(this.menuService.origin).setFirstItemActive(); + + // With no items to focus, fall back to the menu itself so Escape still closes it. + if (!this._keyManager.activeItem) { + this._elementRef.nativeElement.focus(); } } } diff --git a/libs/design/menu/src/menu/specs/usage.spec.ts b/libs/design/menu/src/menu/specs/usage.spec.ts index e5a2e83031..d3e8e11c82 100644 --- a/libs/design/menu/src/menu/specs/usage.spec.ts +++ b/libs/design/menu/src/menu/specs/usage.spec.ts @@ -1,3 +1,4 @@ +import { FocusOrigin } from '@angular/cdk/a11y'; import { Component, DebugElement, @@ -19,7 +20,10 @@ import { DaffMenuConfig, } from '../../config/menu-config'; import { DaffMenuService } from '../../services/menu.service'; -import { provideTestMenuService } from '../../testing/dummy-service'; +import { + DummyMenuService, + provideTestMenuService, +} from '../../testing/dummy-service'; @Component({ template: ` @@ -53,13 +57,21 @@ describe('@daffodil/design/menu | DaffMenuComponent | Usage', () => { .compileComponents(); })); - beforeEach(() => { + const createFixture = (origin?: FocusOrigin) => { + if (origin) { + (TestBed.inject(DaffMenuService)).origin = origin; + } + fixture = TestBed.createComponent(WrapperComponent); wrapper = fixture.componentInstance; fixture.detectChanges(); de = fixture.debugElement.query(By.css('daff-menu')); component = de.componentInstance; + }; + + beforeEach(() => { + createFixture(); }); it('should create', () => { @@ -70,6 +82,34 @@ describe('@daffodil/design/menu | DaffMenuComponent | Usage', () => { expect(document.activeElement).toEqual(de.query(By.css('#focused')).nativeElement); }); + describe('when the menu is opened from the keyboard', () => { + beforeEach(() => { + createFixture('keyboard'); + }); + + it('should focus the first focusable child', () => { + expect(document.activeElement).toEqual(de.query(By.css('#focused')).nativeElement); + }); + + it('should mark the first focusable child as keyboard focused', () => { + expect(de.query(By.css('#focused')).nativeElement.classList).toContain('cdk-keyboard-focused'); + }); + }); + + describe('when the menu is opened with a pointer', () => { + beforeEach(() => { + createFixture('mouse'); + }); + + it('should focus the first focusable child', () => { + expect(document.activeElement).toEqual(de.query(By.css('#focused')).nativeElement); + }); + + it('should not mark the first focusable child as keyboard focused', () => { + expect(de.query(By.css('#focused')).nativeElement.classList).not.toContain('cdk-keyboard-focused'); + }); + }); + describe('Keyboard Events', () => { let menuService: DaffMenuService; diff --git a/libs/design/menu/src/services/menu.service.ts b/libs/design/menu/src/services/menu.service.ts index 53d16224ed..5acea36597 100644 --- a/libs/design/menu/src/services/menu.service.ts +++ b/libs/design/menu/src/services/menu.service.ts @@ -1,3 +1,4 @@ +import { FocusOrigin } from '@angular/cdk/a11y'; import { Overlay, OverlayRef, @@ -33,10 +34,19 @@ export type DaffMenuSlot = TemplateRef | DaffLazyComponent | Type = new BehaviorSubject(false); public open$: Observable = this.$_open.asObservable(); + /** + * What opened the menu. The menu focuses its first item either way, but items + * only take on keyboard focus styling when the menu was opened from the keyboard. + */ + get origin(): FocusOrigin { + return this._origin; + } + constructor( protected overlay: Overlay, private injector: Injector, @@ -86,10 +96,12 @@ export class DaffMenuService { this._activator.element.nativeElement.focus(); } - open(activator: ViewContainerRef, component: DaffMenuSlot, config?: DaffMenuConfig) { + open(activator: ViewContainerRef, component: DaffMenuSlot, config?: DaffMenuConfig, origin: FocusOrigin = 'program') { if (this._overlay) { this._destroyOverlay(); } + + this._origin = origin; this._createOverlay(activator, component, config); this._activator = activator; this.$_open.next(true); diff --git a/libs/design/menu/src/testing/dummy-service.ts b/libs/design/menu/src/testing/dummy-service.ts index 7be7ab90dc..735651af72 100644 --- a/libs/design/menu/src/testing/dummy-service.ts +++ b/libs/design/menu/src/testing/dummy-service.ts @@ -1,13 +1,23 @@ -import { Provider } from '@angular/core'; +import { FocusOrigin } from '@angular/cdk/a11y'; +import { + Provider, + ViewContainerRef, +} from '@angular/core'; import { BehaviorSubject } from 'rxjs'; -import { DaffMenuService } from '../services/menu.service'; +import { DaffMenuConfig } from '../config/menu-config'; +import { + DaffMenuService, + DaffMenuSlot, +} from '../services/menu.service'; type PublicPart = {[K in keyof T]: T[K]}; export class DummyMenuService implements PublicPart{ open$ = new BehaviorSubject(true); - open() { + origin: FocusOrigin = 'program'; + open(activator?: ViewContainerRef, component?: DaffMenuSlot, config?: DaffMenuConfig, origin: FocusOrigin = 'program') { + this.origin = origin; this.open$.next(true); } close() {