import { html, LitElement, css, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import "@supersoniks/concorde/core/components/ui/toast/types"; import { ToastStatus } from "@supersoniks/concorde/core/components/ui/toast/types"; import { customScroll } from "@supersoniks/concorde/core/components/ui/_css/scroll"; import { HTML } from "@supersoniks/concorde/utils"; const icon: Record = { warning: "warning-circled-outline", success: "check-circled-outline", error: "warning-circled-outline", info: "info-empty", }; const tagName = "sonic-toast-item"; @customElement(tagName) export class SonicToastItem extends LitElement { static styles = [ customScroll, css` * { box-sizing: border-box; } :host { display: block; pointer-events: auto; position: relative; --sc-toast-status-color: transparent; --sc-toast-color: var(--sc-base-content, #000); --sc-toast-bg: var(--sc-base, #fff); --sc-toast-rounded: var(--sc-rounded-md); --sc-toast-shadow: var(--sc-shadow-lg); } .fixed-area { position: fixed; bottom: 1.25rem; right: 1.25rem; z-index: 10000; display: flex; flex-direction: column-reverse; } .sonic-toast { position: relative; pointer-events: auto; overflow: hidden; line-height: 1.25; color: var(--sc-toast-color); box-shadow: var(--sc-toast-shadow); border-radius: var(--sc-toast-rounded); background: var(--sc-toast-bg); } .sonic-toast-content { padding: 1em 2.5rem 1em 1em; display: flex; gap: 0.5rem; overflow: auto; position: relative; } .sonic-toast-text { align-self: center; margin-top: auto; margin-bottom: auto; max-width: 70ch; line-height: 1.2; } ::slotted(a:not(.btn)), .sonic-toast-text a { color: inherit !important; text-decoration: underline !important; text-underline-offset: 0.15rem; } ::slotted(:is(p, ul, ol, hr, h1, h2, h3, h4, h5, h6)), .sonic-toast-text :is(p, ul, ol, hr, h1, h2, h3, h4, h5, h6) { margin: 0 0 0.3em !important; } ::slotted(li), .sonic-toast-text li { margin-bottom: 0.15em !important; } ::slotted(:is(p, ul, ol, hr, h1, h2, h3, h4, h5, h6):last-child), .sonic-toast-text > :is(p, ul, ol, hr, h1, h2, h3, h4, h5, h6):last-child { margin-bottom: 0 !important; } /*BUTTON CLOSE*/ .sonic-toast-close { all: unset; position: absolute; z-index: 4; pointer-events: initial; right: 0.5em; top: 0.5em; width: 1.5rem; height: 1.5rem; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; text-align: center; opacity: 0.5; background: rgba(0, 0, 0, 0); } .sonic-toast-close:focus, .sonic-toast-close:hover { opacity: 1; background: rgba(0, 0, 0, 0.075); } .sonic-toast-close svg { width: 1rem; height: 1rem; object-fit: contain; object-position: center center; } /*Title*/ .sonic-toast-title { font-weight: bold; font-size: 1.15rem; margin: 0.15em 0 0.25em; line-height: 1.2; } /*STATUS*/ .success { --sc-toast-status-color: var( --sc-success, var(--sc-base-content, #000) ); } .error { --sc-toast-status-color: var(--sc-danger, var(--sc-base-content, #000)); } .warning { --sc-toast-status-color: var( --sc-warning, var(--sc-base-content, #000) ); } .info { --sc-toast-status-color: var(--sc-info, var(--sc-base-content, #000)); } .success, .error, .info, .warning { border-top: 3px solid var(--sc-toast-status-color, currentColor); } .sonic-toast:before { content: ""; display: block; position: absolute; left: 0; top: 0; right: 0; bottom: 0; opacity: 0.05; pointer-events: none; transition: 0.2s; border-radius: var(--sc-toast-rounded); background-color: var(--sc-toast-status-color); } .sonic-toast:hover:before { opacity: 0.025; } .info .sonic-toast-icon, .error .sonic-toast-icon, .success .sonic-toast-icon, .warning .sonic-toast-icon { color: var(--sc-toast-status-color, currentColor); } .sonic-toast-icon { position: sticky; top: 0; } .ghost { opacity: 0.85; pointer-events: none; } `, ]; @property({ type: String }) title = ""; @property({ type: String }) id = ""; @property({ type: String }) text = ""; @property({ type: String }) status: ToastStatus = ""; @property({ type: Boolean }) ghost = false; @property({ type: Boolean }) preserve = false; @property({ type: Boolean }) dismissForever = false; @property({ type: String }) maxHeight = "10rem"; @state() visible = true; @state() private _titleId: string = ""; // @queryAssignedElements({flatten: true, slot: main"}) // textSlot!: HTMLElement[]; /** * Retourne le rôle ARIA approprié selon le statut du toast * - "alert" pour les erreurs (messages critiques) - implique déjà aria-live="assertive" * - "status" pour les autres (messages informatifs) */ getAriaRole(): "alert" | "status" { return this.status === "error" ? "alert" : "status"; } /** * Retourne la valeur aria-live appropriée selon le statut * - null pour les erreurs car role="alert" implique déjà aria-live="assertive" * - "polite" pour les autres (peut attendre) */ getAriaLive(): "polite" | null { return this.status === "error" ? null : "polite"; } /** * Génère et mémorise un ID unique pour le titre du toast */ getTitleId(): string { if (this._titleId) { return this._titleId; } this._titleId = this.id ? `toast-title-${this.id}` : `toast-title-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; return this._titleId; } /** * Génère un label ARIA descriptif pour le bouton de fermeture */ getCloseButtonLabel(): string { const statusLabels: Record = { error: "Fermer le message d'erreur", warning: "Fermer l'avertissement", success: "Fermer le message de succès", info: "Fermer l'information", }; const statusLabel = this.status ? statusLabels[this.status] || "Fermer la notification" : "Fermer la notification"; return statusLabel; } render() { // check if the toast is dismissed if (this.dismissForever) { const dismissed = localStorage.getItem("sonic-toast-dismissed") || "{}"; const dismissedObj = JSON.parse(dismissed); if (dismissedObj[this.id]) { return nothing; } } if (!this.visible) { return nothing; } const titleId = this.title ? this.getTitleId() : undefined; // Extrait un label pour aria-label si pas de titre, en essayant de couper proprement const ariaLabel = !this.title && this.text ? this.text.length > 100 ? this.text.substring(0, 100).replace(/\s+\S*$/, "") + "..." : this.text : undefined; const ariaLive = this.getAriaLive(); return html`
${this.status && html``}
${this.title ? html`
${this.title}
` : ""} ${this.text ? unsafeHTML(this.text) : ""}
${!this.preserve ? this.autoHide() : ""}
`; } hide() { if (!HTML.getClosestElement(this, "sonic-toast")) { this.visible = false; } if (this.dismissForever) { // set in local Storage an Object with the id as key and if it's dismissed as value const dismissed = localStorage.getItem("sonic-toast-dismissed") || "{}"; const dismissedObj = JSON.parse(dismissed); dismissedObj[this.id] = true; localStorage.setItem( "sonic-toast-dismissed", JSON.stringify(dismissedObj) ); } this.dispatchEvent(new CustomEvent("hide", { bubbles: true })); } show() { this.visible = true; } autoHide() { setTimeout(() => { this.hide(); }, 6000); } }