Files
yaak-mountain-loop/apps/yaak-proxy/components/FlowListHeader.tsx
T
Gregory SchierandClaude Opus 5.5 185a4e30df Drag a whole column's ghost, and keep default places until reordered
The ghost of a dragged column is now its header and the cells of the rows in
view, copied once when the drag starts and moved sideways by a transform,
kept within the list; the column dims in place, its cells by one style rule
so no row renders. A built-in column turned on before any reordering takes
its default place again; once the person has an order, a newly shown column
goes at the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:22:17 -07:00

529 lines
18 KiB
TypeScript

import {
DndContext,
type DragStartEvent,
PointerSensor,
useDraggable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
import {
ContextMenu,
Dropdown,
type DropdownItem,
Icon,
IconButton,
ResizeHandle,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom } from "jotai";
import {
memo,
type MouseEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
addSortKey,
allColumns,
type Column,
type ColumnDef,
type ColumnId,
type ColumnPrefs,
type CustomColumnId,
canHide,
dropBoundary,
hideOtherColumns,
isCustomColumn,
moveColumn,
moveColumnBy,
removeCustomColumn,
resetOrder,
resetWidth,
resetWidths,
resizeColumn,
toggleColumn,
unavailableColumns,
} from "../lib/columns";
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
import { addColumnDialogAtom } from "./AddColumnDialog";
/** Pixels a press on a label moves before it drags the column rather than sorting by it */
const DRAG_DISTANCE = 4;
/** How near the list's side edge, in pixels, a dragged column scrolls the list that way */
const EDGE = 40;
/** The most the list scrolls sideways a frame, at the very edge */
const EDGE_SPEED = 18;
interface Props {
columns: Column[];
template: string;
/** The rows' width, as CSS, so the header scrolls sideways with them */
width: string;
prefs: ColumnPrefs;
sort: FlowSortKey[];
/** A click on a label, or with `add`, an Option-click */
onSort: (column: ColumnId, add: boolean) => void;
/** Sets the sort from the menus; empty goes back to arrival order */
onSetSort: (sort: FlowSortKey[]) => void;
onResetSort: () => void;
/** The rows' scrolling element, which a column dragged to its edge scrolls sideways */
scrollElement: () => HTMLElement | null;
/** Where a dragged column would drop, in the window's coordinates, or null for nowhere */
onDropLine: (x: number | null) => void;
/** A column's drag: its start, the pointer's moves, and its end, for the list's ghost */
onColumnDrag: (event: ColumnDragEvent) => void;
}
/** A column's drag, for the ghost the list draws of it; `x` is the pointer, across the window */
export type ColumnDragEvent =
| { type: "start"; id: ColumnId; x: number }
| { type: "move"; x: number }
| { type: "end" };
/**
* The list's only control surface: a click on a label sorts by it alone, a second reverses it
* and a third goes back to arrival order; Option-click adds it to the sort as a further key.
* Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label
* resizes its column and a double-click on it resets it. A right-click on a label opens the
* column's menu; the picker sits in the empty corner on hover.
*/
export const FlowListHeader = memo(function FlowListHeader({
columns,
template,
width,
prefs,
sort,
onSort,
onSetSort: setSort,
onResetSort,
scrollElement,
onDropLine,
onColumnDrag,
}: Props) {
const setPrefs = useSetAtom(columnPrefsAtom);
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom));
const openAddColumn = useSetAtom(addColumnDialogAtom);
const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null);
// Widths when a drag began, so each move sets the width from where it started.
const startWidth = useRef(0);
const { dragging, sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
columns,
scrollElement,
onDropLine,
onColumnDrag,
onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)),
});
// Taking out the sort's only key goes back to arrival order, as Reset Sort does
const removeColumn = (id: CustomColumnId) => {
if (sort.length > 0 && sort.every((k) => k.column === id)) onResetSort();
setPrefs((p) => removeCustomColumn(p, id));
};
const pickerItems: DropdownItem[] = [
{
type: "content",
label: <div className="px-1.5 pt-1 text-xs text-text-subtlest">Columns</div>,
},
...allColumns(prefs).map((c): DropdownItem => {
const shown = prefs.visible[c.id] ?? c.defaultVisible;
return {
label: c.label,
keepOpenOnSelect: true,
disabled: shown && !canHide(prefs, c.id, unavailable),
leftSlot: shown ? (
<Icon icon="check" size="sm" />
) : (
<span aria-hidden className="inline-block w-4 h-4" />
),
onSelect: () => setPrefs((p) => toggleColumn(p, c.id, unavailable)),
...(isCustomColumn(c.id)
? {
submenuTrigger: "button" as const,
submenu: [
{
label: "Remove Column",
icon: "trash" as const,
color: "danger" as const,
onSelect: () => removeColumn(c.id as CustomColumnId),
},
],
}
: {}),
};
}),
{ label: "Add Column…", icon: "plus", onSelect: () => openAddColumn(true) },
{ type: "separator" },
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
{
label: "Reset Column Order",
disabled: prefs.order.length === 0,
onSelect: () => setPrefs((p) => resetOrder(p)),
},
];
const columnItems = (c: ColumnDef): DropdownItem[] => {
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
const at = columns.findIndex((col) => col.id === c.id);
return [
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
{ label: "Sort Descending", onSelect: () => sortBy(true) },
{ label: "Add to Sort Ascending", onSelect: () => addTo(false) },
{ label: "Add to Sort Descending", onSelect: () => addTo(true) },
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
{ type: "separator" },
{
label: "Move Left",
disabled: at <= 0,
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, -1, unavailable)),
},
{
label: "Move Right",
disabled: at === -1 || at >= columns.length - 1,
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, 1, unavailable)),
},
{ type: "separator" },
{
label: "Hide Column",
disabled: !canHide(prefs, c.id, unavailable),
onSelect: () => setPrefs((p) => toggleColumn(p, c.id, unavailable)),
},
{
label: "Hide Other Columns",
disabled: columns.length <= 1,
onSelect: () => setPrefs((p) => hideOtherColumns(p, c.id)),
},
...(isCustomColumn(c.id)
? [
{
label: "Remove Column",
color: "danger" as const,
onSelect: () => removeColumn(c.id as CustomColumnId),
},
]
: []),
{ type: "separator" },
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
];
};
// A right-click on a label is for its column; anywhere else in the header, the picker.
const openMenu = useCallback(
(e: MouseEvent) => {
e.preventDefault();
const el = (e.target as HTMLElement).closest<HTMLElement>("[data-column]");
const id = el?.dataset.column as ColumnId | undefined;
const column = columns.find((c) => c.id === id) ?? null;
setMenu({ x: e.clientX, y: e.clientY, column });
},
[columns],
);
return (
// The same margins and padding as the rows, so each label sits over its cells.
<div style={{ width, minWidth: "100%" }} className="px-1.5">
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
<div
ref={gridRef}
onPointerDownCapture={onPointerDownCapture}
onContextMenu={openMenu}
style={{ gridTemplateColumns: template }}
className={classNames(
"group/header grid items-center h-xs px-1.5 select-none",
"text-xs text-text-subtlest border-b border-border-subtle",
)}
>
<span className="flex items-center justify-center">
<Dropdown items={pickerItems}>
<IconButton
size="xs"
icon="settings_2"
title="Columns"
tooltip
iconColor="secondary"
className="opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 !h-5 !w-5"
/>
</Dropdown>
</span>
<DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
{columns.map((c) => (
<HeaderCell
key={c.id}
column={c}
sort={sort}
dragging={dragging?.id === c.id}
onSort={onSort}
onResizeStart={() => {
startWidth.current = c.current;
}}
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
/>
))}
</DndContext>
<ContextMenu
triggerPosition={menu}
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
onClose={() => setMenu(null)}
/>
</div>
</div>
);
});
interface HeaderCellProps {
column: Column;
sort: FlowSortKey[];
/** Being dragged, so it dims where it was */
dragging: boolean;
onSort: (column: ColumnId, add: boolean) => void;
onResizeStart: () => void;
/** How far the edge moved since the resize began */
onResizeMove: (dx: number) => void;
onResetWidth: () => void;
}
function HeaderCell({
column: c,
sort,
dragging,
onSort,
onResizeStart,
onResizeMove,
onResetWidth,
}: HeaderCellProps) {
// The label drags the column; the resize handle beside it is outside it, so it still resizes.
const { listeners, setNodeRef } = useDraggable({ id: c.id });
const sorted = sort.some((k) => k.column === c.id);
return (
<span
data-column={c.id}
className={classNames(
"relative h-full flex items-center min-w-0 px-1.5",
dragging && "opacity-40",
)}
>
<button
ref={setNodeRef}
type="button"
{...listeners}
// A press that moves drags instead; dnd-kit swallows the click that ends a drag.
onClick={(e) => onSort(c.id, e.altKey)}
// No tooltip: the header sits under the search field, and a tooltip here covered it
aria-label={`Sort by ${c.label}`}
className={classNames(
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap",
"hover:text-text-subtle",
c.align === "right" && "justify-end",
sorted && "text-text-subtle",
)}
>
<ColumnLabel column={c} sort={sort} />
</button>
{/* On the column's right edge, the boundary with the next, resizing this one */}
<ResizeHandle
side="right"
justify="center"
className="absolute top-0 translate-x-1/2"
onResizeStart={onResizeStart}
onResizeMove={(e) => onResizeMove(e.x - e.xStart)}
onReset={onResetWidth}
/>
</span>
);
}
/** A column's name, after its sort direction and, sorted by several, its place in the sort */
function ColumnLabel({ column: c, sort }: { column: Column; sort: FlowSortKey[] }) {
const at = sort.findIndex((k) => k.column === c.id);
const key = sort[at];
return (
<>
{key != null && (
<Icon
icon="chevron_down"
size="xs"
className={classNames("shrink-0", !key.descending && "rotate-180")}
/>
)}
{key != null && sort.length > 1 && (
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
)}
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
</>
);
}
/**
* Dragging a column's label: where it would drop, the boundary between the labels nearest the
* pointer, and the list scrolling sideways while the pointer is near its edge. The columns only
* move on the drop, so the rows lay out once.
*/
function useColumnDrag({
columns,
scrollElement,
onDropLine,
onColumnDrag,
onDrop,
}: {
columns: Column[];
scrollElement: () => HTMLElement | null;
onDropLine: (x: number | null) => void;
onColumnDrag: (event: ColumnDragEvent) => void;
/** Moves the column to the boundary `to` between those shown */
onDrop: (id: ColumnId, to: number) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_DISTANCE } }),
);
const gridRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<Column | null>(null);
// Live while dragging, read each frame
const state = useRef<{
/** The column dragged */
id: ColumnId;
/** The pointer, across the window */
x: number;
/** The boundary it would move to, null where it would stay, and the line drawn on it */
to: number | null;
line: number | null;
/** The pointer the ghost was last moved to */
ghostX: number;
frame: number;
} | null>(null);
const latest = useRef({ columns, scrollElement, onDropLine, onColumnDrag, onDrop });
useLayoutEffect(() => {
latest.current = { columns, scrollElement, onDropLine, onColumnDrag, onDrop };
});
const stop = useCallback(() => {
const s = state.current;
state.current = null;
if (s != null) cancelAnimationFrame(s.frame);
setDragging(null);
latest.current.onDropLine(null);
if (s != null) latest.current.onColumnDrag({ type: "end" });
return s;
}, []);
const onDragStart = useCallback((e: DragStartEvent) => {
const column = latest.current.columns.find((c) => c.id === e.active.id);
if (column == null) return;
const x = pointer.current;
state.current = { id: column.id, x, to: null, line: null, ghostX: x, frame: 0 };
// Before the column dims, so the ghost is of it as it shows
latest.current.onColumnDrag({ type: "start", id: column.id, x });
setDragging(column);
// The line shows from the drag's first movement, not a frame later
target(state.current);
const tick = () => {
const s = state.current;
if (s == null) return;
// Near an edge, the list scrolls that way, faster the nearer; the header follows it.
const list = latest.current.scrollElement();
if (list != null) {
const { left, right } = list.getBoundingClientRect();
const near =
s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0;
if (near !== 0) {
const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED;
list.scrollLeft += Math.sign(near) * Math.max(1, speed);
}
}
target(s);
if (s.x !== s.ghostX) {
s.ghostX = s.x;
latest.current.onColumnDrag({ type: "move", x: s.x });
}
s.frame = requestAnimationFrame(tick);
};
state.current.frame = requestAnimationFrame(tick);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
}, []);
/** Drops the column where the pointer is, once; a second call finds no drag */
const drop = useCallback(() => {
// Where the pointer is now, as a release can come before the next frame reads it
if (state.current != null) target(state.current);
const s = stop();
if (s?.to != null) latest.current.onDrop(s.id, s.to);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
}, [stop]);
// The pointer from a press on the header until its release, read before dnd-kit sees it.
// dnd-kit starts a drag on the move that crosses `DRAG_DISTANCE` and reports moves and the
// release only once it has rendered the drag, so a release right after a move would drop
// where the pointer was a frame before, or not at all. The release drops it here instead.
const pointer = useRef(0);
const track = useCallback((e: PointerEvent) => {
pointer.current = e.clientX;
if (state.current != null) state.current.x = e.clientX;
}, []);
const unlisten = useRef<(() => void) | null>(null);
const onPointerDownCapture = useCallback(
(e: ReactPointerEvent) => {
unlisten.current?.();
pointer.current = e.clientX;
const up = (e: PointerEvent) => {
unlisten.current?.();
track(e);
drop();
};
const cancel = () => {
unlisten.current?.();
stop();
};
window.addEventListener("pointermove", track, true);
window.addEventListener("pointerup", up, true);
window.addEventListener("pointercancel", cancel, true);
unlisten.current = () => {
unlisten.current = null;
window.removeEventListener("pointermove", track, true);
window.removeEventListener("pointerup", up, true);
window.removeEventListener("pointercancel", cancel, true);
};
},
[track, drop, stop],
);
// A drag never outlives the header
useEffect(
() => () => {
unlisten.current?.();
stop();
},
[stop],
);
/** Sets where the drag would drop, from the pointer and the labels where they are now */
function target(s: NonNullable<typeof state.current>) {
const cells = Array.from(
gridRef.current?.querySelectorAll<HTMLElement>(":scope > [data-column]") ?? [],
).map((el) => el.getBoundingClientRect());
// Its place is read each time, as the columns can change under a drag: Operation shows
// itself when a flow has one.
const from = latest.current.columns.findIndex((c) => c.id === s.id);
const { to, line } = dropBoundary(s.x, cells, from);
s.to = to;
// Told again only when it changes: another boundary, or the same one scrolled
if (line !== s.line) latest.current.onDropLine(line);
s.line = line;
}
return {
dragging,
sensors,
gridRef,
onPointerDownCapture,
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: stop },
};
}