| 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,8 +1,13 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; | 2 | import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; |
| 3 | import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; | 3 | import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; |
| 4 | import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag"; | ||
| 5 | import { | 4 | import { |
| 5 | createSidebarCollisionDetection, | ||
| 6 | createSidebarSortingStrategy, | ||
| 7 | restrictBelowSidebarLabel, | ||
| 8 | } from "./Sidebar.drag"; | ||
| 9 | import { | ||
| 10 | resolveSidebarDropTarget, | ||
| 6 | sidebarListItemId, | 11 | sidebarListItemId, |
| 7 | sidebarMarkerId, | 12 | sidebarMarkerId, |
| 8 | type SidebarListItem, | 13 | type SidebarListItem, |
| ... | @@ -35,7 +40,7 @@ function layout( | ... | @@ -35,7 +40,7 @@ function layout( |
| 35 | ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale | 40 | ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale |
| 36 | : item.marker === "pinned-header" || item.marker === "pinned-divider" | 41 | : item.marker === "pinned-header" || item.marker === "pinned-divider" |
| 37 | ? 0 | 42 | ? 0 |
| 38 | : (item.marker.endsWith("placeholder") ? 36 : 32) * scale; | 43 | : (item.marker.endsWith("placeholder") ? 0 : 32) * scale; |
| 39 | const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 }; | 44 | const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 }; |
| 40 | top += height + 1; | 45 | top += height + 1; |
| 41 | return rect; | 46 | return rect; |
| ... | @@ -119,69 +124,92 @@ describe("sidebar collision detection", () => { | ... | @@ -119,69 +124,92 @@ describe("sidebar collision detection", () => { |
| 119 | expect(detector(collisionArgs())[0]?.id).toBe("blocked"); | 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 | it.each([ | 127 | it.each([ |
| 154 | { reason: "below the boundary cue", x: 130, y: 109, activationY: 140, emptyPins: true }, | 128 | { sourceSection: "active", pins: 0 }, |
| 155 | { reason: "left of the list", x: -1, y: 108, activationY: 140, emptyPins: true }, | 129 | { sourceSection: "active", pins: 1 }, |
| 156 | { reason: "right of the list", x: 261, y: 108, activationY: 140, emptyPins: true }, | 130 | { sourceSection: "pinned", pins: 1 }, |
| 157 | { reason: "less than 6px upward", x: 130, y: 108, activationY: 113, emptyPins: true }, | 131 | { sourceSection: "settled", pins: 1 }, |
| 158 | { reason: "without an activation point", x: 130, y: 108, activationY: null, emptyPins: true }, | 132 | ] as const)( |
| 159 | { reason: "with populated Pins", x: 130, y: 108, activationY: 140, emptyPins: false }, | 133 | "switches on crossing the divider row from $sourceSection with $pins pins", |
| 160 | ])("keeps ordinary collision behavior $reason", ({ x, y, activationY, emptyPins }) => { | 134 | ({ sourceSection, pins }) => { |
| 161 | const detector = createSidebarCollisionDetection(() => true, { emptyPins, activationY }); | 135 | const items = [ |
| 162 | const args = { ...clampedArgs(), pointerCoordinates: { x, y } }; | 136 | pinnedHeader, |
| 163 | expect(detector(args)[0]?.id).toBe("source"); | 137 | ...(pins ? [thread("p", "pinned")] : []), |
| 164 | }); | 138 | ...(sourceSection === "pinned" ? [thread("source", "pinned")] : []), |
| 165 | 139 | divider, | |
| 166 | it("keeps ordinary collision behavior without pointer coordinates", () => { | 140 | thread("a", "active"), |
| 167 | const detector = createSidebarCollisionDetection(() => true, { | 141 | ...(sourceSection === "active" ? [thread("source", "active")] : []), |
| 168 | emptyPins: true, | 142 | settledHeader, |
| 169 | activationY: 140, | 143 | ...(sourceSection === "settled" ? [thread("source", "settled")] : []), |
| 170 | }); | 144 | ]; |
| 171 | expect(detector({ ...clampedArgs(), pointerCoordinates: null })[0]?.id).toBe("source"); | 145 | const { rects, activeIndex } = layout(items, "source", "a"); |
| 172 | }); | 146 | const sourceRect = rects[activeIndex]!; |
| 173 | 147 | let boundaryTop = 300; | |
| 174 | it("validates the empty Pins override and caches an unsupported result", () => { | 148 | const boundaryNode = { |
| 175 | const isValid = vi.fn(() => false); | 149 | querySelector: () => ({ |
| 176 | const detector = createSidebarCollisionDetection(isValid, { | 150 | getBoundingClientRect: () => ({ |
| 177 | emptyPins: true, | 151 | top: boundaryTop, |
| 178 | activationY: 140, | 152 | bottom: boundaryTop + 16, |
| 179 | }); | 153 | left: 0, |
| 180 | const args = clampedArgs(); | 154 | right: 260, |
| 181 | expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); | 155 | }), |
| 182 | expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); | 156 | }), |
| 183 | expect(isValid.mock.calls).toEqual([[sidebarMarkerId("pinned-header")]]); | 157 | } as unknown as HTMLElement; |
| 184 | }); | 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 | it("returns no collision if an unsupported target has no source fallback", () => { | 214 | it("returns no collision if an unsupported target has no source fallback", () => { |
| 187 | const args = collisionArgs(); | 215 | const args = collisionArgs(); |
| ... | @@ -289,7 +317,7 @@ describe("sidebar drag projection", () => { | ... | @@ -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 | const items = [ | 321 | const items = [ |
| 294 | pinnedHeader, | 322 | pinnedHeader, |
| 295 | divider, | 323 | divider, |
| ... | @@ -303,7 +331,10 @@ describe("sidebar drag projection", () => { | ... | @@ -303,7 +331,10 @@ describe("sidebar drag projection", () => { |
| 303 | "second", | 331 | "second", |
| 304 | "first", | 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 | it.each([ | 340 | it.each([ |
| ... | @@ -357,6 +388,36 @@ describe("sidebar drag projection", () => { | ... | @@ -357,6 +388,36 @@ describe("sidebar drag projection", () => { |
| 357 | expect(result.get("s")?.y).toBe(32); | 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 | it("stacks the labels with their gaps when the pinned section is empty", () => { | 421 | it("stacks the labels with their gaps when the pinned section is empty", () => { |
| 361 | const items = [ | 422 | const items = [ |
| 362 | pinnedHeader, | 423 | pinnedHeader, |
| ... | @@ -409,8 +470,8 @@ describe("sidebar drag projection", () => { | ... | @@ -409,8 +470,8 @@ describe("sidebar drag projection", () => { |
| 409 | }); | 470 | }); |
| 410 | 471 | ||
| 411 | it.each([ | 472 | it.each([ |
| 412 | ["p", -83, -37], | 473 | ["p", -83, -1], |
| 413 | ["s", 0, 46], | 474 | ["s", 0, 82], |
| 414 | ] as const)( | 475 | ] as const)( |
| 415 | "replaces the empty Active target when %s enters", | 476 | "replaces the empty Active target when %s enters", |
| 416 | (active, dividerOffset, settledOffset) => { | 477 | (active, dividerOffset, settledOffset) => { |
| ... | @@ -561,7 +622,7 @@ describe("sidebar drag projection", () => { | ... | @@ -561,7 +622,7 @@ describe("sidebar drag projection", () => { |
| 561 | expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5); | 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 | const items = [ | 626 | const items = [ |
| 566 | pinnedHeader, | 627 | pinnedHeader, |
| 567 | thread("p", "pinned"), | 628 | thread("p", "pinned"), |
| ... | @@ -674,3 +735,47 @@ describe("sidebar drag projection", () => { | ... | @@ -674,3 +735,47 @@ describe("sidebar drag projection", () => { |
| 674 | expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46); | 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,4 +1,4 @@ |
| 1 | import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; | 1 | import { closestCenter, type CollisionDetection, type Modifier } from "@dnd-kit/core"; |
| 2 | import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; | 2 | import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; |
| 3 | import { | 3 | import { |
| 4 | resolveSidebarDropTarget, | 4 | resolveSidebarDropTarget, |
| ... | @@ -14,36 +14,77 @@ const hidden = { ...stationary, scaleY: 0 }; | ... | @@ -14,36 +14,77 @@ const hidden = { ...stationary, scaleY: 0 }; |
| 14 | type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>; | 14 | type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>; |
| 15 | type Layout = Parameters<SortingStrategy>[0]; | 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 | /** Reject the nearest unsupported target without selecting another section. | 28 | /** Reject the nearest unsupported target without selecting another section. |
| 18 | * Recreate this detector when drop eligibility changes. */ | 29 | * Recreate this detector when drop eligibility changes. */ |
| 19 | export function createSidebarCollisionDetection( | 30 | export function createSidebarCollisionDetection( |
| 20 | isValidTarget: (id: string) => boolean, | 31 | isValidTarget: (id: string) => boolean, |
| 21 | options: { emptyPins?: boolean; activationY?: number | null } = {}, | 32 | options: { |
| 33 | items?: readonly SidebarListItem[]; | ||
| 34 | activationY?: number | null; | ||
| 35 | } = {}, | ||
| 22 | ): CollisionDetection { | 36 | ): CollisionDetection { |
| 23 | const validity = new Map<string, boolean>(); | 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 | return (args) => { | 41 | return (args) => { |
| 26 | let collisions = closestCenter(args); | 42 | let collisions = closestCenter(args); |
| 27 | const pinnedRect = options.emptyPins ? args.droppableRects.get(pinnedHeaderId) : undefined; | ||
| 28 | const pointer = args.pointerCoordinates; | 43 | const pointer = args.pointerCoordinates; |
| 29 | // The card itself is clamped by the scroll container. An upward pointer | 44 | const items = options.items; |
| 30 | // gesture can still reach the empty pinned boundary without reserving a row. | 45 | const source = items?.find((item) => item.kind === "thread" && item.key === args.active.id); |
| 31 | if ( | 46 | const boundary = args.droppableContainers |
| 32 | pinnedRect && | 47 | .find((container) => container.id === sidebarMarkerId("pinned-divider")) |
| 33 | pointer && | 48 | ?.node.current?.querySelector(".sidebar-drag-boundary-label") |
| 34 | options.activationY != null && | 49 | ?.getBoundingClientRect(); |
| 35 | pointer.y <= options.activationY - 6 && | 50 | if (items && boundary && source?.kind === "thread" && pointer) { |
| 36 | pointer.y <= pinnedRect.top + 8 && | 51 | boundarySection ??= source.section === "pinned" ? "pinned" : "active"; |
| 37 | pointer.x >= pinnedRect.left && | 52 | // Use the visible divider row, including its sortable translation. |
| 38 | pointer.x <= pinnedRect.right | 53 | // Only pointer movement can change sections: opening the destination |
| 39 | ) { | 54 | // moves this row, but must not toggle a stationary gesture back. |
| 40 | const pinned = collisions.find((collision) => collision.id === pinnedHeaderId); | 55 | const previousY = previousPointerY ?? pointer.y; |
| 41 | if (pinned) { | 56 | previousPointerY = pointer.y; |
| 42 | collisions = [pinned, ...collisions.filter((collision) => collision !== pinned)]; | 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 | const nearest = collisions[0]; | 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 | const id = String(nearest.id); | 88 | const id = String(nearest.id); |
| 48 | const valid = validity.get(id) ?? isValidTarget(id); | 89 | const valid = validity.get(id) ?? isValidTarget(id); |
| 49 | validity.set(id, valid); | 90 | validity.set(id, valid); |
| ... | @@ -73,14 +114,10 @@ export function createSidebarSortingStrategy(input: { | ... | @@ -73,14 +114,10 @@ export function createSidebarSortingStrategy(input: { |
| 73 | 114 | ||
| 74 | function project({ rects, activeIndex, overIndex }: Layout) { | 115 | function project({ rects, activeIndex, overIndex }: Layout) { |
| 75 | const active = items[activeIndex]; | 116 | const active = items[activeIndex]; |
| 76 | const over = items[overIndex]; | 117 | const over = items[overIndex] ?? active; |
| 77 | if (active?.kind !== "thread" || !over || !rects[0]) return []; | 118 | if (active?.kind !== "thread" || !over || !rects[0]) return []; |
| 78 | const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); | 119 | const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); |
| 79 | if (!target) return []; | 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 | const groups: Record<SidebarSection, ThreadItem[]> = { | 121 | const groups: Record<SidebarSection, ThreadItem[]> = { |
| 85 | pinned: [], | 122 | pinned: [], |
| 86 | active: [], | 123 | active: [], |
| ... | @@ -89,9 +126,13 @@ export function createSidebarSortingStrategy(input: { | ... | @@ -89,9 +126,13 @@ export function createSidebarSortingStrategy(input: { |
| 89 | }; | 126 | }; |
| 90 | let cardHeight = input.cardHeight; | 127 | let cardHeight = input.cardHeight; |
| 91 | let slimHeight = input.slimHeight; | 128 | let slimHeight = input.slimHeight; |
| 129 | let headerScale: number | undefined; | ||
| 92 | for (const [index, item] of items.entries()) { | 130 | for (const [index, item] of items.entries()) { |
| 93 | if (item.kind === "marker") { | 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 | continue; | 136 | continue; |
| 96 | } | 137 | } |
| 97 | if (item.section === "pinned" || item.section === "active") | 138 | if (item.section === "pinned" || item.section === "active") |
| ... | @@ -100,7 +141,8 @@ export function createSidebarSortingStrategy(input: { | ... | @@ -100,7 +141,8 @@ export function createSidebarSortingStrategy(input: { |
| 100 | if (item.key !== active.key) groups[item.section].push(item); | 141 | if (item.key !== active.key) groups[item.section].push(item); |
| 101 | } | 142 | } |
| 102 | // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9. | 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 | cardHeight ??= 82 * scale; | 146 | cardHeight ??= 82 * scale; |
| 105 | slimHeight ??= 36 * scale; | 147 | slimHeight ??= 36 * scale; |
| 106 | const labelHeight = (input.boundaryLabelHeight ?? 0) * scale; | 148 | const labelHeight = (input.boundaryLabelHeight ?? 0) * scale; |
| ... | @@ -163,9 +205,11 @@ export function createSidebarSortingStrategy(input: { | ... | @@ -163,9 +205,11 @@ export function createSidebarSortingStrategy(input: { |
| 163 | item.kind === "marker" && | 205 | item.kind === "marker" && |
| 164 | (item.marker === "pinned-header" || item.marker === "pinned-divider") | 206 | (item.marker === "pinned-header" || item.marker === "pinned-divider") |
| 165 | ? labelHeight | 207 | ? labelHeight |
| 166 | : moved | 208 | : item.kind === "marker" && item.marker.endsWith("placeholder") |
| 167 | ? fallback | 209 | ? slimHeight |
| 168 | : (rect?.height ?? fallback); | 210 | : moved |
| 211 | ? fallback | ||
| 212 | : (rect?.height ?? fallback); | ||
| 169 | top += height + 1; | 213 | top += height + 1; |
| 170 | } | 214 | } |
| 171 | result[activeIndex] = stationary; | 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,12 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | import { | 3 | import { |
| 4 | DndContext, | 4 | DndContext, |
| 5 | PointerSensor, | ||
| 6 | useSensor, | 5 | useSensor, |
| 7 | useSensors, | 6 | useSensors, |
| 8 | type DragEndEvent, | 7 | type DragEndEvent, |
| 9 | type DragOverEvent, | 8 | type DragOverEvent, |
| 10 | type DragStartEvent, | 9 | type DragStartEvent, |
| 10 | type Modifier, | ||
| 11 | } from "@dnd-kit/core"; | 11 | } from "@dnd-kit/core"; |
| 12 | import { SortableContext, useSortable } from "@dnd-kit/sortable"; | 12 | import { SortableContext, useSortable } from "@dnd-kit/sortable"; |
| 13 | import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; | 13 | import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; |
| ... | @@ -172,7 +172,12 @@ import { | ... | @@ -172,7 +172,12 @@ import { |
| 172 | type SidebarSection, | 172 | type SidebarSection, |
| 173 | } from "./Sidebar.logic"; | 173 | } from "./Sidebar.logic"; |
| 174 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; | 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 | import { createSidebarListMotion } from "./Sidebar.motion"; | 181 | import { createSidebarListMotion } from "./Sidebar.motion"; |
| 177 | import { | 182 | import { |
| 178 | ThreadWorktreeIndicator, | 183 | ThreadWorktreeIndicator, |
| ... | @@ -549,8 +554,8 @@ function SortableSidebarMarker(props: { | ... | @@ -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 | 557 | // Empty targets stay measurable without reserving space at rest. The sorting |
| 553 | // the list's measured positions. | 558 | // strategy opens their hint space during a drag. |
| 554 | function SidebarSectionPlaceholder(props: { | 559 | function SidebarSectionPlaceholder(props: { |
| 555 | marker: "active-placeholder" | "settled-placeholder"; | 560 | marker: "active-placeholder" | "settled-placeholder"; |
| 556 | label: string; | 561 | label: string; |
| ... | @@ -561,26 +566,25 @@ function SidebarSectionPlaceholder(props: { | ... | @@ -561,26 +566,25 @@ function SidebarSectionPlaceholder(props: { |
| 561 | <SortableSidebarMarker | 566 | <SortableSidebarMarker |
| 562 | marker={props.marker} | 567 | marker={props.marker} |
| 563 | data-testid={`sidebar-${props.marker}`} | 568 | data-testid={`sidebar-${props.marker}`} |
| 564 | className={cn( | 569 | className="relative mx-0.5 h-0" |
| 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 | > | 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 | </SortableSidebarMarker> | 581 | </SortableSidebarMarker> |
| 572 | ); | 582 | ); |
| 573 | } | 583 | } |
| 574 | 584 | ||
| 575 | // Boundary labels appear during a drag in space the sorting strategy opens | 585 | // Zero-height markers reserve no label space at rest. During a drag the |
| 576 | // below each marker (SIDEBAR_DRAG_LABEL_HEIGHT), so they never sit on a row. | 586 | // sorting strategy opens 24px for a 16px label with 4px clearance on each side. |
| 577 | // The marker itself stays zero height, so nothing is reserved at rest and | 587 | const SIDEBAR_DRAG_LABEL_HEIGHT = 24; |
| 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; | ||
| 584 | 588 | ||
| 585 | function SidebarDragBoundary(props: { | 589 | function SidebarDragBoundary(props: { |
| 586 | marker: "pinned-header" | "pinned-divider"; | 590 | marker: "pinned-header" | "pinned-divider"; |
| ... | @@ -592,10 +596,10 @@ function SidebarDragBoundary(props: { | ... | @@ -592,10 +596,10 @@ function SidebarDragBoundary(props: { |
| 592 | <SortableSidebarMarker | 596 | <SortableSidebarMarker |
| 593 | marker={props.marker} | 597 | marker={props.marker} |
| 594 | data-testid={`sidebar-${props.marker}`} | 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 | {props.visible ? ( | 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 | <span | 603 | <span |
| 600 | className={cn( | 604 | className={cn( |
| 601 | "inline-flex h-4 shrink-0 items-center rounded-sm border bg-sidebar px-1.5 text-[10px] leading-none font-medium", | 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,8 +3025,15 @@ export default function Sidebar() { |
| 3021 | }, | 3025 | }, |
| 3022 | [unsnoozeThread], | 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 | const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null); | 3034 | const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null); |
| 3025 | const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { | 3035 | const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { |
| 3036 | threadListRef.current = node; | ||
| 3026 | listMotionRef.current?.dispose(); | 3037 | listMotionRef.current?.dispose(); |
| 3027 | listMotionRef.current = node === null ? null : createSidebarListMotion(node); | 3038 | listMotionRef.current = node === null ? null : createSidebarListMotion(node); |
| 3028 | listMotionRef.current?.update(false); | 3039 | listMotionRef.current?.update(false); |
| ... | @@ -3031,16 +3042,35 @@ export default function Sidebar() { | ... | @@ -3031,16 +3042,35 @@ export default function Sidebar() { |
| 3031 | // Hold the chosen section and order until every key write arrives. This | 3042 | // Hold the chosen section and order until every key write arrives. This |
| 3032 | // also covers first-time ordering, which assigns keys to keyless neighbors. | 3043 | // also covers first-time ordering, which assigns keys to keyless neighbors. |
| 3033 | // A failed write, concurrent reorder, or membership change releases the hold. | 3044 | // A failed write, concurrent reorder, or membership change releases the hold. |
| 3034 | const dndSensors = useSensors( | ||
| 3035 | useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), | ||
| 3036 | ); | ||
| 3037 | const [dragState, setDragState] = useState<{ | 3045 | const [dragState, setDragState] = useState<{ |
| 3038 | readonly activeKey: string; | 3046 | readonly activeKey: string; |
| 3039 | readonly activeSection: SidebarSection; | 3047 | readonly activeSection: SidebarSection; |
| 3040 | readonly occurredAt: string; | 3048 | readonly occurredAt: string; |
| 3041 | readonly activationY: number | null; | 3049 | readonly activationY: number | null; |
| 3050 | readonly targetSection: SidebarSection | null; | ||
| 3042 | } | null>(null); | 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 | const sectionByThreadKey = useMemo(() => { | 3074 | const sectionByThreadKey = useMemo(() => { |
| 3045 | const map = new Map<string, SidebarSection>(); | 3075 | const map = new Map<string, SidebarSection>(); |
| 3046 | const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => { | 3076 | const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => { |
| ... | @@ -3185,22 +3215,27 @@ export default function Sidebar() { | ... | @@ -3185,22 +3215,27 @@ export default function Sidebar() { |
| 3185 | if (activeSection === undefined) return; | 3215 | if (activeSection === undefined) return; |
| 3186 | // Stop normal section motion before dnd-kit measures the picked-up row. | 3216 | // Stop normal section motion before dnd-kit measures the picked-up row. |
| 3187 | listMotionRef.current?.suspend(); | 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 | setDragState({ | 3228 | setDragState({ |
| 3189 | activeKey, | 3229 | activeKey, |
| 3190 | activeSection, | 3230 | activeSection, |
| 3231 | targetSection: activeSection, | ||
| 3191 | occurredAt: new Date().toISOString(), | 3232 | occurredAt: new Date().toISOString(), |
| 3192 | activationY: | 3233 | activationY: |
| 3193 | event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, | 3234 | event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, |
| 3194 | }); | 3235 | }); |
| 3195 | setDragTargetSection(activeSection); | ||
| 3196 | }, | 3236 | }, |
| 3197 | [sectionByThreadKey], | 3237 | [sectionByThreadKey], |
| 3198 | ); | 3238 | ); |
| 3199 | const handleThreadDragCancel = useCallback(() => { | ||
| 3200 | listMotionRef.current?.release(); | ||
| 3201 | setDragState(null); | ||
| 3202 | setDragTargetSection(null); | ||
| 3203 | }, []); | ||
| 3204 | // Include every visible row in the measured order. Older servers disable | 3239 | // Include every visible row in the measured order. Older servers disable |
| 3205 | // pickup on their rows without changing where those rows render. | 3240 | // pickup on their rows without changing where those rows render. |
| 3206 | const sidebarListItems = useMemo((): readonly SidebarListItem[] => { | 3241 | const sidebarListItems = useMemo((): readonly SidebarListItem[] => { |
| ... | @@ -3249,6 +3284,14 @@ export default function Sidebar() { | ... | @@ -3249,6 +3284,14 @@ export default function Sidebar() { |
| 3249 | snoozedThreads.length, | 3284 | snoozedThreads.length, |
| 3250 | visibleSnoozedThreads, | 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 | const listMotionPaused = dragState !== null; | 3295 | const listMotionPaused = dragState !== null; |
| 3253 | useLayoutEffect(() => { | 3296 | useLayoutEffect(() => { |
| 3254 | // Drag release clears the baseline, so its commit cannot replay the | 3297 | // Drag release clears the baseline, so its commit cannot replay the |
| ... | @@ -3264,7 +3307,11 @@ export default function Sidebar() { | ... | @@ -3264,7 +3307,11 @@ export default function Sidebar() { |
| 3264 | const target = event.over | 3307 | const target = event.over |
| 3265 | ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) | 3308 | ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) |
| 3266 | : null; | 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 | [sidebarListItems], | 3316 | [sidebarListItems], |
| 3270 | ); | 3317 | ); |
| ... | @@ -3318,18 +3365,22 @@ export default function Sidebar() { | ... | @@ -3318,18 +3365,22 @@ export default function Sidebar() { |
| 3318 | }), | 3365 | }), |
| 3319 | [threads], | 3366 | [threads], |
| 3320 | ); | 3367 | ); |
| 3368 | const draggedThreadKey = dragState?.activeKey; | ||
| 3369 | const draggedFromSection = dragState?.activeSection; | ||
| 3370 | const dragActivationY = dragState?.activationY; | ||
| 3321 | const dndCollisionDetection = useMemo(() => { | 3371 | const dndCollisionDetection = useMemo(() => { |
| 3322 | if (dragState === null) return createSidebarCollisionDetection(() => true); | 3372 | if (draggedThreadKey === undefined || draggedFromSection === undefined) |
| 3323 | const source = threadByKey.get(dragState.activeKey); | 3373 | return createSidebarCollisionDetection(() => true); |
| 3374 | const source = threadByKey.get(draggedThreadKey); | ||
| 3324 | if (source === undefined) return createSidebarCollisionDetection(() => false); | 3375 | if (source === undefined) return createSidebarCollisionDetection(() => false); |
| 3325 | return createSidebarCollisionDetection( | 3376 | return createSidebarCollisionDetection( |
| 3326 | (id) => { | 3377 | (id) => { |
| 3327 | const target = resolveSidebarDropTarget(sidebarListItems, dragState.activeKey, id); | 3378 | const target = resolveSidebarDropTarget(sidebarListItems, draggedThreadKey, id); |
| 3328 | if (target === null) return false; | 3379 | if (target === null) return false; |
| 3329 | return ( | 3380 | return ( |
| 3330 | planSidebarThreadDrop({ | 3381 | planSidebarThreadDrop({ |
| 3331 | activeKey: dragState.activeKey, | 3382 | activeKey: draggedThreadKey, |
| 3332 | activeSection: dragState.activeSection, | 3383 | activeSection: draggedFromSection, |
| 3333 | activePinned: source.pinnedAt != null, | 3384 | activePinned: source.pinnedAt != null, |
| 3334 | activeSettled: source.settledOverride === "settled", | 3385 | activeSettled: source.settledOverride === "settled", |
| 3335 | supportsSettlement: | 3386 | supportsSettlement: |
| ... | @@ -3345,7 +3396,10 @@ export default function Sidebar() { | ... | @@ -3345,7 +3396,10 @@ export default function Sidebar() { |
| 3345 | }).kind !== "none" | 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 | activeKeysById, | 3405 | activeKeysById, |
| ... | @@ -3353,7 +3407,9 @@ export default function Sidebar() { | ... | @@ -3353,7 +3407,9 @@ export default function Sidebar() { |
| 3353 | serverConfigs, | 3407 | serverConfigs, |
| 3354 | activeKeys, | 3408 | activeKeys, |
| 3355 | activeReorderableThreadKeys, | 3409 | activeReorderableThreadKeys, |
| 3356 | dragState, | 3410 | draggedThreadKey, |
| 3411 | draggedFromSection, | ||
| 3412 | dragActivationY, | ||
| 3357 | draggableThreadKeys, | 3413 | draggableThreadKeys, |
| 3358 | pinnedKeys, | 3414 | pinnedKeys, |
| 3359 | sidebarListItems, | 3415 | sidebarListItems, |
| ... | @@ -3361,9 +3417,6 @@ export default function Sidebar() { | ... | @@ -3361,9 +3417,6 @@ export default function Sidebar() { |
| 3361 | ]); | 3417 | ]); |
| 3362 | const handleThreadDragEnd = useCallback( | 3418 | const handleThreadDragEnd = useCallback( |
| 3363 | (event: DragEndEvent) => { | 3419 | (event: DragEndEvent) => { |
| 3364 | listMotionRef.current?.release(); | ||
| 3365 | setDragState(null); | ||
| 3366 | setDragTargetSection(null); | ||
| 3367 | const activeKey = String(event.active.id); | 3420 | const activeKey = String(event.active.id); |
| 3368 | const activeSection = sectionByThreadKey.get(activeKey); | 3421 | const activeSection = sectionByThreadKey.get(activeKey); |
| 3369 | const target = | 3422 | const target = |
| ... | @@ -4547,12 +4600,16 @@ export default function Sidebar() { | ... | @@ -4547,12 +4600,16 @@ export default function Sidebar() { |
| 4547 | <DndContext | 4600 | <DndContext |
| 4548 | sensors={dndSensors} | 4601 | sensors={dndSensors} |
| 4549 | collisionDetection={dndCollisionDetection} | 4602 | collisionDetection={dndCollisionDetection} |
| 4550 | modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]} | 4603 | modifiers={[ |
| 4604 | restrictToVerticalAxis, | ||
| 4605 | restrictBelowPins, | ||
| 4606 | restrictToFirstScrollableAncestor, | ||
| 4607 | ]} | ||
| 4551 | onDragStart={handleThreadDragStart} | 4608 | onDragStart={handleThreadDragStart} |
| 4552 | onDragOver={handleThreadDragOver} | 4609 | onDragOver={handleThreadDragOver} |
| 4553 | onDragCancel={handleThreadDragCancel} | ||
| 4554 | onDragEnd={handleThreadDragEnd} | 4610 | onDragEnd={handleThreadDragEnd} |
| 4555 | > | 4611 | > |
| 4612 | <SidebarDragLifecycle onUnmount={cancelThreadDrag} /> | ||
| 4556 | <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}> | 4613 | <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}> |
| 4557 | <ul | 4614 | <ul |
| 4558 | ref={attachListMotionRef} | 4615 | ref={attachListMotionRef} |
apps/web/src/index.css+15| ... | @@ -2056,3 +2056,18 @@ code { | ... | @@ -2056,3 +2056,18 @@ code { |
| 2056 | transform: scaleX(0.9); | 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 | } |