v1.0

ViewTabs

Preview

Basic

Loading…

Preview

Code

ts
import ViewTabs from '@/components/table/ViewTabs';

src/components/table/ViewTabs.tsx

AI prompt

text
Build a saved-views tab bar component in React + TypeScript + Tailwind CSS, for use above a data table. Each tab has a mode icon, a caret menu, rename in place, drag to reorder, and a "+" that adds a grid or a board view.

## Look
- Root: `flex min-w-0 items-end gap-1 border-b border-slate-200 dark:border-slate-700`.
- Tab strip: `role="tablist"`, `-mb-px flex min-w-0 items-end gap-0.5 overflow-x-auto overflow-y-hidden`, with a thin scrollbar.
  - It scrolls sideways ONLY. `overflow-x: auto` alone makes the other axis auto too, and the 1px underline would then add a vertical scrollbar.
  - `-mb-px` lets the active underline sit ON the bar's border line.
- Tab: `relative flex shrink-0 items-center gap-1.5 rounded-t-lg px-3 py-2 text-xs`.
  - Active: `bg-white font-medium text-indigo-600 dark:bg-slate-900 dark:text-indigo-300`, plus an underline `absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-indigo-500`.
  - Inactive: `text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800`.
- Mode icon (14px): Table2 for grid, SquareKanban for board. indigo-500 on the active tab, slate-400 otherwise.
- Name button: `max-w-[12rem] truncate`, with the title "<name> — double-click to rename".
- Caret button: ChevronDown 12px, `rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600`. Always visible on the active tab. On other tabs it shows only on tab hover or keyboard focus.
- "+" after the strip: 28px square, `mb-1 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-indigo-600` (dark hover slate-800 / indigo-300).
- Rename input: `w-28 rounded border border-indigo-300 bg-white px-1 py-0.5 text-xs`, dark `border-indigo-500/50 bg-slate-900`.
- Menus: 192px wide, opaque, p-1. Items are `flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs text-slate-700 hover:bg-slate-100 disabled:opacity-40` with 14px slate-400 icons.
  - Tab menu: Rename (Pencil), Duplicate (Copy), a divider, then "Delete view" (Trash2) in rose-600 / dark rose-400. Delete is disabled on the last view, with the title "A table always keeps one view".
  - "+" menu: a "New view" heading (10px semibold uppercase slate-400), then "Grid view" and "Board view".

## Behaviour
- Holds no view data. The caller owns the list and every change comes back through a callback. The only local state is which tab is being renamed, the open menu, and the drag.
- Double-click a name, or choose Rename, to edit it in place. The input is focused with its text selected. Enter or blur keeps the trimmed name (ignored if empty or unchanged). Escape abandons the edit.
- `onCreate(mode)` returns the new view's id, and that tab opens straight into rename. If `modes` has a single entry, "+" creates that mode directly with no menu.
- Drag a tab to reorder it (HTML5 drag and drop; no dragging while renaming). The dragged tab goes to 40% opacity. The hovered tab shows a 2px indigo inset bar on the half the pointer is over (`shadow-[inset_2px_0_0_0_#6366f1]` / `shadow-[inset_-2px_0_0_0_#6366f1]`). On drop, call `onReorder(ids)`.
- Menus are portalled to <body> with `position: fixed`, 4px below the trigger, and their left edge clamped to [8, viewportWidth − 200]. They are portalled because the strip scrolls sideways and would clip an in-flow menu.
- Each action shows only when its callback is passed.

## API
- `type ViewMode = 'grid' | 'board'`
- `type ViewTab = { id, name, mode }`
- Props:
  - `views`, `activeId`, `onSelect`
  - `onCreate?: (mode) => string | void`
  - `onRename?`, `onDuplicate?`, `onDelete?`, `onReorder?(ids)`
  - `modes = ['grid','board']`, `className`

## Accessibility
- Name buttons are `role="tab"` with `aria-selected`, and the strip has `aria-label="Views"`.
- Caret button: `aria-label="<name> view options"`. Add button: "Add view". Rename input: "View name".
- Menus use `role="menu"` with `menuitem` items.

## Demo
Three views: "All tasks" (grid), "My open work" (grid) and "Status board" (board). All callbacks are wired to local state.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Copy, Pencil, Plus, SquareKanban, Table2, Trash2, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';

export type ViewMode = 'grid' | 'board';

/** A saved view as the tab bar needs it. The state it carries is the caller's. */
export type ViewTab = { id: string; name: string; mode: ViewMode };

const MODE_ICON: Record<ViewMode, LucideIcon> = { grid: Table2, board: SquareKanban };
const MODE_LABEL: Record<ViewMode, string> = { grid: 'Grid view', board: 'Board view' };

