v1.0

FieldsPanel

Preview

Basic

Loading…

Preview

Code

ts
import FieldsPanel from '@/components/form/FieldsPanel';

src/components/form/FieldsPanel.tsx

AI prompt

text
Build a "Fields" (column visibility and order) toolbar popover component, Lark Base style, in React + TypeScript + Tailwind CSS.

## Look
- Trigger: a 32px icon-only button (`h-8 w-8 rounded-lg`), lucide `SlidersHorizontal` at 16px. Idle `text-slate-500 hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-700/60 dark:hover:text-slate-100`; tinted `bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300` while open or while any column is hidden. A count badge of HIDDEN columns: `absolute -right-0.5 -top-0.5 h-3.5 min-w-3.5 rounded-full bg-indigo-600 px-1 text-[9px] font-semibold text-white ring-2 ring-white dark:ring-slate-800`.
- Panel: `absolute left-0 top-full mt-1 z-50`, width prop (default 320px), `max-w-[calc(100vw-2rem)]`, `max-h-[min(36rem,80vh)]`, opaque floating surface with NO padding, `flex flex-col overflow-hidden`, scale-in from the top-left (0.22s `cubic-bezier(0.34, 1.56, 0.64, 1)`, from opacity 0 / scale 0.96 / translateY 6px).
- Top (`p-2`, fixed): a search input with a 14px `Search` icon inset left (`pl-8 py-1.5`), placeholder "Search columns", autofocused.
- Middle: the only scrolling part (`min-h-0 flex-1 overflow-y-auto px-2 pb-1`). Row: `relative flex items-center gap-2 rounded-lg py-1.5 pl-6 pr-1.5 hover:bg-slate-50 dark:hover:bg-slate-800/60`:
  - A grip (`GripVertical` 14px) absolutely placed in the left gutter (`left-0.5`, vertically centred), `text-slate-300 dark:text-slate-600`, INVISIBLE until the row is hovered (`opacity-0 group-hover:opacity-100`).
  - A 16px field-type icon, slate-400 (slate-300 / dark slate-600 when hidden). Icons: text `Type`, long text `TextAlignStart`, number `Hash`, currency `CircleDollarSign`, date `Calendar`, checkbox `SquareCheck`, single select `CircleChevronDown`, multi select `ListChecks`, person `User`, URL `Link`, email `Mail`.
  - The name as a text button (truncates), 12px slate-700 / dark slate-200, or slate-400 / dark slate-500 when hidden; `hover:text-indigo-600 dark:hover:text-indigo-300` when editable. A 12px `Lock` (slate-300) follows it for a locked or fixed column.
  - At the right (`ml-auto`), an eye toggle `rounded p-1 hover:bg-slate-100 dark:hover:bg-slate-700`: shown = `Eye` in indigo-500 / dark indigo-400; hidden = `EyeOff` in slate-300 / dark slate-600; locked = disabled, `text-indigo-200 dark:text-indigo-500/40`, `cursor-not-allowed`.
- Locked (primary) columns sit first, not draggable, separated from the rest by a `my-1 border-t border-slate-100 dark:border-slate-700` divider.
- Footer (fixed, `border-t border-slate-100 dark:border-slate-700 px-2 py-1.5`): "+ New field" indigo text button (`px-2 py-1 text-xs font-medium text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-950/40`, 16px Plus) when `onNewField` is given; a ghost "Show all" on the right when anything is hidden.

## Behaviour
- COMMITS IMMEDIATELY — an eye click or a drag calls `onChange` with the new layout. No draft, no Apply.
- The layout is overrides on the caller's column array: `type FieldLayout = { order: string[]; hidden: string[]; labels: Record<string, string> }`, empty meaning "as declared". Also export `EMPTY_LAYOUT` and `arrangeColumns(columns, layout)`: locked columns first, then the keys `order` names, then any it does not mention in declared order; unknown keys are dropped. A column's shown name is `layout.labels[key] || label`.
- Search filters by the shown name (case-insensitive). Dragging is OFF while filtering (rows render plainly), since a drop in a filtered list has no honest position in the full one. No hits: "No column matches “query”." centred, 12px slate-400. Closing the panel clears the query.
- Reorder by native HTML5 drag: a row is draggable only while the pointer is down on its grip; rows reorder live; dragged row `opacity-40` + `bg-slate-100 dark:bg-slate-700`; a drag ended without a drop reverts.
- Clicking a name calls `onEdit(key, panelRect)` and "New field" calls `onNewField(panelRect)` — the panel's own bounding rect, so the caller can open a field editor BESIDE the panel. The panel stays open while that editor is in use: mark floating surfaces with a `data-overlay` attribute (this panel `data-overlay="menu"`), treat a click inside any `[data-overlay]` as not-outside, and ignore Escape while a `[data-overlay="panel"]` is open so one Escape closes the editor, not both.

