[ACS-10263] sr personal files mark all as read button incorrectly grouped as notification list (#11331)

* [ACS-10263] Exclude mark all as read button from menu items

* [ACS-10263] Keyboard navigation for notifications list

* [ACS-10263] Excluded load more button from screen reader list for notifications

* [ACS-10263] Excluded non list related elements from screen reading as list for columns visibility

* [ACS-10263] Unit tests

* [ACS-10263] Added readonly and private for constructor parameters

* [ACS-10263] Marked fields as readonly
This commit is contained in:
AleksanderSklorz
2025-11-07 11:26:41 +01:00
committed by GitHub
parent 43622266ce
commit 61e1395bfa
10 changed files with 382 additions and 167 deletions
@@ -13,7 +13,6 @@
<button <button
data-automation-id="adf-columns-selector-close-button" data-automation-id="adf-columns-selector-close-button"
mat-icon-button mat-icon-button
role="menuitem"
[attr.aria-label]="'CLOSE' | translate" [attr.aria-label]="'CLOSE' | translate"
(click)="closeMenu()" (click)="closeMenu()"
> >
@@ -34,21 +33,21 @@
class="adf-columns-selector-search-input" class="adf-columns-selector-search-input"
data-automation-id="adf-columns-selector-search-input" data-automation-id="adf-columns-selector-search-input"
type="text" type="text"
role="menuitem"
[placeholder]='"ADF-DATATABLE.COLUMNS_SELECTOR.SEARCH" | translate'> [placeholder]='"ADF-DATATABLE.COLUMNS_SELECTOR.SEARCH" | translate'>
</div> </div>
<div class="adf-columns-selector-list-container"> <mat-selection-list
class="adf-columns-selector-list-container"
(selectionChange)="changeColumnVisibility($event)"
[attr.aria-label]='"ADF-DATATABLE.COLUMNS_SELECTOR.COLUMNS_VISIBILITY" | translate'>
<div *ngFor="let column of (columnItems | columnsSearchFilter: searchQuery)" class="adf-columns-selector-list-item"> <div *ngFor="let column of (columnItems | columnsSearchFilter: searchQuery)" class="adf-columns-selector-list-item">
<mat-checkbox <mat-list-option
color="primary" color="primary"
class="adf-columns-selector-column-checkbox" class="adf-columns-selector-column-checkbox"
[attr.data-automation-id]="'adf-columns-selector-column-checkbox-' + column.title" [attr.data-automation-id]="'adf-columns-selector-column-checkbox-' + column.title"
[checked]="!column.isHidden" [selected]="!column.isHidden"
role="menuitem"
[disabled]="isCheckboxDisabled(column)" [disabled]="isCheckboxDisabled(column)"
(keydown.enter)="changeColumnVisibility(column)" [value]="column.id">
(change)="changeColumnVisibility(column)">
<div class="adf-columns-selector-list-content"> <div class="adf-columns-selector-list-content">
{{column.title | translate}} {{column.title | translate}}
</div> </div>
@@ -63,16 +62,15 @@
{{column.subtitle | translate}} {{column.subtitle | translate}}
</span> </span>
</div> </div>
</mat-checkbox> </mat-list-option>
</div>
</div> </div>
</mat-selection-list>
<mat-divider class="adf-columns-selector-divider" /> <mat-divider class="adf-columns-selector-divider" />
<div class="adf-columns-selector-footer"> <div class="adf-columns-selector-footer">
<button <button
mat-flat-button mat-flat-button
role="menuitem"
data-automation-id="adf-columns-selector-apply-button" data-automation-id="adf-columns-selector-apply-button"
color="primary" color="primary"
(click)="apply()"> (click)="apply()">
@@ -1,3 +1,5 @@
@use '../../../styles/mat-selectors' as ms;
$adf-columns-selector-space: 12px; $adf-columns-selector-space: 12px;
@mixin adf-columns-selector-side-padding { @mixin adf-columns-selector-side-padding {
@@ -8,7 +10,8 @@ $adf-columns-selector-space: 12px;
padding: $adf-columns-selector-space 0; padding: $adf-columns-selector-space 0;
} }
.adf-columns-selector { adf-datatable-column-selector {
.adf-columns-selector {
@include adf-columns-selector-top-bottom-padding; @include adf-columns-selector-top-bottom-padding;
min-width: 277px; min-width: 277px;
@@ -28,13 +31,21 @@ $adf-columns-selector-space: 12px;
&-list-container { &-list-container {
max-height: 350px; max-height: 350px;
overflow: hidden auto; overflow: hidden auto;
margin-left: -16px;
} }
&-list-item { .adf-columns-selector-list-item {
margin-top: 10px; margin-top: 10px;
&:hover { #{ms.$mat-list-item} {
background-color: var(--adf-theme-background-hover-color); display: flex;
flex-direction: row-reverse;
padding-left: 0;
height: 24px;
#{ms.$mat-list-item-end} {
margin-right: -5px;
}
} }
} }
@@ -44,6 +55,7 @@ $adf-columns-selector-space: 12px;
text-overflow: ellipsis; text-overflow: ellipsis;
display: block; display: block;
width: 210px; width: 210px;
color: var(--theme-secondary-text);
} }
&-subtitle { &-subtitle {
@@ -83,7 +95,7 @@ $adf-columns-selector-space: 12px;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 15px; margin-bottom: 9px;
} }
&-search-input { &-search-input {
@@ -105,4 +117,5 @@ $adf-columns-selector-space: 12px;
top: 10px; top: 10px;
font-size: var(--theme-adf-icon-1-font-size); font-size: var(--theme-adf-icon-1-font-size);
} }
}
} }
@@ -114,14 +114,14 @@ describe('ColumnsSelectorComponent', () => {
menuOpenedTrigger.next(); menuOpenedTrigger.next();
fixture.detectChanges(); fixture.detectChanges();
const checkboxes = await testingUtils.getAllMatCheckboxes(); const options = await testingUtils.getAllMatListOptions();
const inputColumnsWithTitle = inputColumns.filter((column) => !!column.title); const inputColumnsWithTitle = inputColumns.filter((column) => !!column.title);
expect(checkboxes.length).toBe(inputColumnsWithTitle.length); expect(options.length).toBe(inputColumnsWithTitle.length);
for await (const checkbox of checkboxes) { for (const option of options) {
const checkboxLabel = await checkbox.getLabelText(); const optionLabel = await option.getFullText();
const inputColumn = inputColumnsWithTitle.find((inputColumnWithTitle) => inputColumnWithTitle.title === checkboxLabel); const inputColumn = inputColumnsWithTitle.find((inputColumnWithTitle) => inputColumnWithTitle.title === optionLabel);
expect(inputColumn).toBeTruthy('Should have all columns with title'); expect(inputColumn).toBeTruthy('Should have all columns with title');
} }
}); });
@@ -135,37 +135,37 @@ describe('ColumnsSelectorComponent', () => {
tick(400); tick(400);
fixture.detectChanges(); fixture.detectChanges();
const columnCheckboxes = await testingUtils.getAllMatCheckboxes(); const columnOptions = await testingUtils.getAllMatListOptions();
expect(columnCheckboxes.length).toBe(1); expect(columnOptions.length).toBe(1);
expect(await columnCheckboxes[0].getLabelText()).toBe(inputColumns[0].title); expect(await columnOptions[0].getFullText()).toBe(inputColumns[0].title);
})); }));
it('should change column visibility', async () => { it('should change column visibility', async () => {
menuOpenedTrigger.next(); menuOpenedTrigger.next();
fixture.detectChanges(); fixture.detectChanges();
const firstColumnCheckbox = await testingUtils.getMatCheckbox(); const firstColumnOption = await testingUtils.getMatListOption();
const checkBoxName = await firstColumnCheckbox.getLabelText(); const optionName = await firstColumnOption.getFullText();
const toggledColumnItem = component.columnItems.find((item) => item.title === checkBoxName); const toggledColumnItem = component.columnItems.find((item) => item.title === optionName);
expect(toggledColumnItem?.isHidden).toBe(undefined); expect(toggledColumnItem?.isHidden).toBeUndefined();
await firstColumnCheckbox.toggle(); await firstColumnOption.toggle();
expect(toggledColumnItem?.isHidden).toBeTrue(); expect(toggledColumnItem?.isHidden).toBeTrue();
}); });
describe('checkboxes', () => { describe('list options', () => {
it('should have set proper default state', async () => { it('should have set proper default state', async () => {
menuOpenedTrigger.next(); menuOpenedTrigger.next();
fixture.detectChanges(); fixture.detectChanges();
const checkboxes = await testingUtils.getAllMatCheckboxes(); const options = await testingUtils.getAllMatListOptions();
expect(await checkboxes[0].isChecked()).toBe(true); expect(await options[0].isSelected()).toBeTrue();
expect(await checkboxes[1].isChecked()).toBe(true); expect(await options[1].isSelected()).toBeTrue();
expect(await checkboxes[2].isChecked()).toBe(true); expect(await options[2].isSelected()).toBeTrue();
expect(await checkboxes[3].isChecked()).toBe(false); expect(await options[3].isSelected()).toBeFalse();
}); });
it('should be disabled when visible columns limit is reached', async () => { it('should be disabled when visible columns limit is reached', async () => {
@@ -173,12 +173,12 @@ describe('ColumnsSelectorComponent', () => {
menuOpenedTrigger.next(); menuOpenedTrigger.next();
fixture.detectChanges(); fixture.detectChanges();
const checkboxes = await testingUtils.getAllMatCheckboxes(); const options = await testingUtils.getAllMatListOptions();
expect(await checkboxes[0].isDisabled()).toBe(false); expect(await options[0].isDisabled()).toBeFalse();
expect(await checkboxes[1].isDisabled()).toBe(false); expect(await options[1].isDisabled()).toBeFalse();
expect(await checkboxes[2].isDisabled()).toBe(false); expect(await options[2].isDisabled()).toBeFalse();
expect(await checkboxes[3].isDisabled()).toBe(true); expect(await options[3].isDisabled()).toBeTrue();
}); });
}); });
@@ -202,12 +202,9 @@ describe('ColumnsSelectorComponent', () => {
menuOpenedTrigger.next(); menuOpenedTrigger.next();
fixture.detectChanges(); fixture.detectChanges();
const checkboxes = await testingUtils.getAllMatCheckboxes(); const options = await testingUtils.getAllMatListOptions();
const labeTextOne = await checkboxes[0].getLabelText(); expect(await options[0].getFullText()).toBe(shownDataColumn.title);
const labeTextTwo = await checkboxes[1].getLabelText(); expect(await options[1].getFullText()).toBe(hiddenDataColumn.title);
expect(labeTextOne).toBe(shownDataColumn.title!);
expect(labeTextTwo).toBe(hiddenDataColumn.title!);
}); });
it('should NOT show hidden columns at the end of the list if sorting is disabled', async () => { it('should NOT show hidden columns at the end of the list if sorting is disabled', async () => {
@@ -216,12 +213,9 @@ describe('ColumnsSelectorComponent', () => {
menuOpenedTrigger.next(); menuOpenedTrigger.next();
fixture.detectChanges(); fixture.detectChanges();
const checkboxes = await testingUtils.getAllMatCheckboxes(); const options = await testingUtils.getAllMatListOptions();
const labeTextOne = await checkboxes[0].getLabelText(); expect(await options[0].getFullText()).toBe(hiddenDataColumn.title);
const labeTextTwo = await checkboxes[1].getLabelText(); expect(await options[1].getFullText()).toBe(shownDataColumn.title);
expect(labeTextOne).toBe(hiddenDataColumn.title!);
expect(labeTextTwo).toBe(shownDataColumn.title!);
}); });
it('should show subtitle', async () => { it('should show subtitle', async () => {
@@ -238,10 +232,7 @@ describe('ColumnsSelectorComponent', () => {
component.columnsSorting = false; component.columnsSorting = false;
menuOpenedTrigger.next(); menuOpenedTrigger.next();
fixture.detectChanges(); fixture.detectChanges();
expect(await (await testingUtils.getMatListOption()).getFullText()).toBe(`${column.title} ${column.subtitle}`);
const checkboxes = await testingUtils.getAllMatCheckboxes();
const labeTextOne = await checkboxes[0].getLabelText();
expect(labeTextOne).toBe(`${column.title} ${column.subtitle}`);
}); });
}); });
}); });
@@ -28,6 +28,7 @@ import { MatDividerModule } from '@angular/material/divider';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ColumnsSearchFilterPipe } from './columns-search-filter.pipe'; import { ColumnsSearchFilterPipe } from './columns-search-filter.pipe';
import { MatListOption, MatSelectionList, MatSelectionListChange } from '@angular/material/list';
@Component({ @Component({
selector: 'adf-datatable-column-selector', selector: 'adf-datatable-column-selector',
@@ -39,7 +40,9 @@ import { ColumnsSearchFilterPipe } from './columns-search-filter.pipe';
MatDividerModule, MatDividerModule,
ReactiveFormsModule, ReactiveFormsModule,
MatCheckboxModule, MatCheckboxModule,
ColumnsSearchFilterPipe ColumnsSearchFilterPipe,
MatSelectionList,
MatListOption
], ],
templateUrl: './columns-selector.component.html', templateUrl: './columns-selector.component.html',
styleUrls: ['./columns-selector.component.scss'], styleUrls: ['./columns-selector.component.scss'],
@@ -85,8 +88,8 @@ export class ColumnsSelectorComponent implements OnInit {
this.mainMenuTrigger.closeMenu(); this.mainMenuTrigger.closeMenu();
} }
changeColumnVisibility(dataColumn: DataColumn): void { changeColumnVisibility(event: MatSelectionListChange): void {
const selectedColumn = this.columnItems.find((column) => column.id === dataColumn.id); const selectedColumn = this.columnItems.find((column) => column.id === event.options[0].value);
selectedColumn.isHidden = !selectedColumn.isHidden; selectedColumn.isHidden = !selectedColumn.isHidden;
} }
+2 -1
View File
@@ -426,7 +426,8 @@
"COLUMNS_SELECTOR": { "COLUMNS_SELECTOR": {
"COLUMNS": "Columns", "COLUMNS": "Columns",
"SEARCH": "Search", "SEARCH": "Search",
"APPLY": "Apply" "APPLY": "Apply",
"COLUMNS_VISIBILITY": "Select columns to display"
} }
}, },
"USER_PROFILE": { "USER_PROFILE": {
@@ -19,14 +19,16 @@
[yPosition]="menuPositionY" [yPosition]="menuPositionY"
id="adf-notification-history-menu" id="adf-notification-history-menu"
class="adf-notification-history-menu adf-notification-history-menu-panel"> class="adf-notification-history-menu adf-notification-history-menu-panel">
<div class="adf-notification-history-list-header"> <div class="adf-notification-history-list-header" aria-hidden="true">
<span class="adf-notification-history-menu-title">{{ 'NOTIFICATIONS.TITLE' | translate }}</span> <span class="adf-notification-history-menu-title">{{ 'NOTIFICATIONS.TITLE' | translate }}</span>
<button mat-menu-item <button mat-icon-button
*ngIf="notifications.length" *ngIf="notifications.length"
id="adf-notification-history-mark-as-read" data-automation-id="adf-notification-history-mark-as-read"
class="adf-notification-history-mark-as-read" class="adf-notification-history-mark-as-read"
title="{{ 'NOTIFICATIONS.MARK_AS_READ' | translate }}" title="{{ 'NOTIFICATIONS.MARK_AS_READ' | translate }}"
(click)="markAsRead()"> (click)="markAsRead()"
#markAsReadButton
(keydown)="manageFocus($event)">
<mat-icon class="adf-notification-history-mark-as-read-icon">done_all</mat-icon> <mat-icon class="adf-notification-history-mark-as-read-icon">done_all</mat-icon>
</button> </button>
</div> </div>
@@ -38,7 +40,8 @@
<button mat-menu-item <button mat-menu-item
*ngFor="let notification of paginatedNotifications" *ngFor="let notification of paginatedNotifications"
(click)="onNotificationClick(notification, $event)" (click)="onNotificationClick(notification, $event)"
class="adf-notification-history-menu-item"> class="adf-notification-history-menu-item"
(keydown)="manageFocus($event)">
<div class="adf-notification-history-menu-item-content"> <div class="adf-notification-history-menu-item-content">
<div *ngIf="notification.initiator; else no_avatar" <div *ngIf="notification.initiator; else no_avatar"
[outerHTML]="notification.initiator | usernameInitials : 'adf-notification-initiator-pic'"></div> [outerHTML]="notification.initiator | usernameInitials : 'adf-notification-initiator-pic'"></div>
@@ -67,8 +70,16 @@
<mat-divider/> <mat-divider/>
<div class="adf-notification-history-load-more" *ngIf="hasMoreNotifications()"> <div
<button mat-menu-item (click)="loadMore($event)"> class="adf-notification-history-load-more"
*ngIf="hasMoreNotifications()"
aria-hidden="true">
<button
data-automation-id="adf-notification-history-load-more"
mat-button
(click)="loadMore($event)"
#loadMoreButton
(keydown)="manageFocus($event)">
{{ 'NOTIFICATIONS.LOAD_MORE' | translate }} {{ 'NOTIFICATIONS.LOAD_MORE' | translate }}
</button> </button>
</div> </div>
@@ -15,7 +15,7 @@
* limitations under the License. * 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 { NotificationHistoryComponent } from './notification-history.component';
import { OverlayContainer } from '@angular/cdk/overlay'; import { OverlayContainer } from '@angular/cdk/overlay';
import { NotificationService } from '../services/notification.service'; 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 { UnitTestingUtils } from '../../testing/unit-testing-utils';
import { provideCoreAuthTesting } from '../../testing/noop-auth.module'; import { provideCoreAuthTesting } from '../../testing/noop-auth.module';
import { MatIconTestingModule } from '@angular/material/icon/testing'; import { MatIconTestingModule } from '@angular/material/icon/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
describe('Notification History Component', () => { describe('Notification History Component', () => {
let fixture: ComponentFixture<NotificationHistoryComponent>; let fixture: ComponentFixture<NotificationHistoryComponent>;
@@ -46,7 +47,7 @@ describe('Notification History Component', () => {
}); });
fixture = TestBed.createComponent(NotificationHistoryComponent); fixture = TestBed.createComponent(NotificationHistoryComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement); testingUtils = new UnitTestingUtils(fixture.debugElement, TestbedHarnessEnvironment.loader(fixture));
storage = TestBed.inject(StorageService); storage = TestBed.inject(StorageService);
notificationService = TestBed.inject(NotificationService); notificationService = TestBed.inject(NotificationService);
@@ -63,6 +64,15 @@ describe('Notification History Component', () => {
}); });
describe('ui ', () => { describe('ui ', () => {
const getMarkAllAsReadButton = (): HTMLButtonElement =>
overlayContainerElement.querySelector<HTMLButtonElement>('[data-automation-id="adf-notification-history-mark-as-read"]');
const getLoadMoreButton = (): HTMLButtonElement =>
overlayContainerElement.querySelector<HTMLButtonElement>('[data-automation-id="adf-notification-history-load-more"]');
const getNotificationElements = (): NodeListOf<HTMLButtonElement> =>
overlayContainerElement.querySelectorAll<HTMLButtonElement>('.adf-notification-history-menu-item');
it('should empty message be present when there are no notifications in the history', (done) => { it('should empty message be present when there are no notifications in the history', (done) => {
openNotification(); openNotification();
fixture.detectChanges(); fixture.detectChanges();
@@ -80,8 +90,7 @@ describe('Notification History Component', () => {
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
fixture.detectChanges(); fixture.detectChanges();
expect(component.notifications.length).toBe(1); expect(component.notifications.length).toBe(1);
const markAllAsRead = overlayContainerElement.querySelector<HTMLButtonElement>('#adf-notification-history-mark-as-read'); getMarkAllAsReadButton().click();
markAllAsRead.click();
fixture.detectChanges(); fixture.detectChanges();
expect(storage.getItem(NotificationHistoryComponent.NOTIFICATION_STORAGE)).toBeNull(); expect(storage.getItem(NotificationHistoryComponent.NOTIFICATION_STORAGE)).toBeNull();
expect(component.notifications.length).toBe(0); expect(component.notifications.length).toBe(0);
@@ -115,8 +124,7 @@ describe('Notification History Component', () => {
fixture.detectChanges(); fixture.detectChanges();
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
fixture.detectChanges(); fixture.detectChanges();
const notification = overlayContainerElement.querySelector<HTMLButtonElement>('.adf-notification-history-menu-item'); getNotificationElements()[0].click();
notification.click();
expect(callBackSpy).toHaveBeenCalled(); expect(callBackSpy).toHaveBeenCalled();
done(); done();
}); });
@@ -133,9 +141,8 @@ describe('Notification History Component', () => {
openNotification(); openNotification();
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
fixture.detectChanges(); fixture.detectChanges();
const loadMoreButton = overlayContainerElement.querySelector<HTMLButtonElement>('.adf-notification-history-load-more');
expect(component.paginatedNotifications.length).toBe(5); expect(component.paginatedNotifications.length).toBe(5);
expect(loadMoreButton).toBeDefined(); expect(getLoadMoreButton()).toBeDefined();
done(); done();
}); });
}); });
@@ -155,8 +162,7 @@ describe('Notification History Component', () => {
openNotification(); openNotification();
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
fixture.detectChanges(); fixture.detectChanges();
const notification = overlayContainerElement.querySelector<HTMLButtonElement>('.adf-notification-history-menu-item'); expect(getNotificationElements()[0]).toBeDefined();
expect(notification).toBeDefined();
done(); done();
}); });
}, 10000); }, 10000);
@@ -173,10 +179,140 @@ describe('Notification History Component', () => {
openNotification(); openNotification();
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
fixture.detectChanges(); fixture.detectChanges();
const notifications = overlayContainerElement.querySelectorAll('.adf-notification-history-menu-item'); expect(getNotificationElements().length).toBe(6);
expect(notifications.length).toBe(6);
done(); done();
}); });
}, 45000); }, 45000);
describe('focus change', () => {
let markAllAsReadButton: HTMLButtonElement;
let loadMoreButton: HTMLButtonElement;
let notifications: NodeListOf<HTMLButtonElement>;
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]);
}));
});
}); });
}); });
@@ -15,13 +15,25 @@
* limitations under the License. * 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 { NotificationService } from '../services/notification.service';
import { NOTIFICATION_TYPE, NotificationModel } from '../models/notification.model'; 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 { StorageService } from '../../common/services/storage.service';
import { PaginationModel } from '../../models/pagination.model'; 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 { TranslatePipe } from '@ngx-translate/core';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatBadgeModule } from '@angular/material/badge'; import { MatBadgeModule } from '@angular/material/badge';
@@ -30,6 +42,7 @@ import { NgForOf, NgIf } from '@angular/common';
import { InitialUsernamePipe, TimeAgoPipe } from '../../pipes'; import { InitialUsernamePipe, TimeAgoPipe } from '../../pipes';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FocusKeyManager } from '@angular/cdk/a11y';
@Component({ @Component({
selector: 'adf-notification-history', selector: 'adf-notification-history',
@@ -73,9 +86,24 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit {
paginatedNotifications: NotificationModel[] = []; paginatedNotifications: NotificationModel[] = [];
pagination: PaginationModel; pagination: PaginationModel;
@ViewChild('markAsReadButton')
private readonly markAsReadButton: MatIconButton;
@ViewChild('loadMoreButton')
private readonly loadMoreButton: MatButton;
@ViewChildren(MatMenuItem)
private readonly menuItems: QueryList<MatMenuItem>;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
constructor(private notificationService: NotificationService, public storageService: StorageService, public cd: ChangeDetectorRef) {} private focusKeyManager: FocusKeyManager<MatIconButton | MatMenuItem | MatButton>;
constructor(
private readonly notificationService: NotificationService,
private readonly storageService: StorageService,
private readonly cd: ChangeDetectorRef
) {}
ngOnInit() { ngOnInit() {
this.notifications = JSON.parse(this.storageService.getItem(NotificationHistoryComponent.NOTIFICATION_STORAGE)) || []; this.notifications = JSON.parse(this.storageService.getItem(NotificationHistoryComponent.NOTIFICATION_STORAGE)) || [];
@@ -108,6 +136,7 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit {
onMenuOpened() { onMenuOpened() {
this.createPagination(); this.createPagination();
setTimeout(() => this.initializeFocusManager(0));
} }
markAsRead() { markAsRead() {
@@ -132,6 +161,7 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit {
this.pagination.skipCount = this.pagination.maxItems + this.pagination.skipCount; this.pagination.skipCount = this.pagination.maxItems + this.pagination.skipCount;
this.pagination.hasMoreItems = this.notifications.length > this.pagination.skipCount; this.pagination.hasMoreItems = this.notifications.length > this.pagination.skipCount;
this.paginatedNotifications = this.notifications.slice(0, this.pagination.skipCount); this.paginatedNotifications = this.notifications.slice(0, this.pagination.skipCount);
setTimeout(() => this.initializeFocusManager(this.focusKeyManager.activeItemIndex));
} }
hasMoreNotifications(): boolean { hasMoreNotifications(): boolean {
@@ -145,4 +175,23 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit {
this.trigger.closeMenu(); 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);
}
} }
@@ -56,6 +56,8 @@ $mat-calendar-table-header: '.mat-calendar-table-header';
$mat-calendar-body-disabled: '.mat-calendar-body-disabled'; $mat-calendar-body-disabled: '.mat-calendar-body-disabled';
$mat-toolbar: '.mat-toolbar'; $mat-toolbar: '.mat-toolbar';
$mat-list-item-unscoped-content: '.mat-mdc-list-item-unscoped-content'; $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-text-field-no-label: '.mdc-text-field--no-label';
$mat-form-field-infix: '.mat-mdc-form-field-infix'; $mat-form-field-infix: '.mat-mdc-form-field-infix';
$mat-form-field-error-wrapper: '.mat-mdc-form-field-error-wrapper'; $mat-form-field-error-wrapper: '.mat-mdc-form-field-error-wrapper';
@@ -32,6 +32,7 @@ import { MatTabGroupHarness, MatTabHarness } from '@angular/material/tabs/testin
import { MatToolbarHarness } from '@angular/material/toolbar/testing'; import { MatToolbarHarness } from '@angular/material/toolbar/testing';
import { MatSnackBarHarness } from '@angular/material/snack-bar/testing'; import { MatSnackBarHarness } from '@angular/material/snack-bar/testing';
import { MatProgressBarHarness } from '@angular/material/progress-bar/testing'; import { MatProgressBarHarness } from '@angular/material/progress-bar/testing';
import { MatListOptionHarness } from '@angular/material/list/testing';
export class UnitTestingUtils { export class UnitTestingUtils {
constructor( constructor(
@@ -434,4 +435,14 @@ export class UnitTestingUtils {
const progress = await this.loader.getHarness(MatProgressBarHarness); const progress = await this.loader.getHarness(MatProgressBarHarness);
return progress.host(); return progress.host();
} }
/** MatListOption related methods */
async getMatListOption(): Promise<MatListOptionHarness> {
return this.loader.getHarness(MatListOptionHarness);
}
async getAllMatListOptions(): Promise<MatListOptionHarness[]> {
return this.loader.getAllHarnesses(MatListOptionHarness);
}
} }