diff --git a/apps/pwabuilder/Frontend/src/app-index.styles.ts b/apps/pwabuilder/Frontend/src/app-index.styles.ts new file mode 100644 index 000000000..e0dcc2973 --- /dev/null +++ b/apps/pwabuilder/Frontend/src/app-index.styles.ts @@ -0,0 +1,91 @@ +import { css } from 'lit'; + +/** + * Styles for the root component: the router outlet transitions, + * page layout wrapper, sidebar/main grid, and the fixed toast stack. + */ +export const appIndexStyles = css` + #router-outlet > * { + width: 100% !important; + } + + #router-outlet > .leaving { + animation: 160ms fadeOut ease-in-out; + } + + #router-outlet > .entering { + animation: 160ms fadeIn linear; + } + + #router-outlet { + position: relative; + } + + #wrapper { + display: flex; + min-height: 100vh; + flex-direction: column; + } + + #content { + flex: 1; + background-color: rgb(242, 243, 251); + } + + @media (min-width: 1920px) { + #router-outlet { + background: var(--primary-purple); + } + } + + @keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } + } + + @keyframes fadeIn { + from { + opacity: 0.2; + } + + to { + opacity: 1; + } + } + /* To handle sidebar & main */ + .container { + display: grid; + grid-template-columns: minmax(280px, auto); + grid-template-areas: 'sidebar main'; + margin: 0 auto; + height: 100%; + position: relative; + } + .container > .main { + width: calc(100vw - 280px); + grid-area: main; + } + .container > .sidebar { + grid-area: sidebar; + } + + /* Fixed-position container that stacks toasts in the top-right corner. + pointer-events are disabled here so only the toasts themselves are + interactive, letting clicks pass through the empty stack area. */ + .toast-stack { + position: fixed; + top: 1rem; + inset-inline-end: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 1000; + max-width: min(28rem, calc(100vw - 2rem)); + pointer-events: none; + } + `; diff --git a/apps/pwabuilder/Frontend/src/app-index.ts b/apps/pwabuilder/Frontend/src/app-index.ts index 4e7ccb8ee..b14804632 100644 --- a/apps/pwabuilder/Frontend/src/app-index.ts +++ b/apps/pwabuilder/Frontend/src/app-index.ts @@ -1,5 +1,5 @@ import './webawesome'; -import { LitElement, css, html } from 'lit'; +import { LitElement, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { Router, Route } from '@vaadin/router'; @@ -7,85 +7,24 @@ import './script/components/app-footer'; import './script/components/app-button'; //import './script/components/cookie-banner'; import './script/components/discord-box'; +import './script/components/toast-alert'; +import { ToastEvent, SHOW_TOAST_EVENT_NAME } from './script/models/toast-event'; import { recordPageView, storeQueryParam } from './script/utils/analytics'; +import { appIndexStyles } from './app-index.styles'; @customElement('app-index') export class AppIndex extends LitElement { @state() pageName: string = ''; - static get styles() { - return css` - #router-outlet > * { - width: 100% !important; - } - - #router-outlet > .leaving { - animation: 160ms fadeOut ease-in-out; - } - - #router-outlet > .entering { - animation: 160ms fadeIn linear; - } - - #router-outlet { - position: relative; - } - - #wrapper { - display: flex; - min-height: 100vh; - flex-direction: column; - } - - #content { - flex: 1; - background-color: rgb(242, 243, 251); - } - - @media (min-width: 1920px) { - #router-outlet { - background: var(--primary-purple); - } - } - - @keyframes fadeOut { - from { - opacity: 1; - } + // Active toasts rendered in the top-right stack, each paired with a unique + // id so they can be individually removed once dismissed. + @state() private toasts: Array<{ id: number; toast: ToastEvent }> = []; - to { - opacity: 0; - } - } + // Monotonic counter used to give each toast a stable key for removal. + private toastCounter = 0; - @keyframes fadeIn { - from { - opacity: 0.2; - } - - to { - opacity: 1; - } - } - /* To handle sidebar & main */ - .container { - display: grid; - grid-template-columns: minmax(280px, auto); - grid-template-areas: 'sidebar main'; - margin: 0 auto; - height: 100%; - position: relative; - } - .container > .main { - width: calc(100vw - 280px); - grid-area: main; - } - .container > .sidebar { - grid-area: sidebar; - } - `; - } + static styles = [appIndexStyles]; constructor() { super(); @@ -106,14 +45,27 @@ export class AppIndex extends LitElement { super.connectedCallback(); window.addEventListener('DOMContentLoaded', this.handlePageChange); window.addEventListener('popstate', this.handlePageChange); + window.addEventListener(SHOW_TOAST_EVENT_NAME, this.handleShowToast); } disconnectedCallback() { window.removeEventListener('DOMContentLoaded', this.handlePageChange); window.removeEventListener('popstate', this.handlePageChange); + window.removeEventListener(SHOW_TOAST_EVENT_NAME, this.handleShowToast); super.disconnectedCallback(); } + // Adds a toast to the stack when a "show-toast" event is dispatched. + private handleShowToast = (event: WindowEventMap[typeof SHOW_TOAST_EVENT_NAME]) => { + const id = this.toastCounter++; + this.toasts = [...this.toasts, { id, toast: event.detail }]; + }; + + // Removes a toast from the stack once it has been dismissed. + private removeToast(id: number) { + this.toasts = this.toasts.filter(entry => entry.id !== id); + } + handlePageChange = () => { var urlObj = new URL(location.href); @@ -213,17 +165,24 @@ export class AppIndex extends LitElement { render() { return html` -
- - - -
-
-
- ${this.pageName === "congratulations" ? null : html``} - -
- - `; +
+ + + +
+
+
+ ${this.pageName === "congratulations" ? null : html``} + +
+ +
+ ${this.toasts.map(({ id, toast }) => html` + this.removeToast(id)}> + `)} +
+ `; } } diff --git a/apps/pwabuilder/Frontend/src/script/components/android-form.styles.ts b/apps/pwabuilder/Frontend/src/script/components/android-form.styles.ts index e1837ebb0..e39f89881 100644 --- a/apps/pwabuilder/Frontend/src/script/components/android-form.styles.ts +++ b/apps/pwabuilder/Frontend/src/script/components/android-form.styles.ts @@ -39,7 +39,6 @@ export const androidFormStyles = css` #form-layout { flex-grow: 1; display: flex; - overflow: auto; flex-direction: column; } diff --git a/apps/pwabuilder/Frontend/src/script/components/app-package-form-base.styles.ts b/apps/pwabuilder/Frontend/src/script/components/app-package-form-base.styles.ts index 051b5cf88..1ee3ad3b0 100644 --- a/apps/pwabuilder/Frontend/src/script/components/app-package-form-base.styles.ts +++ b/apps/pwabuilder/Frontend/src/script/components/app-package-form-base.styles.ts @@ -31,7 +31,6 @@ export const appPackageFormBaseStyles = css` } #form-layout { - overflow-y: auto; padding: 0em 1.5em 0 1em; } diff --git a/apps/pwabuilder/Frontend/src/script/components/ios-form.styles.ts b/apps/pwabuilder/Frontend/src/script/components/ios-form.styles.ts index c197345da..a5cc43834 100644 --- a/apps/pwabuilder/Frontend/src/script/components/ios-form.styles.ts +++ b/apps/pwabuilder/Frontend/src/script/components/ios-form.styles.ts @@ -28,7 +28,6 @@ export const iosFormStyles = css` #form-layout { flex-grow: 1; display: flex; - overflow: auto; flex-direction: column; } diff --git a/apps/pwabuilder/Frontend/src/script/components/publish-pane.styles.ts b/apps/pwabuilder/Frontend/src/script/components/publish-pane.styles.ts index ff2a83e9d..acddcf041 100644 --- a/apps/pwabuilder/Frontend/src/script/components/publish-pane.styles.ts +++ b/apps/pwabuilder/Frontend/src/script/components/publish-pane.styles.ts @@ -315,6 +315,7 @@ export const publishPaneStyles = css` width: 100%; overflow: auto; position: relative; + padding-top: 12px; } #form-area[data-store="Android"] { diff --git a/apps/pwabuilder/Frontend/src/script/components/publish-pane.ts b/apps/pwabuilder/Frontend/src/script/components/publish-pane.ts index 99802ae92..5cb9c6f97 100644 --- a/apps/pwabuilder/Frontend/src/script/components/publish-pane.ts +++ b/apps/pwabuilder/Frontend/src/script/components/publish-pane.ts @@ -8,7 +8,7 @@ import { } from '../utils/analytics'; import { getURL } from '../services/app-info'; import { generatePackage, Platform } from '../services/publish'; -import { showToast } from '../services/toasts'; +import { showToast } from '../services/toast-service'; import './windows-form'; @@ -341,7 +341,7 @@ export class PublishPane extends LitElement { if (err.message === "Failed to fetch") { title = err.message; - quick_desc = "Our service was unable to package your PWA."; + quick_desc = `We couldn't package your PWA for the store`; hyperlinkText = "Open an issue on GitHub"; hyperlinkUrl = "https://github.com/pwa-builder/PWABuilder/issues/new/choose"; stack_trace += "No stack trace available"; @@ -373,7 +373,7 @@ export class PublishPane extends LitElement { } // Show the error as a toast (rendered outside the publish pane) and close // the dialog so the message isn't hidden behind / overlapping the pane. - showToast(title || "Error generating package", quick_desc, "danger", "exclamation-octagon", 10000, "rtl", hyperlinkText, hyperlinkUrl); + showToast({ title: title || "Error generating package", details: quick_desc, variant: "danger", icon: "exclamation-octagon", countdown: true, hyperlinkText, hyperlinkUrl }); console.error("Error generating package", { title, quick_desc, stack_trace }); this.closePane(); } diff --git a/apps/pwabuilder/Frontend/src/script/components/toast-alert.styles.ts b/apps/pwabuilder/Frontend/src/script/components/toast-alert.styles.ts new file mode 100644 index 000000000..cc0be0a8b --- /dev/null +++ b/apps/pwabuilder/Frontend/src/script/components/toast-alert.styles.ts @@ -0,0 +1,102 @@ +import { css } from 'lit'; + +/** + * Styles for the component: the callout container, scrollable + * body, error text, close button, and the countdown progress ring. + */ +export const toastAlertStyles = css` + :host { + display: block; + pointer-events: auto; + } + + .toast { + position: relative; + overflow: hidden; + display: block; + box-shadow: var(--wa-shadow-m, 0 2px 8px rgba(0, 0, 0, 0.2)); + /* The callout stays neutral; the variant color shows only as the + left accent border so the body keeps the default text color. */ + border-inline-start: 4px solid var(--toast-accent, var(--wa-color-neutral-border, currentColor)); + } + + /* Header row: icon, title, and close button share a single line. */ + .header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + } + + .header-icon { + flex: 0 0 auto; + /* Match the accent border so the icon reinforces the variant color. */ + color: var(--toast-accent, currentColor); + } + + /* Title takes the remaining space, pushing the close button to the end. */ + .title { + flex: 1 1 auto; + min-width: 0; + } + + /* Scrollable body wrapper; caps height so long messages scroll. */ + .body { + max-height: 300px; + overflow-y: auto; + padding: 0 0.5rem 0.5rem; + } + + .error { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.85em; + } + + /* Right-aligned footer holding the optional hyperlink button. */ + .footer { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; + padding: 0 0.5rem 0.5rem; + } + + /* Close button sits at the end of the header row. The auto start margin + keeps it right-aligned even when there's no title; the negative + margins pull it snug into the corner. */ + .close { + flex: 0 0 auto; + margin: -0.25rem -0.25rem -0.25rem auto; + } + + /* When a countdown is active, the close button is wrapped in a progress + ring whose value reflects the remaining time. The ring becomes the + right-aligned header item, and the button is centered within it. */ + .close-ring { + flex: 0 0 auto; + margin-inline-start: auto; + --size: 2rem; + --track-width: 2px; + --indicator-width: 2px; + /* Match the accent color: a strong indicator over a faint track. */ + --indicator-color: var(--toast-accent, var(--wa-color-brand-50)); + --track-color: color-mix(in oklab, var(--toast-accent, var(--wa-color-brand-50)) 20%, transparent); + } + + .close-ring .close { + margin: 0; + } + + /* Shrink the button so it (and its hover background) fits inside the + ring's hole instead of overlapping and hiding the ring. wa-button + sizes itself via ::part(base), so override that rather than the host. */ + .close-ring .close::part(base) { + width: 1.5rem; + height: 1.5rem; + min-height: 0; + padding: 0; + border-radius: 50%; + font-size: 0.8rem; + } + `; diff --git a/apps/pwabuilder/Frontend/src/script/components/toast-alert.ts b/apps/pwabuilder/Frontend/src/script/components/toast-alert.ts new file mode 100644 index 000000000..c4a512e95 --- /dev/null +++ b/apps/pwabuilder/Frontend/src/script/components/toast-alert.ts @@ -0,0 +1,238 @@ +import { LitElement, html, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { ToastEvent, ToastVariant } from '../models/toast-event'; +import { toastAlertStyles } from './toast-alert.styles'; +import '@awesome.me/webawesome/dist/components/callout/callout.js'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import "@awesome.me/webawesome/dist/components/button/button.js"; +import "@awesome.me/webawesome/dist/components/progress-ring/progress-ring.js"; + +/** Auto-dismiss duration, in milliseconds, used when a countdown is enabled. */ +const DEFAULT_DURATION = 10000; + +/** + * Maps a toast variant to its Web Awesome color group. "primary" uses the + * "brand" palette; every other variant shares its name with the palette. The + * resolved group is used to build the accent color (e.g. --wa-color-danger-50) + * applied to the left border and countdown ring. + */ +function variantColorGroup(variant: ToastVariant): string { + return variant === "primary" ? "brand" : variant; +} + +/** + * Renders a single closable toast notification as a . Handles its + * own auto-dismiss countdown (resetting while hovered) and fades out before + * emitting a "toast-dismiss" event so the host can remove it from the DOM. + * + * The countdown is visualized as a wrapped around the close + * button, so the remaining time depletes the ring around the X. When the + * toast's countdown is falsy the toast stays until the user closes it. + */ +@customElement('toast-alert') +export class ToastAlert extends LitElement { + + static styles = [toastAlertStyles]; + + /** The toast to display. */ + @property({ attribute: false }) toast!: ToastEvent; + + /** + * Current countdown progress ring value (0-100). Reactive so the ring + * re-renders automatically as the countdown ticks. Starts full. + */ + @state() private countdownValue = 100; + + /** Total auto-dismiss duration, in ms. Resolved once in firstUpdated. */ + private duration = 0; + + /** Handle for the pending auto-dismiss timeout. */ + private dismissTimer = 0; + + /** Handle for the countdown ring's requestAnimationFrame loop. */ + private rafId = 0; + + /** Remaining time, in ms, before auto-dismissal (survives hover resets). */ + private remaining = 0; + + /** Timestamp of the most recent timer (re)start, used to compute elapsed time. */ + private resumedAt = 0; + + /** Whether an auto-dismiss countdown ring should be shown and animated. */ + private get showCountdown(): boolean { + return this.duration > 0; + } + + /** @inheritdoc */ + firstUpdated(): void { + this.duration = this.toast.countdown ? DEFAULT_DURATION : 0; + if (this.duration <= 0) { + return; + } + + this.remaining = this.duration; + this.startTimer(); + } + + /** @inheritdoc */ + disconnectedCallback(): void { + super.disconnectedCallback(); + window.clearTimeout(this.dismissTimer); + cancelAnimationFrame(this.rafId); + } + + /** Renders the toast content. */ + render(): TemplateResult { + const toast = this.toast; + const showCountdown = !!toast.countdown; + + // Rather than tint the whole callout with the variant color (which made + // the body text hard to read for "danger" etc.), we keep the callout + // neutral and expose the variant color only as an accent used for the + // left border and the countdown ring. + const accent = `var(--wa-color-${variantColorGroup(toast.variant)}-50)`; + + // The close button doubles as the countdown indicator: when a countdown + // is active it's wrapped in a progress ring whose value tracks the + // remaining time, depleting from full as the toast ages. + const closeButton = html` + this.dismiss()}> + + + `; + + return html` + this.pauseTimer()} + @mouseleave=${() => this.resumeTimer()}> +
+ ${toast.icon ? html`` : nothing} + ${toast.title ? html`${toast.title}` : nothing} + ${showCountdown + ? html`${closeButton}` + : closeButton} +
+ ${this.renderBody()} + ${this.renderFooter()} +
+ `; + } + + /** + * Builds the details shown below the header. Returns nothing when there are + * no details so the toast collapses to just the header (and footer). + */ + private renderBody(): TemplateResult | typeof nothing { + const { details } = this.toast; + + if (!details) { + return nothing; + } + + return html`
${this.renderDetails(details)}
`; + } + + /** + * Builds the right-aligned footer containing the optional hyperlink button. + * Returns nothing when there's no hyperlink. + */ + private renderFooter(): TemplateResult | typeof nothing { + const toast = this.toast; + + if (!toast.hyperlinkText || !toast.hyperlinkUrl) { + return nothing; + } + + return html` + + `; + } + + /** + * Renders the details. Errors are shown as preformatted text so the stack + * trace keeps its line breaks; Lit escapes the interpolated text for us. + */ + private renderDetails(details: string | Error): TemplateResult | typeof nothing { + if (!details) { + return nothing; + } + + if (details instanceof Error) { + // V8 stacks already start with the message, so only prepend the + // message when the stack is missing or doesn't already include it. + const stack = details.stack ?? ''; + const errorText = stack.includes(details.message) + ? stack + : [details.message, stack].filter(Boolean).join('\n\n'); + return html`
${errorText}
`; + } + + return html`${details}`; + } + + /** Fades the toast out, then asks the host to remove it. */ + private dismiss(): void { + window.clearTimeout(this.dismissTimer); + this.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: "forwards" }) + .finished.then(() => { + this.dispatchEvent(new CustomEvent("toast-dismiss", { bubbles: true, composed: true })); + }); + } + + /** Starts (or restarts) the auto-dismiss countdown from the remaining time. */ + private startTimer(): void { + this.resumedAt = Date.now(); + this.dismissTimer = window.setTimeout(() => this.dismiss(), this.remaining); + if (this.showCountdown) { + this.rafId = requestAnimationFrame(() => this.updateCountdownRing()); + } + } + + /** + * Pauses and resets the countdown while the pointer is over the toast, so + * it won't auto-dismiss out from under the user. The ring is restored to + * full; the timer resumes from the full duration on mouse leave. + */ + private pauseTimer(): void { + window.clearTimeout(this.dismissTimer); + cancelAnimationFrame(this.rafId); + this.remaining = this.duration; + this.countdownValue = 100; + } + + /** Resumes the countdown from the full duration when the pointer leaves. */ + private resumeTimer(): void { + if (this.showCountdown) { + this.startTimer(); + } + } + + /** + * Drives the progress ring each frame to reflect the remaining time. The + * ring depletes from full as the toast ages; the loop stops once the + * countdown reaches zero (the dismiss timeout handles removal). + */ + private updateCountdownRing(): void { + const elapsed = Date.now() - this.resumedAt; + const currentRemaining = Math.max(0, this.remaining - elapsed); + const remainingFraction = this.duration > 0 ? currentRemaining / this.duration : 0; + this.countdownValue = remainingFraction * 100; + + if (currentRemaining > 0) { + this.rafId = requestAnimationFrame(() => this.updateCountdownRing()); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "toast-alert": ToastAlert; + } +} diff --git a/apps/pwabuilder/Frontend/src/script/components/windows-form.styles.ts b/apps/pwabuilder/Frontend/src/script/components/windows-form.styles.ts index 5f7741aa3..28b495ebc 100644 --- a/apps/pwabuilder/Frontend/src/script/components/windows-form.styles.ts +++ b/apps/pwabuilder/Frontend/src/script/components/windows-form.styles.ts @@ -32,7 +32,6 @@ export const windowsFormStyles = css` #form-layout { flex-grow: 1; display: flex; - overflow: auto; flex-direction: column; } diff --git a/apps/pwabuilder/Frontend/src/script/models/toast-event.ts b/apps/pwabuilder/Frontend/src/script/models/toast-event.ts new file mode 100644 index 000000000..08e040a46 --- /dev/null +++ b/apps/pwabuilder/Frontend/src/script/models/toast-event.ts @@ -0,0 +1,52 @@ +/** + * The visual style of a toast notification. "primary" is mapped to Web Awesome's + * "brand" variant at render time. + */ +export type ToastVariant = "primary" | "success" | "neutral" | "warning" | "danger"; + +/** + * The name of the window event dispatched by showToast and listened to by the + * host (app-index) that renders toasts. + */ +export const SHOW_TOAST_EVENT_NAME = "show-toast"; + +/** + * Describes a single toast notification. Passed as the detail of the + * "show-toast" window event and consumed by the component. + */ +export interface ToastEvent { + /** The title of the toast, shown in bold. If empty, no title is shown. */ + title: string; + /** + * The details of the toast. If an Error is supplied, its message and stack + * trace are rendered. If empty, no details are shown. + */ + details: string | Error; + /** The visual variant of the toast. */ + variant: ToastVariant; + /** An icon name to show in the toast, or null/empty for no icon. */ + icon: string | null; + /** + * Whether the toast auto-dismisses after the default duration, visualized + * by the countdown progress ring. When falsy (false, null, or undefined), + * the toast stays visible until the user closes it. + */ + countdown?: boolean | null; + /** + * The text of an optional hyperlink rendered after the body. Only shown when + * both hyperlinkText and hyperlinkUrl are supplied. + */ + hyperlinkText?: string | null; + /** + * The URL of an optional hyperlink rendered after the body. Only shown when + * both hyperlinkText and hyperlinkUrl are supplied. + */ + hyperlinkUrl?: string | null; +} + +declare global { + interface WindowEventMap { + /** Fired by showToast to request that a new toast be displayed. */ + [SHOW_TOAST_EVENT_NAME]: CustomEvent; + } +} diff --git a/apps/pwabuilder/Frontend/src/script/pages/app-report.api.ts b/apps/pwabuilder/Frontend/src/script/pages/app-report.api.ts index e25ca91c5..6bf1a2fd9 100644 --- a/apps/pwabuilder/Frontend/src/script/pages/app-report.api.ts +++ b/apps/pwabuilder/Frontend/src/script/pages/app-report.api.ts @@ -2,7 +2,7 @@ import type { Validation } from '../models/validation'; import { env } from '../utils/environment'; import { getHeaders } from '../utils/platformTrackingHeaders'; import { TestResult } from '../utils/interfaces'; -import { showToast } from '../services/toasts'; +import { showToast } from '../services/toast-service'; export type ReportAudit = { manifestValidations: Validation[], @@ -180,7 +180,17 @@ export async function enqueueAnalysis(site: string): Promise { const analysisId = await enqueueResult.text(); return analysisId; } catch (error) { - showToast("Unable to analyze your web app", `${error}`.substring(0, 100), "danger", "exclamation-octagon"); + const linkText = "Open an issue on GitHub"; + const linkUrl = "https://github.com/pwa-builder/PWABuilder/issues/new/choose"; + showToast({ + title: "Unable to analyze your web app", + details: error instanceof Error ? error : `${error}`, + variant: "danger", + icon: "exclamation-octagon", + countdown: true, + hyperlinkText: linkText, + hyperlinkUrl: linkUrl + }); throw error; } } @@ -206,7 +216,7 @@ export async function getAnalysis(id: string): Promise { const jsonResult = await fetchResult.json(); return jsonResult; } catch (error) { - showToast("Unable to check the status of your web app's PWA features", `${error}`.substring(0, 100), "danger", "exclamation-octagon"); + showToast({ title: "Unable to check the status of your web app's PWA features", details: `${error}`.substring(0, 100), variant: "danger", icon: "exclamation-octagon" }); throw error; } } diff --git a/apps/pwabuilder/Frontend/src/script/services/toast-service.ts b/apps/pwabuilder/Frontend/src/script/services/toast-service.ts new file mode 100644 index 000000000..4b60532c8 --- /dev/null +++ b/apps/pwabuilder/Frontend/src/script/services/toast-service.ts @@ -0,0 +1,12 @@ +import { SHOW_TOAST_EVENT_NAME, ToastEvent } from '../models/toast-event'; + +/** + * Requests that a toast notification be shown by dispatching a "show-toast" + * event on the window. The host (app-index) listens for this event and renders + * the toast via the component, so callers don't need a reference + * to any DOM node. + * @param toast The toast to display. + */ +export function showToast(toast: ToastEvent): void { + window.dispatchEvent(new CustomEvent(SHOW_TOAST_EVENT_NAME, { detail: toast })); +} diff --git a/apps/pwabuilder/Frontend/src/script/services/toasts.ts b/apps/pwabuilder/Frontend/src/script/services/toasts.ts deleted file mode 100644 index 8ed4c085a..000000000 --- a/apps/pwabuilder/Frontend/src/script/services/toasts.ts +++ /dev/null @@ -1,131 +0,0 @@ -import '@awesome.me/webawesome/dist/components/callout/callout.js'; -import '@awesome.me/webawesome/dist/components/icon/icon.js'; - - - -/** - * Returns the fixed-position container that stacks toast notifications in the - * top-right corner, creating it on first use. Replaces Shoelace's built-in toast - * stack, which moved to Web Awesome Pro. - */ -let toastStack: HTMLElement | null = null; -function getToastStack(): HTMLElement { - if (toastStack && document.body.contains(toastStack)) { - return toastStack; - } - toastStack = document.createElement('div'); - Object.assign(toastStack.style, { - position: 'fixed', - top: '1rem', - insetInlineEnd: '1rem', - display: 'flex', - flexDirection: 'column', - gap: '0.5rem', - zIndex: '1000', - maxWidth: 'min(28rem, calc(100vw - 2rem))', - pointerEvents: 'none', - }); - document.body.appendChild(toastStack); - return toastStack; -} - -/** - * Shows a closable toast notification in the browser. - * @param title The title of the toast, shown in bold. If empty, not title will be shown. - * @param details The details of the toast, shown in plain text. If empty, no details will be shown. - * @param variant The variant of toast to show. - * @param icon An icon name to show in the toast. If null or empty, no icon will be shown. - * @param duration A duration specifying how long the toast will be shown before automatically dismissing. Use 0 to prevent the toast from automatically dismissing. - * @param countdown A countdown UI direction. A progress bar will be shown either counting down from right to left ("rtl") or left to right ("ltr"), indicating the automatic dismissal. Use "none" to hide the progress bar. - * @param hyperlinkText The text of an optional hyperlink rendered after the body. Only shown when both hyperlinkText and hyperlinkUrl are supplied. - * @param hyperlinkUrl The URL of an optional hyperlink rendered after the body. Only shown when both hyperlinkText and hyperlinkUrl are supplied. - * @returns The toast HTML element. - */ -export async function showToast(title: string, details: string, variant: "primary" | "success" | "neutral" | "warning" | "danger", icon: string | null, duration: number = 10000, countdown: "rtl" | "ltr" | "none" = "rtl", hyperlinkText: string | null = null, hyperlinkUrl: string | null = null): Promise { - // Web Awesome renders inline alerts with ; "primary" became "brand". - const waVariant = variant === "primary" ? "brand" : variant; - - const toast = document.createElement("wa-callout"); - toast.setAttribute("variant", waVariant); - toast.setAttribute("role", "status"); - Object.assign(toast.style, { - position: "relative", - overflow: "hidden", - pointerEvents: "auto", - boxShadow: "var(--wa-shadow-m, 0 2px 8px rgba(0, 0, 0, 0.2))", - }); - - let body = ''; - if (icon) { - body += ``; - } - if (title) { - body += `${title}`; - if (details) { - body += '
'; - } - } - if (details) { - body += details; - } - if (hyperlinkText && hyperlinkUrl) { - body += `
${hyperlinkText}`; - } - - toast.innerHTML = body; - getToastStack().appendChild(toast); - - const dismiss = () => { - toast.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: "forwards" }) - .finished.then(() => toast.remove()); - }; - - if (duration > 0) { - let barAnimation: Animation | null = null; - if (countdown !== "none") { - const bar = document.createElement("div"); - Object.assign(bar.style, { - position: "absolute", - bottom: "0", - insetInlineStart: "0", - height: "3px", - width: "100%", - background: "currentColor", - opacity: "0.4", - transformOrigin: countdown === "rtl" ? "right" : "left", - }); - toast.appendChild(bar); - barAnimation = bar.animate([{ transform: "scaleX(1)" }, { transform: "scaleX(0)" }], { duration, fill: "forwards" }); - } - - // Track the remaining time so hovering can pause the countdown: the user - // shouldn't lose a toast while they're still reading it. - let dismissTimer = 0; - let remaining = duration; - let resumedAt = Date.now(); - - const startTimer = () => { - resumedAt = Date.now(); - dismissTimer = window.setTimeout(dismiss, remaining); - }; - - const pauseTimer = () => { - window.clearTimeout(dismissTimer); - remaining -= Date.now() - resumedAt; - barAnimation?.pause(); - }; - - const resumeTimer = () => { - if (remaining > 0) { - barAnimation?.play(); - startTimer(); - } - }; - - toast.addEventListener("mouseenter", pauseTimer); - toast.addEventListener("mouseleave", resumeTimer); - startTimer(); - } - - return toast; -} \ No newline at end of file