Skip to content

Commit f01e49f

Browse files
authored
Merge pull request DSpace#3924 from alexandrevryghem/made-expandable-navbar-section-more-accessible-7_x
[Port dspace-7_x] Made expandable navbar section more keyboard accessible
2 parents c3f17b9 + 6f9c55d commit f01e49f

7 files changed

Lines changed: 383 additions & 74 deletions

File tree

src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
6767
this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
6868
this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
6969
this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(this.menuID);
70-
this.isExpanded$ = combineLatestObservable([this.active, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe(
71-
map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed)))
70+
this.isExpanded$ = combineLatestObservable([this.active$, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe(
71+
map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed))),
7272
);
7373
}
7474

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,37 @@
11
<div class="ds-menu-item-wrapper text-md-center"
2-
[id]="'expandable-navbar-section-' + section.id"
3-
(mouseenter)="onMouseEnter($event, isActive)"
4-
(mouseleave)="onMouseLeave($event, isActive)"
5-
data-test="navbar-section-wrapper"
6-
*ngVar="(active | async) as isActive">
7-
<a href="javascript:void(0);" routerLinkActive="active"
8-
role="menuitem"
9-
(keyup.enter)="toggleSection($event)"
10-
(keyup.space)="toggleSection($event)"
11-
(click)="toggleSection($event)"
12-
(keydown.space)="$event.preventDefault()"
13-
aria-haspopup="menu"
14-
data-test="navbar-section-toggler"
15-
[attr.aria-expanded]="isActive"
16-
[attr.aria-controls]="expandableNavbarSectionId(section.id)"
17-
class="d-flex flex-row flex-nowrap align-items-center gapx-1 ds-menu-toggler-wrapper"
18-
[class.disabled]="section.model?.disabled">
2+
[id]="'expandable-navbar-section-' + section.id"
3+
(mouseenter)="onMouseEnter($event)"
4+
(mouseleave)="onMouseLeave($event)"
5+
data-test="navbar-section-wrapper">
6+
<a href="javascript:void(0);" routerLinkActive="active"
7+
role="menuitem"
8+
(keyup.enter)="toggleSection($event)"
9+
(keyup.space)="toggleSection($event)"
10+
(click)="toggleSection($event)"
11+
(keydown)="keyDown($event)"
12+
aria-haspopup="menu"
13+
data-test="navbar-section-toggler"
14+
[attr.aria-expanded]="(active$ | async).valueOf()"
15+
[attr.aria-controls]="expandableNavbarSectionId()"
16+
class="d-flex flex-row flex-nowrap align-items-center gapx-1 ds-menu-toggler-wrapper"
17+
[class.disabled]="section.model?.disabled">
1918
<span class="flex-fill">
2019
<ng-container
2120
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
22-
<!-- <span class="sr-only">{{'nav.expandable-navbar-section-suffix' | translate}}</span>-->
2321
</span>
24-
<i class="fas fa-caret-down fa-xs toggle-menu-icon" aria-hidden="true"></i>
25-
</a>
26-
<div @slide *ngIf="isActive" (click)="deactivateSection($event)"
27-
[id]="expandableNavbarSectionId(section.id)"
28-
role="menu"
29-
class="dropdown-menu show nav-dropdown-menu m-0 shadow-none border-top-0 px-3 px-md-0 pt-0 pt-md-1">
30-
<div *ngFor="let subSection of (subSections$ | async)" class="text-nowrap" role="presentation">
31-
<ng-container
32-
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
33-
</div>
22+
<i class="fas fa-caret-down fa-xs toggle-menu-icon" aria-hidden="true"></i>
23+
</a>
24+
<div *ngIf="(active$ | async).valueOf() === true" (click)="deactivateSection($event)"
25+
[id]="expandableNavbarSectionId()"
26+
[dsHoverOutsideOfParentSelector]="'#expandable-navbar-section-' + section.id"
27+
(dsHoverOutside)="deactivateSection($event, false)"
28+
role="menu"
29+
class="dropdown-menu show nav-dropdown-menu m-0 shadow-none border-top-0 px-3 px-md-0 pt-0 pt-md-1">
30+
<div @slide role="presentation">
31+
<div *ngFor="let subSection of (subSections$ | async)" class="text-nowrap" role="presentation">
32+
<ng-container
33+
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
34+
</div>
3435
</div>
36+
</div>
3537
</div>

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

Lines changed: 138 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
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';
12-
import { VarDirective } from '../../shared/utils/var.directive';
14+
import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive';
1315

1416
describe('ExpandableNavbarSectionComponent', () => {
1517
let component: ExpandableNavbarSectionComponent;
@@ -20,18 +22,18 @@ describe('ExpandableNavbarSectionComponent', () => {
2022
beforeEach(waitForAsync(() => {
2123
TestBed.configureTestingModule({
2224
imports: [NoopAnimationsModule],
23-
declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
25+
declarations: [
26+
ExpandableNavbarSectionComponent,
27+
HoverOutsideDirective,
28+
TestComponent,
29+
],
2430
providers: [
2531
{ provide: 'sectionDataProvider', useValue: {} },
2632
{ provide: MenuService, useValue: menuService },
27-
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }
28-
]
29-
}).overrideComponent(ExpandableNavbarSectionComponent, {
30-
set: {
31-
entryComponents: [TestComponent]
32-
}
33-
})
34-
.compileComponents();
33+
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
34+
TestComponent,
35+
],
36+
}).compileComponents();
3537
}));
3638

3739
beforeEach(() => {
@@ -43,10 +45,6 @@ describe('ExpandableNavbarSectionComponent', () => {
4345
fixture.detectChanges();
4446
});
4547

46-
it('should create', () => {
47-
expect(component).toBeTruthy();
48-
});
49-
5048
describe('when the mouse enters the section header (while inactive)', () => {
5149
beforeEach(() => {
5250
spyOn(component, 'onMouseEnter').and.callThrough();
@@ -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,13 +193,116 @@ 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', () => {
187298
beforeEach(waitForAsync(() => {
188299
TestBed.configureTestingModule({
189300
imports: [NoopAnimationsModule],
190-
declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
301+
declarations: [
302+
ExpandableNavbarSectionComponent,
303+
HoverOutsideDirective,
304+
TestComponent,
305+
],
191306
providers: [
192307
{ provide: 'sectionDataProvider', useValue: {} },
193308
{ provide: MenuService, useValue: menuService },
@@ -261,7 +376,9 @@ describe('ExpandableNavbarSectionComponent', () => {
261376
// declare a test component
262377
@Component({
263378
selector: 'ds-test-cmp',
264-
template: ``
379+
template: `
380+
<a role="menuitem">link</a>
381+
`,
265382
})
266383
class TestComponent {
267384
}

0 commit comments

Comments
 (0)