View tabs and view menu
Saved Views sit as tabs in the page header, Archived last, then + to save the current table as a new View. Rename, delete and restore live in Display.
Playground
Change the controls to try each prop. The preview puts the tab in a row before Mine; the code lists only the props you changed.
import { ViewTab } from '@/ui/view-tabs-and-view-menu';<ViewTab> Shortlisted</ViewTab>Header tabs
One tab per shared View in saved order, Archived last, then +. Archived is a filter, never a saved View. Press + to save the current table as a new View.
Candidates
import { PlusIcon, UploadSimpleIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Button } from '@/ui/button';
import { toast } from '@/ui/toast';
import { ViewNameDialog, ViewTab, ViewTabs, ViewTabsAdd } from '@/ui/view-tabs-and-view-menu';
// Switch Views, open Archived, or press + to save the table as a new View.
export default function ViewTabsHeaderTabs() {
const [views, setViews] = useState(['All', 'Mine', 'Shortlisted']);
const [current, setCurrent] = useState('All');
const [archived, setArchived] = useState(false);
const [creating, setCreating] = useState(false);
return (
<header className="flex h-14 w-full items-center gap-150 border-b border-border bg-surface px-200">
<h2 className="text-heading-small leading-body font-semibold tracking-heading-small text-text">Candidates</h2>
<ViewTabs className="flex-1">
{views.map((view) => (
<ViewTab
key={view}
current={view === current && !archived}
onClick={() => {
setCurrent(view);
setArchived(false);
}}
>
{view}
</ViewTab>
))}
<ViewTab current={archived} onClick={() => setArchived(true)}>
Archived
</ViewTab>
<ViewTabsAdd onClick={() => setCreating(true)} />
</ViewTabs>
<div className="ms-auto flex items-center gap-100">
<Button>
<UploadSimpleIcon />
Import
</Button>
<Button variant="primary">
<PlusIcon />
Add candidate
</Button>
</div>
<ViewNameDialog
mode="create"
open={creating}
onOpenChange={setCreating}
onSubmit={(name) => {
setViews([...views, name]);
setCurrent(name);
setArchived(false);
setCreating(false);
toast.add({ type: 'success', title: `${name} saved for everyone` });
}}
/>
</header>
);
}Tab states and groups
One tab style everywhere: subtle at rest, ink on hover, ink with a 2px underline on the current View. Counts are 12px subtle, never a badge. A divider starts a group of page links. + disables while a View save is pending.
import { ViewTab, ViewTabs, ViewTabsAdd, ViewTabsDivider } from '@/ui/view-tabs-and-view-menu';
// Groups with a divider: the Views, then page links with their counts. + disables while a View save is pending.
export default function ViewTabsTabStates() {
return (
<ViewTabs className="w-full bg-surface">
<ViewTab current>Open jobs</ViewTab>
<ViewTab>Closed</ViewTab>
<ViewTabsAdd onClick={() => {}} disabled />
<ViewTabsDivider />
<ViewTab href="#to-review" count={12}>
To review
</ViewTab>
<ViewTab href="#placed" count={1234}>
Placed this year
</ViewTab>
</ViewTabs>
);
}More Views than room
A row that overflows scrolls sideways; it never wraps or collapses into a menu. Long and non-Latin names show whole.
import { ViewTab, ViewTabs, ViewTabsAdd } from '@/ui/view-tabs-and-view-menu';
const views = ['All', 'Mine', 'Fintech shortlist for the Halcyon Bank risk search', 'Hà Nội engineers', 'Placed', '王小明 referrals', 'Open to relocation'];
// More Views than room: the row scrolls sideways and never wraps.
export default function ViewTabsOverflow() {
return (
<ViewTabs className="w-96 bg-surface">
{views.map((view, index) => (
<ViewTab key={view} current={index === 0}>
{view}
</ViewTab>
))}
<ViewTabsAdd onClick={() => {}} />
</ViewTabs>
);
}View menu
The Shared View group of Display. Everyone can save as new and rename; Owners and Admins also delete and restore a shipped default. While a View save is pending, every item disables and says why.
import { SlidersHorizontalIcon } from '@phosphor-icons/react';
import { Button } from '@/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/ui/dropdown-menu';
import { ViewMenu } from '@/ui/view-tabs-and-view-menu';
// An Admin gets Delete and Restore; a member gets Save as new and Rename. While a save is pending, every item waits.
export default function ViewTabsViewMenu() {
return (
<div className="flex gap-150">
<DropdownMenu>
<DropdownMenuTrigger render={<Button />}>
<SlidersHorizontalIcon />
Display as an Admin
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<ViewMenu
onSaveAsNew={() => {}}
onRename={() => {}}
onDelete={() => {}}
restorable={[
{ key: 'all', name: 'All candidates' },
{ key: 'mine', name: 'Mine' },
{ key: 'shortlisted', name: 'Shortlisted' },
]}
onRestore={() => {}}
/>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger render={<Button />}>
<SlidersHorizontalIcon />
Display while saving
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<ViewMenu onSaveAsNew={() => {}} onRename={() => {}} pending />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}Create, rename and delete
Save as new View and Rename open the name dialog, which starts on the name field; an empty name says "Enter a View name." Delete always confirms and names the View. The first rename here fails, to show the message above the buttons; the dialog keeps what was typed.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { toast } from '@/ui/toast';
import { DeleteViewDialog, ViewNameDialog } from '@/ui/view-tabs-and-view-menu';
// Save as new View with an empty name says what to do; Rename keeps the name; Delete names the View and confirms.
// The first rename fails, to show the error above the buttons.
export default function ViewTabsDialogs() {
const [open, setOpen] = useState<'create' | 'rename' | 'delete' | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string>();
const [tries, setTries] = useState(0);
const close = () => setOpen(null);
const later = (done: () => void) => {
setPending(true);
setTimeout(() => {
setPending(false);
done();
}, 800);
};
return (
<div className="flex flex-wrap gap-100">
<Button onClick={() => setOpen('create')}>Save as new View</Button>
<Button onClick={() => setOpen('rename')}>Rename View</Button>
<Button variant="danger" onClick={() => setOpen('delete')}>
Delete View
</Button>
<ViewNameDialog mode="create" open={open === 'create'} onOpenChange={(next) => !next && close()} pending={pending} onSubmit={(name) => later(() => { close(); toast.add({ type: 'success', title: `${name} saved for everyone` }); })} />
<ViewNameDialog
mode="rename"
open={open === 'rename'}
defaultName="Fintech shortlist"
onOpenChange={(next) => {
if (!next) close();
setError(undefined);
}}
pending={pending}
error={error}
onSubmit={() =>
later(() => {
if (tries === 0) {
setTries(1);
setError("Couldn't rename the View. Check your connection and try again.");
return;
}
close();
toast.add({ type: 'success', title: 'View renamed' });
})
}
/>
<DeleteViewDialog name="Fintech shortlist" open={open === 'delete'} onOpenChange={(next) => !next && close()} pending={pending} onDelete={() => later(() => { close(); toast.add({ type: 'success', title: 'Fintech shortlist deleted' }); })} />
</div>
);
}