1
0

116404: Prevent the opening from the modal using mouse interactions from automatically focussing on the first element

(cherry picked from commit 82ed3aadff)
This commit is contained in:
Alexandre Vryghem
2025-01-28 00:11:50 +01:00
committed by github-actions[bot]
parent a984957af9
commit 7de6aa0e24
3 changed files with 154 additions and 9 deletions

View File

@@ -1,6 +1,11 @@
import { Component } from '@angular/core'; import {
Component,
DebugElement,
} from '@angular/core';
import { import {
ComponentFixture, ComponentFixture,
fakeAsync,
flush,
TestBed, TestBed,
waitForAsync, waitForAsync,
} from '@angular/core/testing'; } from '@angular/core/testing';
@@ -10,6 +15,8 @@ import { of as observableOf } from 'rxjs';
import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowService } from '../../shared/host-window.service';
import { MenuService } from '../../shared/menu/menu.service'; import { MenuService } from '../../shared/menu/menu.service';
import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model';
import { MenuSection } from '../../shared/menu/menu-section.model';
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
import { MenuServiceStub } from '../../shared/testing/menu-service.stub'; import { MenuServiceStub } from '../../shared/testing/menu-service.stub';
import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive'; import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive';
@@ -33,6 +40,7 @@ describe('ExpandableNavbarSectionComponent', () => {
{ provide: 'sectionDataProvider', useValue: {} }, { provide: 'sectionDataProvider', useValue: {} },
{ provide: MenuService, useValue: menuService }, { provide: MenuService, useValue: menuService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
TestComponent,
], ],
}).compileComponents(); }).compileComponents();
})); }));
@@ -142,6 +150,8 @@ describe('ExpandableNavbarSectionComponent', () => {
}); });
describe('when spacebar is pressed on section header (while inactive)', () => { describe('when spacebar is pressed on section header (while inactive)', () => {
let sidebarToggler: DebugElement;
beforeEach(() => { beforeEach(() => {
spyOn(component, 'toggleSection').and.callThrough(); spyOn(component, 'toggleSection').and.callThrough();
spyOn(menuService, 'toggleActiveSection'); spyOn(menuService, 'toggleActiveSection');
@@ -150,15 +160,27 @@ describe('ExpandableNavbarSectionComponent', () => {
component.ngOnInit(); component.ngOnInit();
fixture.detectChanges(); fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
// dispatch the (keyup.space) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
}); });
it('should call toggleSection on the menuService', () => { it('should call toggleSection on the menuService', () => {
// dispatch the (keyup.space) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space', key: ' ' }));
expect(component.toggleSection).toHaveBeenCalled(); expect(component.toggleSection).toHaveBeenCalled();
expect(menuService.toggleActiveSection).toHaveBeenCalled(); expect(menuService.toggleActiveSection).toHaveBeenCalled();
}); });
// Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/
it('should not do anything on keydown space', () => {
const event: Event = new KeyboardEvent('keydown', { code: 'Space', key: ' ' });
spyOn(event, 'preventDefault').and.callThrough();
// dispatch the (keyup.space) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
});
}); });
describe('when spacebar is pressed on section header (while active)', () => { describe('when spacebar is pressed on section header (while active)', () => {
@@ -180,6 +202,105 @@ describe('ExpandableNavbarSectionComponent', () => {
expect(menuService.toggleActiveSection).toHaveBeenCalled(); expect(menuService.toggleActiveSection).toHaveBeenCalled();
}); });
}); });
describe('when enter is pressed on section header (while inactive)', () => {
let sidebarToggler: DebugElement;
beforeEach(() => {
spyOn(component, 'toggleSection').and.callThrough();
spyOn(menuService, 'toggleActiveSection');
// Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false));
component.ngOnInit();
fixture.detectChanges();
sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
});
// Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/
it('should not do anything on keydown space', () => {
const event: Event = new KeyboardEvent('keydown', { code: 'Enter' });
spyOn(event, 'preventDefault').and.callThrough();
// dispatch the (keyup.space) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
});
});
describe('when arrow down is pressed on section header', () => {
it('should call activateSection', () => {
spyOn(component, 'activateSection').and.callThrough();
const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
// dispatch the (keydown.ArrowDown) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown' }));
expect(component.focusOnFirstChildSection).toBe(true);
expect(component.activateSection).toHaveBeenCalled();
});
});
describe('when tab is pressed on section header', () => {
it('should call deactivateSection', () => {
spyOn(component, 'deactivateSection').and.callThrough();
const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
// dispatch the (keydown.ArrowDown) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' }));
expect(component.deactivateSection).toHaveBeenCalled();
});
});
describe('navigateDropdown', () => {
beforeEach(fakeAsync(() => {
jasmine.getEnv().allowRespy(true);
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([
Object.assign(new MenuSection(), {
id: 'subSection1',
model: Object.assign(new LinkMenuItemModel(), {
type: 'TEST_LINK',
}),
parentId: component.section.id,
}),
Object.assign(new MenuSection(), {
id: 'subSection2',
model: Object.assign(new LinkMenuItemModel(), {
type: 'TEST_LINK',
}),
parentId: component.section.id,
}),
]));
component.ngOnInit();
flush();
fixture.detectChanges();
component.focusOnFirstChildSection = true;
component.active$.next(true);
fixture.detectChanges();
}));
it('should close the modal on Tab', () => {
spyOn(menuService, 'deactivateSection').and.callThrough();
const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0];
firstSubsection.nativeElement.focus();
firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' }));
expect(menuService.deactivateSection).toHaveBeenCalled();
});
it('should close the modal on Escape', () => {
spyOn(menuService, 'deactivateSection').and.callThrough();
const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0];
firstSubsection.nativeElement.focus();
firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape' }));
expect(menuService.deactivateSection).toHaveBeenCalled();
});
});
}); });
describe('on smaller, mobile screens', () => { describe('on smaller, mobile screens', () => {
@@ -259,7 +380,9 @@ describe('ExpandableNavbarSectionComponent', () => {
// declare a test component // declare a test component
@Component({ @Component({
selector: 'ds-test-cmp', selector: 'ds-test-cmp',
template: ``, template: `
<a role="menuitem">link</a>
`,
standalone: true, standalone: true,
}) })
class TestComponent { class TestComponent {

View File

@@ -55,6 +55,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
*/ */
mouseEntered = false; mouseEntered = false;
/**
* Whether the section was expanded
*/
focusOnFirstChildSection = false;
/** /**
* True if screen size was small before a resize event * True if screen size was small before a resize event
*/ */
@@ -107,6 +112,7 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
if (active === true) { if (active === true) {
this.addArrowEventListeners = true; this.addArrowEventListeners = true;
} else { } else {
this.focusOnFirstChildSection = undefined;
this.unsubscribeFromEventListeners(); this.unsubscribeFromEventListeners();
} }
})); }));
@@ -118,7 +124,7 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
this.dropdownItems.forEach((item: HTMLElement) => { this.dropdownItems.forEach((item: HTMLElement) => {
item.addEventListener('keydown', this.navigateDropdown.bind(this)); item.addEventListener('keydown', this.navigateDropdown.bind(this));
}); });
if (this.dropdownItems.length > 0) { if (this.focusOnFirstChildSection && this.dropdownItems.length > 0) {
this.dropdownItems.item(0).focus(); this.dropdownItems.item(0).focus();
} }
this.addArrowEventListeners = false; this.addArrowEventListeners = false;
@@ -130,6 +136,18 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
this.unsubscribeFromEventListeners(); this.unsubscribeFromEventListeners();
} }
/**
* Activate this section if it's currently inactive, deactivate it when it's currently active.
* Also saves whether this toggle was performed by a keyboard event (non-click event) in order to know if thi first
* item should be focussed when activating a section.
*
* @param {Event} event The user event that triggered this method
*/
override toggleSection(event: Event): void {
this.focusOnFirstChildSection = event.type !== 'click';
super.toggleSection(event);
}
/** /**
* Removes all the current event listeners on the dropdown items (called when the menu is closed & on component * Removes all the current event listeners on the dropdown items (called when the menu is closed & on component
* destruction) * destruction)
@@ -222,9 +240,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
this.deactivateSection(event, false); this.deactivateSection(event, false);
break; break;
case 'ArrowDown': case 'ArrowDown':
this.focusOnFirstChildSection = true;
this.activateSection(event); this.activateSection(event);
break; break;
case 'Space': case 'Space':
case 'Enter':
event.preventDefault(); event.preventDefault();
break; break;
} }

View File

@@ -72,9 +72,11 @@ export class MenuSectionComponent implements OnInit, OnDestroy {
* Set initial values for instance variables * Set initial values for instance variables
*/ */
ngOnInit(): void { ngOnInit(): void {
this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()).subscribe((isActive: boolean) => { this.subs.push(this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()).subscribe((isActive: boolean) => {
this.active$.next(isActive); if (this.active$.value !== isActive) {
}); this.active$.next(isActive);
}
}));
this.initializeInjectorData(); this.initializeInjectorData();
} }