[ACS-9266][ACS-9234] Make notification and user menu accessible with keyboard (#10647)

* [ACS-9266][ACS-9234] Make notification and user menu accessible with keyboard

* [ACS-9266] cr fix

* [ACS-9266] cr fixes

* [ACS-9266] fix eslint

* [ACS-9266] fix circular dependency

* [ACS-9266] cr fix
This commit is contained in:
Mykyta Maliarchuk 2025-02-17 10:52:27 +01:00 committed by GitHub
parent c8aa27a87b
commit e4feb68381
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 263 additions and 176 deletions

View File

@ -15,12 +15,12 @@
* limitations under the License.
*/
import { Component, EventEmitter, Output } from '@angular/core';
import { Component, EventEmitter, Output, QueryList, ViewChildren } from '@angular/core';
import { LanguageService } from './service/language.service';
import { Observable } from 'rxjs';
import { LanguageItem } from '../common/services/language-item.interface';
import { CommonModule } from '@angular/common';
import { MatMenuModule } from '@angular/material/menu';
import { MatMenuItem, MatMenuModule } from '@angular/material/menu';
@Component({
selector: 'adf-language-menu',
@ -37,6 +37,9 @@ export class LanguageMenuComponent {
@Output()
changedLanguage: EventEmitter<LanguageItem> = new EventEmitter<LanguageItem>();
@ViewChildren(MatMenuItem)
menuItems: QueryList<MatMenuItem>;
languages$: Observable<LanguageItem[]>;
constructor(private languageService: LanguageService) {

View File

@ -0,0 +1,59 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LanguagePickerComponent } from './language-picker.component';
import { MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { LanguageMenuComponent } from './language-menu.component';
import { QueryList } from '@angular/core';
import { CoreTestingModule, UnitTestingUtils } from '@alfresco/adf-core';
describe('LanguagePickerComponent', () => {
let component: LanguagePickerComponent;
let fixture: ComponentFixture<LanguagePickerComponent>;
let testingUtils: UnitTestingUtils;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CoreTestingModule, LanguagePickerComponent]
}).compileComponents();
fixture = TestBed.createComponent(LanguagePickerComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should assign menuItems to MatMenu in ngAfterViewInit', () => {
testingUtils.getByDirective(MatMenuTrigger).nativeElement.click();
fixture.detectChanges();
const languageMenuComponent = testingUtils.getByDirective(LanguageMenuComponent).componentInstance;
const menuItem1 = new MatMenuItem(null, null, null, null, null);
const menuItem2 = new MatMenuItem(null, null, null, null, null);
languageMenuComponent.menuItems = new QueryList<MatMenuItem>();
languageMenuComponent.menuItems.reset([menuItem1, menuItem2]);
spyOn(component.menu, 'ngAfterContentInit').and.callThrough();
component.ngAfterViewInit();
// eslint-disable-next-line no-underscore-dangle
expect(component.menu._allItems.length).toBe(2);
// eslint-disable-next-line no-underscore-dangle
expect(component.menu._allItems.toArray()).toEqual([menuItem1, menuItem2]);
expect(component.menu.ngAfterContentInit).toHaveBeenCalled();
});
});

View File

@ -15,10 +15,10 @@
* limitations under the License.
*/
import { Component, EventEmitter, Output } from '@angular/core';
import { AfterViewInit, Component, EventEmitter, Output, QueryList, ViewChild } from '@angular/core';
import { LanguageItem } from '../common/services/language-item.interface';
import { CommonModule } from '@angular/common';
import { MatMenuModule } from '@angular/material/menu';
import { MatMenu, MatMenuItem, MatMenuModule } from '@angular/material/menu';
import { TranslateModule } from '@ngx-translate/core';
import { LanguageMenuComponent } from './language-menu.component';
import { MatIconModule } from '@angular/material/icon';
@ -37,7 +37,25 @@ import { MatIconModule } from '@angular/material/icon';
</mat-menu>
`
})
export class LanguagePickerComponent {
export class LanguagePickerComponent implements AfterViewInit {
@Output()
public changedLanguage = new EventEmitter<LanguageItem>();
@ViewChild('langMenu')
menu: MatMenu;
@ViewChild(MatMenuItem)
menuItem: MatMenuItem;
@ViewChild(LanguageMenuComponent)
languageMenuComponent: LanguageMenuComponent;
ngAfterViewInit() {
const menuItems = this.languageMenuComponent.menuItems.filter((menuItem) => menuItem !== undefined);
const menuItemsQueryList = new QueryList<MatMenuItem>();
menuItemsQueryList.reset(menuItems);
// eslint-disable-next-line no-underscore-dangle
this.menu._allItems = menuItemsQueryList;
this.menu.ngAfterContentInit();
}
}

View File

@ -1,79 +1,75 @@
<div (keyup)="onKeyPress($event)" tabindex="-1" role="button" class="adf-notification-history-container">
<button mat-button
[matMenuTriggerFor]="menu"
aria-hidden="false"
[attr.aria-label]="'NOTIFICATIONS.OPEN_HISTORY' | translate"
title="{{ 'NOTIFICATIONS.OPEN_HISTORY' | translate }}"
class="adf-notification-history-menu_button"
id="adf-notification-history-open-button"
(menuOpened)="onMenuOpened()">
<mat-icon matBadge="&#8288;"
[matBadgeHidden]="!notifications.length"
class="adf-notification-history-menu_button-icon"
matBadgeColor="accent"
matBadgeSize="small">notifications
</mat-icon>
</button>
<mat-menu #menu="matMenu"
[xPosition]="menuPositionX"
[yPosition]="menuPositionY"
id="adf-notification-history-menu"
class="adf-notification-history-menu adf-notification-history-menu-panel">
<div class="adf-notification-history-list"
role="button"
tabindex="0"
(keyup.enter)="$event.stopPropagation()"
(click)="$event.stopPropagation()">
<div mat-subheader role="menuitem">
<span class="adf-notification-history-menu-title">{{ 'NOTIFICATIONS.TITLE' | translate }}</span>
<button *ngIf="notifications.length"
id="adf-notification-history-mark-as-read"
class="adf-notification-history-mark-as-read"
mat-icon-button
title="{{ 'NOTIFICATIONS.MARK_AS_READ' | translate }}"
(click)="markAsRead()">
<mat-icon>done_all</mat-icon>
</button>
</div>
<button mat-button
[matMenuTriggerFor]="menu"
aria-hidden="false"
[attr.aria-label]="'NOTIFICATIONS.OPEN_HISTORY' | translate"
title="{{ 'NOTIFICATIONS.OPEN_HISTORY' | translate }}"
class="adf-notification-history-menu_button"
id="adf-notification-history-open-button"
(menuOpened)="onMenuOpened()">
<mat-icon matBadge="&#8288;"
[matBadgeHidden]="!notifications.length"
class="adf-notification-history-menu_button-icon"
matBadgeColor="accent"
matBadgeSize="small">notifications
</mat-icon>
</button>
<mat-divider />
<mat-menu #menu="matMenu"
[xPosition]="menuPositionX"
[yPosition]="menuPositionY"
id="adf-notification-history-menu"
class="adf-notification-history-menu adf-notification-history-menu-panel">
<div class="adf-notification-history-list-header">
<span class="adf-notification-history-menu-title">{{ 'NOTIFICATIONS.TITLE' | translate }}</span>
<button mat-menu-item
*ngIf="notifications.length"
id="adf-notification-history-mark-as-read"
class="adf-notification-history-mark-as-read"
title="{{ 'NOTIFICATIONS.MARK_AS_READ' | translate }}"
(click)="markAsRead()">
<mat-icon class="adf-notification-history-mark-as-read-icon">done_all</mat-icon>
</button>
</div>
<mat-list role="menuitem">
<ng-container *ngIf="notifications.length; else empty_list_template">
<mat-list-item *ngFor="let notification of paginatedNotifications"
class="adf-notification-history-menu-item"
(click)="onNotificationClick(notification)">
<div *ngIf="notification.initiator; else no_avatar"
matListItemAvatar
[outerHTML]="notification.initiator | usernameInitials : 'adf-notification-initiator-pic'"></div>
<ng-template #no_avatar>
<mat-icon matListItemLine
class="adf-notification-history-menu-initiator">{{notification.icon}}</mat-icon>
</ng-template>
<div class="adf-notification-history-menu-item-content">
<p class="adf-notification-history-menu-text adf-notification-history-menu-message"
*ngFor="let message of notification.messages"
matListItemLine [title]="message">{{ message }}</p>
<p class="adf-notification-history-menu-text adf-notification-history-menu-date"
matListItemLine> {{notification.datetime | adfTimeAgo}} </p>
</div>
</mat-list-item>
</ng-container>
<ng-template #empty_list_template>
<mat-list-item id="adf-notification-history-component-no-message"
class="adf-notification-history-menu-no-message">
<p class="adf-notification-history-menu-no-message-text" matListItemLine>{{ 'NOTIFICATIONS.NO_MESSAGE' | translate }}</p>
</mat-list-item>
</ng-template>
</mat-list>
<mat-divider/>
<mat-divider />
<div class="adf-notification-history-item-list">
<ng-container *ngIf="notifications.length; else empty_list_template">
<button mat-menu-item
*ngFor="let notification of paginatedNotifications"
(click)="onNotificationClick(notification, $event)"
class="adf-notification-history-menu-item">
<div class="adf-notification-history-menu-item-content">
<div *ngIf="notification.initiator; else no_avatar"
[outerHTML]="notification.initiator | usernameInitials : 'adf-notification-initiator-pic'"></div>
<ng-template #no_avatar>
<mat-icon class="adf-notification-history-menu-initiator">
{{ notification.icon }}
</mat-icon>
</ng-template>
<div class="adf-notification-history-menu-item-content-message">
<p class="adf-notification-history-menu-text adf-notification-history-menu-message"
*ngFor="let message of notification.messages"
[title]="message">{{ message }}</p>
<p class="adf-notification-history-menu-text adf-notification-history-menu-date"
> {{ notification.datetime | adfTimeAgo }} </p>
</div>
</div>
</button>
</ng-container>
<ng-template #empty_list_template>
<p mat-menu-item id="adf-notification-history-component-no-message"
class="adf-notification-history-menu-no-message-text">
{{ 'NOTIFICATIONS.NO_MESSAGE' | translate }}
</p>
</ng-template>
</div>
<div class="adf-notification-history-load-more" role="menuitem" *ngIf="hasMoreNotifications()">
<button mat-button (click)="loadMore()">
{{ 'NOTIFICATIONS.LOAD_MORE' | translate }}
</button>
</div>
</div>
</mat-menu>
</div>
<mat-divider/>
<div class="adf-notification-history-load-more" *ngIf="hasMoreNotifications()">
<button mat-menu-item (click)="loadMore($event)">
{{ 'NOTIFICATIONS.LOAD_MORE' | translate }}
</button>
</div>
</mat-menu>

View File

@ -3,37 +3,6 @@
$notification-item-height: 72px;
.adf {
&-notification-history-container {
margin-top: 1px;
}
&-notification-history-list {
.adf-notification-history-menu-item-content-wrapper {
height: 100%;
display: flex;
align-items: center;
}
/* stylelint-disable selector-class-pattern */
.mdc-list-item__secondary-text::before {
height: auto;
}
}
&-notification-history-menu-item-content {
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
padding: 0 0 0 16px;
p {
line-height: 16px;
margin-bottom: 0;
color: var(--theme-sidenav-user-menu-color);
}
}
&-notification-history-menu-title {
font-size: 14px;
-webkit-font-smoothing: subpixel-antialiased;
@ -44,6 +13,7 @@ $notification-item-height: 72px;
padding: 0;
min-width: 40px;
height: 40px;
margin-top: 1px;
.adf-notification-history-menu_button-icon {
margin-right: 0;
@ -54,18 +24,67 @@ $notification-item-height: 72px;
}
}
&-notification-history-list #{$mat-subheader} {
display: flex;
justify-content: space-between;
align-items: center;
}
&-notification-history-menu:has(.adf-notification-history-list) {
.adf-notification-history-menu-item {
cursor: pointer;
height: $notification-item-height;
&-notification-history-menu {
.adf-notification-history-list-header {
padding: 10.5px 16px;
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 16px;
.adf-notification-history-mark-as-read {
display: flex;
padding: 10px;
width: auto;
margin: 4px 0;
&:hover,
&:focus,
&:active {
background: none;
}
&-icon {
margin: 0;
color: inherit;
}
}
}
.adf-notification-history-menu-title {
line-height: 28px;
}
.adf-notification-history-item-list {
padding-top: 8px;
.adf-notification-history-menu-item {
cursor: pointer;
height: $notification-item-height;
align-items: center;
display: block;
padding: 0 14px;
&-content {
height: 100%;
display: flex;
align-items: center;
&-message {
display: flex;
flex-direction: column;
justify-content: center;
box-sizing: border-box;
overflow: hidden;
padding: 0 0 0 16px;
p {
line-height: normal;
margin: 0;
color: var(--theme-sidenav-user-menu-color);
}
}
}
}
}
.adf-notification-history-menu-item:focus {
@ -77,20 +96,29 @@ $notification-item-height: 72px;
background-color: var(--adf-theme-background-hover-color);
}
.adf-notification-history-menu-message:is(p),
.adf-notification-history-menu-no-message:is(p) {
.adf-notification-history-menu-message:is(p) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--theme-body-1-font-size);
}
.adf-notification-history-menu-no-message-text {
font-size: 16px;
.adf-notification-history-item-list .adf-notification-history-menu-no-message-text {
color: var(--theme-sidenav-user-menu-color);
margin-bottom: 0;
-webkit-font-smoothing: subpixel-antialiased;
margin: 0;
padding: 12px 16px;
opacity: inherit;
border: none;
span {
font-size: 16px;
-webkit-font-smoothing: subpixel-antialiased;
}
}
.adf-notification-history-menu-date.adf-notification-history-menu-text:is(p) {
font-size: var(--theme-caption-font-size);
text-indent: 3px;
}
.adf-notification-history-menu-initiator {
@ -119,13 +147,11 @@ $notification-item-height: 72px;
padding: 10px;
button {
justify-content: center;
width: 100%;
min-height: 36px;
}
}
&-notification-history-mark-as-read {
margin: 4px 0;
}
}
#{$mat-menu-panel}.adf-notification-history-menu.adf-notification-history-menu-panel {
@ -134,18 +160,5 @@ $notification-item-height: 72px;
#{$mat-menu-content} {
padding: 0;
#{$mat-list} {
padding: 8px 0 0;
#{$mat-list-item-unscoped-content} {
display: flex;
}
#{$mat-list-item-content} {
display: flex;
align-items: center;
}
}
}
}

View File

@ -95,7 +95,7 @@ describe('Notification History Component', () => {
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(overlayContainerElement.querySelector('#adf-notification-history-component-no-message')).toBeNull();
expect(overlayContainerElement.querySelector('.adf-notification-history-list').innerHTML).toContain('Example Message');
expect(overlayContainerElement.querySelector('.adf-notification-history-item-list').innerHTML).toContain('Example Message');
done();
});
});

View File

@ -15,17 +15,7 @@
* 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, ViewChild, 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';
@ -121,22 +111,11 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit {
this.createPagination();
}
onKeyPress(event: KeyboardEvent) {
this.closeUserModal(event);
}
private closeUserModal($event: KeyboardEvent) {
if ($event.keyCode === 27) {
this.trigger.closeMenu();
}
}
markAsRead() {
this.notifications = [];
this.paginatedNotifications = [];
this.storageService.removeItem(NotificationHistoryComponent.NOTIFICATION_STORAGE);
this.createPagination();
this.trigger.closeMenu();
}
createPagination() {
@ -149,7 +128,8 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit {
this.paginatedNotifications = this.notifications.slice(0, this.pagination.skipCount);
}
loadMore() {
loadMore($event: MouseEvent) {
$event.stopPropagation();
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);
@ -159,7 +139,8 @@ export class NotificationHistoryComponent implements OnInit, AfterViewInit {
return this.pagination?.hasMoreItems;
}
onNotificationClick(notification: NotificationModel) {
onNotificationClick(notification: NotificationModel, $event: MouseEvent) {
$event.stopPropagation();
if (notification.clickCallBack) {
notification.clickCallBack(notification.args);
this.trigger.closeMenu();

View File

@ -87,7 +87,6 @@ $mat-calendar-table-header: '.mat-calendar-table-header';
$mat-calendar-body-disabled: '.mat-calendar-body-disabled';
$mat-toolbar: '.mat-toolbar';
$mat-slide-toggle: '.mat-mdc-slide-toggle';
$mat-list: '.mat-mdc-list';
$mat-list-item-content: '.mdc-list-item__content';
$mat-list-item-unscoped-content: '.mat-mdc-list-item-unscoped-content';
$mat-text-field-no-label: '.mdc-text-field--no-label';

View File

@ -19,10 +19,11 @@
import { Component, Input, OnChanges, SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DynamicExtensionComponent } from './dynamic.component';
import { ComponentRegisterService } from '../../services/component-register.service';
import { HttpClientModule } from '@angular/common/http';
import { MatMenuItem } from '@angular/material/menu';
import { By } from '@angular/platform-browser';
@Component({
selector: 'test-component',
@ -71,9 +72,10 @@ describe('DynamicExtensionComponent', () => {
TestBed.resetTestingModule();
});
const getInnerElement = () => fixture.debugElement.query(By.css('[data-automation-id="found-me"]'));
it('should load the TestComponent', () => {
const innerElement = fixture.debugElement.query(By.css('[data-automation-id="found-me"]'));
expect(innerElement).not.toBeNull();
expect(getInnerElement()).not.toBeNull();
});
it('should pass through the data', () => {
@ -90,6 +92,12 @@ describe('DynamicExtensionComponent', () => {
const testComponent = fixture.debugElement.query(By.css('test-component')).componentInstance;
expect(testComponent.data).toBe(data);
});
it('should assign menuItem from dynamically generated component in ngAfterViewInit', () => {
getInnerElement().componentInstance.menuItem = new MatMenuItem(null, null, null, null, null);
component.ngAfterViewInit();
expect(component.menuItem).toBeInstanceOf(MatMenuItem);
});
});
describe('Angular life-cycle methods in sub-component', () => {

View File

@ -15,9 +15,10 @@
* limitations under the License.
*/
import { Component, Input, ComponentRef, ViewChild, ViewContainerRef, OnDestroy, OnChanges, SimpleChanges } from '@angular/core';
import { Component, Input, ComponentRef, ViewChild, ViewContainerRef, OnDestroy, OnChanges, SimpleChanges, AfterViewInit } from '@angular/core';
import { ExtensionService } from '../../services/extension.service';
import { ExtensionComponent } from '../../services/component-register.service';
import { MatMenuItem } from '@angular/material/menu';
// cSpell:words lifecycle
@Component({
@ -25,7 +26,7 @@ import { ExtensionComponent } from '../../services/component-register.service';
standalone: true,
template: `<div #content></div>`
})
export class DynamicExtensionComponent implements OnChanges, OnDestroy {
export class DynamicExtensionComponent implements OnChanges, OnDestroy, AfterViewInit {
@ViewChild('content', { read: ViewContainerRef, static: true })
content: ViewContainerRef;
@ -35,6 +36,9 @@ export class DynamicExtensionComponent implements OnChanges, OnDestroy {
/** Data for the dynamically-loaded component instance. */
@Input() data: any;
/** Provides the menu item of dynamically-loaded component instance. */
menuItem: MatMenuItem;
private componentRef: ComponentRef<ExtensionComponent>;
private loaded: boolean = false;
@ -61,6 +65,10 @@ export class DynamicExtensionComponent implements OnChanges, OnDestroy {
}
}
ngAfterViewInit() {
this.menuItem = this.componentRef?.instance?.menuItem;
}
private loadComponent() {
const componentType = this.extensions.getComponentById<ExtensionComponent>(this.id);
if (componentType) {

View File

@ -16,9 +16,11 @@
*/
import { Injectable, Type } from '@angular/core';
import { MatMenuItem } from '@angular/material/menu';
export interface ExtensionComponent {
data: any;
menuItem?: MatMenuItem;
}
@Injectable({ providedIn: 'root' })