App screens
Data table view
The list-screen pattern, done as a real app screen: the same persistent sidebar/header shell as the other App screens patterns, a row of status Tabs acting as a live filter (not separate content panels), a toolbar with a removable sort pill, filter Dropdowns, and search, and a genuine sortable/selectable Table with AvatarGroup recipients and a Pagination footer.
How it's built
The status row is a real `Tabs`, driving `onSelectionChange` to filter the same Table below instead of swapping between separate content panels — a legitimate `Tabs` use even without one panel per tab, as long as something visibly responds to the selection.
Each status tab's title is `whitespace-nowrap` and the tablist scrolls (`overflow-x-auto`) instead of wrapping — without it, a longer label like "For Approval" wraps onto two lines and the whole row becomes uneven.
Recipients use a real `AvatarGroup` (`max={3}`) — it overlaps the avatars and collapses the overflow into a "+N" once past max, the standard "who's on this" cluster, not a hand-rolled `-space-x` stack.
Selection is controlled — `selectedKeys` / `onSelectionChange` — so a bulk-action bar can react to it.
Sorting is client-side until you pass `onSortChange`, at which point it becomes your server's job.
The sidebar and header are the same bounded-height app shell as analytics-dashboard: `overflow-hidden` on the outer wrapper, independent scroll on the sidebar, `shrink-0` on the header, so only the documents content itself scrolls — and every direct child of that scrollable body carries `shrink-0` too, or a flex child's default `flex-shrink: 1` would silently compress it to fit instead of letting it overflow.
Source
One file · copy & paste"use client";
import { useMemo, useState } from "react";
import { Avatar, AvatarGroup } from "oks-ui/avatar";
import { Button } from "oks-ui/button";
import { Chip } from "oks-ui/chip";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "oks-ui/dropdown";
import { FormFieldSet } from "oks-ui/form-field-set";
import type { NavItemData } from "oks-ui/nav";
import { PageTitle } from "oks-ui/page-title";
import { Pagination, PaginationSummary } from "oks-ui/pagination";
import { Table } from "oks-ui/table";
import { Tab, Tabs } from "oks-ui/tabs";
import "oks-ui/avatar.css";
import "oks-ui/button.css";
import "oks-ui/chip.css";
import "oks-ui/dropdown.css";
import "oks-ui/form-field-set.css";
import "oks-ui/page-title.css";
import "oks-ui/pagination.css";
import "oks-ui/table.css";
import "oks-ui/tabs.css";
import { PatternAppShell } from "../PatternAppShell";
import {
ChevronDownIcon,
ClockIcon,
FileTextIcon,
GridIcon,
HelpCircleIcon,
MailIcon,
MessageCircleIcon,
MoreIcon,
PieChartIcon,
PlusIcon,
ReceiptIcon,
SendIcon,
SparkleIcon,
XIcon,
} from "../../showcase/icons";
// A simple open eye — "Viewed", not a reproduction of any icon set.
function EyeIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M1 8s2.5-4.5 7-4.5S15 8 15 8s-2.5 4.5-7 4.5S1 8 1 8Z" />
<circle cx="8" cy="8" r="2" />
</svg>
);
}
// A single looping arrow — "For approval / in progress".
function RefreshIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M13.5 8A5.5 5.5 0 0 1 3.9 11.6M2.5 8A5.5 5.5 0 0 1 12.1 4.4" />
<path d="M12.1 1.8v2.6h-2.6M3.9 14.2v-2.6h2.6" />
</svg>
);
}
// A checkmark inside a ring — "Sign completed".
function CheckCircleIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="6.5" />
<path d="M5.3 8.2 7.2 10l3.5-4" />
</svg>
);
}
// A funnel — "More filters".
function FilterIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 3h12l-4.5 5.5v4L6.5 14v-5.5Z" />
</svg>
);
}
// A simple folder — "Manage folders".
function FolderIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 4.5a1 1 0 0 1 1-1h3l1.5 1.5H13a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z" />
</svg>
);
}
// Three horizontal lines — the "list view" half of the grid/list toggle.
function ListViewIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
<path d="M3 4h10M3 8h10M3 12h10" />
</svg>
);
}
const NAV_ITEMS: NavItemData[] = [
{
key: "main",
label: "Main menu",
isSection: true,
children: [
{ key: "dashboard", label: "Dashboard", icon: <GridIcon size={18} /> },
{ key: "analytics", label: "Analytics", icon: <PieChartIcon size={18} /> },
{ key: "documents", label: "Documents", icon: <FileTextIcon size={18} /> },
{ key: "invoices", label: "Invoices", icon: <ReceiptIcon size={18} /> },
],
},
{
key: "features",
label: "Features",
isSection: true,
children: [
{ key: "recurring", label: "Recurring", icon: <ClockIcon size={18} /> },
{ key: "feedback", label: "Feedback", icon: <MessageCircleIcon size={18} /> },
],
},
];
const STATUS_TABS = [
{ key: "all", label: "All Documents", icon: <GridIcon size={15} /> },
{ key: "draft", label: "Draft", icon: <FileTextIcon size={15} /> },
{ key: "approval", label: "For Approval", icon: <RefreshIcon size={15} /> },
{ key: "sent", label: "Sent", icon: <SendIcon size={15} /> },
{ key: "viewed", label: "Viewed", icon: <EyeIcon size={15} /> },
{ key: "suggest", label: "Suggest Edits", icon: <SparkleIcon size={15} /> },
{ key: "completed", label: "Sign Completed", icon: <CheckCircleIcon size={15} /> },
{ key: "expired", label: "Expired/Declined", icon: <XIcon size={15} /> },
];
type DocStatus = "Draft" | "Completed" | "Sent" | "Expired";
type DocRow = {
id: number;
name: string;
createdAt: string;
lastActivity: string;
recipients: string[];
status: DocStatus;
} & Record<string, unknown>;
const DOCUMENTS: DocRow[] = [
{ id: 1, name: "Sample Sales Proposal", createdAt: "Created: Jan 16, 2026, 9:18 AM", lastActivity: "Jan 24, 2026, 9:18 AM", recipients: ["Nora Chen", "Marcus Lee", "Ada Lovelace"], status: "Draft" },
{ id: 2, name: "Start exploring here! (Product guide)", createdAt: "Created: Jan 15, 2026, 9:18 AM", lastActivity: "Jan 23, 2026, 9:18 AM", recipients: ["Grace Hopper", "Alan Turing"], status: "Completed" },
{ id: 3, name: "Memorandum of Understanding", createdAt: "Created: Jan 14, 2026, 9:18 AM", lastActivity: "Jan 23, 2026, 9:18 AM", recipients: ["Margaret Hamilton"], status: "Completed" },
{ id: 4, name: "Employment Contract", createdAt: "Created: Jan 13, 2026, 9:20 AM", lastActivity: "Jan 22, 2026, 9:18 AM", recipients: ["Barbara Liskov", "Radia Perlman", "Ada Lovelace", "Grace Hopper"], status: "Sent" },
{ id: 5, name: "Purchase Agreement", createdAt: "Created: Jan 12, 2026, 9:18 AM", lastActivity: "Jan 21, 2026, 10:24 AM", recipients: ["Alan Turing", "Nora Chen"], status: "Draft" },
{ id: 6, name: "Lease Agreement", createdAt: "Created: Jan 10, 2026, 11:18 AM", lastActivity: "Jan 21, 2026, 10:22 AM", recipients: ["Marcus Lee", "Grace Hopper", "Radia Perlman"], status: "Completed" },
{ id: 7, name: "Copyright Registration", createdAt: "Created: Jan 10, 2026, 11:18 AM", lastActivity: "Jan 21, 2026, 10:20 AM", recipients: ["Margaret Hamilton"], status: "Sent" },
{ id: 8, name: "As-is Bill of Sale", createdAt: "Created: Jan 10, 2026, 10:18 AM", lastActivity: "Jan 20, 2026, 10:18 AM", recipients: ["Ada Lovelace", "Barbara Liskov"], status: "Expired" },
{ id: 9, name: "Service Agreement", createdAt: "Created: Jan 10, 2026, 9:18 AM", lastActivity: "Jan 20, 2026, 9:15 AM", recipients: ["Nora Chen", "Alan Turing"], status: "Draft" },
{ id: 10, name: "Non-Disclosure Agreement (NDA)", createdAt: "Created: Jan 10, 2026, 9:16 AM", lastActivity: "Jan 20, 2026, 8:18 AM", recipients: ["Marcus Lee"], status: "Completed" },
];
// A handful of recipients get a real photo — two are the same stock-free
// portrait assets the AvatarGroup recipe already uses elsewhere on this
// site, three are AI-synthesized (non-real-person) faces — the rest fall
// back to initials, exactly like that recipe's own photo/initials mix.
const RECIPIENT_PHOTOS: Record<string, string> = {
"Nora Chen": "/hero/person-woman.jpg",
"Marcus Lee": "/hero/person-man.jpg",
"Ada Lovelace": "/avatars/synthetic-1.jpg",
"Grace Hopper": "/avatars/synthetic-2.jpg",
"Margaret Hamilton": "/avatars/synthetic-3.jpg",
};
const statusTone: Record<DocStatus, "default" | "success" | "info" | "danger"> = {
Draft: "default",
Completed: "success",
Sent: "info",
Expired: "danger",
};
const PAGE_SIZE = 10;
function RowMenu({ label }: { label: string }) {
return (
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button isIconOnly aria-label={label} variant="ghost" radius="full" size="sm">
<MoreIcon size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label={label}>
<DropdownItem key="rename">Rename</DropdownItem>
<DropdownItem key="duplicate">Duplicate</DropdownItem>
<DropdownItem key="delete" classNames={{ title: "text-[color:var(--oks-color-danger-700)]" }}>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
);
}
export function DataTableView() {
const [status, setStatus] = useState("all");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [sortBy, setSortBy] = useState<string | null>("Last Updated Desc");
const filtered = useMemo(() => {
const byStatus =
status === "all"
? DOCUMENTS
: DOCUMENTS.filter((d) => d.status.toLowerCase() === status.replace("approval", "sent").toLowerCase() || d.status.toLowerCase() === status);
return byStatus.filter((d) => d.name.toLowerCase().includes(query.toLowerCase()));
}, [status, query]);
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<PatternAppShell
navItems={NAV_ITEMS}
selectedNavKey="documents"
headerExtra={
<>
<Button isIconOnly aria-label="Help" variant="ghost" radius="full" size="sm">
<HelpCircleIcon size={18} />
</Button>
<Button isIconOnly aria-label="Messages" variant="ghost" radius="full" size="sm">
<MailIcon size={18} />
</Button>
</>
}
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3">
<PageTitle
as="h2"
title="My Documents"
classNames={{ base: "flex-col items-start", title: "text-2xl font-bold tracking-tight text-ink" }}
/>
<div className="flex items-center gap-2">
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button variant="bordered" radius="full" size="sm" endContent={<ChevronDownIcon size={14} />}>
Export
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Export options">
<DropdownItem key="csv">Export as CSV</DropdownItem>
<DropdownItem key="pdf">Export as PDF</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button color="primary" radius="full" size="sm" startContent={<PlusIcon size={14} />}>
New Document
</Button>
</div>
</div>
{/* Status Tabs — a real Tabs, not a set of styled buttons, so
switching between them is roving-tabindex and arrow-key
accessible for free. */}
<Tabs
aria-label="Filter by status"
variant="underlined"
selectedKey={status}
onSelectionChange={(key) => {
setStatus(String(key));
setPage(1);
}}
classNames={{ base: "shrink-0", tabList: "overflow-x-auto", tab: "shrink-0" }}
>
{STATUS_TABS.map((t) => (
<Tab
key={t.key}
title={
<span className="flex items-center gap-1.5 whitespace-nowrap">
{t.icon}
{t.label}
</span>
}
/>
))}
</Tabs>
{/* toolbar */}
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
{sortBy ? (
<Chip
size="sm"
variant="bordered"
color="primary"
onClose={() => setSortBy(null)}
closeIcon={<XIcon size={12} />}
>
Sort By: {sortBy}
</Chip>
) : null}
<Dropdown placement="bottom-start">
<DropdownTrigger>
<Button variant="bordered" size="sm" endContent={<ChevronDownIcon size={14} />}>
Name
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Filter by name" selectionMode="single">
<DropdownItem key="az">A → Z</DropdownItem>
<DropdownItem key="za">Z → A</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown placement="bottom-start">
<DropdownTrigger>
<Button variant="bordered" size="sm" endContent={<ChevronDownIcon size={14} />}>
Date
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Filter by date" selectionMode="single">
<DropdownItem key="newest">Newest first</DropdownItem>
<DropdownItem key="oldest">Oldest first</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button variant="ghost" size="sm" startContent={<FilterIcon size={14} />}>
More Filters
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<FormFieldSet
type="search"
name="q"
label="Search"
placeholder="Search…"
value={query}
onChange={(v: unknown) => {
setQuery(String(v ?? ""));
setPage(1);
}}
classNames={{ base: "w-40", label: "sr-only" }}
/>
<Button variant="ghost" size="sm" startContent={<FolderIcon size={14} />}>
Manage Folders
</Button>
<Button isIconOnly aria-label="Grid view" variant="ghost" size="sm">
<GridIcon size={16} />
</Button>
<Button isIconOnly aria-label="List view" variant="soft" color="primary" size="sm">
<ListViewIcon size={16} />
</Button>
</div>
</div>
<Table
aria-label="My documents"
classNames={{ cell: "whitespace-nowrap" }}
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={(keys) => setSelected(keys as Set<number>)}
columns={[
{
key: "name",
header: "Name",
render: (r) => (
<div className="flex items-center gap-2.5">
<Avatar icon={<FileTextIcon size={16} />} color="default" radius="md" size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink">{r.name}</p>
<p className="truncate text-[0.78rem] text-ink-faint">{r.createdAt}</p>
</div>
</div>
),
},
{ key: "lastActivity", header: "Last Activity", sortable: true, sortValue: (r) => new Date(r.lastActivity) },
{
key: "recipients",
header: "Recipients",
render: (r) => (
<AvatarGroup max={3}>
{r.recipients.map((name) => (
<Avatar key={name} src={RECIPIENT_PHOTOS[name]} name={name} size="sm" radius="full" />
))}
</AvatarGroup>
),
},
{
key: "status",
header: "Status",
render: (r) => (
<Chip size="sm" variant="soft" color={statusTone[r.status]}>
{r.status}
</Chip>
),
},
{
key: "actions",
header: "",
ariaLabel: "Actions",
align: "end",
render: (r) => (
<div className="flex items-center justify-end gap-1.5">
<Button variant="bordered" size="sm">
View
</Button>
<RowMenu label={`${r.name} options`} />
</div>
),
},
]}
rows={pageRows}
getRowKey={(r) => r.id}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<PaginationSummary page={page} pageSize={PAGE_SIZE} total={filtered.length} />
<Pagination page={page} pageCount={Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))} onChange={setPage} />
</div>
</PatternAppShell>
);
}