## API
- `type FieldColumn = { key: string; label: string; type?: FieldType; locked?: boolean; fixed?: boolean }` — `locked`: primary column, pinned first, always shown; `fixed`: app-owned type, shown with a lock but still hideable and movable.
- Props: `columns`, `layout`, `onChange(layout)`, `onEdit?(key, rect)`, `onNewField?(rect)`, `title = 'Fields'`, `width = 320`, `className`.

## Accessibility
- Trigger `aria-label` "Fields, 2 hidden" (or just the title) and `aria-expanded`. Eye: `aria-pressed={shown}`, labels "Hide Status" / "Show Status" / "Task is always shown". Grip: "Drag to reorder Status". Lock icon label: "Primary column — always shown" or "Type set by the app".

## Demo
Columns Task (locked), Status, Estimate, Due, Billable with Estimate hidden to start; show the layout as JSON below.

## 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 { useRef, useState } from 'react';
import { Eye, EyeOff, GripVertical, Lock, Plus, Search, SlidersHorizontal } from 'lucide-react';
import { cn } from '@/lib/cn';
import type { FieldType } from '@/lib/fields';
import { TOOLBAR_BADGE_CLASS, toolbarButtonClass } from '@/lib/toolbar';
import { useDismiss } from '@/lib/use-dismiss';
import SortableList from '@/components/table/SortableList';
import { FieldTypeIcon } from './FieldEditor';

/** A column as the panel needs to name it. */
export type FieldColumn = {
  key: string;
  label: string;
  /** Drives the icon beside the name. Absent, the row has none. */
  type?: FieldType;
  /** The primary column: pinned first, always shown, set apart by a divider. */
  locked?: boolean;
  /** A column whose TYPE the app owns — shown with a lock. Its name and
   *  options can still be edited, and it can be hidden and moved. */
  fixed?: boolean;
};

/**
 * How a view lays its columns out, as overrides on the caller's column array:
 * an order, the keys it hides, and the labels it renames. Empty means "as
 * declared", so a column added later appears without anybody migrating a
 * saved view to mention it.
 */
export type FieldLayout = {
  order: string[];
  hidden: string[];
  labels: Record<string, string>;
};

export const EMPTY_LAYOUT: FieldLayout = { order: [], hidden: [], labels: {} };

/**
 * The columns in layout order: locked ones first, then the ones the layout
 * names, then any it does not know about in declared order. Unknown keys in
 * `order` are dropped — a column removed from the code must not leave a hole.
 */
export function arrangeColumns<C extends { key: string; locked?: boolean }>(columns: C[], layout: FieldLayout): C[] {
  const byKey = new Map(columns.map((c) => [c.key, c]));
  const locked = columns.filter((c) => c.locked);
  const named = layout.order.map((k) => byKey.get(k)).filter((c): c is/** The primary column: pinned first, always shown, set apart by a divider. */=> !!c && !c.locked);
  const rest = columns.filter((c) => !c.locked && !layout.order.includes(c.key));
  return [...locked, ...named, ...rest];
}

