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