diff --git a/apps/web/src/components/Sidebar.drag.browser.test.ts b/apps/web/src/components/Sidebar.drag.browser.test.ts new file mode 100644 index 0000000000000..94bb08e87880b --- /dev/null +++ b/apps/web/src/components/Sidebar.drag.browser.test.ts @@ -0,0 +1,162 @@ +import "../index.css"; + +import { describe, expect, it, vi } from "vite-plus/test"; +import type { SortingStrategy } from "@dnd-kit/sortable"; +import { + createSidebarSortingStrategy, + measureSidebarPinnedGrid, + restrictSidebarDrag, + resolveSidebarDragSourceRect, +} from "./Sidebar.drag"; +import { sidebarListItemId, type SidebarListItem } from "./Sidebar.logic"; +import { createSidebarListMotion } from "./Sidebar.motion"; + +function mountPinnedGrid(width: number, count: number) { + const list = document.createElement("ul"); + list.style.cssText = `display:flex;flex-direction:column;gap:1px;width:${width}px;position:relative`; + const header = document.createElement("li"); + header.style.cssText = "height:0;margin-inline:0.125rem"; + const grid = document.createElement("ul"); + grid.className = "sidebar-pinned-grid"; + grid.dataset.sidebarMotionGroup = ""; + const tiles = Array.from({ length: count }, () => grid.appendChild(document.createElement("li"))); + list.append(header, grid); + document.body.append(list); + return { list, header, grid, tiles, [Symbol.dispose]: () => list.remove() }; +} + +describe("pinned grid layout contract", () => { + it("keeps a full-width pin under the pointer in its compact destination cell", () => { + using mounted = mountPinnedGrid(244, 4); + const source = mounted.tiles[3]!; + source.dataset.threadItem = "wide-pin"; + const bounds = source.getBoundingClientRect(); + const activation = new PointerEvent("pointerdown", { clientX: bounds.left + bounds.width / 2 }); + source.dispatchEvent(activation); + const from = resolveSidebarDragSourceRect(activation, null)!; + expect(from.width).toBe(bounds.width); + const target = mounted.tiles[2]!.getBoundingClientRect(); + const pointerX = target.left + target.width / 2; + const widthRatio = target.width / from.width; + const transform = restrictSidebarDrag( + { + activatorEvent: null, + active: null, + activeNodeRect: from, + draggingNodeRect: from, + containerNodeRect: mounted.grid.getBoundingClientRect(), + over: null, + overlayNodeRect: null, + scrollableAncestors: [mounted.grid], + scrollableAncestorRects: [mounted.grid.getBoundingClientRect()], + transform: { + x: pointerX - (from.left + from.width / 2), + y: target.top - from.top, + scaleX: 1, + scaleY: 1, + }, + windowRect: null, + }, + { width: target.width, offsetX: (from.width / 2) * (1 - widthRatio) }, + ); + source.style.width = `${target.width}px`; + source.style.transform = `translate(${transform.x}px, ${transform.y}px)`; + const preview = source.getBoundingClientRect(); + expect(preview.left).toBeCloseTo(target.left, 0); + expect(pointerX).toBeGreaterThanOrEqual(preview.left); + expect(pointerX).toBeLessThanOrEqual(preview.right); + }); + it("does not animate tiles from their old columns after the sidebar resizes", () => { + using mounted = mountPinnedGrid(244, 5); + const motion = createSidebarListMotion(mounted.list); + try { + motion.update(false); + mounted.list.style.width = "200px"; + motion.update(true); + expect(mounted.tiles.flatMap((tile) => tile.getAnimations())).toHaveLength(0); + } finally { + motion.dispose(); + } + }); + + // Pinning remounts a card as a tile; it should travel from the card, not appear from nowhere. + it.each(["menu", "drop"] as const)("lands a %s pin where its card was", async (path) => { + using mounted = mountPinnedGrid(244, 2); + const card = document.createElement("li"); + card.dataset.threadItem = "t"; + card.textContent = "Thread identity"; + card.style.height = "82px"; + mounted.list.append(card); + const motion = createSidebarListMotion(mounted.list); + try { + motion.update(false); + const from = card.getBoundingClientRect(); + if (path === "drop") motion.release(); + card.remove(); + const tile = mounted.grid.appendChild(document.createElement("li")); + tile.dataset.threadItem = "t"; + tile.textContent = "Thread identity"; + motion.update(true); + expect(mounted.list.textContent).toBe("Thread identity"); + const animations = tile.getAnimations(); + expect(animations).toHaveLength(1); + animations[0]!.pause(); + const start = tile.getBoundingClientRect(); + expect(start.left + start.width / 2).toBeCloseTo(from.left + from.width / 2, 0); + expect(start.top + start.height / 2).toBeCloseTo(from.top + from.height / 2, 0); + // Menu pinning and dropping both hold hover details until the row lands. + const settling = () => mounted.list.hasAttribute("data-sidebar-settling"); + expect(settling()).toBe(true); + for (const animation of mounted.list.getAnimations({ subtree: true })) animation.finish(); + await vi.waitFor(() => expect(settling()).toBe(false)); + } finally { + motion.dispose(); + } + }); + + it.each([ + [152, 1], + [200, 2], + [244, 3], + ])("projects a still drag onto the CSS grid at %ipx (%i columns)", (width, columns) => { + for (let count = 1; count <= 5; count++) { + using mounted = mountPinnedGrid(width, count); + const pinnedGrid = measureSidebarPinnedGrid(mounted.grid, 1); + expect(pinnedGrid?.columns).toBe(columns); + const items: SidebarListItem[] = [ + { kind: "marker", marker: "pinned-header" }, + ...mounted.tiles.map((_, index) => ({ + kind: "thread" as const, + key: `p${index}`, + section: "pinned" as const, + })), + { kind: "marker", marker: "pinned-divider" }, + ]; + const rects = [mounted.header, ...mounted.tiles, mounted.header].map((node) => + node.getBoundingClientRect(), + ); + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: true, + pinnedGrid, + }); + const activeIndex = count; + const args = { + activeIndex, + overIndex: activeIndex, + activeNodeRect: rects[activeIndex]!, + rects, + index: 0, + } satisfies Parameters[0]; + for (const [index, item] of items.entries()) { + if (item.kind !== "thread") continue; + const transform = strategy({ ...args, index }); + const tile = `${sidebarListItemId(item)} of ${count}`; + expect(transform?.x, tile).toBeCloseTo(0, 1); + expect(transform?.y, tile).toBeCloseTo(0, 1); + expect(transform?.scaleX, tile).toBeCloseTo(1, 3); + } + } + }); +}); diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index e1febcd68d533..c106dfcbad4b7 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -69,6 +69,59 @@ function preview( } describe("sidebar collision detection", () => { + it("keeps the same pinned slot under the pointer when preview widths change", () => { + const items = [ + pinnedHeader, + thread("p1", "pinned"), + thread("p2", "pinned"), + thread("p3", "pinned"), + thread("source", "pinned"), + ]; + const rects = [ + { left: 0, top: 100, width: 260, height: 0, right: 260, bottom: 100 }, + ...[0, 88, 176].map((left) => ({ + left, + top: 101, + width: 84, + height: 46, + right: left + 84, + bottom: 147, + })), + { left: 0, top: 151, width: 260, height: 46, right: 260, bottom: 197 }, + ]; + const droppableRects = new Map(items.map((item, i) => [sidebarListItemId(item), rects[i]!])); + const args = { + ...collisionArgs(), + collisionRect: { left: 0, top: 125, width: 260, height: 46, right: 260, bottom: 171 }, + droppableRects, + droppableContainers: items.map((item, i) => ({ + id: sidebarListItemId(item), + key: sidebarListItemId(item), + disabled: false, + data: { current: {} }, + node: { current: null }, + rect: { current: rects[i]! }, + })), + pointerCoordinates: { x: 42, y: 148 }, + } satisfies Parameters[0]; + const detector = createSidebarCollisionDetection(() => true, { + items, + pinnedGrid: { + columns: 3, + rowHeight: 46, + gap: 4, + leading: 0, + trailing: 8, + width: 260, + labelHeight: 24, + }, + }); + expect(detector(args)[0]?.id).toBe("p1"); + droppableRects.set("p1", { ...rects[1]!, width: 260, right: 260 }); + droppableRects.set("source", { ...rects[4]!, width: 84, right: 84 }); + expect(detector(args)[0]?.id).toBe("p1"); + }); + function collisionArgs(blockedAboveSource = false) { const rows = [thread("source", "active"), thread("blocked", "active")]; const items = [ @@ -217,6 +270,44 @@ describe("sidebar collision detection", () => { }), ).toEqual([]); }); + + it("picks the pinned tile under the pointer for a full-width card", () => { + const rect = (left: number, top: number, width: number, height: number) => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }); + const rects = new Map([ + ["p1", rect(0, 100, 140, 46)], + ["p2", rect(144, 100, 116, 46)], + ["card", rect(0, 200, 260, 82)], + ]); + // The card keeps its column, so its center sits nearer p1 while the pointer is over p2. + const collisionRect = rect(0, 82, 260, 82); + const detector = createSidebarCollisionDetection(() => true); + const collisions = detector({ + active: { + id: "card", + data: { current: {} }, + rect: { current: { initial: rects.get("card")!, translated: collisionRect } }, + }, + collisionRect, + droppableRects: rects, + droppableContainers: [...rects].map(([id, current]) => ({ + id, + key: id, + disabled: false, + data: { current: {} }, + node: { current: null }, + rect: { current }, + })), + pointerCoordinates: { x: 220, y: 120 }, + }); + expect(collisions[0]?.id).toBe("p2"); + }); }); describe("sidebar drag projection", () => { @@ -296,14 +387,14 @@ describe("sidebar drag projection", () => { thread("s1", "settled"), ]; - it("keeps the pinned header above the gap when a lower pin moves to the top", () => { + it("holds pins in place when a lower pin moves to the top", () => { const result = preview( { items: pinned, settledOrder: [], settledExpanded: true }, "p2", sidebarMarkerId("pinned-header"), ); expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); - expect(result.get("p1")).toEqual({ ...stationary, y: 83 }); + expect(result.get("p1")).toEqual(stationary); expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary); expect(result.get("a1")).toEqual(stationary); }); @@ -329,11 +420,11 @@ describe("sidebar drag projection", () => { }); it.each([ - [sidebarMarkerId("pinned-divider"), 0, 0], - ["a1", -83, 0], - ["a2", -83, -83], + [sidebarMarkerId("pinned-divider"), 83, 83], + ["a1", 0, 83], + ["a2", 0, 0], ] as const)( - "opens the active pointer slot over %s without adding an empty pinned row", + "opens the active pointer slot over %s and keeps the pin's cell until the drop", (over, a1Offset, a2Offset) => { const items = [ pinnedHeader, @@ -346,10 +437,10 @@ describe("sidebar drag projection", () => { ]; const result = preview({ items, settledOrder: [], settledExpanded: true }, "p", over); expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); - expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-83); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(0); expect(result.get("a1")?.y).toBe(a1Offset); expect(result.get("a2")?.y).toBe(a2Offset); - expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83); }, ); @@ -407,30 +498,24 @@ describe("sidebar drag projection", () => { }, ); - it.each([ - ["p", -83, -1], - ["s", 0, 82], - ] as const)( - "replaces the empty Active target when %s enters", - (active, dividerOffset, settledOffset) => { - const items = [ - pinnedHeader, - thread("p", "pinned"), - divider, - marker("active-placeholder"), - settledHeader, - thread("s", "settled"), - ]; - const result = preview( - { items, settledOrder: [], settledExpanded: true }, - active, - sidebarMarkerId("active-placeholder"), - ); - expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0); - expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(dividerOffset); - expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(settledOffset); - }, - ); + it.each(["p", "s"])("replaces the empty Active target when %s enters", (active) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + marker("active-placeholder"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: true }, + active, + sidebarMarkerId("active-placeholder"), + ); + expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(0); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(82); + }); it("uses the canonical settled rank and the destination's slim height", () => { const items = [ @@ -523,11 +608,11 @@ describe("sidebar drag projection", () => { marker("settled-placeholder"), ]; const input = { items, settledOrder: [], settledExpanded: false }; - expect(preview(input, "p", "a1").get("a2")?.y).toBe(0); + expect(preview(input, "p", "a1").get("a2")?.y).toBe(83); const byTime = preview({ ...input, activeOrder: ["a1", "a2", "p"] }, "p", "a1"); - expect(byTime.get("a2")?.y).toBe(-83); - expect(byTime.get(sidebarMarkerId("working-header"))).toEqual(stationary); - expect(byTime.get("w")).toEqual(stationary); + expect(byTime.get("a2")).toEqual(stationary); + expect(byTime.get(sidebarMarkerId("working-header"))?.y).toBe(83); + expect(byTime.get("w")?.y).toBe(83); }); it("updates the projection when the target or measured geometry changes", () => { @@ -537,10 +622,10 @@ describe("sidebar drag projection", () => { settledExpanded: true, }); const args = layout(pinned, "p1", "p1"); - expect(strategy({ ...args, index: 2 })?.y).toBe(0); - expect(strategy({ ...args, index: 2, overIndex: 4 })?.y).toBe(-83); + expect(strategy({ ...args, index: 5 })?.y).toBe(0); + expect(strategy({ ...args, index: 5, overIndex: 4 })?.y).toBe(83); const smaller = layout(pinned, "p1", "a1", 0.75); - expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5); + expect(strategy({ ...smaller, index: 5 })?.y).toBe(62.5); }); it.each(["active", "settled"] as const)( @@ -682,6 +767,150 @@ describe("sidebar drag projection", () => { }); }); +describe("pinned grid projection", () => { + const grid = { + columns: 3, + rowHeight: 46, + gap: 4, + leading: 0, + trailing: 8, + width: 260, + labelHeight: 0, + }; + const items = [ + pinnedHeader, + thread("p1", "pinned"), + thread("p2", "pinned"), + thread("p3", "pinned"), + thread("p4", "pinned"), + divider, + thread("a1", "active"), + settledHeader, + marker("settled-placeholder"), + ]; + + // Mirrors the CSS grid: full rows split the width, a short last row stretches. + function gridPreview(active: string, over: string) { + const width = 260; + const pinnedKeys = items.flatMap((item) => + item.kind === "thread" && item.section === "pinned" ? [item.key] : [], + ); + const rows = Math.ceil(pinnedKeys.length / grid.columns); + let top = 100; + const rects = items.map((item) => { + const pinnedIndex = item.kind === "thread" ? pinnedKeys.indexOf(item.key) : -1; + if (pinnedIndex >= 0) { + const row = Math.floor(pinnedIndex / grid.columns); + const inRow = Math.min(grid.columns, pinnedKeys.length - row * grid.columns); + const cell = (width - (inRow - 1) * grid.gap) / inRow; + const left = (pinnedIndex % grid.columns) * (cell + grid.gap); + const rowTop = top + grid.leading + row * (grid.rowHeight + grid.gap); + const rect = { + top: rowTop, + height: grid.rowHeight, + bottom: rowTop + grid.rowHeight, + left, + right: left + cell, + width: cell, + }; + if (pinnedIndex === pinnedKeys.length - 1) { + top += grid.leading + rows * grid.rowHeight + (rows - 1) * grid.gap + grid.trailing + 1; + } + return rect; + } + const height = + item.kind === "thread" + ? item.section === "active" + ? 82 + : 36 + : item.marker === "settled-header" + ? 32 + : 0; + const rect = { top, height, bottom: top + height, left: 0, right: width, width }; + top += height + 1; + return rect; + }); + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: true, + pinnedGrid: grid, + }); + const activeIndex = items.findIndex((item) => sidebarListItemId(item) === active); + const args = { + activeIndex, + overIndex: items.findIndex((item) => sidebarListItemId(item) === over), + activeNodeRect: rects[activeIndex]!, + rects, + index: 0, + } satisfies Parameters[0]; + return new Map( + items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]), + ); + } + + it("opens the first cell live for the full-width last pin", () => { + const result = gridPreview("p4", "p1"); + expect(result.get("p1")).toEqual({ ...stationary, x: 88 }); + expect(result.get("p2")).toEqual({ ...stationary, x: 88 }); + expect(result.get("p3")).toEqual({ ...stationary, x: -176, y: 50, scaleX: 260 / 84 }); + expect(result.get("p4")).toEqual(stationary); + }); + + it.each([ + ["a1", "p2", "a card comes in"], + ["p1", "a1", "a pin leaves"], + ])("projects pinned cells while %s hovers %s (%s)", (active, over) => { + const result = gridPreview(active, over); + const expected = + active === "a1" + ? [ + stationary, + { ...stationary, x: 88 }, + { ...stationary, x: -176, y: 50, scaleX: 128 / 84 }, + { ...stationary, x: 132, scaleX: 128 / 260 }, + ] + : [stationary, stationary, stationary, stationary]; + for (const [index, key] of ["p1", "p2", "p3", "p4"].entries()) + expect(result.get(key), key).toEqual(expected[index]); + expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary); + }); +}); + +it.each([ + [0, sidebarMarkerId("pinned-divider")], + [0, "a2"], + [3, sidebarMarkerId("pinned-divider")], +] as const)("opens a grid row with %i pins over %s", (count, over) => { + const items = [ + pinnedHeader, + ...Array.from({ length: count }, (_, index) => thread(`p${index}`, "pinned")), + divider, + thread("a1", "active"), + thread("a2", "active"), + ]; + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: true, + pinnedGrid: { + columns: 3, + rowHeight: 46, + gap: 4, + leading: 0, + trailing: 8, + width: 260, + labelHeight: 0, + }, + }); + const args = layout(items, "a2", over); + const nextRows = Math.ceil((count + 1) / 3); + const previousHeight = count * 83; + const displacement = nextRows * 50 + 5 - previousHeight; + expect(strategy({ ...args, index: count + 1 })?.y).toBe(displacement); + expect(strategy({ ...args, index: count + 2 })?.y).toBe(displacement); +}); + describe("lifted card clearance", () => { const rect = (top: number, height: number) => ({ top, @@ -691,11 +920,18 @@ 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, + containerTop = listTop, + ) => restrictBelowSidebarLabel( { transform: { ...stationary, y }, - containerNodeRect: rect(listTop, 500), + containerNodeRect: rect(containerTop, 500), draggingNodeRect: rect(cardTop, cardHeight), activatorEvent: null, active: null, @@ -707,6 +943,7 @@ describe("lifted card clearance", () => { windowRect: null, }, offset, + listTop, ); it.each([36, 82])("keeps a %ipx row below empty Pins even past the top edge", (height) => { @@ -724,5 +961,6 @@ describe("lifted card clearance", () => { it("follows the list when it scrolls and includes content preceding Pins", () => { expect(511 + apply(511, 36, -500, 96).y).toBe(128); expect(511 + apply(511, 36, -500, 136, 114).y).toBe(250); + expect(511 + apply(511, 46, -500, 136, 114, 250).y).toBe(250); }); }); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 76f2c9bf3a3ec..4e9486fdd1434 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -1,5 +1,6 @@ import { closestCenter, type CollisionDetection, type Modifier } from "@dnd-kit/core"; import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; +import { restrictToFirstScrollableAncestor } from "@dnd-kit/modifiers"; import { resolveSidebarDropTarget, sidebarListItemId, @@ -10,6 +11,40 @@ import { } from "./Sidebar.logic"; const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; + +export function resolveSidebarDragSourceRect( + event: Event, + initialRect: Parameters[0]["activeNodeRect"], +) { + // DragStart precedes dnd-kit's first rectangle measurement. + return ( + initialRect ?? + (event.target instanceof Element + ? event.target.closest("[data-thread-item]")?.getBoundingClientRect() + : undefined) + ); +} + +export function restrictSidebarDrag( + args: Parameters[0], + preview?: { readonly width: number; readonly offsetX: number }, +) { + const rect = args.draggingNodeRect; + if (!preview || !rect) return restrictToFirstScrollableAncestor(args); + const width = preview.width; + return restrictToFirstScrollableAncestor({ + ...args, + transform: { ...args.transform, x: args.transform.x + preview.offsetX }, + draggingNodeRect: { + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.left + width, + width, + height: rect.height, + }, + }); +} const hidden = { ...stationary, scaleY: 0 }; type ThreadItem = Extract; type Layout = Parameters[0]; @@ -22,9 +57,10 @@ const isShelfHeader = (item: SidebarListItem | undefined) => export function restrictBelowSidebarLabel( { transform, containerNodeRect, draggingNodeRect }: Parameters[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; } @@ -33,15 +69,54 @@ export function createSidebarCollisionDetection( options: { items?: readonly SidebarListItem[]; activationY?: number | null; + pinnedGrid?: SidebarPinnedGrid | undefined; } = {}, ): CollisionDetection { const validity = new Map(); const sections = new Map(); let previousPointerY = options.activationY; let boundarySection: "pinned" | "active" | undefined; + const pins = + options.items?.filter( + (item): item is ThreadItem => item.kind === "thread" && item.section === "pinned", + ) ?? []; return (args) => { - let collisions = closestCenter(args); const pointer = args.pointerCoordinates; + let droppableRects = args.droppableRects; + const grid = options.pinnedGrid; + const header = args.droppableRects.get(sidebarMarkerId("pinned-header")); + if (grid && header && pins.length > 0) { + droppableRects = new Map(args.droppableRects); + for (const [index, pin] of pins.entries()) { + const cell = sidebarPinnedGridCell(grid, index, pins.length); + const left = header.left + cell.left; + const top = header.top + grid.labelHeight + 1 + grid.leading + cell.top; + droppableRects.set(pin.key, { + left, + top, + width: cell.width, + height: grid.rowHeight, + right: left + cell.width, + bottom: top + grid.rowHeight, + }); + } + } + // Rows span the list, so only pinned grid columns feel the pointer's x; cards keep their column. + let collisions = closestCenter( + pointer + ? { + ...args, + droppableRects, + collisionRect: { + ...args.collisionRect, + left: pointer.x - args.collisionRect.width / 2, + right: pointer.x + args.collisionRect.width / 2, + top: pointer.y - args.collisionRect.height / 2, + bottom: pointer.y + args.collisionRect.height / 2, + }, + } + : args, + ); const items = options.items; const source = items?.find((item) => item.kind === "thread" && item.key === args.active.id); const boundary = args.droppableContainers @@ -89,6 +164,57 @@ export function createSidebarCollisionDetection( }; } +/** Pinned tile grid geometry in client pixels, measured when a drag starts. */ +export type SidebarPinnedGrid = { + readonly width: number; + readonly labelHeight: number; + readonly columns: number; + readonly rowHeight: number; + readonly gap: number; + readonly leading: number; + readonly trailing: number; +}; + +export function sidebarPinnedGridCell(grid: SidebarPinnedGrid, index: number, count: number) { + const row = Math.floor(index / grid.columns); + const columns = Math.min(grid.columns, count - row * grid.columns); + const width = (grid.width - (columns - 1) * grid.gap) / columns; + return { + left: (index % grid.columns) * (width + grid.gap), + top: row * (grid.rowHeight + grid.gap), + width, + }; +} + +/** Reads the pinned grid's CSS-driven columns and spacing; `scale` maps CSS to client pixels. */ +export function measureSidebarPinnedGrid( + grid: HTMLElement, + scale: number, + labelHeight = 0, +): SidebarPinnedGrid | undefined { + const style = getComputedStyle(grid); + const rowHeight = Number.parseFloat(style.gridAutoRows); + if (!Number.isFinite(rowHeight)) return undefined; + const tile = grid.firstElementChild; + const columns = tile + ? Number.parseInt(getComputedStyle(tile).getPropertyValue("--sidebar-pinned-columns"), 10) + : 1; + const px = (value: string) => (Number.parseFloat(value) || 0) * scale; + return { + width: + grid.getBoundingClientRect().width || + (grid.parentElement?.getBoundingClientRect().width ?? 0) - + px(style.marginLeft) - + px(style.marginRight), + labelHeight, + columns: Number.isFinite(columns) && columns > 0 ? columns : 1, + rowHeight: rowHeight * scale, + gap: px(style.rowGap), + leading: px(style.marginTop) + px(style.borderTopWidth) + px(style.paddingTop), + trailing: px(style.paddingBottom) + px(style.borderBottomWidth) + px(style.marginBottom), + }; +} + export function createSidebarSortingStrategy(input: { items: readonly SidebarListItem[]; enabled?: boolean; @@ -101,6 +227,8 @@ export function createSidebarSortingStrategy(input: { cardHeight?: number; slimHeight?: number; boundaryLabelHeight?: number; + /** Omitted, pinned rows stack like cards: one column at their own height. */ + pinnedGrid?: SidebarPinnedGrid | undefined; }): SortingStrategy { if (input.enabled === false) return () => stationary; const { items } = input; @@ -123,6 +251,7 @@ export function createSidebarSortingStrategy(input: { }; let cardHeight = input.cardHeight; let slimHeight = input.slimHeight; + let pinnedHeight: number | undefined; let headerScale: number | undefined; for (const [index, item] of items.entries()) { if (item.kind === "marker") { @@ -132,11 +261,13 @@ export function createSidebarSortingStrategy(input: { } continue; } - if (item.section === "pinned" || item.section === "active" || item.section === "working") + if (item.section === "pinned") pinnedHeight ??= rects[index]?.height; + else if (item.section === "active" || item.section === "working") cardHeight ??= rects[index]?.height; else slimHeight ??= rects[index]?.height; if (item.key !== active.key) groups[item.section].push(item); } + if (!input.pinnedGrid) cardHeight ??= pinnedHeight; const scale = slimHeight !== undefined ? slimHeight / 36 : (headerScale ?? (cardHeight ?? 82) / 82); cardHeight ??= 82 * scale; @@ -173,7 +304,6 @@ export function createSidebarSortingStrategy(input: { else marker(`${name}-placeholder`); }; marker("pinned-header"); - projected.push(...groups.pinned); marker("pinned-divider"); section("active"); if (items.some((item) => item.kind === "marker" && item.marker === "working-header")) { @@ -190,12 +320,40 @@ export function createSidebarSortingStrategy(input: { } marker("settled-header"); section("settled"); + const pinnedGrid = input.pinnedGrid ?? { + columns: 1, + width: rects[0].width, + labelHeight: 0, + rowHeight: pinnedHeight ?? cardHeight, + gap: 1, + leading: 0, + trailing: 0, + }; + // An outgoing pin leaves its cell; an incoming pin reserves its destination row. + const restingPins = items.flatMap((item, index) => + item.kind === "thread" && item.section === "pinned" ? [index] : [], + ); + const pinnedRows = Math.ceil( + Math.max( + restingPins.length, + target.section === "pinned" ? groups.pinned.length : 0, + input.pinnedGrid && restingPins.length === 0 ? 1 : 0, + ) / pinnedGrid.columns, + ); + // The grid hides when empty, so it adds no list gap either. + const pinnedBlock = + pinnedRows === 0 + ? 0 + : pinnedGrid.leading + + pinnedRows * pinnedGrid.rowHeight + + (pinnedRows - 1) * pinnedGrid.gap + + pinnedGrid.trailing + + 1; const heights = projected.map((item) => { const index = indices.get(sidebarListItemId(item)); const rect = index === undefined ? undefined : rects[index]; const fallback = - item.kind === "thread" && - (item.section === "pinned" || item.section === "active" || item.section === "working") + item.kind === "thread" && (item.section === "active" || item.section === "working") ? cardHeight : slimHeight; const moved = item.kind === "thread" && item.key === active.key; @@ -216,12 +374,16 @@ export function createSidebarSortingStrategy(input: { shelfRect && beforeShelf && lastRect && shelfRect.top > beforeShelf.bottom + 1 ? Math.max( 0, - lastRect.bottom - rects[0].top - heights.reduce((sum, height) => sum + height + 1, -1), + lastRect.bottom - + rects[0].top - + heights.reduce((sum, height) => sum + height + 1, -1) - + pinnedBlock, ) : 0; const result = items.map(() => hidden); + const firstPin = rects[restingPins[0] ?? -1]; let top = rects[0].top; - for (const [projectedIndex, item] of projected.entries()) { + for (const [flowIndex, item] of projected.entries()) { if (isShelfHeader(item)) { top += shelfSpace; shelfSpace = 0; @@ -229,7 +391,30 @@ export function createSidebarSortingStrategy(input: { const index = indices.get(sidebarListItemId(item)); const rect = index === undefined ? undefined : rects[index]; if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top }; - top += heights[projectedIndex]! + 1; + top += heights[flowIndex]! + 1; + if (item.kind === "marker" && item.marker === "pinned-header") { + const y = firstPin ? top + pinnedGrid.leading - firstPin.top : 0; + for (const pin of restingPins) result[pin] = { ...stationary, y }; + if (input.pinnedGrid && target.section === "pinned") { + for (const [position, pin] of groups.pinned.entries()) { + const pinIndex = indices.get(pin.key); + if (pinIndex === undefined || pinIndex === activeIndex) continue; + const from = sidebarPinnedGridCell( + input.pinnedGrid, + restingPins.indexOf(pinIndex), + restingPins.length, + ); + const to = sidebarPinnedGridCell(input.pinnedGrid, position, groups.pinned.length); + result[pinIndex] = { + ...stationary, + x: to.left - from.left, + y: y + to.top - from.top, + scaleX: to.width / from.width, + }; + } + } + top += pinnedBlock; + } } result[activeIndex] = stationary; return result; diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index fbc0d8434a82b..7302dbdf8e3b1 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,9 +1,22 @@ const motionTiming = { duration: 150, easing: "ease-out" }; +// A drop lands every reordered row in one move; longer than list motion so a cross-row path reads. +const settleTiming = { duration: 200, easing: "cubic-bezier(0.23, 1, 0.32, 1)" }; +const SETTLING_ATTRIBUTE = "data-sidebar-settling"; +const LEAD_ATTRIBUTE = "data-sidebar-motion-lead"; +const MOVING_ATTRIBUTE = "data-sidebar-motion-moving"; +/** Matches a list that is dragging (Sidebar sets the attribute) or landing a drop. */ +export const SIDEBAR_SORTING_SELECTOR = `[data-sidebar-dragging], [${SETTLING_ATTRIBUTE}]`; const rowTravel = (height: number) => Math.min(height, 40); // Fades clone removed rows and animate clones and entries; intervening layout reads force reflows. const MAX_FADED_ROWS_PER_UPDATE = 40; type RowPosition = { top: number; left: number; width: number; height: number }; +const centerDelta = (from: RowPosition, to: RowPosition, listWidth: number) => ({ + x: Math.max(0, Math.min(from.left + (from.width - to.width) / 2, listWidth - to.width)) - to.left, + y: from.top + from.height / 2 - (to.top + to.height / 2), +}); +// Pinning or unpinning remounts a row as a tile or card; its thread key carries it across. +const threadKeyOf = (node: HTMLElement) => node.dataset.threadItem; function progress(animation: Animation) { return animation.playState === "finished" @@ -11,17 +24,44 @@ function progress(animation: Animation) { : (animation.effect?.getComputedTiming().progress ?? 0); } -/** Position the list so all direct children's offsetTop share an origin. */ +/** Animates the list's direct children, and the children of any `data-sidebar-motion-group`. */ export function createSidebarListMotion(parent: HTMLUListElement) { let positions: Map | null = null; + let previousWidth: number | null = null; let disposed = false; const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( "(prefers-reduced-motion: reduce)", ); - const running = new Map(); + const running = new Map(); const entering = new Map(); const exiting = new Map(); - let released: Map | null = null; + let released: { + nodes: Map; + threads: Map; + activeKey: string | undefined; + } | null = null; + let settleGeneration = 0; + + const rows = () => + Array.from(parent.children) + .flatMap((node) => + node instanceof HTMLElement && node.hasAttribute("data-sidebar-motion-group") + ? Array.from(node.children) + : [node], + ) + .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)); + // Grouped rows may sit in a nested offsetParent; measure every row from the list. + const offsetInParent = (node: HTMLElement) => { + let top = 0; + let left = 0; + let current: Element | null = node; + while (current instanceof HTMLElement && current !== parent) { + top += current.offsetTop; + left += current.offsetLeft; + current = current.offsetParent; + } + return { top, left }; + }; const remainingOffset = (node: HTMLElement) => { const current = running.get(node); @@ -30,6 +70,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0; return run + enter; }; + const remainingOffsetX = (node: HTMLElement) => { + const current = running.get(node); + return current ? current.offsetX * (1 - progress(current.animation)) : 0; + }; const clearFades = () => { for (const entry of entering.values()) entry.animation.cancel(); for (const animation of exiting.values()) animation.cancel(); @@ -57,7 +101,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { Object.assign(clone.style, { position: "absolute", top: `${position.top + remainingOffset(node)}px`, - left: `${position.left}px`, + left: `${position.left + remainingOffsetX(node)}px`, width: `${position.width}px`, height: `${position.height}px`, margin: "0", @@ -91,47 +135,61 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const cancel = (node: HTMLElement) => { running.get(node)?.animation.cancel(); running.delete(node); + node.removeAttribute(LEAD_ATTRIBUTE); + node.removeAttribute(MOVING_ATTRIBUTE); }; const suspend = () => { for (const node of running.keys()) cancel(node); clearFades(); positions = null; released = null; + settleGeneration++; + parent.removeAttribute(SETTLING_ATTRIBUTE); }; - const move = (node: HTMLElement, offset: number) => { + const move = ( + node: HTMLElement, + offset: number, + offsetX: number, + timing = motionTiming, + lead = false, + ) => { + const isLead = lead || node.hasAttribute(LEAD_ATTRIBUTE); cancel(node); const entry = entering.get(node); if (entry) entry.travel = 0; - if (offset === 0 && !entry) return; + if (offset === 0 && offsetX === 0 && !entry) return undefined; + if (isLead) node.setAttribute(LEAD_ATTRIBUTE, ""); + node.setAttribute(MOVING_ATTRIBUTE, ""); const animation = node.animate( - [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], - motionTiming, + [{ transform: `translate(${offsetX}px, ${offset}px)` }, { transform: "translate(0px, 0px)" }], + timing, ); - running.set(node, { animation, offset }); + running.set(node, { animation, offset, offsetX }); animation.addEventListener( "finish", () => { - if (running.get(node)?.animation === animation) running.delete(node); + if (running.get(node)?.animation === animation) { + running.delete(node); + node.removeAttribute(LEAD_ATTRIBUTE); + node.removeAttribute(MOVING_ATTRIBUTE); + } }, { once: true }, ); + return animation; }; return { update(animate: boolean) { if (disposed) return; + const width = parent.clientWidth; + const widthUnchanged = previousWidth === width; + previousWidth = width; 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, + { ...offsetInParent(node), width: node.offsetWidth, height: node.offsetHeight }, + ]), ); let fadeCount = 0; if (positions !== null) { @@ -144,11 +202,16 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } const shouldAnimate = animate && + widthUnchanged && positions !== null && !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; const movedDelta = new Map(); + const movedDeltaX = new Map(); + const remounted = new Map(); const nextOrder = [...next.keys()]; + const nextThreadKeys = new Set(nextOrder.map(threadKeyOf)); + const landing: Animation[] = []; const oldOrder = positions === null ? [] : [...positions.keys()]; const ridingDelta = ( order: readonly HTMLElement[], @@ -166,12 +229,30 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (!shouldAnimate) clearFades(); else { for (const [node, position] of next) { - const previousTop = positions!.get(node)?.top; - if (previousTop === undefined || previousTop === position.top) continue; - movedDelta.set(node, previousTop + remainingOffset(node) - position.top); + const previous = positions!.get(node); + if ( + previous === undefined || + (previous.top === position.top && previous.left === position.left) + ) + continue; + movedDelta.set(node, previous.top + remainingOffset(node) - position.top); + movedDeltaX.set( + node, + Math.max(0, Math.min(previous.left + remainingOffsetX(node), width - position.width)) - + position.left, + ); } for (const [node, position] of positions!) { if (next.has(node)) continue; + const key = threadKeyOf(node); + if (key !== undefined && nextThreadKeys.has(key)) { + remounted.set(key, { + ...position, + top: position.top + remainingOffset(node), + left: position.left + remainingOffsetX(node), + }); + continue; + } const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n)); fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta); } @@ -190,7 +271,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const position = next.get(node)!; const previousTop = positions!.get(node)?.top; if (previousTop === undefined) { - if (position.height > 0) { + const key = threadKeyOf(node); + const from = key === undefined ? undefined : remounted.get(key); + if (from !== undefined && position.height > 0) { + const delta = centerDelta(from, position, width); + const animation = move(node, delta.y, delta.x, motionTiming, true); + if (animation) landing.push(animation); + } else if (position.height > 0) { const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n)); const travel = delta === undefined ? -rowTravel(position.height) : delta; const animation = node.animate( @@ -214,28 +301,57 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const delta = movedDelta.get(node); // Computed progress includes easing; carry forward only our translation, never dnd-kit's // transforms. - if (delta !== undefined) move(node, delta); + if (delta !== undefined) move(node, delta, movedDeltaX.get(node) ?? 0); } } if (released !== null) { if (!reducedMotion?.matches) { for (const [node, position] of next) { - const top = released.get(node); - if (top !== undefined) move(node, top - position.top); + const key = threadKeyOf(node); + const origin = + released.nodes.get(node) ?? + (key === undefined ? undefined : released.threads.get(key)); + if (origin === undefined) continue; + const delta = centerDelta(origin, position, width); + const animation = move( + node, + delta.y, + delta.x, + settleTiming, + key !== undefined && key === released.activeKey, + ); + if (animation) landing.push(animation); } } released = null; } + if (landing.length > 0) { + const generation = ++settleGeneration; + parent.setAttribute(SETTLING_ATTRIBUTE, ""); + void Promise.allSettled(landing.map((animation) => animation.finished)).then(() => { + if (generation === settleGeneration) parent.removeAttribute(SETTLING_ATTRIBUTE); + }); + } positions = next; }, - release() { + release(activeKey?: string) { suspend(); - const origin = parent.getBoundingClientRect().top; - 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]), - ); + const origin = parent.getBoundingClientRect(); + const nodes = new Map(); + const threads = new Map(); + for (const node of rows()) { + const rect = node.getBoundingClientRect(); + const position = { + top: rect.top - origin.top, + left: rect.left - origin.left, + width: rect.width, + height: rect.height, + }; + nodes.set(node, position); + const key = threadKeyOf(node); + if (key !== undefined) threads.set(key, position); + } + released = { nodes, threads, activeKey }; }, suspend, dispose() { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75b225a8b4351..0e40f338dafae 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -24,7 +24,7 @@ import { type Modifier, } from "@dnd-kit/core"; import { SortableContext } from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { canSnooze, effectiveSnoozed, @@ -217,8 +217,13 @@ import { } from "./Sidebar.logic"; import { createSidebarCollisionDetection, + measureSidebarPinnedGrid, + sidebarPinnedGridCell, + type SidebarPinnedGrid, createSidebarSortingStrategy, restrictBelowSidebarLabel, + restrictSidebarDrag, + resolveSidebarDragSourceRect, } from "./Sidebar.drag"; import { SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; @@ -1054,6 +1059,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve readonly activationY: number | null; readonly targetSection: SidebarSection | null; readonly contextDrag: boolean; + readonly pinnedGrid: SidebarPinnedGrid | undefined; + readonly pinnedTargetIndex: number | null; + readonly grabOffsetX: number; + readonly sourceWidth: number; } | null>(null); const dragSensorRef = useRef(null); const contextDragKeyRef = useRef(null); @@ -1680,12 +1689,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve })(); }; const restrictBelowPins: Modifier = (args) => - restrictBelowSidebarLabel(args, dragLabelOffsetRef.current); - const dndModifiers = [ - restrictToVerticalAxis, - restrictBelowPins, - restrictToFirstScrollableAncestor, - ]; + restrictBelowSidebarLabel( + args, + dragLabelOffsetRef.current, + threadListRef.current?.getBoundingClientRect().top, + ); const attachListMotionRef = (node: HTMLUListElement) => { threadListRef.current = node; const motion = createSidebarListMotion(node); @@ -1718,12 +1726,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve dragState?.activeSection === "working"; const dragTargetSection = isContextDrag ? null : (dragState?.targetSection ?? null); const finishThreadDrag = (started: boolean) => { + const activeKey = contextDragKeyRef.current ?? undefined; dragSensorRef.current = null; contextDragKeyRef.current = null; dragScrollRef.current = null; endThreadContextDrag(); if (started) { - listMotionRef.current?.release(); + listMotionRef.current?.release(activeKey); setDragState(null); } }; @@ -1903,9 +1912,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const viewport = list?.closest('[data-slot="scroll-area-viewport"]'); dragScrollRef.current = viewport ? { viewport, top: viewport.scrollTop } : null; const header = list?.querySelector('[data-testid="sidebar-pinned-header"]'); - if (list && header) { - const listRect = list.getBoundingClientRect(); - const scale = list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1; + const grid = list?.querySelector('[data-testid="sidebar-pinned-grid"]'); + const listRect = list?.getBoundingClientRect(); + const sourceRect = resolveSidebarDragSourceRect( + event.activatorEvent, + event.active.rect.current.initial, + ); + const scale = list && listRect && list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1; + if (listRect && header) { dragLabelOffsetRef.current = header.getBoundingClientRect().top - listRect.top + SIDEBAR_DRAG_LABEL_HEIGHT * scale; } else { @@ -1916,6 +1930,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeSection, targetSection: activeSection, contextDrag: false, + pinnedGrid: grid + ? measureSidebarPinnedGrid(grid, scale, SIDEBAR_DRAG_LABEL_HEIGHT * scale) + : undefined, + pinnedTargetIndex: activeSection === "pinned" ? pinnedKeys.indexOf(activeKey) : null, + grabOffsetX: + event.activatorEvent instanceof PointerEvent + ? event.activatorEvent.clientX - (sourceRect?.left ?? event.activatorEvent.clientX) + : 0, + sourceWidth: sourceRect?.width ?? listRect?.width ?? 0, occurredAt: new Date().toISOString(), activationY: event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, @@ -2014,10 +2037,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const target = event.over ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) : null; + const pinnedTargetIndex = + target?.section === "pinned" ? target.pinnedOrder.indexOf(String(event.active.id)) : null; setDragState((current) => - current === null || current.activeKey !== String(event.active.id) + current === null || + current.activeKey !== String(event.active.id) || + (current.targetSection === (target?.section ?? null) && + current.pinnedTargetIndex === pinnedTargetIndex) ? current - : { ...current, targetSection: target?.section ?? null }, + : { ...current, targetSection: target?.section ?? null, pinnedTargetIndex }, ); }; const sortableIds = sidebarListItems.map(sidebarListItemId); @@ -2042,10 +2070,33 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve settledVisibleCount, routeThreadKey: routeRowThreadKey, snoozedThreadCount: snoozedThreads.length, + pinnedGrid: dragState?.pinnedGrid, }); const draggedThreadKey = dragState?.activeKey; const draggedFromSection = dragState?.activeSection; const dragActivationY = dragState?.activationY; + const pinnedDragPreview = (() => { + const grid = dragState?.pinnedGrid; + if (!grid || dragState.targetSection !== "pinned" || dragState.pinnedTargetIndex === null) + return undefined; + const from = pinnedKeys.indexOf(dragState.activeKey); + if (dragState.pinnedTargetIndex < 0) return undefined; + const sourceWidth = + from < 0 ? dragState.sourceWidth : sidebarPinnedGridCell(grid, from, pinnedKeys.length).width; + if (sourceWidth <= 0) return undefined; + const width = sidebarPinnedGridCell( + grid, + dragState.pinnedTargetIndex, + pinnedKeys.length + (from < 0 ? 1 : 0), + ).width; + const widthRatio = width / sourceWidth; + return { width, widthRatio, offsetX: dragState.grabOffsetX * (1 - widthRatio) }; + })(); + const restrictPreview: Modifier = (args) => restrictSidebarDrag(args, pinnedDragPreview); + const dndModifiers = + dragState?.activeSection === "pinned" + ? [restrictBelowPins, restrictPreview] + : [restrictToVerticalAxis, restrictBelowPins, restrictPreview]; const dndCollisionDetection = (() => { if (draggedThreadKey === undefined || draggedFromSection === undefined) return createSidebarCollisionDetection(() => true); @@ -2076,6 +2127,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve { items: sidebarListItems, activationY: dragActivationY ?? null, + pinnedGrid: dragState?.pinnedGrid, }, ); })(); @@ -3327,6 +3379,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
    , ]; + const pinnedTiles = sidebarListItems.flatMap((item) => + item.kind === "thread" && item.section === "pinned" + ? [renderThreadRow(threadByKey.get(item.key)!, item.section)] + : [], + ); for (const item of sidebarListItems) { if (item.kind === "thread") { - // Folders draw active rows in their own place below. - if (projectFolders !== null && item.section === "active") continue; + // Pins render in their grid; folders draw active rows in their own place below. + if ( + item.section === "pinned" || + (projectFolders !== null && item.section === "active") + ) + continue; items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); continue; } @@ -3517,13 +3584,25 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); break; case "pinned-divider": + // Stays mounted while empty so a drag can measure the grid it would open. items.push( +
      + {pinnedTiles} +
    , 0} />, ); break; diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index da9444a5c1b24..eaeec5786a7c7 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -80,6 +80,8 @@ export function SidebarDragBoundary(props: { label: string; visible: boolean; isDropTarget: boolean; + /** A resting hairline, drawn in the space the pinned grid leaves above it. */ + rule?: boolean; }) { return ( + {props.rule && !props.visible ? ( + + ) : null} {props.visible ? (
    ; +// A drag and its drop settle hold hover details, so a popup never covers where a row lands. +function holdDetailsWhileSorting(open: boolean, details: { event: Event; cancel: () => void }) { + const target = details.event?.target; + if (open && target instanceof Element && target.closest(SIDEBAR_SORTING_SELECTOR)) { + details.cancel(); + } +} + export const draftSurfaceClassName = "bg-warning/4 hover:bg-warning/8"; export const draftPenClassName = "size-3 shrink-0 text-warning-foreground"; @@ -471,7 +480,14 @@ export function SortableThreadRow( const bag = { listeners, setNodeRef, - transform: contextDrag ? null : transform, + transform: contextDrag + ? null + : transform && isDragging + ? { + ...transform, + scaleX: rowProps.pinnedDragPreview?.widthRatio ?? 1, + } + : transform, transition: contextDrag && isDragging ? "transform 150ms ease-out" : transition, isDragging: isDragging && !contextDrag, }; @@ -536,8 +552,9 @@ function TopStatusIcon(props: { icon: keyof typeof TOP_STATUS_ICONS; className: type SidebarThreadRowProps = { thread: SidebarThreadSummary; // "folder" is a one-line row inside a boring sidebar project folder; - // "folder-multiline" adds the card's branch and PR line under it. - variant: "card" | "slim" | "folder" | "folder-multiline"; + // "folder-multiline" adds the card's branch and PR line under it; + // "tile" is a compact cell of the pinned grid. + variant: "card" | "slim" | "folder" | "folder-multiline" | "tile"; navigationParent?: string | undefined; variantAction: SidebarSweepAction; settlementSupported: boolean; @@ -545,6 +562,7 @@ type SidebarThreadRowProps = { pinningSupported: boolean; isPinned: boolean; sortable?: SortableThreadRowBag | undefined; + pinnedDragPreview?: { readonly widthRatio: number; readonly offsetX: number } | undefined; dropVerb: SidebarDropVerb | null; dragOverPinned: boolean; sweepAction: SidebarSweepAction | null; @@ -980,9 +998,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const sortableRootProps = sortable ? { ref: sortable.setNodeRef, + "data-sidebar-drag-source": sortable.isDragging ? "" : undefined, style: { transform: CSS.Translate.toString(sortable.transform), - transition: sortable.transition, + transition: + variant === "tile" && sortable.transition + ? `${sortable.transition}, ${sortable.transition.replace("transform", "width")}` + : sortable.transition, + width: variant === "tile" ? `${(sortable.transform?.scaleX ?? 1) * 100}%` : undefined, // A zero-height boundary also makes dnd-kit scale the source to // zero. Only projected peers use scaleY as a visibility sentinel. visibility: @@ -1200,6 +1223,110 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null; + if (variant === "tile") { + return ( +
  • + + + } + > + {accessibleTitle} +
    + {props.project ? ( + + ) : null} + {draftIndicator} + {props.projectDisplayName ? ( + + {props.projectDisplayName} + + ) : null} + {pinIndicator ? ( + + {pinIndicator} + + ) : null} + {sortable?.isDragging ? ( + dragDestination + ) : jumpHintLabel ? ( + + + + ) : topStatus ? ( + + + {topStatus.label} + + ) : ( + + + + )} +
    +
    + {isRenaming ? ( + title + ) : ( + + {thread.title} + + )} +
    + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
    + {detailsTooltip} +
    +
  • + ); + } + if (variant !== "card") { const multiline = variant === "folder-multiline"; const inFolder = variant === "folder" || multiline; @@ -1214,7 +1341,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP sortable?.isDragging && "relative z-20", )} > - + - + [data-sidebar-motion-moving], > [data-sidebar-drag-source]) { + z-index: 20; + } + [data-sidebar-motion-lead] { + position: relative; + z-index: 20; + } + [data-sidebar-motion-moving] [data-sidebar-row] { + background-color: var(--sidebar); + opacity: 1; + } + .sidebar-pinned-grid:has(> [data-sidebar-drag-source]) + > :not([data-sidebar-drag-source]) + [data-sidebar-row] { + background-color: var(--sidebar); + } + .sidebar-pinned-grid > * { + --sidebar-pinned-columns: 1; + grid-column: span 6; + } + @container sidebar-pinned (10rem <= width < 14rem) { + .sidebar-pinned-grid > * { + --sidebar-pinned-columns: 2; + grid-column: span 3; + } + .sidebar-pinned-grid > :nth-child(2n + 1):last-child { + grid-column: span 6; + } + } + @container sidebar-pinned (width >= 14rem) { + .sidebar-pinned-grid > * { + --sidebar-pinned-columns: 3; + grid-column: span 2; + } + .sidebar-pinned-grid > :nth-child(3n + 1):last-child { + grid-column: span 6; + } + .sidebar-pinned-grid > :nth-child(3n + 1):nth-last-child(2), + .sidebar-pinned-grid > :nth-child(3n + 1):nth-last-child(2) + * { + grid-column: span 3; + } + } } /* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..f71b70608e342 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -84,8 +84,9 @@ list to unpin it. Dragging a thread onto the **Settled** header settles it, and thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation. -Pinned and active boundary labels appear only while dragging, without moving the rows. The -other rows slide aside to show where the thread will land. When you cross into another section, +Pinned and active boundary labels appear only while dragging, without moving the rows. List +rows and pinned tiles slide aside to show where the thread will land. A pinned tile takes the +width of its destination while you drag it. When you cross into another section, the dragged thread shows the action the drop performs, with its icon: **Pin**, **Unpin**, **Settle**, **Un-settle**, or **Wake**. Its status and hover actions hide during the drag. A pinned thread keeps its pin only while it stays in the pinned section; once it leaves, the badge takes @@ -94,8 +95,8 @@ edge to pin a thread. Section labels stay readable for the whole drag, and the s thread is over takes the accent color. Section labels also identify empty sections and a collapsed settled shelf. -Drag within the pinned or active section to change its order. Other rows slide aside to show the -spot where the thread will land. Drops into either section keep the position you choose. On +Drag within the pinned or active section to change its order. Drops into either section keep +the position you choose. On mobile, open a thread's menu and choose **Arrange threads**. Drag a handle within or between **Pinned** and **Active** to reorder, pin, or unpin. Drop onto the **Settled** divider to settle a thread. The dragged card shows the action before you release it. Expand **Snoozed**