diff --git a/Build/Sources/Sass/element/typo3-notification-message.scss b/Build/Sources/Sass/element/typo3-notification-message.scss index 40bb4363dd67e9bfd11d04c6ab0d4609eeb7a59b..d2427e8d692fef51164fc42cde8f4274751f1105 100644 --- a/Build/Sources/Sass/element/typo3-notification-message.scss +++ b/Build/Sources/Sass/element/typo3-notification-message.scss @@ -22,9 +22,24 @@ right: calc(var(--typo3-spacing) * 1.5); } + .alert-list { + display: block; + max-height: calc(100vh - $toolbar-height - var(--typo3-spacing) - var(--module-docheader-height) * 2); + overflow-y: auto; + } + typo3-notification-message { margin-top: 5px; } + + typo3-notification-clear-all { + text-align: right; + + div { + display: block; + padding-bottom: calc(var(--typo3-spacing) / 2); + } + } } typo3-notification-message { diff --git a/Build/Sources/TypeScript/backend/notification.ts b/Build/Sources/TypeScript/backend/notification.ts index a3cf9a20f4256f8b25f380bd6433f5e38de810c9..7da60ea7e11fdcfbadedebd2d46e362fde98cbb6 100644 --- a/Build/Sources/TypeScript/backend/notification.ts +++ b/Build/Sources/TypeScript/backend/notification.ts @@ -19,6 +19,7 @@ import { AbstractAction } from './action-button/abstract-action'; import { SeverityEnum } from './enum/severity'; import Severity from './severity'; import '@typo3/backend/element/icon-element'; +import { lll } from '@typo3/core/lit-helper'; interface Action { label: string; @@ -31,7 +32,11 @@ interface Action { */ class Notification { private static readonly duration: number = 5; + private static readonly showClearAllButtonCount: number = 2; + private static totalNotifications: number = 0; private static messageContainer: HTMLElement = null; + private static notificationList: HTMLElement = null; + private static clearAllButton: HTMLElement = null; /** * Show a notice notification @@ -114,7 +119,30 @@ class Notification { if (this.messageContainer === null || document.getElementById('alert-container') === null) { this.messageContainer = document.createElement('div'); this.messageContainer.setAttribute('id', 'alert-container'); + this.notificationList = document.createElement('div'); + this.notificationList.setAttribute('class', 'alert-list') + // Enable focusing for keyboard scrolling (accessibility) + this.notificationList.setAttribute('tabindex', '0') + this.messageContainer.appendChild(this.notificationList); + + this.clearAllButton = <ClearNotificationMessages>document.createElement('typo3-notification-clear-all'); + this.containerItemVisibility(); + this.messageContainer.prepend(this.clearAllButton); document.body.appendChild(this.messageContainer); + + document.addEventListener('typo3-notification-open', () => { + this.totalNotifications++; + this.containerItemVisibility(); + }) + + document.addEventListener('typo3-notification-clear', () => { + // Avoid negative value + if(this.totalNotifications > 0) { + this.totalNotifications--; + } + + this.containerItemVisibility(); + }) } const box = <NotificationMessage>document.createElement('typo3-notification-message'); @@ -126,10 +154,45 @@ class Notification { box.setAttribute('notification-severity', severity.toString()); box.setAttribute('notification-duration', duration.toString()); box.actions = actions; - this.messageContainer.appendChild(box); + + // Wait for the animation to finish, before scrolling into view + setTimeout(() => { + this.notificationList.querySelector('typo3-notification-message:last-child').scrollIntoView(); + }, Number(duration)) + + this.notificationList.appendChild(box); + } + + protected static containerItemVisibility() { + this.clearAllButton.hidden = this.totalNotifications < this.showClearAllButtonCount; + this.messageContainer.hidden = this.totalNotifications === 0; } } +@customElement('typo3-notification-clear-all') +export class ClearNotificationMessages extends LitElement { + @property({ type: String, attribute: 'notification-container' }) notificationId: string; + + public async clearAll() { + this.dispatchEvent( + new CustomEvent('typo3-notification-clear-all', { bubbles: true, composed: true }) + ); + + this.hidden = true + } + + protected createRenderRoot(): HTMLElement | DocumentFragment { + return this; + } + + protected render() { + return html`<div><button @click=${() => this.clearAll()} class="btn btn-default"> + <typo3-backend-icon identifier="actions-close" size="small"></typo3-backend-icon> ${lll('button.clearAll') || 'Clear all'} + </button></div>`; + } + +} + @customElement('typo3-notification-message') export class NotificationMessage extends LitElement { @property({ type: String, attribute: 'notification-id' }) notificationId: string; @@ -142,15 +205,26 @@ export class NotificationMessage extends LitElement { @state() executingAction: number = -1; public async firstUpdated(): Promise<void> { + document.addEventListener('typo3-notification-clear-all', async () => { + this.clear(); + }); + + const event = new CustomEvent('typo3-notification-open', { bubbles: true, composed: true }); + this.dispatchEvent(event); + await new Promise(resolve => window.setTimeout(resolve, 200)); await this.requestUpdate(); if (this.notificationDuration > 0) { await new Promise(resolve => window.setTimeout(resolve, this.notificationDuration * 1000)); - this.close(); + this.clear(); } } - public async close(): Promise<void> { + public async clear(): Promise<void> { + this.dispatchEvent( + new CustomEvent('typo3-notification-clear', { bubbles: true, composed: true }) + ); + const onfinish = () => { this.parentNode && this.parentNode.removeChild(this); }; @@ -209,7 +283,7 @@ export class NotificationMessage extends LitElement { aria-labelledby="alert-title-${randomSuffix}" aria-describedby="alert-message-${randomSuffix}" > - <button type="button" class="close" @click="${async () => this.close()}"> + <button type="button" class="close" @click="${async () => this.clear()}"> <span aria-hidden="true"><typo3-backend-icon identifier="actions-close" size="small"></typo3-backend-icon></span> <span class="visually-hidden">Close</span> </button> @@ -236,7 +310,7 @@ export class NotificationMessage extends LitElement { if ('action' in action) { await action.action.execute(event.currentTarget as HTMLAnchorElement); } - this.close(); + this.clear(); }}" class="${classMap({ executing: this.executingAction === index, diff --git a/Build/Sources/TypeScript/backend/tests/notification-test.ts b/Build/Sources/TypeScript/backend/tests/notification-test.ts index f002ba8cf6d8ff293c82450c8ca906b13939593f..cbe7dbdf793d654bef2553407fd738908647fce4 100644 --- a/Build/Sources/TypeScript/backend/tests/notification-test.ts +++ b/Build/Sources/TypeScript/backend/tests/notification-test.ts @@ -31,7 +31,7 @@ describe('@typo3/backend/notification:', () => { afterEach((): void => { getIconStub.restore(); - const alertContainer = document.getElementById('alert-container'); + const alertContainer = document.querySelector('#alert-container .alert-list'); while (alertContainer !== null && alertContainer.firstChild) { alertContainer.removeChild(alertContainer.firstChild); } diff --git a/typo3/sysext/adminpanel/Resources/Private/Language/locallang.xlf b/typo3/sysext/adminpanel/Resources/Private/Language/locallang.xlf index 53df11676517cccab7204a60ff8cd80103620082..8ca41a7265b82e9dcda9fceed25e0059b980edb8 100644 --- a/typo3/sysext/adminpanel/Resources/Private/Language/locallang.xlf +++ b/typo3/sysext/adminpanel/Resources/Private/Language/locallang.xlf @@ -22,6 +22,9 @@ <trans-unit id="button.view" resname="button.view"> <source>View</source> </trans-unit> + <trans-unit id="button.clearAll" resname="button.clearAll"> + <source>Clear all</source> + </trans-unit> </body> </file> </xliff> diff --git a/typo3/sysext/backend/Resources/Public/Css/backend.css b/typo3/sysext/backend/Resources/Public/Css/backend.css index 0138ff019206bfd49b71497e43f5b80426a3cb57..e63a80bab0ea3a2af5a5cf3857e4c323248b30c8 100644 --- a/typo3/sysext/backend/Resources/Public/Css/backend.css +++ b/typo3/sysext/backend/Resources/Public/Css/backend.css @@ -3894,7 +3894,10 @@ typo3-backend-table-wizard{display:inline-block} @media (min-width:768px){ #alert-container{right:calc(var(--typo3-spacing) * 1.5)} } +#alert-container .alert-list{display:block;max-height:calc(100vh - 45px - var(--typo3-spacing) - var(--module-docheader-height) * 2);overflow-y:auto} #alert-container typo3-notification-message{margin-top:5px} +#alert-container typo3-notification-clear-all{text-align:right} +#alert-container typo3-notification-clear-all div{display:block;padding-bottom:calc(var(--typo3-spacing)/ 2)} typo3-notification-message{display:block} typo3-notification-message .alert{box-shadow:var(--typo3-component-box-shadow-dialog);margin:0} typo3-rte-ckeditor-ckeditor5 .ck.ck-style-panel .ck-style-grid .ck-style-grid__button.ck-disabled{display:none} diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/notification.js b/typo3/sysext/backend/Resources/Public/JavaScript/notification.js index 2743f9ebcaa6903e8ebe8923cd4fd7af1c01b121..f6b3f594db7e2071ed0da465cbf12f9891ebc360 100644 --- a/typo3/sysext/backend/Resources/Public/JavaScript/notification.js +++ b/typo3/sysext/backend/Resources/Public/JavaScript/notification.js @@ -10,7 +10,9 @@ * * The TYPO3 project - inspiring people to share! */ -var __decorate=function(t,i,e,o){var n,a=arguments.length,s=a<3?i:null===o?o=Object.getOwnPropertyDescriptor(i,e):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,i,e,o);else for(var c=t.length-1;c>=0;c--)(n=t[c])&&(s=(a<3?n(s):a>3?n(i,e,s):n(i,e))||s);return a>3&&s&&Object.defineProperty(i,e,s),s};import{LitElement,html}from"lit";import{customElement,property,state}from"lit/decorators.js";import{classMap}from"lit/directives/class-map.js";import{ifDefined}from"lit/directives/if-defined.js";import{SeverityEnum}from"@typo3/backend/enum/severity.js";import Severity from"@typo3/backend/severity.js";import"@typo3/backend/element/icon-element.js";class Notification{static notice(t,i,e,o){Notification.showMessage(t,i,SeverityEnum.notice,e,o)}static info(t,i,e,o){Notification.showMessage(t,i,SeverityEnum.info,e,o)}static success(t,i,e,o){Notification.showMessage(t,i,SeverityEnum.ok,e,o)}static warning(t,i,e,o){Notification.showMessage(t,i,SeverityEnum.warning,e,o)}static error(t,i,e=0,o){Notification.showMessage(t,i,SeverityEnum.error,e,o)}static showMessage(t,i,e=SeverityEnum.info,o,n=[]){void 0===o&&(o=e===SeverityEnum.error?0:this.duration),null!==this.messageContainer&&null!==document.getElementById("alert-container")||(this.messageContainer=document.createElement("div"),this.messageContainer.setAttribute("id","alert-container"),document.body.appendChild(this.messageContainer));const a=document.createElement("typo3-notification-message");a.setAttribute("notification-id","notification-"+Math.random().toString(36).substring(2,6)),a.setAttribute("notification-title",t),i&&a.setAttribute("notification-message",i),a.setAttribute("notification-severity",e.toString()),a.setAttribute("notification-duration",o.toString()),a.actions=n,this.messageContainer.appendChild(a)}}Notification.duration=5,Notification.messageContainer=null;let notificationObject,NotificationMessage=class extends LitElement{constructor(){super(...arguments),this.notificationSeverity=SeverityEnum.info,this.notificationDuration=0,this.actions=[],this.executingAction=-1}async firstUpdated(){await new Promise((t=>window.setTimeout(t,200))),await this.requestUpdate(),this.notificationDuration>0&&(await new Promise((t=>window.setTimeout(t,1e3*this.notificationDuration))),this.close())}async close(){const t=()=>{this.parentNode&&this.parentNode.removeChild(this)};!window.matchMedia("(prefers-reduced-motion: reduce)").matches&&"animate"in this?(this.style.overflow="hidden",this.style.display="block",this.animate([{height:this.getBoundingClientRect().height+"px"},{height:0,opacity:0,marginTop:0}],{duration:400,easing:"cubic-bezier(.02, .01, .47, 1)"}).onfinish=t):t()}createRenderRoot(){return this}render(){const t=Severity.getCssClass(this.notificationSeverity);let i="";switch(this.notificationSeverity){case SeverityEnum.notice:i="actions-lightbulb";break;case SeverityEnum.ok:i="actions-check";break;case SeverityEnum.warning:i="actions-exclamation";break;case SeverityEnum.error:i="actions-close";break;case SeverityEnum.info:default:i="actions-info"}const e=(Math.random()+1).toString(36).substring(2);return html` +var __decorate=function(t,i,e,o){var n,a=arguments.length,s=a<3?i:null===o?o=Object.getOwnPropertyDescriptor(i,e):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,i,e,o);else for(var c=t.length-1;c>=0;c--)(n=t[c])&&(s=(a<3?n(s):a>3?n(i,e,s):n(i,e))||s);return a>3&&s&&Object.defineProperty(i,e,s),s};import{LitElement,html}from"lit";import{customElement,property,state}from"lit/decorators.js";import{classMap}from"lit/directives/class-map.js";import{ifDefined}from"lit/directives/if-defined.js";import{SeverityEnum}from"@typo3/backend/enum/severity.js";import Severity from"@typo3/backend/severity.js";import"@typo3/backend/element/icon-element.js";import{lll}from"@typo3/core/lit-helper.js";class Notification{static notice(t,i,e,o){Notification.showMessage(t,i,SeverityEnum.notice,e,o)}static info(t,i,e,o){Notification.showMessage(t,i,SeverityEnum.info,e,o)}static success(t,i,e,o){Notification.showMessage(t,i,SeverityEnum.ok,e,o)}static warning(t,i,e,o){Notification.showMessage(t,i,SeverityEnum.warning,e,o)}static error(t,i,e=0,o){Notification.showMessage(t,i,SeverityEnum.error,e,o)}static showMessage(t,i,e=SeverityEnum.info,o,n=[]){void 0===o&&(o=e===SeverityEnum.error?0:this.duration),null!==this.messageContainer&&null!==document.getElementById("alert-container")||(this.messageContainer=document.createElement("div"),this.messageContainer.setAttribute("id","alert-container"),this.notificationList=document.createElement("div"),this.notificationList.setAttribute("class","alert-list"),this.notificationList.setAttribute("tabindex","0"),this.messageContainer.appendChild(this.notificationList),this.clearAllButton=document.createElement("typo3-notification-clear-all"),this.containerItemVisibility(),this.messageContainer.prepend(this.clearAllButton),document.body.appendChild(this.messageContainer),document.addEventListener("typo3-notification-open",(()=>{this.totalNotifications++,this.containerItemVisibility()})),document.addEventListener("typo3-notification-clear",(()=>{this.totalNotifications>0&&this.totalNotifications--,this.containerItemVisibility()})));const a=document.createElement("typo3-notification-message");a.setAttribute("notification-id","notification-"+Math.random().toString(36).substring(2,6)),a.setAttribute("notification-title",t),i&&a.setAttribute("notification-message",i),a.setAttribute("notification-severity",e.toString()),a.setAttribute("notification-duration",o.toString()),a.actions=n,setTimeout((()=>{this.notificationList.querySelector("typo3-notification-message:last-child").scrollIntoView()}),Number(o)),this.notificationList.appendChild(a)}static containerItemVisibility(){this.clearAllButton.hidden=this.totalNotifications<this.showClearAllButtonCount,this.messageContainer.hidden=0===this.totalNotifications}}Notification.duration=5,Notification.showClearAllButtonCount=2,Notification.totalNotifications=0,Notification.messageContainer=null,Notification.notificationList=null,Notification.clearAllButton=null;let ClearNotificationMessages=class extends LitElement{async clearAll(){this.dispatchEvent(new CustomEvent("typo3-notification-clear-all",{bubbles:!0,composed:!0})),this.hidden=!0}createRenderRoot(){return this}render(){return html`<div><button @click=${()=>this.clearAll()} class="btn btn-default"> + <typo3-backend-icon identifier="actions-close" size="small"></typo3-backend-icon> ${lll("button.clearAll")||"Clear all"} + </button></div>`}};__decorate([property({type:String,attribute:"notification-container"})],ClearNotificationMessages.prototype,"notificationId",void 0),ClearNotificationMessages=__decorate([customElement("typo3-notification-clear-all")],ClearNotificationMessages);export{ClearNotificationMessages};let notificationObject,NotificationMessage=class extends LitElement{constructor(){super(...arguments),this.notificationSeverity=SeverityEnum.info,this.notificationDuration=0,this.actions=[],this.executingAction=-1}async firstUpdated(){document.addEventListener("typo3-notification-clear-all",(async()=>{this.clear()}));const t=new CustomEvent("typo3-notification-open",{bubbles:!0,composed:!0});this.dispatchEvent(t),await new Promise((t=>window.setTimeout(t,200))),await this.requestUpdate(),this.notificationDuration>0&&(await new Promise((t=>window.setTimeout(t,1e3*this.notificationDuration))),this.clear())}async clear(){this.dispatchEvent(new CustomEvent("typo3-notification-clear",{bubbles:!0,composed:!0}));const t=()=>{this.parentNode&&this.parentNode.removeChild(this)};!window.matchMedia("(prefers-reduced-motion: reduce)").matches&&"animate"in this?(this.style.overflow="hidden",this.style.display="block",this.animate([{height:this.getBoundingClientRect().height+"px"},{height:0,opacity:0,marginTop:0}],{duration:400,easing:"cubic-bezier(.02, .01, .47, 1)"}).onfinish=t):t()}createRenderRoot(){return this}render(){const t=Severity.getCssClass(this.notificationSeverity);let i="";switch(this.notificationSeverity){case SeverityEnum.notice:i="actions-lightbulb";break;case SeverityEnum.ok:i="actions-check";break;case SeverityEnum.warning:i="actions-exclamation";break;case SeverityEnum.error:i="actions-close";break;case SeverityEnum.info:default:i="actions-info"}const e=(Math.random()+1).toString(36).substring(2);return html` <div id="${ifDefined(this.notificationId||void 0)}" class="alert alert-${t} alert-dismissible" @@ -18,7 +20,7 @@ var __decorate=function(t,i,e,o){var n,a=arguments.length,s=a<3?i:null===o?o=Obj aria-labelledby="alert-title-${e}" aria-describedby="alert-message-${e}" > - <button type="button" class="close" @click="${async()=>this.close()}"> + <button type="button" class="close" @click="${async()=>this.clear()}"> <span aria-hidden="true"><typo3-backend-icon identifier="actions-close" size="small"></typo3-backend-icon></span> <span class="visually-hidden">Close</span> </button> @@ -38,7 +40,7 @@ var __decorate=function(t,i,e,o){var n,a=arguments.length,s=a<3?i:null===o?o=Obj ${this.actions.map(((t,i)=>html` <a href="#" title="${t.label}" - @click="${async e=>{e.preventDefault(),this.executingAction=i,await this.updateComplete,"action"in t&&await t.action.execute(e.currentTarget),this.close()}}" + @click="${async e=>{e.preventDefault(),this.executingAction=i,await this.updateComplete,"action"in t&&await t.action.execute(e.currentTarget),this.clear()}}" class="${classMap({executing:this.executingAction===i,disabled:this.executingAction>=0&&this.executingAction!==i})}" >${t.label}</a> `))} diff --git a/typo3/sysext/core/Documentation/Changelog/13.2/Feature-104069-ImprovedBackendNotificationsDisplayAndHandling.rst b/typo3/sysext/core/Documentation/Changelog/13.2/Feature-104069-ImprovedBackendNotificationsDisplayAndHandling.rst new file mode 100644 index 0000000000000000000000000000000000000000..d4331103e44aecfe983076aee7793b32e3e67c9e --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/13.2/Feature-104069-ImprovedBackendNotificationsDisplayAndHandling.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-104069-1718551315: + +====================================================================== +Feature: #104069 - Improved backend notifications display and handling +====================================================================== + +See :issue:`104069` + +Description +=========== + +The notifications shown on the lower right now have a "Clear all" button to allow the +user to clear all notifications with a single click. This button is only displayed when +two or more notifications are on screen. + +In case the height of the notification container exceeds the viewport, a scroll bar will +allow the user to navigate through the notifications. + +Impact +====== + +Handling of multiple notifications has been improved by allowing to +scroll and clear all notifications at once. + +.. index:: Backend, ext:backend diff --git a/typo3/sysext/core/Tests/Acceptance/Application/Styleguide/NotificationCest.php b/typo3/sysext/core/Tests/Acceptance/Application/Styleguide/NotificationCest.php new file mode 100644 index 0000000000000000000000000000000000000000..78c03f30f1691f3282d1ea4c49050c92bc1bdd97 --- /dev/null +++ b/typo3/sysext/core/Tests/Acceptance/Application/Styleguide/NotificationCest.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Acceptance\Application\Styleguide; + +use TYPO3\CMS\Core\Tests\Acceptance\Support\ApplicationTester; + +final class NotificationCest +{ + public function _before(ApplicationTester $I): void + { + $I->useExistingSession('admin'); + $I->amOnPage('/typo3/module/system/styleguide?action=notifications'); + } + + public function seeClearAllButton(ApplicationTester $I): void + { + $I->amGoingTo('Open a notification'); + $I->switchToContentFrame(); + $I->click('.styleguide-content > p [data-severity="error"]'); + $I->switchToMainFrame(); + $I->waitForElement('#alert-container'); + $I->dontSee('#alert-container typo3-notification-message'); + + $I->amGoingTo('Open a second notification and expecting to see the "Clear all" button'); + $I->switchToContentFrame(); + $I->click('.styleguide-content > p [data-severity="error"]'); + $I->switchToMainFrame(); + $I->waitForElement('#alert-container typo3-notification-message'); + $I->waitForElement('#alert-container typo3-notification-clear-all'); + $I->click('typo3-notification-clear-all'); + + $I->dontSee('typo3-notification-clear-all'); + $I->dontSee('typo3-notification-message'); + } +}