From 80b587f507b19608bc7ec3be59b590b964c762aa Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 10:09:39 -0700 Subject: [PATCH] Slide columns aside for a dragged one instead of drawing a drop line While a column is dragged it hides where it is and the columns between it and where it would land slide by its width, header and cells together, so the gap shows the drop. One style element carries a rule a column and changes only when the target does; the rows don't render. On drop the rules go as the order changes, in the same task, so nothing animates; on Escape the columns slide home and the column shows again. The drop line and its code are gone. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/FlowList.tsx | 165 ++++++++++++------ apps/yaak-proxy/components/FlowListHeader.tsx | 120 ++++++------- apps/yaak-proxy/lib/columns.test.ts | 39 +++-- apps/yaak-proxy/lib/columns.ts | 32 +++- 4 files changed, 223 insertions(+), 133 deletions(-) diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index fc196d318..df6a8af35 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -30,6 +30,7 @@ import { type Column, type ColumnId, cellFilterTerm, + columnShifts, customValue, gridTemplate, gridWidth, @@ -308,15 +309,10 @@ export function FlowList({ style, className }: Props) { el.scrollLeft += e.deltaX; }, []); - // A dragged column's drop line, from the window's coordinates to the list's + // A dragged column: its ghost, and the others sliding aside to make room for it const rootRef = useRef(null); - const [dropLine, setDropLine] = useState(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); - }, []); + const onColumnDrag = useColumnDragView(rootRef, headerScrollRef, scrollRef); // A list too short to scroll still loads older flows on scrolling up. const handleWheel = useCallback( @@ -449,27 +445,9 @@ export function FlowList({ style, className }: Props) { onSetSort={handleSetSort} onResetSort={follow} scrollElement={getScrollElement} - onDropLine={handleDropLine} onColumnDrag={onColumnDrag} /> - {/* A dragged column's cells dim where they are, in one rule rather than a render a row */} - {ghostColumn != null && ( - - )} - {/* Where a dragged column would drop, down the header and the rows in view, over its ghost */} - {dropLine != null && ( -
-
-
- )}
{ }, [flows, following]); } +/** How long the columns take to slide aside for a dragged column, or back */ +const SLIDE_MS = 150; + /** - * 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. + * Shows a column's drag without rendering a row. Its ghost is its header and the cells of the + * rows in view, copied once when the drag starts and following the pointer sideways. The column + * hides where it is, and the columns between it and where it would drop slide aside by its width + * to leave a gap there, all by one style element: a rule a column, header and cells together, + * which rows arriving meanwhile follow too. Dropped, the rules go as the order changes, in the + * same task, so nothing animates or jumps; put back, the columns slide home first. */ -function useColumnGhost( +function useColumnDragView( rootRef: RefObject, headerRef: RefObject, listRef: RefObject, -): { ghostColumn: ColumnId | null; onColumnDrag: (event: ColumnDragEvent) => void } { - const [ghostColumn, setGhostColumn] = useState(null); - const ghost = useRef(null); +): (event: ColumnDragEvent) => void { + const drag = useRef(null); + // Columns sliding home after a drag was put back, until they are there + const settling = useRef<{ style: HTMLStyleElement; timer: number } | null>(null); + + const settle = useCallback(() => { + const s = settling.current; + settling.current = null; + if (s == null) return; + clearTimeout(s.timer); + s.style.remove(); + }, []); const onColumnDrag = useCallback( (event: ColumnDragEvent) => { - if (event.type === "move") { - ghost.current?.move(event.x); - return; + const d = drag.current; + switch (event.type) { + case "move": + d?.ghost?.move(event.x); + return; + case "target": + if (d == null) return; + d.to = event.to; + d.style.textContent = columnDragCss(d); + return; + case "end": { + drag.current = null; + if (d == null) return; + d.ghost?.remove(); + if (event.dropped || d.to == null) { + d.style.remove(); + return; + } + d.to = null; + d.style.textContent = columnDragCss(d); + settling.current = { style: d.style, timer: window.setTimeout(settle, SLIDE_MS) }; + return; + } + case "start": { + settle(); + d?.ghost?.remove(); + d?.style.remove(); + drag.current = null; + const root = rootRef.current; + const header = headerRef.current; + const list = listRef.current; + if (root == null || header == null || list == null) return; + const cells = Array.from( + header.querySelectorAll("[data-flow-header] > [data-column]"), + ); + const ids = cells.map((c) => c.dataset.column as ColumnId); + const from = ids.indexOf(event.id); + if (from === -1) return; + // Copied before it hides + const ghost = snapshotColumn(event.id, event.x, root, header, list); + const style = document.createElement("style"); + document.head.append(style); + const width = cells[from]?.getBoundingClientRect().width ?? 0; + drag.current = { ghost, ids, from, width, to: null, style }; + style.textContent = columnDragCss(drag.current); + } } - 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], + [rootRef, headerRef, listRef, settle], ); - useEffect(() => () => ghost.current?.remove(), []); - return { ghostColumn, onColumnDrag }; + useEffect( + () => () => { + settle(); + drag.current?.ghost?.remove(); + drag.current?.style.remove(); + drag.current = null; + }, + [settle], + ); + return onColumnDrag; +} + +interface ColumnDragView { + ghost: ColumnGhost | null; + /** The columns shown, the dragged one's place among them and its width, and its target */ + ids: ColumnId[]; + from: number; + width: number; + to: number | null; + style: HTMLStyleElement; +} + +/** The rules for a drag: the column hidden, and those it passes slid aside */ +function columnDragCss({ ids, from, width, to }: ColumnDragView): string { + // A row's cell is the box in its grid: the column's child, or inside the `contents` span a + // tooltip wraps it in, as a box that is only its contents can't move + const cells = (is: string) => + `[data-flow-rows] ${is} > :not(.contents), [data-flow-rows] ${is} > .contents > *`; + const column = (id: ColumnId) => { + const is = `[data-column="${CSS.escape(id)}"]`; + return `[data-flow-header] > ${is}, ${cells(is)}`; + }; + const rules = [ + `[data-flow-header] > [data-column], ${cells("[data-column]")} { transition: transform ${SLIDE_MS}ms ease; }`, + ]; + const dragged = ids[from]; + if (dragged != null) rules.push(`${column(dragged)} { visibility: hidden; }`); + columnShifts(ids.length, from, width, to).forEach((dx, i) => { + const id = ids[i]; + if (dx !== 0 && id != null) rules.push(`${column(id)} { transform: translateX(${dx}px); }`); + }); + return rules.join("\n"); } interface ColumnGhost { diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index 952ca6cc6..837eec069 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -73,17 +73,20 @@ interface Props { 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 */ + /** A column's drag, for the list to show: its ghost, and the columns making room for it */ onColumnDrag: (event: ColumnDragEvent) => void; } -/** A column's drag, for the ghost the list draws of it; `x` is the pointer, across the window */ +/** + * A column's drag: its start, the pointer's moves (`x`, across the window), the boundary it + * would drop at when that changes (`to`, null where it would stay), and its end, dropped there + * or put back + */ export type ColumnDragEvent = | { type: "start"; id: ColumnId; x: number } | { type: "move"; x: number } - | { type: "end" }; + | { type: "target"; to: number | null } + | { type: "end"; dropped: boolean }; /** * The list's only control surface: a click on a label sorts by it alone, a second reverses it @@ -102,7 +105,6 @@ export const FlowListHeader = memo(function FlowListHeader({ onSetSort: setSort, onResetSort, scrollElement, - onDropLine, onColumnDrag, }: Props) { const setPrefs = useSetAtom(columnPrefsAtom); @@ -111,10 +113,9 @@ export const FlowListHeader = memo(function FlowListHeader({ 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({ + const { sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({ columns, scrollElement, - onDropLine, onColumnDrag, onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)), }); @@ -232,6 +233,7 @@ export const FlowListHeader = memo(function FlowListHeader({ {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
{ startWidth.current = c.current; @@ -281,8 +282,6 @@ export const FlowListHeader = memo(function FlowListHeader({ 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 */ @@ -293,7 +292,6 @@ interface HeaderCellProps { function HeaderCell({ column: c, sort, - dragging, onSort, onResizeStart, onResizeMove, @@ -303,13 +301,7 @@ function HeaderCell({ const { listeners, setNodeRef } = useDraggable({ id: c.id }); const sorted = sort.some((k) => k.column === c.id); return ( - +