v1.0

FieldEditor

Preview

Basic

Preview

Code

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

src/components/form/FieldEditor.tsx

AI prompt

text
Build an "Edit field" / "New field" form panel for a spreadsheet-style table (Lark Base style) in React + TypeScript + Tailwind CSS. It opens as a floating card right where the column is, not in a drawer.

## Look — the anchored card
- Portalled to <body>, `fixed z-[200]`, opaque floating surface with no padding, `shadow-2xl`, `flex flex-col overflow-hidden`, fading in over 0.2s. Width 360px (capped to viewport minus 16px).
- Header: `min-h-[2.75rem] px-3 py-2 border-b border-slate-200 dark:border-slate-700`, title 12px semibold slate-900 / dark slate-100 ("New field" or "Edit field"), for an existing field a subtitle in 11px slate-500 like "Single select · status" (type label · key), and a 14px `X` close button (`rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700`).
- Body scrolls (`min-h-0 flex-1 overflow-y-auto`); footer is pinned under it (`border-t px-3 py-2`).
- Below 640px wide it becomes a bottom sheet: `fixed inset-x-0 bottom-0 max-h-[85vh] rounded-t-xl border-t shadow-2xl`.

## Look — the form (`space-y-3 p-3`)
- "Name": autofocused text input, placeholder "Field name".
- "Type": a two-column grid (`grid grid-cols-2 gap-1`) of 11 type tiles, each `flex items-center gap-2 rounded-lg border px-2 py-1.5 text-xs` with a 14px icon, the label (medium weight) and a 10px slate-400 hint beneath: Text "A short line" (`Type`), Long text "Paragraphs" (`TextAlignStart`), Number "Plain figure" (`Hash`), Currency "Two decimals" (`CircleDollarSign`), Date "A calendar day" (`Calendar`), Checkbox "Yes or no" (`SquareCheck`), Single select "One choice" (`CircleChevronDown`), Multi select "Several choices" (`ListChecks`), Person "One of a list of people" (`User`), URL "A link" (`Link`), Email "An address" (`Mail`). Selected: `border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-500/40 dark:bg-indigo-500/15 dark:text-indigo-200`, icon indigo-500. Others: `border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800`, icon slate-400.
- With `typeLocked`, instead a read-only row `rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 dark:bg-slate-800/60`: icon, type label, and "Fixed for this column" at the right in 11px slate-400.
- "Options" (labelled "People" for Person) only for Single select, Multi select and Person. Empty: strip `rounded-lg bg-slate-50 py-3 text-center text-[11px] text-slate-400` "No options yet. Add the first below." Each option row (`flex items-center gap-1.5`): a drag grip (`GripVertical`, slate-300); a colour swatch; the label as an inline text input (`py-1.5 flex-1`); a preview pill (hidden below `sm`); a remove × (hover rose-600). Under the list: an "Add an option" / "Add a person" input and a ghost "+ Add" button (disabled when empty).
- Colour swatch: a 20px `rounded` square filled with the tone's 400 shade (`bg-sky-400` …) and `ring-1 ring-inset ring-black/10 dark:ring-white/10`. Clicking opens a small popover (`absolute left-0 top-6 z-50 grid grid-cols-6 gap-1 p-1.5`, opaque surface) of all 17 tones — sky, amber, slate, emerald, zinc, rose, violet, indigo, orange, yellow, lime, teal, cyan, blue, purple, fuchsia, pink — the current one outlined (`outline outline-2 outline-offset-1 outline-indigo-500`); picking closes it.
- Pill: `inline-flex rounded-full px-2.5 py-1 text-[11px] font-medium leading-none ring-1 ring-inset` with `bg-{tone}-50 text-{tone}-700 ring-{tone}-200 dark:bg-{tone}-950/40 dark:text-{tone}-300 dark:ring-{tone}-900`. Write every class string out in full in a lookup map — never interpolate tone names into class names.
- Footer: for an existing field with `onDelete`, a rose text button "Delete" with `Trash2` (`text-rose-600 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950/40`); a ghost "Cancel" (`ml-auto`); a primary "Add field" (new) or "Save" (existing), disabled at 40% while the name is blank.

## Behaviour
- Placement from an `anchor` rect `{ top, left, right, bottom }` (viewport coordinates). `"below"` (default): 8px under it, left edges aligned, else right edges. `"beside"`: 8px right of it, else left, top-aligned. Skip a side that overlaps another open panel/menu; keep 8px from the window edges, push up only as far as needed, cap max-height to the room below. Null anchor = centred. Measure in a layout effect (no flash at 0,0); re-place on resize.
- A DRAFT until Save: re-seed name, type and options from `field` on every open; Cancel, the ×, Escape and an outside click discard. Enter in the form saves. Escape and outside click only apply to the topmost open panel, and clicks inside other floating overlays don't count as outside.
- New field (`field === null`): the key is derived from the name on save — lower-case, runs of non-alphanumerics → `_`, trimmed, "field" if empty — made unique against `existingKeys` with `_2`, `_3` …. An existing field keeps its key forever.
- Adding an option: Enter or "+ Add"; its value is the same slug of its label, unique among the options; its tone is the palette entry at its position, so consecutive options differ. Options reorder by native drag from the grip (live reordering; a cancelled drag reverts). Saved `options` are only included for option types.
- Delete asks first in a small confirm popover: "Delete this field?" / "Every value stored in it goes with it." with a red "Delete" confirm.

## API
- `type FieldType = 'text' | 'longtext' | 'number' | 'currency' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'user' | 'url' | 'email'`; `type FieldDef = { key: string; label: string; type: FieldType; options?: { value: string; label: string; tone?: string }[] }`.
- Props: `open`, `anchor: Anchor | null`, `field: FieldDef | null`, `existingKeys?: string[]`, `typeLocked = false`, `placement: 'beside' | 'below' = 'below'`, `onSave(field)`, `onDelete?(key)`, `onClose()`. Also export `FieldTypeIcon({ type, className })` for column headers.

## Accessibility
- The card is `role="dialog"` labelled by its title. The type grid is `role="radiogroup"` "Field type" with `role="radio"` + `aria-checked` tiles. Swatch: `aria-label="Colour: sky"`; each tone button is labelled with its name.

## Demo
An "Add field" button that measures itself as the anchor and opens a new-field editor against existing keys title, status, priority, team, owner; show the saved FieldDef as JSON.

