diff --git a/lib/core/src/lib/datatable/components/columns-selector/columns-selector.component.html b/lib/core/src/lib/datatable/components/columns-selector/columns-selector.component.html index f05021f7c5..999cd51f80 100644 --- a/lib/core/src/lib/datatable/components/columns-selector/columns-selector.component.html +++ b/lib/core/src/lib/datatable/components/columns-selector/columns-selector.component.html @@ -13,7 +13,6 @@ @@ -38,7 +40,8 @@ diff --git a/lib/core/src/lib/notifications/components/notification-history.component.spec.ts b/lib/core/src/lib/notifications/components/notification-history.component.spec.ts index 0f7b18d501..d036c68bb8 100644 --- a/lib/core/src/lib/notifications/components/notification-history.component.spec.ts +++ b/lib/core/src/lib/notifications/components/notification-history.component.spec.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; import { NotificationHistoryComponent } from './notification-history.component'; import { OverlayContainer } from '@angular/cdk/overlay'; import { NotificationService } from '../services/notification.service'; @@ -24,6 +24,7 @@ import { NOTIFICATION_TYPE, NotificationModel } from '../models/notification.mod import { UnitTestingUtils } from '../../testing/unit-testing-utils'; import { provideCoreAuthTesting } from '../../testing/noop-auth.module'; import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; describe('Notification History Component', () => { let fixture: ComponentFixture; @@ -46,7 +47,7 @@ describe('Notification History Component', () => { }); fixture = TestBed.createComponent(NotificationHistoryComponent); component = fixture.componentInstance; - testingUtils = new UnitTestingUtils(fixture.debugElement); + testingUtils = new UnitTestingUtils(fixture.debugElement, TestbedHarnessEnvironment.loader(fixture)); storage = TestBed.inject(StorageService); notificationService = TestBed.inject(NotificationService); @@ -63,6 +64,15 @@ describe('Notification History Component', () => { }); describe('ui ', () => { + const getMarkAllAsReadButton = (): HTMLButtonElement => + overlayContainerElement.querySelector('[data-automation-id="adf-notification-history-mark-as-read"]'); + + const getLoadMoreButton = (): HTMLButtonElement => + overlayContainerElement.querySelector('[data-automation-id="adf-notification-history-load-more"]'); + + const getNotificationElements = (): NodeListOf => + overlayContainerElement.querySelectorAll('.adf-notification-history-menu-item'); + it('should empty message be present when there are no notifications in the history', (done) => { openNotification(); fixture.detectChanges(); @@ -80,8 +90,7 @@ describe('Notification History Component', () => { fixture.whenStable().then(() => { fixture.detectChanges(); expect(component.notifications.length).toBe(1); - const markAllAsRead = overlayContainerElement.querySelector('#adf-notification-history-mark-as-read'); - markAllAsRead.click(); + getMarkAllAsReadButton().click(); fixture.detectChanges(); expect(storage.getItem(NotificationHistoryComponent.NOTIFICATION_STORAGE)).toBeNull(); expect(component.notifications.length).toBe(0); @@ -115,8 +124,7 @@ describe('Notification History Component', () => { fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); - const notification = overlayContainerElement.querySelector('.adf-notification-history-menu-item'); - notification.click(); + getNotificationElements()[0].click(); expect(callBackSpy).toHaveBeenCalled(); done(); }); @@ -133,9 +141,8 @@ describe('Notification History Component', () => { openNotification(); fixture.whenStable().then(() => { fixture.detectChanges(); - const loadMoreButton = overlayContainerElement.querySelector('.adf-notification-history-load-more'); expect(component.paginatedNotifications.length).toBe(5); - expect(loadMoreButton).toBeDefined(); + expect(getLoadMoreButton()).toBeDefined(); done(); }); }); @@ -155,8 +162,7 @@ describe('Notification History Component', () => { openNotification(); fixture.whenStable().then(() => { fixture.detectChanges(); - const notification = overlayContainerElement.querySelector('.adf-notification-history-menu-item'); - expect(notification).toBeDefined(); + expect(getNotificationElements()[0]).toBeDefined(); done(); }); }, 10000); @@ -173,10 +179,140 @@ describe('Notification History Component', () => { openNotification(); fixture.whenStable().then(() => { fixture.detectChanges(); - const notifications = overlayContainerElement.querySelectorAll('.adf-notification-history-menu-item'); - expect(notifications.length).toBe(6); + expect(getNotificationElements().length).toBe(6); done(); }); }, 45000); + + describe('focus change', () => { + let markAllAsReadButton: HTMLButtonElement; + let loadMoreButton: HTMLButtonElement; + let notifications: NodeListOf; + + beforeEach(fakeAsync(() => { + spyOn(storage, 'getItem').and.returnValue( + JSON.stringify([ + { + messages: ['My new message 1'] + }, + { + messages: ['My new message 2'] + }, + { + messages: ['My new message 3'] + }, + { + messages: ['My new message 4'] + }, + { + messages: ['My new message 5'] + }, + { + messages: ['My new message 6'] + } + ] as NotificationModel[]) + ); + openNotification(); + tick(); + markAllAsReadButton = getMarkAllAsReadButton(); + loadMoreButton = getLoadMoreButton(); + notifications = getNotificationElements(); + })); + + it('should focus mark all as read button when menu has been opened', () => { + expect(document.activeElement).toBe(markAllAsReadButton); + }); + + it('should focus load more button when pressing arrow up when mark all as read button is focused', () => { + markAllAsReadButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp' + }) + ); + expect(document.activeElement).toBe(loadMoreButton); + }); + + it('should focus first notification when pressing arrow down when mark all as read button is focused', () => { + markAllAsReadButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown' + }) + ); + expect(document.activeElement).toBe(notifications[0]); + }); + + it('should focus mark all as read button when pressing arrow up when first notification is focused', () => { + markAllAsReadButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown' + }) + ); + + notifications[0].dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp' + }) + ); + expect(document.activeElement).toBe(markAllAsReadButton); + }); + + it('should focus second notification when pressing arrow down when first notification is focused', () => { + markAllAsReadButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown' + }) + ); + + notifications[0].dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown' + }) + ); + expect(document.activeElement).toBe(notifications[1]); + }); + + it('should focus mark all as read button when pressing arrow down when load more button is focused', () => { + markAllAsReadButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp' + }) + ); + + loadMoreButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown' + }) + ); + expect(document.activeElement).toBe(markAllAsReadButton); + }); + + it('should last paged notification when pressing arrow up when load more button is focused', () => { + markAllAsReadButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp' + }) + ); + + loadMoreButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp' + }) + ); + expect(document.activeElement).toBe(notifications[4]); + }); + + it('should focus correct notification after loading more', fakeAsync(() => { + markAllAsReadButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp' + }) + ); + + loadMoreButton.click(); + fixture.detectChanges(); + tick(); + expect(document.activeElement).toBe(getNotificationElements()[5]); + })); + }); }); }); diff --git a/lib/core/src/lib/notifications/components/notification-history.component.ts b/lib/core/src/lib/notifications/components/notification-history.component.ts index 2f38d46c0d..68b7064e87 100644 --- a/lib/core/src/lib/notifications/components/notification-history.component.ts +++ b/lib/core/src/lib/notifications/components/notification-history.component.ts @@ -15,13 +15,25 @@ * limitations under the License. */ -import { AfterViewInit, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + DestroyRef, + inject, + Input, + OnInit, + QueryList, + ViewChild, + ViewChildren, + ViewEncapsulation +} from '@angular/core'; import { NotificationService } from '../services/notification.service'; import { NOTIFICATION_TYPE, NotificationModel } from '../models/notification.model'; -import { MatMenuModule, MatMenuTrigger, MenuPositionX, MenuPositionY } from '@angular/material/menu'; +import { MatMenuItem, MatMenuModule, MatMenuTrigger, MenuPositionX, MenuPositionY } from '@angular/material/menu'; import { StorageService } from '../../common/services/storage.service'; import { PaginationModel } from '../../models/pagination.model'; -import { MatButtonModule } from '@angular/material/button'; +import { MatButton, MatButtonModule, MatIconButton } from '@angular/material/button'; import { TranslatePipe } from '@ngx-translate/core'; import { MatIconModule } from '@angular/material/icon'; import { MatBadgeModule } from '@angular/material/badge'; @@ -30,6 +42,7 @@ import { NgForOf, NgIf } from '@angular/common'; import { InitialUsernamePipe, TimeAgoPipe } from '../../pipes'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FocusKeyManager } from '@angular/cdk/a11y'; @Component({ selector: 'adf-notification-history', @@ -73,9 +86,24 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit { paginatedNotifications: NotificationModel[] = []; pagination: PaginationModel; + @ViewChild('markAsReadButton') + private readonly markAsReadButton: MatIconButton; + + @ViewChild('loadMoreButton') + private readonly loadMoreButton: MatButton; + + @ViewChildren(MatMenuItem) + private readonly menuItems: QueryList; + private readonly destroyRef = inject(DestroyRef); - constructor(private notificationService: NotificationService, public storageService: StorageService, public cd: ChangeDetectorRef) {} + private focusKeyManager: FocusKeyManager; + + constructor( + private readonly notificationService: NotificationService, + private readonly storageService: StorageService, + private readonly cd: ChangeDetectorRef + ) {} ngOnInit() { this.notifications = JSON.parse(this.storageService.getItem(NotificationHistoryComponent.NOTIFICATION_STORAGE)) || []; @@ -108,6 +136,7 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit { onMenuOpened() { this.createPagination(); + setTimeout(() => this.initializeFocusManager(0)); } markAsRead() { @@ -132,6 +161,7 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit { this.pagination.skipCount = this.pagination.maxItems + this.pagination.skipCount; this.pagination.hasMoreItems = this.notifications.length > this.pagination.skipCount; this.paginatedNotifications = this.notifications.slice(0, this.pagination.skipCount); + setTimeout(() => this.initializeFocusManager(this.focusKeyManager.activeItemIndex)); } hasMoreNotifications(): boolean { @@ -145,4 +175,23 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit { this.trigger.closeMenu(); } } + + manageFocus(event: KeyboardEvent): void { + if (event.key === 'ArrowUp') { + this.focusKeyManager.setPreviousItemActive(); + event.stopPropagation(); + } else if (event.key === 'ArrowDown') { + event.stopPropagation(); + this.focusKeyManager.setNextItemActive(); + } + } + + private initializeFocusManager(activeIndex: number): void { + this.focusKeyManager = new FocusKeyManager([ + this.markAsReadButton, + ...this.menuItems, + ...(this.hasMoreNotifications() ? [this.loadMoreButton] : []) + ]).withWrap(); + this.focusKeyManager.setActiveItem(activeIndex); + } } diff --git a/lib/core/src/lib/styles/_mat-selectors.scss b/lib/core/src/lib/styles/_mat-selectors.scss index 88fc34bd9c..1eb1fa22d7 100644 --- a/lib/core/src/lib/styles/_mat-selectors.scss +++ b/lib/core/src/lib/styles/_mat-selectors.scss @@ -56,6 +56,8 @@ $mat-calendar-table-header: '.mat-calendar-table-header'; $mat-calendar-body-disabled: '.mat-calendar-body-disabled'; $mat-toolbar: '.mat-toolbar'; $mat-list-item-unscoped-content: '.mat-mdc-list-item-unscoped-content'; +$mat-list-item: '.mat-mdc-list-item'; +$mat-list-item-end: '.mdc-list-item__end'; $mat-text-field-no-label: '.mdc-text-field--no-label'; $mat-form-field-infix: '.mat-mdc-form-field-infix'; $mat-form-field-error-wrapper: '.mat-mdc-form-field-error-wrapper'; diff --git a/lib/core/src/lib/testing/unit-testing-utils.ts b/lib/core/src/lib/testing/unit-testing-utils.ts index 2b9c3d358a..e48628e53d 100644 --- a/lib/core/src/lib/testing/unit-testing-utils.ts +++ b/lib/core/src/lib/testing/unit-testing-utils.ts @@ -32,6 +32,7 @@ import { MatTabGroupHarness, MatTabHarness } from '@angular/material/tabs/testin import { MatToolbarHarness } from '@angular/material/toolbar/testing'; import { MatSnackBarHarness } from '@angular/material/snack-bar/testing'; import { MatProgressBarHarness } from '@angular/material/progress-bar/testing'; +import { MatListOptionHarness } from '@angular/material/list/testing'; export class UnitTestingUtils { constructor( @@ -434,4 +435,14 @@ export class UnitTestingUtils { const progress = await this.loader.getHarness(MatProgressBarHarness); return progress.host(); } + + /** MatListOption related methods */ + + async getMatListOption(): Promise { + return this.loader.getHarness(MatListOptionHarness); + } + + async getAllMatListOptions(): Promise { + return this.loader.getAllHarnesses(MatListOptionHarness); + } }