diff --git a/src/app/app.component.html b/src/app/app.component.html index 9adfa5413a0b0980f236410b9febbb3921f063f5..fb62f78b6969c04af428010b8ee4ca20801476f0 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,3 +1,6 @@ +<div class="visually-hidden"> + <p>Utilisez <strong>h</strong> (header) pour revenir au logo Res'in à tout moment.</p> +</div> <app-header /> <main class="app-container"> <div class="app-body" id="app-body" (scroll)="onScroll($event)"> diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 85005ac0a540734037022cc222d8580d52d8df6d..3d629e84e7d9cc523cc46de682d8b32a9af01667 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, HostListener, OnInit, ViewChild } from '@angular/core'; import { GuardsCheckStart, NavigationCancel, @@ -8,6 +8,7 @@ import { Router, } from '@angular/router'; import { MatomoInitializerService } from 'ngx-matomo-client'; +import { HeaderComponent } from './header/header.component'; import { ProfileService } from './profile/services/profile.service'; import { AuthService } from './services/auth.service'; import { ConfigService } from './services/config.service'; @@ -24,6 +25,36 @@ import { WindowScrollService } from './shared/service/windowScroll.service'; export class AppComponent implements OnInit { public loading = true; + @ViewChild(HeaderComponent) headerComponent!: HeaderComponent; + + // Listener, keyboard shortcuts + @HostListener('window:keydown', ['$event']) + handleKeyboardEvent(event: KeyboardEvent): void { + const target = event.target as HTMLElement; + const tagName = target.tagName.toLowerCase(); + + // Check if the focus is within an input, textarea + if (tagName === 'input') { + const inputElement = target as HTMLInputElement; + const inputType = inputElement.type.toLowerCase(); + // Ignore only text-based inputs, not others like radio, checkbox, etc. + if (!['radio', 'checkbox', 'button', 'submit', 'reset'].includes(inputType)) { + return; + } + } else if (tagName === 'textarea' || target.isContentEditable) { + return; + } + + switch (event.key) { + case 'h': // 'h' to go to the header (resin logo) + this.headerComponent.focusLogo(); + event.preventDefault(); + break; + default: + break; + } + } + constructor( public printService: PrintService, private authService: AuthService, diff --git a/src/app/form/orientation-form-view/global-components/navigation/navigation.component.html b/src/app/form/orientation-form-view/global-components/navigation/navigation.component.html index ee4229d053a39adba8fce91df29a7ffa55878298..a8121829ce7ec6bf15f0245dc2e83bf72f69a3fd 100644 --- a/src/app/form/orientation-form-view/global-components/navigation/navigation.component.html +++ b/src/app/form/orientation-form-view/global-components/navigation/navigation.component.html @@ -1,14 +1,16 @@ <div class="footerForm"> <ng-container *ngIf="!failedOrientation"> <app-button - *ngIf="currentStep !== null && !(isPrevHidden || isLastStep)" + *ngIf="showPrevButton()" + #prevButton [variant]="'secondary'" [label]="'Précédent'" [iconName]="'arrowBack'" (action)="prevPage()" /> <app-button - *ngIf="!hideNavButtons" + *ngIf="showNextButton()" + #nextButton [variant]="'primary'" [label]="isLastStep ? 'Imprimer' : 'Suivant'" [iconName]="isLastStep ? 'printer' : 'arrowForward'" diff --git a/src/app/form/orientation-form-view/global-components/navigation/navigation.component.ts b/src/app/form/orientation-form-view/global-components/navigation/navigation.component.ts index 2485d902406ed7f0f1291d4a51e8c109113b9db5..65650971e82b46fc14d76a7fe2f3e37b430c16db 100644 --- a/src/app/form/orientation-form-view/global-components/navigation/navigation.component.ts +++ b/src/app/form/orientation-form-view/global-components/navigation/navigation.component.ts @@ -1,5 +1,6 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { ButtonComponent } from '../../../../shared/components'; import { NeedsType, OnlineDemarche } from '../../enums/orientation.enums'; import { MediationStepType, MediationType } from '../../types/orientation.types'; @@ -22,11 +23,21 @@ export class NavigationComponent { @Output() goPrev = new EventEmitter<any>(); @Output() goReset = new EventEmitter<any>(); + @ViewChild('prevButton', { read: ButtonComponent }) prevButton: ButtonComponent; + @ViewChild('nextButton', { read: ButtonComponent }) nextButton: ButtonComponent; + public NeedsTypeEnum = NeedsType; constructor( private router: Router, private route: ActivatedRoute, ) {} + + public showPrevButton(): boolean { + return this.currentStep !== null && !(this.isPrevHidden || this.isLastStep); + } + public showNextButton(): boolean { + return !this.hideNavButtons; + } public nextPage(isPrint?: boolean): void { this.goNext.emit(isPrint); } @@ -41,4 +52,11 @@ export class NavigationComponent { public resetOrientation(): void { this.goReset.emit(); } + public focusFirstButton(): void { + if (this.showPrevButton()) { + this.prevButton.focus(); + } else if (this.showNextButton()) { + this.nextButton.focus(); + } + } } diff --git a/src/app/form/orientation-form-view/orientation-form-view.component.html b/src/app/form/orientation-form-view/orientation-form-view.component.html index 067376c77260fe410cf55a8a0e03a0b32abb722a..43a2110fd0c0d3f0870d83c5d4f2aba8e8b42811 100644 --- a/src/app/form/orientation-form-view/orientation-form-view.component.html +++ b/src/app/form/orientation-form-view/orientation-form-view.component.html @@ -1,5 +1,8 @@ <div class="orientation"> <h1 class="visually-hidden">Orientation</h1> + <div class="visually-hidden"> + <p>Utilisez <strong>f</strong> (footer) pour aller directement aux boutons de validation.</p> + </div> <app-progress-bar [currentPage]="currentStep" [nbSteps]="nbSteps" [formType]="formType.orientation" /> <div class="container" [ngClass]="{ 'no-max-width': fullScreen }"> <app-needs-selection diff --git a/src/app/form/orientation-form-view/orientation-form-view.component.ts b/src/app/form/orientation-form-view/orientation-form-view.component.ts index b7587902bde1b027fdea223c1e45faa281548912..8d091903b4bc19183050430cdc62ed972d179ae5 100644 --- a/src/app/form/orientation-form-view/orientation-form-view.component.ts +++ b/src/app/form/orientation-form-view/orientation-form-view.component.ts @@ -1,4 +1,4 @@ -import { AfterContentChecked, ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { AfterContentChecked, ChangeDetectorRef, Component, HostListener, OnInit, ViewChild } from '@angular/core'; import { AbstractControl, FormGroup, UntypedFormGroup } from '@angular/forms'; import { Router } from '@angular/router'; import { lastValueFrom } from 'rxjs'; @@ -28,6 +28,7 @@ import { OnlineDemarchesCommonSteps, StructuresListSteps, } from './enums/orientation.enums'; +import { NavigationComponent } from './global-components/navigation/navigation.component'; import { IAppointment } from './interfaces/appointment.interface'; import { FiltersForm } from './interfaces/filtersForm.interface'; import { IOnlineMediation } from './interfaces/onlineMediation.interface'; @@ -116,6 +117,36 @@ export class OrientationFormViewComponent implements OnInit, AfterContentChecked public showConfirmationModal = false; private resolve: CanExitResolver; + @ViewChild(NavigationComponent) navComponent!: NavigationComponent; + + // Listener, keyboard shortcuts + @HostListener('window:keydown', ['$event']) + handleKeyboardEvent(event: KeyboardEvent): void { + const target = event.target as HTMLElement; + const tagName = target.tagName.toLowerCase(); + + // Check if the focus is within an input, textarea + if (tagName === 'input') { + const inputElement = target as HTMLInputElement; + const inputType = inputElement.type.toLowerCase(); + // Ignore only text-based inputs, not others like radio, checkbox, etc. + if (!['radio', 'checkbox', 'button', 'submit', 'reset'].includes(inputType)) { + return; + } + } else if (tagName === 'textarea' || target.isContentEditable) { + return; + } + + switch (event.key) { + case 'f': // 'f' to go to the navigation footer + this.navComponent.focusFirstButton(); + event.preventDefault(); + break; + default: + break; + } + } + constructor( public orientationService: OrientationService, private notificationService: NotificationService, @@ -124,12 +155,13 @@ export class OrientationFormViewComponent implements OnInit, AfterContentChecked private profileService: ProfileService, private searchService: SearchService, private structureService: StructureService, - private cdref: ChangeDetectorRef, + private cdRef: ChangeDetectorRef, private indicatorService: IndicatorService, private router: Router, ) { this.setCategories(); } + async ngOnInit(): Promise<void> { this.orientationService.rdvUser = null; if (history.state.rdvUser) { @@ -167,7 +199,7 @@ export class OrientationFormViewComponent implements OnInit, AfterContentChecked } ngAfterContentChecked(): void { - this.cdref.detectChanges(); + this.cdRef.detectChanges(); } public validatePage(event: boolean): void { diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 9595402006d9d7d0858f58bab23637345a751152..2cf2d5b3aa3dcd9e9ce072ca87194369a662dc70 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -10,6 +10,7 @@ /> </div> <div + #clickableLogo class="logo clickable" aria-label="Retour accueil" role="button" diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index 324ca2e285f33cb6799601f64e6aac8fe1ca7912..eb66efb8a77f8cd2ab26f4870b8ec493b83e827d 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -1,5 +1,5 @@ import { animate, animateChild, query, style, transition, trigger } from '@angular/animations'; -import { Component } from '@angular/core'; +import { Component, ElementRef, ViewChild } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { Structure } from '../models/structure.model'; import { ProfileService } from '../profile/services/profile.service'; @@ -39,6 +39,8 @@ export class HeaderComponent { private displayDataShare = false; private loadingDataShare = false; + @ViewChild('clickableLogo') clickableLogoDiv: ElementRef; + constructor( private authService: AuthService, private profileService: ProfileService, @@ -85,6 +87,12 @@ export class HeaderComponent { } } + public focusLogo(): void { + if (this.clickableLogoDiv) { + this.clickableLogoDiv.nativeElement.focus(); + } + } + public get isLoggedIn(): boolean { return this.authService.isLoggedIn(); } diff --git a/src/app/shared/components/button/button.component.html b/src/app/shared/components/button/button.component.html index 98ac1d01715f2310a1fd56667d88f2e9d060d6c0..2dd9c619275fb621efe2aa82958a8dfa48e6d53e 100644 --- a/src/app/shared/components/button/button.component.html +++ b/src/app/shared/components/button/button.component.html @@ -1,4 +1,5 @@ <button + #buttonElement [attr.aria-label]="ariaLabel" [type]="type" [ngClass]="classes" diff --git a/src/app/shared/components/button/button.component.ts b/src/app/shared/components/button/button.component.ts index b9da4f71b29877ab8d8474a4e7f9c8c361f813eb..bd8e2220c0f4dd603dd5dc9e35c3f3c7693bc8d8 100644 --- a/src/app/shared/components/button/button.component.ts +++ b/src/app/shared/components/button/button.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { SpriteFolderType } from '../svg-icon/SpriteFolder.type'; /** values will be used for css selectors */ @@ -54,7 +54,13 @@ export class ButtonComponent { /** Click handler */ @Output() action = new EventEmitter<Event>(); + @ViewChild('buttonElement') buttonElement!: ElementRef; + public get classes(): string[] { return [this.variant, this.size, this.wide ? 'wide' : '']; } + + public focus(): void { + this.buttonElement.nativeElement.focus(); + } } diff --git a/src/app/structure-list/components/more-filters/more-filters.component.html b/src/app/structure-list/components/more-filters/more-filters.component.html index b1d890873a2b9ae770f57ca1e716d4aca551f4c3..40e860711c3fe49c06c319d5f1584656bb6b80e5 100644 --- a/src/app/structure-list/components/more-filters/more-filters.component.html +++ b/src/app/structure-list/components/more-filters/more-filters.component.html @@ -28,17 +28,16 @@ <div *ngIf="categories.length > 1" class="modalContent multipleCollapse"> <app-collapse *ngFor="let c of categories"> <app-collapse-header> - <div class="collapseHeader"> <app-checkbox [size]="'small'" [id]="c.id" [checked]="getCategoryCheckboxStatus(c) === 'checked'" [indeterminate]="getCategoryCheckboxStatus(c) === 'halfChecked'" + [ngStyle]="{ padding: '4px 4px' }" (action)="handleCategoryCheckBox($event, c); $event.stopPropagation()" - [ngStyle]="{'padding': '4px 4px'}" - ></app-checkbox> - <span [attr.aria-label]="" id="categoryName">{{ c.name }}</span> + /> + <span id="categoryName" [attr.aria-label]="">{{ c.name }}</span> <!-- label only for screen reader to avoid selecting all the elements when clicking the label --> <label class="visually-hidden" for="{{ c.id }}">{{ c.name }}. Cocher pour tout sélectionner</label> </div> @@ -64,7 +63,7 @@ [ariaLabel]="'Effacer et fermer'" (action)="clearFilters()" /> - <app-button [variant]="'primary'" [label]="'Appliquer'" (action)="emitModules(checkedModules)"/> + <app-button [variant]="'primary'" [label]="'Appliquer'" (action)="emitModules(checkedModules)" /> </div> </div> </div>