Skip to content

feat(components): add ol-toast and ol-banner web components#12868

Merged
mekarpeles merged 3 commits into
internetarchive:masterfrom
lokesh:banner
Jun 11, 2026
Merged

feat(components): add ol-toast and ol-banner web components#12868
mekarpeles merged 3 commits into
internetarchive:masterfrom
lokesh:banner

Conversation

@lokesh

@lokesh lokesh commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Part of the notification UI consolidation effort.

Feature: two new Lit web components, demoed on /developers/design only — 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 with variant (neutral/success/warning/danger) and appearance (outlined/plain), built-in icons matching the toast treatment.

Technical

  • Toast = shadow DOM (JS-instantiated, can't FOUC). Banner = light DOM (server-rendered chrome; styled at first paint via static/css/components/ol-banner.cssol-components.css, with pre-hydration placeholders to avoid layout shift). This decision rule is now documented in docs/ai/web-components.md.
  • Motion is transition-driven (transform/opacity only, prefers-reduced-motion supported). Banners intentionally have no entrance animation.
  • ol-banner is persistence-agnostic: it only fires ol-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).
  • No runtime string is ever parsed as HTML; toast/banner content comes from attributes (text) or server-rendered markup.
  • i18n: components own no copy except label-close (attribute override pattern); all content arrives translated.

Testing

  1. make lit-components css (or docker compose exec web make lit-components css), restart web.
  2. /developers/design#toast — add toasts; hover the stack to expand (timers stay paused while expanded, even when moving between toasts); check prefers-reduced-motion.
  3. /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
image image

Stakeholders

@cdrini @mekarpeles

@lokesh lokesh requested a review from Sadashii June 6, 2026 06:49
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.
@lokesh lokesh marked this pull request as ready for review June 6, 2026 07:02
…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.
@Sadashii

Sadashii commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Looks beautiful! Definitely a great step in the modernization efforts.

A few observations / suggestions to polish this first-version even more.

From a patron's view:

Stacking of toats with different sizes causes weird overlap:

  • Case 1 (Smaller toast in front, larger toast behind): The larger toast behind extends past the top, which can look a bit awkward:
    image
    image
  • Case 2 (Larger toast in front, smaller toast behind): The larger toast in front completely overlaps and hides the smaller/older toast behind it:
    image
  • Suggestion: We might want to adjust the stack positioning logic so that older notifications are offset appropriately based on height differences, ensuring they are never fully obscured.

Split-second collapse of hovered toasts when dismissing one

  • When hovering over a stack of multiple toasts, clicking "close" on one of them causes the entire list to briefly collapse and then snap back open. The stack should probably stay expanded as long as the user's mouse is still hovering over the notification area.

Font size feels too small

  • For banner, the font size and the colored icons (especially info/warning, while the colors do a great job, icon is difficult) feel too small to see clearly. Same thing with the dismission cross (especially on toast) - it's bounding box is a little too small making dismissing a small task of it's own. Might want to increase it to make the texts/icons/dismiss more prominent and easy.

From a contributor's view:

1. Rename dismiss event reason

  • The ol-toast-close event currently fires with the reasons close-button and dismiss. Having both is slightly confusing since a user click also "dismisses" the toast. Probably rename dismiss to programmatic (or api) to clearly indicate that this was a code-based dismissal.

Side note, Love the icon-custom-slot option for the banner.

@github-actions github-actions Bot added the Needs: Response Issues which require feedback from lead label Jun 6, 2026
@mekarpeles mekarpeles self-assigned this Jun 8, 2026
… 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.
@lokesh

lokesh commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

@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: OlToastRegion._layout measures each toast's height and sets --ol-toast-offset.)

image

Snap-back on dismiss ✅ — Fixed. There's no longer an expand/collapse relayout to snap back, and a new stillInteracting guard keeps the timers paused when dismissing a toast drops focus to <body> while the pointer is still over the stack.


Too small ⚠️ → split into three:

  • Dismiss cross ✅ — Now a real SVG "X" inside a 28×28 hit area (WCAG 2.2 target minimum), so it's an easy click.
  • Icon legibility ✅ — Bumped the glyph stroke (3→4), and the warning icon is now a distinct filled caution-triangle rather than another circular badge.
  • Banner body font (14px) — I decided to leave this as-is. The color and icon should be enough to lift the banner in the hierarchy. Why not bump the font? I worry about an arms race of components competing for the patron's attention. Using OL should feel pleasant — like reading a book — with gentle guides, not a lot of "look at me." Several of our screens are dense, and a few loud items can quickly make them feel cluttered. Below: the banners as they stand, plus the Claude app as an example of a dense UI that holds a subtle hierarchy without leaning on many font sizes, weights, or colors.
image image

Rename dismiss ✅ — Good call. It's now programmatic across the code, JSDoc, @fires annotation, and the design demo.

And thanks for the note on the icon-slot, glad you liked it.

@lokesh

lokesh commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

@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.

@mekarpeles mekarpeles merged commit 828309e into internetarchive:master Jun 11, 2026
4 of 5 checks passed
@Sadashii

Copy link
Copy Markdown
Collaborator

@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.

  1. Add a slight progress bar to the toasts (so they can be differentiated from the persistent ones for patrons) - Added, a 2px progress-bar attached to the bottom border (shrinks with time, pauses on hover) + dosen't show on prefers-reduced-motion
  2. Due to the dismiss icon being 28px, and the icon + text being 20px, the top alignment looks unpleasant for single-line text, less visible for multiline, have made the minimum-height to 28px on the icon/text with vertical alignment so for single-line they are centered and top-centered (but properly) for multiline.

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```

@Sadashii

Sadashii commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs: Response Issues which require feedback from lead

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants