Skip to content

Commit 2a40b5e

Browse files
os-zhuangclaude
andauthored
feat(notifications): each spec displayType gets its own presentation (#3014) (#3071)
`NotificationProvider` handed every notification to the host's `onToast` delegate regardless of `displayType`, so all five spec types presented as a toast — an author picking `banner` or `inline` got a transient overlay. #3008 made the value reach the delegate; nothing branched on it. Each type now routes to its own surface: toast -> the host's `onToast` delegate (unchanged) snackbar -> <NotificationSnackbar /> bottom-anchored, one at a time, 1 action banner -> <NotificationBanners /> page-width strip, in the content flow alert -> <NotificationAlerts /> blocking acknowledgement, FIFO queue inline -> <NotificationInline /> in place, at the raising surface Banner/inline placement is the host's — they are not overlays — so the context exposes the items (`useNotificationsByPresentation`) and the surfaces subscribe. `alert` renders through the AlertDialog primitive, NOT the action system's ModalHandler: that handler resolves a page and reports an ActionResult, while a notification alert has no schema, target or result. Auto-dismiss follows the presentation: toast/snackbar stay transient, banner/alert/inline are persistent unless `duration` is set explicitly (a persistent banner used to evaporate on the shared 5s toast timer). `NOTIFICATION_PRESENTATIONS` is `Record<NotificationPresentation, …>`, so a new spec enum member fails type-check until its presentation is decided; the parity test asserts the table covers `NotificationTypeSchema` exactly and that no two types share a surface. Raising a surface-rendered type with nothing mounted to present it warns in dev instead of vanishing. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d61efd1 commit 2a40b5e

15 files changed

Lines changed: 1397 additions & 24 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@object-ui/react": minor
3+
"@object-ui/components": minor
4+
---
5+
6+
feat(notifications): each spec `displayType` gets its own presentation instead of a toast (#3014)
7+
8+
#3008 closed the **contract** half of this: `NotificationContext`'s union matched
9+
`NotificationTypeSchema`, and `notify()` materialized the declared type so a
10+
consumer *could* branch on it. Nothing did. `NotificationProvider` handed every
11+
item to the host's `onToast` delegate regardless of type, so an author picking
12+
`banner` or `inline` got a transient overlay — plausible output, wrong output.
13+
14+
Each of the five spec types now has a presentation of its own:
15+
16+
| `displayType` | Presentation | Rendered by |
17+
|---|---|---|
18+
| `toast` | transient overlay (unchanged) | the host's `onToast` delegate |
19+
| `snackbar` | bottom-anchored bar, one at a time, at most one action | `<NotificationSnackbar />` |
20+
| `banner` | page-width strip **in the content flow** | `<NotificationBanners />` |
21+
| `alert` | blocking acknowledgement dialog, FIFO queue | `<NotificationAlerts />` |
22+
| `inline` | in place, at the raising surface | `<NotificationInline scope="…" />` |
23+
24+
The four surface components ship from `@object-ui/components` and subscribe via
25+
`useNotificationsByPresentation(type, scope?)`.
26+
27+
**Answers to the three questions the issue left open:**
28+
29+
1. **Banner/inline placement is the host's.** They are not overlays: a banner takes
30+
space at the top of the content area and an `inline` notification belongs next to
31+
the thing that raised it. So the context exposes the items and the surfaces
32+
subscribe, rather than one `onToast`-style delegate positioning everything. An
33+
`inline` notification carries a `scope` that pairs it with its outlet, so two
34+
forms on one page don't show each other's messages.
35+
2. **`alert` is modal-ish but NOT the action system's `ModalHandler`.** That handler
36+
resolves a page/object, renders it, and reports an `ActionResult` back to the
37+
`ActionRunner`; a notification alert has no schema, no target and no result.
38+
Routing it there would mean synthesizing a page just to say "OK". It renders
39+
through the `AlertDialog` primitive instead — no second action-modal path.
40+
3. **`snackbar` earns its own component.** It supersedes rather than stacks, anchors
41+
bottom regardless of the toast position config, and takes at most one action.
42+
Making it a sonner variant is what "presents as a toast" means.
43+
44+
**Also fixed:** auto-dismiss now follows the presentation. `toast`/`snackbar` keep
45+
the transient timer; `banner`/`alert`/`inline` are persistent unless the raiser sets
46+
`duration` explicitly — a persistent banner used to evaporate on the shared 5s toast
47+
timer. `dismissible` is honored on the persistent surfaces (an `alert` always keeps
48+
its acknowledge button; `dismissible: false` only closes the Escape route).
49+
50+
`onToast` now receives **only** `toast` items. A provider with no `onToast` remains
51+
the supported store-only mode (a bell reading `notifications`/`unreadCount`), but
52+
raising one of the other four types with its surface unmounted warns in dev, naming
53+
the component to mount — that failure used to be silent.
54+
55+
`NOTIFICATION_PRESENTATIONS` is typed `Record<NotificationPresentation, …>`, so a new
56+
member in the spec enum fails type-check until its presentation is decided; a parity
57+
test additionally asserts the table covers `NotificationTypeSchema` exactly and that
58+
no two types share a surface.

content/docs/guide/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"designing-app-navigation",
2222
"public-forms",
2323
"record-edit-modes",
24+
"notifications",
2425
"dashboard-filters",
2526
"slotted-pages",
2627
"react-pages",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
title: Notifications
3+
description: The five spec display types and the surface that renders each
4+
---
5+
6+
# Notifications
7+
8+
ObjectUI's notification system implements the spec `NotificationSchema`
9+
(`@objectstack/spec``ui/notification.zod.ts`). A notification carries two
10+
independent axes:
11+
12+
- **`severity`**`info` / `success` / `warning` / `error`. Picks the icon and tone.
13+
- **`displayType`**`toast` / `snackbar` / `banner` / `alert` / `inline`. Picks the
14+
**surface**: where and how it appears.
15+
16+
`displayType` used to be stored and never read, so every type surfaced as a
17+
toast — an author asking for a `banner` got a transient overlay. Each type now
18+
has a presentation of its own.
19+
20+
## The five presentations
21+
22+
| `displayType` | Presentation | Rendered by | Persists |
23+
|---|---|---|---|
24+
| `toast` | Transient overlay | the host's `onToast` delegate (sonner in the console) | no — auto-dismiss |
25+
| `snackbar` | Bottom-anchored bar, one at a time, at most one action | `<NotificationSnackbar />` | no — auto-dismiss |
26+
| `banner` | Page-width strip **in the content flow** | `<NotificationBanners />` | yes — until dismissed |
27+
| `alert` | Blocking acknowledgement dialog, FIFO queue | `<NotificationAlerts />` | yes — until acknowledged |
28+
| `inline` | In place, at the surface that raised it | `<NotificationInline />` | yes — until dismissed |
29+
30+
Auto-dismiss follows the presentation, not a single global timer: `toast` and
31+
`snackbar` are transient (`config.defaultDuration`, 5s by default), the other
32+
three stay until dismissed. An explicit `duration` always wins — including
33+
`duration: 0`, which makes a toast persistent.
34+
35+
## Mounting the surfaces
36+
37+
`toast` is delegated to the host; the other four are React components that
38+
subscribe to the provider. Placement is deliberately yours: a banner needs a
39+
slot in the content area and an inline notification belongs next to the thing
40+
that raised it, so neither can be positioned by a global overlay.
41+
42+
```tsx
43+
import {
44+
NotificationAlerts,
45+
NotificationBanners,
46+
NotificationInline,
47+
NotificationSnackbar,
48+
} from '@object-ui/components';
49+
import { NotificationProvider } from '@object-ui/react';
50+
import { toast } from 'sonner';
51+
52+
<NotificationProvider
53+
config={{ position: 'top_right', defaultDuration: 5000, maxVisible: 5 }}
54+
onToast={(n) => toast[n.severity](n.title, { description: n.message })}
55+
>
56+
<main>
57+
<NotificationBanners /> {/* top of the content area */}
58+
<Outlet />
59+
</main>
60+
<NotificationSnackbar />
61+
<NotificationAlerts />
62+
</NotificationProvider>
63+
```
64+
65+
A provider with **no** `onToast` is a supported "notification centre" mode: items
66+
are collected in `notifications` / `unreadCount` for a bell or list, and nothing
67+
is overlaid. Raising one of the other four types with its surface unmounted is a
68+
mistake, though — dev builds warn, naming the component to mount.
69+
70+
## Raising notifications
71+
72+
```tsx
73+
const { notify } = useNotifications();
74+
75+
notify({ title: 'Saved', severity: 'success' }); // toast (spec default)
76+
notify({ title: 'Row deleted', severity: 'info', displayType: 'snackbar',
77+
actions: [{ label: 'Undo', onClick: undo }] }); // one action
78+
notify({ title: 'Viewing a draft', severity: 'warning', displayType: 'banner' });
79+
notify({ title: 'Session expired', severity: 'error', displayType: 'alert' });
80+
```
81+
82+
### `inline` and `scope`
83+
84+
An inline notification is rendered by the surface that raised it. `scope` is the
85+
routing key that pairs the two, so two forms on one page don't show each other's
86+
messages:
87+
88+
```tsx
89+
notify({
90+
title: 'Fix 2 fields', severity: 'error',
91+
displayType: 'inline', scope: 'contact-form',
92+
});
93+
94+
<NotificationInline scope="contact-form" className="mb-4" />
95+
```
96+
97+
Omit `scope` on both ends for a page-level inline outlet. `scope` is renderer-local
98+
routing metadata, not a spec field — the spec describes what a notification *is*,
99+
not which React subtree hosts it.
100+
101+
### `dismissible`
102+
103+
Defaults to `true`. On the persistent presentations, `dismissible: false` removes
104+
the dismiss control (a banner you must resolve rather than wave away). An `alert`
105+
always keeps its acknowledge button — `dismissible: false` only closes the Escape
106+
route, never the way out.
107+
108+
## Notes
109+
110+
- **`alert` is not the action system's modal.** `ModalHandler` resolves a page or
111+
object, renders it, and reports an `ActionResult` back to the `ActionRunner`. A
112+
notification alert has no schema, no target and no result — it is a message and
113+
an acknowledgement, so it renders through the `AlertDialog` primitive instead.
114+
- **`snackbar` is not a toast variant.** It supersedes rather than stacks, anchors
115+
to the bottom regardless of the toast position config, and carries at most one
116+
action.
117+
- Adding a member to the spec `NotificationTypeSchema` fails type-check in
118+
`NOTIFICATION_PRESENTATIONS` (`@object-ui/react`) until its presentation is
119+
decided — new types cannot silently fall back to a toast.
120+
121+
## Server-side notifications
122+
123+
`useClientNotifications` bridges the `@objectstack/client` notifications API into
124+
the same provider (ADR-0030). Fetched items are persistent and default to `toast`,
125+
so a host that renders a bell from `notifications` needs no surface at all.

packages/components/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,32 @@ function MyComponent() {
150150
- `link` - Link component
151151
- `breadcrumb` - Breadcrumb navigation
152152

153+
## Notification Surfaces
154+
155+
Direct-import React components (not schema blocks) that render the notifications
156+
raised through `NotificationProvider` from `@object-ui/react`. One per spec
157+
`displayType`, so a `banner` no longer presents as a toast:
158+
159+
| Component | `displayType` | Where to mount it |
160+
| --- | --- | --- |
161+
| `<NotificationSnackbar />` | `snackbar` | anywhere inside the provider — it anchors itself bottom-center |
162+
| `<NotificationBanners />` | `banner` | top of the content area (it takes space in the flow) |
163+
| `<NotificationAlerts />` | `alert` | anywhere inside the provider — blocking dialog, FIFO queue |
164+
| `<NotificationInline scope="…" />` | `inline` | in the surface that raises them |
165+
166+
`toast` stays with the host's `onToast` delegate (sonner in the console). See the
167+
[notifications guide](https://objectui.org/docs/guide/notifications).
168+
169+
```tsx
170+
import { NotificationBanners, NotificationAlerts } from '@object-ui/components';
171+
172+
<main>
173+
<NotificationBanners />
174+
<Outlet />
175+
<NotificationAlerts />
176+
</main>
177+
```
178+
153179
## Customization
154180

155181
### Override Styles

packages/components/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export {
5656
export * from './ui';
5757
export * from './custom';
5858

59+
// Export the notification surfaces — one per spec `displayType` (#3014).
60+
export * from './notifications';
61+
5962
// Export hooks
6063
export { useConfigDraft } from './hooks/use-config-draft';
6164
export type { UseConfigDraftOptions, UseConfigDraftReturn } from './hooks/use-config-draft';
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `displayType: 'alert'` — a blocking acknowledgement.
11+
*
12+
* Why NOT the action system's `ModalHandler` (#3014, open question 2): that
13+
* handler is `(schema, context) => Promise<ActionResult>` — it resolves a page
14+
* or object, renders it as a modal, and reports the outcome back to the
15+
* `ActionRunner`. A notification alert has no schema, no target and no action
16+
* result; it is a message plus an acknowledgement. Routing it there would mean
17+
* synthesizing a page just to say "OK" and requiring an action runtime to
18+
* present a notification. So `alert` renders through the Radix `AlertDialog`
19+
* primitive — the blocking-acknowledgement primitive — which duplicates none of
20+
* the action-modal machinery.
21+
*/
22+
23+
import * as React from 'react';
24+
import { useNotifications, useNotificationsByPresentation } from '@object-ui/react';
25+
import { cn } from '../lib/utils';
26+
import {
27+
AlertDialog,
28+
AlertDialogAction,
29+
AlertDialogContent,
30+
AlertDialogDescription,
31+
AlertDialogFooter,
32+
AlertDialogHeader,
33+
AlertDialogTitle,
34+
} from '../ui/alert-dialog';
35+
import { notificationSeverityStyle } from './severity';
36+
37+
export interface NotificationAlertsProps {
38+
/** Extra classes for the dialog content. */
39+
className?: string;
40+
/** Label of the acknowledge button. Default `OK`. */
41+
acknowledgeLabel?: string;
42+
}
43+
44+
/**
45+
* Renders `alert` notifications one at a time as a blocking dialog.
46+
*
47+
* @example
48+
* ```tsx
49+
* <NotificationProvider>
50+
* <App />
51+
* <NotificationAlerts />
52+
* </NotificationProvider>
53+
* ```
54+
*/
55+
export function NotificationAlerts({ className, acknowledgeLabel = 'OK' }: NotificationAlertsProps) {
56+
const alerts = useNotificationsByPresentation('alert');
57+
const { dismiss } = useNotifications();
58+
59+
// The list is newest-first; a blocking queue is FIFO, so the OLDEST unhandled
60+
// alert is the one the user is currently being asked to acknowledge.
61+
const notification = alerts[alerts.length - 1];
62+
if (!notification) return null;
63+
64+
const { Icon, iconTone } = notificationSeverityStyle(notification.severity);
65+
const acknowledge = () => dismiss(notification.id);
66+
// `dismissible: false` removes the wave-it-away paths (Escape); it never
67+
// removes the acknowledge button — an alert nobody can acknowledge is a trap.
68+
const blocking = notification.dismissible === false;
69+
70+
return (
71+
<AlertDialog open onOpenChange={(open) => { if (!open && !blocking) acknowledge(); }}>
72+
<AlertDialogContent
73+
className={className}
74+
data-notification-surface="alert"
75+
data-severity={notification.severity}
76+
onEscapeKeyDown={(event) => { if (blocking) event.preventDefault(); }}
77+
>
78+
<AlertDialogHeader>
79+
<AlertDialogTitle className="flex items-center gap-2">
80+
<Icon className={cn('h-5 w-5 shrink-0', iconTone)} aria-hidden="true" />
81+
{notification.title}
82+
</AlertDialogTitle>
83+
{notification.message ? (
84+
<AlertDialogDescription>{notification.message}</AlertDialogDescription>
85+
) : null}
86+
</AlertDialogHeader>
87+
<AlertDialogFooter>
88+
{notification.actions?.map((action) => (
89+
<AlertDialogAction
90+
key={action.label}
91+
className={cn(action.variant === 'destructive' && 'bg-destructive text-destructive-foreground hover:bg-destructive/90')}
92+
onClick={() => { action.onClick(); acknowledge(); }}
93+
>
94+
{action.label}
95+
</AlertDialogAction>
96+
))}
97+
<AlertDialogAction
98+
className={cn(notification.actions?.length ? 'bg-secondary text-secondary-foreground hover:bg-secondary/80' : undefined)}
99+
onClick={acknowledge}
100+
>
101+
{acknowledgeLabel}
102+
</AlertDialogAction>
103+
</AlertDialogFooter>
104+
</AlertDialogContent>
105+
</AlertDialog>
106+
);
107+
}
108+
109+
NotificationAlerts.displayName = 'NotificationAlerts';

0 commit comments

Comments
 (0)