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
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { FocusableOption } from '@angular/cdk/a11y';
import {
FocusableOption,
FocusMonitor,
FocusOrigin,
} from '@angular/cdk/a11y';
import {
AfterViewInit,
Directive,
Expand Down Expand Up @@ -29,6 +33,7 @@ export class DaffBreadcrumbMenuItemDirective implements FocusableOption, AfterVi
constructor(
private _viewContainerRef: ViewContainerRef,
private _menuService: DaffMenuService,
private _focusMonitor: FocusMonitor,
) {}

/**
Expand All @@ -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 {
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});

Expand Down Expand Up @@ -143,6 +204,7 @@ describe('@daffodil/design/menu | DaffMenuActivatorDirective | With Custom Posit
jasmine.anything(),
jasmine.anything(),
jasmine.objectContaining({ xPosition: 'before', yPosition: 'above' }),
jasmine.anything(),
);
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FocusOrigin } from '@angular/cdk/a11y';
import {
ChangeDetectorRef,
computed,
Expand Down Expand Up @@ -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',
Expand All @@ -48,6 +51,7 @@ export class DaffMenuActivatorDirective implements OnDestroy {

private _destroyed$ = new Subject<boolean>();
private _defaultMenuId = daffNextMenuId();
private _openedBy: FocusOrigin = null;
protected _open: boolean;
readonly isOpen = signal(false);

Expand Down Expand Up @@ -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;
}
}
37 changes: 32 additions & 5 deletions libs/design/menu/src/menu-item/menu-item.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
*/
Expand All @@ -44,8 +50,25 @@ export class DaffMenuItemComponent implements FocusableOption {
constructor(
private _elementRef: ElementRef<HTMLElement>,
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
*/
Expand All @@ -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);
}
}
}
5 changes: 3 additions & 2 deletions libs/design/menu/src/menu-theme.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// stylelint-disable selector-class-pattern
@use '../../scss/theming' as *;

@mixin daff-menu-theme($theme) {
Expand All @@ -23,7 +24,7 @@
}
}

&:focus {
&.cdk-keyboard-focused {
background: rgba(daff-color($neutral, 20), 0.5);
}
}
Expand All @@ -45,7 +46,7 @@
}
}

&:focus {
&.cdk-keyboard-focused {
background: rgba(daff-color($neutral, 20), 0.08);
}
}
Expand Down
10 changes: 6 additions & 4 deletions libs/design/menu/src/menu/menu.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
44 changes: 42 additions & 2 deletions libs/design/menu/src/menu/specs/usage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FocusOrigin } from '@angular/cdk/a11y';
import {
Component,
DebugElement,
Expand All @@ -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: `
Expand Down Expand Up @@ -53,13 +57,21 @@ describe('@daffodil/design/menu | DaffMenuComponent | Usage', () => {
.compileComponents();
}));

beforeEach(() => {
const createFixture = (origin?: FocusOrigin) => {
if (origin) {
(<DummyMenuService><unknown>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', () => {
Expand All @@ -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;

Expand Down
Loading
Loading