From 04b655fa9bf98605f37f1b0ce08dd3d2e5df3495 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold Date: Mon, 14 Sep 2026 05:53:51 +0200 Subject: [PATCH] Keep the snackbar's live region in the page The region carrying aria-live was created together with its message, inside the x-if that draws the snackbar, so a screen reader had nothing to notice changing. It moves to the permanent host, which is polite and atomic as M3 asks; a type now only picks the region's role, and the explicit aria-live keeps an error polite too. Plan step 11, actions.md ACT-02. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/views/components/toast.blade.php | 12 ++++++++++-- tests/Feature/Components/ToastTest.php | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index f82ff6d9..fb09d3c7 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -15,6 +15,12 @@ the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back once the queue is empty. One is kept at a time; a newer sticky toast replaces it. + The live region is the host itself, not the snackbar: a region must be in the page before its + contents change for a screen reader to announce them reliably, and the snackbar comes and + goes. It is `aria-live="polite" aria-atomic="true"`, as M3 asks for a snackbar (never + assertive); a `type` picks the region's role — `alert` for an error or a warning, `status` + otherwise — and the explicit `aria-live` keeps even those polite. + Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its action button. @@ -33,6 +39,10 @@ @persist('material-toast')
class([ 'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]', 'justify-center' => $position !== 'bottom-start', @@ -43,8 +53,6 @@
blade(''); expect($html) - ->toMatch('/]*\bdata-toast\b[^>]*aria-live="polite"/') + ->toMatch('/]*\bdata-toast\b/') ->toMatch('/]*\bdata-toast-action\b[^>]*x-on:click="act\(\)"/'); }); + +it('keeps the live region in the page, polite, around the snackbar that comes and goes', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toMatch('/]*x-data="materialSnackbar"[^>]*aria-live="polite"/') + ->toContain('aria-atomic="true"') + ->toContain("x-bind:role=\"current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'\"") + ->and(substr_count($html, 'aria-live'))->toBe(1); +});