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 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mwadri4inaohxyiw committed 2026-11-10 10:09:45 -07:00
1 parent 185a4e30df
commit 80b587f507
4 files changed
+216 -126

No files matched your search

+107 -44
View File
@@ -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<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);
}, []);
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}
/>
</div>
{/* 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 z-30 overflow-hidden"
>
<div
style={{ left: dropLine }}
className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-primary"
/>
</div>
)}
<div className="relative min-h-0">
<div
ref={scrollRef}
@@ -583,44 +561,129 @@ function useArrivals(flows: Flow[], following: boolean): Map<string, number> {
}, [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<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);
): (event: ColumnDragEvent) => void {
const drag = useRef<ColumnDragView | null>(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);
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;
}
ghost.current?.remove();
ghost.current = null;
if (event.type === "end") {
setGhostColumn(null);
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;
ghost.current = snapshotColumn(event.id, event.x, root, header, list);
setGhostColumn(event.id);
const cells = Array.from(
header.querySelectorAll<HTMLElement>("[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);
}
}
},
[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 {
+58 -62
View File
@@ -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 */}
<div
ref={gridRef}
data-flow-header
onPointerDownCapture={onPointerDownCapture}
onContextMenu={openMenu}
style={{ gridTemplateColumns: template }}
@@ -258,7 +260,6 @@ export const FlowListHeader = memo(function FlowListHeader({
key={c.id}
column={c}
sort={sort}
dragging={dragging?.id === c.id}
onSort={onSort}
onResizeStart={() => {
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 (
<span
data-column={c.id}
className={classNames(
"relative h-full flex items-center min-w-0 px-1.5",
dragging && "opacity-40",
)}
>
<span data-column={c.id} className="relative h-full flex items-center min-w-0 px-1.5">
<button
ref={setNodeRef}
type="button"
@@ -363,19 +355,18 @@ function ColumnLabel({ column: c, sort }: { column: Column; sort: FlowSortKey[]
/**
* 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.
* pointer as they were laid out when the drag began, and the list scrolling sideways while the
* pointer is near its edge. The list shows the drag; the columns only change order 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;
@@ -384,44 +375,51 @@ function useColumnDrag({
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 */
/** The column dragged, and its place among those shown */
id: ColumnId;
from: number;
/** The labels' edges when the drag began, from the header's left, as the columns slide */
cells: { left: number; right: number }[];
/** The pointer, across the window */
x: number;
/** The boundary it would move to, null where it would stay, and the line drawn on it */
/** The boundary it would move to, null where it would stay */
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 });
const latest = useRef({ columns, scrollElement, onColumnDrag, onDrop });
useLayoutEffect(() => {
latest.current = { columns, scrollElement, onDropLine, onColumnDrag, onDrop };
latest.current = { columns, scrollElement, onColumnDrag, onDrop };
});
const stop = useCallback(() => {
/** Ends the drag, dropped or put back */
const stop = useCallback((dropped: boolean) => {
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" });
if (s == null) return null;
cancelAnimationFrame(s.frame);
latest.current.onColumnDrag({ type: "end", dropped });
return s;
}, []);
const onDragStart = useCallback((e: DragStartEvent) => {
const column = latest.current.columns.find((c) => c.id === e.active.id);
if (column == null) return;
const grid = gridRef.current;
const from = latest.current.columns.findIndex((c) => c.id === e.active.id);
if (grid == null || from === -1) return;
const gridLeft = grid.getBoundingClientRect().left;
const cells = Array.from(grid.querySelectorAll<HTMLElement>(":scope > [data-column]")).map(
(el) => {
const box = el.getBoundingClientRect();
return { left: box.left - gridLeft, right: box.right - gridLeft };
},
);
const id = e.active.id as ColumnId;
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
state.current = { id, from, cells, x, to: null, ghostX: x, frame: 0 };
latest.current.onColumnDrag({ type: "start", id, x });
target(state.current);
const tick = () => {
@@ -453,10 +451,14 @@ function useColumnDrag({
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();
// The list lets go of the drag and the order changes in the same task, so nothing jumps.
const s = stop(true);
if (s?.to != null) latest.current.onDrop(s.id, s.to);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
}, [stop]);
const cancel = useCallback(() => {
stop(false);
}, [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
@@ -477,52 +479,46 @@ function useColumnDrag({
track(e);
drop();
};
const cancel = () => {
const lost = () => {
unlisten.current?.();
stop();
cancel();
};
window.addEventListener("pointermove", track, true);
window.addEventListener("pointerup", up, true);
window.addEventListener("pointercancel", cancel, true);
window.addEventListener("pointercancel", lost, true);
unlisten.current = () => {
unlisten.current = null;
window.removeEventListener("pointermove", track, true);
window.removeEventListener("pointerup", up, true);
window.removeEventListener("pointercancel", cancel, true);
window.removeEventListener("pointercancel", lost, true);
};
},
[track, drop, stop],
[track, drop, cancel],
);
// A drag never outlives the header
useEffect(
() => () => {
unlisten.current?.();
stop();
cancel();
},
[stop],
[cancel],
);
/** Sets where the drag would drop, from the pointer and the labels where they are now */
/** Sets where the drag would drop, telling the list when that changes */
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);
const gridLeft = gridRef.current?.getBoundingClientRect().left ?? 0;
const cells = s.cells.map((c) => ({ left: gridLeft + c.left, right: gridLeft + c.right }));
const to = dropBoundary(s.x, cells, s.from);
if (to === s.to) return;
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;
latest.current.onColumnDrag({ type: "target", to });
}
return {
dragging,
sensors,
gridRef,
onPointerDownCapture,
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: stop },
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: cancel },
};
}
+26 -13
View File
@@ -6,6 +6,7 @@ import {
addSortKey,
allColumns,
cellFilterTerm,
columnShifts,
type ColumnPrefs,
customPath,
customPathProblem,
@@ -336,25 +337,37 @@ describe("column order", () => {
expect(parseColumnPrefs({ order: "time" }).order).toEqual([]);
});
test("the drop line shows at every boundary, its own slot's edges included", () => {
test("a drag's target is the boundary nearest the pointer, null in its own slot", () => {
// Four columns 100px wide from x 24, after the mark column; the third is dragged
const cells = [24, 124, 224, 324].map((left) => ({ left, right: left + 100 }));
const at = (x: number) => dropBoundary(x, cells, 2);
// Over the mark column or the first's left half: the first boundary
expect(at(5)).toEqual({ to: 0, line: 24 });
expect(at(60)).toEqual({ to: 0, line: 24 });
expect(at(80)).toEqual({ to: 1, line: 124 });
// The left neighbour's near half and its own left half: its left edge, staying
expect(at(190)).toEqual({ to: null, line: 224 });
expect(at(250)).toEqual({ to: null, line: 224 });
// Its own right half and the right neighbour's near half: its right edge, staying
expect(at(290)).toEqual({ to: null, line: 324 });
expect(at(350)).toEqual({ to: null, line: 324 });
expect(at(5)).toBe(0);
expect(at(60)).toBe(0);
expect(at(80)).toBe(1);
// The left neighbour's near half, its own column, the right neighbour's near half: staying
expect(at(190)).toBeNull();
expect(at(250)).toBeNull();
expect(at(290)).toBeNull();
expect(at(350)).toBeNull();
// Past the right neighbour's middle, and past the end
expect(at(380)).toEqual({ to: 4, line: 424 });
expect(at(900)).toEqual({ to: 4, line: 424 });
expect(at(380)).toBe(4);
expect(at(900)).toBe(4);
// The last column dragged to the end stays
expect(dropBoundary(900, cells, 3)).toEqual({ to: null, line: 424 });
expect(dropBoundary(900, cells, 3)).toBeNull();
});
test("the columns a drag passes slide by its width to make room", () => {
// Five columns; the second, 80px wide, is dragged
expect(columnShifts(5, 1, 80, null)).toEqual([0, 0, 0, 0, 0]);
// Right, to before the fifth: the third and fourth slide left
expect(columnShifts(5, 1, 80, 4)).toEqual([0, 0, -80, -80, 0]);
// Right, to the end: all after it slide left
expect(columnShifts(5, 1, 80, 5)).toEqual([0, 0, -80, -80, -80]);
// Left, to the start: the first slides right
expect(columnShifts(5, 1, 80, 0)).toEqual([80, 0, 0, 0, 0]);
// The fourth, dragged left to before the second: the second and third slide right
expect(columnShifts(5, 3, 60, 1)).toEqual([0, 60, 60, 0, 0]);
});
test("Reset Column Order puts every column back", () => {
+25 -7
View File
@@ -301,21 +301,39 @@ export function resetOrder(prefs: ColumnPrefs): ColumnPrefs {
/**
* Where a dragged column would drop, from the pointer and the shown columns' edges, left to
* right: the boundary before the first column whose middle is right of the pointer, and the
* line's place on it. Over its own column or the near half of a neighbour, that is an edge of
* its own slot, where it would stay, so `to` is null; the line still shows there. Left of the
* first column, over the fixed mark column, it is the first boundary.
* right, as they are laid out before the drag: the boundary before the first column whose middle
* is right of the pointer. Over its own column or the near half of a neighbour, that is an edge
* of its own slot, where it would stay, so null. Left of the first column, over the fixed mark
* column, it is the first boundary.
*/
export function dropBoundary(
x: number,
cells: { left: number; right: number }[],
from: number,
): { to: number | null; line: number | null } {
): number | null {
let at = cells.findIndex((r) => x < (r.left + r.right) / 2);
if (at === -1) at = cells.length;
const line = cells[at]?.left ?? cells.at(-1)?.right ?? null;
const stays = from === -1 || at === from || at === from + 1;
return { to: stays ? null : at, line };
return stays ? null : at;
}
/**
* How far, in pixels, each of `count` shown columns slides while the one at `from`, `width`
* wide, is dragged to the boundary `to`: those it would pass make room for it, leftwards when it
* goes right and rightwards when it goes left. The dragged column itself, and the rest, stay.
*/
export function columnShifts(
count: number,
from: number,
width: number,
to: number | null,
): number[] {
return Array.from({ length: count }, (_, i) => {
if (to == null || i === from) return 0;
if (to > from && i > from && i < to) return -width;
if (to < from && i >= to && i < from) return width;
return 0;
});
}
/** Shows `id` alone */