f-ui
Components

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

SurfaceRole
FormErrorSummaryForm-root / header / array-root (e.g. “at least one line”) — not cell dual-list
Notification CenterActivity elsewhere that concerns this user — survives navigation and reload; never object readiness
EditableErrorRollupUnder-table cell list; defer drafting via showErrorRollup={false}
Message PopoverObject 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

AreaBehavior
TriggerGhost count of the worst type only (1 error / 2 errors / 2 warnings); hide when items.length === 0. Optional trigger slot still replaces the default.
ShellDesktop Popover · mobile Sheet
FiltersAll · Errors (n) · Warnings (n) — stable tabs; filtered 0 → region Empty + Show all
ChannelsOptional host-supplied facets beyond severity (see Channels)
Empty customizationSlot props emptyTitle / emptyDescription / emptyIcon / emptyAction, or renderEmpty full takeover
NavigateOptional onNavigatetitle is the jump (link-styled button). No default Go to field control. Shell closes on navigate
LocationOptional subtitle (field / Line 3 · Unit price). Omit when unknown — do not invent
GroupsOptional group section headers. Ungrouped items go under General only when at least one item has group
AggregateIdentical titles collapse when count > 5
DetailsOptional details long text. Chevron on the row; same Popover/Sheet (not a dialog). Sole item with details opens the details page first.
Extra typesInfo / 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.

PropWhen
onAcknowledgePer-warning checkbox (default short label, or renderAcknowledge)
onAcknowledgeAllLink when any warning is unacked
renderAcknowledgeLong 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-popover
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/message-popover
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/message-popover
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/message-popover

Also 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 fix

Line 3 · Unit price

Enter a price greater than 0.

Right

Must fix

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

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

PropTypeDefaultNotes
itemsMessageItem[]Hide root when empty
open / onOpenChangecontrolledOptional
channelsMessageChannel[]Host filter facets; tab only when matching items exist
defaultFilterMessageFilter"all"Uncontrolled initial filter (severity or channel id)
filter / onFilterChangecontrolledControlled filter (severity or channel id)
onNavigate(item) => voidShows title as the jump control; closes shell before invoke
navigateLabelstring | (item) => stringOptional. 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) => stringitem.titleDisplay only; aggregate uses title
triggerReactNodeghost worst-type countFooter embedding
renderEmpty(ctx) => ReactNodeFull Empty takeover
emptyTitle / emptyDescription / emptyIcon / emptyActionReactNodei18n defaultsSlot overrides for default Empty
onAcknowledge(id, ack) => voidOptional — warnings only; enables default checkbox
onAcknowledgeAll() => voidOptional — Acknowledge all link
renderAcknowledge(item) => ReactNodeshort checkboxOptional — long legal copy
id / classNamestringRoot 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.

On this page