diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts index 2b7c6652588..f8bb35b57c5 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts @@ -1,12 +1,14 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync, fakeAsync, flush } from '@angular/core/testing'; import { ExpandableNavbarSectionComponent } from './expandable-navbar-section.component'; import { By } from '@angular/platform-browser'; import { MenuServiceStub } from '../../shared/testing/menu-service.stub'; -import { Component } from '@angular/core'; +import { Component, DebugElement } from '@angular/core'; import { of as observableOf } from 'rxjs'; import { HostWindowService } from '../../shared/host-window.service'; import { MenuService } from '../../shared/menu/menu.service'; +import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; +import { MenuSection } from '../../shared/menu/menu-section.model'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive'; @@ -28,14 +30,10 @@ describe('ExpandableNavbarSectionComponent', () => { providers: [ { provide: 'sectionDataProvider', useValue: {} }, { provide: MenuService, useValue: menuService }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(800) } - ] - }).overrideComponent(ExpandableNavbarSectionComponent, { - set: { - entryComponents: [TestComponent] - } - }) - .compileComponents(); + { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + TestComponent, + ], + }).compileComponents(); })); beforeEach(() => { @@ -143,6 +141,8 @@ describe('ExpandableNavbarSectionComponent', () => { }); describe('when spacebar is pressed on section header (while inactive)', () => { + let sidebarToggler: DebugElement; + beforeEach(() => { spyOn(component, 'toggleSection').and.callThrough(); spyOn(menuService, 'toggleActiveSection'); @@ -151,15 +151,27 @@ describe('ExpandableNavbarSectionComponent', () => { component.ngOnInit(); fixture.detectChanges(); - const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); - // dispatch the (keyup.space) action used in our component HTML - sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); + sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); }); it('should call toggleSection on the menuService', () => { + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space', key: ' ' })); + expect(component.toggleSection).toHaveBeenCalled(); expect(menuService.toggleActiveSection).toHaveBeenCalled(); }); + + // Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/ + it('should not do anything on keydown space', () => { + const event: Event = new KeyboardEvent('keydown', { code: 'Space', key: ' ' }); + spyOn(event, 'preventDefault').and.callThrough(); + + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); }); describe('when spacebar is pressed on section header (while active)', () => { @@ -181,6 +193,105 @@ describe('ExpandableNavbarSectionComponent', () => { expect(menuService.toggleActiveSection).toHaveBeenCalled(); }); }); + + describe('when enter is pressed on section header (while inactive)', () => { + let sidebarToggler: DebugElement; + + beforeEach(() => { + spyOn(component, 'toggleSection').and.callThrough(); + spyOn(menuService, 'toggleActiveSection'); + // Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property. + spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + + sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); + }); + + // Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/ + it('should not do anything on keydown space', () => { + const event: Event = new KeyboardEvent('keydown', { code: 'Enter' }); + spyOn(event, 'preventDefault').and.callThrough(); + + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('when arrow down is pressed on section header', () => { + it('should call activateSection', () => { + spyOn(component, 'activateSection').and.callThrough(); + + const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); + // dispatch the (keydown.ArrowDown) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown' })); + + expect(component.focusOnFirstChildSection).toBe(true); + expect(component.activateSection).toHaveBeenCalled(); + }); + }); + + describe('when tab is pressed on section header', () => { + it('should call deactivateSection', () => { + spyOn(component, 'deactivateSection').and.callThrough(); + + const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); + // dispatch the (keydown.ArrowDown) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' })); + + expect(component.deactivateSection).toHaveBeenCalled(); + }); + }); + + describe('navigateDropdown', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([ + Object.assign(new MenuSection(), { + id: 'subSection1', + model: Object.assign(new LinkMenuItemModel(), { + type: 'TEST_LINK', + }), + parentId: component.section.id, + }), + Object.assign(new MenuSection(), { + id: 'subSection2', + model: Object.assign(new LinkMenuItemModel(), { + type: 'TEST_LINK', + }), + parentId: component.section.id, + }), + ])); + component.ngOnInit(); + flush(); + fixture.detectChanges(); + component.focusOnFirstChildSection = true; + component.active$.next(true); + fixture.detectChanges(); + })); + + it('should close the modal on Tab', () => { + spyOn(menuService, 'deactivateSection').and.callThrough(); + + const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0]; + firstSubsection.nativeElement.focus(); + firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' })); + + expect(menuService.deactivateSection).toHaveBeenCalled(); + }); + + it('should close the modal on Escape', () => { + spyOn(menuService, 'deactivateSection').and.callThrough(); + + const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0]; + firstSubsection.nativeElement.focus(); + firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape' })); + + expect(menuService.deactivateSection).toHaveBeenCalled(); + }); + }); }); describe('on smaller, mobile screens', () => { @@ -265,7 +376,9 @@ describe('ExpandableNavbarSectionComponent', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: `` + template: ` + link + `, }) class TestComponent { } diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts index 063b080c629..c4842bbb443 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -29,6 +29,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp */ mouseEntered = false; + /** + * Whether the section was expanded + */ + focusOnFirstChildSection = false; + /** * True if screen size was small before a resize event */ @@ -81,6 +86,7 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp if (active === true) { this.addArrowEventListeners = true; } else { + this.focusOnFirstChildSection = undefined; this.unsubscribeFromEventListeners(); } })); @@ -92,7 +98,7 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp this.dropdownItems.forEach((item: HTMLElement) => { item.addEventListener('keydown', this.navigateDropdown.bind(this)); }); - if (this.dropdownItems.length > 0) { + if (this.focusOnFirstChildSection && this.dropdownItems.length > 0) { this.dropdownItems.item(0).focus(); } this.addArrowEventListeners = false; @@ -104,6 +110,18 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp this.unsubscribeFromEventListeners(); } + /** + * Activate this section if it's currently inactive, deactivate it when it's currently active. + * Also saves whether this toggle was performed by a keyboard event (non-click event) in order to know if thi first + * item should be focussed when activating a section. + * + * @param {Event} event The user event that triggered this method + */ + override toggleSection(event: Event): void { + this.focusOnFirstChildSection = event.type !== 'click'; + super.toggleSection(event); + } + /** * Removes all the current event listeners on the dropdown items (called when the menu is closed & on component * destruction) @@ -196,9 +214,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp this.deactivateSection(event, false); break; case 'ArrowDown': + this.focusOnFirstChildSection = true; this.activateSection(event); break; case 'Space': + case 'Enter': event.preventDefault(); break; } diff --git a/src/app/shared/menu/menu-section/menu-section.component.ts b/src/app/shared/menu/menu-section/menu-section.component.ts index 8ee40ee8c6d..8988d53fe14 100644 --- a/src/app/shared/menu/menu-section/menu-section.component.ts +++ b/src/app/shared/menu/menu-section/menu-section.component.ts @@ -55,9 +55,11 @@ export class MenuSectionComponent implements OnInit, OnDestroy { * Set initial values for instance variables */ ngOnInit(): void { - this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()).subscribe((isActive: boolean) => { - this.active$.next(isActive); - }); + this.subs.push(this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()).subscribe((isActive: boolean) => { + if (this.active$.value !== isActive) { + this.active$.next(isActive); + } + })); this.initializeInjectorData(); }