Skip to content
Open
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
15 changes: 14 additions & 1 deletion apps/web/src/components/Sidebar.drag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,14 @@ describe("lifted card clearance", () => {
right: 260,
width: 260,
});
const apply = (cardTop: number, cardHeight: number, y: number, listTop = 136, offset = 32) =>
const apply = (
cardTop: number,
cardHeight: number,
y: number,
listTop = 136,
offset = 32,
contentTop?: number,
) =>
restrictBelowSidebarLabel(
{
transform: { ...stationary, y },
Expand All @@ -817,6 +824,7 @@ describe("lifted card clearance", () => {
windowRect: null,
},
offset,
contentTop,
);

it.each([36, 82])("keeps a %ipx row below empty Pins even past the top edge", (height) => {
Expand All @@ -826,6 +834,11 @@ describe("lifted card clearance", () => {
}
});

it("uses the scrolled list origin instead of a virtual row container", () => {
expect(apply(511, 36, -200, 511, 32, 136).y).toBe(-200);
expect(apply(511, 36, -800, 511, 32, -164).y).toBe(-643);
});

it("preserves pointer movement below the label", () => {
expect(apply(511, 36, -200).y).toBe(-200);
});
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/Sidebar.drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ type Layout = Parameters<SortingStrategy>[0];
export function restrictBelowSidebarLabel(
{ transform, containerNodeRect, draggingNodeRect }: Parameters<Modifier>[0],
offset: number,
listTop = containerNodeRect?.top,
) {
if (!containerNodeRect || !draggingNodeRect) return transform;
const minimumY = containerNodeRect.top + offset - draggingNodeRect.top;
if (listTop === undefined || !draggingNodeRect) return transform;
const minimumY = listTop + offset - draggingNodeRect.top;
return transform.y < minimumY ? { ...transform, y: minimumY } : transform;
}

Expand Down
57 changes: 53 additions & 4 deletions apps/web/src/components/Sidebar.motion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,15 @@ class TestRow {
readonly name: string,
public offsetHeight = 82,
) {}
get firstElementChild(): TestRow | null {
return this.children[0] ?? null;
}
getBoundingClientRect() {
return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight };
return {
top: this.offsetTop + this.dragTranslate,
left: this.offsetLeft,
height: this.offsetHeight,
};
}
setAttribute(name: string, value: string) {
this.removeAttribute(name);
Expand Down Expand Up @@ -65,12 +72,18 @@ class TestRow {
});
}

function fixture(rows: TestRow[]) {
function fixture(rows: TestRow[], virtual = false) {
const media = { matches: false };
const parent = {
children: rows,
ownerDocument: { defaultView: { matchMedia: () => media } },
getBoundingClientRect: () => ({ top: 0 }),
getBoundingClientRect: () => ({ top: 0, left: 0 }),
scrollTop: 50,
scrollLeft: 0,
querySelectorAll: () =>
parent.children.filter((row) =>
row.attributes.some((attribute) => attribute.name === "data-sidebar-list-key"),
),
append(node: TestRow) {
parent.children.push(node);
node.remove.mockImplementation(() => {
Expand All @@ -81,6 +94,7 @@ function fixture(rows: TestRow[]) {
function layout(next: TestRow[]) {
let top = 8;
for (const row of next) {
if (virtual) row.setAttribute("data-sidebar-list-key", row.name);
row.offsetTop = top;
top += row.offsetHeight + 1;
}
Expand All @@ -90,7 +104,7 @@ function fixture(rows: TestRow[]) {
];
}
layout(rows);
const motion = createSidebarListMotion(parent as unknown as HTMLUListElement);
const motion = createSidebarListMotion(parent as unknown as HTMLUListElement, { virtual });
return { motion, layout, media, parent };
}

Expand Down Expand Up @@ -172,6 +186,25 @@ describe("sidebar list motion", () => {
expectMove(c, 16);
});

it("preserves child drag transforms when releasing virtual rows in a scrolled viewport", () => {
const [a, b] = [new TestRow("a wrapper"), new TestRow("b wrapper")];
const [aRow, bRow] = [new TestRow("a"), new TestRow("b")];
a.children = [aRow];
b.children = [bRow];
const { motion, layout } = fixture([a, b], true);
aRow.offsetTop = a.offsetTop;
bRow.offsetTop = b.offsetTop;
motion.update(false);
aRow.dragTranslate = 130;
bRow.dragTranslate = -83;
motion.release();
layout([b, a]);
aRow.dragTranslate = bRow.dragTranslate = 0;
motion.update(true);
expectMove(a, 47);
expect(b.animate).not.toHaveBeenCalled();
});

it("does not glide on release when motion is reduced", () => {
const [a, b] = [new TestRow("a"), new TestRow("b")];
const { motion, layout, media } = fixture([a, b]);
Expand Down Expand Up @@ -259,6 +292,22 @@ describe("sidebar list motion", () => {
expect(a.animate).toHaveBeenCalledTimes(2);
});

it("excludes virtual exit clones from later row motion", () => {
const a = new TestRow("a");
const b = new TestRow("b");
const { motion, layout, parent } = fixture([a, b], true);
motion.update(true);
layout([b]);
motion.update(true);
const clone = a.clones[0]!;
expect(parent.children.includes(clone)).toBe(true);
expect(parent.querySelectorAll().length).toBe(1);
expect(parent.querySelectorAll()[0]).toBe(b);
motion.update(true);
expect(clone.animations).toHaveLength(1);
expect(clone.clones).toHaveLength(0);
});

it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => {
const a = new TestRow("a");
const b = new TestRow("b");
Expand Down
49 changes: 33 additions & 16 deletions apps/web/src/components/Sidebar.motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function progress(animation: Animation) {

/** Animate rows between their layout positions. The list must be
* positioned so every direct child's offsetTop has the same origin. */
export function createSidebarListMotion(parent: HTMLUListElement) {
export function createSidebarListMotion(parent: HTMLElement, options?: { virtual: boolean }) {
let positions: Map<HTMLElement, RowPosition> | null = null;
let disposed = false;
const reducedMotion = parent.ownerDocument.defaultView?.matchMedia(
Expand All @@ -29,6 +29,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
// commit can glide every row from where dnd-kit left it into its slot.
let released: Map<HTMLElement, number> | null = null;

const rows = () =>
Array.from(
options?.virtual
? parent.querySelectorAll<HTMLElement>("[data-sidebar-list-key]")
: parent.children,
).filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node));

const remainingOffset = (node: HTMLElement) => {
const current = running.get(node);
return current ? current.offset * (1 - progress(current.animation)) : 0;
Expand All @@ -48,6 +55,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
if (
(attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") ||
attribute.name === "data-thread-item" ||
attribute.name === "data-sidebar-list-key" ||
attribute.name === "data-thread-selection-safe" ||
attribute.name === "data-testid"
) {
Expand Down Expand Up @@ -118,17 +126,24 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
update(animate: boolean) {
if (disposed) return;
const next = new Map(
Array.from(parent.children)
.filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
.map((node) => [
node,
{
top: node.offsetTop,
left: node.offsetLeft,
width: node.offsetWidth,
height: node.offsetHeight,
},
]),
rows().map((node) => [
node,
{
top: options?.virtual
? node.getBoundingClientRect().top -
parent.getBoundingClientRect().top +
parent.scrollTop -
remainingOffset(node)
: node.offsetTop,
left: options?.virtual
? node.getBoundingClientRect().left -
parent.getBoundingClientRect().left +
parent.scrollLeft
: node.offsetLeft,
width: node.offsetWidth,
height: node.offsetHeight,
},
]),
);
let fadeCount = 0;
if (positions !== null) {
Expand Down Expand Up @@ -199,11 +214,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
* next update glides each of them into its committed slot. */
release() {
suspend();
const origin = parent.getBoundingClientRect().top;
const origin = parent.getBoundingClientRect().top - (options?.virtual ? parent.scrollTop : 0);
released = new Map(
Array.from(parent.children)
.filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
.map((node) => [node, node.getBoundingClientRect().top - origin]),
rows().map((node) => [
node,
(options?.virtual ? (node.firstElementChild ?? node) : node).getBoundingClientRect().top -
origin,
]),
);
},
suspend,
Expand Down
33 changes: 31 additions & 2 deletions apps/web/src/components/Sidebar.pointer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,17 @@ function gesture() {
onPending: vi.fn(),
};
const onFinish = vi.fn();
const onBeforeStart = vi.fn();
// The sensor never reads dnd-kit's layout context or active node.
const props = {
active: "thread",
event: pointer("pointerdown"),
options: { distance: 6, onAttach: vi.fn(), onFinish },
options: { distance: 6, onAttach: vi.fn(), onFinish, onBeforeStart },
...callbacks,
} as unknown as SensorProps<ConstructorParameters<typeof SidebarPointerSensor>[0]["options"]>;
const sensor = new SidebarPointerSensor(props);
sensors.push(sensor);
return { sensor, onFinish, ...callbacks };
return { sensor, onFinish, onBeforeStart, ...callbacks };
}

beforeEach(() => {
Expand All @@ -61,6 +62,34 @@ afterEach(() => {
});

describe("sidebar pointer lifecycle", () => {
it("prepares virtual rows before drag capture, without materializing ordinary clicks", () => {
const drag = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 14 }));
expect(drag.onBeforeStart).not.toHaveBeenCalled();
drag.onStart.mockImplementation(() => expect(drag.onBeforeStart).toHaveBeenCalledOnce());
document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
expect(drag.onStart).toHaveBeenCalledOnce();
});

it("cancels a failed preparation and ignores subsequent pointer events", () => {
const listeners = vi.spyOn(document, "addEventListener");
const drag = gesture();
const failure = new Error("Unable to prepare rows");
drag.onBeforeStart.mockImplementation(() => {
throw failure;
});
const move = listeners.mock.calls.find(([name]) => name === "pointermove")?.[1];
if (typeof move !== "function") throw new Error("Missing move listener");
expect(() => move.call(document, pointer("pointermove", { clientY: 20 }))).toThrow(failure);
document.dispatchEvent(pointer("pointermove", { clientY: 50 }));
document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
expect(drag.onFinish).toHaveBeenCalledOnce();
expect(drag.onCancel).toHaveBeenCalledOnce();
expect(drag.onStart).not.toHaveBeenCalled();
expect(drag.onMove).not.toHaveBeenCalled();
expect(drag.onEnd).not.toHaveBeenCalled();
});

it("keeps a click idle and starts only after the drag threshold", () => {
const click = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 16 }));
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/Sidebar.pointer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) {

type Options = {
distance: number;
onBeforeStart?: () => void;
onAttach: (sensor: SidebarPointerSensor) => void;
onFinish: (started: boolean) => void;
};
Expand Down Expand Up @@ -84,6 +85,12 @@ export class SidebarPointerSensor {
this.document.addEventListener("click", this.suppressClick, { capture: true });
this.document.addEventListener("selectionchange", this.clearSelection);
this.clearSelection();
try {
this.props.options.onBeforeStart?.();
} catch (error) {
this.cancel();
throw error;
}
this.props.onStart(this.coordinates());
return;
}
Expand Down
Loading
Loading