Skip to content

Commit b9b1d3f

Browse files
116404: Added navigation with arrow keys in navbar & collapsed the expandable menu when hovering outside of it
(cherry picked from commit 05232cd)
1 parent 17e0333 commit b9b1d3f

4 files changed

Lines changed: 185 additions & 43 deletions

File tree

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,37 @@
1-
<div class="ds-menu-item-wrapper text-md-center"
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.space)="$event.preventDefault()"
12-
(keydown.tab)="deactivateSection($event, false)"
13-
aria-haspopup="menu"
14-
data-test="navbar-section-toggler"
15-
[attr.aria-expanded]="(active$ | async).valueOf()"
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">
1+
<div #expandableNavbarSectionContainer class="ds-menu-item-wrapper text-md-center"
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>
2221
</span>
23-
<i class="fas fa-caret-down fa-xs toggle-menu-icon" aria-hidden="true"></i>
24-
</a>
25-
<div *ngIf="(active$ | async).valueOf() === true" (click)="deactivateSection($event)"
26-
[id]="expandableNavbarSectionId(section.id)"
27-
role="menu"
28-
class="dropdown-menu show nav-dropdown-menu m-0 shadow-none border-top-0 px-3 px-md-0 pt-0 pt-md-1">
29-
<div @slide role="presentation">
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+
[dsHoverOutsideOfElement]="expandableNavbarSection"
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>
3434
</div>
3535
</div>
36+
</div>
3637
</div>

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

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import {
55
NgIf,
66
} from '@angular/common';
77
import {
8+
AfterViewChecked,
89
Component,
10+
ElementRef,
911
HostListener,
1012
Inject,
1113
Injector,
1214
OnInit,
15+
ViewChild,
1316
} from '@angular/core';
1417
import { RouterLinkActive } from '@angular/router';
1518
import { Observable } from 'rxjs';
@@ -19,6 +22,8 @@ import { slide } from '../../shared/animations/slide';
1922
import { HostWindowService } from '../../shared/host-window.service';
2023
import { MenuService } from '../../shared/menu/menu.service';
2124
import { MenuID } from '../../shared/menu/menu-id.model';
25+
import { MenuSection } from '../../shared/menu/menu-section.model';
26+
import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive';
2227
import { VarDirective } from '../../shared/utils/var.directive';
2328
import { NavbarSectionComponent } from '../navbar-section/navbar-section.component';
2429

