Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
reduceSidebarProjectScopeMenuState,
resolveAdjacentThreadId,
resolveProjectStatusIndicator,
resolveSidebarSweepKeys,
resolveSidebarStageBadgeLabel,
resolveSidebarThreadSection,
resolveSidebarRowAccessibility,
Expand Down Expand Up @@ -2014,6 +2015,22 @@ describe("sortPinnedThreadsForSidebar", () => {
});
});

describe("resolveSidebarSweepKeys", () => {
const ordered = ["a", "b", "c", "d", "blocked"];
const canSettle = (key: string) => key !== "blocked";

it("covers every row between the pressed row and the pointer, in either direction", () => {
expect(resolveSidebarSweepKeys(ordered, "b", "b", canSettle)).toEqual(["b"]);
expect(resolveSidebarSweepKeys(ordered, "b", "d", canSettle)).toEqual(["b", "c", "d"]);
expect(resolveSidebarSweepKeys(ordered, "d", "a", canSettle)).toEqual(["a", "b", "c", "d"]);
});

it("leaves out rows that cannot settle and rows that left the list", () => {
expect(resolveSidebarSweepKeys(ordered, "c", "blocked", canSettle)).toEqual(["c", "d"]);
expect(resolveSidebarSweepKeys(ordered, "gone", "a", canSettle)).toEqual([]);
});
});

describe("navigation after parking a thread", () => {
it.each([
["settle", "settled", null, "thread", true],
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,34 @@ export function resolveSidebarDropVerb(
return "wake";
}

/** Eligible rows between the pressed action and the pointer, in sidebar order. */
export function resolveSidebarSweepKeys(
orderedKeys: readonly string[],
originKey: string,
targetKey: string,
canApply: (key: string) => boolean,
): string[] {
const origin = orderedKeys.indexOf(originKey);
const target = orderedKeys.indexOf(targetKey);
if (origin === -1 || target === -1) return [];
return orderedKeys.slice(Math.min(origin, target), Math.max(origin, target) + 1).filter(canApply);
}

/** The thread row at a pointer height, clamped to the rows visible in the
sidebar's scroll viewport. A gap between rows resolves to the row above
it. Rows carry their key in data-thread-item, which departing motion
clones drop. */
export function sidebarThreadKeyAtY(list: HTMLElement, y: number): string | null {
const viewport = list.closest('[data-slot="scroll-area-viewport"]')?.getBoundingClientRect();
const visibleY = viewport ? Math.min(Math.max(y, viewport.top), viewport.bottom - 1) : y;
let key: string | null = null;
for (const row of list.querySelectorAll<HTMLElement>("li[data-thread-item]")) {
if (key !== null && row.getBoundingClientRect().top > visibleY) break;
key = row.dataset.threadItem ?? null;
}
return key;
}

export function planSidebarThreadDrop(input: {
readonly activeKey: string;
readonly activeSection: SidebarSection;
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/components/Sidebar.pointer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ describe("sidebar pointer lifecycle", () => {
expect(drag.onFinish).toHaveBeenCalledOnce();
});

it("moves to the pointer on the move that starts the drag", () => {
const drag = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 90 }));
expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 });
expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 90 });
});

const interruptions = {
blur: () => window.dispatchEvent(new Event("blur")),
hidden: () => {
Expand Down Expand Up @@ -122,10 +129,11 @@ describe("sidebar pointer lifecycle", () => {
const drag = gesture({ onMove, onDrop });
document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 }));
expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 });
expect(drag.onMove).toHaveBeenNthCalledWith(1, { x: 10, y: 20 });
expect(drag.onMove).toHaveBeenNthCalledWith(2, { x: 30, y: 30 });
document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 }));
// Outside the list the sort no longer sees the pointer.
expect(drag.onMove).toHaveBeenCalledOnce();
expect(drag.onMove).toHaveBeenCalledTimes(2);
document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 }));
expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 90, y: 30 });
expect(drag.onCancel).toHaveBeenCalledOnce();
Expand All @@ -148,7 +156,8 @@ describe("sidebar pointer lifecycle", () => {
document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 }));
document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 100 }));
document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 200 }));
expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 });
expect(drag.onMove).toHaveBeenCalledTimes(2);
expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 30 });

document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 60 }));
expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 60 });
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/Sidebar.pointer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export class SidebarPointerSensor {
private readonly document: Document;
private readonly window: Window;

constructor(private readonly props: SensorProps<Options>) {
// The settle sweep constructs this sensor directly, outside dnd-kit, so it
// takes only the props the gesture reads.
constructor(private readonly props: Omit<SensorProps<Options>, "activeNode" | "context">) {
this.pointer = props.event as PointerEvent;
this.document = getOwnerDocument(this.pointer.target);
this.window = getWindow(this.pointer.target);
Expand Down Expand Up @@ -89,8 +91,9 @@ export class SidebarPointerSensor {
this.document.addEventListener("selectionchange", this.clearSelection);
this.clearSelection();
this.props.onStart(this.coordinates());
return;
}
// The move that starts a drag also moves it, so a release before the
// next pointermove still lands where the pointer is.
if (this.phase === "dragging") {
if (event.cancelable) event.preventDefault();
if (this.props.options.onMove?.(coordinates) === true) return;
Expand Down
Loading
Loading