Skip to content
Merged
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
58 changes: 58 additions & 0 deletions .changeset/notification-display-types-present-distinctly.md
Original file line number Diff line number Diff line change
@@ -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 | `<NotificationSnackbar />` |
| `banner` | page-width strip **in the content flow** | `<NotificationBanners />` |
| `alert` | blocking acknowledgement dialog, FIFO queue | `<NotificationAlerts />` |
| `inline` | in place, at the raising surface | `<NotificationInline scope="…" />` |

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<NotificationPresentation, …>`, 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.
1 change: 1 addition & 0 deletions content/docs/guide/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"designing-app-navigation",
"public-forms",
"record-edit-modes",
"notifications",
"dashboard-filters",
"slotted-pages",
"react-pages",
Expand Down
125 changes: 125 additions & 0 deletions content/docs/guide/notifications.md
Original file line number Diff line number Diff line change
@@ -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 | `<NotificationSnackbar />` | no — auto-dismiss |
| `banner` | Page-width strip **in the content flow** | `<NotificationBanners />` | yes — until dismissed |
| `alert` | Blocking acknowledgement dialog, FIFO queue | `<NotificationAlerts />` | yes — until acknowledged |
| `inline` | In place, at the surface that raised it | `<NotificationInline />` | 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';

<NotificationProvider
config={{ position: 'top_right', defaultDuration: 5000, maxVisible: 5 }}
onToast={(n) => toast[n.severity](n.title, { description: n.message })}
>
<main>
<NotificationBanners /> {/* top of the content area */}
<Outlet />
</main>
<NotificationSnackbar />
<NotificationAlerts />
</NotificationProvider>
```

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',
});

<NotificationInline scope="contact-form" className="mb-4" />
```

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.
26 changes: 26 additions & 0 deletions packages/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
| `<NotificationSnackbar />` | `snackbar` | anywhere inside the provider — it anchors itself bottom-center |
| `<NotificationBanners />` | `banner` | top of the content area (it takes space in the flow) |
| `<NotificationAlerts />` | `alert` | anywhere inside the provider — blocking dialog, FIFO queue |
| `<NotificationInline scope="…" />` | `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';

<main>
<NotificationBanners />
<Outlet />
<NotificationAlerts />
</main>
```

## Customization

### Override Styles
Expand Down
3 changes: 3 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
109 changes: 109 additions & 0 deletions packages/components/src/notifications/NotificationAlerts.tsx
Original file line number Diff line number Diff line change
@@ -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<ActionResult>` — 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
* <NotificationProvider>
* <App />
* <NotificationAlerts />
* </NotificationProvider>
* ```
*/
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 (
<AlertDialog open onOpenChange={(open) => { if (!open && !blocking) acknowledge(); }}>
<AlertDialogContent
className={className}
data-notification-surface="alert"
data-severity={notification.severity}
onEscapeKeyDown={(event) => { if (blocking) event.preventDefault(); }}
>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Icon className={cn('h-5 w-5 shrink-0', iconTone)} aria-hidden="true" />
{notification.title}
</AlertDialogTitle>
{notification.message ? (
<AlertDialogDescription>{notification.message}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
{notification.actions?.map((action) => (
<AlertDialogAction
key={action.label}
className={cn(action.variant === 'destructive' && 'bg-destructive text-destructive-foreground hover:bg-destructive/90')}
onClick={() => { action.onClick(); acknowledge(); }}
>
{action.label}
</AlertDialogAction>
))}
<AlertDialogAction
className={cn(notification.actions?.length ? 'bg-secondary text-secondary-foreground hover:bg-secondary/80' : undefined)}
onClick={acknowledge}
>
{acknowledgeLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

NotificationAlerts.displayName = 'NotificationAlerts';
Loading
Loading