feat(components): add ol-toast and ol-banner web components#12868
Conversation
New Lit components for the notification UI consolidation effort: - ol-toast + ol-toast-region: Sonner-style stacked toasts (shadow DOM). Bottom-center stack, slide-up enter via data-mounted, hover/focus expands the stack and pauses dismiss timers, aria-live announcements, prefers-reduced-motion support. showToast() helper accepts a string, template element, or nodes - never parses runtime strings as HTML. - ol-banner: callout-style announcement banner (light DOM, ol-button pattern) with neutral/success/warning/danger variants and outlined/plain appearances. Persistence-agnostic: fires ol-banner-dismiss; site glue in js/banner/index.js POSTs /hide_banner; templates guard rendering on the dismissal cookie. - Document shadow-vs-light DOM decision rule in web-components.md.
…e; default timeout 4000ms - resumeTimer() now no-ops while the parent ol-toast-region is expanded, so moving the pointer between toasts (mouseleave on the one being left) can't restart its timer mid-hover. Region exposes a public 'expanded' getter for this. - Remove the document-level Escape handler: it closed every open toast at once and hijacked Escape presses meant for dropdowns/inputs/dialogs. - Raise the default auto-dismiss timeout from 2500ms to 4000ms.
… icons Toast/region: - Replace the Sonner-style scaled stack with a plain bottom-anchored vertical list: new toasts slide up from below, older ones slide up to make room. Drops peek/scale/depth, the collapse-vs-expand distinction, and the MAX_VISIBLE fade-out. - Keep hover/focus timer-pause; guard _collapse so it no longer resumes timers (and snaps the list) while focus/pointer is still inside. - Announce the message one frame after mount (empty live region first) so screen readers read it as a mutation; re-layout on the resulting resize. - Rename the default close reason 'dismiss' -> 'programmatic'. Icons (toast + banner): - Bigger stroke-based close X matching ol-dialog (20px in a 28px target). - Danger/error: X -> exclamation, so it no longer reads as a close action. - Warning: a self-contained filled, rounded-corner caution-triangle (yield) instead of the circular badge, distinct in shape from danger. - Thicker (stroke-width 4), friendly strokes with more dot/stem spacing in the i and ! glyphs.
|
@Sadashii Thanks for the review and the side-by-side screenshots, they made each issue easy to see. Walking through your points: Toast overlap ✅ — Rewrote the stacking from the Sonner-style depth/scale into a flat, bottom-anchored list, so toasts can no longer overlap regardless of their relative sizes. (Under the hood:
Snap-back on dismiss ✅ — Fixed. There's no longer an expand/collapse relayout to snap back, and a new Too small
Rename And thanks for the note on the icon-slot, glad you liked it. |
|
@Sadashii if you could review again, it would be appreciated. It's a low risk PR since the components are not implemented outside of the demo page (design.html) so would love to get it in and then have your help in vetting in the product. We can continue iterating on the APIs and design as we see the components in context. |
|
@lokesh Looks good to me. I think these two additions should complete this for an initial draft and we can start updating the usages with these components, updating the API as needed. For now, the design and API look ideal as well to me.
Diff, index baa469f911..6e4e83d58e 100644
--- a/openlibrary/components/lit/OlToast.js
+++ b/openlibrary/components/lit/OlToast.js
@@ -150,9 +150,14 @@ export class OlToast extends LitElement {
:host {
transition: none;
}
+ .toast__progress {
+ display: none;
+ }
}
.toast {
+ position: relative;
+ overflow: hidden;
display: flex;
align-items: flex-start;
gap: var(--spacing-inline-md);
@@ -172,6 +177,19 @@ export class OlToast extends LitElement {
0 2px 4px 0 var(--icon-link-grey);
}
+ .toast__progress {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background-color: currentColor;
+ opacity: 0.35;
+ transform-origin: left;
+ transform: scaleX(var(--toast-progress-scale, 1));
+ transition: transform var(--toast-progress-time, 0ms) linear;
+ }
+
:host([data-stacked]) .toast {
width: 100%;
}
@@ -186,7 +204,7 @@ export class OlToast extends LitElement {
flex-shrink: 0;
width: 20px;
height: 20px;
- margin-top: 1px; /* optically center against the first text
line */
+ margin-top: 4px; /* Centered against the first line of text
*/
border-radius: 50%;
color: var(--white);
}
@@ -210,6 +228,12 @@ export class OlToast extends LitElement {
content gets the same treatment */
font-size: var(--font-size-body-large);
font-weight: 500;
+
+ /* Align single-line text to center of 28px close button hei
ght */
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 28px;
}
.toast__message {
@@ -309,6 +333,15 @@ export class OlToast extends LitElement {
requestAnimationFrame(() => {
this.setAttribute('data-mounted', '');
this._announce = true;
+
+ // Start the progress bar transition after the element i
s mounted and visible
+ if (!this.persistent && this._timerId) {
+ requestAnimationFrame(() => {
+ if (this._closing || this._timerId === null) ret
urn;
+ this.style.setProperty('--toast-progress-scale',
'0');
+ this.style.setProperty('--toast-progress-time',
`${this._remainingMs}ms`);
+ });
+ }
});
});
}
@@ -341,7 +374,11 @@ export class OlToast extends LitElement {
pauseTimer() {
if (this._timerId) {
this._clearTimer();
- this._remainingMs -= Date.now() - this._timerStartedAt;
+ const elapsed = Date.now() - this._timerStartedAt;
+ this._remainingMs = Math.max(0, this._remainingMs - elapsed)
;
+ const fraction = this._remainingMs / this.timeout;
+ this.style.setProperty('--toast-progress-scale', String(frac
tion));
+ this.style.setProperty('--toast-progress-time', '0ms');
}
}
@@ -356,6 +393,16 @@ export class OlToast extends LitElement {
if (this.closest('ol-toast-region')?.expanded) return;
this._timerStartedAt = Date.now();
this._timerId = setTimeout(() => this.close('timeout'), Math.max
(0, this._remainingMs));
+
+ // Only schedule progress animation immediately if the element h
as already mounted.
+ // If not mounted yet, connectedCallback's requestAnimationFrame
chain will start it.
+ if (this.hasAttribute('data-mounted')) {
+ requestAnimationFrame(() => {
+ if (this._closing || this._timerId === null) return;
+ this.style.setProperty('--toast-progress-scale', '0');
+ this.style.setProperty('--toast-progress-time', `${this.
_remainingMs}ms`);
+ });
+ }
}
/**
@@ -413,6 +460,7 @@ export class OlToast extends LitElement {
aria-label=${this.labelClose}
@click=${() => this.close('close-button')}
>${OlToast._closeIcon}</button>
+ ${!this.persistent ? html`<div class="toast__progress"><
/div>` : ''}
</div>
`;
}
diff --git a/static/css/components/ol-banner.css b/static/css/components/
ol-banner.css
index 53bb57e527..448a32dd7f 100644
--- a/static/css/components/ol-banner.css
+++ b/static/css/components/ol-banner.css
@@ -88,7 +88,7 @@ ol-banner > .ol-banner__icon {
flex-shrink: 0;
width: 20px;
height: 20px;
- margin-top: 1px; /* optically center against the first text line */
+ margin-top: 4px; /* optically center against the first text line */
border-radius: var(--border-radius-circle);
background-color: var(--banner-accent);
color: var(--white);
@@ -102,6 +102,10 @@ ol-banner:not([hydrated]) > [slot="icon"] {
ol-banner > .ol-banner__content {
flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 28px;
}
/* Reserve the close button's column pre-hydration so it doesn't reflow```
|
|
The ol-banner could probably support an argument that goes to the data-ol-link-track on the dismiss icon so banner dismisses can be tracked easily. Note to self, need to update #12910 to use this. |






Part of the notification UI consolidation effort.
Feature: two new Lit web components, demoed on
/developers/designonly — no production call sites yet.ol-toast+ol-toast-region— Sonner-style stacked toasts. Bottom-center fixed stack; new toasts slide up while older ones scale back behind; hover/focus expands the stack and pauses dismiss timers.showToast()accepts a string, a<template>element, or nodes.ol-banner— callout-style announcement banner withvariant(neutral/success/warning/danger) andappearance(outlined/plain), built-in icons matching the toast treatment.Technical
static/css/components/ol-banner.css→ol-components.css, with pre-hydration placeholders to avoid layout shift). This decision rule is now documented indocs/ai/web-components.md.prefers-reduced-motionsupported). Banners intentionally have no entrance animation.ol-banneris persistence-agnostic: it only firesol-banner-dismiss. OL-specific plumbing: templates guard rendering on the dismissal cookie, and a site-level listener (js/banner/index.js) POSTs to/hide_banner(also migrated from jQuery to fetch).label-close(attribute override pattern); all content arrives translated.Testing
make lit-components css(ordocker compose exec web make lit-components css), restart web./developers/design#toast— add toasts; hover the stack to expand (timers stay paused while expanded, even when moving between toasts); checkprefers-reduced-motion./developers/design#banner— variants/appearances/custom icon; dismiss the cookie-persisted demo, reload (server no longer renders it), reset to restore.Screenshot
toast-banner.mp4
Stakeholders
@cdrini @mekarpeles