/**
 * The Lark Base "Fields" panel — a searchable list of every column with its
 * type icon, an eye to show or hide it, a grip to reorder it, and "New field"
 * at the foot.
 *
 * ── Commits immediately ─────────────────────────────────────────────────────
 *
 * An eye is one deliberate act with one visible outcome: the column appears
 * or goes. A draft here would mean toggling an eye and watching nothing
 * happen until Apply. The same goes for a drag.
 *
 * ── Opening a field ─────────────────────────────────────────────────────────
 *
 * Clicking a name (`onEdit`) and "New field" (`onNewField`) hand back the
 * panel's own rect, so the caller can open the field editor BESIDE it. The
 * panel stays open while that form is in use — a click in the form is not a
 * click outside the panel — and one Escape closes the form, not both.
 *
 * ── Searching ───────────────────────────────────────────────────────────────
 *
 * A wide table can have forty columns; the box narrows the list by name.
 * Dragging is off while it is narrowed, because a drop between two rows of a
 * filtered list has no honest position in the full one.
 */
export default function FieldsPanel({
  columns,
  layout,
  onChange,
  onEdit,
  onNewField,
  title = 'Fields',
  width = 320,
  className,
}: {
  columns: FieldColumn[];
  layout: FieldLayout;
  onChange: (layout: FieldLayout) => void;
  /** A name was clicked. The rect is the panel's, for placing an editor beside it. */
  onEdit?: (key: string, panel: DOMRect) => void;
  /** Present, the foot offers "New field". */
  onNewField?: (panel: DOMRect) => void;
  title?: string;
  width?: number;
  className?: string;
}) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const root = useRef<HTMLDivElement>(null);
  const panel = useRef<HTMLDivElement>(null);

  const close = () => {
    setOpen(false);
    setQuery('');
  };
  useDismiss(root, open, close, close, true);

  const labelOf = (c: FieldColumn) => layout.labels[c.key] || c.label;
  const arranged = arrangeColumns(columns, layout);
  const locked = arranged.filter((c) => c.locked);
  const movable = arranged.filter((c) => !c.locked);
  const hiddenCount = layout.hidden.filter((k) => movable.some((c) => c.key === k)).length;

  const needle = query.trim().toLowerCase();
  const matches = (c: FieldColumn) => !needle || labelOf(c).toLowerCase().includes(needle);

  const toggle = (key: string) =>
    onChange({ ...layout, hidden: layout.hidden.includes(key) ? layout.hidden.filter((k) => k !== key) : [...layout.hidden, key] });

  const rect = () => panel.current!.getBoundingClientRect();

  const row = (c: FieldColumn, handleProps?: { onPointerDown: () => void; onPointerUp: () => void }, dragging = false) => {
    const hidden = !c.locked && layout.hidden.includes(c.key);
    const editable = Boolean(onEdit);
    return (
      <div
        className={cn(
          'group/field relative flex items-center gap-2 rounded-lg py-1.5 pl-6 pr-1.5 hover:bg-slate-50 dark:hover:bg-slate-800/60',
          dragging && 'bg-slate-100 dark:bg-slate-700',
        )}
      >
        {handleProps && (
          <button
            type="button"
            aria-label={`Drag to reorder ${labelOf(c)}`}
            {...handleProps}
            className="absolute left-0.5 top-1/2 -translate-y-1/2 cursor-grab touch-none rounded p-0.5 text-slate-300 opacity-0 hover:text-slate-500 group-hover/field:opacity-100 active:cursor-grabbing dark:text-slate-600"
          >
            <GripVertical className="h-3.5 w-3.5" aria-hidden />
          </button>
        )}
        {c.type && <FieldTypeIcon type={c.type} className={cn('h-4 w-4 shrink-0', hidden ? 'text-slate-300 dark:text-slate-600' : 'text-slate-400')} />}
        <button
          type="button"
          disabled={!editable}
          onClick={() => onEdit?.(c.key, rect())}
          title={editable ? `Edit ${labelOf(c)}` : undefined}
          className={cn(
            'flex min-w-0 items-center gap-1.5 text-left text-xs',
            hidden ? 'text-slate-400 dark:text-slate-500' : 'text-slate-700 dark:text-slate-200',
            editable && 'hover:text-indigo-600 dark:hover:text-indigo-300',
          )}
        >
          <span className="truncate">{labelOf(c)}</span>
          {(c.locked || c.fixed) && (
            <Lock
              className="h-3 w-3 shrink-0 text-slate-300 dark:text-slate-600"
              aria-label={c.locked ? 'Primary column — always shown' : 'Type set by the app'}
            />
          )}
        </button>
        <button
          type="button"
          disabled={c.locked}
          aria-label={c.locked ? `${labelOf(c)} is always shown` : hidden ? `Show ${labelOf(c)}` : `Hide ${labelOf(c)}`}
          aria-pressed={!hidden}
          title={c.locked ? 'Always shown' : hidden ? 'Show' : 'Hide'}
          onClick={() => toggle(c.key)}
          className={cn(
            'ml-auto shrink-0 rounded p-1 hover:bg-slate-100 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:hover:bg-slate-700',
            c.locked ? 'text-indigo-200 dark:text-indigo-500/40' : hidden ? 'text-slate-300 dark:text-slate-600' : 'text-indigo-500 dark:text-indigo-400',
          )}
        >
          {hidden ? <EyeOff className="h-4 w-4" aria-hidden /> : <Eye className="h-4 w-4" aria-hidden />}
        </button>
      </div>
    );
  };

  const shownMovable = movable.filter(matches);
  const label = hiddenCount ? `${title}, ${hiddenCount} hidden` : title;

  return (
    <div ref={root} className={cn('relative inline-block', className)}>
      <button
        type="button"
        aria-label={label}
        aria-expanded={open}
        title={label}
        onClick={() => (open ? close() : setOpen(true))}
        className={toolbarButtonClass(open || hiddenCount > 0)}
      >
        <SlidersHorizontal className="h-4 w-4" aria-hidden />
        {hiddenCount > 0 && <span className={TOOLBAR_BADGE_CLASS}>{hiddenCount}</span>}
      </button>

      {open && (
        <div
          ref={panel}
          // An obstacle for an AnchoredPanel's placement, so the field editor
          // opens beside this panel rather than over it.
          data-overlay="menu"
          style={{ width }}
          className="absolute left-0 top-full z-50 mt-1 flex max-h-[min(36rem,80vh)] max-w-[calc(100vw-2rem)] origin-top-left flex-col overflow-hidden panel panel-solid p-0 animate-scale-in"
        >
          <div className="shrink-0 p-2">
            <div className="relative">
              <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" aria-hidden />
              <input
                autoFocus
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search columns"
                aria-label="Search columns"
                className="field-input py-1.5 pl-8 text-xs"
              />
            </div>
          </div>

          <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-1">
            {locked.filter(matches).map((c) => (
              <div key={c.key}>{row(c)}</div>
            ))}
            {locked.some(matches) && shownMovable.length > 0 && <div className="my-1 border-t border-slate-100 dark:border-slate-700" />}
            {needle ? (
              shownMovable.map((c) => <div key={c.key}>{row(c)}</div>)
            ) : (
              <SortableList
                group="field-layout"
                items={movable}
                getId={(c) => c.key}
                onReorder={(next) => onChange({ ...layout, order: next.map((c) => c.key) })}
                renderItem={(c, { isDragging, handleProps }) => row(c, handleProps, isDragging)}
              />
            )}
            {needle && shownMovable.length === 0 && !locked.some(matches) && (
              <p className="px-2 py-4 text-center text-xs text-slate-400">No column matches “{query.trim()}”.</p>
            )}
          </div>

          <div className="flex shrink-0 items-center gap-2 border-t border-slate-100 px-2 py-1.5 dark:border-slate-700">
            {onNewField && (
              <button
                type="button"
                onClick={() => onNewField(rect())}
                className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-950/40"
              >
                <Plus className="h-4 w-4" aria-hidden /> New field
              </button>
            )}
            {hiddenCount > 0 && (
              <button type="button" onClick={() => onChange({ ...layout, hidden: [] })} className="ml-auto btn-ghost text-xs">
                Show all
              </button>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
columns*FieldColumn[]—
layout*FieldLayout—
onChange*(layout: FieldLayout) => void—
onEdit(key: string, panel: DOMRect) => void—A name was clicked. The rect is the panel's, for placing an editor beside it.
onNewField(panel: DOMRect) => void—Present, the foot offers "New field".
titlestring'Fields'
widthnumber320
classNamestring—