/**
 * The view tabs above a Lark Base table — one tab per saved view, each with
 * its mode's icon, a caret menu (rename, duplicate, delete), double-click to
 * rename in place, drag to reorder, and "+" to add a grid or a board.
 *
 * Stateless about the views themselves: every change comes back through a
 * callback and the caller holds the list — `BaseTable` does, and so can an
 * app that saves views on its server. The one state kept here is which tab
 * is being renamed, because that is the tab bar's business alone.
 *
 * The menus are PORTALLED and fixed: the tab strip scrolls sideways when
 * there are many views, and an in-flow menu would be clipped by it.
 */
export default function ViewTabs({
  views,
  activeId,
  onSelect,
  onCreate,
  onRename,
  onDuplicate,
  onDelete,
  onReorder,
  modes = ['grid', 'board'],
  className,
}: {
  views: ViewTab[];
  activeId: string;
  onSelect: (id: string) => void;
  /** Returns the new view's id, so the tab can open straight into renaming. */
  onCreate?: (mode: ViewMode) => string | void;
  onRename?: (id: string, name: string) => void;
  onDuplicate?: (id: string) => void;
  /** Never offered on the last view — a table always has one. */
  onDelete?: (id: string) => void;
  onReorder?: (ids: string[]) => void;
  /** Which kinds of view "+" offers. */
  modes?: ViewMode[];
  className?: string;
}) {
  const [renaming, setRenaming] = useState<string | null>(null);
  const [menu, setMenu] = useState<{ id: string | null; top: number; left: number } | null>(null);
  const [dragId, setDragId] = useState<string | null>(null);
  const [dropAt, setDropAt] = useState<{ id: string; side: 'before' | 'after' } | null>(null);
  const menuRef = useRef<HTMLDivElement>(null);
  useDismiss(menuRef, menu !== null, () => setMenu(null));

  const openMenu = (id: string | null, el: HTMLElement) => {
    const r = el.getBoundingClientRect();
    setMenu({ id, top: r.bottom + 4, left: Math.max(8, Math.min(r.left, window.innerWidth - 200)) });
  };

  const create = (mode: ViewMode) => {
    setMenu(null);
    const id = onCreate?.(mode);
    if (id) setRenaming(id);
  };

  const drop = () => {
    if (!dragId || !dropAt || dragId === dropAt.id || !onReorder) return;
    const ids = views.map((v) => v.id).filter((id) => id !== dragId);
    const at = ids.indexOf(dropAt.id) + (dropAt.side === 'after' ? 1 : 0);
    ids.splice(at, 0, dragId);
    onReorder(ids);
  };

  const item = 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-slate-700 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent dark:text-slate-200 dark:hover:bg-slate-700';
  const menuView = menu?.id ? views.find((v) => v.id === menu.id) : null;

  return (
    <div className={cn('flex min-w-0 items-end gap-1 border-b border-slate-200 dark:border-slate-700', className)}>
      {/* Scrolls SIDEWAYS only. `overflow-x: auto` alone makes the other axis
          `auto` too, and the underline reaching the strip's border was one
          pixel of vertical overflow — a scrollbar on a one-row strip. The
          strip overlaps the border by that pixel (`-mb-px`) so the underline
          sits ON the line inside the box, and the vertical axis is clipped. */}
      <div
        role="tablist"
        aria-label="Views"
        className="-mb-px flex min-w-0 items-end gap-0.5 overflow-x-auto overflow-y-hidden [scrollbar-width:thin]"
      >
        {views.map((v) => {
          const Icon = MODE_ICON[v.mode];
          const active = v.id === activeId;
          return (
            <div
              key={v.id}
              draggable={Boolean(onReorder) && renaming !== v.id}
              onDragStart={(e) => {
                e.dataTransfer.effectAllowed = 'move';
                e.dataTransfer.setData('text/plain', v.id);
                setDragId(v.id);
              }}
              onDragOver={(e) => {
                if (!dragId) return;
                e.preventDefault();
                const r = e.currentTarget.getBoundingClientRect();
                const side = e.clientX < r.left + r.width / 2 ? 'before' : 'after';
                if (dropAt?.id !== v.id || dropAt.side !== side) setDropAt({ id: v.id, side });
              }}
              onDrop={(e) => { e.preventDefault(); drop(); }}
              onDragEnd={() => { setDragId(null); setDropAt(null); }}
              className={cn(
                'group/tab relative flex shrink-0 items-center gap-1.5 rounded-t-lg px-3 py-2 text-xs transition-colors',
                active
                  ? 'bg-white font-medium text-indigo-600 dark:bg-slate-900 dark:text-indigo-300'
                  : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800',
                dragId === v.id && 'opacity-40',
                dropAt?.id === v.id && dropAt.side === 'before' && 'shadow-[inset_2px_0_0_0_#6366f1]',
                dropAt?.id === v.id && dropAt.side === 'after' && 'shadow-[inset_-2px_0_0_0_#6366f1]',
              )}
            >
              <Icon className={cn('h-3.5 w-3.5 shrink-0', active ? 'text-indigo-500' : 'text-slate-400')} aria-hidden />
              {renaming === v.id ? (
                <RenameInput
                  initial={v.name}
                  onDone={(name) => {
                    setRenaming(null);
                    if (name && name !== v.name) onRename?.(v.id, name);
                  }}
                />
              ) : (
                <button
                  type="button"
                  role="tab"
                  aria-selected={active}
                  onClick={() => onSelect(v.id)}
                  onDoubleClick={() => onRename && setRenaming(v.id)}
                  className="max-w-[12rem] truncate outline-none"
                  title={onRename ? `${v.name} — double-click to rename` : v.name}
                >
                  {v.name}
                </button>
              )}
              {(onRename || onDuplicate || onDelete) && renaming !== v.id && (
                <button
                  type="button"
                  aria-label={`${v.name} view options`}
                  onClick={(e) => openMenu(v.id, e.currentTarget)}
                  className={cn(
                    'rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 dark:hover:bg-slate-700',
                    active ? 'opacity-100' : 'opacity-0 group-hover/tab:opacity-100 focus-visible:opacity-100',
                  )}
                >
                  <ChevronDown className="h-3 w-3" aria-hidden />
                </button>
              )}
              {/* The active tab's underline, drawn over the strip's own border. */}
              {active && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-indigo-500" aria-hidden />}
            </div>
          );
        })}
      </div>

      {onCreate && (
        <button
          type="button"
          aria-label="Add view"
          title="Add view"
          onClick={(e) => (modes.length === 1 ? create(modes[0]) : openMenu(null, e.currentTarget))}
          className="mb-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-indigo-600 dark:hover:bg-slate-800 dark:hover:text-indigo-300"
        >
          <Plus className="h-4 w-4" aria-hidden />
        </button>
      )}

      {menu && typeof document !== 'undefined'
        ? createPortal(
            <div ref={menuRef} role="menu" data-overlay="menu" style={{ top: menu.top, left: menu.left }} className="fixed z-[200] w-48 panel panel-solid p-1">
              {menu.id === null ? (
                <>
                  <p className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">New view</p>
                  {modes.map((m) => {
                    const Icon = MODE_ICON[m];
                    return (
                      <button key={m} type="button" role="menuitem" className={item} onClick={() => create(m)}>
                        <Icon className="h-3.5 w-3.5 text-slate-400" /> {MODE_LABEL[m]}
                      </button>
                    );
                  })}
                </>
              ) : (
                menuView && (
                  <>
                    {onRename && (
                      <button type="button" role="menuitem" className={item} onClick={() => { setMenu(null); setRenaming(menuView.id); }}>
                        <Pencil className="h-3.5 w-3.5 text-slate-400" /> Rename
                      </button>
                    )}
                    {onDuplicate && (
                      <button type="button" role="menuitem" className={item} onClick={() => { setMenu(null); onDuplicate(menuView.id); }}>
                        <Copy className="h-3.5 w-3.5 text-slate-400" /> Duplicate
                      </button>
                    )}
                    {onDelete && (
                      <>
                        <div className="my-1 border-t border-slate-100 dark:border-slate-700" />
                        <button
                          type="button"
                          role="menuitem"
                          disabled={views.length <= 1}
                          title={views.length <= 1 ? 'A table always keeps one view' : undefined}
                          className={cn(item, 'text-rose-600 dark:text-rose-400')}
                          onClick={() => { setMenu(null); onDelete(menuView.id); }}
                        >
                          <Trash2 className="h-3.5 w-3.5" /> Delete view
                        </button>
                      </>
                    )}
                  </>
                )
              )}
            </div>,
            document.body,
          )
        : null}
    </div>
  );
}

/** The in-place rename box. Enter and blur keep, Escape abandons. */
function RenameInput({ initial, onDone }: { initial: string; onDone: (name: string | null) => void }) {
  const [text, setText] = useState(initial);
  const done = useRef(false);
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => ref.current?.select(), []);
  const finish = (name: string | null) => {
    if (done.current) return;
    done.current = true;
    onDone(name?.trim() || null);
  };
  return (
    <input
      ref={ref}
      autoFocus
      value={text}
      onChange={(e) => setText(e.target.value)}
      onBlur={() => finish(text)}
      onKeyDown={(e) => {
        if (e.key === 'Enter') finish(text);
        else if (e.key === 'Escape') finish(null);
      }}
      aria-label="View name"
      className="w-28 rounded border border-indigo-300 bg-white px-1 py-0.5 text-xs text-slate-800 outline-none dark:border-indigo-500/50 dark:bg-slate-900 dark:text-slate-100"
    />
  );
}

Props

PropTypeDefaultDescription
views*ViewTab[]—
activeId*string—
onSelect*(id: string) => void—
onCreate(mode: ViewMode) => string | void—Returns the new view's id, so the tab can open straight into renaming.
onRename(id: string, name: string) => void—
onDuplicate(id: string) => void—
onDelete(id: string) => void—Never offered on the last view — a table always has one.
onReorder(ids: string[]) => void—
modesViewMode[]['grid', 'board']Which kinds of view "+" offers.
classNamestring—