diff --git a/.changeset/notification-display-types-present-distinctly.md b/.changeset/notification-display-types-present-distinctly.md new file mode 100644 index 000000000..ad3cd0e4e --- /dev/null +++ b/.changeset/notification-display-types-present-distinctly.md @@ -0,0 +1,58 @@ +--- +"@object-ui/react": minor +"@object-ui/components": minor +--- + +feat(notifications): each spec `displayType` gets its own presentation instead of a toast (#3014) + +#3008 closed the **contract** half of this: `NotificationContext`'s union matched +`NotificationTypeSchema`, and `notify()` materialized the declared type so a +consumer *could* branch on it. Nothing did. `NotificationProvider` handed every +item to the host's `onToast` delegate regardless of type, so an author picking +`banner` or `inline` got a transient overlay — plausible output, wrong output. + +Each of the five spec types now has a presentation of its own: + +| `displayType` | Presentation | Rendered by | +|---|---|---| +| `toast` | transient overlay (unchanged) | the host's `onToast` delegate | +| `snackbar` | bottom-anchored bar, one at a time, at most one action | `` | +| `banner` | page-width strip **in the content flow** | `` | +| `alert` | blocking acknowledgement dialog, FIFO queue | `` | +| `inline` | in place, at the raising surface | `` | + +The four surface components ship from `@object-ui/components` and subscribe via +`useNotificationsByPresentation(type, scope?)`. + +**Answers to the three questions the issue left open:** + +1. **Banner/inline placement is the host's.** They are not overlays: a banner takes + space at the top of the content area and an `inline` notification belongs next to + the thing that raised it. So the context exposes the items and the surfaces + subscribe, rather than one `onToast`-style delegate positioning everything. An + `inline` notification carries a `scope` that pairs it with its outlet, so two + forms on one page don't show each other's messages. +2. **`alert` is modal-ish but NOT the action system's `ModalHandler`.** That handler + resolves a page/object, renders it, and reports an `ActionResult` back to the + `ActionRunner`; a notification alert has no schema, no target and no result. + Routing it there would mean synthesizing a page just to say "OK". It renders + through the `AlertDialog` primitive instead — no second action-modal path. +3. **`snackbar` earns its own component.** It supersedes rather than stacks, anchors + bottom regardless of the toast position config, and takes at most one action. + Making it a sonner variant is what "presents as a toast" means. + +**Also fixed:** auto-dismiss now follows the presentation. `toast`/`snackbar` keep +the transient timer; `banner`/`alert`/`inline` are persistent unless the raiser sets +`duration` explicitly — a persistent banner used to evaporate on the shared 5s toast +timer. `dismissible` is honored on the persistent surfaces (an `alert` always keeps +its acknowledge button; `dismissible: false` only closes the Escape route). + +`onToast` now receives **only** `toast` items. A provider with no `onToast` remains +the supported store-only mode (a bell reading `notifications`/`unreadCount`), but +raising one of the other four types with its surface unmounted warns in dev, naming +the component to mount — that failure used to be silent. + +`NOTIFICATION_PRESENTATIONS` is typed `Record`, so a new +member in the spec enum fails type-check until its presentation is decided; a parity +test additionally asserts the table covers `NotificationTypeSchema` exactly and that +no two types share a surface. diff --git a/content/docs/guide/meta.json b/content/docs/guide/meta.json index eaeee8189..dbfc85af4 100644 --- a/content/docs/guide/meta.json +++ b/content/docs/guide/meta.json @@ -21,6 +21,7 @@ "designing-app-navigation", "public-forms", "record-edit-modes", + "notifications", "dashboard-filters", "slotted-pages", "react-pages", diff --git a/content/docs/guide/notifications.md b/content/docs/guide/notifications.md new file mode 100644 index 000000000..dbfec1a64 --- /dev/null +++ b/content/docs/guide/notifications.md @@ -0,0 +1,125 @@ +--- +title: Notifications +description: The five spec display types and the surface that renders each +--- + +# Notifications + +ObjectUI's notification system implements the spec `NotificationSchema` +(`@objectstack/spec` → `ui/notification.zod.ts`). A notification carries two +independent axes: + +- **`severity`** — `info` / `success` / `warning` / `error`. Picks the icon and tone. +- **`displayType`** — `toast` / `snackbar` / `banner` / `alert` / `inline`. Picks the + **surface**: where and how it appears. + +`displayType` used to be stored and never read, so every type surfaced as a +toast — an author asking for a `banner` got a transient overlay. Each type now +has a presentation of its own. + +## The five presentations + +| `displayType` | Presentation | Rendered by | Persists | +|---|---|---|---| +| `toast` | Transient overlay | the host's `onToast` delegate (sonner in the console) | no — auto-dismiss | +| `snackbar` | Bottom-anchored bar, one at a time, at most one action | `` | no — auto-dismiss | +| `banner` | Page-width strip **in the content flow** | `` | yes — until dismissed | +| `alert` | Blocking acknowledgement dialog, FIFO queue | `` | yes — until acknowledged | +| `inline` | In place, at the surface that raised it | `` | yes — until dismissed | + +Auto-dismiss follows the presentation, not a single global timer: `toast` and +`snackbar` are transient (`config.defaultDuration`, 5s by default), the other +three stay until dismissed. An explicit `duration` always wins — including +`duration: 0`, which makes a toast persistent. + +## Mounting the surfaces + +`toast` is delegated to the host; the other four are React components that +subscribe to the provider. Placement is deliberately yours: a banner needs a +slot in the content area and an inline notification belongs next to the thing +that raised it, so neither can be positioned by a global overlay. + +```tsx +import { + NotificationAlerts, + NotificationBanners, + NotificationInline, + NotificationSnackbar, +} from '@object-ui/components'; +import { NotificationProvider } from '@object-ui/react'; +import { toast } from 'sonner'; + + toast[n.severity](n.title, { description: n.message })} +> +
+ {/* top of the content area */} + +
+ + +
+``` + +A provider with **no** `onToast` is a supported "notification centre" mode: items +are collected in `notifications` / `unreadCount` for a bell or list, and nothing +is overlaid. Raising one of the other four types with its surface unmounted is a +mistake, though — dev builds warn, naming the component to mount. + +## Raising notifications + +```tsx +const { notify } = useNotifications(); + +notify({ title: 'Saved', severity: 'success' }); // toast (spec default) +notify({ title: 'Row deleted', severity: 'info', displayType: 'snackbar', + actions: [{ label: 'Undo', onClick: undo }] }); // one action +notify({ title: 'Viewing a draft', severity: 'warning', displayType: 'banner' }); +notify({ title: 'Session expired', severity: 'error', displayType: 'alert' }); +``` + +### `inline` and `scope` + +An inline notification is rendered by the surface that raised it. `scope` is the +routing key that pairs the two, so two forms on one page don't show each other's +messages: + +```tsx +notify({ + title: 'Fix 2 fields', severity: 'error', + displayType: 'inline', scope: 'contact-form', +}); + + +``` + +Omit `scope` on both ends for a page-level inline outlet. `scope` is renderer-local +routing metadata, not a spec field — the spec describes what a notification *is*, +not which React subtree hosts it. + +### `dismissible` + +Defaults to `true`. On the persistent presentations, `dismissible: false` removes +the dismiss control (a banner you must resolve rather than wave away). An `alert` +always keeps its acknowledge button — `dismissible: false` only closes the Escape +route, never the way out. + +## Notes + +- **`alert` is not the action system's modal.** `ModalHandler` resolves a page or + object, renders it, and reports an `ActionResult` back to the `ActionRunner`. A + notification alert has no schema, no target and no result — it is a message and + an acknowledgement, so it renders through the `AlertDialog` primitive instead. +- **`snackbar` is not a toast variant.** It supersedes rather than stacks, anchors + to the bottom regardless of the toast position config, and carries at most one + action. +- Adding a member to the spec `NotificationTypeSchema` fails type-check in + `NOTIFICATION_PRESENTATIONS` (`@object-ui/react`) until its presentation is + decided — new types cannot silently fall back to a toast. + +## Server-side notifications + +`useClientNotifications` bridges the `@objectstack/client` notifications API into +the same provider (ADR-0030). Fetched items are persistent and default to `toast`, +so a host that renders a bell from `notifications` needs no surface at all. diff --git a/packages/components/README.md b/packages/components/README.md index 2c7a2292e..240401c8d 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -150,6 +150,32 @@ function MyComponent() { - `link` - Link component - `breadcrumb` - Breadcrumb navigation +## Notification Surfaces + +Direct-import React components (not schema blocks) that render the notifications +raised through `NotificationProvider` from `@object-ui/react`. One per spec +`displayType`, so a `banner` no longer presents as a toast: + +| Component | `displayType` | Where to mount it | +| --- | --- | --- | +| `` | `snackbar` | anywhere inside the provider — it anchors itself bottom-center | +| `` | `banner` | top of the content area (it takes space in the flow) | +| `` | `alert` | anywhere inside the provider — blocking dialog, FIFO queue | +| `` | `inline` | in the surface that raises them | + +`toast` stays with the host's `onToast` delegate (sonner in the console). See the +[notifications guide](https://objectui.org/docs/guide/notifications). + +```tsx +import { NotificationBanners, NotificationAlerts } from '@object-ui/components'; + +
+ + + +
+``` + ## Customization ### Override Styles diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index ff6d7e87d..16bdb802d 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -56,6 +56,9 @@ export { export * from './ui'; export * from './custom'; +// Export the notification surfaces — one per spec `displayType` (#3014). +export * from './notifications'; + // Export hooks export { useConfigDraft } from './hooks/use-config-draft'; export type { UseConfigDraftOptions, UseConfigDraftReturn } from './hooks/use-config-draft'; diff --git a/packages/components/src/notifications/NotificationAlerts.tsx b/packages/components/src/notifications/NotificationAlerts.tsx new file mode 100644 index 000000000..dfc73b417 --- /dev/null +++ b/packages/components/src/notifications/NotificationAlerts.tsx @@ -0,0 +1,109 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `displayType: 'alert'` — a blocking acknowledgement. + * + * Why NOT the action system's `ModalHandler` (#3014, open question 2): that + * handler is `(schema, context) => Promise` — it resolves a page + * or object, renders it as a modal, and reports the outcome back to the + * `ActionRunner`. A notification alert has no schema, no target and no action + * result; it is a message plus an acknowledgement. Routing it there would mean + * synthesizing a page just to say "OK" and requiring an action runtime to + * present a notification. So `alert` renders through the Radix `AlertDialog` + * primitive — the blocking-acknowledgement primitive — which duplicates none of + * the action-modal machinery. + */ + +import * as React from 'react'; +import { useNotifications, useNotificationsByPresentation } from '@object-ui/react'; +import { cn } from '../lib/utils'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../ui/alert-dialog'; +import { notificationSeverityStyle } from './severity'; + +export interface NotificationAlertsProps { + /** Extra classes for the dialog content. */ + className?: string; + /** Label of the acknowledge button. Default `OK`. */ + acknowledgeLabel?: string; +} + +/** + * Renders `alert` notifications one at a time as a blocking dialog. + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export function NotificationAlerts({ className, acknowledgeLabel = 'OK' }: NotificationAlertsProps) { + const alerts = useNotificationsByPresentation('alert'); + const { dismiss } = useNotifications(); + + // The list is newest-first; a blocking queue is FIFO, so the OLDEST unhandled + // alert is the one the user is currently being asked to acknowledge. + const notification = alerts[alerts.length - 1]; + if (!notification) return null; + + const { Icon, iconTone } = notificationSeverityStyle(notification.severity); + const acknowledge = () => dismiss(notification.id); + // `dismissible: false` removes the wave-it-away paths (Escape); it never + // removes the acknowledge button — an alert nobody can acknowledge is a trap. + const blocking = notification.dismissible === false; + + return ( + { if (!open && !blocking) acknowledge(); }}> + { if (blocking) event.preventDefault(); }} + > + + + + {notification.message ? ( + {notification.message} + ) : null} + + + {notification.actions?.map((action) => ( + { action.onClick(); acknowledge(); }} + > + {action.label} + + ))} + + {acknowledgeLabel} + + + + + ); +} + +NotificationAlerts.displayName = 'NotificationAlerts'; diff --git a/packages/components/src/notifications/NotificationBanners.tsx b/packages/components/src/notifications/NotificationBanners.tsx new file mode 100644 index 000000000..8495fc1b7 --- /dev/null +++ b/packages/components/src/notifications/NotificationBanners.tsx @@ -0,0 +1,103 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `displayType: 'banner'` — a persistent, page-width strip. + * + * Unlike the overlay surfaces, a banner is IN FLOW: it takes vertical space and + * pushes the content down, which is what makes it read as a standing condition + * ("you are viewing a draft", "the connection dropped") rather than as an event + * that just happened. Placement is therefore the host's: mount this at the top + * of the content area, inside the `NotificationProvider`. + */ + +import * as React from 'react'; +import { X } from 'lucide-react'; +import { useNotifications, useNotificationsByPresentation } from '@object-ui/react'; +import { cn } from '../lib/utils'; +import { Button } from '../ui/button'; +import { notificationSeverityStyle } from './severity'; + +export interface NotificationBannersProps { + /** Extra classes for the banner stack container. */ + className?: string; + /** + * Cap on simultaneously rendered banners (newest first). Banners are + * persistent, so an uncapped stack can swallow the viewport. Default 3. + */ + max?: number; +} + +/** + * Renders every active `banner` notification as a stacked strip. + * + * @example + * ```tsx + *
+ * + * + *
+ * ``` + */ +export function NotificationBanners({ className, max = 3 }: NotificationBannersProps) { + const banners = useNotificationsByPresentation('banner'); + const { dismiss } = useNotifications(); + + if (banners.length === 0) return null; + + return ( +
+ {banners.slice(0, max).map((notification) => { + const { Icon, tone } = notificationSeverityStyle(notification.severity); + return ( +
+
+ ); + })} +
+ ); +} + +NotificationBanners.displayName = 'NotificationBanners'; diff --git a/packages/components/src/notifications/NotificationInline.tsx b/packages/components/src/notifications/NotificationInline.tsx new file mode 100644 index 000000000..941075f1b --- /dev/null +++ b/packages/components/src/notifications/NotificationInline.tsx @@ -0,0 +1,108 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `displayType: 'inline'` — rendered in place by the surface that raised it. + * + * The whole point of `inline` is proximity: a validation summary belongs above + * the form that failed, not in a corner of the viewport. So this component + * floats nothing and positions nothing — it renders where you put it, and a + * `scope` matches it to its raiser so two forms on one page don't show each + * other's messages. + */ + +import * as React from 'react'; +import { X } from 'lucide-react'; +import { useNotifications, useNotificationsByPresentation } from '@object-ui/react'; +import { cn } from '../lib/utils'; +import { Button } from '../ui/button'; +import { notificationSeverityStyle } from './severity'; + +export interface NotificationInlineProps { + /** + * Routing key — renders only notifications raised with the same `scope`. + * Omit on both ends for the page-level inline outlet. + */ + scope?: string; + /** Extra classes for the stack container. */ + className?: string; +} + +/** + * Renders the `inline` notifications raised for `scope`, in place. + * + * @example + * ```tsx + * // in the form that raises them + * notify({ title: 'Fix 2 fields', severity: 'error', displayType: 'inline', scope: 'contact-form' }); + * + * + * ``` + */ +export function NotificationInline({ scope, className }: NotificationInlineProps) { + const items = useNotificationsByPresentation('inline', scope); + const { dismiss } = useNotifications(); + + if (items.length === 0) return null; + + return ( +
+ {items.map((notification) => { + const { Icon, tone } = notificationSeverityStyle(notification.severity); + return ( +
+
+ ); + })} +
+ ); +} + +NotificationInline.displayName = 'NotificationInline'; diff --git a/packages/components/src/notifications/NotificationSnackbar.tsx b/packages/components/src/notifications/NotificationSnackbar.tsx new file mode 100644 index 000000000..10ed71782 --- /dev/null +++ b/packages/components/src/notifications/NotificationSnackbar.tsx @@ -0,0 +1,103 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `displayType: 'snackbar'` — a bottom-anchored transient with ONE action. + * + * Deliberately not a sonner variant. A snackbar is singular (a new one replaces + * the current, it does not stack), bottom-anchored regardless of the toast + * position config, and carries at most one action — "Undo" being the archetype. + * Routing it through the toast stack is exactly the collapse #3014 is about: it + * would look like a toast, so the spec's fifth member would mean nothing. + */ + +import * as React from 'react'; +import { X } from 'lucide-react'; +import { useNotifications, useNotificationsByPresentation } from '@object-ui/react'; +import { cn } from '../lib/utils'; +import { Button } from '../ui/button'; +import { notificationSeverityStyle } from './severity'; + +export interface NotificationSnackbarProps { + /** Extra classes for the snackbar container. */ + className?: string; +} + +/** + * Renders the most recent `snackbar` notification, anchored bottom-center. + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export function NotificationSnackbar({ className }: NotificationSnackbarProps) { + const snackbars = useNotificationsByPresentation('snackbar'); + const { dismiss } = useNotifications(); + + // Newest-first — a snackbar supersedes rather than stacks. + const notification = snackbars[0]; + if (!notification) return null; + + const { Icon, iconTone } = notificationSeverityStyle(notification.severity); + // The spec frames a snackbar as carrying "an optional single action"; extra + // actions belong on a banner or an alert. + const action = notification.actions?.[0]; + + return ( +
+
+ ); +} + +NotificationSnackbar.displayName = 'NotificationSnackbar'; diff --git a/packages/components/src/notifications/__tests__/notification-surfaces.test.tsx b/packages/components/src/notifications/__tests__/notification-surfaces.test.tsx new file mode 100644 index 000000000..fb805006b --- /dev/null +++ b/packages/components/src/notifications/__tests__/notification-surfaces.test.tsx @@ -0,0 +1,209 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Notification presentation coverage (#3014). + * + * `NotificationProvider` used to hand EVERY notification to the host's + * `onToast` delegate, so all five spec display types presented identically — + * an author picking `banner` got a toast. These tests pin the opposite: each + * spec `NotificationTypeSchema` member reaches its own surface, and the + * surfaces are distinguishable in the DOM. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NotificationTypeSchema } from '@objectstack/spec/ui'; +import { + NOTIFICATION_PRESENTATIONS, + NotificationProvider, + useNotifications, + type NotificationItem, + type NotificationPresentation, +} from '@object-ui/react'; +import { NotificationBanners } from '../NotificationBanners'; +import { NotificationSnackbar } from '../NotificationSnackbar'; +import { NotificationAlerts } from '../NotificationAlerts'; +import { NotificationInline } from '../NotificationInline'; + +function specDisplayTypes(): string[] { + const raw = (NotificationTypeSchema as { options?: readonly string[] }).options; + return Array.isArray(raw) ? [...raw] : []; +} + +type Notify = (input: Omit) => string; + +/** Populated once the harness has mounted; the tests raise through it. */ +const notifier: { current: Notify | null } = { current: null }; + +function Raiser() { + const { notify } = useNotifications(); + React.useEffect(() => { notifier.current = notify; }, [notify]); + return null; +} + +const raise: Notify = (input) => notifier.current!(input); + +function Harness({ onToast }: { onToast?: (n: NotificationItem) => void }) { + return ( + + + + + + + + + ); +} + +/** The surface element a notification title actually rendered into, if any. */ +function surfaceOf(title: string): string | null { + const host = screen.queryByText(title)?.closest('[data-notification-surface]'); + return host?.getAttribute('data-notification-surface') ?? null; +} + +describe('every spec display type has its own surface', () => { + it('the presentation table covers NotificationTypeSchema exactly', () => { + const spec = specDisplayTypes(); + expect(spec, 'could not read NotificationTypeSchema from the spec').not.toEqual([]); + expect([...spec].sort()).toEqual(Object.keys(NOTIFICATION_PRESENTATIONS).sort()); + }); + + it('no two display types share a surface', () => { + const surfaces = Object.values(NOTIFICATION_PRESENTATIONS).map((p) => p.surface); + expect(new Set(surfaces).size).toBe(surfaces.length); + }); +}); + +describe('notification surfaces', () => { + beforeEach(() => { + render(); + }); + + it('renders a banner as an in-flow strip, not an overlay', () => { + act(() => { raise({ title: 'Draft mode', message: 'Unpublished', severity: 'warning', displayType: 'banner' }); }); + + expect(surfaceOf('Draft mode')).toBe('banner'); + expect(screen.getByText('Unpublished')).toBeInTheDocument(); + // A banner takes space in the flow — never `fixed`/`absolute` positioned. + const strip = screen.getByText('Draft mode').closest('[data-notification-surface="banner"]')!; + expect(strip.className).not.toMatch(/\b(fixed|absolute)\b/); + }); + + it('renders a snackbar bottom-anchored, superseding rather than stacking', () => { + act(() => { raise({ title: 'Item moved', severity: 'info', displayType: 'snackbar' }); }); + expect(surfaceOf('Item moved')).toBe('snackbar'); + + act(() => { raise({ title: 'Item deleted', severity: 'info', displayType: 'snackbar' }); }); + expect(surfaceOf('Item deleted')).toBe('snackbar'); + expect(screen.queryByText('Item moved')).not.toBeInTheDocument(); + + const bar = screen.getByText('Item deleted').closest('[data-notification-surface="snackbar"]')!; + expect(bar.className).toMatch(/fixed/); + expect(bar.className).toMatch(/bottom-4/); + }); + + it('renders an alert as a blocking dialog with an acknowledgement', async () => { + const user = userEvent.setup(); + act(() => { raise({ title: 'Session expired', message: 'Sign in again', severity: 'error', displayType: 'alert' }); }); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveAttribute('data-notification-surface', 'alert'); + expect(screen.getByText('Session expired')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'OK' })); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('queues alerts FIFO — the oldest is the one being acknowledged', async () => { + const user = userEvent.setup(); + act(() => { + raise({ title: 'First alert', severity: 'error', displayType: 'alert' }); + raise({ title: 'Second alert', severity: 'error', displayType: 'alert' }); + }); + + await screen.findByRole('alertdialog'); + expect(screen.getByText('First alert')).toBeInTheDocument(); + expect(screen.queryByText('Second alert')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'OK' })); + expect(await screen.findByText('Second alert')).toBeInTheDocument(); + }); + + it('renders inline notifications at the raising surface only', () => { + act(() => { + raise({ title: 'Fix 2 fields', severity: 'error', displayType: 'inline', scope: 'form-a' }); + raise({ title: 'Page notice', severity: 'info', displayType: 'inline' }); + }); + + const scoped = screen.getByText('Fix 2 fields').closest('[data-notification-surface="inline"]')!; + expect(scoped).toHaveAttribute('data-scope', 'form-a'); + const unscoped = screen.getByText('Page notice').closest('[data-notification-surface="inline"]')!; + expect(unscoped).not.toHaveAttribute('data-scope'); + // In place, never floating. + expect(scoped.className).not.toMatch(/\b(fixed|absolute)\b/); + }); + + it('leaves toasts to the host delegate — no surface renders them', () => { + act(() => { raise({ title: 'Saved', severity: 'success', displayType: 'toast' }); }); + + expect(screen.queryByText('Saved')).not.toBeInTheDocument(); + }); + + it('dismisses a banner from its own control', async () => { + const user = userEvent.setup(); + act(() => { raise({ title: 'Dismiss me', severity: 'info', displayType: 'banner' }); }); + + await user.click(screen.getByRole('button', { name: 'Dismiss Dismiss me' })); + expect(screen.queryByText('Dismiss me')).not.toBeInTheDocument(); + }); + + it('omits the dismiss control when dismissible is false', () => { + act(() => { raise({ title: 'Sticky', severity: 'warning', displayType: 'banner', dismissible: false }); }); + + expect(screen.queryByRole('button', { name: 'Dismiss Sticky' })).not.toBeInTheDocument(); + }); + + it('runs a snackbar action and clears the snackbar', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + act(() => { + raise({ + title: 'Row deleted', severity: 'info', displayType: 'snackbar', + actions: [{ label: 'Undo', onClick }], + }); + }); + + await user.click(screen.getByRole('button', { name: 'Undo' })); + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Row deleted')).not.toBeInTheDocument(); + }); +}); + +describe('presentation coverage', () => { + it('presents a notification of EVERY spec display type distinctly', () => { + const onToast = vi.fn(); + render(); + + const seen = new Map(); + for (const displayType of specDisplayTypes()) { + const title = `Notice ${displayType}`; + act(() => { raise({ title, severity: 'info', displayType: displayType as NotificationPresentation }); }); + // `toast` is presented by the host delegate, everything else by a surface. + seen.set(displayType, surfaceOf(title) ?? 'delegate'); + } + + expect(onToast).toHaveBeenCalledTimes(1); + // One surface per display type — the collapse this issue is about would + // show the same value five times (or `delegate` for all of them). + expect(new Set(seen.values()).size).toBe(seen.size); + }); +}); diff --git a/packages/components/src/notifications/index.ts b/packages/components/src/notifications/index.ts new file mode 100644 index 000000000..f6366d096 --- /dev/null +++ b/packages/components/src/notifications/index.ts @@ -0,0 +1,32 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Notification surfaces — one per spec `NotificationTypeSchema` member. + * + * | `displayType` | Surface | Placement | + * |---|---|---| + * | `toast` | the host's `onToast` delegate (sonner in the console) | overlay, host-owned | + * | `snackbar` | {@link NotificationSnackbar} | bottom-anchored, one at a time | + * | `banner` | {@link NotificationBanners} | in flow, top of the content area | + * | `alert` | {@link NotificationAlerts} | blocking dialog, FIFO queue | + * | `inline` | {@link NotificationInline} | in place, at the raising surface | + * + * Every one of these used to be handed to `onToast` regardless of type, so all + * five looked identical (#3014). + */ + +export { NotificationBanners, type NotificationBannersProps } from './NotificationBanners'; +export { NotificationSnackbar, type NotificationSnackbarProps } from './NotificationSnackbar'; +export { NotificationAlerts, type NotificationAlertsProps } from './NotificationAlerts'; +export { NotificationInline, type NotificationInlineProps } from './NotificationInline'; +export { + NOTIFICATION_SEVERITY_STYLES, + notificationSeverityStyle, + type NotificationSeverityStyle, +} from './severity'; diff --git a/packages/components/src/notifications/severity.ts b/packages/components/src/notifications/severity.ts new file mode 100644 index 000000000..33987696c --- /dev/null +++ b/packages/components/src/notifications/severity.ts @@ -0,0 +1,65 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Severity presentation shared by the four notification surfaces. + * + * `severity` (info/success/warning/error) and `displayType` are ORTHOGONAL in + * the spec: severity picks the icon and tone, `displayType` picks the surface. + * Keeping the tone table here is what lets a `warning` look the same whether it + * arrives as a banner or as an inline message. + */ + +import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from 'lucide-react'; +import type { NotificationSeverityLevel } from '@object-ui/react'; + +export interface NotificationSeverityStyle { + /** Leading icon. */ + Icon: LucideIcon; + /** Border + surface + text tone for the in-flow surfaces (banner, inline). */ + tone: string; + /** Icon tint for surfaces that bring their own chrome (snackbar, alert). */ + iconTone: string; +} + +/** + * Typed as `Record` so a new severity in the spec + * `NotificationSeveritySchema` fails type-check here until it has a tone. + */ +export const NOTIFICATION_SEVERITY_STYLES: Record< + NotificationSeverityLevel, + NotificationSeverityStyle +> = { + info: { + Icon: Info, + tone: 'border-border bg-muted/50 text-foreground', + iconTone: 'text-muted-foreground', + }, + success: { + Icon: CheckCircle2, + tone: 'border-emerald-300/70 bg-emerald-50 text-emerald-900 dark:border-emerald-700/60 dark:bg-emerald-950/40 dark:text-emerald-200', + iconTone: 'text-emerald-600 dark:text-emerald-400', + }, + warning: { + Icon: AlertTriangle, + tone: 'border-amber-300/70 bg-amber-50 text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/40 dark:text-amber-200', + iconTone: 'text-amber-600 dark:text-amber-400', + }, + error: { + Icon: XCircle, + tone: 'border-destructive/50 bg-destructive/10 text-destructive dark:text-destructive-foreground', + iconTone: 'text-destructive', + }, +}; + +/** Resolve a severity's presentation, defaulting to the spec default (`info`). */ +export function notificationSeverityStyle( + severity?: NotificationSeverityLevel, +): NotificationSeverityStyle { + return NOTIFICATION_SEVERITY_STYLES[severity ?? 'info'] ?? NOTIFICATION_SEVERITY_STYLES.info; +} diff --git a/packages/react/README.md b/packages/react/README.md index 015e24ff9..ec6fd434e 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -182,6 +182,38 @@ The `previewMode` object contains: | `expiresInSeconds` | `number` | `0` | Session duration (0 = no expiry) | | `bannerMessage` | `string` | — | UI banner message | +### useNotifications / useNotificationsByPresentation + +`NotificationProvider` implements the spec `NotificationSchema`. A notification's +`severity` picks its icon and tone; its `displayType` picks the **surface** that +renders it — and each of the five spec types has a distinct one: + +| `displayType` | Presentation | Rendered by | Auto-dismiss | +| --- | --- | --- | --- | +| `toast` | transient overlay | the `onToast` delegate | yes | +| `snackbar` | bottom-anchored bar, one at a time, one action | `` | yes | +| `banner` | page-width strip in the content flow | `` | no | +| `alert` | blocking acknowledgement dialog (FIFO) | `` | no | +| `inline` | in place, at the raising surface | `` | no | + +The surface components ship in `@object-ui/components`; mount them where they +belong (a banner is in flow, an inline notification sits next to its raiser). +`onToast` receives **only** `toast` items — it used to receive all five, which is +why every type looked like a toast. + +```tsx +const { notify } = useNotifications() + +notify({ title: 'Saved', severity: 'success' }) // toast (spec default) +notify({ title: 'Viewing a draft', severity: 'warning', displayType: 'banner' }) +notify({ title: 'Fix 2 fields', severity: 'error', displayType: 'inline', scope: 'contact-form' }) +``` + +A surface component subscribes with `useNotificationsByPresentation(type, scope?)`, +which also registers the surface — raising a `banner` with no banner surface +mounted warns in dev instead of vanishing. See the +[notifications guide](https://objectui.org/docs/guide/notifications). + ## API Reference See [full documentation](https://objectui.org/api/react) for detailed API reference. diff --git a/packages/react/src/context/NotificationContext.tsx b/packages/react/src/context/NotificationContext.tsx index 6f923806e..c65c3860a 100644 --- a/packages/react/src/context/NotificationContext.tsx +++ b/packages/react/src/context/NotificationContext.tsx @@ -14,27 +14,98 @@ * NotificationActionSchema from @objectstack/spec v2.0.7. */ -import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; /** Notification severity levels aligned with NotificationSeveritySchema */ export type NotificationSeverityLevel = 'info' | 'success' | 'warning' | 'error'; /** - * Notification display type — the spec `NotificationTypeSchema` + * Notification presentation — the spec `NotificationTypeSchema` * (`ui/notification.zod.ts`: toast / snackbar / banner / alert / inline). - * This union used to claim alignment while carrying a renderer-local `modal` - * and missing `alert` / `inline` (#2942); `modal` stays accepted as a - * deprecated legacy spelling for stored items. + * Every member has a DISTINCT presentation; see {@link NOTIFICATION_PRESENTATIONS}. */ -export type NotificationDisplayType = +export type NotificationPresentation = | 'toast' | 'snackbar' | 'banner' | 'alert' - | 'inline' + | 'inline'; + +/** + * Notification display type as AUTHORED. Identical to + * {@link NotificationPresentation} plus the deprecated renderer-local `modal` + * spelling, which stored items may still carry (#2942) and which resolves to + * `alert`. Use {@link resolveNotificationPresentation} to get the presentation. + */ +export type NotificationDisplayType = + | NotificationPresentation /** @deprecated renderer dialect — never in the spec; presented as `alert` */ | 'modal'; +/** + * Where a presentation is rendered. + * + * - `delegate` — handed to the provider's `onToast` callback; the host owns the + * overlay (sonner in the console). Historically EVERY type took this path, + * which is why a `banner` surfaced as a toast (#3014). + * - everything else names a surface component that subscribes to the context + * and renders the items in place. `@object-ui/components` ships one per + * surface (`NotificationSnackbar` / `NotificationBanners` / + * `NotificationAlerts` / `NotificationInline`). + */ +export type NotificationSurface = 'delegate' | 'snackbar' | 'banner' | 'alert' | 'inline'; + +export interface NotificationPresentationBehavior { + /** The surface responsible for rendering this presentation. */ + surface: NotificationSurface; + /** + * Whether the presentation auto-dismisses when the raiser declares no + * `duration`. A toast/snackbar is transient by definition; a banner is a + * persistent strip and an alert is a blocking acknowledgement, so neither may + * evaporate on the shared 5s timer. An explicit `duration` always wins. + */ + transient: boolean; +} + +/** + * The presentation table — one entry per spec `NotificationTypeSchema` member. + * + * Typed as `Record`, so adding a member to the + * spec enum fails type-check here until its presentation is decided, rather + * than silently falling back to a toast. + */ +export const NOTIFICATION_PRESENTATIONS: Record< + NotificationPresentation, + NotificationPresentationBehavior +> = { + toast: { surface: 'delegate', transient: true }, + snackbar: { surface: 'snackbar', transient: true }, + banner: { surface: 'banner', transient: false }, + alert: { surface: 'alert', transient: false }, + inline: { surface: 'inline', transient: false }, +}; + +/** + * Resolve an authored `displayType` to the presentation that renders it: + * the spec default (`toast`) when absent, and `alert` for the deprecated + * `modal` dialect. + */ +export function resolveNotificationPresentation( + displayType?: NotificationDisplayType, +): NotificationPresentation { + if (displayType === 'modal') return 'alert'; + if (displayType && displayType in NOTIFICATION_PRESENTATIONS) return displayType; + return 'toast'; +} + /** * Notification position — the spec `NotificationPositionSchema` * (underscore spellings). The hyphen forms are this context's historical @@ -65,9 +136,9 @@ export type NotificationPositionValue = * tests (#2942), which fail the moment `NotificationTypeSchema` / * `NotificationPositionSchema` and these sets drift in either direction. */ -export const SUPPORTED_NOTIFICATION_DISPLAY_TYPES: ReadonlySet = new Set([ - 'toast', 'snackbar', 'banner', 'alert', 'inline', -]); +export const SUPPORTED_NOTIFICATION_DISPLAY_TYPES: ReadonlySet = new Set( + Object.keys(NOTIFICATION_PRESENTATIONS), +); export const SUPPORTED_NOTIFICATION_POSITIONS: ReadonlySet = new Set([ 'top_left', 'top_center', 'top_right', 'bottom_left', 'bottom_center', 'bottom_right', ]); @@ -85,10 +156,33 @@ export interface NotificationItem { title: string; message?: string; severity: NotificationSeverityLevel; + /** + * The presentation the raiser asked for. Materialized by `notify()` — stored + * items always carry a resolved {@link NotificationPresentation}, never + * `modal` and never `undefined`. + */ displayType?: NotificationDisplayType; actions?: NotificationActionButton[]; - /** Duration in ms (0 = persistent). Default: 5000 */ + /** + * Duration in ms (0 = persistent). Defaults to the configured duration for + * TRANSIENT presentations (toast / snackbar) and to persistent for the rest — + * a banner that evaporates after 5s is not a banner. + */ duration?: number; + /** + * Whether the surface offers a dismiss control (spec `dismissible`, default + * `true`). Only meaningful for the persistent presentations — a transient + * one dismisses itself. + */ + dismissible?: boolean; + /** + * `inline` only — names the surface that raised it, so the matching + * `` renders it in place instead of every + * inline outlet on the page showing every inline notification. Renderer-local + * routing metadata, not a spec field: the spec describes what a notification + * IS, not which React subtree hosts it. + */ + scope?: string; /** Whether the notification has been read */ read?: boolean; /** Timestamp */ @@ -113,7 +207,14 @@ export interface NotificationProviderProps { children: React.ReactNode; /** System configuration */ config?: NotificationSystemConfig; - /** External toast handler (e.g., Sonner) for rendering toasts */ + /** + * External toast handler (e.g. Sonner) — the `toast` presentation's surface. + * + * Called ONLY for `displayType: 'toast'`. It used to receive every + * notification, which is why the other four spec types all surfaced as + * toasts (#3014); they now render through their own surface components, + * which subscribe via {@link useNotificationsByPresentation}. + */ onToast?: (notification: NotificationItem) => void; } @@ -142,22 +243,58 @@ interface NotificationContextValue { clearAll: () => void; /** System configuration */ config: NotificationSystemConfig; + /** + * Announce that a surface is mounted and will render `surface` items. + * Returns the unregister callback. Surfaces call this from an effect; the + * provider uses it only to warn (dev builds) when a notification is raised + * with nothing on screen able to present it. + */ + registerSurface: (surface: NotificationSurface) => () => void; } let notificationCounter = 0; +/** Dev-build check — same idiom as `SchemaRenderer`'s `__DEV__`. */ +const __DEV__ = (() => { + try { + return (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV + !== 'production'; + } catch { + return true; + } +})(); + +/** Dev-only guidance for a presentation raised with no surface mounted. */ +const SURFACE_HINTS: Record = { + delegate: 'pass `onToast` to ', + snackbar: 'mount from @object-ui/components', + banner: 'mount from @object-ui/components', + alert: 'mount from @object-ui/components', + inline: 'mount from @object-ui/components at the raising surface', +}; + const NotificationCtx = createContext(null); /** * NotificationProvider — Provides a spec-driven notification system. * + * Each spec display type is presented by its own surface. `toast` is delegated + * to the host (`onToast`); the other four are rendered by surface components + * that subscribe to this context — mount them where they belong: + * `NotificationSnackbar` / `NotificationAlerts` anywhere inside the provider, + * `NotificationBanners` at the top of the content area, and + * `NotificationInline` in the surface that raises the notification. + * * @example * ```tsx * toast[n.severity](n.title, { description: n.message })} * > + * * + * + * * * ``` */ @@ -180,16 +317,31 @@ export const NotificationProvider: React.FC = ({ const [notifications, setNotifications] = useState([]); + // Surfaces currently mounted, by presentation surface. A ref (not state) so + // `notify` can read the live set without re-creating itself on every mount. + const mountedSurfaces = useRef>(new Map()); + + const registerSurface = useCallback((surface: NotificationSurface) => { + const counts = mountedSurfaces.current; + counts.set(surface, (counts.get(surface) ?? 0) + 1); + return () => { + const next = (counts.get(surface) ?? 1) - 1; + if (next > 0) counts.set(surface, next); + else counts.delete(surface); + }; + }, []); + const notify = useCallback( (input: Omit): string => { const id = `notification-${++notificationCounter}`; + // Materialize the declared presentation (spec default: toast; the legacy + // `modal` dialect presents as its nearest spec family, alert) so every + // stored item carries the presentation that will render it (#2942). + const presentation = resolveNotificationPresentation(input.displayType); + const { surface, transient } = NOTIFICATION_PRESENTATIONS[presentation]; const notification: NotificationItem = { ...input, - // Materialize the declared presentation (spec default: toast; the - // legacy `modal` dialect presents as its nearest spec family, alert) - // so the delegate can branch on it — it used to be stored and never - // read anywhere (#2942). - displayType: input.displayType === 'modal' ? 'alert' : (input.displayType ?? 'toast'), + displayType: presentation, id, createdAt: new Date(), read: false, @@ -197,13 +349,28 @@ export const NotificationProvider: React.FC = ({ setNotifications((prev) => [notification, ...prev]); - // Delegate to external toast handler if provided - if (onToast) { - onToast(notification); + // Route to the presentation's surface. `onToast` is the `toast` surface — + // it used to receive EVERY type, so a banner/alert/inline notification + // presented as a toast (#3014). The other four are rendered by the + // surface components subscribing through the context below. + if (surface === 'delegate') onToast?.(notification); + + // A surface-rendered presentation with no surface mounted is invisible — + // the "silently absent" shape this whole issue is about. Say so loudly in + // dev. `toast` is exempt: a provider with no `onToast` is the supported + // store-only mode (a notification centre with no overlay). + if (__DEV__ && surface !== 'delegate' && (mountedSurfaces.current.get(surface) ?? 0) === 0) { + console.warn( + `[NotificationProvider] "${notification.title}" declares displayType ` + + `'${presentation}' but no ${presentation} surface is mounted, so it will ` + + `not be shown. To present it, ${SURFACE_HINTS[surface]}.`, + ); } - // Auto-dismiss non-persistent notifications - const duration = input.duration ?? config.defaultDuration ?? 5000; + // Auto-dismiss transient presentations. A banner/alert/inline stays until + // it is dismissed unless the raiser asked for a duration explicitly — + // sharing the toast timer made "persistent" presentations evaporate. + const duration = input.duration ?? (transient ? config.defaultDuration ?? 5000 : 0); if (duration > 0) { setTimeout(() => { setNotifications((prev) => prev.filter((n) => n.id !== id)); @@ -276,6 +443,7 @@ export const NotificationProvider: React.FC = ({ dismiss, clearAll, config, + registerSurface, }), [ notifications, @@ -290,6 +458,7 @@ export const NotificationProvider: React.FC = ({ dismiss, clearAll, config, + registerSurface, ], ); @@ -324,3 +493,36 @@ export function useNotifications(): NotificationContextValue { export function useHasNotificationProvider(): boolean { return useContext(NotificationCtx) !== null; } + +/** + * Subscribe to the live notifications for ONE presentation — the contract a + * surface component implements (`@object-ui/components` ships one per spec + * type). Registering also tells the provider the surface exists, so raising a + * `banner` with no banner surface mounted warns in dev instead of vanishing. + * + * Items come back newest-first, matching `notifications`. A surface that wants + * FIFO order (an `alert` queue) reverses them itself. + * + * @param presentation the spec display type this surface renders + * @param scope optional `inline` routing key — when set, only items raised with + * the same `scope` are returned; when omitted, only unscoped items are. + */ +export function useNotificationsByPresentation( + presentation: NotificationPresentation, + scope?: string, +): NotificationItem[] { + const { notifications, registerSurface } = useNotifications(); + const { surface } = NOTIFICATION_PRESENTATIONS[presentation]; + + useEffect(() => registerSurface(surface), [registerSurface, surface]); + + return useMemo( + () => + notifications.filter( + (n) => + resolveNotificationPresentation(n.displayType) === presentation && + (presentation !== 'inline' || (n.scope ?? undefined) === scope), + ), + [notifications, presentation, scope], + ); +} diff --git a/packages/react/src/context/__tests__/NotificationContext.test.tsx b/packages/react/src/context/__tests__/NotificationContext.test.tsx index fc1804af4..89d3b49e4 100644 --- a/packages/react/src/context/__tests__/NotificationContext.test.tsx +++ b/packages/react/src/context/__tests__/NotificationContext.test.tsx @@ -1,13 +1,17 @@ /** * Tests for NotificationProvider and useNotifications */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import React from 'react'; import { + NOTIFICATION_PRESENTATIONS, NotificationProvider, + resolveNotificationPresentation, useNotifications, + useNotificationsByPresentation, useHasNotificationProvider, + type NotificationPresentation, } from '../NotificationContext'; const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -155,6 +159,199 @@ describe('NotificationProvider', () => { }); }); +/** + * Presentation routing (#3014). + * + * `onToast` used to receive EVERY notification, so a `banner` and an `inline` + * both surfaced as a toast — the value reached the delegate but nothing branched + * on it. Each presentation now goes to its own surface. + */ +describe('displayType routes to a distinct presentation', () => { + const warn = vi.spyOn(console, 'warn'); + beforeEach(() => { warn.mockImplementation(() => {}); }); + afterEach(() => { warn.mockReset(); }); + + function setup(onToast?: (n: unknown) => void) { + const customWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} + ); + return renderHook( + () => ({ + ctx: useNotifications(), + toast: useNotificationsByPresentation('toast'), + snackbar: useNotificationsByPresentation('snackbar'), + banner: useNotificationsByPresentation('banner'), + alert: useNotificationsByPresentation('alert'), + inline: useNotificationsByPresentation('inline'), + }), + { wrapper: customWrapper }, + ); + } + + it('hands the toast delegate ONLY the toast notifications', () => { + const onToast = vi.fn(); + const { result } = setup(onToast); + + act(() => { + result.current.ctx.notify({ title: 'A toast', severity: 'info', displayType: 'toast' }); + result.current.ctx.notify({ title: 'A banner', severity: 'warning', displayType: 'banner' }); + result.current.ctx.notify({ title: 'A snackbar', severity: 'info', displayType: 'snackbar' }); + result.current.ctx.notify({ title: 'An alert', severity: 'error', displayType: 'alert' }); + result.current.ctx.notify({ title: 'Inline', severity: 'info', displayType: 'inline' }); + }); + + expect(onToast).toHaveBeenCalledTimes(1); + expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'A toast' })); + }); + + it('exposes each notification to exactly one presentation surface', () => { + const { result } = setup(vi.fn()); + + act(() => { + result.current.ctx.notify({ title: 'A toast', severity: 'info', displayType: 'toast' }); + result.current.ctx.notify({ title: 'A banner', severity: 'warning', displayType: 'banner' }); + result.current.ctx.notify({ title: 'A snackbar', severity: 'info', displayType: 'snackbar' }); + result.current.ctx.notify({ title: 'An alert', severity: 'error', displayType: 'alert' }); + result.current.ctx.notify({ title: 'Inline', severity: 'info', displayType: 'inline' }); + }); + + expect(result.current.toast.map((n) => n.title)).toEqual(['A toast']); + expect(result.current.banner.map((n) => n.title)).toEqual(['A banner']); + expect(result.current.snackbar.map((n) => n.title)).toEqual(['A snackbar']); + expect(result.current.alert.map((n) => n.title)).toEqual(['An alert']); + expect(result.current.inline.map((n) => n.title)).toEqual(['Inline']); + }); + + it('defaults to toast and resolves the deprecated modal dialect to alert', () => { + const onToast = vi.fn(); + const { result } = setup(onToast); + + act(() => { + result.current.ctx.notify({ title: 'Default', severity: 'info' }); + result.current.ctx.notify({ title: 'Legacy', severity: 'info', displayType: 'modal' }); + }); + + expect(result.current.toast.map((n) => n.title)).toEqual(['Default']); + expect(result.current.alert.map((n) => n.title)).toEqual(['Legacy']); + // The stored item carries the RESOLVED presentation, never `modal`. + expect(result.current.alert[0].displayType).toBe('alert'); + expect(onToast).toHaveBeenCalledTimes(1); + }); + + it('keeps the persistent presentations on screen past the transient timer', () => { + vi.useFakeTimers(); + try { + const { result } = setup(vi.fn()); + + act(() => { + result.current.ctx.notify({ title: 'A toast', severity: 'info', displayType: 'toast' }); + result.current.ctx.notify({ title: 'A snackbar', severity: 'info', displayType: 'snackbar' }); + result.current.ctx.notify({ title: 'A banner', severity: 'warning', displayType: 'banner' }); + result.current.ctx.notify({ title: 'An alert', severity: 'error', displayType: 'alert' }); + result.current.ctx.notify({ title: 'Inline', severity: 'info', displayType: 'inline' }); + }); + + act(() => { vi.advanceTimersByTime(10_000); }); + + // A banner that evaporates after the shared 5s toast timer is not a banner. + expect(result.current.ctx.notifications.map((n) => n.title)) + .toEqual(['Inline', 'An alert', 'A banner']); + } finally { + vi.useRealTimers(); + } + }); + + it('honors an explicit duration on a persistent presentation', () => { + vi.useFakeTimers(); + try { + const { result } = setup(vi.fn()); + + act(() => { + result.current.ctx.notify({ + title: 'Timed banner', severity: 'info', displayType: 'banner', duration: 1000, + }); + }); + act(() => { vi.advanceTimersByTime(1500); }); + + expect(result.current.ctx.notifications).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it('routes inline notifications by scope', () => { + const scoped: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} + ); + const { result } = renderHook( + () => ({ + ctx: useNotifications(), + formA: useNotificationsByPresentation('inline', 'form-a'), + formB: useNotificationsByPresentation('inline', 'form-b'), + unscoped: useNotificationsByPresentation('inline'), + }), + { wrapper: scoped }, + ); + + act(() => { + result.current.ctx.notify({ title: 'A', severity: 'error', displayType: 'inline', scope: 'form-a' }); + result.current.ctx.notify({ title: 'B', severity: 'error', displayType: 'inline', scope: 'form-b' }); + result.current.ctx.notify({ title: 'Page', severity: 'info', displayType: 'inline' }); + }); + + expect(result.current.formA.map((n) => n.title)).toEqual(['A']); + expect(result.current.formB.map((n) => n.title)).toEqual(['B']); + expect(result.current.unscoped.map((n) => n.title)).toEqual(['Page']); + }); + + it('warns when a surface-rendered presentation has no surface mounted', () => { + const { result } = renderHook(() => useNotifications(), { wrapper }); + + act(() => { + result.current.notify({ title: 'Orphan', severity: 'warning', displayType: 'banner' }); + }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("declares displayType 'banner'")); + }); + + it('stays quiet for a store-only provider raising toasts', () => { + // No `onToast` is the supported notification-centre mode, not a mistake. + const { result } = renderHook(() => useNotifications(), { wrapper }); + + act(() => { result.current.info('Just stored'); }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('does not warn once the surface is mounted', () => { + const { result } = renderHook( + () => ({ ctx: useNotifications(), banners: useNotificationsByPresentation('banner') }), + { wrapper }, + ); + + act(() => { + result.current.ctx.notify({ title: 'Seen', severity: 'info', displayType: 'banner' }); + }); + + expect(warn).not.toHaveBeenCalled(); + expect(result.current.banners).toHaveLength(1); + }); +}); + +describe('presentation table', () => { + it('gives every display type its own surface', () => { + const surfaces = Object.values(NOTIFICATION_PRESENTATIONS).map((p) => p.surface); + // The bug: five display types, one surface. Distinct surfaces is the fix. + expect(new Set(surfaces).size).toBe(surfaces.length); + }); + + it('resolves unknown / absent display types to the spec default', () => { + expect(resolveNotificationPresentation(undefined)).toBe('toast'); + expect(resolveNotificationPresentation('modal')).toBe('alert'); + expect(resolveNotificationPresentation('nonsense' as NotificationPresentation)).toBe('toast'); + }); +}); + describe('useHasNotificationProvider', () => { it('returns false outside provider', () => { const { result } = renderHook(() => useHasNotificationProvider());