Skip to content

Commit 7de6aa0

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 a984957 commit 7de6aa0

File tree

3 files changed

+154
-9
lines changed

3 files changed

+154
-9
lines changed

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

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { Component } from '@angular/core';
1+
import {
2+
Component,
3+
DebugElement,
4+
} from '@angular/core';
25
import {
36
ComponentFixture,
7+
fakeAsync,
8+
flush,
49
TestBed,
510
waitForAsync,
611
} from '@angular/core/testing';
@@ -10,6 +15,8 @@ import { of as observableOf } from 'rxjs';
1015

1116
import { HostWindowService } from '../../shared/host-window.service';
1217
import { MenuService } from '../../shared/menu/menu.service';
18+
import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model';
19+
import { MenuSection } from '../../shared/menu/menu-section.model';
1320
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
1421
import { MenuServiceStub } from '../../shared/testing/menu-service.stub';
1522
import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive';
@@ -33,6 +40,7 @@ describe('ExpandableNavbarSectionComponent', () => {
3340
{ provide: 'sectionDataProvider', useValue: {} },
3441
{ provide: MenuService, useValue: menuService },
3542
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
43+
TestComponent,
3644
],
3745
}).compileComponents();
3846
}));
@@ -142,6 +150,8 @@ describe('ExpandableNavbarSectionComponent', () => {
142150
});
143151

144152
describe('when spacebar is pressed on section header (while inactive)', () => {
153+
let sidebarToggler: DebugElement;
154+
145155
beforeEach(() => {
146156
spyOn(component, 'toggleSection').and.callThrough();
147157
spyOn(menuService, 'toggleActiveSection');
@@ -150,15 +160,27 @@ describe('ExpandableNavbarSectionComponent', () => {
150160
component.ngOnInit();
151161
fixture.detectChanges();
152162

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

158166
it('should call toggleSection on the menuService', () => {
167+
// dispatch the (keyup.space) action used in our component HTML
168+
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space', key: ' ' }));
169+
159170
expect(component.toggleSection).toHaveBeenCalled();
160171
expect(menuService.toggleActiveSection).toHaveBeenCalled();
161172
});
173+
174+
// Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/
175+
it('should not do anything on keydown space', () => {
176+
const event: Event = new KeyboardEvent('keydown', { code: 'Space', key: ' ' });
177+
spyOn(event, 'preventDefault').and.callThrough();
178+
179+
// dispatch the (keyup.space) action used in our component HTML
180+
sidebarToggler.nativeElement.dispatchEvent(event);
181+
182+
expect(event.preventDefault).toHaveBeenCalled();
183+
});
162184
});
163185

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

185306
describe('on smaller, mobile screens', () => {
@@ -259,7 +380,9 @@ describe('ExpandableNavbarSectionComponent', () => {
259380
// declare a test component
260381
@Component({
261382
selector: 'ds-test-cmp',
262-
template: ``,
383+
template: `
384+
<a role="menuitem">link</a>
385+
`,
263386
standalone: true,
264387
})
265388
class TestComponent {

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
@@ -55,6 +55,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
5555
*/
5656
mouseEntered = false;
5757

58+
/**
59+
* Whether the section was expanded
60+
*/
61+
focusOnFirstChildSection = false;
62+
5863
/**
5964
* True if screen size was small before a resize event
6065
*/
@@ -107,6 +112,7 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
107112
if (active === true) {
108113
this.addArrowEventListeners = true;
109114
} else {
115+
this.focusOnFirstChildSection = undefined;
110116
this.unsubscribeFromEventListeners();
111117
}
112118
}));
@@ -118,7 +124,7 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
118124
this.dropdownItems.forEach((item: HTMLElement) => {
119125
item.addEventListener('keydown', this.navigateDropdown.bind(this));
120126
});
121-
if (this.dropdownItems.length > 0) {
127+
if (this.focusOnFirstChildSection && this.dropdownItems.length > 0) {
122128
this.dropdownItems.item(0).focus();
123129
}
124130
this.addArrowEventListeners = false;
@@ -130,6 +136,18 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
130136
this.unsubscribeFromEventListeners();
131137
}
132138

139+
/**
140+
* Activate this section if it's currently inactive, deactivate it when it's currently active.
141+
* Also saves whether this toggle was performed by a keyboard event (non-click event) in order to know if thi first
142+
* item should be focussed when activating a section.
143+
*
144+
* @param {Event} event The user event that triggered this method
145+
*/
146+
override toggleSection(event: Event): void {
147+
this.focusOnFirstChildSection = event.type !== 'click';
148+
super.toggleSection(event);
149+
}
150+
133151
/**
134152
* Removes all the current event listeners on the dropdown items (called when the menu is closed & on component
135153
* destruction)
@@ -222,9 +240,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
222240
this.deactivateSection(event, false);
223241
break;
224242
case 'ArrowDown':
243+
this.focusOnFirstChildSection = true;
225244
this.activateSection(event);
226245
break;
227246
case 'Space':
247+
case 'Enter':
228248
event.preventDefault();
229249
break;
230250
}

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ export class MenuSectionComponent implements OnInit, OnDestroy {
7272
* Set initial values for instance variables
7373
*/
7474
ngOnInit(): void {
75-
this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()).subscribe((isActive: boolean) => {
76-
this.active$.next(isActive);
77-
});
75+
this.subs.push(this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()).subscribe((isActive: boolean) => {
76+
if (this.active$.value !== isActive) {
77+
this.active$.next(isActive);
78+
}
79+
}));
7880
this.initializeInjectorData();
7981
}
8082

0 commit comments

Comments
 (0)