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>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mwadri4inaohxyiw committed 2026-11-10 09:22:17 -07:00
1 parent 9ee37e4015
commit 185a4e30df
4 files changed
+198 -50

No files matched your search

+147 -4
View File
@@ -14,7 +14,14 @@ import {
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import type { CSSProperties, KeyboardEvent, MouseEvent, ReactElement, WheelEvent } from "react";
import type {
CSSProperties,
KeyboardEvent,
MouseEvent,
ReactElement,
RefObject,
WheelEvent,
} from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms";
import { shortContentType } from "../lib/body";
@@ -61,7 +68,7 @@ import {
import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon";
import { FlowListEmpty, PausedRow } from "./FlowListEmpty";
import { FlowListHeader } from "./FlowListHeader";
import { type ColumnDragEvent, FlowListHeader } from "./FlowListHeader";
const BOTTOM_PADDING = 6;
/** Rows from the top at which older flows start loading, or from the bottom, sorted, more */
@@ -305,6 +312,7 @@ export function FlowList({ style, className }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const [dropLine, setDropLine] = useState<number | null>(null);
const getScrollElement = useCallback(() => scrollRef.current, []);
const { ghostColumn, onColumnDrag } = useColumnGhost(rootRef, headerScrollRef, scrollRef);
const handleDropLine = useCallback((x: number | null) => {
const root = rootRef.current;
setDropLine(x == null || root == null ? null : x - root.getBoundingClientRect().left);
@@ -442,14 +450,19 @@ export function FlowList({ style, className }: Props) {
onResetSort={follow}
scrollElement={getScrollElement}
onDropLine={handleDropLine}
onColumnDrag={onColumnDrag}
/>
</div>
{/* Where a dragged column would drop, down the header and the rows in view */}
{/* A dragged column's cells dim where they are, in one rule rather than a render a row */}
{ghostColumn != null && (
<style>{`[data-flow-rows] [data-column="${ghostColumn}"] > * { opacity: 0.4; }`}</style>
)}
{/* Where a dragged column would drop, down the header and the rows in view, over its ghost */}
{dropLine != null && (
<div
aria-hidden
style={{ right: scrollbarWidth }}
className="pointer-events-none absolute inset-y-0 left-0 overflow-hidden"
className="pointer-events-none absolute inset-y-0 left-0 z-30 overflow-hidden"
>
<div
style={{ left: dropLine }}
@@ -460,6 +473,7 @@ export function FlowList({ style, className }: Props) {
<div className="relative min-h-0">
<div
ref={scrollRef}
data-flow-rows
role="listbox"
aria-label="Flows"
tabIndex={0}
@@ -569,6 +583,135 @@ function useArrivals(flows: Flow[], following: boolean): Map<string, number> {
}, [flows, following]);
}
/**
* The ghost of a dragged column: its header and the cells of the rows in view, copied once when
* the drag starts, at the column's width and the list's height. It follows the pointer sideways,
* kept within the list, by a transform, so the rows don't render while it moves; flows arriving
* or the list scrolling sideways meanwhile leave the copy as it was.
*/
function useColumnGhost(
rootRef: RefObject<HTMLDivElement | null>,
headerRef: RefObject<HTMLDivElement | null>,
listRef: RefObject<HTMLDivElement | null>,
): { ghostColumn: ColumnId | null; onColumnDrag: (event: ColumnDragEvent) => void } {
const [ghostColumn, setGhostColumn] = useState<ColumnId | null>(null);
const ghost = useRef<ColumnGhost | null>(null);
const onColumnDrag = useCallback(
(event: ColumnDragEvent) => {
if (event.type === "move") {
ghost.current?.move(event.x);
return;
}
ghost.current?.remove();
ghost.current = null;
if (event.type === "end") {
setGhostColumn(null);
return;
}
const root = rootRef.current;
const header = headerRef.current;
const list = listRef.current;
if (root == null || header == null || list == null) return;
ghost.current = snapshotColumn(event.id, event.x, root, header, list);
setGhostColumn(event.id);
},
[rootRef, headerRef, listRef],
);
useEffect(() => () => ghost.current?.remove(), []);
return { ghostColumn, onColumnDrag };
}
interface ColumnGhost {
/** Follows the pointer, across the window */
move: (x: number) => void;
remove: () => void;
}
function snapshotColumn(
id: ColumnId,
startX: number,
root: HTMLElement,
header: HTMLElement,
list: HTMLElement,
): ColumnGhost | null {
const cell = header.querySelector<HTMLElement>(`[data-column="${id}"]`);
if (cell == null) return null;
const rootBox = root.getBoundingClientRect();
const cellBox = cell.getBoundingClientRect();
const listBox = list.getBoundingClientRect();
const listHeight = list.clientHeight;
const el = document.createElement("div");
el.setAttribute("aria-hidden", "true");
el.className = classNames(
"pointer-events-none absolute top-0 z-20 overflow-hidden rounded-sm",
// Lifted: a shadow, and just enough opacity to read as held, not enough to show text through
"bg-surface border border-border-subtle shadow-lg opacity-95",
);
Object.assign(el.style, {
left: `${cellBox.left - rootBox.left}px`,
width: `${cellBox.width}px`,
height: `${rootBox.height}px`,
willChange: "transform",
});
// The header's label, without its resize handle
const head = place(cellBox.top - rootBox.top, cellBox.height, "text-xs text-text-subtlest");
const label = cell.cloneNode(true) as HTMLElement;
for (const handle of label.querySelectorAll(".cursor-col-resize")) handle.remove();
label.style.width = "100%";
head.append(label);
el.append(head);
// The rows in view, clipped to the list as it shows them
const rows = document.createElement("div");
rows.className = "absolute left-0 right-0 overflow-hidden";
Object.assign(rows.style, { top: `${listBox.top - rootBox.top}px`, height: `${listHeight}px` });
for (const row of list.querySelectorAll<HTMLElement>('[role="option"]')) {
const rowBox = row.getBoundingClientRect();
if (rowBox.bottom <= listBox.top || rowBox.top >= listBox.top + listHeight) continue;
const source = row.querySelector(`[data-column="${id}"]`);
if (source == null) continue;
const copy = place(
rowBox.top - listBox.top,
rowBox.height,
classNames(
"whitespace-nowrap tabular-nums",
row.getAttribute("aria-selected") === "true" && "text-text bg-surface-highlight",
row.classList.contains("text-text-subtlest") && "text-text-subtlest",
),
);
for (const child of source.children) {
const clone = child.cloneNode(true) as HTMLElement;
clone.style.width = "100%";
copy.append(clone);
}
rows.append(copy);
}
el.append(rows);
root.append(el);
// Kept within the list's visible width
const min = listBox.left;
const max = Math.max(min, listBox.left + list.clientWidth - cellBox.width);
const move = (x: number) => {
const left = Math.min(max, Math.max(min, cellBox.left + x - startX));
el.style.transform = `translateX(${left - cellBox.left}px)`;
};
move(startX);
return { move, remove: () => el.remove() };
}
/** A line of the ghost, `top` pixels down its box */
function place(top: number, height: number, className: string): HTMLElement {
const el = document.createElement("div");
el.className = classNames("absolute left-0 right-0 flex items-center", className);
Object.assign(el.style, { top: `${top}px`, height: `${height}px` });
return el;
}
interface RowProps {
flow: Flow;
index: number;
+28 -37
View File
@@ -1,6 +1,5 @@
import {
DndContext,
DragOverlay,
type DragStartEvent,
PointerSensor,
useDraggable,
@@ -28,7 +27,6 @@ import {
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
addSortKey,
allColumns,
@@ -77,8 +75,16 @@ interface Props {
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.
@@ -97,6 +103,7 @@ export const FlowListHeader = memo(function FlowListHeader({
onResetSort,
scrollElement,
onDropLine,
onColumnDrag,
}: Props) {
const setPrefs = useSetAtom(columnPrefsAtom);
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom));
@@ -108,6 +115,7 @@ export const FlowListHeader = memo(function FlowListHeader({
columns,
scrollElement,
onDropLine,
onColumnDrag,
onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)),
});
@@ -259,24 +267,6 @@ export const FlowListHeader = memo(function FlowListHeader({
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
/>
))}
{/* Over everything, as the header clips what overflows it */}
{createPortal(
<DragOverlay dropAnimation={null}>
{dragging != null && (
<div
className={classNames(
// As wide as its name, however narrow the column
"flex items-center gap-0.5 h-full w-max px-1.5 rounded-sm",
"cursor-grabbing whitespace-nowrap",
"text-xs text-text bg-surface-highlight border border-border shadow-lg",
)}
>
<ColumnLabel column={dragging} sort={sort} ghost />
</div>
)}
</DragOverlay>,
document.body,
)}
</DndContext>
<ContextMenu
triggerPosition={menu}
@@ -350,19 +340,8 @@ function HeaderCell({
);
}
/**
* A column's name, after its sort direction and, sorted by several, its place in the sort. App's
* column is as narrow as its icons, so only its dragged ghost names it.
*/
function ColumnLabel({
column: c,
sort,
ghost = false,
}: {
column: Column;
sort: FlowSortKey[];
ghost?: boolean;
}) {
/** 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 (
@@ -377,7 +356,7 @@ function ColumnLabel({
{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" && !ghost ? "" : c.label}</span>
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
</>
);
}
@@ -391,11 +370,13 @@ 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;
}) {
@@ -413,11 +394,13 @@ function useColumnDrag({
/** 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, onDrop });
const latest = useRef({ columns, scrollElement, onDropLine, onColumnDrag, onDrop });
useLayoutEffect(() => {
latest.current = { columns, scrollElement, onDropLine, onDrop };
latest.current = { columns, scrollElement, onDropLine, onColumnDrag, onDrop };
});
const stop = useCallback(() => {
@@ -426,13 +409,17 @@ function useColumnDrag({
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;
state.current = { id: column.id, x: pointer.current, to: null, line: null, frame: 0 };
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);
@@ -452,6 +439,10 @@ function useColumnDrag({
}
}
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);
+20 -7
View File
@@ -269,17 +269,30 @@ describe("column order", () => {
]);
});
test("a column turned on goes at the end, unless it had a place", () => {
test("never reordered, a built-in column turned on takes its default place", () => {
// Request Size's default place is before Size
let prefs = toggleColumn(defaultColumnPrefs, "requestSize");
const prefs = toggleColumn(defaultColumnPrefs, "requestSize");
expect(prefs.order).toEqual([]);
expect(ids(prefs).slice(-4)).toEqual(["type", "requestSize", "size", "time"]);
// Among the built-in ones, so a custom column stays at the end
const custom = addCustomColumn(defaultColumnPrefs, "header.x-cache");
expect(ids(custom).at(-1)).toBe("field:header.x-cache");
const both = toggleColumn(custom, "requestSize");
expect(both.order).toEqual([]);
expect(ids(both).slice(-4)).toEqual(["requestSize", "size", "time", "field:header.x-cache"]);
});
test("once reordered, a column turned on goes at the end, unless it had a place", () => {
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
prefs = toggleColumn(prefs, "requestSize");
expect(ids(prefs).at(-1)).toBe("requestSize");
expect(ids(prefs).slice(0, -1)).toEqual(defaults);
// Moved, hidden and shown again, it goes back where it was moved to
prefs = moveColumn(prefs, "requestSize", defaults.indexOf("size"));
const before = ids(prefs).indexOf("size");
prefs = moveColumn(prefs, "requestSize", before);
prefs = toggleColumn(toggleColumn(prefs, "requestSize"), "requestSize");
expect(ids(prefs).slice(-3)).toEqual(["requestSize", "size", "time"]);
// Already last of those shown, nothing needs placing
expect(toggleColumn(defaultColumnPrefs, "remote").order).toEqual([]);
expect(ids(prefs).slice(-3)).toEqual(["type", "requestSize", "size"]);
// A custom column added goes at the end too
expect(ids(addCustomColumn(prefs, "query.page")).at(-1)).toBe("field:query.page");
});
test("a custom column is added at the end and leaves the order when removed", () => {
+3 -2
View File
@@ -243,10 +243,11 @@ export function toggleColumn(
/**
* Puts a column just shown after the others shown, unless the person placed it before, so it
* goes back to that place. The columns shown before it are placed too, where they are, so the
* ones not listed don't jump ahead of it.
* ones not listed don't jump ahead of it. Until the person has ordered the columns, each keeps
* its default place: a built-in one among the others, an added one at the end.
*/
function placeAtEnd(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs {
if (prefs.order.includes(id)) return prefs;
if (prefs.order.length === 0 || prefs.order.includes(id)) return prefs;
const all = allColumns(prefs);
if (all.filter((c) => isVisible(prefs, c)).at(-1)?.id === id) return prefs;
const placed = all.filter(