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

fix(notifications): the spec `icon` is read instead of stored and ignored (#3014 follow-up)

`NotificationSchema.icon` — "Icon name override" — reached `NotificationItem` and
stopped there. Every surface drew the severity icon, so an author writing
`icon: 'rocket'` got the success checkmark. Same shape as the `displayType`
collapse #3071 fixed: a value that validates, is carried, and renders nothing.

All five presentations now resolve it through one rule (`notificationIcon`): a
declared Lucide name — kebab-case or PascalCase — replaces the severity icon;
anything else falls back to it. That includes the console's sonner toast, so the
override behaves identically on a toast, a banner, a snackbar, an alert and an
inline message.

**The fallback is the interesting part.** `getLazyIcon` degrades an unknown name
to a `Database` glyph, which is right for a data-shaped schema slot and wrong
here — on an error notification it swaps a meaningful icon for a meaningless one.
So the name is checked first, via a new `isLucideIconName` export, and a typo
costs the author their override and nothing more.
15 changes: 15 additions & 0 deletions content/docs/guide/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ Omit `scope` on both ends for a page-level inline outlet. `scope` is renderer-lo
routing metadata, not a spec field — the spec describes what a notification *is*,
not which React subtree hosts it.

### `icon`

Every surface — including the console's sonner toast — resolves `icon` through
the same rule: a declared Lucide name (kebab-case or PascalCase) replaces the
severity icon; anything else falls back to it.

```tsx
notify({ title: 'Deploy finished', severity: 'success', displayType: 'banner', icon: 'rocket' });
```

A name Lucide doesn't have costs the author their override and nothing more —
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.

### `dismissible`

Defaults to `true`. On the persistent presentations, `dismissible: false` removes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

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

vi.mock('sonner', () => ({
Expand All @@ -16,6 +17,10 @@ vi.mock('sonner', () => ({

import { toast } from 'sonner';
import { presentNotificationToast } from './notificationToast';
// Imported at module scope, not inside a test: `@object-ui/components` is a
// heavy barrel and resolving it mid-assertion would race the RTL timeouts
// (AGENTS.md § 测试纪律).
import '@object-ui/components';

function item(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
Expand Down Expand Up @@ -101,4 +106,31 @@ describe('presentNotificationToast', () => {
dismissible: false,
}));
});

it('passes the spec icon override to sonner', () => {
presentNotificationToast(item({ icon: 'rocket' }));
const { icon } = vi.mocked(toast.success).mock.calls[0][1] as { icon?: unknown };
expect(isValidElement(icon)).toBe(true);
expect((icon as { props: { name?: string } }).props.name).toBe('rocket');
});

it('accepts a PascalCase icon name', () => {
presentNotificationToast(item({ icon: 'CircleCheck' }));
const { icon } = vi.mocked(toast.success).mock.calls[0][1] as { icon?: unknown };
expect(isValidElement(icon)).toBe(true);
});

it('omits the icon key for a name Lucide does not have', () => {
// Passing it through would render LazyIcon's `Database` fallback — a
// meaningless glyph where ConsoleToaster's severity icon belongs.
presentNotificationToast(item({ icon: 'not-a-real-icon' }));
const options = vi.mocked(toast.success).mock.calls[0][1] as Record<string, unknown>;
expect(options).not.toHaveProperty('icon');
});

it('omits the icon key when none is declared', () => {
presentNotificationToast(item());
const options = vi.mocked(toast.success).mock.calls[0][1] as Record<string, unknown>;
expect(options).not.toHaveProperty('icon');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

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

/**
* Typed as `Record<NotificationSeverityLevel, …>` so a new severity in the spec
Expand All @@ -40,11 +41,19 @@ export function presentNotificationToast(notification: NotificationItem): void {
// 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.
const action = notification.actions?.[0];
// The spec `icon` override, matching what the four surface components do.
// Only a REAL Lucide name is passed: `LazyIcon` degrades an unknown one to a
// `Database` glyph, whereas omitting the key leaves ConsoleToaster's severity
// icon in place — the better fallback for a typo.
const icon = isLucideIconName(notification.icon)
? <LazyIcon name={notification.icon} className="h-4 w-4" />
: undefined;

show(notification.title, {
description: notification.message,
duration: notification.duration === 0 ? Infinity : notification.duration,
dismissible: notification.dismissible,
...(icon ? { icon } : {}),
...(action ? { action: { label: action.label, onClick: action.onClick } } : {}),
});
}
4 changes: 3 additions & 1 deletion packages/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ raised through `NotificationProvider` from `@object-ui/react`. One per spec
| `<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
`toast` stays with the host's `onToast` delegate (sonner in the console). All of
them draw the notification's `severity` icon unless it declares an `icon`
override naming a real Lucide icon. See the
[notifications guide](https://objectui.org/docs/guide/notifications).

```tsx
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import './renderers';
export { cn } from './lib/utils';
export { renderChildren } from './lib/utils';
export { cva } from 'class-variance-authority';
export { getLazyIcon, LazyIcon, toKebabIconName } from './lib/lazy-icon';
export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/lazy-icon';

// Export placeholder registration
export { registerPlaceholders } from './renderers/placeholders';
Expand Down
13 changes: 13 additions & 0 deletions packages/components/src/lib/lazy-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ function isLucideIcon(kebab: string): boolean {
return VALID_ICON_NAMES.has(kebab);
}

/**
* Whether `name` (kebab-case or PascalCase) resolves to a real Lucide icon.
*
* Exported because `getLazyIcon` degrades an unknown name to the `Database`
* icon, which is the right default for a data-shaped schema slot but wrong
* where a caller has a BETTER fallback of its own — a notification, for
* instance, would rather show its severity icon than a stray database glyph.
* Ask first, then choose.
*/
export function isLucideIconName(name?: string): boolean {
return !!name && isLucideIcon(toKebabIconName(name));
}

const cache = new Map<string, React.ElementType>();

/**
Expand Down
6 changes: 4 additions & 2 deletions packages/components/src/notifications/NotificationAlerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '../ui/alert-dialog';
import { notificationSeverityStyle } from './severity';
import { notificationIcon, notificationSeverityStyle } from './severity';

export interface NotificationAlertsProps {
/** Extra classes for the dialog content. */
Expand Down Expand Up @@ -61,7 +61,8 @@ export function NotificationAlerts({ className, acknowledgeLabel = 'OK' }: Notif
const notification = alerts[alerts.length - 1];
if (!notification) return null;

const { Icon, iconTone } = notificationSeverityStyle(notification.severity);
const { iconTone } = notificationSeverityStyle(notification.severity);
const Icon = notificationIcon(notification);
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.
Expand All @@ -77,6 +78,7 @@ export function NotificationAlerts({ className, acknowledgeLabel = 'OK' }: Notif
>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{/* eslint-disable-next-line react-hooks/static-components -- notificationIcon returns a module-cached stable component per name, not one created during render */}
<Icon className={cn('h-5 w-5 shrink-0', iconTone)} aria-hidden="true" />
{notification.title}
</AlertDialogTitle>
Expand Down
5 changes: 3 additions & 2 deletions packages/components/src/notifications/NotificationBanners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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';
import { notificationIcon, notificationSeverityStyle } from './severity';

export interface NotificationBannersProps {
/** Extra classes for the banner stack container. */
Expand Down Expand Up @@ -53,7 +53,8 @@ export function NotificationBanners({ className, max = 3 }: NotificationBannersP
return (
<div className={cn('flex w-full flex-col', className)} data-notification-surface="banner">
{banners.slice(0, max).map((notification) => {
const { Icon, tone } = notificationSeverityStyle(notification.severity);
const { tone } = notificationSeverityStyle(notification.severity);
const Icon = notificationIcon(notification);
return (
<div
key={notification.id}
Expand Down
5 changes: 3 additions & 2 deletions packages/components/src/notifications/NotificationInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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';
import { notificationIcon, notificationSeverityStyle } from './severity';

export interface NotificationInlineProps {
/**
Expand Down Expand Up @@ -57,7 +57,8 @@ export function NotificationInline({ scope, className }: NotificationInlineProps
data-scope={scope}
>
{items.map((notification) => {
const { Icon, tone } = notificationSeverityStyle(notification.severity);
const { tone } = notificationSeverityStyle(notification.severity);
const Icon = notificationIcon(notification);
return (
<div
key={notification.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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';
import { notificationIcon, notificationSeverityStyle } from './severity';

export interface NotificationSnackbarProps {
/** Extra classes for the snackbar container. */
Expand All @@ -47,7 +47,8 @@ export function NotificationSnackbar({ className }: NotificationSnackbarProps) {
const notification = snackbars[0];
if (!notification) return null;

const { Icon, iconTone } = notificationSeverityStyle(notification.severity);
const { iconTone } = notificationSeverityStyle(notification.severity);
const Icon = notificationIcon(notification);
// 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];
Expand All @@ -65,6 +66,7 @@ export function NotificationSnackbar({ className }: NotificationSnackbarProps) {
data-notification-surface="snackbar"
data-severity={notification.severity}
>
{/* eslint-disable-next-line react-hooks/static-components -- notificationIcon returns a module-cached stable component per name, not one created during render */}
<Icon className={cn('h-4 w-4 shrink-0', iconTone)} aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{notification.title}</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { NotificationBanners } from '../NotificationBanners';
import { NotificationSnackbar } from '../NotificationSnackbar';
import { NotificationAlerts } from '../NotificationAlerts';
import { NotificationInline } from '../NotificationInline';
import { notificationIcon, notificationSeverityStyle } from '../severity';

function specDisplayTypes(): string[] {
const raw = (NotificationTypeSchema as { options?: readonly string[] }).options;
Expand Down Expand Up @@ -172,6 +173,16 @@ describe('notification surfaces', () => {
expect(screen.queryByRole('button', { name: 'Dismiss Sticky' })).not.toBeInTheDocument();
});

it('renders a surface whose notification declares an icon override', () => {
act(() => {
raise({ title: 'Deploy finished', severity: 'success', displayType: 'banner', icon: 'rocket' });
});

// The resolution contract is pinned on `notificationIcon`; here the point is
// only that a declared icon reaches the surface without breaking it.
expect(surfaceOf('Deploy finished')).toBe('banner');
});

it('runs a snackbar action and clears the snackbar', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
Expand All @@ -188,6 +199,36 @@ describe('notification surfaces', () => {
});
});

/**
* The spec `icon` — a second value that was stored and never read. Every
* surface resolves it through `notificationIcon`, so the override behaves the
* same on a banner, a snackbar, an alert and an inline message.
*/
describe('notificationIcon', () => {
it('uses the severity icon when no override is declared', () => {
expect(notificationIcon({ severity: 'error' }))
.toBe(notificationSeverityStyle('error').Icon);
});

it('honors a declared Lucide icon over the severity default', () => {
const resolved = notificationIcon({ severity: 'error', icon: 'rocket' });
expect(resolved).not.toBe(notificationSeverityStyle('error').Icon);
expect(resolved).toBeTruthy();
});

it('accepts the PascalCase spelling too', () => {
expect(notificationIcon({ severity: 'info', icon: 'CircleCheck' }))
.not.toBe(notificationSeverityStyle('info').Icon);
});

it('falls back to the severity icon for a name Lucide does not have', () => {
// NOT `getLazyIcon`'s `Database` glyph: on a notification the severity icon
// is always the better fallback, so a typo costs the override and no more.
expect(notificationIcon({ severity: 'warning', icon: 'not-a-real-icon' }))
.toBe(notificationSeverityStyle('warning').Icon);
});
});

describe('presentation coverage', () => {
it('presents a notification of EVERY spec display type distinctly', () => {
const onToast = vi.fn();
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/notifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { NotificationAlerts, type NotificationAlertsProps } from './Notification
export { NotificationInline, type NotificationInlineProps } from './NotificationInline';
export {
NOTIFICATION_SEVERITY_STYLES,
notificationIcon,
notificationSeverityStyle,
type NotificationSeverityStyle,
} from './severity';
21 changes: 20 additions & 1 deletion packages/components/src/notifications/severity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
* arrives as a banner or as an inline message.
*/

import type { ElementType } from 'react';
import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from 'lucide-react';
import type { NotificationSeverityLevel } from '@object-ui/react';
import type { NotificationItem, NotificationSeverityLevel } from '@object-ui/react';
import { getLazyIcon, isLucideIconName } from '../lib/lazy-icon';

export interface NotificationSeverityStyle {
/** Leading icon. */
Expand Down Expand Up @@ -63,3 +65,20 @@ export function notificationSeverityStyle(
): NotificationSeverityStyle {
return NOTIFICATION_SEVERITY_STYLES[severity ?? 'info'] ?? NOTIFICATION_SEVERITY_STYLES.info;
}

/**
* The icon a notification renders: the spec `icon` override when it names a
* real Lucide icon, otherwise the severity default.
*
* The name check is the point. `getLazyIcon` degrades an unknown name to the
* `Database` glyph — sensible for a data-shaped schema slot, but on an error
* notification it would replace a meaningful icon with a meaningless one. Here
* the severity icon is always the better fallback, so a typo costs the author
* their override and nothing more.
*/
export function notificationIcon(
notification: Pick<NotificationItem, 'severity' | 'icon'>,
): ElementType {
if (isLucideIconName(notification.icon)) return getLazyIcon(notification.icon);
return notificationSeverityStyle(notification.severity).Icon;
}
Loading