| author | |
| committer | |
| log | 4e969f3731aaa590889d74274b3b6dced6d86291 |
| tree | 16c7e578405b586c1dfb07e6aaafb90c266006ca |
| parent | ecf3716fd190005b1552957b3c7880ab849a0a65 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 704 insertions(+), 142 deletions(-)
apps/web/src/components/Sidebar.drag.test.ts+174-69| ... | ... | @@ -1,8 +1,13 @@ |
| 1 | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; |
| 3 | 3 | import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; |
| 4 | import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag"; | |
| 5 | 4 | import { |
| 5 | createSidebarCollisionDetection, | |
| 6 | createSidebarSortingStrategy, | |
| 7 | restrictBelowSidebarLabel, | |
| 8 | } from "./Sidebar.drag"; | |
| 9 | import { | |
| 10 | resolveSidebarDropTarget, | |
| 6 | 11 | sidebarListItemId, |
| 7 | 12 | sidebarMarkerId, |
| 8 | 13 | type SidebarListItem, |
| ... | ... | @@ -35,7 +40,7 @@ function layout( |
| 35 | 40 | ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale |
| 36 | 41 | : item.marker === "pinned-header" || item.marker === "pinned-divider" |
| 37 | 42 | ? 0 |
| 38 | : (item.marker.endsWith("placeholder") ? 36 : 32) * scale; | |
| 43 | : (item.marker.endsWith("placeholder") ? 0 : 32) * scale; | |
| 39 | 44 | const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 }; |
| 40 | 45 | top += height + 1; |
| 41 | 46 | return rect; |
| ... | ... | @@ -119,69 +124,92 @@ describe("sidebar collision detection", () => { |
| 119 | 124 | expect(detector(collisionArgs())[0]?.id).toBe("blocked"); |
| 120 | 125 | }); |
| 121 | 126 | |
| 122 | function clampedArgs() { | |
| 123 | const args = collisionArgs(); | |
| 124 | const pinned = args.droppableRects.get(sidebarMarkerId("pinned-header"))!; | |
| 125 | const source = args.droppableRects.get("source")!; | |
| 126 | const collisionRect = { | |
| 127 | ...source, | |
| 128 | top: pinned.top - 8, | |
| 129 | bottom: pinned.top - 8 + source.height, | |
| 130 | }; | |
| 131 | return { | |
| 132 | ...args, | |
| 133 | active: { | |
| 134 | ...args.active, | |
| 135 | rect: { current: { initial: source, translated: collisionRect } }, | |
| 136 | }, | |
| 137 | collisionRect, | |
| 138 | pointerCoordinates: { x: pinned.left + pinned.width / 2, y: pinned.top + 8 }, | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | it("reaches empty Pins with an upward pointer while the card is clamped at the top", () => { | |
| 143 | const args = clampedArgs(); | |
| 144 | const detector = createSidebarCollisionDetection(() => true, { | |
| 145 | emptyPins: true, | |
| 146 | activationY: args.pointerCoordinates.y + 6, | |
| 147 | }); | |
| 148 | expect(args.droppableRects.get(sidebarMarkerId("pinned-header"))?.height).toBe(0); | |
| 149 | expect(closestCenter(args)[0]?.id).toBe("source"); | |
| 150 | expect(detector(args)[0]?.id).toBe(sidebarMarkerId("pinned-header")); | |
| 151 | }); | |
| 152 | ||
| 153 | 127 | it.each([ |
| 154 | { reason: "below the boundary cue", x: 130, y: 109, activationY: 140, emptyPins: true }, | |
| 155 | { reason: "left of the list", x: -1, y: 108, activationY: 140, emptyPins: true }, | |
| 156 | { reason: "right of the list", x: 261, y: 108, activationY: 140, emptyPins: true }, | |
| 157 | { reason: "less than 6px upward", x: 130, y: 108, activationY: 113, emptyPins: true }, | |
| 158 | { reason: "without an activation point", x: 130, y: 108, activationY: null, emptyPins: true }, | |
| 159 | { reason: "with populated Pins", x: 130, y: 108, activationY: 140, emptyPins: false }, | |
| 160 | ])("keeps ordinary collision behavior $reason", ({ x, y, activationY, emptyPins }) => { | |
| 161 | const detector = createSidebarCollisionDetection(() => true, { emptyPins, activationY }); | |
| 162 | const args = { ...clampedArgs(), pointerCoordinates: { x, y } }; | |
| 163 | expect(detector(args)[0]?.id).toBe("source"); | |
| 164 | }); | |
| 165 | ||
| 166 | it("keeps ordinary collision behavior without pointer coordinates", () => { | |
| 167 | const detector = createSidebarCollisionDetection(() => true, { | |
| 168 | emptyPins: true, | |
| 169 | activationY: 140, | |
| 170 | }); | |
| 171 | expect(detector({ ...clampedArgs(), pointerCoordinates: null })[0]?.id).toBe("source"); | |
| 172 | }); | |
| 173 | ||
| 174 | it("validates the empty Pins override and caches an unsupported result", () => { | |
| 175 | const isValid = vi.fn(() => false); | |
| 176 | const detector = createSidebarCollisionDetection(isValid, { | |
| 177 | emptyPins: true, | |
| 178 | activationY: 140, | |
| 179 | }); | |
| 180 | const args = clampedArgs(); | |
| 181 | expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); | |
| 182 | expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); | |
| 183 | expect(isValid.mock.calls).toEqual([[sidebarMarkerId("pinned-header")]]); | |
| 184 | }); | |
| 128 | { sourceSection: "active", pins: 0 }, | |
| 129 | { sourceSection: "active", pins: 1 }, | |
| 130 | { sourceSection: "pinned", pins: 1 }, | |
| 131 | { sourceSection: "settled", pins: 1 }, | |
| 132 | ] as const)( | |
| 133 | "switches on crossing the divider row from $sourceSection with $pins pins", | |
| 134 | ({ sourceSection, pins }) => { | |
| 135 | const items = [ | |
| 136 | pinnedHeader, | |
| 137 | ...(pins ? [thread("p", "pinned")] : []), | |
| 138 | ...(sourceSection === "pinned" ? [thread("source", "pinned")] : []), | |
| 139 | divider, | |
| 140 | thread("a", "active"), | |
| 141 | ...(sourceSection === "active" ? [thread("source", "active")] : []), | |
| 142 | settledHeader, | |
| 143 | ...(sourceSection === "settled" ? [thread("source", "settled")] : []), | |
| 144 | ]; | |
| 145 | const { rects, activeIndex } = layout(items, "source", "a"); | |
| 146 | const sourceRect = rects[activeIndex]!; | |
| 147 | let boundaryTop = 300; | |
| 148 | const boundaryNode = { | |
| 149 | querySelector: () => ({ | |
| 150 | getBoundingClientRect: () => ({ | |
| 151 | top: boundaryTop, | |
| 152 | bottom: boundaryTop + 16, | |
| 153 | left: 0, | |
| 154 | right: 260, | |
| 155 | }), | |
| 156 | }), | |
| 157 | } as unknown as HTMLElement; | |
| 158 | const detector = createSidebarCollisionDetection(() => true, { | |
| 159 | items, | |
| 160 | activationY: sourceSection === "pinned" ? 200 : 600, | |
| 161 | }); | |
| 162 | const at = (center: number) => { | |
| 163 | const collisionRect = { | |
| 164 | ...sourceRect, | |
| 165 | top: center - sourceRect.height / 2, | |
| 166 | bottom: center + sourceRect.height / 2, | |
| 167 | }; | |
| 168 | const args = { | |
| 169 | ...collisionArgs(), | |
| 170 | active: { | |
| 171 | id: "source", | |
| 172 | data: { current: {} }, | |
| 173 | rect: { current: { initial: sourceRect, translated: collisionRect } }, | |
| 174 | }, | |
| 175 | collisionRect, | |
| 176 | pointerCoordinates: { x: 130, y: center }, | |
| 177 | droppableRects: new Map( | |
| 178 | items.map((item, index) => [sidebarListItemId(item), rects[index]!]), | |
| 179 | ), | |
| 180 | droppableContainers: items.map((item, index) => ({ | |
| 181 | id: sidebarListItemId(item), | |
| 182 | key: sidebarListItemId(item), | |
| 183 | disabled: false, | |
| 184 | data: { current: {} }, | |
| 185 | node: { | |
| 186 | current: | |
| 187 | item === divider | |
| 188 | ? boundaryNode | |
| 189 | : item === settledHeader | |
| 190 | ? ({ getBoundingClientRect: () => ({ top: 600 }) } as unknown as HTMLElement) | |
| 191 | : null, | |
| 192 | }, | |
| 193 | rect: { current: rects[index]! }, | |
| 194 | })), | |
| 195 | }; | |
| 196 | const over = detector(args)[0]; | |
| 197 | return over ? resolveSidebarDropTarget(items, "source", String(over.id))?.section : null; | |
| 198 | }; | |
| 199 | expect(at(330)).toBe("active"); | |
| 200 | expect(at(317)).toBe("active"); | |
| 201 | expect(at(316)).toBe("pinned"); | |
| 202 | // The preview moves the divider; a stationary pointer must not undo the drop target. | |
| 203 | boundaryTop = 400; | |
| 204 | expect(at(316)).toBe("pinned"); | |
| 205 | expect(at(399)).toBe("pinned"); | |
| 206 | expect(at(400)).toBe("active"); | |
| 207 | boundaryTop = 300; | |
| 208 | expect(at(400)).toBe("active"); | |
| 209 | expect(at(317)).toBe("active"); | |
| 210 | expect(at(316)).toBe("pinned"); | |
| 211 | }, | |
| 212 | ); | |
| 185 | 213 | |
| 186 | 214 | it("returns no collision if an unsupported target has no source fallback", () => { |
| 187 | 215 | const args = collisionArgs(); |
| ... | ... | @@ -289,7 +317,7 @@ describe("sidebar drag projection", () => { |
| 289 | 317 | } |
| 290 | 318 | }); |
| 291 | 319 | |
| 292 | it("leaves canonically sorted settled peers in place", () => { | |
| 320 | it("preserves settled order while opening the zero-height Active target", () => { | |
| 293 | 321 | const items = [ |
| 294 | 322 | pinnedHeader, |
| 295 | 323 | divider, |
| ... | ... | @@ -303,7 +331,10 @@ describe("sidebar drag projection", () => { |
| 303 | 331 | "second", |
| 304 | 332 | "first", |
| 305 | 333 | ); |
| 306 | expect([...result.values()]).toEqual(items.map(() => stationary)); | |
| 334 | expect(result.get(sidebarMarkerId("active-placeholder"))).toEqual(stationary); | |
| 335 | expect(result.get(sidebarMarkerId("settled-header"))).toEqual({ ...stationary, y: 36 }); | |
| 336 | expect(result.get("first")).toEqual({ ...stationary, y: 36 }); | |
| 337 | expect(result.get("second")).toEqual(stationary); | |
| 307 | 338 | }); |
| 308 | 339 | |
| 309 | 340 | it.each([ |
| ... | ... | @@ -357,6 +388,36 @@ describe("sidebar drag projection", () => { |
| 357 | 388 | expect(result.get("s")?.y).toBe(32); |
| 358 | 389 | }); |
| 359 | 390 | |
| 391 | it.each(["s1", "missing-target"])( | |
| 392 | "keeps label clearance when a settled drag is over %s", | |
| 393 | (over) => { | |
| 394 | const items = [ | |
| 395 | pinnedHeader, | |
| 396 | thread("p", "pinned"), | |
| 397 | divider, | |
| 398 | thread("a", "active"), | |
| 399 | settledHeader, | |
| 400 | thread("s1", "settled"), | |
| 401 | thread("s2", "settled"), | |
| 402 | ]; | |
| 403 | const result = preview( | |
| 404 | { | |
| 405 | items, | |
| 406 | settledOrder: ["s1", "s2"], | |
| 407 | settledExpanded: true, | |
| 408 | boundaryLabelHeight: 24, | |
| 409 | }, | |
| 410 | "s2", | |
| 411 | over, | |
| 412 | ); | |
| 413 | expect(result.get("p")?.y).toBe(24); | |
| 414 | expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(24); | |
| 415 | expect(result.get("a")?.y).toBe(48); | |
| 416 | expect(result.get("s1")?.y).toBe(48); | |
| 417 | expect(result.get("s2")).toEqual(stationary); | |
| 418 | }, | |
| 419 | ); | |
| 420 | ||
| 360 | 421 | it("stacks the labels with their gaps when the pinned section is empty", () => { |
| 361 | 422 | const items = [ |
| 362 | 423 | pinnedHeader, |
| ... | ... | @@ -409,8 +470,8 @@ describe("sidebar drag projection", () => { |
| 409 | 470 | }); |
| 410 | 471 | |
| 411 | 472 | it.each([ |
| 412 | ["p", -83, -37], | |
| 413 | ["s", 0, 46], | |
| 473 | ["p", -83, -1], | |
| 474 | ["s", 0, 82], | |
| 414 | 475 | ] as const)( |
| 415 | 476 | "replaces the empty Active target when %s enters", |
| 416 | 477 | (active, dividerOffset, settledOffset) => { |
| ... | ... | @@ -561,7 +622,7 @@ describe("sidebar drag projection", () => { |
| 561 | 622 | expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5); |
| 562 | 623 | }); |
| 563 | 624 | |
| 564 | it("uses measured placeholder sizing when card height differs from its default", () => { | |
| 625 | it("uses shelf height for empty target sizing when card height differs from its default", () => { | |
| 565 | 626 | const items = [ |
| 566 | 627 | pinnedHeader, |
| 567 | 628 | thread("p", "pinned"), |
| ... | ... | @@ -674,3 +735,47 @@ describe("sidebar drag projection", () => { |
| 674 | 735 | expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46); |
| 675 | 736 | }); |
| 676 | 737 | }); |
| 738 | ||
| 739 | describe("lifted card clearance", () => { | |
| 740 | const rect = (top: number, height: number) => ({ | |
| 741 | top, | |
| 742 | bottom: top + height, | |
| 743 | height, | |
| 744 | left: 0, | |
| 745 | right: 260, | |
| 746 | width: 260, | |
| 747 | }); | |
| 748 | const apply = (cardTop: number, cardHeight: number, y: number, listTop = 136, offset = 32) => | |
| 749 | restrictBelowSidebarLabel( | |
| 750 | { | |
| 751 | transform: { ...stationary, y }, | |
| 752 | containerNodeRect: rect(listTop, 500), | |
| 753 | draggingNodeRect: rect(cardTop, cardHeight), | |
| 754 | activatorEvent: null, | |
| 755 | active: null, | |
| 756 | activeNodeRect: null, | |
| 757 | over: null, | |
| 758 | overlayNodeRect: null, | |
| 759 | scrollableAncestors: [], | |
| 760 | scrollableAncestorRects: [], | |
| 761 | windowRect: null, | |
| 762 | }, | |
| 763 | offset, | |
| 764 | ); | |
| 765 | ||
| 766 | it.each([36, 82])("keeps a %ipx row below empty Pins even past the top edge", (height) => { | |
| 767 | for (const pointerY of [150, 136, 100, 0]) { | |
| 768 | const transform = apply(511, height, pointerY - 529); | |
| 769 | expect(511 + transform.y).toBe(168); | |
| 770 | } | |
| 771 | }); | |
| 772 | ||
| 773 | it("preserves pointer movement below the label", () => { | |
| 774 | expect(apply(511, 36, -200).y).toBe(-200); | |
| 775 | }); | |
| 776 | ||
| 777 | it("follows the list when it scrolls and includes content preceding Pins", () => { | |
| 778 | expect(511 + apply(511, 36, -500, 96).y).toBe(128); | |
| 779 | expect(511 + apply(511, 36, -500, 136, 114).y).toBe(250); | |
| 780 | }); | |
| 781 | }); |
apps/web/src/components/Sidebar.drag.ts+73-29| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; | |
| 1 | import { closestCenter, type CollisionDetection, type Modifier } from "@dnd-kit/core"; | |
| 2 | 2 | import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; |
| 3 | 3 | import { |
| 4 | 4 | resolveSidebarDropTarget, |
| ... | ... | @@ -14,36 +14,77 @@ const hidden = { ...stationary, scaleY: 0 }; |
| 14 | 14 | type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>; |
| 15 | 15 | type Layout = Parameters<SortingStrategy>[0]; |
| 16 | 16 | |
| 17 | /** Keep the lifted card below the Pins label, including when Pins is empty. | |
| 18 | * The container rect follows scrolling; the offset is measured once at pickup. */ | |
| 19 | export function restrictBelowSidebarLabel( | |
| 20 | { transform, containerNodeRect, draggingNodeRect }: Parameters<Modifier>[0], | |
| 21 | offset: number, | |
| 22 | ) { | |
| 23 | if (!containerNodeRect || !draggingNodeRect) return transform; | |
| 24 | const minimumY = containerNodeRect.top + offset - draggingNodeRect.top; | |
| 25 | return transform.y < minimumY ? { ...transform, y: minimumY } : transform; | |
| 26 | } | |
| 27 | ||
| 17 | 28 | /** Reject the nearest unsupported target without selecting another section. |
| 18 | 29 | * Recreate this detector when drop eligibility changes. */ |
| 19 | 30 | export function createSidebarCollisionDetection( |
| 20 | 31 | isValidTarget: (id: string) => boolean, |
| 21 | options: { emptyPins?: boolean; activationY?: number | null } = {}, | |
| 32 | options: { | |
| 33 | items?: readonly SidebarListItem[]; | |
| 34 | activationY?: number | null; | |
| 35 | } = {}, | |
| 22 | 36 | ): CollisionDetection { |
| 23 | 37 | const validity = new Map<string, boolean>(); |
| 24 | const pinnedHeaderId = sidebarMarkerId("pinned-header"); | |
| 38 | const sections = new Map<string, SidebarSection | null>(); | |
| 39 | let previousPointerY = options.activationY; | |
| 40 | let boundarySection: "pinned" | "active" | undefined; | |
| 25 | 41 | return (args) => { |
| 26 | 42 | let collisions = closestCenter(args); |
| 27 | const pinnedRect = options.emptyPins ? args.droppableRects.get(pinnedHeaderId) : undefined; | |
| 28 | 43 | const pointer = args.pointerCoordinates; |
| 29 | // The card itself is clamped by the scroll container. An upward pointer | |
| 30 | // gesture can still reach the empty pinned boundary without reserving a row. | |
| 31 | if ( | |
| 32 | pinnedRect && | |
| 33 | pointer && | |
| 34 | options.activationY != null && | |
| 35 | pointer.y <= options.activationY - 6 && | |
| 36 | pointer.y <= pinnedRect.top + 8 && | |
| 37 | pointer.x >= pinnedRect.left && | |
| 38 | pointer.x <= pinnedRect.right | |
| 39 | ) { | |
| 40 | const pinned = collisions.find((collision) => collision.id === pinnedHeaderId); | |
| 41 | if (pinned) { | |
| 42 | collisions = [pinned, ...collisions.filter((collision) => collision !== pinned)]; | |
| 44 | const items = options.items; | |
| 45 | const source = items?.find((item) => item.kind === "thread" && item.key === args.active.id); | |
| 46 | const boundary = args.droppableContainers | |
| 47 | .find((container) => container.id === sidebarMarkerId("pinned-divider")) | |
| 48 | ?.node.current?.querySelector(".sidebar-drag-boundary-label") | |
| 49 | ?.getBoundingClientRect(); | |
| 50 | if (items && boundary && source?.kind === "thread" && pointer) { | |
| 51 | boundarySection ??= source.section === "pinned" ? "pinned" : "active"; | |
| 52 | // Use the visible divider row, including its sortable translation. | |
| 53 | // Only pointer movement can change sections: opening the destination | |
| 54 | // moves this row, but must not toggle a stationary gesture back. | |
| 55 | const previousY = previousPointerY ?? pointer.y; | |
| 56 | previousPointerY = pointer.y; | |
| 57 | if (pointer.x >= boundary.left && pointer.x <= boundary.right) { | |
| 58 | if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned"; | |
| 59 | else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active"; | |
| 60 | const nextHeader = | |
| 61 | args.droppableContainers.find( | |
| 62 | (container) => container.id === sidebarMarkerId("snoozed-header"), | |
| 63 | ) ?? | |
| 64 | args.droppableContainers.find( | |
| 65 | (container) => container.id === sidebarMarkerId("settled-header"), | |
| 66 | ); | |
| 67 | const activeBottom = nextHeader?.node.current?.getBoundingClientRect().top; | |
| 68 | if (boundarySection === "pinned" || (activeBottom != null && pointer.y < activeBottom)) { | |
| 69 | const target = collisions.find((collision) => { | |
| 70 | const id = String(collision.id); | |
| 71 | if (!sections.has(id)) { | |
| 72 | sections.set( | |
| 73 | id, | |
| 74 | resolveSidebarDropTarget(items, String(args.active.id), id)?.section ?? null, | |
| 75 | ); | |
| 76 | } | |
| 77 | return sections.get(id) === boundarySection; | |
| 78 | }); | |
| 79 | if (target) | |
| 80 | collisions = [target, ...collisions.filter((collision) => collision !== target)]; | |
| 81 | } | |
| 43 | 82 | } |
| 44 | 83 | } |
| 45 | 84 | const nearest = collisions[0]; |
| 46 | if (!nearest || nearest.id === args.active.id) return collisions; | |
| 85 | if (!nearest || nearest.id === args.active.id) { | |
| 86 | return collisions; | |
| 87 | } | |
| 47 | 88 | const id = String(nearest.id); |
| 48 | 89 | const valid = validity.get(id) ?? isValidTarget(id); |
| 49 | 90 | validity.set(id, valid); |
| ... | ... | @@ -73,14 +114,10 @@ export function createSidebarSortingStrategy(input: { |
| 73 | 114 | |
| 74 | 115 | function project({ rects, activeIndex, overIndex }: Layout) { |
| 75 | 116 | const active = items[activeIndex]; |
| 76 | const over = items[overIndex]; | |
| 117 | const over = items[overIndex] ?? active; | |
| 77 | 118 | if (active?.kind !== "thread" || !over || !rects[0]) return []; |
| 78 | 119 | const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); |
| 79 | 120 | if (!target) return []; |
| 80 | // Settled keeps time order, so a reorder inside it previews nothing. | |
| 81 | // Every other drag projects so the boundary labels get their space. | |
| 82 | if (target.section === active.section && over.kind === "thread" && target.section === "settled") | |
| 83 | return []; | |
| 84 | 121 | const groups: Record<SidebarSection, ThreadItem[]> = { |
| 85 | 122 | pinned: [], |
| 86 | 123 | active: [], |
| ... | ... | @@ -89,9 +126,13 @@ export function createSidebarSortingStrategy(input: { |
| 89 | 126 | }; |
| 90 | 127 | let cardHeight = input.cardHeight; |
| 91 | 128 | let slimHeight = input.slimHeight; |
| 129 | let headerScale: number | undefined; | |
| 92 | 130 | for (const [index, item] of items.entries()) { |
| 93 | 131 | if (item.kind === "marker") { |
| 94 | if (item.marker.endsWith("placeholder")) slimHeight ??= rects[index]?.height; | |
| 132 | if (item.marker === "settled-header" || item.marker === "snoozed-header") { | |
| 133 | const height = rects[index]?.height; | |
| 134 | if (height) headerScale ??= height / 32; | |
| 135 | } | |
| 95 | 136 | continue; |
| 96 | 137 | } |
| 97 | 138 | if (item.section === "pinned" || item.section === "active") |
| ... | ... | @@ -100,7 +141,8 @@ export function createSidebarSortingStrategy(input: { |
| 100 | 141 | if (item.key !== active.key) groups[item.section].push(item); |
| 101 | 142 | } |
| 102 | 143 | // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9. |
| 103 | const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82; | |
| 144 | const scale = | |
| 145 | slimHeight !== undefined ? slimHeight / 36 : (headerScale ?? (cardHeight ?? 82) / 82); | |
| 104 | 146 | cardHeight ??= 82 * scale; |
| 105 | 147 | slimHeight ??= 36 * scale; |
| 106 | 148 | const labelHeight = (input.boundaryLabelHeight ?? 0) * scale; |
| ... | ... | @@ -163,9 +205,11 @@ export function createSidebarSortingStrategy(input: { |
| 163 | 205 | item.kind === "marker" && |
| 164 | 206 | (item.marker === "pinned-header" || item.marker === "pinned-divider") |
| 165 | 207 | ? labelHeight |
| 166 | : moved | |
| 167 | ? fallback | |
| 168 | : (rect?.height ?? fallback); | |
| 208 | : item.kind === "marker" && item.marker.endsWith("placeholder") | |
| 209 | ? slimHeight | |
| 210 | : moved | |
| 211 | ? fallback | |
| 212 | : (rect?.height ?? fallback); | |
| 169 | 213 | top += height + 1; |
| 170 | 214 | } |
| 171 | 215 | result[activeIndex] = stationary; |
apps/web/src/components/Sidebar.pointer.test.ts created+200| ... | ... | @@ -0,0 +1,200 @@ |
| 1 | import type { SensorProps } from "@dnd-kit/core"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | import { act, createElement, StrictMode } from "react"; | |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 5 | import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; | |
| 6 | ||
| 7 | class TestDocument extends EventTarget { | |
| 8 | hidden = false; | |
| 9 | getSelection = () => ({ removeAllRanges() {} }); | |
| 10 | } | |
| 11 | ||
| 12 | let document: TestDocument; | |
| 13 | let window: EventTarget; | |
| 14 | const sensors: SidebarPointerSensor[] = []; | |
| 15 | ||
| 16 | function pointer(type: string, values: Partial<PointerEvent> = {}) { | |
| 17 | return Object.assign(new Event(type, { cancelable: true }), { | |
| 18 | pointerId: 1, | |
| 19 | clientX: 10, | |
| 20 | clientY: 10, | |
| 21 | buttons: 1, | |
| 22 | ...values, | |
| 23 | }); | |
| 24 | } | |
| 25 | ||
| 26 | function gesture() { | |
| 27 | const callbacks = { | |
| 28 | onStart: vi.fn(), | |
| 29 | onMove: vi.fn(), | |
| 30 | onEnd: vi.fn(), | |
| 31 | onCancel: vi.fn(), | |
| 32 | onAbort: vi.fn(), | |
| 33 | onPending: vi.fn(), | |
| 34 | }; | |
| 35 | const onFinish = vi.fn(); | |
| 36 | // The sensor never reads dnd-kit's layout context or active node. | |
| 37 | const props = { | |
| 38 | active: "thread", | |
| 39 | event: pointer("pointerdown"), | |
| 40 | options: { distance: 6, onAttach: vi.fn(), onFinish }, | |
| 41 | ...callbacks, | |
| 42 | } as unknown as SensorProps<ConstructorParameters<typeof SidebarPointerSensor>[0]["options"]>; | |
| 43 | const sensor = new SidebarPointerSensor(props); | |
| 44 | sensors.push(sensor); | |
| 45 | return { sensor, onFinish, ...callbacks }; | |
| 46 | } | |
| 47 | ||
| 48 | beforeEach(() => { | |
| 49 | vi.useFakeTimers(); | |
| 50 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 51 | document = new TestDocument(); | |
| 52 | window = Object.assign(new EventTarget(), { setTimeout }); | |
| 53 | vi.stubGlobal("document", document); | |
| 54 | vi.stubGlobal("window", window); | |
| 55 | }); | |
| 56 | afterEach(() => { | |
| 57 | for (const sensor of sensors.splice(0)) sensor.cancel(); | |
| 58 | vi.runAllTimers(); | |
| 59 | vi.useRealTimers(); | |
| 60 | vi.unstubAllGlobals(); | |
| 61 | }); | |
| 62 | ||
| 63 | describe("sidebar pointer lifecycle", () => { | |
| 64 | it("keeps a click idle and starts only after the drag threshold", () => { | |
| 65 | const click = gesture(); | |
| 66 | document.dispatchEvent(pointer("pointermove", { clientY: 16 })); | |
| 67 | expect(click.onStart).not.toHaveBeenCalled(); | |
| 68 | document.dispatchEvent(pointer("pointerup", { buttons: 0 })); | |
| 69 | expect(click.onAbort).toHaveBeenCalledOnce(); | |
| 70 | expect(click.onFinish).toHaveBeenCalledOnce(); | |
| 71 | ||
| 72 | const drag = gesture(); | |
| 73 | document.dispatchEvent(pointer("pointermove", { clientY: 17 })); | |
| 74 | expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 }); | |
| 75 | document.dispatchEvent(pointer("pointerup", { buttons: 0 })); | |
| 76 | expect(drag.onEnd).toHaveBeenCalledOnce(); | |
| 77 | expect(drag.onAbort).not.toHaveBeenCalled(); | |
| 78 | expect(drag.onFinish).toHaveBeenCalledOnce(); | |
| 79 | }); | |
| 80 | ||
| 81 | const interruptions = { | |
| 82 | blur: () => window.dispatchEvent(new Event("blur")), | |
| 83 | hidden: () => { | |
| 84 | document.hidden = true; | |
| 85 | document.dispatchEvent(new Event("visibilitychange")); | |
| 86 | }, | |
| 87 | pagehide: () => window.dispatchEvent(new Event("pagehide")), | |
| 88 | resize: () => window.dispatchEvent(new Event("resize")), | |
| 89 | escape: () => document.dispatchEvent(Object.assign(new Event("keydown"), { code: "Escape" })), | |
| 90 | pointercancel: () => document.dispatchEvent(pointer("pointercancel")), | |
| 91 | "missed release": () => | |
| 92 | document.dispatchEvent(pointer("pointermove", { buttons: 0, clientY: 100 })), | |
| 93 | }; | |
| 94 | for (const [name, interrupt] of Object.entries(interruptions)) { | |
| 95 | it.each([false, true])(`cancels on ${name}, started=%s, and ignores late events`, (started) => { | |
| 96 | const drag = gesture(); | |
| 97 | if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); | |
| 98 | interrupt(); | |
| 99 | document.dispatchEvent(pointer("pointermove", { clientY: 100 })); | |
| 100 | document.dispatchEvent(pointer("pointerup", { buttons: 0 })); | |
| 101 | drag.sensor.cancel(); | |
| 102 | expect(drag.onCancel).toHaveBeenCalledOnce(); | |
| 103 | expect(drag.onEnd).not.toHaveBeenCalled(); | |
| 104 | expect(drag.onFinish).toHaveBeenCalledOnce(); | |
| 105 | expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0); | |
| 106 | expect(drag.onAbort).toHaveBeenCalledTimes(started ? 0 : 1); | |
| 107 | ||
| 108 | document.hidden = false; | |
| 109 | const next = gesture(); | |
| 110 | document.dispatchEvent(pointer("pointermove", { clientY: 20 })); | |
| 111 | document.dispatchEvent(pointer("pointerup", { buttons: 0 })); | |
| 112 | expect(next.onStart).toHaveBeenCalledOnce(); | |
| 113 | expect(next.onEnd).toHaveBeenCalledOnce(); | |
| 114 | }); | |
| 115 | } | |
| 116 | ||
| 117 | it("suppresses a delayed release click after cancellation, then accepts the next click", () => { | |
| 118 | const drag = gesture(); | |
| 119 | document.dispatchEvent(pointer("pointermove", { clientY: 20 })); | |
| 120 | drag.sensor.cancel(); | |
| 121 | vi.advanceTimersByTime(1000); | |
| 122 | const releaseClick = new Event("click"); | |
| 123 | const releasePropagation = vi.spyOn(releaseClick, "stopPropagation"); | |
| 124 | document.dispatchEvent(releaseClick); | |
| 125 | expect(releasePropagation).toHaveBeenCalledOnce(); | |
| 126 | document.dispatchEvent(pointer("pointerdown")); | |
| 127 | const nextClick = new Event("click"); | |
| 128 | const nextPropagation = vi.spyOn(nextClick, "stopPropagation"); | |
| 129 | document.dispatchEvent(nextClick); | |
| 130 | expect(nextPropagation).not.toHaveBeenCalled(); | |
| 131 | }); | |
| 132 | ||
| 133 | it("allows the next click when the cancelled release happened outside the document", () => { | |
| 134 | const drag = gesture(); | |
| 135 | document.dispatchEvent(pointer("pointermove", { clientY: 20 })); | |
| 136 | drag.sensor.cancel(); | |
| 137 | document.dispatchEvent(pointer("pointerdown")); | |
| 138 | const click = new Event("click"); | |
| 139 | const propagation = vi.spyOn(click, "stopPropagation"); | |
| 140 | document.dispatchEvent(click); | |
| 141 | expect(propagation).not.toHaveBeenCalled(); | |
| 142 | }); | |
| 143 | ||
| 144 | it("does not move after cancellation during activation", () => { | |
| 145 | const drag = gesture(); | |
| 146 | drag.onStart.mockImplementation(() => drag.sensor.cancel()); | |
| 147 | document.dispatchEvent(pointer("pointermove", { clientY: 20 })); | |
| 148 | expect(drag.onCancel).toHaveBeenCalledOnce(); | |
| 149 | expect(drag.onMove).not.toHaveBeenCalled(); | |
| 150 | expect(drag.onFinish).toHaveBeenCalledOnce(); | |
| 151 | }); | |
| 152 | ||
| 153 | it("ignores events from other pointers", () => { | |
| 154 | const drag = gesture(); | |
| 155 | document.dispatchEvent(pointer("pointermove", { pointerId: 2, buttons: 0, clientY: 100 })); | |
| 156 | document.dispatchEvent(pointer("pointercancel", { pointerId: 2 })); | |
| 157 | document.dispatchEvent(pointer("pointerup", { pointerId: 2 })); | |
| 158 | expect(drag.onStart).not.toHaveBeenCalled(); | |
| 159 | expect(drag.onFinish).not.toHaveBeenCalled(); | |
| 160 | document.dispatchEvent(pointer("pointermove", { clientY: 20 })); | |
| 161 | expect(drag.onStart).toHaveBeenCalledOnce(); | |
| 162 | }); | |
| 163 | ||
| 164 | it.each([false, true])("cancels when search unmounts the list, started=%s", (started) => { | |
| 165 | let renderer: ReactTestRenderer; | |
| 166 | let drag: ReturnType<typeof gesture> | undefined; | |
| 167 | act(() => { | |
| 168 | renderer = create( | |
| 169 | createElement( | |
| 170 | StrictMode, | |
| 171 | null, | |
| 172 | createElement(SidebarDragLifecycle, { | |
| 173 | onUnmount: () => drag?.sensor.cancel(), | |
| 174 | }), | |
| 175 | ), | |
| 176 | ); | |
| 177 | }); | |
| 178 | drag = gesture(); | |
| 179 | if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); | |
| 180 | act(() => renderer.unmount()); | |
| 181 | document.dispatchEvent(pointer("pointermove", { clientY: 100 })); | |
| 182 | document.dispatchEvent(pointer("pointerup", { buttons: 0 })); | |
| 183 | expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0); | |
| 184 | expect(drag.onCancel).toHaveBeenCalledOnce(); | |
| 185 | expect(drag.onEnd).not.toHaveBeenCalled(); | |
| 186 | expect(drag.onFinish).toHaveBeenCalledOnce(); | |
| 187 | }); | |
| 188 | ||
| 189 | it("detaches a cancelled sensor before a replacement gesture starts", () => { | |
| 190 | const previous = gesture(); | |
| 191 | document.dispatchEvent(pointer("pointermove", { clientY: 20 })); | |
| 192 | previous.sensor.cancel(); | |
| 193 | const next = gesture(); | |
| 194 | document.dispatchEvent(pointer("pointermove", { clientY: 20 })); | |
| 195 | document.dispatchEvent(pointer("pointerup", { buttons: 0 })); | |
| 196 | expect(previous.onCancel).toHaveBeenCalledOnce(); | |
| 197 | expect(previous.onEnd).not.toHaveBeenCalled(); | |
| 198 | expect(next.onEnd).toHaveBeenCalledOnce(); | |
| 199 | }); | |
| 200 | }); |
apps/web/src/components/Sidebar.pointer.ts created+141| ... | ... | @@ -0,0 +1,141 @@ |
| 1 | import { useLayoutEffect, type PointerEvent as ReactPointerEvent } from "react"; | |
| 2 | import { type SensorProps } from "@dnd-kit/core"; | |
| 3 | import { getOwnerDocument, getWindow } from "@dnd-kit/utilities"; | |
| 4 | ||
| 5 | // Search unmounts the drag context while its owning Sidebar remains mounted. | |
| 6 | export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) { | |
| 7 | useLayoutEffect(() => onUnmount, [onUnmount]); | |
| 8 | return null; | |
| 9 | } | |
| 10 | ||
| 11 | type Options = { | |
| 12 | distance: number; | |
| 13 | onAttach: (sensor: SidebarPointerSensor) => void; | |
| 14 | onFinish: (started: boolean) => void; | |
| 15 | }; | |
| 16 | ||
| 17 | /** A sidebar gesture ends on release, cancellation, or loss of its window. | |
| 18 | * Own the listeners so unmounting the list can cancel the sensor too. */ | |
| 19 | export class SidebarPointerSensor { | |
| 20 | static activators = [ | |
| 21 | { | |
| 22 | eventName: "onPointerDown" as const, | |
| 23 | handler: ({ nativeEvent }: ReactPointerEvent) => | |
| 24 | nativeEvent.isPrimary && nativeEvent.button === 0, | |
| 25 | }, | |
| 26 | ]; | |
| 27 | autoScrollEnabled = true; | |
| 28 | private phase: "pending" | "dragging" | "finished" = "pending"; | |
| 29 | private readonly pointer: PointerEvent; | |
| 30 | private readonly document: Document; | |
| 31 | private readonly window: Window; | |
| 32 | ||
| 33 | constructor(private readonly props: SensorProps<Options>) { | |
| 34 | this.pointer = props.event as PointerEvent; | |
| 35 | this.document = getOwnerDocument(this.pointer.target); | |
| 36 | this.window = getWindow(this.pointer.target); | |
| 37 | this.document.addEventListener("pointermove", this.move, { passive: false, capture: true }); | |
| 38 | this.document.addEventListener("pointerup", this.end, { capture: true }); | |
| 39 | this.document.addEventListener("pointercancel", this.pointerCancel, { capture: true }); | |
| 40 | this.document.addEventListener("keydown", this.keydown, { capture: true }); | |
| 41 | this.document.addEventListener("visibilitychange", this.visibilityChange); | |
| 42 | this.window.addEventListener("blur", this.cancel); | |
| 43 | this.window.addEventListener("pagehide", this.cancel); | |
| 44 | this.window.addEventListener("resize", this.cancel); | |
| 45 | this.document.addEventListener("dragstart", this.preventDefault); | |
| 46 | this.document.addEventListener("contextmenu", this.preventDefault); | |
| 47 | props.options.onAttach(this); | |
| 48 | props.onPending(props.active, { distance: props.options.distance }, this.coordinates()); | |
| 49 | } | |
| 50 | ||
| 51 | private coordinates = () => ({ x: this.pointer.clientX, y: this.pointer.clientY }); | |
| 52 | private preventDefault = (event: Event) => event.preventDefault(); | |
| 53 | private clearClickSuppression = () => { | |
| 54 | this.document.removeEventListener("click", this.suppressClick, { capture: true }); | |
| 55 | this.document.removeEventListener("pointerdown", this.clearClickSuppression, { capture: true }); | |
| 56 | }; | |
| 57 | private suppressClick = (event: Event) => { | |
| 58 | event.stopPropagation(); | |
| 59 | this.clearClickSuppression(); | |
| 60 | }; | |
| 61 | private clearSelection = () => this.document.getSelection()?.removeAllRanges(); | |
| 62 | ||
| 63 | private move = (event: PointerEvent) => { | |
| 64 | if (this.phase === "finished" || event.pointerId !== this.pointer.pointerId) return; | |
| 65 | // A release outside the window can be missed. Never activate or continue | |
| 66 | // a drag when the initiating button is no longer held. | |
| 67 | if ((event.buttons & 1) === 0) return this.cancel(); | |
| 68 | const coordinates = { x: event.clientX, y: event.clientY }; | |
| 69 | if (this.phase === "pending") { | |
| 70 | const offset = { | |
| 71 | x: event.clientX - this.pointer.clientX, | |
| 72 | y: event.clientY - this.pointer.clientY, | |
| 73 | }; | |
| 74 | if (Math.hypot(offset.x, offset.y) <= this.props.options.distance) { | |
| 75 | this.props.onPending( | |
| 76 | this.props.active, | |
| 77 | { distance: this.props.options.distance }, | |
| 78 | this.coordinates(), | |
| 79 | offset, | |
| 80 | ); | |
| 81 | return; | |
| 82 | } | |
| 83 | this.phase = "dragging"; | |
| 84 | this.document.addEventListener("click", this.suppressClick, { capture: true }); | |
| 85 | this.document.addEventListener("selectionchange", this.clearSelection); | |
| 86 | this.clearSelection(); | |
| 87 | this.props.onStart(this.coordinates()); | |
| 88 | return; | |
| 89 | } | |
| 90 | if (this.phase === "dragging") { | |
| 91 | if (event.cancelable) event.preventDefault(); | |
| 92 | this.props.onMove(coordinates); | |
| 93 | } | |
| 94 | }; | |
| 95 | ||
| 96 | private end = (event: PointerEvent) => { | |
| 97 | if (event.pointerId === this.pointer.pointerId) this.finish(false); | |
| 98 | }; | |
| 99 | private pointerCancel = (event: PointerEvent) => { | |
| 100 | if (event.pointerId === this.pointer.pointerId) this.cancel(); | |
| 101 | }; | |
| 102 | private keydown = (event: KeyboardEvent) => { | |
| 103 | if (event.code === "Escape") this.cancel(); | |
| 104 | }; | |
| 105 | private visibilityChange = () => { | |
| 106 | if (this.document.hidden) this.cancel(); | |
| 107 | }; | |
| 108 | cancel = () => this.finish(true); | |
| 109 | ||
| 110 | private finish(cancelled: boolean) { | |
| 111 | if (this.phase === "finished") return; | |
| 112 | const aborted = this.phase === "pending"; | |
| 113 | this.phase = "finished"; | |
| 114 | this.document.removeEventListener("pointermove", this.move, { capture: true }); | |
| 115 | this.document.removeEventListener("pointerup", this.end, { capture: true }); | |
| 116 | this.document.removeEventListener("pointercancel", this.pointerCancel, { capture: true }); | |
| 117 | this.document.removeEventListener("keydown", this.keydown, { capture: true }); | |
| 118 | this.document.removeEventListener("visibilitychange", this.visibilityChange); | |
| 119 | this.window.removeEventListener("blur", this.cancel); | |
| 120 | this.window.removeEventListener("pagehide", this.cancel); | |
| 121 | this.window.removeEventListener("resize", this.cancel); | |
| 122 | this.document.removeEventListener("dragstart", this.preventDefault); | |
| 123 | this.document.removeEventListener("contextmenu", this.preventDefault); | |
| 124 | this.document.removeEventListener("selectionchange", this.clearSelection); | |
| 125 | // Cancellation can precede release by an arbitrary amount of time. Consume | |
| 126 | // that release click, or let a fresh pointerdown end suppression if release | |
| 127 | // happened outside the document. Ordinary clicks never install this guard. | |
| 128 | if (!aborted) { | |
| 129 | this.document.addEventListener("pointerdown", this.clearClickSuppression, { capture: true }); | |
| 130 | } | |
| 131 | try { | |
| 132 | // Release the sidebar preview before dnd-kit clears its transforms. | |
| 133 | // Its public end/cancel event can be omitted before its first layout. | |
| 134 | this.props.options.onFinish(!aborted); | |
| 135 | } finally { | |
| 136 | if (aborted) this.props.onAbort(this.props.active); | |
| 137 | if (cancelled) this.props.onCancel(); | |
| 138 | else this.props.onEnd(); | |
| 139 | } | |
| 140 | } | |
| 141 | } |
apps/web/src/components/Sidebar.tsx+101-44| ... | ... | @@ -2,12 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | import { |
| 4 | 4 | DndContext, |
| 5 | PointerSensor, | |
| 6 | 5 | useSensor, |
| 7 | 6 | useSensors, |
| 8 | 7 | type DragEndEvent, |
| 9 | 8 | type DragOverEvent, |
| 10 | 9 | type DragStartEvent, |
| 10 | type Modifier, | |
| 11 | 11 | } from "@dnd-kit/core"; |
| 12 | 12 | import { SortableContext, useSortable } from "@dnd-kit/sortable"; |
| 13 | 13 | import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; |
| ... | ... | @@ -172,7 +172,12 @@ import { |
| 172 | 172 | type SidebarSection, |
| 173 | 173 | } from "./Sidebar.logic"; |
| 174 | 174 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 175 | import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag"; | |
| 175 | import { | |
| 176 | createSidebarCollisionDetection, | |
| 177 | createSidebarSortingStrategy, | |
| 178 | restrictBelowSidebarLabel, | |
| 179 | } from "./Sidebar.drag"; | |
| 180 | import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; | |
| 176 | 181 | import { createSidebarListMotion } from "./Sidebar.motion"; |
| 177 | 182 | import { |
| 178 | 183 | ThreadWorktreeIndicator, |
| ... | ... | @@ -549,8 +554,8 @@ function SortableSidebarMarker(props: { |
| 549 | 554 | ); |
| 550 | 555 | } |
| 551 | 556 | |
| 552 | // Empty targets stay mounted before pickup so starting a drag never changes | |
| 553 | // the list's measured positions. | |
| 557 | // Empty targets stay measurable without reserving space at rest. The sorting | |
| 558 | // strategy opens their hint space during a drag. | |
| 554 | 559 | function SidebarSectionPlaceholder(props: { |
| 555 | 560 | marker: "active-placeholder" | "settled-placeholder"; |
| 556 | 561 | label: string; |
| ... | ... | @@ -561,26 +566,25 @@ function SidebarSectionPlaceholder(props: { |
| 561 | 566 | <SortableSidebarMarker |
| 562 | 567 | marker={props.marker} |
| 563 | 568 | data-testid={`sidebar-${props.marker}`} |
| 564 | className={cn( | |
| 565 | "mx-0.5 flex h-9 items-center justify-center rounded-md border border-dashed border-transparent text-xs text-sidebar-muted-foreground/60", | |
| 566 | props.showHint && "border-sidebar-foreground/25 text-sidebar-foreground/80", | |
| 567 | props.isDropTarget && "border-primary/40 bg-primary/5 text-primary", | |
| 568 | )} | |
| 569 | className="relative mx-0.5 h-0" | |
| 569 | 570 | > |
| 570 | {props.showHint ? props.label : null} | |
| 571 | {props.showHint ? ( | |
| 572 | <div | |
| 573 | className={cn( | |
| 574 | "absolute inset-x-0 top-0 flex h-9 items-center justify-center rounded-md border border-dashed border-sidebar-foreground/25 text-xs text-sidebar-foreground/80", | |
| 575 | props.isDropTarget && "border-primary/40 bg-primary/5 text-primary", | |
| 576 | )} | |
| 577 | > | |
| 578 | {props.label} | |
| 579 | </div> | |
| 580 | ) : null} | |
| 571 | 581 | </SortableSidebarMarker> |
| 572 | 582 | ); |
| 573 | 583 | } |
| 574 | 584 | |
| 575 | // Boundary labels appear during a drag in space the sorting strategy opens | |
| 576 | // below each marker (SIDEBAR_DRAG_LABEL_HEIGHT), so they never sit on a row. | |
| 577 | // The marker itself stays zero height, so nothing is reserved at rest and | |
| 578 | // pickup measurements are unchanged. They read at full strength so the | |
| 579 | // sections are easy to find, and the section under the lifted row takes the | |
| 580 | // accent. They paint above the lifted row so a card dragged across a | |
| 581 | // boundary never hides its label. | |
| 582 | // Matches the label's h-4 below. | |
| 583 | const SIDEBAR_DRAG_LABEL_HEIGHT = 16; | |
| 585 | // Zero-height markers reserve no label space at rest. During a drag the | |
| 586 | // sorting strategy opens 24px for a 16px label with 4px clearance on each side. | |
| 587 | const SIDEBAR_DRAG_LABEL_HEIGHT = 24; | |
| 584 | 588 | |
| 585 | 589 | function SidebarDragBoundary(props: { |
| 586 | 590 | marker: "pinned-header" | "pinned-divider"; |
| ... | ... | @@ -592,10 +596,10 @@ function SidebarDragBoundary(props: { |
| 592 | 596 | <SortableSidebarMarker |
| 593 | 597 | marker={props.marker} |
| 594 | 598 | data-testid={`sidebar-${props.marker}`} |
| 595 | className="pointer-events-none relative z-30 mx-0.5 h-0" | |
| 599 | className="pointer-events-none relative mx-0.5 h-0" | |
| 596 | 600 | > |
| 597 | 601 | {props.visible ? ( |
| 598 | <div className="absolute inset-x-2 top-0 flex h-4 items-center gap-1.5"> | |
| 602 | <div className="sidebar-drag-boundary-label absolute inset-x-2 top-1 flex h-4 items-center gap-1.5"> | |
| 599 | 603 | <span |
| 600 | 604 | className={cn( |
| 601 | 605 | "inline-flex h-4 shrink-0 items-center rounded-sm border bg-sidebar px-1.5 text-[10px] leading-none font-medium", |
| ... | ... | @@ -3021,8 +3025,15 @@ export default function Sidebar() { |
| 3021 | 3025 | }, |
| 3022 | 3026 | [unsnoozeThread], |
| 3023 | 3027 | ); |
| 3028 | const threadListRef = useRef<HTMLUListElement | null>(null); | |
| 3029 | const dragLabelOffsetRef = useRef(0); | |
| 3030 | const restrictBelowPins = useCallback<Modifier>( | |
| 3031 | (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), | |
| 3032 | [], | |
| 3033 | ); | |
| 3024 | 3034 | const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null); |
| 3025 | 3035 | const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { |
| 3036 | threadListRef.current = node; | |
| 3026 | 3037 | listMotionRef.current?.dispose(); |
| 3027 | 3038 | listMotionRef.current = node === null ? null : createSidebarListMotion(node); |
| 3028 | 3039 | listMotionRef.current?.update(false); |
| ... | ... | @@ -3031,16 +3042,35 @@ export default function Sidebar() { |
| 3031 | 3042 | // Hold the chosen section and order until every key write arrives. This |
| 3032 | 3043 | // also covers first-time ordering, which assigns keys to keyless neighbors. |
| 3033 | 3044 | // A failed write, concurrent reorder, or membership change releases the hold. |
| 3034 | const dndSensors = useSensors( | |
| 3035 | useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), | |
| 3036 | ); | |
| 3037 | 3045 | const [dragState, setDragState] = useState<{ |
| 3038 | 3046 | readonly activeKey: string; |
| 3039 | 3047 | readonly activeSection: SidebarSection; |
| 3040 | 3048 | readonly occurredAt: string; |
| 3041 | 3049 | readonly activationY: number | null; |
| 3050 | readonly targetSection: SidebarSection | null; | |
| 3042 | 3051 | } | null>(null); |
| 3043 | const [dragTargetSection, setDragTargetSection] = useState<SidebarSection | null>(null); | |
| 3052 | const dragTargetSection = dragState?.targetSection ?? null; | |
| 3053 | const dragSensorRef = useRef<SidebarPointerSensor | null>(null); | |
| 3054 | const finishThreadDrag = useCallback((started: boolean) => { | |
| 3055 | dragSensorRef.current = null; | |
| 3056 | if (started) { | |
| 3057 | listMotionRef.current?.release(); | |
| 3058 | setDragState(null); | |
| 3059 | } | |
| 3060 | }, []); | |
| 3061 | const attachDragSensor = useCallback((sensor: SidebarPointerSensor) => { | |
| 3062 | dragSensorRef.current = sensor; | |
| 3063 | }, []); | |
| 3064 | const cancelThreadDrag = useCallback(() => { | |
| 3065 | dragSensorRef.current?.cancel(); | |
| 3066 | }, []); | |
| 3067 | const dndSensors = useSensors( | |
| 3068 | useSensor(SidebarPointerSensor, { | |
| 3069 | distance: 6, | |
| 3070 | onAttach: attachDragSensor, | |
| 3071 | onFinish: finishThreadDrag, | |
| 3072 | }), | |
| 3073 | ); | |
| 3044 | 3074 | const sectionByThreadKey = useMemo(() => { |
| 3045 | 3075 | const map = new Map<string, SidebarSection>(); |
| 3046 | 3076 | const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => { |
| ... | ... | @@ -3185,22 +3215,27 @@ export default function Sidebar() { |
| 3185 | 3215 | if (activeSection === undefined) return; |
| 3186 | 3216 | // Stop normal section motion before dnd-kit measures the picked-up row. |
| 3187 | 3217 | listMotionRef.current?.suspend(); |
| 3218 | const list = threadListRef.current; | |
| 3219 | const header = list?.querySelector<HTMLElement>('[data-testid="sidebar-pinned-header"]'); | |
| 3220 | if (list && header) { | |
| 3221 | const listRect = list.getBoundingClientRect(); | |
| 3222 | const scale = list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1; | |
| 3223 | dragLabelOffsetRef.current = | |
| 3224 | header.getBoundingClientRect().top - listRect.top + SIDEBAR_DRAG_LABEL_HEIGHT * scale; | |
| 3225 | } else { | |
| 3226 | dragLabelOffsetRef.current = 0; | |
| 3227 | } | |
| 3188 | 3228 | setDragState({ |
| 3189 | 3229 | activeKey, |
| 3190 | 3230 | activeSection, |
| 3231 | targetSection: activeSection, | |
| 3191 | 3232 | occurredAt: new Date().toISOString(), |
| 3192 | 3233 | activationY: |
| 3193 | 3234 | event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, |
| 3194 | 3235 | }); |
| 3195 | setDragTargetSection(activeSection); | |
| 3196 | 3236 | }, |
| 3197 | 3237 | [sectionByThreadKey], |
| 3198 | 3238 | ); |
| 3199 | const handleThreadDragCancel = useCallback(() => { | |
| 3200 | listMotionRef.current?.release(); | |
| 3201 | setDragState(null); | |
| 3202 | setDragTargetSection(null); | |
| 3203 | }, []); | |
| 3204 | 3239 | // Include every visible row in the measured order. Older servers disable |
| 3205 | 3240 | // pickup on their rows without changing where those rows render. |
| 3206 | 3241 | const sidebarListItems = useMemo((): readonly SidebarListItem[] => { |
| ... | ... | @@ -3249,6 +3284,14 @@ export default function Sidebar() { |
| 3249 | 3284 | snoozedThreads.length, |
| 3250 | 3285 | visibleSnoozedThreads, |
| 3251 | 3286 | ]); |
| 3287 | useEffect(() => { | |
| 3288 | if ( | |
| 3289 | dragState !== null && | |
| 3290 | !sidebarListItems.some((item) => item.kind === "thread" && item.key === dragState.activeKey) | |
| 3291 | ) { | |
| 3292 | cancelThreadDrag(); | |
| 3293 | } | |
| 3294 | }, [cancelThreadDrag, dragState, sidebarListItems]); | |
| 3252 | 3295 | const listMotionPaused = dragState !== null; |
| 3253 | 3296 | useLayoutEffect(() => { |
| 3254 | 3297 | // Drag release clears the baseline, so its commit cannot replay the |
| ... | ... | @@ -3264,7 +3307,11 @@ export default function Sidebar() { |
| 3264 | 3307 | const target = event.over |
| 3265 | 3308 | ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) |
| 3266 | 3309 | : null; |
| 3267 | setDragTargetSection(target?.section ?? null); | |
| 3310 | setDragState((current) => | |
| 3311 | current === null || current.activeKey !== String(event.active.id) | |
| 3312 | ? current | |
| 3313 | : { ...current, targetSection: target?.section ?? null }, | |
| 3314 | ); | |
| 3268 | 3315 | }, |
| 3269 | 3316 | [sidebarListItems], |
| 3270 | 3317 | ); |
| ... | ... | @@ -3318,18 +3365,22 @@ export default function Sidebar() { |
| 3318 | 3365 | }), |
| 3319 | 3366 | [threads], |
| 3320 | 3367 | ); |
| 3368 | const draggedThreadKey = dragState?.activeKey; | |
| 3369 | const draggedFromSection = dragState?.activeSection; | |
| 3370 | const dragActivationY = dragState?.activationY; | |
| 3321 | 3371 | const dndCollisionDetection = useMemo(() => { |
| 3322 | if (dragState === null) return createSidebarCollisionDetection(() => true); | |
| 3323 | const source = threadByKey.get(dragState.activeKey); | |
| 3372 | if (draggedThreadKey === undefined || draggedFromSection === undefined) | |
| 3373 | return createSidebarCollisionDetection(() => true); | |
| 3374 | const source = threadByKey.get(draggedThreadKey); | |
| 3324 | 3375 | if (source === undefined) return createSidebarCollisionDetection(() => false); |
| 3325 | 3376 | return createSidebarCollisionDetection( |
| 3326 | 3377 | (id) => { |
| 3327 | const target = resolveSidebarDropTarget(sidebarListItems, dragState.activeKey, id); | |
| 3378 | const target = resolveSidebarDropTarget(sidebarListItems, draggedThreadKey, id); | |
| 3328 | 3379 | if (target === null) return false; |
| 3329 | 3380 | return ( |
| 3330 | 3381 | planSidebarThreadDrop({ |
| 3331 | activeKey: dragState.activeKey, | |
| 3332 | activeSection: dragState.activeSection, | |
| 3382 | activeKey: draggedThreadKey, | |
| 3383 | activeSection: draggedFromSection, | |
| 3333 | 3384 | activePinned: source.pinnedAt != null, |
| 3334 | 3385 | activeSettled: source.settledOverride === "settled", |
| 3335 | 3386 | supportsSettlement: |
| ... | ... | @@ -3345,7 +3396,10 @@ export default function Sidebar() { |
| 3345 | 3396 | }).kind !== "none" |
| 3346 | 3397 | ); |
| 3347 | 3398 | }, |
| 3348 | { emptyPins: pinnedKeys.length === 0, activationY: dragState.activationY }, | |
| 3399 | { | |
| 3400 | items: sidebarListItems, | |
| 3401 | activationY: dragActivationY ?? null, | |
| 3402 | }, | |
| 3349 | 3403 | ); |
| 3350 | 3404 | }, [ |
| 3351 | 3405 | activeKeysById, |
| ... | ... | @@ -3353,7 +3407,9 @@ export default function Sidebar() { |
| 3353 | 3407 | serverConfigs, |
| 3354 | 3408 | activeKeys, |
| 3355 | 3409 | activeReorderableThreadKeys, |
| 3356 | dragState, | |
| 3410 | draggedThreadKey, | |
| 3411 | draggedFromSection, | |
| 3412 | dragActivationY, | |
| 3357 | 3413 | draggableThreadKeys, |
| 3358 | 3414 | pinnedKeys, |
| 3359 | 3415 | sidebarListItems, |
| ... | ... | @@ -3361,9 +3417,6 @@ export default function Sidebar() { |
| 3361 | 3417 | ]); |
| 3362 | 3418 | const handleThreadDragEnd = useCallback( |
| 3363 | 3419 | (event: DragEndEvent) => { |
| 3364 | listMotionRef.current?.release(); | |
| 3365 | setDragState(null); | |
| 3366 | setDragTargetSection(null); | |
| 3367 | 3420 | const activeKey = String(event.active.id); |
| 3368 | 3421 | const activeSection = sectionByThreadKey.get(activeKey); |
| 3369 | 3422 | const target = |
| ... | ... | @@ -4547,12 +4600,16 @@ export default function Sidebar() { |
| 4547 | 4600 | <DndContext |
| 4548 | 4601 | sensors={dndSensors} |
| 4549 | 4602 | collisionDetection={dndCollisionDetection} |
| 4550 | modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]} | |
| 4603 | modifiers={[ | |
| 4604 | restrictToVerticalAxis, | |
| 4605 | restrictBelowPins, | |
| 4606 | restrictToFirstScrollableAncestor, | |
| 4607 | ]} | |
| 4551 | 4608 | onDragStart={handleThreadDragStart} |
| 4552 | 4609 | onDragOver={handleThreadDragOver} |
| 4553 | onDragCancel={handleThreadDragCancel} | |
| 4554 | 4610 | onDragEnd={handleThreadDragEnd} |
| 4555 | 4611 | > |
| 4612 | <SidebarDragLifecycle onUnmount={cancelThreadDrag} /> | |
| 4556 | 4613 | <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}> |
| 4557 | 4614 | <ul |
| 4558 | 4615 | ref={attachListMotionRef} |
apps/web/src/index.css+15| ... | ... | @@ -2056,3 +2056,18 @@ code { |
| 2056 | 2056 | transform: scaleX(0.9); |
| 2057 | 2057 | } |
| 2058 | 2058 | } |
| 2059 | ||
| 2060 | /* Let the sortable rows open label clearance before painting the dividers. | |
| 2061 | This runs once per pickup; rows retain dnd-kit's normal 200ms transitions. */ | |
| 2062 | .sidebar-drag-boundary-label { | |
| 2063 | animation: sidebar-drag-label-reveal 200ms step-end; | |
| 2064 | } | |
| 2065 | ||
| 2066 | @keyframes sidebar-drag-label-reveal { | |
| 2067 | from { | |
| 2068 | visibility: hidden; | |
| 2069 | } | |
| 2070 | to { | |
| 2071 | visibility: visible; | |
| 2072 | } | |
| 2073 | } |