diff --git a/src/lib/toast.ts b/src/lib/toast.ts index d75d2eb..4d324bb 100644 --- a/src/lib/toast.ts +++ b/src/lib/toast.ts @@ -1,7 +1,5 @@ let styleInjected = false; -let toastCount = 0; -const GAP = 10; -const TOAST_HEIGHT = 40; +let stack: HTMLDivElement | null = null; const DEFAULT_TIMEOUT = 2000; function injectStyles() { @@ -10,14 +8,25 @@ function injectStyles() { const style = document.createElement("style"); style.id = "shellular-toast-styles"; style.textContent = ` - .shellular-toast { + .shellular-toast-stack { position: fixed; - bottom: 24px; left: 50%; transform: translateX(-50%); + bottom: calc(var(--keyboard-height, 0px) + var(--sab, 0px) + 24px); + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 10px; + width: max-content; + max-width: min(calc(100vw - 32px), 420px); + z-index: 9999; + pointer-events: none; + } + .shellular-toast { display: flex; align-items: center; gap: 8px; + max-width: 100%; padding: 10px 18px; border-radius: 10px; background: var(--popup-background); @@ -25,9 +34,13 @@ function injectStyles() { color: var(--primary-text); font-size: 13px; font-weight: 500; - white-space: nowrap; + text-align: left; + /* A toast carries host names, file paths and raw CLI errors, so it has + to be allowed to wrap. It used to be nowrap with no max-width, which + clipped anything wider than the screen at BOTH ends, because the + element is centred. */ + overflow-wrap: anywhere; box-shadow: 0 8px 32px var(--shadow-color); - z-index: 9999; pointer-events: none; animation: shellularToastIn 180ms ease-out both; } @@ -35,46 +48,57 @@ function injectStyles() { animation: shellularToastOut 150ms ease-in both; } .shellular-toast .icon-check { + flex-shrink: 0; font-size: 14px; color: var(--success); } @keyframes shellularToastIn { from { opacity: 0; - transform: translateX(-50%) translateY(8px) scale(0.95); + transform: translateY(8px) scale(0.95); } to { opacity: 1; - transform: translateX(-50%) translateY(0) scale(1); + transform: translateY(0) scale(1); } } @keyframes shellularToastOut { from { opacity: 1; - transform: translateX(-50%) translateY(0) scale(1); + transform: translateY(0) scale(1); } to { opacity: 0; - transform: translateX(-50%) translateY(8px) scale(0.95); + transform: translateY(8px) scale(0.95); } } `; document.head.appendChild(style); } +function getStack(): HTMLDivElement { + if (stack?.isConnected) return stack; + stack = document.createElement("div"); + stack.className = "shellular-toast-stack"; + document.body.appendChild(stack); + return stack; +} + export default function toast(msg: string, timeoutMs?: number): void { injectStyles(); const el = document.createElement("div"); el.className = "shellular-toast"; - el.innerHTML = `${msg}`; - - const index = toastCount++; + const icon = document.createElement("span"); + icon.className = "icon-check"; + icon.setAttribute("aria-hidden", "true"); + el.append(icon, document.createTextNode(msg)); - el.style.bottom = `calc(var(--keyboard-height, 0px) + var(--sab, 0px) + ${ - 24 + index * (TOAST_HEIGHT + GAP) - }px)`; - document.body.appendChild(el); + // Stacking is the container's job. Doing it with a per-toast offset needed a + // fixed toast height, which is what forced the single line in the first + // place, and its index counter also let a new toast land on the slot of one + // that was still animating out. + getStack().appendChild(el); const duration = timeoutMs ?? DEFAULT_TIMEOUT; @@ -82,7 +106,10 @@ export default function toast(msg: string, timeoutMs?: number): void { el.classList.add("shellular-toast-out"); el.addEventListener("animationend", () => { el.remove(); - toastCount--; + if (stack && !stack.childElementCount) { + stack.remove(); + stack = null; + } }); }, duration); }