## 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, useState } from 'react';
import {
  Calendar,
  CircleChevronDown,
  CircleDollarSign,
  GripVertical,
  Hash,
  Link,
  ListChecks,
  Mail,
  Plus,
  SquareCheck,
  TextAlignStart,
  Trash2,
  Type,
  User,
  X,
  type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/cn';
import type { FilterFieldOption } from '@/lib/conditions';
import { FIELD_TYPES, FIELD_TYPE_LABELS, hasOptions, slugKey, type FieldDef, type FieldType } from '@/lib/fields';
import { PANEL_GRIP_CLASS, PANEL_REMOVE_CLASS } from '@/lib/toolbar';
import { isTone, toneAt, type Tone } from '@/lib/tones';
import AnchoredPanel, { type Anchor } from '@/components/overlay/AnchoredPanel';
import ConfirmPopover from '@/components/overlay/ConfirmPopover';
import OptionPill from '@/components/data/OptionPill';
import SortableList from '@/components/table/SortableList';
import { SwatchPicker } from './ColorRulesPanel';

const ICONS: Record<FieldType, LucideIcon> = {
  text: Type,
  longtext: TextAlignStart,
  number: Hash,
  currency: CircleDollarSign,
  date: Calendar,
  checkbox: SquareCheck,
  select: CircleChevronDown,
  multiselect: ListChecks,
  user: User,
  url: Link,
  email: Mail,
};

/** The icon for a field type, shared with the grid's headers. */
export function FieldTypeIcon({ type, className }: { type: FieldType; className?: string }) {
  const Icon = ICONS[type];
  return <Icon className={cn('h-3.5 w-3.5', className)} aria-hidden />;
}

/**
 * The field editor — the Lark Base "Edit field" form: a name, a type from
 * the full list, and, for a choice type, the options with their colours.
 * Opens as an `AnchoredPanel` beside or below whatever asked for it (a "+"
 * column header, a column menu), so editing a column happens where the
 * column is rather than in a drawer across the screen.
 *
 * ── New or existing ─────────────────────────────────────────────────────────
 *
 * `field` null is a NEW field: the key is derived from the name on Save
 * (`slugKey`, unique against `existingKeys`) and never changes after, so a
 * rename later does not orphan every stored value. An existing field keeps
 * its key; `typeLocked` shows its type without offering a change, for a
 * column the caller defines in code rather than as data.
 *
 * ── Drafts until Save ───────────────────────────────────────────────────────
 *
 * Nothing reaches `onSave` until the button; Cancel, Escape and an outside
 * click discard. Deleting asks first, through `ConfirmPopover`, because a
 * column's values go with it.
 */
export default function FieldEditor({
  open,
  anchor,
  field,
  existingKeys = [],
  typeLocked = false,
  placement = 'below',
  onSave,
  onDelete,
  onClose,
}: {
  open: boolean;
  anchor: Anchor | null;
  /** The field being edited, or null for a new one. */
  field: FieldDef | null;
  /** Keys already in use, so a new field's key is unique. */
  existingKeys?: string[];
  /** Show the type but do not offer a change. */
  typeLocked?: boolean;
  placement?: 'beside' | 'below';
  onSave: (field: FieldDef) => void;
  /** Present, an existing field gets a Delete button. */
  onDelete?: (key: string) => void;
  onClose: () => void;
}) {
  const [label, setLabel] = useState(field?.label ?? '');
  const [type, setType] = useState<FieldType>(field?.type ?? 'text');
  const [options, setOptions] = useState<FilterFieldOption[]>(field?.options ?? []);
  const [newOption, setNewOption] = useState('');

  // Re-seed on every open, so a cancelled edit does not leak into the next.
  useEffect(() => {
    if (!open) return;
    setLabel(field?.label ?? '');
    setType(field?.type ?? 'text');
    setOptions(field?.options ?? []);
    setNewOption('');
  }, [open, field]);

  const isNew = field === null;
  const canSave = label.trim() !== '';

  const save = () => {
    if (!canSave) return;
    const def: FieldDef = {
      key: field?.key ?? slugKey(label, existingKeys),
      label: label.trim(),
      type,
      ...(hasOptions(type) ? { options } : {}),
    };
    onSave(def);
  };

  const addOption = () => {
    const text = newOption.trim();
    if (!text) return;
    const value = slugKey(text, options.map((o) => o.value));
    setOptions((prev) => [...prev, { value, label: text, tone: toneAt(prev.length) }]);
    setNewOption('');
  };

  const patchOption = (value: string, patch: Partial<FilterFieldOption>) =>
    setOptions((prev) => prev.map((o) => (o.value === value ? { ...o, ...patch } : o)));

  return (
    <AnchoredPanel
      open={open}
      anchor={anchor}
      onClose={onClose}
      title={isNew ? 'New field' : 'Edit field'}
      subtitle={isNew ? undefined : `${FIELD_TYPE_LABELS[type]} · ${field.key}`}
      placement={placement}
      width={360}
      footer={
        <div className="flex items-center gap-2">
          {!isNew && onDelete && (
            <ConfirmPopover
              title="Delete this field?"
              description="Every value stored in it goes with it."
              confirmText="Delete"
              onConfirm={() => onDelete(field.key)}
            >
              <button type="button" aria-label="Delete field" className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-rose-600 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950/40">
                <Trash2 className="h-3.5 w-3.5" aria-hidden /> Delete
              </button>
            </ConfirmPopover>
          )}
          <button type="button" onClick={onClose} className="ml-auto btn-ghost text-xs">
            Cancel
          </button>
          <button type="button" onClick={save} disabled={!canSave} className="btn-primary disabled:opacity-40">
            {isNew ? 'Add field' : 'Save'}
          </button>
        </div>
      }
    >
      <form
        className="space-y-3 p-3"
        onSubmit={(e) => {
          e.preventDefault();
          save();
        }}
      >
        <label className="block">
          <span className="field-label">Name</span>
          <input autoFocus value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Field name" className="field-input" />
        </label>

        <div>
          <span className="field-label">Type</span>
          {typeLocked ? (
            <div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
              <FieldTypeIcon type={type} className="text-slate-400" />
              {FIELD_TYPE_LABELS[type]}
              <span className="ml-auto text-[11px] text-slate-400">Fixed for this column</span>
            </div>
          ) : (
            <div role="radiogroup" aria-label="Field type" className="grid grid-cols-2 gap-1">
              {FIELD_TYPES.map((t) => {
                const active = t.type === type;
                return (
                  <button
                    key={t.type}
                    type="button"
                    role="radio"
                    aria-checked={active}
                    onClick={() => setType(t.type)}
                    className={cn(
                      'flex items-center gap-2 rounded-lg border px-2 py-1.5 text-left text-xs transition-colors',
                      active
                        ? 'border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-500/40 dark:bg-indigo-500/15 dark:text-indigo-200'
                        : 'border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800',
                    )}
                  >
                    <FieldTypeIcon type={t.type} className={active ? 'text-indigo-500' : 'text-slate-400'} />
                    <span className="min-w-0">
                      <span className="block font-medium leading-4">{t.label}</span>
                      <span className="block text-[10px] leading-3 text-slate-400">{t.hint}</span>
                    </span>
                  </button>
                );
              })}
            </div>
          )}
        </div>

        {hasOptions(type) && (
          <div>
            <span className="field-label">{type === 'user' ? 'People' : 'Options'}</span>
            {options.length === 0 && (
              <p className="mb-1.5 rounded-lg bg-slate-50 px-3 py-3 text-center text-[11px] text-slate-400 dark:bg-slate-900/40">
                No {type === 'user' ? 'people' : 'options'} yet. Add the first below.
              </p>
            )}
            <SortableList
              group="field-editor-options"
              items={options}
              getId={(o) => o.value}
              onReorder={setOptions}
              className="space-y-1"
              renderItem={(o, { isDragging, handleProps }) => (
                <div className={cn('flex items-center gap-1.5 rounded', isDragging && 'bg-slate-100 dark:bg-slate-700')}>
                  <button type="button" aria-label={`Drag to reorder ${o.label}`} {...handleProps} className={PANEL_GRIP_CLASS}>
                    <GripVertical className="h-3.5 w-3.5" aria-hidden />
                  </button>
                  <SwatchPicker
                    tone={isTone(o.tone) ? o.tone : toneAt(options.indexOf(o))}
                    onPick={(tone: Tone) => patchOption(o.value, { tone })}
                  />
                  <input
                    value={o.label}
                    onChange={(e) => patchOption(o.value, { label: e.target.value })}
                    aria-label={`Label for ${o.label}`}
                    className="field-input min-w-0 flex-1 py-1.5 text-xs"
                  />
                  <OptionPill label={o.label || '…'} tone={isTone(o.tone) ? o.tone : toneAt(options.indexOf(o))} className="hidden sm:inline-flex" />
                  <button type="button" aria-label={`Remove ${o.label}`} onClick={() => setOptions((prev) => prev.filter((x) => x.value !== o.value))} className={PANEL_REMOVE_CLASS}>
                    <X className="h-3.5 w-3.5" />
                  </button>
                </div>
              )}
            />
            <div className="mt-1.5 flex gap-1.5">
              <input
                value={newOption}
                onChange={(e) => setNewOption(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') {
                    e.preventDefault();
                    addOption();
                  }
                }}
                placeholder={type === 'user' ? 'Add a person' : 'Add an option'}
                className="field-input min-w-0 flex-1 py-1.5 text-xs"
              />
              <button type="button" onClick={addOption} disabled={!newOption.trim()} className="btn-ghost inline-flex items-center gap-1 text-xs disabled:opacity-40">
                <Plus className="h-3.5 w-3.5" aria-hidden /> Add
              </button>
            </div>
          </div>
        )}
      </form>
    </AnchoredPanel>
  );
}

Props

PropTypeDefaultDescription
open*boolean—
anchor*Anchor | null—
field*FieldDef | null—The field being edited, or null for a new one.
onSave*(field: FieldDef) => void—
onClose*() => void—
existingKeysstring[][]Keys already in use, so a new field's key is unique.
typeLockedbooleanfalseShow the type but do not offer a change.
placement'beside' | 'below''below'
onDelete(key: string) => void—Present, an existing field gets a Delete button.