From 806780fa08478fbea70cd01a922c4f4d33b11296 Mon Sep 17 00:00:00 2001 From: Etienne LOUPIAS <eloupias@grandlyon.com> Date: Thu, 1 Aug 2024 12:35:54 +0000 Subject: [PATCH] fix(accessibility): annuaire Menu Button aria pattern --- .../annuaire-header.component.html | 4 +++ .../annuaire-header.component.ts | 29 ++++++++++++++++++- .../filter-modal/filter-modal.component.html | 8 ++++- .../collapsable-filter.component.html | 3 +- .../structure-list-search.component.ts | 28 +++--------------- src/app/utils/utils.ts | 10 ++++++- 6 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/app/annuaire/annuaire-header/annuaire-header.component.html b/src/app/annuaire/annuaire-header/annuaire-header.component.html index dda02e28c..48e53f05d 100644 --- a/src/app/annuaire/annuaire-header/annuaire-header.component.html +++ b/src/app/annuaire/annuaire-header/annuaire-header.component.html @@ -5,14 +5,18 @@ <app-collapsable-filter [label]="'Fonction'" [expanded]="modalTypeOpened === TypeModal.jobs" + [id]="'modal' + TypeModal.jobs" [active]="jobsFiltersActive" (toggle)="openModal(TypeModal.jobs)" + (keyup)="onKeyboardNavOnFilters($event)" /> <app-collapsable-filter [label]="'Employeur'" [expanded]="modalTypeOpened === TypeModal.employers" + [id]="'modal' + TypeModal.employers" [active]="employersFiltersActive" (toggle)="openModal(TypeModal.employers)" + (keyup)="onKeyboardNavOnFilters($event)" /> <app-filter-modal [modalType]="modalTypeOpened" diff --git a/src/app/annuaire/annuaire-header/annuaire-header.component.ts b/src/app/annuaire/annuaire-header/annuaire-header.component.ts index 2d18ac530..2660979de 100644 --- a/src/app/annuaire/annuaire-header/annuaire-header.component.ts +++ b/src/app/annuaire/annuaire-header/annuaire-header.component.ts @@ -1,7 +1,8 @@ -import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, lastValueFrom } from 'rxjs'; import { SearchService } from '../../structure-list/services/search.service'; +import { Utils } from '../../utils/utils'; import { TypeModal } from '../enums/TypeModal.enum'; import { SearchQuery } from '../models/searchQuery.model'; @@ -14,6 +15,7 @@ export class AnnuaireHeaderComponent implements OnInit, OnChanges { @Input() shouldResetFilters = 0; @Output() searchEvent = new EventEmitter<SearchQuery>(); + public utils = new Utils(); public modalTypeOpened: TypeModal; public employersFiltersActive = false; public jobsFiltersActive = false; @@ -24,11 +26,13 @@ export class AnnuaireHeaderComponent implements OnInit, OnChanges { public searchInput = ''; public jobFilterChecked: string[] = []; public employerFilterChecked: string[] = []; + public keyboardEvent = false; constructor( private activatedRoute: ActivatedRoute, private router: Router, public searchService: SearchService, + private elementRef: ElementRef, ) {} async ngOnInit(): Promise<void> { @@ -142,9 +146,32 @@ export class AnnuaireHeaderComponent implements OnInit, OnChanges { /** Open the modal and display the list according to the right filter button */ public openModal(modalType: TypeModal): void { this.modalTypeOpened = this.modalTypeOpened === modalType ? undefined : modalType; + + // Accessibility: when navigating with keyboard and opening a filter modal, send focus to the first focusable element of the opened modal + if (this.keyboardEvent) { + setTimeout(() => { + this.utils.setFocusOnElement(this.elementRef, `.modalContent .collapse-header, .modalContent input`); + }, 0); + } + } + + // When filters and their modal are in the same component, we can remove onKeyboardNavOnFilters, setFocusOnOpenedModal, and setFocusOnFilters. + // because the focus will then flow normally between the filter and the modal + public onKeyboardNavOnFilters(event: KeyboardEvent): void { + switch (event.key) { + case 'ArrowUp': + case 'ArrowDown': + case 'Tab': + this.keyboardEvent = true; + break; + } } public closeModal(): void { + // Accessibility: when navigating with keyboard and closing a filter modal, send focus back to filters + if (this.keyboardEvent) { + this.utils.setFocusOnElement(this.elementRef, '#modal' + this.modalTypeOpened + ' button:first-of-type'); + } this.modalTypeOpened = undefined; this.countCheckedFilters(); } diff --git a/src/app/annuaire/filter-modal/filter-modal.component.html b/src/app/annuaire/filter-modal/filter-modal.component.html index 82d4be520..2743408cd 100644 --- a/src/app/annuaire/filter-modal/filter-modal.component.html +++ b/src/app/annuaire/filter-modal/filter-modal.component.html @@ -1,4 +1,10 @@ -<div *ngIf="modalType" [ngClass]="['filterModal', getModalType()]"> +<div + *ngIf="modalType" + role="menu" + cdkTrapFocus + [ngClass]="['filterModal', getModalType()]" + [cdkTrapFocusAutoCapture]="true" +> <div class="filterModalContainer"> <div class="modalContent"> <app-label-checkbox diff --git a/src/app/shared/components/collapsable-filter/collapsable-filter.component.html b/src/app/shared/components/collapsable-filter/collapsable-filter.component.html index 21e02b156..31f08faf3 100644 --- a/src/app/shared/components/collapsable-filter/collapsable-filter.component.html +++ b/src/app/shared/components/collapsable-filter/collapsable-filter.component.html @@ -1,7 +1,8 @@ <button type="button" aria-haspopup="true" - [attr.aria-label]="label + 'Déplier les filtres : '" + [attr.aria-label]="'Déplier les filtres : ' + label" + [attr.aria-expanded]="expanded" [ngClass]="{ expanded: expanded, active: active diff --git a/src/app/structure-list/components/structure-list-search/structure-list-search.component.ts b/src/app/structure-list/components/structure-list-search/structure-list-search.component.ts index 3ff2728f0..ee547e67a 100644 --- a/src/app/structure-list/components/structure-list-search/structure-list-search.component.ts +++ b/src/app/structure-list/components/structure-list-search/structure-list-search.component.ts @@ -189,7 +189,9 @@ export class StructureListSearchComponent implements OnInit { // Accessibility: when navigating with keyboard and opening a filter modal, send focus to the first focusable element of the opened modal if (this.keyboardEvent) { - this.setFocusOnOpenedModal(); + setTimeout(() => { + this.utils.setFocusOnElement(this.elementRef, `.modalContent .collapse-header, .modalContent input`); + }, 0); } } } @@ -206,32 +208,10 @@ export class StructureListSearchComponent implements OnInit { } } - private setFocusOnOpenedModal(): void { - setTimeout(() => { - const modalFirstFocusableElement = this.elementRef.nativeElement.querySelector( - `.modalContent .collapse-header, .modalContent input`, - ); - if (modalFirstFocusableElement) { - const focusedElement = modalFirstFocusableElement as HTMLElement; - focusedElement.focus(); - } - }, 0); - } - - private setFocusOnFilter(): void { - const filterButton = this.elementRef.nativeElement.querySelector( - '#modal' + this.modalTypeOpened + ' button:first-of-type', - ); - if (filterButton) { - const focusedElement = filterButton as HTMLElement; - focusedElement.focus(); - } - } - public closeModal(): void { // Accessibility: when navigating with keyboard and closing a filter modal, send focus back to filters if (this.keyboardEvent) { - this.setFocusOnFilter(); + this.utils.setFocusOnElement(this.elementRef, '#modal' + this.modalTypeOpened + ' button:first-of-type'); } this.modalTypeOpened = undefined; } diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index e4d85206e..9753d60b9 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { ElementRef, Injectable } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; import { Owner } from '../models/owner.model'; import { Structure } from '../models/structure.model'; @@ -153,6 +153,14 @@ export class Utils { return Boolean(structureForm.value.categories?.onlineProcedures.find((el) => el === 'autres')); } + public setFocusOnElement(elementRef: ElementRef<any>, cssSelector: string): void { + const filterButton = elementRef.nativeElement.querySelector(cssSelector); + if (filterButton) { + const focusedElement = filterButton as HTMLElement; + focusedElement.focus(); + } + } + /* * Sanitize value to null if empty */ -- GitLab