Skip to content

Commit 6f9c55d

Browse files
116404: Prevent the opening from the modal using mouse interactions from automatically focussing on the first element
(cherry picked from commit 82ed3aa)
1 parent 9c6fb17 commit 6f9c55d

3 files changed

Lines changed: 153 additions & 18 deletions

File tree

src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts

Lines changed: 127 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
1+
import { ComponentFixture, TestBed, waitForAsync, fakeAsync, flush } from '@angular/core/testing';
22

33
import { ExpandableNavbarSectionComponent } from './expandable-navbar-section.component';
44
import { By } from '@angular/platform-browser';
55
import { MenuServiceStub } from '../../shared/testing/menu-service.stub';
6-
import { Component } from '@angular/core';
6+
import { Component, DebugElement } from '@angular/core';
77
import { of as observableOf } from 'rxjs';
88
import { HostWindowService } from '../../shared/host-window.service';
99
import { MenuService } from '../../shared/menu/menu.service';
10+
import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model';
11+
import { MenuSection } from '../../shared/menu/menu-section.model';
1012
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
1113
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
1214
import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive';
@@ -28,14 +30,10 @@ describe('ExpandableNavbarSectionComponent', () => {
2830
providers: [
2931
{ provide: 'sectionDataProvider', useValue: {} },
3032
{ provide: MenuService, useValue: menuService },
31-
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }
32-
]
33-
}).overrideComponent(ExpandableNavbarSectionComponent, {
34-
set: {
35-
entryComponents: [TestComponent]
36-
}
37-
})
38-
.compileComponents();
33+
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
34+
TestComponent,
35+
],
36+
}).compileComponents();
3937
}));
4038

4139
beforeEach(() => {
@@ -143,6 +141,8 @@ describe('ExpandableNavbarSectionComponent', () => {
143141
});
144142

145143
describe('when spacebar is pressed on section header (while inactive)', () => {
144+
let sidebarToggler: DebugElement;
145+
146146
beforeEach(() => {
147147
spyOn(component, 'toggleSection').and.callThrough();
148148
spyOn(menuService, 'toggleActiveSection');
@@ -151,15 +151,27 @@ describe('ExpandableNavbarSectionComponent', () => {
151151
component.ngOnInit();
152152
fixture.detectChanges();
153153

154-
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
155-
// dispatch the (keyup.space) action used in our component HTML
156-
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
154+
sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
157155
});
158156

159157
it('should call toggleSection on the menuService', () => {
158+
// dispatch the (keyup.space) action used in our component HTML
159+
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space', key: ' ' }));
160+
160161
expect(component.toggleSection).toHaveBeenCalled();
161162
expect(menuService.toggleActiveSection).toHaveBeenCalled();
162163
});
164+
165+
// Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/
166+
it('should not do anything on keydown space', () => {
167+
const event: Event = new KeyboardEvent('keydown', { code: 'Space', key: ' ' });
168+
spyOn(event, 'preventDefault').and.callThrough();
169+
170+
// dispatch the (keyup.space) action used in our component HTML
171+
sidebarToggler.nativeElement.dispatchEvent(event);
172+
173+
expect(event.preventDefault).toHaveBeenCalled();
174+
});
163175
});
164176

165177
describe('when spacebar is pressed on section header (while active)', () => {
@@ -181,6 +193,105 @@ describe('ExpandableNavbarSectionComponent', () => {
181193
expect(menuService.toggleActiveSection).toHaveBeenCalled();
182194
});
183195
});
196+
197+
describe('when enter is pressed on section header (while inactive)', () => {
198+
let sidebarToggler: DebugElement;
199+
200+
beforeEach(() => {
201+
spyOn(component, 'toggleSection').and.callThrough();
202+
spyOn(menuService, 'toggleActiveSection');
203+
// Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property.
204+
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false));
205+
component.ngOnInit();
206+
fixture.detectChanges();
207+
208+
sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
209+
});
210+
211+
// Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/
212+
it('should not do anything on keydown space', () => {
213+
const event: Event = new KeyboardEvent('keydown', { code: 'Enter' });
214+
spyOn(event, 'preventDefault').and.callThrough();
215+
216+
// dispatch the (keyup.space) action used in our component HTML
217+
sidebarToggler.nativeElement.dispatchEvent(event);
218+
219+
expect(event.preventDefault).toHaveBeenCalled();
220+
});
221+
});
222+
223+
describe('when arrow down is pressed on section header', () => {
224+
it('should call activateSection', () => {
225+
spyOn(component, 'activateSection').and.callThrough();
226+
227+
const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
228+
// dispatch the (keydown.ArrowDown) action used in our component HTML
229+
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown' }));
230+
231+
expect(component.focusOnFirstChildSection).toBe(true);
232+
expect(component.activateSection).toHaveBeenCalled();
233+
});
234+
});
235+
236+
describe('when tab is pressed on section header', () => {
237+
it('should call deactivateSection', () => {
238+
spyOn(component, 'deactivateSection').and.callThrough();
239+
240+
const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
241+
// dispatch the (keydown.ArrowDown) action used in our component HTML
242+
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' }));
243+
244+
expect(component.deactivateSection).toHaveBeenCalled();
245+
});
246+
});
247+
248+
describe('navigateDropdown', () => {
249+
beforeEach(fakeAsync(() => {
250+
jasmine.getEnv().allowRespy(true);
251+
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([
252+
Object.assign(new MenuSection(), {
253+
id: 'subSection1',
254+
model: Object.assign(new LinkMenuItemModel(), {
255+
type: 'TEST_LINK',
256+
}),
257+
parentId: component.section.id,
258+
}),
259+
Object.assign(new MenuSection(), {
260+
id: 'subSection2',
261+
model: Object.assign(new LinkMenuItemModel(), {
262+
type: 'TEST_LINK',
263+
}),
264+
parentId: component.section.id,
265+
}),
266+
]));
267+
component.ngOnInit();
268+
flush();
269+
fixture.detectChanges();
270+
component.focusOnFirstChildSection = true;
271+
component.active$.next(true);
272+
fixture.detectChanges();
273+
}));
274+
275+
it('should close the modal on Tab', () => {
276+
spyOn(menuService, 'deactivateSection').and.callThrough();
277+
278+
const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0];
279+
firstSubsection.nativeElement.focus();
280+
firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' }));
281+
282+
expect(menuService.deactivateSection).toHaveBeenCalled();
283+
});
284+
285+
it('should close the modal on Escape', () => {
286+
spyOn(menuService, 'deactivateSection').and.callThrough();
287+
288+
const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0];
289+
firstSubsection.nativeElement.focus();
290+
firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape' }));
291+
292+
expect(menuService.deactivateSection).toHaveBeenCalled();
293+
});
294+
});
184295
});
185296

