1import { describe, expect, it, vi } from "vite-plus/test";
2import { closestCenter, type CollisionDetection } from "@dnd-kit/core";
3import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
4import {
5 createSidebarCollisionDetection,
6 createSidebarSortingStrategy,
7 restrictBelowSidebarLabel,
8} from "./Sidebar.drag";
9import {
10 resolveSidebarDropTarget,
11 sidebarListItemId,
12 sidebarMarkerId,
13 type SidebarListItem,
14 type SidebarListMarker,
15 type SidebarSection,
16} from "./Sidebar.logic";
17
18const thread = (key: string, section: SidebarSection): SidebarListItem => ({
19 kind: "thread",
20 key,
21 section,
22});
23const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker });
24const pinnedHeader = marker("pinned-header");
25const divider = marker("pinned-divider");
26const settledHeader = marker("settled-header");
27const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
28
29function layout(
30 items: readonly SidebarListItem[],
31 active: string,
32 over: string,
33 scale = 1,
34 cardHeight = 82,
35) {
36 let top = 100;
37 const rects = items.map((item) => {
38 const height =
39 item.kind === "thread"
40 ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale
41 : item.marker === "pinned-header" || item.marker === "pinned-divider"
42 ? 0
43 : (item.marker.endsWith("placeholder") ? 0 : 32) * scale;
44 const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 };
45 top += height + 1;
46 return rect;
47 });
48 const activeIndex = items.findIndex((item) => sidebarListItemId(item) === active);
49 return {
50 activeIndex,
51 overIndex: items.findIndex((item) => sidebarListItemId(item) === over),
52 activeNodeRect: rects[activeIndex]!,
53 rects,
54 index: 0,
55 } satisfies Parameters<SortingStrategy>[0];
56}
57
58function preview(
59 input: Parameters<typeof createSidebarSortingStrategy>[0],
60 active: string,
61 over: string,
62 scale = 1,
63) {
64 const strategy = createSidebarSortingStrategy(input);
65 const args = layout(input.items, active, over, scale);
66 return new Map(
67 input.items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]),
68 );
69}
70
71describe("sidebar collision detection", () => {
72 function collisionArgs(blockedAboveSource = false) {
73 const rows = [thread("source", "active"), thread("blocked", "active")];
74 const items = [
75 pinnedHeader,
76 divider,
77 ...(blockedAboveSource ? rows.toReversed() : rows),
78 settledHeader,
79 marker("settled-placeholder"),
80 ];
81 const { rects, activeIndex, overIndex } = layout(items, "source", "blocked");
82 const collisionRect = rects[overIndex]!;
83 return {
84 active: {
85 id: "source",
86 data: { current: {} },
87 rect: { current: { initial: rects[activeIndex]!, translated: collisionRect } },
88 },
89 collisionRect,
90 droppableRects: new Map(items.map((item, index) => [sidebarListItemId(item), rects[index]!])),
91 droppableContainers: items.map((item, index) => ({
92 id: sidebarListItemId(item),
93 key: sidebarListItemId(item),
94 disabled: false,
95 data: { current: {} },
96 node: { current: null },
97 rect: { current: rects[index]! },
98 })),
99 pointerCoordinates: null,
100 } satisfies Parameters<CollisionDetection>[0];
101 }
102
103 it.each([
104 [false, sidebarMarkerId("settled-header")],
105 [true, sidebarMarkerId("pinned-divider")],
106 ] as const)(
107 "rejects unsupported Active instead of selecting %s / %s",
108 (blockedAboveSource, nearbyTarget) => {
109 const args = collisionArgs(blockedAboveSource);
110 const detector = createSidebarCollisionDetection((id) => id !== "blocked");
111 const filtered = closestCenter({
112 ...args,
113 droppableContainers: args.droppableContainers.filter(
114 (container) => container.id !== "blocked",
115 ),
116 });
117 expect(filtered[0]?.id).toBe(nearbyTarget);
118 expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
119 },
120 );
121
122 it("selects the nearest supported target", () => {
123 const detector = createSidebarCollisionDetection(() => true);
124 expect(detector(collisionArgs())[0]?.id).toBe("blocked");
125 });
126
127 it.each([
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 );
213
214 it("returns no collision if an unsupported target has no source fallback", () => {
215 const args = collisionArgs();
216 const detector = createSidebarCollisionDetection(() => false);
217 expect(
218 detector({
219 ...args,
220 droppableContainers: args.droppableContainers.filter(
221 (container) => container.id !== "source",
222 ),
223 }),
224 ).toEqual([]);
225 });
226
227 it("validates each hovered target once and always allows returning to the source", () => {
228 const args = collisionArgs();
229 const isValid = vi.fn((id: string) => id !== "blocked");
230 const detector = createSidebarCollisionDetection(isValid);
231 expect(detector(args)[0]?.id).toBe("source");
232 expect(
233 detector({
234 ...args,
235 collisionRect: {
236 ...args.collisionRect,
237 top: args.collisionRect.top + 3,
238 bottom: args.collisionRect.bottom + 3,
239 },
240 })[0]?.id,
241 ).toBe("source");
242 expect(detector({ ...args, collisionRect: args.droppableRects.get("source")! })[0]?.id).toBe(
243 "source",
244 );
245 expect(
246 detector({
247 ...args,
248 collisionRect: args.droppableRects.get(sidebarMarkerId("settled-placeholder"))!,
249 })[0]?.id,
250 ).toBe(sidebarMarkerId("settled-placeholder"));
251 expect(isValid.mock.calls).toEqual([["blocked"], [sidebarMarkerId("settled-placeholder")]]);
252 });
253});
254
255describe("sidebar drag projection", () => {
256 it.each([
257 ["a2", "a1"],
258 ["p", "a1"],
259 ["s", sidebarMarkerId("pinned-header")],
260 ["z", sidebarMarkerId("settled-header")],
261 ])("restores every row and marker when dragging %s out after hovering %s", (active, over) => {
262 const items = [
263 pinnedHeader,
264 thread("p", "pinned"),
265 divider,
266 marker("active-placeholder"),
267 thread("a1", "active"),
268 thread("a2", "active"),
269 marker("snoozed-header"),
270 thread("z", "snoozed"),
271 settledHeader,
272 marker("settled-placeholder"),
273 thread("s", "settled"),
274 ];
275 const input = {
276 items,
277 settledOrder: ["z", "s"],
278 settledExpanded: true,
279 boundaryLabelHeight: 24,
280 snoozedThreadCount: 1,
281 };
282 const reordered = preview(input, active, over);
283 expect([...reordered.values()].some((transform) => transform?.y !== 0)).toBe(true);
284
285 const restored = preview({ ...input, enabled: false }, active, over);
286 for (const transform of restored.values()) expect(transform).toEqual(stationary);
287
288 // Returning to the sidebar resumes the same live reorder preview.
289 expect(preview({ ...input, enabled: true }, active, over)).toEqual(reordered);
290 });
291
292 it.each([
293 ["a1", "a2"],
294 ["a1", sidebarMarkerId("settled-header")],
295 ["z", "a2"],
296 ])("keeps sparse shelves at the bottom when dragging %s over %s", (active, over) => {
297 const items = [
298 pinnedHeader,
299 divider,
300 thread("a1", "active"),
301 thread("a2", "active"),
302 marker("snoozed-header"),
303 thread("z", "snoozed"),
304 settledHeader,
305 thread("s", "settled"),
306 ];
307 const strategy = createSidebarSortingStrategy({
308 items,
309 settledOrder: over === sidebarMarkerId("settled-header") ? ["a1", "s"] : ["s"],
310 settledExpanded: true,
311 boundaryLabelHeight: 24,
312 snoozedThreadCount: 1,
313 });
314 const args = layout(items, active, over);
315 for (const rect of args.rects.slice(4)) {
316 rect.top += 400;
317 rect.bottom += 400;
318 }
319 const lastIndex = items.length - 1;
320 expect(strategy({ ...args, index: lastIndex })).toEqual(stationary);
321 });
322
323 const pinned = [
324 pinnedHeader,
325 thread("p1", "pinned"),
326 thread("p2", "pinned"),
327 divider,
328 thread("a1", "active"),
329 settledHeader,
330 thread("s1", "settled"),
331 ];
332
333 it.each([
334 ["p1", "p2"],
335 ["p2", "p1"],
336 ])("preserves existing pinned transforms from %s to %s", (active, over) => {
337 const strategy = createSidebarSortingStrategy({
338 items: pinned,
339 settledOrder: [],
340 settledExpanded: true,
341 });
342 const args = layout(pinned, active, over);
343 // dnd-kit moves the lifted row by the pointer delta, so only peers matter.
344 for (let index = 0; index < pinned.length; index += 1) {
345 if (index === args.activeIndex) continue;
346 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
347 }
348 });
349
350 it("keeps the pinned header above the gap when a lower pin moves to the top", () => {
351 const result = preview(
352 { items: pinned, settledOrder: [], settledExpanded: true },
353 "p2",
354 sidebarMarkerId("pinned-header"),
355 );
356 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
357 expect(result.get("p1")).toEqual({ ...stationary, y: 83 });
358 expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary);
359 expect(result.get("a1")).toEqual(stationary);
360 });
361
362 it.each([
363 ["a1", "a2"],
364 ["a2", "a1"],
365 ])("uses pinned dragging behavior for Active from %s to %s", (active, over) => {
366 const items = [
367 pinnedHeader,
368 thread("p", "pinned"),
369 divider,
370 thread("a1", "active"),
371 thread("a2", "active"),
372 settledHeader,
373 thread("s", "settled"),
374 ];
375 const strategy = createSidebarSortingStrategy({
376 items,
377 settledOrder: [],
378 settledExpanded: true,
379 });
380 const args = layout(items, active, over);
381 for (let index = 0; index < items.length; index += 1) {
382 if (index === args.activeIndex) continue;
383 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
384 }
385 });
386
387 it("preserves settled order while opening the zero-height Active target", () => {
388 const items = [
389 pinnedHeader,
390 divider,
391 marker("active-placeholder"),
392 settledHeader,
393 thread("first", "settled"),
394 thread("second", "settled"),
395 ];
396 const result = preview(
397 { items, settledOrder: ["first", "second"], settledExpanded: true },
398 "second",
399 "first",
400 );
401 expect(result.get(sidebarMarkerId("active-placeholder"))).toEqual(stationary);
402 expect(result.get(sidebarMarkerId("settled-header"))).toEqual({ ...stationary, y: 36 });
403 expect(result.get("first")).toEqual({ ...stationary, y: 36 });
404 expect(result.get("second")).toEqual(stationary);
405 });
406
407 it.each([
408 [sidebarMarkerId("pinned-divider"), 0, 0],
409 ["a1", -83, 0],
410 ["a2", -83, -83],
411 ] as const)(
412 "opens the active pointer slot over %s without adding an empty pinned row",
413 (over, a1Offset, a2Offset) => {
414 const items = [
415 pinnedHeader,
416 thread("p", "pinned"),
417 divider,
418 thread("a1", "active"),
419 thread("a2", "active"),
420 settledHeader,
421 thread("s", "settled"),
422 ];
423 const result = preview({ items, settledOrder: [], settledExpanded: true }, "p", over);
424 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
425 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-83);
426 expect(result.get("a1")?.y).toBe(a1Offset);
427 expect(result.get("a2")?.y).toBe(a2Offset);
428 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);
429 },
430 );
431
432 it("opens label space below each pinned boundary while dragging", () => {
433 const items = [
434 pinnedHeader,
435 thread("p", "pinned"),
436 divider,
437 thread("a1", "active"),
438 thread("a2", "active"),
439 settledHeader,
440 thread("s", "settled"),
441 ];
442 // Reorder inside active: the header gap shifts every row, the divider
443 // gap shifts the active rows and the shelf below by a second label.
444 const result = preview(
445 { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 },
446 "a2",
447 "a1",
448 );
449 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
450 expect(result.get("p")?.y).toBe(16);
451 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(16);
452 expect(result.get("a2")).toEqual(stationary);
453 expect(result.get("a1")?.y).toBe(32 + 83);
454 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(32);
455 expect(result.get("s")?.y).toBe(32);
456 });
457
458 it.each(["s1", "missing-target"])(
459 "keeps label clearance when a settled drag is over %s",
460 (over) => {
461 const items = [
462 pinnedHeader,
463 thread("p", "pinned"),
464 divider,
465 thread("a", "active"),
466 settledHeader,
467 thread("s1", "settled"),
468 thread("s2", "settled"),
469 ];
470 const result = preview(
471 {
472 items,
473 settledOrder: ["s1", "s2"],
474 settledExpanded: true,
475 boundaryLabelHeight: 24,
476 },
477 "s2",
478 over,
479 );
480 expect(result.get("p")?.y).toBe(24);
481 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(24);
482 expect(result.get("a")?.y).toBe(48);
483 expect(result.get("s1")?.y).toBe(48);
484 expect(result.get("s2")).toEqual(stationary);
485 },
486 );
487
488 it("stacks the labels with their gaps when the pinned section is empty", () => {
489 const items = [
490 pinnedHeader,
491 divider,
492 thread("a1", "active"),
493 thread("a2", "active"),
494 settledHeader,
495 thread("s", "settled"),
496 ];
497 const result = preview(
498 { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 },
499 "a2",
500 "a1",
501 );
502 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
503 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(16);
504 expect(result.get("a1")?.y).toBe(32 + 83);
505 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(32);
506 });
507
508 it("scales the label space with the measured root scale", () => {
509 const items = [pinnedHeader, thread("p", "pinned"), divider, thread("a1", "active")];
510 const result = preview(
511 { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 },
512 "a1",
513 "p",
514 2,
515 );
516 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(32 + 165);
517 });
518
519 it("keeps the pinned header above the first arriving pin", () => {
520 const items = [
521 pinnedHeader,
522 divider,
523 thread("a1", "active"),
524 thread("a2", "active"),
525 settledHeader,
526 thread("s", "settled"),
527 ];
528 const result = preview(
529 { items, settledOrder: [], settledExpanded: true },
530 "a2",
531 sidebarMarkerId("pinned-header"),
532 );
533 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
534 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(83);
535 expect(result.get("a1")?.y).toBe(83);
536 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);
537 });
538
539 it.each([
540 ["p", -83, -1],
541 ["s", 0, 82],
542 ] as const)(
543 "replaces the empty Active target when %s enters",
544 (active, dividerOffset, settledOffset) => {
545 const items = [
546 pinnedHeader,
547 thread("p", "pinned"),
548 divider,
549 marker("active-placeholder"),
550 settledHeader,
551 thread("s", "settled"),
552 ];
553 const result = preview(
554 { items, settledOrder: [], settledExpanded: true },
555 active,
556 sidebarMarkerId("active-placeholder"),
557 );
558 expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0);
559 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(dividerOffset);
560 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(settledOffset);
561 },
562 );
563
564 it("uses the canonical settled rank and the destination's slim height", () => {
565 const items = [
566 pinnedHeader,
567 thread("p", "pinned"),
568 divider,
569 thread("a", "active"),
570 settledHeader,
571 thread("s1", "settled"),
572 thread("s2", "settled"),
573 ];
574 const result = preview(
575 { items, settledOrder: ["s1", "a", "s2"], settledExpanded: true },
576 "a",
577 "s2",
578 );
579 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46);
580 expect(result.get("s1")?.y).toBe(-46);
581 expect(result.get("s2")?.y).toBe(-9);
582 });
583
584 it.each([
585 ["a1", 83],
586 ["a2", 0],
587 ] as const)(
588 "reserves a full card at the pointer slot over %s when a slim row enters Active",
589 (over, firstOffset) => {
590 const items = [
591 pinnedHeader,
592 thread("p", "pinned"),
593 divider,
594 thread("a1", "active"),
595 thread("a2", "active"),
596 settledHeader,
597 thread("s", "settled"),
598 ];
599 const result = preview({ items, settledOrder: [], settledExpanded: true }, "s", over);
600 expect(result.get("a1")?.y).toBe(firstOffset);
601 expect(result.get("a2")?.y).toBe(83);
602 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83);
603 },
604 );
605
606 it("removes the snoozed header when its last row leaves", () => {
607 const items = [
608 pinnedHeader,
609 thread("p", "pinned"),
610 divider,
611 thread("a", "active"),
612 marker("snoozed-header"),
613 thread("z", "snoozed"),
614 settledHeader,
615 thread("s", "settled"),
616 ];
617 const result = preview({ items, settledOrder: [], settledExpanded: true }, "z", "a");
618 expect(result.get(sidebarMarkerId("snoozed-header"))?.scaleY).toBe(0);
619 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(13);
620 expect(result.get("s")?.y).toBe(13);
621 });
622
623 it("keeps a collapsed settled target without inserting a hidden row", () => {
624 const items = [
625 pinnedHeader,
626 thread("p", "pinned"),
627 divider,
628 thread("a1", "active"),
629 thread("a2", "active"),
630 settledHeader,
631 marker("settled-placeholder"),
632 ];
633 const result = preview(
634 { items, settledOrder: [], settledExpanded: false },
635 "a2",
636 sidebarMarkerId("settled-placeholder"),
637 );
638 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-83);
639 expect(result.get(sidebarMarkerId("settled-placeholder"))).toEqual({ ...stationary, y: -83 });
640 });
641
642 it("preserves a collapsed snoozed header while another section changes", () => {
643 const items = [
644 pinnedHeader,
645 thread("p", "pinned"),
646 divider,
647 thread("a", "active"),
648 marker("snoozed-header"),
649 settledHeader,
650 marker("settled-placeholder"),
651 ];
652 const result = preview(
653 { items, settledOrder: [], settledExpanded: false },
654 "a",
655 sidebarMarkerId("settled-placeholder"),
656 );
657 expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 });
658 });
659
660 it("previews a time-ordered inbox drop at its time slot, above the Working shelf", () => {
661 const items = [
662 pinnedHeader,
663 thread("p", "pinned"),
664 divider,
665 thread("a1", "active"),
666 thread("a2", "active"),
667 marker("working-header"),
668 thread("w", "working"),
669 settledHeader,
670 marker("settled-placeholder"),
671 ];
672 const input = { items, settledOrder: [], settledExpanded: false };
673 // By pointer, the unpinned row lands between a1 and a2.
674 expect(preview(input, "p", "a1").get("a2")?.y).toBe(0);
675 // By time, it lands below a2, and the shelf does not move.
676 const byTime = preview({ ...input, activeOrder: ["a1", "a2", "p"] }, "p", "a1");
677 expect(byTime.get("a2")?.y).toBe(-83);
678 expect(byTime.get(sidebarMarkerId("working-header"))).toEqual(stationary);
679 expect(byTime.get("w")).toEqual(stationary);
680 });
681
682 it("derives missing card geometry from the measured root scale", () => {
683 const items = [
684 pinnedHeader,
685 divider,
686 marker("active-placeholder"),
687 settledHeader,
688 thread("s", "settled"),
689 ];
690 const result = preview(
691 { items, settledOrder: [], settledExpanded: true },
692 "s",
693 sidebarMarkerId("pinned-header"),
694 0.75,
695 );
696 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
697 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(62.5);
698 expect(result.get(sidebarMarkerId("active-placeholder"))?.y).toBe(62.5);
699 });
700
701 it("updates the projection when the target or measured geometry changes", () => {
702 const strategy = createSidebarSortingStrategy({
703 items: pinned,
704 settledOrder: [],
705 settledExpanded: true,
706 });
707 const args = layout(pinned, "p1", "p1");
708 expect(strategy({ ...args, index: 2 })?.y).toBe(0);
709 expect(strategy({ ...args, index: 2, overIndex: 4 })?.y).toBe(-83);
710 const smaller = layout(pinned, "p1", "a1", 0.75);
711 expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5);
712 });
713
714 it.each(["active", "settled"] as const)(
715 "reveals the mounted empty %s target when its last row leaves and hides it on return",
716 (section) => {
717 const items = [
718 pinnedHeader,
719 thread("p", "pinned"),
720 divider,
721 marker("active-placeholder"),
722 thread("a", "active"),
723 settledHeader,
724 marker("settled-placeholder"),
725 thread("s", "settled"),
726 ];
727 const active = section === "active" ? "a" : "s";
728 const input = { items, settledOrder: ["s"], settledExpanded: true };
729 const placeholderId = sidebarMarkerId(`${section}-placeholder`);
730 const resting = preview(input, active, active);
731 expect(resting.get(placeholderId)?.scaleY).toBe(0);
732 const leaving = preview(input, active, sidebarMarkerId("pinned-header"));
733 expect(leaving.get(placeholderId)?.scaleY).toBe(1);
734 const returning = preview(input, active, active);
735 expect(returning.get(placeholderId)?.scaleY).toBe(0);
736 },
737 );
738
739 it("uses shelf height for empty target sizing when card height differs from its default", () => {
740 const items = [
741 pinnedHeader,
742 thread("p", "pinned"),
743 divider,
744 thread("a", "active"),
745 settledHeader,
746 marker("settled-placeholder"),
747 ];
748 const strategy = createSidebarSortingStrategy({
749 items,
750 settledOrder: [],
751 settledExpanded: false,
752 });
753 const args = layout(items, "a", sidebarMarkerId("settled-placeholder"), 1, 78);
754 expect(strategy({ ...args, index: 4 })?.y).toBe(-42);
755 });
756
757 it("keeps the route row visible after a settled drop pushes it beyond the page", () => {
758 const items = [
759 pinnedHeader,
760 thread("p", "pinned"),
761 divider,
762 thread("a", "active"),
763 settledHeader,
764 thread("s", "settled"),
765 ];
766 const input = {
767 items,
768 settledOrder: ["a", "s", "hidden"],
769 settledExpanded: true,
770 settledVisibleCount: 1,
771 };
772 const withRoute = preview({ ...input, routeThreadKey: "s" }, "a", "s");
773 const withoutRoute = preview(input, "a", "s");
774 expect(withRoute.get("s")).toEqual({ ...stationary, y: -9 });
775 expect(withoutRoute.get("s")?.scaleY).toBe(0);
776 });
777
778 it("reserves the next page row when a visible settled thread leaves", () => {
779 const items = [
780 pinnedHeader,
781 thread("p", "pinned"),
782 divider,
783 thread("a", "active"),
784 settledHeader,
785 thread("s1", "settled"),
786 thread("route", "settled"),
787 ];
788 const result = preview(
789 {
790 items,
791 settledOrder: ["s1", "hidden", "route"],
792 settledExpanded: true,
793 settledVisibleCount: 1,
794 routeThreadKey: "route",
795 },
796 "s1",
797 "a",
798 );
799 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83);
800 expect(result.get("route")?.y).toBe(83);
801 });
802
803 it("keeps the dropped route thread visible in a collapsed settled shelf", () => {
804 const items = [
805 pinnedHeader,
806 thread("p", "pinned"),
807 divider,
808 thread("a", "active"),
809 settledHeader,
810 marker("settled-placeholder"),
811 ];
812 const result = preview(
813 {
814 items,
815 settledOrder: ["a", "hidden"],
816 settledExpanded: false,
817 settledVisibleCount: 1,
818 routeThreadKey: "a",
819 },
820 "a",
821 sidebarMarkerId("settled-placeholder"),
822 );
823 expect(result.get(sidebarMarkerId("settled-placeholder"))?.scaleY).toBe(0);
824 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46);
825 });
826
827 it("preserves hidden snoozed membership when the only rendered route row leaves", () => {
828 const items = [
829 pinnedHeader,
830 thread("p", "pinned"),
831 divider,
832 thread("a", "active"),
833 marker("snoozed-header"),
834 thread("z", "snoozed"),
835 settledHeader,
836 thread("s", "settled"),
837 ];
838 const result = preview(
839 {
840 items,
841 settledOrder: ["s"],
842 settledExpanded: true,
843 snoozedThreadCount: 2,
844 },
845 "z",
846 "a",
847 );
848 expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: 83 });
849 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46);
850 });
851});
852
853describe("lifted card clearance", () => {
854 const rect = (top: number, height: number) => ({
855 top,
856 bottom: top + height,
857 height,
858 left: 0,
859 right: 260,
860 width: 260,
861 });
862 const apply = (cardTop: number, cardHeight: number, y: number, listTop = 136, offset = 32) =>
863 restrictBelowSidebarLabel(
864 {
865 transform: { ...stationary, y },
866 containerNodeRect: rect(listTop, 500),
867 draggingNodeRect: rect(cardTop, cardHeight),
868 activatorEvent: null,
869 active: null,
870 activeNodeRect: null,
871 over: null,
872 overlayNodeRect: null,
873 scrollableAncestors: [],
874 scrollableAncestorRects: [],
875 windowRect: null,
876 },
877 offset,
878 );
879
880 it.each([36, 82])("keeps a %ipx row below empty Pins even past the top edge", (height) => {
881 for (const pointerY of [150, 136, 100, 0]) {
882 const transform = apply(511, height, pointerY - 529);
883 expect(511 + transform.y).toBe(168);
884 }
885 });
886
887 it("preserves pointer movement below the label", () => {
888 expect(apply(511, 36, -200).y).toBe(-200);
889 });
890
891 it("follows the list when it scrolls and includes content preceding Pins", () => {
892 expect(511 + apply(511, 36, -500, 96).y).toBe(128);
893 expect(511 + apply(511, 36, -500, 136, 114).y).toBe(250);
894 });
895});