import { html, css, type TemplateResult, nothing } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import { OmniElement } from '../core/OmniElement.js'; import { Modal } from '../modal/Modal.js'; import type { RenderFunction, RenderResult } from '../render-element/RenderElement.js'; export type { RenderFunction, RenderResult } from '../render-element/RenderElement.js'; import '../button/Button.js'; import '../render-element/RenderElement.js'; import '../modal/Modal.js'; /** * Component that displays an alert. * * @import * ```js * import '@capitec/omni-components/alert'; * ``` * * @example * ```html * * * ``` * * @element omni-alert * * Registry of all properties defined by the component. * * @fires alert-action-click - Dispatched when an alert action button is clicked. * @fires alert-close - Dispatched when the alert is closed. * * @slot status-indicator - Content to render as the status indicator instead of default status icons. * @slot header - Content to render inside the component message area. * @slot - Content to render inside the component description body. * @slot primary - Content to render as the primary action button. * @slot secondary - Content to render as the secondary action button. * * @csspart modal - Internal `omni-modal` element instance. * @csspart content - Internal `HTMLDivElement` instance for container of header and description content. * @csspart content - Internal `HTMLDivElement` instance for each line of description (does not include slotted description content). * @csspart header - Internal `HTMLDivElement` instance for header. * @csspart actions - Internal `HTMLDivElement` instance for container of action button(s). * * @cssprop --omni-alert-min-width - Minimum width for alert. * @cssprop --omni-alert-max-width - Maximum width for alert. * @cssprop --omni-alert-border - Alert border. * @cssprop --omni-alert-border-radius - Alert border radius. * @cssprop --omni-alert-box-shadow - Alert box shadow. * * @cssprop --omni-alert-animation-duration - Alert fade in and out animation duration. * @cssprop --omni-alert-padding-top - Alert content top padding. * @cssprop --omni-alert-padding-bottom - Alert content bottom padding. * @cssprop --omni-alert-padding-left - Alert content left padding. * @cssprop --omni-alert-padding-right - Alert content right padding. * * @cssprop --omni-alert-header-font-color - Alert header font color. * @cssprop --omni-alert-header-font-family - Alert header font family. * @cssprop --omni-alert-header-font-size - Alert header font size. * @cssprop --omni-alert-header-font-weight - Alert header font weight. * @cssprop --omni-alert-header-line-height - Alert header line height. * @cssprop --omni-alert-header-background - Alert header background. * * @cssprop --omni-alert-header-padding-top - Alert header top padding. * @cssprop --omni-alert-header-padding-bottom - Alert header bottom padding. * @cssprop --omni-alert-header-padding-left - Alert header left padding. * @cssprop --omni-alert-header-padding-right - Alert header right padding. * * @cssprop --omni-alert-description-font-color - Alert description font color. * @cssprop --omni-alert-description-font-family - Alert description font family. * @cssprop --omni-alert-description-font-size - Alert description font size. * @cssprop --omni-alert-description-font-weight - Alert description font weight. * @cssprop --omni-alert-description-line-height - Alert description line height. * * @cssprop --omni-alert-description-padding-top - Alert description top padding. * @cssprop --omni-alert-description-padding-bottom - Alert description bottom padding. * @cssprop --omni-alert-description-padding-left - Alert description left padding. * @cssprop --omni-alert-description-padding-right - Alert description right padding. * * @cssprop --omni-alert-action-padding-top - Alert actions part top padding. * @cssprop --omni-alert-action-padding-bottom - Alert actions part bottom padding. * @cssprop --omni-alert-action-padding-left - Alert actions part left padding. * @cssprop --omni-alert-action-padding-right - Alert actions part right padding. * * @cssprop --omni-alert-action-button-padding-top - Alert action button(s) top padding. * @cssprop --omni-alert-action-button-padding-bottom - Alert action button(s) bottom padding. * @cssprop --omni-alert-action-button-padding-left - Alert action button(s) left padding. * @cssprop --omni-alert-action-button-padding-right - Alert action button(s) right padding. * * @cssprop --omni-alert-action-button-internal-padding-top - Alert action button(s) internal top padding. * @cssprop --omni-alert-action-button-internal-padding-bottom - Alert action button(s) internal bottom padding. * @cssprop --omni-alert-action-button-internal-padding-left - Alert action button(s) internal left padding. * @cssprop --omni-alert-action-button-internal-padding-right - Alert action button(s) internal right padding. * * @cssprop --omni-alert-header-horizontal-gap - Alert header horizontal space between status indicator and header content. * * @cssprop --omni-alert-header-status-size - Alert header status indicator symmetrical size. */ @customElement('omni-alert') export class Alert extends OmniElement { /** * Internal `omni-modal` instance. * @no_attribute * @ignore */ @query('omni-modal') modal!: Modal; /** * The alert status (Defaults to 'none'). * @attr */ @property({ type: String, reflect: true }) status: 'success' | 'warning' | 'error' | 'info' | 'none' = 'none'; /** * The alert header message. * @attr */ @property({ type: String, reflect: true }) message?: string; /** * Header content horizontal alignment: * - `left` Align header to the left. * - `center` Align header to the center. (Default) * - `right` Align header to the right. * @attr [header-align] */ @property({ type: String, attribute: 'header-align', reflect: true }) headerAlign?: 'left' | 'center' | 'right'; /** * The alert detail message. * @attr */ @property({ type: String, reflect: true }) description?: string; /** * Description content horizontal alignment: * - `left` Align description content to the left. * - `center` Align description content to the center. (Default) * - `right` Align description content to the right. * @attr [description-align] */ @property({ type: String, attribute: 'description-align', reflect: true }) descriptionAlign?: 'left' | 'center' | 'right'; /** * The primary action button label (Defaults to 'Ok'). * @attr [primary-action] */ @property({ type: String, reflect: true, attribute: 'primary-action' }) primaryAction?: string; /** * The secondary action button label (Defaults to 'Cancel'). * @attr [secondary-action] */ @property({ type: String, reflect: true, attribute: 'secondary-action' }) secondaryAction?: string; /** * If true, will provide a secondary action button. * @attr [enable-secondary] */ @property({ type: Boolean, reflect: true, attribute: 'enable-secondary' }) enableSecondary?: boolean; /** * Action button(s) horizontal alignment: * - `left` Align action button(s) to the left. * - `center` Align action button(s) to the center. * - `right` Align action button(s) to the right. (Default) * - `stretch` Align action button(s) stretched to fill the horizontal space. * @attr [action-align] */ @property({ type: String, attribute: 'action-align', reflect: true }) actionAlign?: 'left' | 'center' | 'right' | 'stretch'; /** * Create a global `omni-alert` element without showing it. * * @returns The alert instance. */ static create(init: AlertInit) { const element = document.body.appendChild(document.createElement('omni-alert')); if (!init) { init = {}; } // Set the `omni-alert` component values. element.status = init.status ?? 'none'; element.message = init.message; element.headerAlign = init.headerAlign; element.descriptionAlign = init.descriptionAlign; element.description = init.description; element.primaryAction = init.primaryAction; element.secondaryAction = init.secondaryAction; element.enableSecondary = init.enableSecondary; element.actionAlign = init.actionAlign; if (init.id) { element.id = init.id; } // Setup optional renderers for slot(s) if (init.statusIndicator) { const renderElement = document.createElement('omni-render-element'); renderElement.slot = 'status-indicator'; renderElement.renderer = typeof init.statusIndicator === 'function' ? init.statusIndicator : () => init.statusIndicator as RenderResult; element.appendChild(renderElement); } if (init.header) { const renderElement = document.createElement('omni-render-element'); renderElement.slot = 'header'; renderElement.renderer = typeof init.header === 'function' ? init.header : () => init.header as RenderResult; element.appendChild(renderElement); } if (init.body) { const renderElement = document.createElement('omni-render-element'); renderElement.renderer = typeof init.body === 'function' ? init.body : () => init.body as RenderResult; element.appendChild(renderElement); } if (init.primary) { const renderElement = document.createElement('omni-render-element'); renderElement.slot = 'primary'; renderElement.renderer = typeof init.primary === 'function' ? init.primary : () => init.primary as RenderResult; element.appendChild(renderElement); } if (init.secondary) { const renderElement = document.createElement('omni-render-element'); renderElement.slot = 'secondary'; renderElement.renderer = typeof init.secondary === 'function' ? init.secondary : () => init.secondary as RenderResult; element.appendChild(renderElement); } return element as Alert; } /** * Show a global `omni-alert` element. * * @returns The alert instance. */ static show( init: AlertInit & { onClose?: (reason: 'auto' | 'primary' | 'secondary') => void; } ) { const element = Alert.create(init); if (init.onClose) { let reason: 'auto' | 'primary' | 'secondary' = 'auto'; element.addEventListener('alert-action-click', (e: Event) => { const actionClickEvent = e as CustomEvent<{ secondary: boolean }>; reason = actionClickEvent.detail.secondary ? 'secondary' : 'primary'; }); element.addEventListener('alert-close', () => { init.onClose?.apply(element, [reason]); }); } // Show the component as a modal dialog. return element.show() as Alert; } /** * Show a global `omni-alert` element asynchronously, waits for it to close and returns the reason for close. * * @returns The reason for the alert close. */ static showAsync(init: AlertInit) { const element = Alert.create(init); return element.showAsync() as Promise<'auto' | 'primary' | 'secondary'>; } /** * Show the `omni-alert` asynchronously, waits for it to close and returns the reason for close. * * @returns The reason for the alert close. */ showAsync() { return new Promise<'auto' | 'primary' | 'secondary'>((resolve, reject) => { try { this.show(); let reason: 'auto' | 'primary' | 'secondary' = 'auto'; this.addEventListener('alert-action-click', (e: Event) => { const actionClickEvent = e as CustomEvent<{ secondary: boolean }>; reason = actionClickEvent.detail.secondary ? 'secondary' : 'primary'; }); this.addEventListener('alert-close', () => { resolve?.apply(this, [reason]); }); } catch (error) { reject.apply(this, [error]); } }); } /** * Show the `omni-alert`. * * @returns The alert instance. */ show(): Alert { // Show the component modal dialog, after the initial component render has completed. this.updateComplete.then(() => { this.modal.hide = false; }); return this; } /** * Hide the `omni-alert` and remove the component from the DOM */ hide(): void { this.updateComplete.then(async () => { const { matches: motionOK } = window.matchMedia('(prefers-reduced-motion: no-preference)'); // Animate the alert fading out if the user allows motion. if (motionOK && document.timeline) { // Get current opacity to cater for existing fade out of timed toasts. const currentOpacity = Number(getComputedStyle(this.modal).getPropertyValue('opacity')); const anim = this.modal.animate( [ // key frames { offset: 0, opacity: currentOpacity }, { offset: 1, opacity: 0 } ], { // sync options duration: 500, easing: 'ease' } ); await anim.finished; } this.modal.hide = true; this.dispatchEvent(new CustomEvent('alert-close')); if (this.parentElement) { this.remove(); } }); } private onActionClick(secondary?: boolean) { this.dispatchEvent( new CustomEvent('alert-action-click', { detail: { secondary: secondary ?? false } }) ); this.hide(); } /** * The element style template. */ static override get styles() { return [ super.styles, css` :host { box-sizing: border-box; } omni-modal { --omni-modal-body-padding: 0px; } omni-modal::part(container) { min-width: var(--omni-alert-min-width, 10%); max-width: var(--omni-alert-max-width, 80%); } /** Dialog */ .container { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; padding: 0px; border: var(--omni-alert-border, none); box-shadow: var(--omni-alert-box-shadow, 0px 0px 3px rgba(0, 0, 0, 0.1)); border-radius: var(--omni-alert-border-radius, 10px); } omni-modal:not([hide]) { animation: fadein var(--omni-alert-animation-duration, 0.2s) ease-in-out; animation-fill-mode: forwards; } @media (prefers-reduced-motion) { /* styles to apply if a user's device settings are set to reduced motion */ omni-modal:not([hide]) { animation: unset; opacity: 1; } } @keyframes fadein { from { opacity: 0; } to { opacity: 1; } } /* Content */ .content { display: flex; flex-direction: column; justify-content: center; padding-top: var(--omni-alert-padding-top, 10px); padding-bottom: var(--omni-alert-padding-bottom, 10px); padding-left: var(--omni-alert-padding-left, 10px); padding-right: var(--omni-alert-padding-right, 10px); } .header { display: inline-flex; align-items: center; text-align: center; justify-content: center; color: var(--omni-alert-header-font-color,var(--omni-font-color)); background: var(--omni-alert-header-background, var(--omni-background-color)); font-family: var(--omni-alert-header-font-family, var(--omni-font-family)); font-size: var(--omni-alert-header-font-size, var(--omni-font-size)); line-height: var(--omni-alert-header-line-height, 1.2); font-weight: var(--omni-alert-header-font-weight, bold); position: relative; margin-top: var(--omni-alert-header-padding-top, 10px); margin-bottom: var(--omni-alert-header-padding-bottom, 0px); margin-left: var(--omni-alert-header-padding-left, 0px); margin-right: var(--omni-alert-header-padding-right, 0px); } .header.left { justify-content: left; } .header.right { justify-content: right; text-align: right; } ::slotted(*:not([slot])), .description { font-family: var(--omni-alert-description-font-family, sans-serif); font-size: var(--omni-alert-description-font-size, 16px); font-weight: var(--omni-alert-description-font-weight, normal); line-height: var(--omni-alert-description-line-height, 1.2); color: var(--omni-alert-description-font-color, var(--omni-font-color)); text-align: center; margin-top: var(--omni-alert-description-padding-top, 10px); margin-bottom: var(--omni-alert-description-padding-bottom, 0px); margin-left: var(--omni-alert-description-padding-left, 0px); margin-right: var(--omni-alert-description-padding-right, 0px); } .description.left { justify-content: left; text-align: left; } .description.right { justify-content: right; text-align: right; } .actions { display: flex; flex-direction: row; justify-content: flex-end; align-items: center; padding-top: var(--omni-alert-action-padding-top, 0px); padding-bottom: var(--omni-alert-action-padding-bottom, 0px); padding-left: var(--omni-alert-action-padding-left, 0px); padding-right: var(--omni-alert-action-padding-right, 0px); } .actions.center { justify-content: center; text-align: center; } .actions.left { flex-direction: row-reverse; text-align: left; } .actions.left .action-btn { padding-left: var(--omni-alert-action-button-padding-left, 4px); padding-right: var(--omni-alert-action-button-padding-right); } .actions.stretch .action-btn, .actions.stretch .clear-btn { padding-left: var(--omni-alert-action-button-padding-left, 4px); padding-right: var(--omni-alert-action-button-padding-right, 4px); width: 100%; } .action-btn { padding-top: var(--omni-alert-action-button-padding-top); padding-bottom: var(--omni-alert-action-button-padding-bottom, 4px); padding-left: var(--omni-alert-action-button-padding-left); padding-right: var(--omni-alert-action-button-padding-right, 4px); --omni-button-padding-top:var(--omni-alert-action-button-internal-padding-top, 0px); --omni-button-padding-bottom:var(--omni-alert-action-button-internal-padding-bottom, 0px); --omni-button-padding-left:var(--omni-alert-action-button-internal-padding-left, 4px); --omni-button-padding-right:var(--omni-alert-action-button-internal-padding-right, 4px); } .clear-btn { line-height: normal; padding-top: var(--omni-alert-action-button-padding-top, 4px); padding-bottom: var(--omni-alert-action-button-padding-bottom); padding-left: var(--omni-alert-action-button-padding-left); padding-right: var(--omni-alert-action-button-padding-right); --omni-button-padding-top:var(--omni-alert-action-button-internal-padding-top); --omni-button-padding-bottom:var(--omni-alert-action-button-internal-padding-bottom); --omni-button-padding-left:var(--omni-alert-action-button-internal-padding-left); --omni-button-padding-right:var(--omni-alert-action-button-internal-padding-right); } .status-icon { margin-right: var(--omni-alert-header-horizontal-gap, 10px); width: var(--omni-alert-header-status-size, 24px); height: var(--omni-alert-header-status-size, 24px); min-width: var(--omni-alert-header-status-size, 24px); min-height: var(--omni-alert-header-status-size, 24px); max-width: var(--omni-alert-header-status-size, 24px); max-height: var(--omni-alert-header-status-size, 24px); } ` ]; } /** * Generate the web component template. * * @returns The HTML component template. */ override render(): TemplateResult { // Determine the icon to show. let iconTemplate: TemplateResult | typeof nothing = nothing; // Derive the icon from the status. switch (this.status) { case 'info': iconTemplate = html` `; break; case 'success': iconTemplate = html` `; break; case 'error': iconTemplate = html` `; break; case 'warning': iconTemplate = html` `; break; case 'none': default: iconTemplate = nothing; break; } // Generate the component template. return html`
${iconTemplate} ${this.message ? html`
${this.message}
` : nothing}
${ this.description ? this.description .split('\n') .map( (paragraph) => html`
${paragraph}
` ) : nothing }
${ this.enableSecondary ? html`
` : nothing }
`; } } /** * Context for `Alert.show`/`Alert.showAsync` function(s) to programmatically render a new `` instance. */ export type AlertInit = { /** * The id to apply to the Alert element. */ id?: string; /** * A function that returns, or an instance of content to render as the alert status indicator */ statusIndicator?: RenderFunction | RenderResult; /** * A function that returns, or an instance of content to render in the alert header */ header?: RenderFunction | RenderResult; /** * A function that returns, or an instance of content to render as alert body */ body?: RenderFunction | RenderResult; /** * A function that returns, or an instance of content to render as the alert primary action */ primary?: RenderFunction | RenderResult; /** * A function that returns, or an instance of content to render as the alert secondary action */ secondary?: RenderFunction | RenderResult; /** * The alert status (Defaults to 'none'). */ status?: 'success' | 'warning' | 'error' | 'info' | 'none'; /** * The alert header message. */ message?: string; /** * Header content horizontal alignment: * - `left` Align header to the left. * - `center` Align header to the center. (Default) * - `right` Align header to the right. */ headerAlign?: 'left' | 'center' | 'right'; /** * The alert detail message. */ description?: string; /** * Description content horizontal alignment: * - `left` Align description content to the left. * - `center` Align description content to the center. (Default) * - `right` Align description content to the right. */ descriptionAlign?: 'left' | 'center' | 'right'; /** * The primary action button label (Defaults to 'Ok'). */ primaryAction?: string; /** * The secondary action button label (Defaults to 'Cancel'). */ secondaryAction?: string; /** * If true, will provide a secondary action button. */ enableSecondary?: boolean; /** * Action button(s) horizontal alignment: * - `left` Align action button(s) to the left. * - `center` Align action button(s) to the center. * - `right` Align action button(s) to the right. (Default) * - `stretch` Align action button(s) stretched to fill the horizontal space. */ actionAlign?: 'left' | 'center' | 'right' | 'stretch'; }; declare global { interface HTMLElementTagNameMap { 'omni-alert': Alert; } }