Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .agents/skills/codeatlas-ui/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
5 changes: 5 additions & 0 deletions .changeset/notification-bell-state.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -31,11 +40,16 @@
markAll?: string;
clear?: string;
empty?: string;
loading?: string;
};
}

let {
notifications = [],
open = $bindable(false),
onOpenChange,
count,
loading = false,
onRead,
onReadAll,
onClear,
Expand All @@ -44,20 +58,37 @@
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<HTMLElement | null>(null);
let panel = $state<HTMLElement | null>(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;
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 });
}
</script>
Expand Down Expand Up @@ -107,8 +138,13 @@
</div>
</div>

{#if loading}
<div class="ca-notif-empty">{labels.loading ?? t().notificationBell.loading}</div>
{/if}
{#if notifications.length === 0}
<div class="ca-notif-empty">{labels.empty ?? t().notificationBell.empty}</div>
{#if !loading}
<div class="ca-notif-empty">{labels.empty ?? t().notificationBell.empty}</div>
{/if}
{:else}
<ul class="ca-notif-list">
{#each notifications as n}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
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$/i });
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: "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 });
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("7 unread notifications");
await expect.element(page.getByText("7", { exact: true })).toBeVisible();
await screen.rerender({ count: 3 });
await expect.element(trigger()).toHaveAccessibleName("3 unread notifications");
await expect.element(page.getByText("3", { exact: true })).toBeVisible();
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");
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();
await expect.poll(() => live()?.textContent).toBe("Notifications");
});

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");
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("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");
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("7 unread notifications");
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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import NotificationBell, { type NotificationItem } from "./NotificationBell.svelte";

interface Props {
items?: NotificationItem[];
open?: boolean;
count?: number;
loading?: boolean;
lazy?: boolean;
onOpenChange?: (open: boolean) => void;
onReadAll?: () => void;
}
let {
items = [], open = $bindable(false), count, loading = false, lazy = false,
onOpenChange, onReadAll,
}: Props = $props();
</script>

<button type="button">Outside</button>
<NotificationBell
notifications={items}
bind:open
{count}
{loading}
onOpenChange={(next) => {
if (lazy && next) loading = true;
onOpenChange?.(next);
}}
onReadAll={() => {
if (lazy) count = 0;
onReadAll?.();
}}
/>
<output aria-label="Bound open">{open ? "open" : "closed"}</output>
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/groups/notificationBell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -18,5 +19,6 @@ export const notificationBell = defineGroup("notificationBell", {
markAll: "Marcar todas como lidas",
clear: "Limpar",
empty: "Nenhuma notificação",
loading: "Carregando notificações...",
},
});
Loading