UNPKG

@digital-blueprint/lunchlottery-app

Version:

[GitHub Repository](https://github.com/digital-blueprint/lunchlottery-app) | [npmjs package](https://www.npmjs.com/package/@digital-blueprint/lunchlottery-app) | [Unpkg CDN](https://unpkg.com/browse/@digital-blueprint/lunchlottery-app/)

376 lines (338 loc) 11.9 kB
import {createUUID} from '@dbp-toolkit/common/utils'; import {css, html} from 'lit'; import {repeat} from 'lit/directives/repeat.js'; import {ScopedElementsMixin, Icon} from '@dbp-toolkit/common'; import DBPLitElement from '@dbp-toolkit/common/dbp-lit-element'; import * as commonStyles from '@dbp-toolkit/common/styles'; /** * Safely renders text with newlines converted to <br> elements. * Text is automatically escaped by lit-html to prevent XSS. * * @param text - Text containing newline characters (\n) * @returns {import('lit').TemplateResult[]} Template array with lines separated by <br> tags */ function renderTextWithBreaks(text) { const lines = text.split('\n'); return lines.map( (line, index) => html` ${line}${index < lines.length - 1 ? html` <br /> ` : ''} `, ); } class NotificationItem extends ScopedElementsMixin(DBPLitElement) { constructor() { super(); this.type = 'info'; this.body = ''; this.summary = ''; this.timeout = 0; this.icon = ''; this.replaceId = null; this.notificationId = null; this.targetNotificationId = null; this._progressTimeout = null; } static get scopedElements() { return { 'dbp-icon': Icon, }; } static get properties() { return { type: {type: String}, body: {type: String}, summary: {type: String}, timeout: {type: Number}, icon: {type: String}, replaceId: {type: String}, notificationId: {type: String, reflect: true}, }; } connectedCallback() { super.connectedCallback(); if (this.timeout > 0) { this._progressTimeout = setTimeout(() => { this.handleDelete(true); }, this.timeout * 1000); } } disconnectedCallback() { if (this._progressTimeout) { clearTimeout(this._progressTimeout); } super.disconnectedCallback(); } handleDelete(automatic = false) { this.dispatchEvent( new CustomEvent('dbp-notification-close', { detail: { targetNotificationId: this.targetNotificationId, // Indicates if the close was automatic (timeout) or manual (user click) automatic: automatic, }, bubbles: true, composed: true, }), ); } static get styles() { return css` ${commonStyles.getThemeCSS()} ${commonStyles.getGeneralCSS()} ${commonStyles.getNotificationCSS()} .delete, .modal-close { -moz-appearance: none; -webkit-appearance: none; background-color: rgba(10, 10, 10, 0.2); border: none; border-radius: 290486px; cursor: pointer; pointer-events: auto; display: inline-block; flex-grow: 0; flex-shrink: 0; font-size: 0; height: 20px; max-height: 20px; max-width: 20px; min-height: 20px; min-width: 20px; outline: 0; position: relative; vertical-align: top; width: 20px; } .delete::before, .modal-close::before, .delete::after, .modal-close::after { background-color: var(--dbp-background); color: var(--dbp-content); content: ''; display: block; left: 50%; position: absolute; top: 50%; -webkit-transform: translateX(-50%) translateY(-50%) rotate(45deg); transform: translateX(-50%) translateY(-50%) rotate(45deg); -webkit-transform-origin: center center; transform-origin: center center; } .delete::before, .modal-close::before { height: 2px; width: 50%; } .delete::after, .modal-close::after { height: 50%; width: 2px; } .delete:hover, .modal-close:hover, .delete:focus, .modal-close:focus { background-color: rgba(10, 10, 10, 0.3); } .delete:active, .modal-close:active { background-color: rgba(10, 10, 10, 0.4); } `; } render() { const progressClass = this.timeout > 0 ? 'has-progress-bar' : 'no-progress-bar'; const progressStyle = this.timeout > 0 ? `--dbp-progress-timeout: ${this.timeout}s;` : ''; return html` <div class="notification is-${this.type} ${progressClass} enter-animation"> <button class="delete" @click=${this.handleDelete} aria-label="Close notification"></button> ${this.summary ? html` <h3>${this.summary}</h3> ` : ''} <div class="content"> ${this.icon ? html` <dbp-icon name="${this.icon}"></dbp-icon> ` : ''} <span>${renderTextWithBreaks(this.body)}</span> </div> ${this.timeout > 0 ? html` <div class="progress-container"> <div class="progress" style="${progressStyle}"></div> </div> ` : ''} </div> `; } } /** * Notification web component */ export class Notification extends ScopedElementsMixin(DBPLitElement) { constructor() { super(); this.notifications = []; this._boundNotificationHandler = this._handleNotificationEvent.bind(this); } static get scopedElements() { return { 'dbp-notification-item': NotificationItem, }; } static get properties() { return { ...super.properties, inline: {type: Boolean, attribute: 'inline'}, notifications: {type: Array, state: true}, }; } /** * @param {CustomEvent} customEvent */ _handleNotificationEvent(customEvent) { let targetNotificationId = customEvent.detail.targetNotificationId ?? null; if (targetNotificationId) { // If targetNotificationId is set, only process notifications for that component if (targetNotificationId !== this.id) { return; } } else { // If targetNotificationId is not set, only process notifications for the default notification component in the app-shell if (this.id !== 'dbp-notification') { return; } } customEvent.preventDefault(); customEvent.stopImmediatePropagation(); let replaceId = customEvent.detail.replaceId ?? null; let notificationData = { id: createUUID(), type: customEvent.detail.type ?? 'info', body: customEvent.detail.body ?? '', summary: customEvent.detail.summary ?? '', timeout: customEvent.detail.timeout ?? 0, icon: customEvent.detail.icon ?? '', replaceId: replaceId, }; if (replaceId) { this.notifications = this.notifications.filter((n) => n.replaceId !== replaceId); } this.notifications.push(notificationData); this.requestUpdate('notifications'); this.updateComplete.then(() => { const notificationEvent = new CustomEvent('dbp-notification-added', { detail: {targetNotificationId: this.id}, bubbles: true, composed: true, }); this.dispatchEvent(notificationEvent); }); } connectedCallback() { super.connectedCallback(); window.addEventListener('dbp-notification-send', this._boundNotificationHandler); } disconnectedCallback() { window.removeEventListener('dbp-notification-send', this._boundNotificationHandler); super.disconnectedCallback(); for (const notification of Object.values(this.notifications)) { clearTimeout(notification.progressTimeout); } } _removeById(notificationId, noAnimation = false) { const item = this.shadowRoot.querySelector( `dbp-notification-item[notificationId="${notificationId}"]`, ); if (item) { const animationTimeout = noAnimation ? 0 : 500; item.classList.add('is-removing'); setTimeout(() => { this.notifications = this.notifications.filter((n) => n.id !== notificationId); this.dispatchEvent( new CustomEvent('dbp-notification-removed', { detail: {targetNotificationId: this.id}, bubbles: true, composed: true, }), ); }, animationTimeout); } } /** * Removes all notifications */ removeAllNotifications() { this.notifications = []; } static get styles() { // language=css return css` ${commonStyles.getThemeCSS()} ${commonStyles.getGeneralCSS()} ${commonStyles.getNotificationCSS()} .notification-container { position: fixed; top: 0; max-width: 500px; margin: 0.75em auto; left: 0; right: 0; z-index: 1000; padding: 0; } :host([inline]) .notification-container { top: 0; left: 0; right: 0; max-width: 100%; margin: 0 auto; display: flex; flex-direction: column; } :host([inline]) .notification-container--inside { margin-top: 60px; } .notification h3 { font: inherit; font-weight: bold; margin-bottom: 3px; } `; } render() { return html` <div class="notification-container" id="notification-container"> ${repeat( this.notifications, (notification) => notification.id, (notification) => html` <dbp-notification-item .type=${notification.type} .body=${notification.body} .summary=${notification.summary} .timeout=${notification.timeout} .icon=${notification.icon} .replaceId=${notification.replaceId} .notificationId=${notification.id} @dbp-notification-close=${(e) => { this._removeById(notification.id, e.detail.automatic); }}></dbp-notification-item> `, )} </div> `; } }