f-ui
Design

Object Messaging And Table Chrome

Reusable decisions for Message Popover, validation surfaces, cell value state, and region table toolbars — distilled from enterprise patterns.

Patterns below are product design decisions for integrators. Prefer kit chrome (Message Popover, DataListToolbar, Editable Table) over inventing a second language.

Examples

Object readiness lives in a sticky footer: Message Popover on the left, Save / Submit on the right. Click Validate (or a blocked Submit) to open — draft edits do not auto-open the shell.

The miniature page body uses the same px-6 inset as FooterToolbar so title, Validate, and footer actions share one column.

Purchase request · PR-1042

Draft edits stay quiet. Click Validate to open the object message list.

"use client";

import { useState } from "react";

import {
  MessagePopover,
  type MessageItem,
} from "@/components/f-ui/message-popover/message-popover";
import { FooterToolbar } from "@/components/f-ui/page/footer-toolbar";
import { Button } from "@/components/ui/button";

const MESSAGES: MessageItem[] = [
  {
    id: "e1",
    severity: "error",
    title: "Missing unit price",
    subtitle: "Line 2 · Unit price",
    group: "Line items",
    description: "Enter a price greater than 0.",
  },
  {
    id: "w1",
    severity: "warning",
    title: "Qty looks high",
    subtitle: "Line 1 · Qty",
    group: "Line items",
    description: "Confirm quantity on line 1 before submit.",
  },
];

/**
 * Footer left = Message Popover; finalizing actions on the right.
 * Open after Validate / blocked Submit — not on every draft keystroke.
 */
export function ObjectMessagingFooterPopoverDemo() {
  const [open, setOpen] = useState(false);
  const [items] = useState(MESSAGES);

  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}
          />
        }
      >
        <Button type="button" variant="outline">
          Save draft
        </Button>
        <Button
          type="button"
          onClick={() => setOpen(true)}
        >
          Submit
        </Button>
      </FooterToolbar>
    </div>
  );
}

Validation Surface Matrix

Do not stack Form Error Summary, Editable Error Rollup, and Message Popover essays for the same cells. For a single Editable Table, prefer cells + region rollup; use Message Popover when the page has multiple regions or an object-readiness footer. Required-empty cells stay blank inside that chrome — do not paint EmptyValuePlaceholder (—) as fake invalid data (that glyph is read-only empty only).

Wrong

Form errors
  • SKU required
  • Qty must be > 0
  • SKU required
Table rollup
  • Line 1 · SKU — Required
  • Line 2 · Qty — must be > 0

Three Banner lists for the same cell issues — wrong is dual ownership, not pink Alert chrome

Right

12
SKU-104
0

Cells + region rollup; Message Popover for multi-region readiness

"use client";

/**
 * Validation matrix: cell chrome + one Message Popover — not stacked essays.
 *
 * Empty invalid cells = blank kit field box (Fiori / Ant / Editable Table).
 * Never put EmptyValuePlaceholder (—) inside error chrome — that glyph is
 * read-only empty only, not “required failed.”
 *
 * @see docs/superpowers/research/2026-08-10-editable-cell-error-chrome-single-border-industry.md
 */
import { useMemo, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AlertTriangle, CircleAlert } from "lucide-react";

import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import {
  editableTableCellControlClass,
  editableTableEditableFieldErrorClass,
} from "@/components/f-ui/editable-table/lib/table-cell-chrome";
import { f } from "@/components/f-ui/field-types/catalog";
import {
  formErrorBannerBodyInnerClassName,
  formErrorBannerHeadingClassName,
  formErrorBannerLinkClassName,
  formErrorBannerRootClassName,
} from "@/components/f-ui/formily/internals/form-error-banner-chrome";
import {
  MessagePopover,
  type MessageItem,
} from "@/components/f-ui/message-popover/message-popover";
import { FooterToolbar } from "@/components/f-ui/page/footer-toolbar";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
import { cn } from "@/lib/utils";

