Skip to content

Commit 5eee80c

Browse files
authored
Merge pull request #3919 from DSpace/backport-3581-to-dspace-8_x
[Port dspace-8_x] Made expandable navbar section more keyboard accessible
2 parents bb977dc + 7de6aa0 commit 5eee80c

File tree

7 files changed

+398
-70
lines changed

7 files changed

+398
-70
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
8484
this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
8585
this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
8686
this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(this.menuID);
87-
this.isExpanded$ = combineLatestObservable([this.active, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe(
87+
this.isExpanded$ = combineLatestObservable([this.active$, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe(
8888
map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed))),
8989
);
9090
}
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: 141 additions & 12 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,9 +15,11 @@ 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';
15-
import { VarDirective } from '../../shared/utils/var.directive';
22+
import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive';
1623
import { ExpandableNavbarSectionComponent } from './expandable-navbar-section.component';
1724

1825
describe('ExpandableNavbarSectionComponent', () => {
@@ -23,11 +30,17 @@ describe('ExpandableNavbarSectionComponent', () => {
2330
describe('on larger screens', () => {
2431
beforeEach(waitForAsync(() => {
2532
TestBed.configureTestingModule({
26-
imports: [NoopAnimationsModule, ExpandableNavbarSectionComponent, TestComponent, VarDirective],
33+
imports: [
34+
ExpandableNavbarSectionComponent,
35+
HoverOutsideDirective,
36+
NoopAnimationsModule,
37+
TestComponent,
38+
],
2739
providers: [
2840
{ provide: 'sectionDataProvider', useValue: {} },
2941
{ provide: MenuService, useValue: menuService },
3042
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
43+
TestComponent,
3144
],
3245
}).compileComponents();
3346
}));
@@ -41,10 +54,6 @@ describe('ExpandableNavbarSectionComponent', () => {
4154
fixture.detectChanges();
4255
});
4356

44-
it('should create', () => {
45-
expect(component).toBeTruthy();
46-
});
47-
4857
describe('when the mouse enters the section header (while inactive)', () => {
4958
beforeEach(() => {
5059
spyOn(component, 'onMouseEnter').and.callThrough();
@@ -141,6 +150,8 @@ describe('ExpandableNavbarSectionComponent', () => {
141150
});
142151

143152
describe('when spacebar is pressed on section header (while inactive)', () => {
153+
let sidebarToggler: DebugElement;
154+
144155
beforeEach(() => {
145156
spyOn(component, 'toggleSection').and.callThrough();
146157
spyOn(menuService, 'toggleActiveSection');
@@ -149,15 +160,27 @@ describe('ExpandableNavbarSectionComponent', () => {
149160
component.ngOnInit();
150161
fixture.detectChanges();
151162

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

157166
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+
158170
expect(component.toggleSection).toHaveBeenCalled();
159171
expect(menuService.toggleActiveSection).toHaveBeenCalled();
160172
});
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+
});
161184
});
162185

163186
describe('when spacebar is pressed on section header (while active)', () => {
@@ -179,12 +202,116 @@ describe('ExpandableNavbarSectionComponent', () => {
179202
expect(menuService.toggleActiveSection).toHaveBeenCalled();
180203
});
181204
});
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+
});
182304
});
183305

184306
describe('on smaller, mobile screens', () => {
185307
beforeEach(waitForAsync(() => {
186308
TestBed.configureTestingModule({
187-
imports: [NoopAnimationsModule, ExpandableNavbarSectionComponent, TestComponent, VarDirective],
309+
imports: [
310+
ExpandableNavbarSectionComponent,
311+
HoverOutsideDirective,
312+
NoopAnimationsModule,
313+
TestComponent,
314+
],
188315
providers: [
189316
{ provide: 'sectionDataProvider', useValue: {} },
190317
{ provide: MenuService, useValue: menuService },
@@ -253,7 +380,9 @@ describe('ExpandableNavbarSectionComponent', () => {
253380
// declare a test component
254381
@Component({
255382
selector: 'ds-test-cmp',
256-
template: ``,
383+
template: `
384+
<a role="menuitem">link</a>
385+
`,
257386
standalone: true,
258387
})
259388
class TestComponent {

0 commit comments

Comments
 (0)