From 69eff8ea5aa1a7d67e84e9cd4baa92d0338f2612 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 23 Oct 2026 07:47:43 +0200 Subject: [PATCH 1/4] feat(notification-bell): add lazy list state controls Co-Authored-By: GPT-6.1 Sol --- .changeset/notification-bell-state.md | 5 + .../NotificationBell/NotificationBell.svelte | 43 +++++- .../NotificationBellState.svelte.test.ts | 140 ++++++++++++++++++ .../NotificationBellState.test.svelte | 34 +++++ .../src/lib/i18n/groups/notificationBell.ts | 2 + .../docs/display/notification-bell.mdx | 28 +++- 6 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 .changeset/notification-bell-state.md create mode 100644 packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts create mode 100644 packages/ui/src/lib/components/NotificationBell/NotificationBellState.test.svelte diff --git a/.changeset/notification-bell-state.md b/.changeset/notification-bell-state.md new file mode 100644 index 00000000..68768037 --- /dev/null +++ b/.changeset/notification-bell-state.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +Add bindable open state, user open-change callbacks, an independent unread count and loading feedback to NotificationBell for lazy notification lists. Announce unread count changes politely. diff --git a/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte b/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte index 35b24b89..2e9db419 100644 --- a/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte +++ b/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte @@ -3,10 +3,11 @@ import { notificationBell } from '../../i18n/groups/notificationBell.js'; import { Bell } from '@lucide/svelte'; import { uid } from '../../utils/index.js'; + import { announce } from '../../utils/announce.js'; import FloatingLayer from '../Popover/FloatingLayer.svelte'; import { createTriggerProps, tabIntoPopup } from '../Popover/trigger.js'; import Badge from '../Badge/Badge.svelte'; - import type { Snippet } from 'svelte'; + import { type Snippet, untrack } from 'svelte'; const t = useMessages(notificationBell); export interface NotificationItem { @@ -20,6 +21,14 @@ interface Props { notifications?: NotificationItem[]; + /** Bindable. Whether the panel is open. */ + open?: boolean; + /** Called when the user opens or closes the panel, not when the parent sets open. */ + onOpenChange?: (open: boolean) => void; + /** Unread total. Defaults to the unread notifications in the list. */ + count?: number; + /** Shows loading text while keeping loaded notifications visible. */ + loading?: boolean; onRead?: (id: string) => void; onReadAll?: () => void; onClear?: () => void; @@ -31,11 +40,16 @@ markAll?: string; clear?: string; empty?: string; + loading?: string; }; } let { notifications = [], + open = $bindable(false), + onOpenChange, + count, + loading = false, onRead, onReadAll, onClear, @@ -44,20 +58,32 @@ labels = {}, }: Props = $props(); - let unread = $derived(notifications.filter(n => !n.read).length); + let unread = $derived(count ?? notifications.filter(n => !n.read).length); + let lastUnread = untrack(() => unread); + $effect(() => { + const next = unread; + if (next === lastUnread) return; + lastUnread = next; + announce(t().notificationBell.label(next)); + }); const id = uid('notif'); - let open = $state(false); let anchor = $state(null); let panel = $state(null); + function setOpen(next: boolean) { + if (next === open) return; + open = next; + onOpenChange?.(next); + } + const makeTrigger = createTriggerProps((el) => (anchor = el)); const triggerAttrs = $derived( - makeTrigger({ id: `${id}-trigger`, popup: 'dialog', open, controls: `${id}-panel`, onToggle: () => (open = !open), onKeydown: (e) => open && tabIntoPopup(e, panel) }) + makeTrigger({ id: `${id}-trigger`, popup: 'dialog', open, controls: `${id}-panel`, onToggle: () => setOpen(!open), onKeydown: (e) => open && tabIntoPopup(e, panel) }) ); function close(restoreFocus: boolean) { - open = false; + setOpen(false); if (restoreFocus) anchor?.focus({ preventScroll: true }); } @@ -107,8 +133,13 @@ + {#if loading} +
{labels.loading ?? t().notificationBell.loading}
+ {/if} {#if notifications.length === 0} -
{labels.empty ?? t().notificationBell.empty}
+ {#if !loading} +
{labels.empty ?? t().notificationBell.empty}
+ {/if} {:else}
    {#each notifications as n} diff --git a/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts b/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts new file mode 100644 index 00000000..28ec6dfa --- /dev/null +++ b/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; +import { render } from "vitest-browser-svelte"; +import { checkA11y } from "../../../../tests/a11y"; +import { settled } from "../../../../tests/settled"; +import NotificationBell from "./NotificationBell.svelte"; +import Harness from "./NotificationBellState.test.svelte"; + +const items = [ + { id: "a", title: "New invoice" }, + { id: "b", title: "Read receipt", read: true }, +]; +const trigger = () => page.getByRole("button", { name: /^Notifications/ }); +const bound = () => page.getByRole("status", { name: "Bound open" }); +const live = () => document.querySelector('[data-ca-announcer="polite"]'); + +afterEach(() => { + for (const el of document.querySelectorAll("[data-ca-announcer]")) el.remove(); +}); + +describe("NotificationBell state", () => { + for (const mode of ["parent", "click"] as const) { + it.each([ + { count: 7, loading: true, items: [], label: "Notifications (7 unread)" }, + { count: 7, loading: false, items: [], label: "Notifications (7 unread)" }, + { count: 7, loading: true, items, label: "Notifications (7 unread)" }, + { count: 7, loading: false, items, label: "Notifications (7 unread)" }, + { count: undefined, loading: true, items: [], label: "Notifications (0 unread)" }, + { count: undefined, loading: false, items: [], label: "Notifications (0 unread)" }, + { count: undefined, loading: true, items, label: "Notifications (1 unread)" }, + { count: undefined, loading: false, items, label: "Notifications (1 unread)" }, + ])(`${mode}: combination %#`, async (row) => { + const onOpenChange = vi.fn(); + const screen = await render(Harness, { ...row, onOpenChange }); + await expect.element(trigger()).toHaveAccessibleName(row.label); + await expect.element(trigger()).toHaveAttribute("aria-expanded", "false"); + if (mode === "parent") await screen.rerender({ open: true }); + else await trigger().click(); + await expect.element(page.getByRole("dialog")).toBeVisible(); + await expect.element(bound()).toHaveTextContent("open"); + if (mode === "parent") expect(onOpenChange).not.toHaveBeenCalled(); + else expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true); + if (row.loading) + await expect + .element(page.getByText("Loading notifications...", { exact: true })) + .toBeVisible(); + else + await expect + .element(page.getByText("Loading notifications...", { exact: true })) + .not.toBeInTheDocument(); + if (row.items.length) + await expect.element(page.getByRole("button", { name: "New invoice" })).toBeVisible(); + else if (!row.loading) + await expect.element(page.getByText("No notifications", { exact: true })).toBeVisible(); + if (row.loading || row.items.length) + await expect + .element(page.getByText("No notifications", { exact: true })) + .not.toBeInTheDocument(); + if (row.count === 7 || row.items.length) + await expect.element(page.getByRole("button", { name: "Mark all read" })).toBeVisible(); + else + await expect + .element(page.getByRole("button", { name: "Mark all read" })) + .not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await expect.element(bound()).toHaveTextContent("closed"); + await expect.element(trigger()).toHaveFocus(); + expect(onOpenChange.mock.calls).toEqual(mode === "parent" ? [[false]] : [[true], [false]]); + }); + } + + it("parent updates count, including zero, and announces politely", async () => { + const screen = await render(Harness, { count: 7, items }); + await expect.element(trigger()).toHaveAccessibleName("Notifications (7 unread)"); + await expect.element(page.getByText("7", { exact: true })).toBeVisible(); + await screen.rerender({ count: 3 }); + await expect.element(trigger()).toHaveAccessibleName("Notifications (3 unread)"); + await expect.poll(() => live()?.textContent).toBe("Notifications (3 unread)"); + expect(live()?.getAttribute("aria-live")).toBe("polite"); + await screen.rerender({ count: 0, open: true }); + await expect.element(trigger()).toHaveAccessibleName("Notifications (0 unread)"); + await expect.element(page.getByText("3", { exact: true })).not.toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Mark all read" })) + .not.toBeInTheDocument(); + await expect.poll(() => live()?.textContent).toBe("Notifications (0 unread)"); + }); + + it("user opens a lazy list, marks read, and finishes loading", async () => { + const onOpenChange = vi.fn(); + const onReadAll = vi.fn(); + const screen = await render(Harness, { count: 7, lazy: true, onOpenChange, onReadAll }); + await trigger().click(); + await expect.element(bound()).toHaveTextContent("open"); + await expect.element(page.getByText("Loading notifications...", { exact: true })).toBeVisible(); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true); + await screen.rerender({ loading: false, items }); + await expect.element(page.getByRole("button", { name: "New invoice" })).toBeVisible(); + await expect + .element(page.getByText("Loading notifications...", { exact: true })) + .not.toBeInTheDocument(); + await page.getByRole("button", { name: "Mark all read" }).click(); + expect(onReadAll).toHaveBeenCalledOnce(); + await expect.element(trigger()).toHaveAccessibleName("Notifications (0 unread)"); + await settled(); + expect( + await checkA11y(document.querySelector(".ca-notif-panel") as HTMLElement) + ).toHaveNoViolations(); + await screen.rerender({ open: false }); + await expect.element(trigger()).toHaveAttribute("aria-expanded", "false"); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true); + }); + + it("trigger and outside dismiss update binding and callbacks", async () => { + const onOpenChange = vi.fn(); + await render(Harness, { onOpenChange }); + await trigger().click(); + await expect.element(bound()).toHaveTextContent("open"); + await trigger().click(); + await expect.element(bound()).toHaveTextContent("closed"); + await trigger().click(); + await page.getByRole("button", { name: "Outside", exact: true }).click(); + await expect.element(bound()).toHaveTextContent("closed"); + await expect.element(page.getByRole("button", { name: "Outside", exact: true })).toHaveFocus(); + expect(onOpenChange.mock.calls).toEqual([[true], [false], [true], [false]]); + }); + + it("omitted props keep list counts, callbacks, and empty state", async () => { + const onRead = vi.fn(); + const screen = await render(NotificationBell, { notifications: items, onRead }); + await expect.element(trigger()).toHaveAccessibleName("Notifications (1 unread)"); + await trigger().click(); + await page.getByRole("button", { name: "New invoice" }).click(); + expect(onRead).toHaveBeenCalledExactlyOnceWith("a"); + await screen.rerender({ notifications: [] }); + await expect.element(trigger()).toHaveAccessibleName("Notifications (0 unread)"); + await expect.element(page.getByText("No notifications", { exact: true })).toBeVisible(); + }); +}); diff --git a/packages/ui/src/lib/components/NotificationBell/NotificationBellState.test.svelte b/packages/ui/src/lib/components/NotificationBell/NotificationBellState.test.svelte new file mode 100644 index 00000000..f934e600 --- /dev/null +++ b/packages/ui/src/lib/components/NotificationBell/NotificationBellState.test.svelte @@ -0,0 +1,34 @@ + + + + { + if (lazy && next) loading = true; + onOpenChange?.(next); + }} + onReadAll={() => { + if (lazy) count = 0; + onReadAll?.(); + }} +/> +{open ? "open" : "closed"} diff --git a/packages/ui/src/lib/i18n/groups/notificationBell.ts b/packages/ui/src/lib/i18n/groups/notificationBell.ts index 55ccba8b..c8aacfaf 100644 --- a/packages/ui/src/lib/i18n/groups/notificationBell.ts +++ b/packages/ui/src/lib/i18n/groups/notificationBell.ts @@ -8,6 +8,7 @@ export const notificationBell = defineGroup("notificationBell", { markAll: "Mark all read", clear: "Clear", empty: "No notifications", + loading: "Loading notifications...", }, "pt-BR": { label: (unread) => @@ -18,5 +19,6 @@ export const notificationBell = defineGroup("notificationBell", { markAll: "Marcar todas como lidas", clear: "Limpar", empty: "Nenhuma notificação", + loading: "Carregando notificações...", }, }); diff --git a/site/src/content/docs/display/notification-bell.mdx b/site/src/content/docs/display/notification-bell.mdx index 3cbf2e06..9d713102 100644 --- a/site/src/content/docs/display/notification-bell.mdx +++ b/site/src/content/docs/display/notification-bell.mdx @@ -19,17 +19,41 @@ import ControlledSrc from "~/demos/display/notification-bell/Controlled.svelte?r ``` -The unread count is derived from `notifications` where `read` is falsy. The component is controlled: update your list in `onRead`, `onReadAll` and `onClear`. Every visible string can be replaced through `labels`. The panel is a popover layer anchored below the bell, aligned to its end edge, and closes on Escape or an outside press. +The unread count defaults to the number of `notifications` where `read` is falsy. Pass `count` to show an independent unread total, including zero, while loading only part of the list. Update your list in `onRead`, `onReadAll` and `onClear`. Every visible string can be replaced through `labels`. The panel is a popover layer anchored below the bell, aligned to its end edge, and closes on Escape or an outside press. Escape returns focus to the trigger. + +Use `bind:open` to enable your list query only while the panel is open, and refresh `count` independently in the background. `onOpenChange` runs when the user opens or closes the panel; assigning `open` from code does not call it. Count changes update the trigger's accessible name and are announced politely. + +```svelte + + + isOpen && refresh()} +/> +``` + +While `loading` is true, the panel shows loading text instead of the empty state. Any loaded notifications remain visible during a refresh. void", default: "undefined", description: "Called when the user opens or closes the panel, including dismissal. Not called for parent assignments." }, + { name: "count", type: "number", default: "derived from notifications", description: "Independent unread total for the badge, accessible name and mark-all action. Zero overrides the list count." }, + { name: "loading", type: "boolean", default: "false", description: "Shows loading text, suppresses the empty state and keeps loaded notifications visible." }, { name: "onRead", type: "(id: string) => void", default: "undefined", description: "Called when an unread item is clicked. Items already read do not call it." }, { name: "onReadAll", type: "() => void", default: "undefined", description: "Called by the mark all as read action." }, { name: "onClear", type: "() => void", default: "undefined", description: "Called by the clear action." }, - { name: "labels", type: "{ title?, markAll?, clear?, empty? }", default: "{}", description: "String overrides for i18n." }, + { name: "labels", type: "{ title?, markAll?, clear?, empty?, loading? }", default: "{}", description: "String overrides for i18n." }, { name: "class", type: "string", default: "''", description: "Extra class names." }, { name: "children", type: "Snippet", default: "undefined", description: "Custom trigger content." }, ]} /> From 783fa3ebf3f8c33a5fd3635d6e796c04979ef302 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 23 Oct 2026 08:05:46 +0200 Subject: [PATCH 2/4] fix(notification-bell): restore focus on parent-driven close Co-Authored-By: GPT-6.1 Sol --- .../NotificationBell/NotificationBell.svelte | 5 ++ .../NotificationBellState.svelte.test.ts | 72 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte b/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte index 2e9db419..2acd8609 100644 --- a/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte +++ b/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte @@ -71,6 +71,11 @@ let anchor = $state(null); let panel = $state(null); + // Restore focus before a closing panel removes its focused child. + $effect.pre(() => { + if (!open && panel?.contains(document.activeElement)) anchor?.focus({ preventScroll: true }); + }); + function setOpen(next: boolean) { if (next === open) return; open = next; diff --git a/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts b/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts index 28ec6dfa..0428d158 100644 --- a/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts +++ b/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts @@ -75,11 +75,16 @@ describe("NotificationBell state", () => { await expect.element(page.getByText("7", { exact: true })).toBeVisible(); await screen.rerender({ count: 3 }); await expect.element(trigger()).toHaveAccessibleName("Notifications (3 unread)"); + await expect.element(page.getByText("3", { exact: true })).toBeVisible(); await expect.poll(() => live()?.textContent).toBe("Notifications (3 unread)"); expect(live()?.getAttribute("aria-live")).toBe("polite"); await screen.rerender({ count: 0, open: true }); await expect.element(trigger()).toHaveAccessibleName("Notifications (0 unread)"); await expect.element(page.getByText("3", { exact: true })).not.toBeInTheDocument(); + await expect.element(page.getByText("7", { exact: true })).not.toBeInTheDocument(); + await expect + .element(page.getByRole("status", { name: /^\d+ notifications$/ })) + .not.toBeInTheDocument(); await expect .element(page.getByRole("button", { name: "Mark all read" })) .not.toBeInTheDocument(); @@ -137,4 +142,71 @@ describe("NotificationBell state", () => { await expect.element(trigger()).toHaveAccessibleName("Notifications (0 unread)"); await expect.element(page.getByText("No notifications", { exact: true })).toBeVisible(); }); + + it("parent close restores focus from a notification item", async () => { + const onOpenChange = vi.fn(); + const screen = await render(Harness, { open: true, count: 7, items, onOpenChange }); + const item = page.getByRole("button", { name: "New invoice" }); + await expect.element(item).toBeVisible(); + (item.element() as HTMLElement).focus(); + await expect.element(item).toHaveFocus(); + await screen.rerender({ open: false }); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + expect(onOpenChange).not.toHaveBeenCalled(); + await expect.element(trigger()).toHaveFocus(); + }); + + it("parent close keeps focus already outside the panel", async () => { + const onOpenChange = vi.fn(); + const screen = await render(Harness, { open: true, items, onOpenChange }); + await expect.element(page.getByRole("dialog")).toBeVisible(); + const outside = page.getByRole("button", { name: "Outside", exact: true }); + (outside.element() as HTMLElement).focus(); + await expect.element(outside).toHaveFocus(); + await expect.element(bound()).toHaveTextContent("open"); + await screen.rerender({ open: false }); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + await expect.element(outside).toHaveFocus(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("lazy user close does not restart loading", async () => { + const onOpenChange = vi.fn(); + const screen = await render(Harness, { lazy: true, onOpenChange }); + await trigger().click(); + await expect.element(page.getByText("Loading notifications...", { exact: true })).toBeVisible(); + await screen.rerender({ loading: false }); + await expect.element(page.getByText("No notifications", { exact: true })).toBeVisible(); + await trigger().click(); + await expect.element(bound()).toHaveTextContent("closed"); + expect(onOpenChange.mock.calls).toEqual([[true], [false]]); + await screen.rerender({ open: true }); + await expect.element(page.getByText("No notifications", { exact: true })).toBeVisible(); + await expect + .element(page.getByText("Loading notifications...", { exact: true })) + .not.toBeInTheDocument(); + expect(onOpenChange.mock.calls).toEqual([[true], [false]]); + }); + + it("mark-all leaves the parent-owned count unchanged", async () => { + const onReadAll = vi.fn(); + await render(Harness, { open: true, count: 7, lazy: false, onReadAll }); + await page.getByRole("button", { name: "Mark all read" }).click(); + expect(onReadAll).toHaveBeenCalledOnce(); + await expect.element(trigger()).toHaveAccessibleName("Notifications (7 unread)"); + await expect.element(page.getByText("7", { exact: true })).toBeVisible(); + }); + + it("ending refresh clears loading with the same items", async () => { + const screen = await render(Harness, { open: true, items }); + await expect.element(page.getByRole("button", { name: "New invoice" })).toBeVisible(); + await screen.rerender({ loading: true }); + await expect.element(page.getByText("Loading notifications...", { exact: true })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "New invoice" })).toBeVisible(); + await screen.rerender({ loading: false }); + await expect + .element(page.getByText("Loading notifications...", { exact: true })) + .not.toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "New invoice" })).toBeVisible(); + }); }); From aa301cff0f42734828a134273ea6438ea2523e59 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 23 Oct 2026 16:14:24 +0200 Subject: [PATCH 3/4] chore: regenerate manifest after rebase Co-Authored-By: GLM-5.3-Flash --- .agents/skills/codeatlas-ui/API.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.agents/skills/codeatlas-ui/API.md b/.agents/skills/codeatlas-ui/API.md index 87a2a243..f8dfa6b7 100644 --- a/.agents/skills/codeatlas-ui/API.md +++ b/.agents/skills/codeatlas-ui/API.md @@ -2336,11 +2336,15 @@ Parts: `ca-bottom-nav__more` `ca-bottom-nav__more-btn` `ca-bottom-nav__more-btn- | Prop | Type | Default | Notes | |---|---|---|---| | `notifications?` | `NotificationItem[]` | `[]` | | +| `open?` | `boolean` | `false` | `bind:` Bindable. Whether the panel is open. | +| `onOpenChange?` | `(open: boolean) => void` | | Called when the user opens or closes the panel, not when the parent sets open. | +| `count?` | `number` | | Unread total. Defaults to the unread notifications in the list. | +| `loading?` | `boolean` | `false` | Shows loading text while keeping loaded notifications visible. | | `onRead?` | `(id: string) => void` | | | | `onReadAll?` | `() => void` | | | | `onClear?` | `() => void` | | | | `class?` | `string` | `''` | | -| `labels?` | `{ title?: string; markAll?: string; clear?: string; empty?: string; }` | `{}` | Label overrides for i18n. Defaults to English strings. | +| `labels?` | `{ title?: string; markAll?: string; clear?: string; empty?: string; loading?: string; }` | `{}` | Label overrides for i18n. Defaults to English strings. | Snippets: `children` From 849fa50f64ec3cc611a923c4819ba50b64e1f168 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Sun, 25 Oct 2026 17:26:17 +0100 Subject: [PATCH 4/4] test(notification-bell): expect main's trigger label wording Co-Authored-By: GPT-6.1 Sol --- .../NotificationBellState.svelte.test.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts b/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts index 0428d158..6c9c605c 100644 --- a/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts +++ b/packages/ui/src/lib/components/NotificationBell/NotificationBellState.svelte.test.ts @@ -10,7 +10,7 @@ const items = [ { id: "a", title: "New invoice" }, { id: "b", title: "Read receipt", read: true }, ]; -const trigger = () => page.getByRole("button", { name: /^Notifications/ }); +const trigger = () => page.getByRole("button", { name: /notifications$/i }); const bound = () => page.getByRole("status", { name: "Bound open" }); const live = () => document.querySelector('[data-ca-announcer="polite"]'); @@ -21,14 +21,14 @@ afterEach(() => { describe("NotificationBell state", () => { for (const mode of ["parent", "click"] as const) { it.each([ - { count: 7, loading: true, items: [], label: "Notifications (7 unread)" }, - { count: 7, loading: false, items: [], label: "Notifications (7 unread)" }, - { count: 7, loading: true, items, label: "Notifications (7 unread)" }, - { count: 7, loading: false, items, label: "Notifications (7 unread)" }, - { count: undefined, loading: true, items: [], label: "Notifications (0 unread)" }, - { count: undefined, loading: false, items: [], label: "Notifications (0 unread)" }, - { count: undefined, loading: true, items, label: "Notifications (1 unread)" }, - { count: undefined, loading: false, items, label: "Notifications (1 unread)" }, + { count: 7, loading: true, items: [], label: "7 unread notifications" }, + { count: 7, loading: false, items: [], label: "7 unread notifications" }, + { count: 7, loading: true, items, label: "7 unread notifications" }, + { count: 7, loading: false, items, label: "7 unread notifications" }, + { count: undefined, loading: true, items: [], label: "Notifications" }, + { count: undefined, loading: false, items: [], label: "Notifications" }, + { count: undefined, loading: true, items, label: "1 unread notifications" }, + { count: undefined, loading: false, items, label: "1 unread notifications" }, ])(`${mode}: combination %#`, async (row) => { const onOpenChange = vi.fn(); const screen = await render(Harness, { ...row, onOpenChange }); @@ -71,15 +71,15 @@ describe("NotificationBell state", () => { it("parent updates count, including zero, and announces politely", async () => { const screen = await render(Harness, { count: 7, items }); - await expect.element(trigger()).toHaveAccessibleName("Notifications (7 unread)"); + await expect.element(trigger()).toHaveAccessibleName("7 unread notifications"); await expect.element(page.getByText("7", { exact: true })).toBeVisible(); await screen.rerender({ count: 3 }); - await expect.element(trigger()).toHaveAccessibleName("Notifications (3 unread)"); + await expect.element(trigger()).toHaveAccessibleName("3 unread notifications"); await expect.element(page.getByText("3", { exact: true })).toBeVisible(); - await expect.poll(() => live()?.textContent).toBe("Notifications (3 unread)"); + await expect.poll(() => live()?.textContent).toBe("3 unread notifications"); expect(live()?.getAttribute("aria-live")).toBe("polite"); await screen.rerender({ count: 0, open: true }); - await expect.element(trigger()).toHaveAccessibleName("Notifications (0 unread)"); + await expect.element(trigger()).toHaveAccessibleName("Notifications"); await expect.element(page.getByText("3", { exact: true })).not.toBeInTheDocument(); await expect.element(page.getByText("7", { exact: true })).not.toBeInTheDocument(); await expect @@ -88,7 +88,7 @@ describe("NotificationBell state", () => { await expect .element(page.getByRole("button", { name: "Mark all read" })) .not.toBeInTheDocument(); - await expect.poll(() => live()?.textContent).toBe("Notifications (0 unread)"); + await expect.poll(() => live()?.textContent).toBe("Notifications"); }); it("user opens a lazy list, marks read, and finishes loading", async () => { @@ -106,7 +106,7 @@ describe("NotificationBell state", () => { .not.toBeInTheDocument(); await page.getByRole("button", { name: "Mark all read" }).click(); expect(onReadAll).toHaveBeenCalledOnce(); - await expect.element(trigger()).toHaveAccessibleName("Notifications (0 unread)"); + await expect.element(trigger()).toHaveAccessibleName("Notifications"); await settled(); expect( await checkA11y(document.querySelector(".ca-notif-panel") as HTMLElement) @@ -134,12 +134,12 @@ describe("NotificationBell state", () => { it("omitted props keep list counts, callbacks, and empty state", async () => { const onRead = vi.fn(); const screen = await render(NotificationBell, { notifications: items, onRead }); - await expect.element(trigger()).toHaveAccessibleName("Notifications (1 unread)"); + await expect.element(trigger()).toHaveAccessibleName("1 unread notifications"); await trigger().click(); await page.getByRole("button", { name: "New invoice" }).click(); expect(onRead).toHaveBeenCalledExactlyOnceWith("a"); await screen.rerender({ notifications: [] }); - await expect.element(trigger()).toHaveAccessibleName("Notifications (0 unread)"); + await expect.element(trigger()).toHaveAccessibleName("Notifications"); await expect.element(page.getByText("No notifications", { exact: true })).toBeVisible(); }); @@ -193,7 +193,7 @@ describe("NotificationBell state", () => { await render(Harness, { open: true, count: 7, lazy: false, onReadAll }); await page.getByRole("button", { name: "Mark all read" }).click(); expect(onReadAll).toHaveBeenCalledOnce(); - await expect.element(trigger()).toHaveAccessibleName("Notifications (7 unread)"); + await expect.element(trigger()).toHaveAccessibleName("7 unread notifications"); await expect.element(page.getByText("7", { exact: true })).toBeVisible(); });