const ITEMS: MessageItem[] = [
  {
    id: "e1",
    severity: "error",
    title: "SKU required",
    description: "Line 1 · SKU",
  },
  {
    id: "e2",
    severity: "error",
    title: "Qty must be > 0",
    description: "Line 2 · Qty",
  },
];

type LineRow = {
  id: string;
  sku: string;
  qty: number;
  invalid?: "sku" | "qty";
};

/** Empty string = missing required SKU — never fake with "—". */
const ROWS: LineRow[] = [
  { id: "1", sku: "", qty: 12, invalid: "sku" },
  { id: "2", sku: "SKU-104", qty: 0, invalid: "qty" },
];

function CellValue({
  invalid,
  empty,
  children,
}: {
  invalid: boolean;
  empty?: boolean;
  children?: ReactNode;
}) {
  if (!invalid) {
    return (
      <span className="inline-flex min-h-8 items-center px-2 text-sm">
        {children}
      </span>
    );
  }

  return (
    <div
      data-cell-error
      className={cn(
        editableTableEditableFieldErrorClass,
        "inline-flex min-h-8 min-w-[4.5rem] items-center",
      )}
    >
      {empty ? (
        <span
          aria-hidden
          className={cn(editableTableCellControlClass, "block min-h-8 w-full")}
        />
      ) : (
        <span
          className={cn(
            editableTableCellControlClass,
            "inline-flex min-h-8 items-center text-sm tabular-nums",
          )}
        >
          {children}
        </span>
      )}
    </div>
  );
}

const schema = defineDataListSchema<LineRow>({
  sku: f.custom({
    label: "SKU",
    render: (_value, row) => (
      <CellValue invalid={row.invalid === "sku"} empty={!row.sku.trim()}>
        {row.sku || undefined}
      </CellValue>
    ),
  }),
  qty: f.custom({
    label: "Qty",
    render: (_value, row) => (
      <CellValue invalid={row.invalid === "qty"}>
        <span className="tabular-nums">{row.qty}</span>
      </CellValue>
    ),
  }),
});

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});

function LoudEssay({
  icon,
  title,
  lines,
}: {
  icon: ReactNode;
  title: string;
  lines: string[];
}) {
  return (
    <div className={formErrorBannerRootClassName}>
      <div className={formErrorBannerHeadingClassName}>
        {icon}
        {title}
      </div>
      <ul className={cn(formErrorBannerBodyInnerClassName, "space-y-0.5")}>
        {lines.map((line) => (
          <li key={line} className={`${formErrorBannerLinkClassName} text-xs`}>
            {line}
          </li>
        ))}
      </ul>
    </div>
  );
}

function RightMatrix() {
  const handle = useDataList({
    schema,
    listCode: "demo-msg-matrix",
    data: ROWS,
    getRowId: (r) => r.id,
    defaultPageSize: "all",
  });

  return (
    <div className="space-y-3">
      <Table dataList={handle} />
      <FooterToolbar
        className="rounded-lg border"
        extra={<MessagePopover items={ITEMS} />}
      >
        <Button type="button">Submit</Button>
      </FooterToolbar>
      <p className="text-xs text-muted-foreground">
        Cells + region rollup; Message Popover for multi-region readiness
      </p>
    </div>
  );
}

/**
 * Do not stack FormErrorSummary + EditableErrorRollup + Message Popover essays
 * for the same cells. Prefer cell chrome + object Message Popover.
 */
export function ObjectMessagingValidationMatrixDemo() {
  const client = useMemo(() => queryClient, []);

  return (
    <DesignCompare
      wrong={
        <div className="space-y-2">
          <LoudEssay
            icon={<CircleAlert className="size-3.5" />}
            title="Form errors"
            lines={["SKU required", "Qty must be > 0", "SKU required"]}
          />
          <LoudEssay
            icon={<AlertTriangle className="size-3.5" />}
            title="Table rollup"
            lines={["Line 1 · SKU — Required", "Line 2 · Qty — must be > 0"]}
          />
          <div className="rounded-lg border p-2">
            <MessagePopover items={ITEMS} />
          </div>
          <p className="text-xs text-muted-foreground">
            Three Banner lists for the same cell issues — wrong is dual ownership, not pink Alert chrome
          </p>
        </div>
      }
      right={
        <QueryClientProvider client={client}>
          <RightMatrix />
        </QueryClientProvider>
      }
    />
  );
}

