Skip to content

fix(core): show stacked snackbars as tabs - #6045

Merged
norman-abramovitz merged 16 commits into
cloudfoundry:developfrom
nabramovitz:norm/fix/snackbar-stacking
Oct 22, 2026
Merged

norman-abramovitz merged 16 commits into
cloudfoundry:developfrom
nabramovitz:norm/fix/snackbar-stacking

Conversation

@nabramovitz

@nabramovitz nabramovitz commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Several pop-up messages (snackbars) can be open at once, and that is intended. The fault was where they sat: TailwindSnackBarService put every message at the same fixed spot (fixed bottom-4 left-1/2), exactly on top of the one before. The older message peeked out behind the newer one and could take a click meant for the newer one's close button. A live check of #6042 ran into this.

With this change the stack is one box at the same spot, with a numbered tab per message.

This PR's first two commits made it one message at a time, as the Angular Material snack bar was. That took the intended stacking away. They stay in the history because the branch is pushed; the commits after them restore the stacking as tabs and replace that behaviour.

How it behaves

  • One message looks as it does today. With two or more, a strip of numbered tabs runs along the top of the box, and a new message adds a tab and is selected.
  • Numbers don't shift. Closing tab 2 of 1, 2, 3 leaves 1 and 3, and the next message is 4. Numbering starts again at 1 once every tab is closed. There is no cap: the strip scrolls sideways when the tabs no longer fit.
  • Closing. Each message has one close button, "Dismiss", which closes only that message. Before, a message could show both a "Dismiss" (or "Close") action and a "×" that did the same thing; a caller's "Dismiss" or "Close" now becomes that one button, and other actions keep their own button beside it. A "Close all" button beside the strip, shown when two or more tabs are open, closes them all. A message that closes itself after a few seconds removes only its own tab.
  • Repeats. A message that repeats the newest one, straight after it, joins its tab instead of getting a new one. The count shows once, as "(N)" at the start of the message text, with the tooltip "Shown N times"; the tab keeps just its number, and a lone repeated message shows no strip. A repeat of an older message gets a new tab. Two messages count as the same when their plain text, their kind, and their link destination (if any) match. Error text with styled pieces (fix(frontend): show upstream HTML error pages as their message #6042) is compared by its plain text.
  • Stepper. A stepper closes only the error message it opened, so other open messages stay up when it retries or is left.

Accessibility

  • The strip is a tablist that works with the arrow keys. Each tab is named "Message N", and screen readers add its position ("tab 2 of 3"). The position is always correct, so it never reads "3 of 2".
  • The visible "(N)" is aria-hidden, and visually hidden text "Shown N times" stands in for it. A repeat is announced again with "(Shown N times)".
  • Closing a message clears its text from the live region, and a message that is fading out is inert.
  • When the box closes with focus inside it, focus returns to the element it came from, if that element is still on the page.

Tests

  • tailwind-snackbar.service.spec.ts covers the tabs, numbering, Close all, the repeat rule (newest only, kind, link destination, pieces), the count and its accessible text, the live region, inert, and focus return. The stepper spec covers the stepper closing only its own message.
  • Each change's specs were run against the code before it and failed. The pieces specs came after the develop merge, so they were checked by breaking the code on purpose instead.
  • Reverse tests. For each change, the code was broken on purpose and the named specs went red, for example:
    • merging into any identical tab;
    • dropping the destination from the comparison;
    • the stepper closing everything;
    • no focus return;
    • aria-hidden removed from the count.
  • make check gate: passed (4578 tests passed, 1 skipped).

Merge with develop

develop is merged in (no rebase, since the branch is pushed). The only conflict was in tailwind-snackbar.service.ts, against #6042's styled error text. The tabs keep #6042's text-only piece spans.

Release note: changelog.d/0023-snackbar-stacking.md.

AI disclosure (RFC-0047): prepared with AI coding assistance; reviewed by the author.

Every toast is placed at the same fixed spot at the bottom of the page,
so a second toast covered the first. The older one peeked out behind
the newer one and could take a click meant for the newer one's close
button.

The Material snack bar this service replaced showed one toast at a
time, and the old snackbar facade said so ("Only one snack bar is shown
at a given time"); stacking was never built. Opening a toast now
dismisses the one on screen, as Material did, and the service-level
dismiss() closes it through its ref, so afterDismissed() fires.

A toast that is fading out is made inert, so it takes no clicks and is
not read out. Dismissing the toast on screen clears the announcer's
live region, so a closed message is not left behind there; a replaced
toast's late timer or close leaves the new announcement alone.
Release note for the fix that stops pop-up messages piling up in the
same spot at the bottom of the page.
@nabramovitz
nabramovitz marked this pull request as draft October 22, 2026 03:17
Several messages being open at once is intended. The defect was where
they sat: every toast was placed at the same fixed spot at the bottom
of the page, exactly on top of the one before, so the older one peeked
out and could take a click meant for the newer one. The one-at-a-time
change stopped the stray clicks by closing the older message, which
also took the stacking away. This brings the stacking back, with every
message staying open, and shows the stack as one box with a tab per
message. It replaces the one-at-a-time behaviour.

Open messages share one box at the old spot. With one message it looks
as before. With more, a strip of numbered tabs along the top switches
between them, and a new message adds a tab and is selected. Numbers
follow arrival and do not change while any tab is open: closing tab 2
of 1, 2, 3 leaves 1 and 3, and the next message is 4. Numbering starts
again at 1 once every tab is closed. There is no cap; the strip
scrolls sideways when the tabs no longer fit, and the box stays 1rem
inside each side of a narrow screen.

Closing a message (its close button, its action, its own timer or its
ref) removes only its tab and selects a neighbour, and closing the
last one closes the box. Errors still stay until dismissed. A message
with the same text, kind and action as an open one joins that tab: the
tab shows a count, is selected and announced again, and its timer, if
it has one, starts over.

The strip is a tablist: arrow keys, Home and End move along it, and
only the selected tab is in the Tab order. Closing the focused message
puts focus on the tab now selected. Closing a tab clears the live
region only when it holds that tab's message, and a closed message's
late timer changes nothing. A box that is fading out stays inert.

The service-level dismiss() still closes through each ref, so
afterDismissed() fires, and it now closes every open message again, as
it did before the one-at-a-time change. The stepper calls it to clear
its error before the next try; closing only the selected tab could
leave that error up behind a newer message.

The one-at-a-time spec gives way to specs for the tabs, and hide() is
again checked with a shown message and an error open together.
The release note said one pop-up message is shown at a time. They now
stay open together as numbered tabs, so the note describes that.
Each message closes on its own with its close button or Dismiss, a
merged set included. With two or more tabs open there was no quick way
to clear them, one click per tab.

A "Close all" button now sits at the end of the tab strip, shown only
while two or more tabs are open. It sits next to the tablist, not in
it, since a tablist holds only tabs. It closes every message through
its own ref, so each afterDismissed() fires, and closes the box;
numbering starts again at 1. As with closing the last message, the
box goes inert and focus falls back to the page.

The service-level dismiss() runs the same close-all path, so its scope
is unchanged.
A message with the same text, kind and action as any open tab joined
that tab, even when other messages had come in since. A set is meant
for a run of the same message back to back, such as one failing call
raising the same error three times in a row; the same text after
something else is a new event.

A message now joins a tab only when it repeats the newest one, the tab
with the last number given out, and that tab is still open. Otherwise
it gets a new tab, even if an older open tab has the same text. The
comparison itself (text, kind and action) is unchanged and kept in one
function, isSameMessage().
A merged set showed its count only on its tab, as "×N". The message
itself gave no sign it had been shown more than once.

A set now shows "(N)" at the start of its message's first line, so it
takes no extra line, with the tooltip "Shown N times". The visible
"(N)" is aria-hidden; screen readers read the visually hidden "Shown N
times" beside it instead. A repeat is announced again with the count,
as "<message> (Shown N times)". A message shown once has no count.

The message keeps a span of its own inside the line, so update() and
the repeat check still see only its text. The tab's "×N" and the
one-tab strip for a lone repeated message stay as they were.
The release note said a message that repeats any open one adds a count
to its tab, and read as if messages stacking was the fault. Stacking is
intended; the fault was that each message sat exactly on top of the
one before. The note now says so, says the change makes stacked
messages usable as tabs, and covers that only a repeat of the newest
message is counted, that the count also shows at the start of its
text, and that a "Close all" button closes every tab.
…stacking

Develop's cloudfoundry#6042 lets open(), error() and show() take styled message
pieces as well as a string, each piece a span set with textContent and
announced as its plain text. This branch replaced the single-toast
open() with numbered tabs, so the two met in open() and in the panel
builder.

The tabs keep their structure. open() takes TailwindSnackBarMessage,
works out the plain text once (piecesToText for pieces), and uses it
both for the announcement and for the repeat check, so isSameMessage()
compares plain text, not the array. The panel's line keeps a set's
"(N)" ahead of the message, and .snackbar-message now holds develop's
piece spans; it keeps whitespace-pre-line, which develop's spec checks.
Since develop's message pieces came in, every call that passes pieces
builds a new array. A repeat is found by its plain text, so two calls
with the same pieces join one set, and the set's "(N)" stays ahead of
the first piece. A string with the same plain text as some pieces is
the same message too.
A repeat of the newest message joined its set when the text, kind and
action label matched. For a showWithLink() message that left out where
the action goes, so "Return to space" for one space could join a set
for another and lead to the wrong place.

isSameMessage() now also compares the destination: a URL string or a
route array, kept apart so the two never match each other. A message
with no link is never the same as one with a link.

A repeat link that joins its set no longer subscribes the navigation a
second time; one click on a merged link navigated twice.

@norman-abramovitz norman-abramovitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Before the next try, and when it goes away, the stepper cleared its
last error with the service-level dismiss(). With messages open
together as tabs, that closed every other message too, such as a
notice opened while the stepper's error was up.

The stepper now closes its error through the ref the error came with,
so other tabs stay open. The service-level dismiss() still closes
every message; nothing else in the app calls it now. Its comment, and
its spec's, no longer say the stepper relies on it.
Closing the last message, or every message with "Close all", closes
the box and makes it inert, and the focused control went with it. A
keyboard user was dropped back at the start of the page.

The box now records where focus was just before it entered: the
element focus came from, each time it comes in from outside. When the
box closes with focus inside, focus goes back to that element. If the
element is gone from the page, or focus came in from the page itself,
focus is left alone, and so is focus that is already outside the box
when it closes, as when a message times out. Closing one tab while
others stay open still moves focus to the tab now selected.
@nabramovitz nabramovitz changed the title fix(core): show one snackbar at a time fix(core): show stacked snackbars as tabs Oct 22, 2026

@norman-abramovitz norman-abramovitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

With three tabs open and focus on "Close all", Enter closed the box but
focus did not go back to where it came from. Close all closes the
messages one at a time; once one tab is left, the button hides, and a
browser blurs a hidden focused element to the page. By the last close
focus was no longer in the box, so it was not returned. The last single
close had the same flaw: its panel went inert before the check.

Whether focus was in the box is now read once, before anything is
hidden, removed or made inert: before the loop for Close all and the
service-level dismiss(), at the top of close() otherwise. Focus that
was outside the box is still left where it is.

The new specs make hiding and inert blur the focused element, as a
browser does; the test DOM keeps focus on it, which hid the defect.
@nabramovitz

Copy link
Copy Markdown
Contributor Author

Live check

Chromium, using the repo's Playwright e2e setup against a Cloud Foundry lab, with a fresh database. In the browser, every write was answered with a 502 carrying X-Stratos-Error-Reason: operation_failed and a CF-style message, so nothing was written to Cloud Foundry. Messages were raised through the real UI: Delete on the space Routes list, and Update in the edit organization stepper.

Step Result
Two different errors Tabs [1][2], tab 2 selected
The newest error again Tab 2 reads "2×2"; the text starts "(2)" with the tooltip "Shown 2 times"
The older error again A new tab 3; tab 1 is not merged
Close tab 2 with its own close button Tabs [1][3]; numbers unchanged
Arrow keys from the selected tab Left → tab 1, Right → tab 3, Right → tab 1, focus following
Tab to "Close all", Enter Box gone; focus back on the "Filter by Name" field it came from
A new error A single message with no strip; it is number 1 again
Stepper error with another message open Tabs [1][2], the stepper's error as tab 2
Stepper retry While it retries, only tab 1 is left; the failed retry comes back as tab 3

The first run found a defect that the unit specs could not see. Close all hides its own button once one tab is left, and a real browser then moves focus to <body>, so focus was never returned. 7c8eaa1 now decides whether focus was in the box before anything is hidden or made inert. Its specs make the test DOM blur a hidden element the way a browser does.

@nabramovitz
nabramovitz marked this pull request as ready for review October 22, 2026 10:22
Every message showed two controls that closed it: the caller's action,
usually "Dismiss" (error() defaults to it) or "Close", and the
service's own "×" named "Close".

Each message now has one control that closes it. It reads "Dismiss",
which is also its accessible name, and is styled as the text action
was, in both the error and the default colours. It keeps the
snackbar-close class. A caller's "Dismiss" or "Close" action, matched
exactly, is this control rather than a second button, and clicking it
still fires onAction() before afterDismissed(), as that button did; on
a message without such an action it only dismisses, as the "×" did.
Any other action, such as a link, keeps its own button before
"Dismiss". Messages that time out on their own have "Dismiss" too.

Two end-to-end helpers that closed an error banner by the "×"'s
"Close" label now also match .snackbar-close. The release note says
each message has one "Dismiss".
@nabramovitz

Copy link
Copy Markdown
Contributor Author

0b82503 gives each message one close button, "Dismiss".

Before, an error message showed two buttons that did the same thing: the caller's "Dismiss" action and the service's "×". A caller's "Dismiss" or "Close" now becomes that one button, with the same onAction()/afterDismissed() behaviour as before. Any other action, such as "Reconnect" or "View", keeps its own button beside it. Messages that close themselves after a few seconds also get "Dismiss". The button's accessible name is its visible text.

Two e2e tests closed error banners by the old "×" label; they now match .snackbar-close too.

The live check was rerun and passes all steps. A new assertion checks that each message has a single button reading "Dismiss". The gate also passed: 4577 tests passed, 1 skipped.

A set's count showed twice: as "×N" on its tab and as "(N)" at the
start of its message. The tab now shows only its number, and its
accessible name is "Message N" without the count. The count stays in
the message: the visible "(N)" with its "Shown N times" tooltip, the
visually hidden "Shown N times" for screen readers, and the
announcement on a repeat.

A lone repeated message showed a strip with one tab only to carry
that "×N". The strip now shows only for two or more tabs, so a lone
repeated message looks like any single message, with "(N)" at the
start of its text.

The release note now says the count shows at the start of the
message's text.
@nabramovitz

Copy link
Copy Markdown
Contributor Author

ecc17e5 shows a repeated message's count once, as "(N)" at the start of its text. The tab no longer carries a "×N" badge: its visible text and its accessible name are just "Message N". A lone repeated message no longer shows a one-tab strip, because that strip only existed to carry the badge. Live check rerun: all steps pass, and the tab now reads "2". Gate: 4578 passed, 1 skipped.

@norman-abramovitz
norman-abramovitz merged commit f88c08f into cloudfoundry:develop Oct 22, 2026
24 checks passed
@nabramovitz
nabramovitz deleted the norm/fix/snackbar-stacking branch October 22, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants