TableColumnMenu.tsx14.5 KBView on GitHub
'use client';

/**
 * The header column menu. Everything it does is a write to the schema map.
 *
 * The one rule the whole design leans on: RENAME CHANGES `label`, NEVER `key`. The key is
 * the column's stable handle — every `r_07.outreach` in the markdown mirror, in agent
 * memory and in a running fan-out resolves through it, so renaming the header must not
 * touch it.
 *
 * ── Flyouts holding a picker ──
 *
 * Type and wrapping open on HOVER, as a submenu does — no click to get to them — but what
 * flies out is an `OptionPicker` rather than a list of radio items: a filter field, an icon
 * per row, the tick on the right, ordinal shortcuts.
 *
 * Their open state is CONTROLLED, which is what makes that combination possible. The picker
 * stops every keystroke before Radix sees it (it has to: a Radix menu claims all the
 * character keys for its own typeahead, and that is exactly the filter field's keyboard), so
 * Escape never reaches the submenu either. Owning `open` here is how Escape still means
 * "back to the menu" — and how picking a value closes the whole popover the way the radio
 * items it replaced did.
 */

import {
  ArrowLeftToLine,
  ArrowRightToLine,
  EyeOff,
  MousePointerSquareDashed,
  Trash2,
  Type,
  Unlink,
  Wand2,
  WrapText,
  X,
} from 'lucide-react';
import {
  columnWrap,
  DEFAULT_CELL_WRAP,
  TABLE_SORT_DIRECTION,
  type TableCellWrap,
  type TableColumn,
  type TableColumnType,
  type TableSortDirection,
} from '@zero/server/table';
import { useRef, useState } from 'react';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
  CELL_WRAP_ICONS,
  CELL_WRAP_LABELS,
  CELL_WRAP_ORDER,
  COLUMN_TYPE_ICONS,
  COLUMN_TYPE_LABELS,
  COLUMN_TYPE_ORDER,
  SORT_DIRECTION_ICONS,
  sortActionLabel,
} from './constants';
import {
  OptionPicker,
  PICKER_SURFACE_CLASS,
  type PickerOption,
} from '@/components/ui/option-picker';
import { FILL_PANEL_CLASS, TableFillInstructions } from './TableFillInstructions';

export interface TableColumnMenuProps {
  column: TableColumn;
  /**
   * Every declared column, in schema order. The fill-instruction flyout shows the whole set —
   * one column's instruction only makes sense against the ones beside it.
   */
  columns: TableColumn[];
  /** Patch merged into this column. `key` is intentionally not patchable. */
  onPatch: (patch: Partial<Omit<TableColumn, 'key'>>) => void;
  /** Patch ANY column — the fill panel edits its neighbours in place. */
  onPatchColumn: (key=[redacted], patch: Partial<Omit<TableColumn, 'key'>>) => void;
  onInsertBefore: () => void;
  onInsertAfter: () => void;
  onDelete: () => void;
  /** Sever the `binding`, keeping the computed values. Only offered for a bound column. */
  onUnbind: () => void;
  /** Select every cell in this column — the pointer half of Cmd+A. */
  onSelectColumn: () => void;
  /** Order the whole table by this column, or clear the order (`null`). */
  onSort: (direction: TableSortDirection | null) => void;
  /** The direction this column currently orders the table by, or null when it does not. */
  sortDirection: TableSortDirection | null;
  children: React.ReactNode;
}

/** Which flyout is open, if any. Controlled so the picker inside it can drive it. */
type OpenFlyout = 'type' | 'wrap' | 'fill' | null;

/** Ascending above descending, always — the order every spreadsheet's menu puts them in. */
const SORT_ROWS: TableSortDirection[] = [TABLE_SORT_DIRECTION.ASC, TABLE_SORT_DIRECTION.DESC];

export function TableColumnMenu({
  column,
  columns,
  onPatch,
  onPatchColumn,
  onInsertBefore,
  onInsertAfter,
  onDelete,
  onUnbind,
  onSelectColumn,
  onSort,
  sortDirection,
  children,
}: TableColumnMenuProps) {
  const [open, setOpen] = useState(false);
  const [label, setLabel] = useState(column.label);
  const [flyout, setFlyout] = useState<OpenFlyout>(null);

  // Re-seed the drafts each time the menu opens so a rename made elsewhere (or by an
  // agent) isn't overwritten by a stale draft.
  const handleOpenChange = (next: boolean) => {
    if (next) {
      setLabel(column.label);
      setFlyout(null);
    }
    setOpen(next);
  };

  const commitLabel = () => {
    const trimmed = label.trim();
    if (trimmed && trimmed !== column.label) onPatch({ label: trimmed });
  };

  const wrap = columnWrap(column);

  return (
    <DropdownMenu open={open} onOpenChange={handleOpenChange}>
      <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
      <DropdownMenuContent align="start" className="w-64 rounded-lg shadow-lg">
        <div className="p-1">
          <input
            autoFocus
            aria-label="Column name"
            value={label}
            onChange={(event) => setLabel(event.target.value)}
            onBlur={commitLabel}
            onKeyDown={(event) => {
              if (event.key === 'Enter') {
                commitLabel();
                setOpen(false);
              }
            }}
            className="border-border focus:ring-primary w-full rounded-sm border bg-transparent px-2 py-1 text-sm outline-none focus:ring-1"
          />
          <p className="text-muted-foreground px-2 pt-1 text-xs">
            Key <span className="font-mono">{column.key}</span> — never changes
          </p>
          {column.binding && (
            <p className="text-muted-foreground px-2 pt-1 text-xs">
              Linked to <span className="font-mono">{column.binding}</span>
            </p>
          )}
        </div>
        <DropdownMenuSeparator />
        <PickerFlyout
          open={flyout === 'type'}
          onOpenChange={(next) => setFlyout(next ? 'type' : null)}
          icon={<Type className="size-4 shrink-0" />}
          label="Type"
          value={COLUMN_TYPE_LABELS[column.type]}
          placeholder="Change type…"
          options={COLUMN_TYPE_ORDER.map((type) => {
            const Icon = COLUMN_TYPE_ICONS[type];
            return {
              value: type,
              label: COLUMN_TYPE_LABELS[type],
              icon: <Icon aria-hidden className="text-muted-foreground" />,
            };
          })}
          selected={[column.type]}
          onPick={(value) => {
            onPatch({ type: value as TableColumnType });
            // The write is done, so the whole popover goes — the radio items this
            // replaced closed it too, and leaving it open over a column that just
            // changed shape reads as though the pick didn't take.
            setOpen(false);
          }}
        />
        <PickerFlyout
          open={flyout === 'wrap'}
          onOpenChange={(next) => setFlyout(next ? 'wrap' : null)}
          icon={<WrapText className="size-4 shrink-0" />}
          label="Wrapping"
          value={CELL_WRAP_LABELS[wrap]}
          placeholder="Change wrapping…"
          // Two options are read at a glance; a filter field over them is ceremony.
          searchable={false}
          options={CELL_WRAP_ORDER.map((mode) => {
            const Icon = CELL_WRAP_ICONS[mode];
            return {
              value: mode,
              label: CELL_WRAP_LABELS[mode],
              icon: <Icon aria-hidden className="text-muted-foreground" />,
            };
          })}
          selected={[wrap]}
          onPick={(value) => {
            // The default is `wrap`, and an explicit `wrap` is written back as
            // `undefined` so a schema nobody has customized carries no wrap keys at all —
            // `patchColumn` deletes on undefined for exactly this.
            onPatch({
              wrap: value === DEFAULT_CELL_WRAP ? undefined : (value as TableCellWrap),
            });
            setOpen(false);
          }}
        />
        <FillInstructionFlyout
          open={flyout === 'fill'}
          onOpenChange={(next) => setFlyout(next ? 'fill' : null)}
          column={column}
          columns={columns}
          onPatchColumn={onPatchColumn}
        />
        {column.binding && (
          <DropdownMenuItem
            className="cursor-pointer"
            title="Keeps the values Cedar has already computed, and makes them this table's own"
            onSelect={onUnbind}
          >
            <Unlink className="size-4 shrink-0" />
            <span className="truncate">Sever link</span>
          </DropdownMenuItem>
        )}
        <DropdownMenuSeparator />
        {/*
          Two direct rows rather than a picker flyout. Sorting is the most-reached-for thing in
          this menu and it is a two-way choice, so a hover-then-pick costs a gesture to say
          something the row could have said itself — and the labels below already name the
          direction in the column's own terms.
        */}
        {SORT_ROWS.map((direction) => {
          const Icon = SORT_DIRECTION_ICONS[direction];
          const active = sortDirection === direction;
          return (
            <DropdownMenuItem
              key=[redacted]
              className="cursor-pointer"
              // Clicking the direction the table is ALREADY ordered by clears it, so the row
              // that turned the sort on is also the one that turns it off.
              onSelect={() => onSort(active ? null : direction)}
            >
              <Icon className="size-4 shrink-0" />
              <span className="flex-1 truncate">{sortActionLabel(column, direction)}</span>
              {active && <span className="text-muted-foreground text-xs">On</span>}
            </DropdownMenuItem>
          );
        })}
        {sortDirection && (
          <DropdownMenuItem className="cursor-pointer" onSelect={() => onSort(null)}>
            <X className="size-4 shrink-0" />
            <span className="truncate">Clear sort</span>
          </DropdownMenuItem>
        )}
        <DropdownMenuSeparator />
        <DropdownMenuItem className="cursor-pointer" onSelect={onSelectColumn}>
          <MousePointerSquareDashed className="size-4 shrink-0" />
          <span className="truncate">Select column</span>
        </DropdownMenuItem>
        <DropdownMenuItem className="cursor-pointer" onSelect={onInsertBefore}>
          <ArrowLeftToLine className="size-4 shrink-0" />
          <span className="truncate">Insert left</span>
        </DropdownMenuItem>
        <DropdownMenuItem className="cursor-pointer" onSelect={onInsertAfter}>
          <ArrowRightToLine className="size-4 shrink-0" />
          <span className="truncate">Insert right</span>
        </DropdownMenuItem>
        <DropdownMenuItem className="cursor-pointer" onSelect={() => onPatch({ hidden: true })}>
          <EyeOff className="size-4 shrink-0" />
          <span className="truncate">Hide column</span>
        </DropdownMenuItem>
        <DropdownMenuItem
          className="text-destructive focus:text-destructive cursor-pointer"
          onSelect={onDelete}
        >
          <Trash2 className="size-4 shrink-0" />
          <span className="truncate">Delete column</span>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

/**
 * The "Fill instructions" row, and the wide panel it flies out.
 *
 * Same shell as `PickerFlyout` — hover to open, controlled so Escape means "back to the menu"
 * rather than "close everything" — but what flies out is the whole column set's instructions
 * rather than a list to pick from. It used to be a menu ITEM that replaced the menu's contents
 * with a lone textarea, which is how you could edit one instruction while unable to read any
 * of the others.
 */
function FillInstructionFlyout({
  open,
  onOpenChange,
  column,
  columns,
  onPatchColumn,
}: {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  column: TableColumn;
  columns: TableColumn[];
  onPatchColumn: (key=[redacted], patch: Partial<Omit<TableColumn, 'key'>>) => void;
}) {
  const triggerRef = useRef<HTMLDivElement>(null);
  const close = () => {
    onOpenChange(false);
    triggerRef.current?.focus();
  };

  return (
    <DropdownMenuSub open={open} onOpenChange={onOpenChange}>
      <DropdownMenuSubTrigger ref={triggerRef}>
        <Wand2 className="size-4 shrink-0" />
        <span className="flex-1 truncate">Fill instructions</span>
        <span className="text-muted-foreground text-xs">
          {column.fillInstruction ? 'Set' : 'None'}
        </span>
      </DropdownMenuSubTrigger>
      <DropdownMenuSubContent
        className={FILL_PANEL_CLASS}
        onEscapeKeyDown={(event) => {
          event.preventDefault();
          close();
        }}
      >
        <TableFillInstructions
          columns={columns}
          activeKey=[redacted]
          onPatchColumn={onPatchColumn}
          onClose={close}
        />
      </DropdownMenuSubContent>
    </DropdownMenuSub>
  );
}

/**
 * One menu row that flies a picker out on hover.
 *
 * Escape is the whole reason this owns `open`. Radix dismisses on a CAPTURE-phase document
 * listener, so the picker's `stopPropagation` — which runs at React's root, below that —
 * cannot reach it, and Radix's own handler closes the entire menu rather than the flyout.
 * Preventing that default is what leaves Escape meaning "back to the menu", and the trigger
 * is re-focused by hand because the path that normally does it (ArrowLeft) is a key the
 * filter field has already claimed for moving the caret.
 */
function PickerFlyout({
  open,
  onOpenChange,
  icon,
  label,
  value,
  placeholder,
  options,
  selected,
  searchable,
  onPick,
}: {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  icon: React.ReactNode;
  label: string;
  /** The current setting, shown on the row the way Linear shows it. */
  value: string;
  placeholder: string;
  options: PickerOption[];
  selected: string[];
  searchable?: boolean;
  onPick: (value: string) => void;
}) {
  const triggerRef = useRef<HTMLDivElement>(null);

  const close = () => {
    onOpenChange(false);
    triggerRef.current?.focus();
  };

  return (
    <DropdownMenuSub open={open} onOpenChange={onOpenChange}>
      <DropdownMenuSubTrigger ref={triggerRef}>
        {icon}
        <span className="flex-1 truncate">{label}</span>
        <span className="text-muted-foreground text-xs">{value}</span>
      </DropdownMenuSubTrigger>
      <DropdownMenuSubContent
        className={PICKER_SURFACE_CLASS}
        onEscapeKeyDown={(event) => {
          event.preventDefault();
          close();
        }}
      >
        <OptionPicker
          options={options}
          selected={selected}
          placeholder={placeholder}
          searchable={searchable}
          onPick={onPick}
          onClose={close}
        />
      </DropdownMenuSubContent>
    </DropdownMenuSub>
  );
}