Cell Focus Value State

Table cells use one severity border on the field (SAP Fiori value state / Ant Form.Item — never a second nested Input border). Show the message on focus (value-state popper), not as a hover Tooltip. Blurred invalid cells keep that single chrome; the full list stays in Message Popover / rollup.

Precedent: Fiori Input — Value State; NN/G — Don’t use tooltips to report errors; kit research cell-tooltip-vs-value-state + editable-cell-error-chrome-single-border.

Wrong

Qty

Same single chrome — but recovery text only on hover Tooltip (banned as the primary channel)

Right

Qty

Focus → value-state popper (Fiori / Editable Table). One border only.

"use client";

/**
 * Industry chrome: ONE severity border on the field box (SAP Fiori value state,
 * Ant Form.Item / EditableProTable, Salesforce cell highlight) — never Input’s
 * own border + a wrapper border.
 *
 * @see docs/superpowers/research/2026-08-10-editable-cell-error-chrome-single-border-industry.md
 * @see docs/superpowers/research/2026-08-05-cell-tooltip-vs-value-state.md
 */
import { useState } from "react";

import {
  editableTableCellControlClass,
  editableTableEditableFieldErrorClass,
} from "@/components/f-ui/editable-table/lib/table-cell-chrome";
import { Input } from "@/components/ui/input";
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
} from "@/components/ui/popover";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { DesignCompare } from "@/demos/_design/design-compare";
import { cn } from "@/lib/utils";

const ERROR = "Qty must be greater than 0";

/** Fiori / kit: single field box; control is borderless. */
function ErrorFieldBox({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <div
      data-cell-error
      className={cn(
        editableTableEditableFieldErrorClass,
        "w-full max-w-[12rem]",
        className,
      )}
    >
      {children}
    </div>
  );
}

function BorderlessInvalidInput(
  props: React.ComponentProps<typeof Input>,
) {
  return (
    <Input
      {...props}
      aria-invalid
      className={cn(editableTableCellControlClass, props.className)}
    />
  );
}

function HoverTooltipCell() {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger asChild>
          <ErrorFieldBox>
            <BorderlessInvalidInput defaultValue="0" />
          </ErrorFieldBox>
        </TooltipTrigger>
        <TooltipContent side="bottom">{ERROR}</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}

function FocusValueStateCell() {
  const [focused, setFocused] = useState(false);

  return (
    <Popover open={focused} modal={false}>
      <PopoverAnchor asChild>
        <ErrorFieldBox>
          <BorderlessInvalidInput
            defaultValue="0"
            aria-description={ERROR}
            onFocus={() => setFocused(true)}
            onBlur={() => setFocused(false)}
          />
        </ErrorFieldBox>
      </PopoverAnchor>
      <PopoverContent
        side="bottom"
        align="start"
        sideOffset={4}
        className="w-auto max-w-xs p-2 text-xs text-destructive shadow-md"
        onOpenAutoFocus={(e) => e.preventDefault()}
        onCloseAutoFocus={(e) => e.preventDefault()}
      >
        <p role="alert">{ERROR}</p>
      </PopoverContent>
    </Popover>
  );
}

/**
 * Wrong = hover Tooltip as primary error channel (NN/G anti-pattern).
 * Right = Fiori value-state: one field chrome + message on focus (kit ArrayCellDecorator).
 */
export function ObjectMessagingCellFocusDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">Qty</p>
          <HoverTooltipCell />
          <p className="text-xs text-muted-foreground">
            Same single chrome — but recovery text only on hover Tooltip (banned
            as the primary channel)
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">Qty</p>
          <FocusValueStateCell />
          <p className="text-xs text-muted-foreground">
            Focus → value-state popper (Fiori / Editable Table). One border only.
          </p>
        </div>
      }
    />
  );
}