@@ -31,9 +36,20 @@ import { NavbarSectionComponent } from '../navbar-section/navbar-section.compone
3136
styleUrls: ['./expandable-navbar-section.component.scss'],
3237
animations: [slide],
3338
standalone: true,
34-
imports: [VarDirective, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe],
39+
imports: [
40+
AsyncPipe,
41+
HoverOutsideDirective,
42+
NgComponentOutlet,
43+
NgFor,
44+
NgIf,
45+
RouterLinkActive,
46+
VarDirective,
47+
],
3548
})
36-
export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit {
49+
export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements AfterViewChecked, OnInit {
50+
51+
@ViewChild('expandableNavbarSectionContainer') expandableNavbarSection: ElementRef;
52+
3753
/**
3854
* This section resides in the Public Navbar
3955
*/
@@ -54,6 +70,13 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
5470
*/
5571
isMobile$: Observable<boolean>;
5672

73+
/**
74+
* Boolean used to add the event listeners to the items in the expandable menu when expanded. This is done for
75+
* performance reasons, there is currently an *ngIf on the menu to prevent the {@link HoverOutsideDirective} to tank
76+
* performance when not expanded.
77+
*/
78+
addArrowEventListeners = false;
79+
5780
@HostListener('window:resize', ['$event'])
5881
onResize() {
5982
this.isMobile$.pipe(
@@ -68,17 +91,33 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
6891
});
6992
}
7093

71-
constructor(@Inject('sectionDataProvider') menuSection,
72-
protected menuService: MenuService,
73-
protected injector: Injector,
74-
private windowService: HostWindowService,
94+
constructor(
95+
@Inject('sectionDataProvider') public section: MenuSection,
96+
protected menuService: MenuService,
97+
protected injector: Injector,
98+
protected windowService: HostWindowService,
7599
) {
76-
super(menuSection, menuService, injector);
100+
super(section, menuService, injector);
77101
this.isMobile$ = this.windowService.isMobile();
78102
}
79103

80104
ngOnInit() {
81105
super.ngOnInit();
106+
this.subs.push(this.active$.subscribe((active: boolean) => {
107+
if (active === true) {
108+
this.addArrowEventListeners = true;
109+
}
110+
}));
111+
}
112+
113+
ngAfterViewChecked(): void {
114+
if (this.addArrowEventListeners) {
115+
const dropdownItems = document.querySelectorAll(`#${this.expandableNavbarSectionId()} *[role="menuitem"]`);
116+
dropdownItems.forEach(item => {
117+
item.addEventListener('keydown', this.navigateDropdown.bind(this));
118+
});
119+
this.addArrowEventListeners = false;
120+
}
82121
}
83122

84123
/**
@@ -113,9 +152,54 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
113152

114153
/**
115154
* returns the ID of the DOM element representing the navbar section
116-
* @param sectionId
117155
*/
118-
expandableNavbarSectionId(sectionId: string) {
119-
return `expandable-navbar-section-${sectionId}-dropdown`;
156+
expandableNavbarSectionId(): string {
157+
return `expandable-navbar-section-${this.section.id}-dropdown`;
158+
}
159+
160+
/**
161+
* Handles the navigation between the menu items
162+
*
163+
* @param event
164+
*/
165+
navigateDropdown(event: KeyboardEvent): void {
166+
if (event.key === 'Tab') {
167+
this.deactivateSection(event, false);
168+
return;
169+
}
170+
event.preventDefault();
171+
event.stopPropagation();
172+
173+
const items: NodeListOf<Element> = document.querySelectorAll(`#${this.expandableNavbarSectionId()} *[role="menuitem"]`);
174+
if (items.length === 0) {
175+
return;
176+
}
177+
const currentIndex: number = Array.from(items).findIndex((item: Element) => item === event.target);
178+
179+
if (event.key === 'ArrowDown') {
180+
(items[(currentIndex + 1) % items.length] as HTMLElement).focus();
181+
} else if (event.key === 'ArrowUp') {
182+
(items[(currentIndex - 1 + items.length) % items.length] as HTMLElement).focus();
183+
}
184+
}
185+
186+
/**
187+
* Handles all the keydown events on the dropdown toggle
188+
*
189+
* @param event
190+
*/
191+
keyDown(event: KeyboardEvent): void {
192+
switch (event.code) {
193+
// Works for both Tab & Shift Tab
194+
case 'Tab':
195+
this.deactivateSection(event, false);
196+
break;
197+
case 'ArrowDown':
198+
this.navigateDropdown(event);
199+
break;
200+
case 'Space':
201+
event.preventDefault();
202+
break;
203+
}
120204
}
121205
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
Directive,
3+
ElementRef,
4+
EventEmitter,
5+
HostListener,
6+
Input,
7+
Output,
8+
} from '@angular/core';
9+
10+
/**
11+
* Directive to detect when the user hovers outside of the element the directive was put on
12+
*
13+
* BEWARE: it's probably not good for performance to use this excessively (on {@link ExpandableNavbarSectionComponent}
14+
* for example, a workaround for this problem was to add an `*ngIf` to prevent this Directive from always being active)
15+
*/
16+
@Directive({
17+
selector: '[dsHoverOutside]',
18+
standalone: true,
19+
})
20+
export class HoverOutsideDirective {
21+
22+
/**
23+
* Emits null when the user hovers outside of the element
24+
*/
25+
@Output()
26+
public dsHoverOutside = new EventEmitter();
27+
28+
/**
29+
* The {@link ElementRef} for which this directive should emit when the mouse leaves it. By default this will be the
30+
* element the directive was put on.
31+
*/
32+
@Input()
33+
public dsHoverOutsideOfElement: ElementRef;
34+
35+
constructor(
36+
private elementRef: ElementRef,
37+
) {
38+
this.dsHoverOutsideOfElement = this.elementRef;
39+
}
40+
41+
@HostListener('document:mouseover', ['$event'])
42+
public onMouseOver(event: MouseEvent): void {
43+
const targetElement: HTMLElement = event.target as HTMLElement;
44+
const hoveredInside = this.dsHoverOutsideOfElement.nativeElement.contains(targetElement);
45+
46+
if (!hoveredInside) {
47+
this.dsHoverOutside.emit(null);
48+
}
49+
}
50+
51+
}

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@ import { RouterLinkActive } from '@angular/router';
99

1010
import { ExpandableNavbarSectionComponent as BaseComponent } from '../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component';
1111
import { slide } from '../../../../../app/shared/animations/slide';
12+
import { HoverOutsideDirective } from '../../../../../app/shared/utils/hover-outside.directive';
1213
import { VarDirective } from '../../../../../app/shared/utils/var.directive';
1314

14-
/**
15-
* Represents an expandable section in the navbar
16-
*/
1715
@Component({
1816
selector: 'ds-themed-expandable-navbar-section',
1917
// templateUrl: './expandable-navbar-section.component.html',
@@ -22,7 +20,15 @@ import { VarDirective } from '../../../../../app/shared/utils/var.directive';
2220
styleUrls: ['../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss'],
2321
animations: [slide],
2422
standalone: true,
25-
imports: [VarDirective, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe],
23+
imports: [
24+
AsyncPipe,
25+
HoverOutsideDirective,
26+
NgComponentOutlet,
27+
NgFor,
28+
NgIf,
29+
RouterLinkActive,
30+
VarDirective,
31+
],
2632
})
2733
export class ExpandableNavbarSectionComponent extends BaseComponent {
2834
}

0 commit comments

Comments
 (0)