ComponentsTables and views

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.

States
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>
  );
}

On this page