Region Table Toolbar

A section that owns a table keeps title and tools on one horizontal row. Left side shows the floating vertical tool stack anti-pattern; right side uses DataListToolbar rhythm.

Wrong

Documents

API overviewReady
Onboarding guidePending

Vertical tool stack + solid Refresh + mixed control heights

Right

Documents
API overviewReady
Onboarding guidePending

One row — title · ghost Refresh · outline Columns · one primary Add

"use client";

import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Columns3Icon, PlusIcon, RotateCwIcon } from "lucide-react";

import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { DataListToolbar } from "@/components/f-ui/data-list-chrome/data-list-toolbar";
import { f } from "@/components/f-ui/field-types/catalog";
import { TableView } from "@/components/f-ui/table/table-parts/table-view";
import { useTable } from "@/components/f-ui/table/use-table";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";

type DocRow = {
  id: string;
  title: string;
  status: "ready" | "pending";
};

const ROWS: DocRow[] = [
  { id: "1", title: "API overview", status: "ready" },
  { id: "2", title: "Onboarding guide", status: "pending" },
];

const schema = defineDataListSchema<DocRow>({
  title: f.text({ label: "Title" }),
  status: f.enum({
    render: "status",
    label: "Status",
    variants: {
      ready: { label: "Ready", tone: "success" },
      pending: { label: "Pending", tone: "warning" },
    },
  }),
});

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});

function MiniTable() {
  const handle = useDataList({
    schema,
    listCode: "demo-msg-region",
    data: ROWS,
    getRowId: (r) => r.id,
    defaultPageSize: "all",
  });
  const view = useTable(handle);

  return (
    <DataListProvider dataList={handle}>
      <TableView
        table={view.table}
        density={view.density}
        sort={handle.sort}
        onSortColumn={handle.onSortColumn}
        selection={handle.selection}
        getRowId={handle.getRowId}
        dataList={handle}
      />
    </DataListProvider>
  );
}

/**
 * Region table chrome: one horizontal title + tools row — never a vertical
 * floating tool stack in the corner.
 */
export function ObjectMessagingRegionToolbarDemo() {
  const client = useMemo(() => queryClient, []);

  return (
    <QueryClientProvider client={client}>
      <DesignCompare
        wrong={
          <div className="space-y-3 rounded-xl border bg-card p-4">
            <div className="flex items-start justify-between gap-4">
              <h3 className="text-sm font-medium text-muted-foreground">
                Documents
              </h3>
              <div className="flex flex-col items-stretch gap-2">
                <Button type="button">
                  <RotateCwIcon className="size-4" />
                  Refresh
                </Button>
                <Button type="button" variant="outline">
                  <Columns3Icon className="size-4" />
                  Columns
                </Button>
                <Button type="button">
                  <PlusIcon className="size-4" />
                  Add
                </Button>
              </div>
            </div>
            <MiniTable />
            <p className="text-xs text-muted-foreground">
              Vertical tool stack + solid Refresh + mixed control heights
            </p>
          </div>
        }
        right={
          <div className="space-y-3 rounded-xl border bg-card p-4">
            <DataListToolbar
              title={
                <span className="text-sm font-medium text-muted-foreground">
                  Documents
                </span>
              }
              actions={
                <>
                  <Button type="button" variant="ghost">
                    <RotateCwIcon className="size-4" />
                    Refresh
                  </Button>
                  <Button type="button" variant="outline">
                    <Columns3Icon className="size-4" />
                    Columns
                  </Button>
                  <Button type="button">
                    <PlusIcon className="size-4" />
                    Add
                  </Button>
                </>
              }
            />
            <MiniTable />
            <p className="text-xs text-muted-foreground">
              One row — title · ghost Refresh · outline Columns · one primary Add
            </p>
          </div>
        }
      />
    </QueryClientProvider>
  );
}

Segmented Same-Table Views

Mutually exclusive filters on one table use a segmented control (≤3) — not a pile of independent outline/default Buttons pretending to be tabs.

