Repository navigation
fix(core): show stacked snackbars as tabs - #6045
norman-abramovitz merged 16 commits into
Conversation
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.
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.
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.
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.
Live checkChromium, 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
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 |
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".
|
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 Two e2e tests closed error banners by the old "×" label; they now match 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.
|
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. |
Summary
Several pop-up messages (snackbars) can be open at once, and that is intended. The fault was where they sat:
TailwindSnackBarServiceput 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
Accessibility
tablistthat 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".aria-hidden, and visually hidden text "Shown N times" stands in for it. A repeat is announced again with "(Shown N times)".inert.Tests
tailwind-snackbar.service.spec.tscovers 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.aria-hiddenremoved 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.