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
52 changes: 52 additions & 0 deletions .changeset/notification-config-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
"@object-ui/react": minor
"@object-ui/components": minor
"@object-ui/app-shell": minor
---

fix(notifications): the config, `position` and action `variant` are read instead of forked or ignored (#3014 follow-up)

The last of the notification contract. After `displayType` (#3071) and `icon`
(#3076), four gaps of the same family were left:

- **the config was 3/4 inert** — only `defaultDuration` was ever read.
`maxVisible` and `stacking` were carried and ignored, while
`NotificationBanners` capped at a hard-coded `3` of its own;
- **its field names forked from `NotificationConfigSchema`** — `position` vs
`defaultPosition`, a renderer-local `stacking` boolean with no spec
counterpart, and no `pauseOnHover` at all;
- **a notification could not declare a `position`.** The #3008 parity guard
asserted the position *vocabulary* matched the spec while nothing positioned
anything by it — a guard passing over an unused value;
- **`NotificationActionButton.variant` was the shadcn Button vocabulary**
(`default | destructive | outline`) under a spec-shaped name, forking
`NotificationActionSchema.variant` (`primary | secondary | link`).

**How positioning resolves now** — `notification.position ?? config.defaultPosition
?? nothing`, and "nothing" is a real answer:

- **declared** → the surface pins itself there, always. `presentNotificationToast`
passes it per-toast so the contract wins over the container;
- **undeclared** → the surface keeps its own anchor (a snackbar's bottom edge) or
defers to the host's toast chrome.

That asymmetry is the design decision. The host's sonner container also serves
toasts that are *not* spec notifications (the console action runtime's own
`toast.*` calls), so it stays the fallback authority for placement — never a
competing one. A declared position a component prop could silently override
would be the same "validates, then does nothing" shape this whole area is about.
Hence `defaultPosition` has no fabricated default: "the host didn't say" has to
be representable.

Also: `maxVisible` / `stackDirection` now drive every stacking surface through
one shared `visibleNotificationStack` (cap keeps the NEWEST, stack grows in the
declared direction); `pauseOnHover` holds a transient notification's timer and
resumes it with the time it had left, which needed the provider to track live
timers rather than fire-and-forget `setTimeout`s. Legacy spellings still resolve:
`position` folds into `defaultPosition`, and `stacking: false` reads as
`maxVisible: 1` rather than being ignored.

`onToast` now receives the resolved config as a second argument, so the delegate
can apply the parts of the contract only it can. Existing one-argument handlers
are unaffected. The spec-parity guard gained the action-variant vocabulary, the
one notification enum it did not cover.
55 changes: 55 additions & 0 deletions content/docs/guide/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,26 @@ notify({ title: 'Viewing a draft', severity: 'warning', displayType: 'banner' })
notify({ title: 'Session expired', severity: 'error', displayType: 'alert' });
```

### `actions`

An action's `variant` is the spec vocabulary — `primary` (default) / `secondary`
/ `link` — describing the action's **role**. Each surface maps it onto its own
button styling; it is not the shadcn Button vocabulary, which is a look.

```tsx
notify({
title: 'Storage almost full', severity: 'warning', displayType: 'banner',
actions: [
{ label: 'Upgrade', onClick: upgrade }, // primary by default
{ label: 'Learn more', onClick: openDocs, variant: 'link' },
],
});
```

A `snackbar` and a `toast` render only the **first** action — both have one
action slot by nature. A `banner` and an `inline` render all of them; an `alert`
renders them beside its acknowledge button.

### `inline` and `scope`

An inline notification is rendered by the surface that raised it. `scope` is the
Expand Down Expand Up @@ -130,6 +150,41 @@ deliberately *not* the generic `Database` glyph `getLazyIcon` returns for
data-shaped schema slots, which on an error notification would replace a
meaningful icon with a meaningless one.

### `position`

Honored by the **floating** presentations — `toast` and `snackbar`. `banner`,
`inline` and `alert` are anchored by what they are (content top / in place /
centred modal) and ignore it.

Resolution is `notification.position ?? config.defaultPosition ?? nothing`, and
"nothing" is a real answer rather than a missing one:

- **declared** → the surface pins itself there, always;
- **undeclared** → the surface keeps its own anchor (a snackbar's bottom edge)
or defers to the host's toast chrome.

That asymmetry is deliberate. The host's toast container is shared with toasts
that are *not* spec notifications (in the console, the action runtime's own
`toast.*` calls), so it stays the fallback authority for placement — but never a
competing one. A declared position that a component prop could silently override
would be the same "validates, then does nothing" shape this whole area is about.

## Configuring the system

`NotificationProvider`'s `config` is the spec `NotificationConfigSchema`:

| Key | Default | Effect |
|---|---|---|
| `defaultPosition` | — | Fallback position for the floating presentations. Deliberately unset: see above. |
| `defaultDuration` | `5000` | Auto-dismiss for the transient presentations. |
| `maxVisible` | `5` | Cap on a stacking surface (banner, inline); the newest survive. |
| `stackDirection` | `down` | Which way a stack grows — `down` puts the newest below. |
| `pauseOnHover` | `true` | Hold a transient notification's timer while it is hovered. |

The legacy spellings `position` and `stacking` are still accepted:
`defaultPosition` wins over `position`, and `stacking: false` reads as
`maxVisible: 1` ("show only the newest") rather than being ignored.

### `dismissible`

Defaults to `true`. On the persistent presentations, `dismissible: false` removes
Expand Down
37 changes: 36 additions & 1 deletion packages/app-shell/src/chrome/notificationToast.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isValidElement } from 'react';
import type { NotificationItem } from '@object-ui/react';
import { resolveNotificationConfig, type NotificationItem } from '@object-ui/react';

vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
Expand Down Expand Up @@ -133,4 +133,39 @@ describe('presentNotificationToast', () => {
const options = vi.mocked(toast.success).mock.calls[0][1] as Record<string, unknown>;
expect(options).not.toHaveProperty('icon');
});

// Position is the half of the contract that had to be settled: declared wins,
// undeclared defers to the sonner container (host chrome, which also places
// the console's many direct `toast.*` calls).
describe('position', () => {
it('omits the key when neither the notification nor the config declares one', () => {
presentNotificationToast(item(), resolveNotificationConfig());
const options = vi.mocked(toast.success).mock.calls[0][1] as Record<string, unknown>;
expect(options).not.toHaveProperty('position');
});

it('passes the configured default, translated to sonner ids', () => {
presentNotificationToast(item(), resolveNotificationConfig({ defaultPosition: 'top_right' }));
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
position: 'top-right',
}));
});

it("lets the notification's own position win", () => {
presentNotificationToast(
item({ position: 'bottom_left' }),
resolveNotificationConfig({ defaultPosition: 'top_right' }),
);
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
position: 'bottom-left',
}));
});

it('works with no config at all', () => {
presentNotificationToast(item({ position: 'top_center' }));
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
position: 'top-center',
}));
});
});
});
25 changes: 22 additions & 3 deletions packages/app-shell/src/chrome/notificationToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@
*/

import { toast } from 'sonner';
import type { NotificationItem, NotificationSeverityLevel } from '@object-ui/react';
import { isLucideIconName, LazyIcon } from '@object-ui/components';
import {
resolveNotificationPosition,
type NotificationItem,
type NotificationSeverityLevel,
type ResolvedNotificationConfig,
} from '@object-ui/react';
import { isLucideIconName, LazyIcon, TOASTER_POSITIONS } from '@object-ui/components';

/**
* Typed as `Record<NotificationSeverityLevel, …>` so a new severity in the spec
Expand All @@ -35,8 +40,19 @@ const TOAST_BY_SEVERITY: Record<NotificationSeverityLevel, (message: string, dat
* Duration follows the notification contract: `0` means persistent (sonner's
* `Infinity`), and an absent duration defers to the `ConsoleToaster` default
* rather than being invented here.
*
* Position follows the same rule, and it is the one that had to be settled: a
* DECLARED position (the notification's own, else `config.defaultPosition`) is
* passed per-toast so the contract always wins, and an UNDECLARED one omits the
* key entirely so the sonner container keeps placing it. That container is host
* chrome — it also serves the console's many direct `toast.*` calls, which are
* not spec notifications — so it stays the fallback authority, never a
* competing one.
*/
export function presentNotificationToast(notification: NotificationItem): void {
export function presentNotificationToast(
notification: NotificationItem,
config?: Pick<ResolvedNotificationConfig, 'defaultPosition'>,
): void {
const show = TOAST_BY_SEVERITY[notification.severity] ?? TOAST_BY_SEVERITY.info;
// A toast carries at most one action button — sonner has one `action` slot,
// and a notification needing more than one belongs on a banner or an alert.
Expand All @@ -48,12 +64,15 @@ export function presentNotificationToast(notification: NotificationItem): void {
const icon = isLucideIconName(notification.icon)
? <LazyIcon name={notification.icon} className="h-4 w-4" />
: undefined;
const declared = resolveNotificationPosition(notification, config);
const position = declared ? TOASTER_POSITIONS[declared] : undefined;

show(notification.title, {
description: notification.message,
duration: notification.duration === 0 ? Infinity : notification.duration,
dismissible: notification.dismissible,
...(icon ? { icon } : {}),
...(position ? { position } : {}),
...(action ? { action: { label: action.label, onClick: action.onClick } } : {}),
});
}
9 changes: 7 additions & 2 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,13 @@ function GlobalActionRuntimeProvider({ dataSource, children }: { dataSource: unk
export function ConsoleShell({ children }: { children: ReactNode }) {
return (
<ThemeProvider defaultTheme="system" storageKey="object-ui-theme">
{/* `defaultDuration` matches ConsoleToaster's 4s toast default, so a
snackbar and a toast raised together disappear together. */}
{/* `defaultDuration` matches ConsoleToaster's 4s toast default and
`maxVisible` its `visibleToasts={4}`, so a snackbar and a toast raised
together disappear together and the banner stack caps like the toast
stack. No `defaultPosition`: nothing declared means the sonner
container keeps placing toasts (it also serves the console's direct
`toast.*` calls) and the snackbar keeps its own bottom anchor — a
notification that DECLARES a position still overrides both. */}
<NotificationProvider config={{ defaultDuration: 4000, maxVisible: 4 }} onToast={presentNotificationToast}>
<NavigationProvider>
<UserStateAdaptersProvider>
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export * from './custom';

// Export the notification surfaces — one per spec `displayType` (#3014).
export * from './notifications';
// Spec position → sonner position ids, shared with the console's toast bridge.
export { TOASTER_POSITIONS } from './renderers/feedback/toaster';

// Export hooks
export { useConfigDraft } from './hooks/use-config-draft';
Expand Down
21 changes: 18 additions & 3 deletions packages/components/src/notifications/NotificationAlerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,18 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '../ui/alert-dialog';
import { notificationIcon, notificationSeverityStyle } from './severity';
import { notificationActionVariant, notificationIcon, notificationSeverityStyle } from './severity';

/**
* Button-variant classes for the dialog footer. `AlertDialogAction` bakes in
* `buttonVariants()` (the `default` look), so a variant is expressed as an
* override rather than a prop — `primary` needs none.
*/
const ALERT_ACTION_CLASSES: Record<'default' | 'secondary' | 'link', string | undefined> = {
default: undefined,
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
link: 'bg-transparent text-primary underline-offset-4 hover:bg-transparent hover:underline',
};

export interface NotificationAlertsProps {
/** Extra classes for the dialog content. */
Expand Down Expand Up @@ -90,14 +101,18 @@ export function NotificationAlerts({ className, acknowledgeLabel = 'OK' }: Notif
{notification.actions?.map((action) => (
<AlertDialogAction
key={action.label}
className={cn(action.variant === 'destructive' && 'bg-destructive text-destructive-foreground hover:bg-destructive/90')}
// AlertDialogAction hard-codes the default Button styling, so the
// spec variant is applied as an override on top of it.
className={cn(ALERT_ACTION_CLASSES[notificationActionVariant(action.variant)])}
onClick={() => { action.onClick(); acknowledge(); }}
>
{action.label}
</AlertDialogAction>
))}
<AlertDialogAction
className={cn(notification.actions?.length ? 'bg-secondary text-secondary-foreground hover:bg-secondary/80' : undefined)}
// Declared actions are the primary ones; the bare acknowledge steps
// back to secondary when it is not the only button.
className={cn(notification.actions?.length ? ALERT_ACTION_CLASSES.secondary : undefined)}
onClick={acknowledge}
>
{acknowledgeLabel}
Expand Down
25 changes: 14 additions & 11 deletions packages/components/src/notifications/NotificationBanners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,18 @@

import * as React from 'react';
import { X } from 'lucide-react';
import { useNotifications, useNotificationsByPresentation } from '@object-ui/react';
import {
useNotifications,
useNotificationsByPresentation,
visibleNotificationStack,
} from '@object-ui/react';
import { cn } from '../lib/utils';
import { Button } from '../ui/button';
import { notificationIcon, notificationSeverityStyle } from './severity';
import { notificationActionVariant, notificationIcon, 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;
}

/**
Expand All @@ -44,15 +43,19 @@ export interface NotificationBannersProps {
* </main>
* ```
*/
export function NotificationBanners({ className, max = 3 }: NotificationBannersProps) {
export function NotificationBanners({ className }: NotificationBannersProps) {
const banners = useNotificationsByPresentation('banner');
const { dismiss } = useNotifications();
const { dismiss, config } = useNotifications();

if (banners.length === 0) return null;

// `maxVisible` / `stackDirection` come from the provider config — the spec's
// own knobs — instead of a cap this component invents for itself.
const visible = visibleNotificationStack(banners, config);

return (
<div className={cn('flex w-full flex-col', className)} data-notification-surface="banner">
{banners.slice(0, max).map((notification) => {
{visible.map((notification) => {
const { tone } = notificationSeverityStyle(notification.severity);
const Icon = notificationIcon(notification);
return (
Expand All @@ -76,7 +79,7 @@ export function NotificationBanners({ className, max = 3 }: NotificationBannersP
<Button
key={action.label}
size="sm"
variant={action.variant ?? 'outline'}
variant={notificationActionVariant(action.variant)}
className="h-7 shrink-0"
onClick={action.onClick}
>
Expand Down
17 changes: 12 additions & 5 deletions packages/components/src/notifications/NotificationInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@

import * as React from 'react';
import { X } from 'lucide-react';
import { useNotifications, useNotificationsByPresentation } from '@object-ui/react';
import {
useNotifications,
useNotificationsByPresentation,
visibleNotificationStack,
} from '@object-ui/react';
import { cn } from '../lib/utils';
import { Button } from '../ui/button';
import { notificationIcon, notificationSeverityStyle } from './severity';
import { notificationActionVariant, notificationIcon, notificationSeverityStyle } from './severity';

export interface NotificationInlineProps {
/**
Expand All @@ -46,17 +50,20 @@ export interface NotificationInlineProps {
*/
export function NotificationInline({ scope, className }: NotificationInlineProps) {
const items = useNotificationsByPresentation('inline', scope);
const { dismiss } = useNotifications();
const { dismiss, config } = useNotifications();

if (items.length === 0) return null;

// Same `maxVisible` / `stackDirection` contract as the banner stack.
const visible = visibleNotificationStack(items, config);

return (
<div
className={cn('flex w-full flex-col gap-2', className)}
data-notification-surface="inline"
data-scope={scope}
>
{items.map((notification) => {
{visible.map((notification) => {
const { tone } = notificationSeverityStyle(notification.severity);
const Icon = notificationIcon(notification);
return (
Expand All @@ -78,7 +85,7 @@ export function NotificationInline({ scope, className }: NotificationInlineProps
<Button
key={action.label}
size="sm"
variant={action.variant ?? 'outline'}
variant={notificationActionVariant(action.variant)}
className="h-7"
onClick={action.onClick}
>
Expand Down
Loading
Loading