Wrong

Button pile pretending to be tabs on one table

Right

Line items

Showing · All

Segmented tabs (≤3) for mutually exclusive same-table filters

"use client";

import { useState } from "react";

import { DataListToolbar } from "@/components/f-ui/data-list-chrome/data-list-toolbar";
import { DataListToolbarTabs } from "@/components/f-ui/data-list-chrome/data-list-toolbar-tabs";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
import { cn } from "@/lib/utils";

const VIEWS = [
  { id: "all", label: "All" },
  { id: "open", label: "Open" },
  { id: "closed", label: "Closed" },
] as const;

/**
 * Same-table mutually exclusive view filters → segmented control (≤3), not a
 * pile of independent outline/default Buttons pretending to be tabs.
 */
export function ObjectMessagingSegmentedViewDemo() {
  const [wrongView, setWrongView] = useState<(typeof VIEWS)[number]["id"]>("all");
  const [rightView, setRightView] = useState<(typeof VIEWS)[number]["id"]>("all");

  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <div className="flex flex-wrap items-center gap-2">
            {VIEWS.map((view) => (
              <Button
                key={view.id}
                type="button"
                variant={wrongView === view.id ? "default" : "outline"}
                onClick={() => setWrongView(view.id)}
              >
                {view.label}
              </Button>
            ))}
          </div>
          <p className="text-xs text-muted-foreground">
            Button pile pretending to be tabs on one table
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <DataListToolbar
            title={
              <span className="text-sm font-medium text-muted-foreground">
                Line items
              </span>
            }
            tabs={
              <DataListToolbarTabs
                tabs={[...VIEWS]}
                value={rightView}
                onChange={(id) =>
                  setRightView(id as (typeof VIEWS)[number]["id"])
                }
              />
            }
          />
          <p
            className={cn(
              "rounded-lg border bg-muted/30 px-3 py-6 text-center text-xs text-muted-foreground",
            )}
          >
            Showing · {VIEWS.find((v) => v.id === rightView)?.label}
          </p>
          <p className="text-xs text-muted-foreground">
            Segmented tabs (≤3) for mutually exclusive same-table filters
          </p>
        </div>
      }
    />
  );
}

1. Object Message Aggregation

Industry twin: SAP Fiori Message Popover.

DecisionLock
RoleAggregate object / multi-region readiness messages — not cell chrome, not toast, not page Result
Not a bellItems die with the object. Cross-app activity that outlives this page belongs to Notification Center — see Messaging Surfaces
PlacementSticky footer toolbar, left; finalizing actions (Save / Submit) on the right
TriggerGhost control with worst-type icon + count of that type only (2 errors); hide when no messages. Hosts may replace the trigger.
Open timingDo not auto-open on draft edits; do open after Validate / Run check / blocked Submit (finalizing)
ShellDesktop Popover · mobile Sheet
FiltersStable All · Errors · Warnings (with counts). Info / Success tabs only when those items exist. Do not collapse empty Errors/Warnings tabs; empty filter → region Empty + Show all
AcknowledgeOptional host policy only (soft warnings / compliance). Not part of the Message Popover twin — forced acknowledgment belongs on Message Box / submit confirm. Kit shows ack chrome only when host passes callbacks
NavigateTitle is the jump (link emphasis). Optional subtitle for location. Optional group section headers. Do not add a row Go to field button

Component: Message Popover.

2. Validation Surface Matrix

Do not stack three loud essays for the same cell paths.

LayerSurfaceOwns
CellValue state (table) / Form Item (panel)Field-shaped issues on that control
Row (optional later)Contains-errors scan markerWhich rows need attention — not the full message
RegionEditable Error RollupThat table’s cell list + jump links; defer with showErrorRollup={false} while drafting
Form-rootForm Error SummaryRoot / header / array-root issues (e.g. “at least one line”). Never dual-list Editable Table cell paths
ObjectMessage PopoverMulti-region readiness after Validate / blocked Submit — not a draft auto-open

