Message Popover
Compact severity-aware message trigger with on-demand Popover or Sheet list, filters, navigate, and optional host acknowledge slots.
Plus Registry
This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/message-popover.
Message Popover aggregates object / page readiness messages behind a quiet count trigger — open the list on demand (desktop Popover, mobile bottom Sheet). Industry twin: SAP Fiori Message Popover. It is not an in-content Message Strip, toast, or page-level Result.
Core identity: filter by severity → list → navigate to the field. Soft-warning Acknowledge is an optional host extension, not part of the Fiori twin (forced acknowledgment belongs on Message Box).
Design locks (placement, timing, surfaces): Object Messaging And Table Chrome.
When To Use
- Surface multi-region blockers and warnings after Validate / Run check / Save / Submit.
- Pair with field or Editable Table cell Value State — popover for object readiness, cells for local focus.
- Place near footer actions or a dedicated band above the footer.
When Not To Use
- Do not auto-open on every draft edit — host timing owns open (prefer finalizing actions).
- Do not dual-list the same cell errors in FormErrorSummary and EditableErrorRollup.
- Prefer cells + region rollup for a single Editable Table; use Message Popover for multi-region / object-readiness footers — avoid three loud essays.
- Do not treat Acknowledge as required kit chrome — use a submit-time Message Box when one interruptive confirm is enough.
- Do not mount Message Popover inside a small dialog. Highlight the field in that dialog instead.
- Do not use it as a notification bell. These messages are about the object on screen and die when it is saved; cross-app activity that outlives the page belongs to Notification Center. Full class map: Messaging Surfaces.
Surface Matrix
| Surface | Role |
|---|---|
| FormErrorSummary | Form-root / header / array-root (e.g. “at least one line”) — not cell dual-list |
| Notification Center | Activity elsewhere that concerns this user — survives navigation and reload; never object readiness |
| EditableErrorRollup | Under-table cell list; defer drafting via showErrorRollup={false} |
| Message Popover | Object readiness / multi-region messages |
Array-root issues stay on FormErrorSummary; do not dual-list the same cell paths in summary and rollup. See Object Messaging And Table Chrome.
Features
| Area | Behavior |
|---|---|
| Trigger | Ghost count of the worst type only (1 error / 2 errors / 2 warnings); hide when items.length === 0. Optional trigger slot still replaces the default. |
| Shell | Desktop Popover · mobile Sheet |
| Filters | All · Errors (n) · Warnings (n) — stable tabs; filtered 0 → region Empty + Show all |
| Channels | Optional host-supplied facets beyond severity (see Channels) |
| Empty customization | Slot props emptyTitle / emptyDescription / emptyIcon / emptyAction, or renderEmpty full takeover |
| Navigate | Optional onNavigate → title is the jump (link-styled button). No default Go to field control. Shell closes on navigate |
| Location | Optional subtitle (field / Line 3 · Unit price). Omit when unknown — do not invent — |
| Groups | Optional group section headers. Ungrouped items go under General only when at least one item has group |
| Aggregate | Identical titles collapse when count > 5 |
| Details | Optional details long text. Chevron on the row; same Popover/Sheet (not a dialog). Sole item with details opens the details page first. |
| Extra types | Info / Success tabs only when those items exist. All / Errors / Warnings stay visible. |
Close on navigate: activating the title closes the Popover / Sheet before onNavigate runs, so the list does not stay open over the scrolled field.
Locate pulse: field focus / highlight after navigate is host-owned. Pair onNavigate with Form issue navigation (navigateToFormIssue / applyLocatePulse) for a short primary ring (data-locate-pulse); Message Popover itself does not pulse the target.
Channels
Optional channels let the host add filter facets that are not severities — for example a host Returns work queue. Pass channels with stable id + label (and optional match), set item.channel (or use match), and open on a facet with defaultFilter / controlled filter + onFilterChange. Channel tabs appear only when at least one item matches. The kit does not hardcode product channel names.
Optional: Acknowledge (host policy)
Not Fiori Message Popover consensus. Use only for soft-warning / compliance gates (e.g. OCR doubt) where the host owns acknowledged state and submit rules.
| Prop | When |
|---|---|
onAcknowledge | Per-warning checkbox (default short label, or renderAcknowledge) |
onAcknowledgeAll | Link when any warning is unacked |
renderAcknowledge | Long legal copy (host soft-warning policy) |
No ack chrome unless these callbacks are passed. Prefer Message Box at Submit for a single “continue with N warnings?” decision.
Installing
Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/message-popoverFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/message-popoverFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/message-popoverFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/message-popoverAlso works via URL: https://ui.isaacfei.com/api/plus/r/message-popover.json.
Usage
import { MessagePopover } from "@/components/f-ui/message-popover/message-popover";
// Core — list + navigate
<MessagePopover
items={[
{ id: "e1", severity: "error", title: "Missing price", subtitle: "Line 3 · Unit price", group: "Line items", description: "Enter a price greater than 0." },
{ id: "w1", severity: "warning", title: "OCR low" },
]}
onNavigate={(item) => focusField(item.id)}
/>;
// Optional host policy gate
<MessagePopover
items={items}
onNavigate={(item) => focusField(item.id)}
onAcknowledge={(id, acknowledged) => setAck(id, acknowledged)}
onAcknowledgeAll={() => ackAllWarnings()}
renderAcknowledge={(item) => /* long legal copy */}
/>;Examples
Title Jump
The list opens on the trigger. Header and Line items are host groups; the OCR warning has no group, so it lands under General. Click a title to jump — there is no Go to field button.
"use client";
import { toast } from "sonner";
import {
groupedItems,
MessagePopoverFrame,
} from "@/demos/message-popover/demo-data";
export function MessagePopoverDemo() {
return (
<MessagePopoverFrame
items={groupedItems}
onNavigate={(item) => {
toast.message(`Navigate: ${item.title}`);
}}
/>
);
}Title vs Outline Button
The title is the recovery control (link emphasis). Do not keep an inert title and a second outline Go to field on the row.
Wrong
Missing unit price
Must fixLine 3 · Unit price
Enter a price greater than 0.
Right
Line 3 · Unit price
Enter a price greater than 0.
"use client";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
function RowSketch({ titleAsLink }: { titleAsLink: boolean }) {
return (
<div className="flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
{titleAsLink ? (
<button
type="button"
className="rounded-sm text-left text-sm font-medium text-primary hover:underline"
>
Missing unit price
</button>
) : (
<p className="text-sm font-medium text-foreground">
Missing unit price
</p>
)}
<StatusTag tone="destructive" icon="auto">
Must fix
</StatusTag>
</div>
<p className="text-sm text-muted-foreground">Line 3 · Unit price</p>
<p className="text-sm text-muted-foreground">
Enter a price greater than 0.
</p>
</div>
{titleAsLink ? null : (
<Button type="button" variant="outline">
Go to field
</Button>
)}
</div>
);
}
export function MessagePopoverTitleVsButtonDemo() {
return (
<DesignCompare
wrong={<RowSketch titleAsLink={false} />}
right={<RowSketch titleAsLink />}
/>
);
}Flat List
When no item sets group, the list stays flat — no General heading.
"use client";
import { toast } from "sonner";
import {
flatItems,
MessagePopoverFrame,
} from "@/demos/message-popover/demo-data";
export function MessagePopoverFlatDemo() {
return (
<MessagePopoverFrame
items={flatItems}
onNavigate={(item) => {
toast.message(`Navigate: ${item.title}`);
}}
/>
);
}Collapsed Identical Titles
Six lines share the title Missing invoice number, so they collapse to Title ×6. The collapsed row only expands. Open it, then click a child title — each row still has its own subtitle (Line n · Invoice number).
"use client";
import { toast } from "sonner";
import type { MessageItem } from "@/components/f-ui/message-popover/message-popover";
import { MessagePopoverFrame } from "@/demos/message-popover/demo-data";
const items: MessageItem[] = Array.from({ length: 6 }, (_, i) => ({
id: `inv-${i + 1}`,
severity: "error" as const,
title: "Missing invoice number",
subtitle: `Line ${i + 1} · Invoice number`,
group: "Line items",
description: "Enter the supplier invoice number.",
}));
export function MessagePopoverAggregateDemo() {
return (
<MessagePopoverFrame
items={items}
onNavigate={(item) => {
toast.message(`Navigate: ${item.subtitle ?? item.title}`);
}}
/>
);
}Empty Filter
This popover has warnings only. It still shows the Errors tab (count 0). The empty filter uses region Empty + Show all — do not hide the tab.
"use client";
import { MessagePopoverFrame } from "@/demos/message-popover/demo-data";
export function MessagePopoverEmptyFilterDemo() {
return (
<MessagePopoverFrame
items={[
{
id: "w1",
severity: "warning",
title: "Quantity looks high",
subtitle: "Line 1 · Qty",
description: "Confirm quantity before submit.",
},
]}
defaultFilter="errors"
/>
);
}Host Channel Tab
channels add a facet that is not a severity. This example opens on Holds; the tab is hidden when no item matches.
"use client";
import { toast } from "sonner";
import type { MessageItem } from "@/components/f-ui/message-popover/message-popover";
import { MessagePopoverFrame } from "@/demos/message-popover/demo-data";
const items: MessageItem[] = [
{
id: "e1",
severity: "error",
title: "Missing unit price",
subtitle: "Line 3 · Unit price",
group: "Line items",
description: "Enter a price greater than 0.",
},
{
id: "hold-1",
severity: "warning",
title: "Packing list mismatch",
subtitle: "Shipment 14 · Carton 2",
channel: "holds",
description: "Confirm the carton contents before submit.",
},
];
export function MessagePopoverChannelDemo() {
return (
<MessagePopoverFrame
items={items}
channels={[{ id: "holds", label: "Holds (1)" }]}
defaultFilter="holds"
onNavigate={(item) => {
toast.message(`Navigate: ${item.title}`);
}}
/>
);
}Optional Acknowledge
Soft-warning policy is host-owned. Pass onAcknowledge / onAcknowledgeAll only when you need the checkbox row. Omit them for twin-shaped chrome (see Title Jump).
"use client";
import { useState } from "react";
import { toast } from "sonner";
import type { MessageItem } from "@/components/f-ui/message-popover/message-popover";
import {
groupedItems,
MessagePopoverFrame,
} from "@/demos/message-popover/demo-data";
export function MessagePopoverAcknowledgeDemo() {
const [items, setItems] = useState<MessageItem[]>(groupedItems);
return (
<MessagePopoverFrame
items={items}
onNavigate={(item) => {
toast.message(`Navigate: ${item.title}`);
}}
onAcknowledge={(id, acknowledged) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, acknowledged } : item,
),
);
}}
onAcknowledgeAll={() => {
setItems((prev) =>
prev.map((item) =>
item.severity === "warning"
? { ...item, acknowledged: true }
: item,
),
);
}}
/>
);
}Footer After Validate
Object readiness lives in the sticky footer: Message Popover on the left, Save / Submit on the right. Open after Validate or a blocked Submit — not on every draft keystroke. Placement locks: Object Messaging And Table Chrome.
Miniature footer pages must share the toolbar inset (px-6 / 24px). Do not mix a px-4 body with the kit footer.
Purchase request · PR-1042
Draft edits stay quiet. Click Validate to open the object message list.
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { FooterToolbar } from "@/components/f-ui/page/footer-toolbar";
import {
MessagePopover,
type MessageItem,
} from "@/components/f-ui/message-popover/message-popover";
import { Button } from "@/components/ui/button";
import { groupedItems } from "@/demos/message-popover/demo-data";
export function MessagePopoverFooterDemo() {
const [open, setOpen] = useState(false);
const [items] = useState<MessageItem[]>(groupedItems);
return (
<div className="overflow-hidden rounded-xl border bg-card">
<div className="space-y-2 border-b px-6 py-3">
<p className="text-sm font-medium">Purchase request · PR-1042</p>
<p className="text-xs text-muted-foreground">
Draft edits stay quiet. Click Validate to open the object message
list.
</p>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Validate
</Button>
</div>
<FooterToolbar
className="border-t-0"
extra={
<MessagePopover
items={items}
open={open}
onOpenChange={setOpen}
onNavigate={(item) => {
toast.message(`Navigate: ${item.title}`);
}}
/>
}
>
<Button type="button" variant="outline">
Save draft
</Button>
<Button type="button" onClick={() => setOpen(true)}>
Submit
</Button>
</FooterToolbar>
</div>
);
}Message Details
Long backend text belongs on details, not description. Open the chevron, or a single message with details.
Several messages
Sole message with details
"use client";
import { type MessageItem } from "@/components/f-ui/message-popover/message-popover";
import { MessagePopoverFrame } from "@/demos/message-popover/demo-data";
const several: MessageItem[] = [
{
id: "e1",
severity: "error",
title: "Missing unit price",
subtitle: "Line 3 · Unit price",
group: "Line items",
description: "Enter a price greater than 0.",
details:
"The catalog row has no price. Purchasing must confirm the contract rate before submit.\nDo not guess a unit price from a prior PO.",
detailsLink: {
href: "https://ui.isaacfei.com/docs/components/message-popover",
label: "More information",
},
},
{
id: "w1",
severity: "warning",
title: "Quantity looks high",
subtitle: "Line 1 · Qty",
group: "Line items",
description: "Confirm quantity before submit.",
},
];
const sole: MessageItem[] = [several[0]!];
export function MessagePopoverDetailsDemo() {
return (
<div className="flex flex-col gap-8">
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Several messages</p>
<MessagePopoverFrame items={several} onNavigate={() => {}} />
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Sole message with details
</p>
<MessagePopoverFrame items={sole} onNavigate={() => {}} />
</div>
</div>
);
}Timing
Hosts decide when items appear and when open is set. Recommended: after Validate / Run check / Save / Submit — not on every keystroke while drafting.
API
MessagePopover
| Prop | Type | Default | Notes |
|---|---|---|---|
items | MessageItem[] | — | Hide root when empty |
open / onOpenChange | controlled | — | Optional |
channels | MessageChannel[] | — | Host filter facets; tab only when matching items exist |
defaultFilter | MessageFilter | "all" | Uncontrolled initial filter (severity or channel id) |
filter / onFilterChange | controlled | — | Controlled filter (severity or channel id) |
onNavigate | (item) => void | — | Shows title as the jump control; closes shell before invoke |
navigateLabel | string | (item) => string | — | Optional. When set, exposes the string via aria-describedby only (e.g. Go to line). Never the visible button name. No default Go to field describedby. |
formatTitle | (item) => string | item.title | Display only; aggregate uses title |
trigger | ReactNode | ghost worst-type count | Footer embedding |
renderEmpty | (ctx) => ReactNode | — | Full Empty takeover |
emptyTitle / emptyDescription / emptyIcon / emptyAction | ReactNode | i18n defaults | Slot overrides for default Empty |
onAcknowledge | (id, ack) => void | — | Optional — warnings only; enables default checkbox |
onAcknowledgeAll | () => void | — | Optional — Acknowledge all link |
renderAcknowledge | (item) => ReactNode | short checkbox | Optional — long legal copy |
id / className | string | — | Root wrapper |
MessageItem
{
id: string;
severity: "error" | "warning" | "info" | "success";
title: string;
subtitle?: string; // location only; omit when unknown
description?: string; // advice
details?: string; // long text; details page only
detailsLink?: { href: string; label?: string }; // optional docs <a> on details page
group?: string; // host section name
acknowledged?: boolean;
channel?: string;
meta?: Record<string, unknown>;
}info / success may appear under All; Info / Success tabs appear only when those items exist. All / Errors / Warnings stay visible. Trigger count uses the worst type only. Channel ids are separate from severity.
Result
Centered exception and outcome block for 403/404/500/success/error/info with default status icons and inline recovery actions.
Notification Center
Bell trigger with an on-demand notification panel — unseen count, unread filter, grouped sections, inline actions with overflow, and Loading/Empty/Error states.