186297
describe('on smaller, mobile screens', () => {
@@ -265,7 +376,9 @@ describe('ExpandableNavbarSectionComponent', () => {
265376
// declare a test component
266377
@Component({
267378
selector: 'ds-test-cmp',
268-
template: ``
379+
template: `
380+
<a role="menuitem">link</a>
381+
`,
269382
})
270383
class TestComponent {
271384
}

src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
2929
*/
3030
mouseEntered = false;
3131

32+
/**
33+
* Whether the section was expanded
34+
*/
35+
focusOnFirstChildSection = false;
36+
3237
/**
3338
* True if screen size was small before a resize event
3439
*/
@@ -81,6 +86,7 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
8186
if (active === true) {
8287
this.addArrowEventListeners = true;
8388
} else {
89+
this.focusOnFirstChildSection = undefined;
8490
this.unsubscribeFromEventListeners();
8591
}
8692
}));
@@ -92,7 +98,7 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
9298
this.dropdownItems.forEach((item: HTMLElement) => {
9399
item.addEventListener('keydown', this.navigateDropdown.bind(this));
94100
});
95-
if (this.dropdownItems.length > 0) {
101+
if (this.focusOnFirstChildSection && this.dropdownItems.length > 0) {
96102
this.dropdownItems.item(0).focus();
97103
}
98104
this.addArrowEventListeners = false;
@@ -104,6 +110,18 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
104110
this.unsubscribeFromEventListeners();
105111
}
106112

113+
/**
114+
* Activate this section if it's currently inactive, deactivate it when it's currently active.
115+
* Also saves whether this toggle was performed by a keyboard event (non-click event) in order to know if thi first
116+
* item should be focussed when activating a section.
117+
*
118+
* @param {Event} event The user event that triggered this method
119+
*/
120+
override toggleSection(event: Event): void {
121+
this.focusOnFirstChildSection = event.type !== 'click';
122+
super.toggleSection(event);
123+
}
124+
107125
/**
108126
* Removes all the current event listeners on the dropdown items (called when the menu is closed & on component
109127
* destruction)
@@ -196,9 +214,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
196214
this.deactivateSection(event, false);
197215
break;
198216
case 'ArrowDown':
217+
this.focusOnFirstChildSection = true;
199218
this.activateSection(event);
200219
break;
201220
case 'Space':
221+
case 'Enter':
202222
event.preventDefault();
203223
break;
204224
}

src/app/shared/menu/menu-section/menu-section.component.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,11 @@ export class MenuSectionComponent implements OnInit, OnDestroy {
5555
* Set initial values for instance variables
5656
*/
5757
ngOnInit(): void {
58-
this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()).subscribe((isActive: boolean) => {
59-
this.active$.next(isActive);
60-
});
58+
this.subs.push(this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()).subscribe((isActive: boolean) => {
59+
if (this.active$.value !== isActive) {
60+
this.active$.next(isActive);
61+
}
62+
}));
6163
this.initializeInjectorData();
6264
}
6365

0 commit comments

Comments
 (0)