Defaults

  • Single Editable Table on a form: prefer cells + region rollup. Use Message Popover when the page has multiple regions or an object-readiness footer.
  • Table-heavy page with header or array-root rules: keep FormPage’s default FormErrorSummary (scope="all") — kit strips owned cell paths and still surfaces header / array-root.
  • Region rollup (EditableErrorRollup) uses the same Banner card as Form Error Summary (not a pink Alert). Same chrome, disjoint paths.
  • Pure table cell errors only (no header / array-root / system): errorSummary={false} so the rollup owns the aggregate, or scope="form" for system/root (path == null) only — that scope does not include header or array-root paths.
  • Kit drops owned cell paths from scope="all" automatically; array-root path issues still belong in Form Error Summary under scope="all".

Forbidden: listing the same cell issue in Form Error Summary and Editable Error Rollup; rollup-only with no cell chrome after reveal; GOV.UK-style flattening of every table cell into the page summary when a region rollup already owns those cells.

3. Cell Error And Warning Messages

DecisionLock
AlwaysValue-state chrome (ring / field tint) only when field.pattern === "editable"
Table cellsShow message on focus (value-state popper under the cell) — not hover Tooltip as the primary channel
readOnly / readPretty / displayNo ring. Host issues still appear in EditableErrorRollup. Display attention uses getRowHighlight + CellCriticalityValue (text + icon)
Panel fieldsInline message under the control (Form Item rhythm)
Blurred invalid cellChrome + accessible description; full list via Message Popover / rollup
ⓘ on column / labelOnly for supplemental definition help — never for required format rules or validation text. Default: omit until a clear need

Industry backing: NN/G — Don’t use tooltips to report errors; Fiori value state + value state message; Carbon helper text vs tooltip.

Shipped in Editable Table ArrayCellDecorator.

Display emphasis (not a ring)

JobLanguage
Which rowsgetRowHighlight leading-edge bar (error / warning / success / new)
Which cellrenderCell wrapping CellCriticalityValue — colour on the value plus a mandatory icon
WhyHost reason slot (inline, on a real link/button, or in the rollup). Kit never ships "was" / "changed from"
JumpRollup item → scroll + focus, or locate pulse when there is no control
Wrap / extra copySchema renderCell(formatted, row) — does not replace kind
Cell wash (not a ring)cellClassName on the <td> / cell — never rebuild value-state chrome

Wrong: a display table whose only signal is a red input ring. Right: row bar + coloured value with icon + rollup.

Layered cell customization (wrap copy, td wash, status + host badge) lives on Table — Cell Customization and Editable Table. CellCriticalityValue is a helper inside renderCell, not a fifth ring.

4. Region Table Toolbar

Authority rhythm: Ant Design Pro ListToolBar + CRUD Page Patterns — Region Table Toolbar.

[ Section title ]     [ view filter ]     [ region actions ]
                      [ Columns ] [ Expand ]   ← same control height
[ table … ]
DecisionLock
One horizontal row with the section title — no vertical “tool stack” in a corner
Control heightButton default (h-8). Do not mix size="sm" beside default Columns / tools
EmphasisView tools (Columns, Expand, Refresh) = ghost / outline; at most one solid primary in the row
Same-table view filtersMutually exclusive filters on one table → segmented control (≤3) or Select (≥4). Not a pile of independent outline/default Buttons pretending to be tabs
True TabsOnly when switching different content areas or separate table instances — not for filtering one Editable Table
Separate rolesView filter ≠ region action (Change selection / Mass edit) ≠ table personalization (Columns)

Industry: Fiori Table Toolbar (segmented ≤3 / select ≥4); NN/G Progressive Disclosure.

  • Prefer stock DataListToolbar / DataListToolbarTabs / Editable Table toolbar over hand-rolled chip rows.

Also see CRUD Page Patterns — Region Table Toolbar.

5. Copy And Hierarchy

  • In-app section titles, labels, and buttons: sentence case — never CSS uppercase / ALL CAPS section chips. Normative page: Sentence Case.
  • Docs / registry display titles: Title Case (documentation convention only)

See Also

On this page