From f44c56db79688c60d807d757e73be4a9a4a97a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A1n=20Lagos?= Date: Mon, 29 Jun 2026 20:42:41 -0400 Subject: [PATCH] Improve selected state and keyboard navigation in DSO selector --- .../dso-selector/dso-selector.component.html | 10 +-- .../dso-selector.component.spec.ts | 62 +++++++++++++++++++ .../dso-selector/dso-selector.component.ts | 52 +++++++++++++++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html index cfe8da849ff..d89f489ec8c 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html @@ -22,15 +22,17 @@ {{'dso-selector.no-results' | translate: { type: typesString } }} } - @for (listEntry of (listEntries$ | async); track listEntry) { + @for (listEntry of (listEntries$ | async); track listEntry; let i = $index) { } } diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts index a2125859f07..88765d0da8a 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts @@ -1,6 +1,8 @@ import { DebugElement, + ElementRef, NO_ERRORS_SCHEMA, + QueryList, } from '@angular/core'; import { ComponentFixture, @@ -110,6 +112,66 @@ describe('DSOSelectorComponent', () => { expect(component).toBeTruthy(); }); + describe('getDsoId', () => { + it('should return the id of the indexable object', () => { + expect(component.getDsoId(searchResult)).toEqual(searchResult.indexableObject.id); + }); + }); + + describe('isCurrentDso', () => { + it('should return true when the list entry represents the current DSO', () => { + component.currentDSOId = searchResult.indexableObject.id; + + expect(component.isCurrentDso(searchResult)).toBeTrue(); + }); + + it('should return false when the list entry does not represent the current DSO', () => { + component.currentDSOId = 'another-id'; + + expect(component.isCurrentDso(searchResult)).toBeFalse(); + }); + }); + + describe('keyboard navigation', () => { + let firstElement: HTMLButtonElement; + let secondElement: HTMLButtonElement; + + beforeEach(() => { + firstElement = document.createElement('button'); + secondElement = document.createElement('button'); + + const listElements = new QueryList(); + listElements.reset([ + new ElementRef(firstElement), + new ElementRef(secondElement), + ]); + + component.listElements = listElements; + }); + + it('should move focus to the next list entry when pressing ArrowDown', () => { + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + spyOn(event, 'preventDefault'); + spyOn(secondElement, 'focus'); + + component.onListEntryKeydown(event, 0); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(secondElement.focus).toHaveBeenCalled(); + }); + + it('should move focus to the previous list entry when pressing ArrowUp', () => { + const event = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + spyOn(event, 'preventDefault'); + spyOn(firstElement, 'focus'); + + component.onListEntryKeydown(event, 1); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(firstElement.focus).toHaveBeenCalled(); + }); + }); + describe('populating listEntries', () => { it('should not be empty', (done) => { component.listEntries$.subscribe((listEntries) => { diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts index a3388f499b8..e7436ba4313 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts @@ -1,6 +1,6 @@ import { AsyncPipe, - NgClass, + NgClass } from '@angular/common'; import { Component, @@ -335,6 +335,56 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { } } + /** + * Get the DSO id from the given listable object + * @param listableObject The {@link ListableObject} to evaluate + */ + getDsoId(listableObject: ListableObject): string { + const searchResult = listableObject as SearchResult; + return hasValue(searchResult.indexableObject) ? searchResult.indexableObject.id : null; + } + + /** + * Check if the given list entry represents the currently selected DSO + * @param listableObject The {@link ListableObject} to evaluate + */ + isCurrentDso(listableObject: ListableObject): boolean { + return this.getDsoId(listableObject) === this.currentDSOId; + } + + /** + * Handles keyboard navigation between list entries + * @param event The keyboard event + * @param index The index of the current list entry + */ + onListEntryKeydown(event: KeyboardEvent, index: number): void { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.focusListEntry(index + 1); + break; + case 'ArrowUp': + event.preventDefault(); + this.focusListEntry(index - 1); + break; + } + } + + /** + * Set focus on the list entry at the given index + * @param index The index of the list entry to focus + */ + private focusListEntry(index: number): void { + if (this.listElements.length > 0) { + const boundedIndex = Math.max(0, Math.min(index, this.listElements.length - 1)); + const element = this.listElements.get(boundedIndex); + + if (hasValue(element)) { + element.nativeElement.focus(); + } + } + } + /** * Unsubscribe from all subscriptions */