diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index c757eca87aa4..9a19c102e07d 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -801,7 +801,14 @@ describe("lifted card clearance", () => { right: 260, width: 260, }); - const apply = (cardTop: number, cardHeight: number, y: number, listTop = 136, offset = 32) => + const apply = ( + cardTop: number, + cardHeight: number, + y: number, + listTop = 136, + offset = 32, + contentTop?: number, + ) => restrictBelowSidebarLabel( { transform: { ...stationary, y }, @@ -817,6 +824,7 @@ describe("lifted card clearance", () => { windowRect: null, }, offset, + contentTop, ); it.each([36, 82])("keeps a %ipx row below empty Pins even past the top edge", (height) => { @@ -826,6 +834,11 @@ describe("lifted card clearance", () => { } }); + it("uses the scrolled list origin instead of a virtual row container", () => { + expect(apply(511, 36, -200, 511, 32, 136).y).toBe(-200); + expect(apply(511, 36, -800, 511, 32, -164).y).toBe(-643); + }); + it("preserves pointer movement below the label", () => { expect(apply(511, 36, -200).y).toBe(-200); }); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 0b5710c671fc..db002c09f339 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -19,9 +19,10 @@ type Layout = Parameters[0]; export function restrictBelowSidebarLabel( { transform, containerNodeRect, draggingNodeRect }: Parameters[0], offset: number, + listTop = containerNodeRect?.top, ) { - if (!containerNodeRect || !draggingNodeRect) return transform; - const minimumY = containerNodeRect.top + offset - draggingNodeRect.top; + if (listTop === undefined || !draggingNodeRect) return transform; + const minimumY = listTop + offset - draggingNodeRect.top; return transform.y < minimumY ? { ...transform, y: minimumY } : transform; } diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 8313bed9daab..c493c0b632c7 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -36,8 +36,15 @@ class TestRow { readonly name: string, public offsetHeight = 82, ) {} + get firstElementChild(): TestRow | null { + return this.children[0] ?? null; + } getBoundingClientRect() { - return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight }; + return { + top: this.offsetTop + this.dragTranslate, + left: this.offsetLeft, + height: this.offsetHeight, + }; } setAttribute(name: string, value: string) { this.removeAttribute(name); @@ -65,12 +72,18 @@ class TestRow { }); } -function fixture(rows: TestRow[]) { +function fixture(rows: TestRow[], virtual = false) { const media = { matches: false }; const parent = { children: rows, ownerDocument: { defaultView: { matchMedia: () => media } }, - getBoundingClientRect: () => ({ top: 0 }), + getBoundingClientRect: () => ({ top: 0, left: 0 }), + scrollTop: 50, + scrollLeft: 0, + querySelectorAll: () => + parent.children.filter((row) => + row.attributes.some((attribute) => attribute.name === "data-sidebar-list-key"), + ), append(node: TestRow) { parent.children.push(node); node.remove.mockImplementation(() => { @@ -81,6 +94,7 @@ function fixture(rows: TestRow[]) { function layout(next: TestRow[]) { let top = 8; for (const row of next) { + if (virtual) row.setAttribute("data-sidebar-list-key", row.name); row.offsetTop = top; top += row.offsetHeight + 1; } @@ -90,7 +104,7 @@ function fixture(rows: TestRow[]) { ]; } layout(rows); - const motion = createSidebarListMotion(parent as unknown as HTMLUListElement); + const motion = createSidebarListMotion(parent as unknown as HTMLUListElement, { virtual }); return { motion, layout, media, parent }; } @@ -172,6 +186,25 @@ describe("sidebar list motion", () => { expectMove(c, 16); }); + it("preserves child drag transforms when releasing virtual rows in a scrolled viewport", () => { + const [a, b] = [new TestRow("a wrapper"), new TestRow("b wrapper")]; + const [aRow, bRow] = [new TestRow("a"), new TestRow("b")]; + a.children = [aRow]; + b.children = [bRow]; + const { motion, layout } = fixture([a, b], true); + aRow.offsetTop = a.offsetTop; + bRow.offsetTop = b.offsetTop; + motion.update(false); + aRow.dragTranslate = 130; + bRow.dragTranslate = -83; + motion.release(); + layout([b, a]); + aRow.dragTranslate = bRow.dragTranslate = 0; + motion.update(true); + expectMove(a, 47); + expect(b.animate).not.toHaveBeenCalled(); + }); + it("does not glide on release when motion is reduced", () => { const [a, b] = [new TestRow("a"), new TestRow("b")]; const { motion, layout, media } = fixture([a, b]); @@ -259,6 +292,22 @@ describe("sidebar list motion", () => { expect(a.animate).toHaveBeenCalledTimes(2); }); + it("excludes virtual exit clones from later row motion", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout, parent } = fixture([a, b], true); + motion.update(true); + layout([b]); + motion.update(true); + const clone = a.clones[0]!; + expect(parent.children.includes(clone)).toBe(true); + expect(parent.querySelectorAll().length).toBe(1); + expect(parent.querySelectorAll()[0]).toBe(b); + motion.update(true); + expect(clone.animations).toHaveLength(1); + expect(clone.clones).toHaveLength(0); + }); + it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => { const a = new TestRow("a"); const b = new TestRow("b"); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index d70c839d4941..3215d5039d86 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -16,7 +16,7 @@ function progress(animation: Animation) { /** Animate rows between their layout positions. The list must be * positioned so every direct child's offsetTop has the same origin. */ -export function createSidebarListMotion(parent: HTMLUListElement) { +export function createSidebarListMotion(parent: HTMLElement, options?: { virtual: boolean }) { let positions: Map | null = null; let disposed = false; const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( @@ -29,6 +29,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) { // commit can glide every row from where dnd-kit left it into its slot. let released: Map | null = null; + const rows = () => + Array.from( + options?.virtual + ? parent.querySelectorAll("[data-sidebar-list-key]") + : parent.children, + ).filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)); + const remainingOffset = (node: HTMLElement) => { const current = running.get(node); return current ? current.offset * (1 - progress(current.animation)) : 0; @@ -48,6 +55,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if ( (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") || attribute.name === "data-thread-item" || + attribute.name === "data-sidebar-list-key" || attribute.name === "data-thread-selection-safe" || attribute.name === "data-testid" ) { @@ -118,17 +126,24 @@ export function createSidebarListMotion(parent: HTMLUListElement) { update(animate: boolean) { if (disposed) return; const next = new Map( - Array.from(parent.children) - .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) - .map((node) => [ - node, - { - top: node.offsetTop, - left: node.offsetLeft, - width: node.offsetWidth, - height: node.offsetHeight, - }, - ]), + rows().map((node) => [ + node, + { + top: options?.virtual + ? node.getBoundingClientRect().top - + parent.getBoundingClientRect().top + + parent.scrollTop - + remainingOffset(node) + : node.offsetTop, + left: options?.virtual + ? node.getBoundingClientRect().left - + parent.getBoundingClientRect().left + + parent.scrollLeft + : node.offsetLeft, + width: node.offsetWidth, + height: node.offsetHeight, + }, + ]), ); let fadeCount = 0; if (positions !== null) { @@ -199,11 +214,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) { * next update glides each of them into its committed slot. */ release() { suspend(); - const origin = parent.getBoundingClientRect().top; + const origin = parent.getBoundingClientRect().top - (options?.virtual ? parent.scrollTop : 0); released = new Map( - Array.from(parent.children) - .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) - .map((node) => [node, node.getBoundingClientRect().top - origin]), + rows().map((node) => [ + node, + (options?.virtual ? (node.firstElementChild ?? node) : node).getBoundingClientRect().top - + origin, + ]), ); }, suspend, diff --git a/apps/web/src/components/Sidebar.pointer.test.ts b/apps/web/src/components/Sidebar.pointer.test.ts index 9a82edbd6dc7..f68b08e61aa7 100644 --- a/apps/web/src/components/Sidebar.pointer.test.ts +++ b/apps/web/src/components/Sidebar.pointer.test.ts @@ -33,16 +33,17 @@ function gesture() { onPending: vi.fn(), }; const onFinish = vi.fn(); + const onBeforeStart = vi.fn(); // The sensor never reads dnd-kit's layout context or active node. const props = { active: "thread", event: pointer("pointerdown"), - options: { distance: 6, onAttach: vi.fn(), onFinish }, + options: { distance: 6, onAttach: vi.fn(), onFinish, onBeforeStart }, ...callbacks, } as unknown as SensorProps[0]["options"]>; const sensor = new SidebarPointerSensor(props); sensors.push(sensor); - return { sensor, onFinish, ...callbacks }; + return { sensor, onFinish, onBeforeStart, ...callbacks }; } beforeEach(() => { @@ -61,6 +62,34 @@ afterEach(() => { }); describe("sidebar pointer lifecycle", () => { + it("prepares virtual rows before drag capture, without materializing ordinary clicks", () => { + const drag = gesture(); + document.dispatchEvent(pointer("pointermove", { clientY: 14 })); + expect(drag.onBeforeStart).not.toHaveBeenCalled(); + drag.onStart.mockImplementation(() => expect(drag.onBeforeStart).toHaveBeenCalledOnce()); + document.dispatchEvent(pointer("pointermove", { clientY: 20 })); + expect(drag.onStart).toHaveBeenCalledOnce(); + }); + + it("cancels a failed preparation and ignores subsequent pointer events", () => { + const listeners = vi.spyOn(document, "addEventListener"); + const drag = gesture(); + const failure = new Error("Unable to prepare rows"); + drag.onBeforeStart.mockImplementation(() => { + throw failure; + }); + const move = listeners.mock.calls.find(([name]) => name === "pointermove")?.[1]; + if (typeof move !== "function") throw new Error("Missing move listener"); + expect(() => move.call(document, pointer("pointermove", { clientY: 20 }))).toThrow(failure); + document.dispatchEvent(pointer("pointermove", { clientY: 50 })); + document.dispatchEvent(pointer("pointerup", { buttons: 0 })); + expect(drag.onFinish).toHaveBeenCalledOnce(); + expect(drag.onCancel).toHaveBeenCalledOnce(); + expect(drag.onStart).not.toHaveBeenCalled(); + expect(drag.onMove).not.toHaveBeenCalled(); + expect(drag.onEnd).not.toHaveBeenCalled(); + }); + it("keeps a click idle and starts only after the drag threshold", () => { const click = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 16 })); diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index f79bc06c64be..27e6e82a1d3f 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -10,6 +10,7 @@ export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) { type Options = { distance: number; + onBeforeStart?: () => void; onAttach: (sensor: SidebarPointerSensor) => void; onFinish: (started: boolean) => void; }; @@ -84,6 +85,12 @@ export class SidebarPointerSensor { this.document.addEventListener("click", this.suppressClick, { capture: true }); this.document.addEventListener("selectionchange", this.clearSelection); this.clearSelection(); + try { + this.props.options.onBeforeStart?.(); + } catch (error) { + this.cancel(); + throw error; + } this.props.onStart(this.coordinates()); return; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ea25b5c2c5c4..5f0890761078 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,5 @@ +import { flushSync } from "react-dom"; +import { SidebarVirtualList } from "./sidebar/SidebarVirtualList"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { useCompactSidebarEnabled } from "../hooks/useSettings"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; @@ -1730,11 +1732,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-thread-item {...sortableRootProps} {...(fileDropHandlers ?? {})} - className={cn( - // Matches the h-9 row so unrendered rows never shift the list when they paint. - "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]", - sortable?.isDragging && "relative z-20", - )} + className={cn("list-none", sortable?.isDragging && "relative z-20")} > void; onSelect: () => void; onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; @@ -2328,6 +2322,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { // which owns all keyboard interaction for the listbox. tabIndex={-1} aria-selected={props.isHighlighted} + aria-posinset={props.resultIndex + 1} + aria-setsize={props.resultCount} aria-current={props.isRouteActive ? "page" : undefined} aria-label={ props.projectDisplayName @@ -2894,13 +2890,6 @@ export default function Sidebar() { setActiveSearchResultIndex(0); }, [threadSearchResultOrderKey]); - useEffect(() => { - if (!isSearchingThreads) return; - document - .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`) - ?.scrollIntoView({ block: "nearest" }); - }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]); - // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted // soonest-first, so entry 0 is the boundary. @@ -3364,27 +3353,26 @@ export default function Sidebar() { }, [unsnoozeThread], ); - const threadListRef = useRef(null); + const [materializeThreadList, setMaterializeThreadList] = useState(false); + const prepareThreadDrag = useCallback(() => { + listMotionRef.current?.suspend(); + flushSync(() => setMaterializeThreadList(true)); + }, []); + const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); - const restrictBelowPins = useCallback( - (args) => - restrictBelowSidebarLabel( - { - ...args, - // The fixed snoozed shelf shares the main list's drag boundary. - containerNodeRect: compact - ? (threadListRef.current?.getBoundingClientRect() ?? args.containerNodeRect) - : args.containerNodeRect, - }, - dragLabelOffsetRef.current, - ), - [compact], - ); + const restrictBelowPins = useCallback((args) => { + const viewport = threadListRef.current; + return restrictBelowSidebarLabel( + args, + dragLabelOffsetRef.current, + viewport ? viewport.getBoundingClientRect().top - viewport.scrollTop : undefined, + ); + }, []); const listMotionRef = useRef | null>(null); - const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { + const attachListMotionRef = useCallback((node: HTMLElement | null) => { threadListRef.current = node; listMotionRef.current?.dispose(); - listMotionRef.current = node === null ? null : createSidebarListMotion(node); + listMotionRef.current = node === null ? null : createSidebarListMotion(node, { virtual: true }); listMotionRef.current?.update(false); }, []); @@ -3406,6 +3394,7 @@ export default function Sidebar() { listMotionRef.current?.release(); setDragState(null); } + setMaterializeThreadList(false); }, []); const attachDragSensor = useCallback((sensor: SidebarPointerSensor) => { dragSensorRef.current = sensor; @@ -3417,6 +3406,7 @@ export default function Sidebar() { useSensor(SidebarPointerSensor, { distance: 6, onAttach: attachDragSensor, + onBeforeStart: prepareThreadDrag, onFinish: finishThreadDrag, }), ); @@ -3570,7 +3560,10 @@ export default function Sidebar() { const listRect = list.getBoundingClientRect(); const scale = list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1; dragLabelOffsetRef.current = - header.getBoundingClientRect().top - listRect.top + SIDEBAR_DRAG_LABEL_HEIGHT * scale; + header.getBoundingClientRect().top - + listRect.top + + list.scrollTop + + SIDEBAR_DRAG_LABEL_HEIGHT * scale; } else { dragLabelOffsetRef.current = 0; } @@ -4631,10 +4624,8 @@ export default function Sidebar() { <> 0 ? (
    } > - + {isSearchingThreads ? ( threadSearchResults.length > 0 ? ( - + }} + /> ) : (

    -

      0 && "flex-1", - )} - > - {(() => { - const renderThreadRowInner = ( - thread: EnvironmentThreadShell, - section: SidebarSection, - sortable?: SortableThreadRowBag, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed always use slim rows. Active and - // pinned threads use cards unless the user has explicitly - // enabled the compact thread-list preference. - const isCard = section === "active" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - return ( - { + const renderThreadRowInner = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + sortable?: SortableThreadRowBag, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + // Settled and snoozed are the ONLY things that collapse a + // row: every other thread is a full card. Density comes + // from users (or the auto rules) actually parking work, + // not from the sidebar second-guessing what still matters. + const isCard = section === "active" || section === "pinned"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + + {(bag) => + renderThreadRowInner( + thread, + section, + draggingCompactSnoozed && bag.isDragging + ? { ...bag, hidden: true } + : bag, + ) + } + + ); + }; + const from = dragState?.activeSection ?? null; + const showDragLabels = + from !== null && + (!compact || + dragTargetSection === "active" || + dragTargetSection === "pinned"); + const snoozedItems: { key: string; render: () => ReactNode }[] = []; + const items: { key: string; render: () => ReactNode }[] = [ + { + key: "draft-sessions", + render: () => ( + - ); - }; - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: SidebarSection, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - return ( - - {(bag) => - renderThreadRowInner( - thread, - section, - draggingCompactSnoozed && bag.isDragging - ? { ...bag, hidden: true } - : bag, - ) - } - - ); - }; - const from = dragState?.activeSection ?? null; - const showDragLabels = - from !== null && - (!compact || - dragTargetSection === "active" || - dragTargetSection === "pinned"); - const snoozedItems: ReactNode[] = []; - const items: ReactNode[] = [ - , - ]; - for (const item of sidebarListItems) { - const destination = - compact && - (item.kind === "thread" - ? item.section === "snoozed" - : item.marker === "snoozed-header") - ? snoozedItems - : items; - if (item.kind === "thread") { - destination.push( - renderThreadRow(threadByKey.get(item.key)!, item.section), - ); - continue; - } - switch (item.marker) { - case "pinned-header": - items.push( + ), + }, + ]; + for (const item of sidebarListItems) { + const destination = + compact && + (item.kind === "thread" + ? item.section === "snoozed" + : item.marker === "snoozed-header") + ? snoozedItems + : items; + if (item.kind === "thread") { + destination.push({ + key: item.key, + render: () => renderThreadRow(threadByKey.get(item.key)!, item.section), + }); + continue; + } + switch (item.marker) { + case "pinned-header": + items.push({ + key: "pinned-header", + render: () => ( , - ); - break; - case "pinned-divider": - items.push( + /> + ), + }); + break; + case "pinned-divider": + items.push({ + key: "pinned-divider", + render: () => ( , - ); - break; - case "active-placeholder": - items.push( + /> + ), + }); + break; + case "active-placeholder": + items.push({ + key: "active-placeholder", + render: () => ( , - ); - break; - case "snoozed-header": - destination.push( + /> + ), + }); + break; + case "snoozed-header": + destination.push({ + key: "snoozed-shelf-header", + render: () => ( , - ); - break; - case "settled-header": - items.push( + /> + ), + }); + break; + case "settled-header": + items.push({ + key: "settled-shelf-header", + render: () => ( , - ); - break; - case "settled-placeholder": - items.push( + /> + ), + }); + break; + case "settled-placeholder": + items.push({ + key: "settled-placeholder", + render: () => ( , - ); - break; - } + /> + ), + }); + break; } - // Keep the shelf inside this drag context while anchoring - // its DOM outside the main thread scroller. - return [ - ...items, - compact && snoozedFooter - ? createPortal(snoozedItems, snoozedFooter, "snoozed-footer") - : null, - compactSnoozedDragThread + } + if (!compact && settledShelfExpanded && hiddenSettledCount > 0) + items.push({ + key: "show-more-settled", + render: () => ( +
    • + +
    • + ), + }); + + return ( + <> + { + const section = sectionByThreadKey.get(item.key); + return section === "pinned" || section === "active" + ? "card" + : section + ? "slim" + : item.key; + }} + aria-label="Threads" + activeKey={routeThreadKey} + retainedKeys={[renamingThreadKey, dragState?.activeKey ?? null]} + materialize={materializeThreadList} + draggingKey={dragState?.activeKey} + onViewportRef={attachListMotionRef} + renderItem={(item) => item.render()} + estimatedItemSize={compactThreadRows ? 37 : 83} + /> + {compact && snoozedFooter + ? createPortal( + snoozedItems.map((item) => item.render()), + snoozedFooter, + "snoozed-footer", + ) + : null} + {compactSnoozedDragThread ? createPortal(
        @@ -5198,36 +5250,10 @@ export default function Sidebar() { document.body, "compact-snoozed-drag", ) - : null, - ]; - })()} - {!compact && settledShelfExpanded && hiddenSettledCount > 0 ? ( -
      • - - - } - > - - - Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more - - - - Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more - - -
      • - ) : null} -
      + : null} + + ); + })()} diff --git a/apps/web/src/components/sidebar/SidebarVirtualList.tsx b/apps/web/src/components/sidebar/SidebarVirtualList.tsx new file mode 100644 index 000000000000..6bfd0fec2a2d --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarVirtualList.tsx @@ -0,0 +1,138 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; + +const EMPTY_KEYS: readonly (string | null)[] = []; + +/** One viewport for the sidebar. Retain the last focused row and rows that own a rename or drag. */ +export function SidebarVirtualList({ + data, + materialize = false, + draggingKey, + onViewportRef, + renderItem, + getItemType, + activeKey, + revealVersion, + retainedKeys = EMPTY_KEYS, + estimatedItemSize = 83, + role = "list", + id, + "aria-label": label, +}: { + data: T[]; + materialize?: boolean; + draggingKey?: string | undefined; + onViewportRef?: (node: HTMLElement | null) => void; + renderItem: (item: T, index: number) => ReactNode; + getItemType?: (item: T) => string; + activeKey: string | null; + revealVersion?: string; + retainedKeys?: readonly (string | null)[]; + estimatedItemSize?: number; + role?: "list" | "listbox"; + id?: string; + "aria-label": string; +}) { + const listRef = useRef(null); + const revealed = useRef<{ key: string; version: string | undefined } | null>(null); + const [focusedKey, setFocusedKey] = useState(null); + const [loaded, setLoaded] = useState(false); + const [fade, setFade] = useState({ top: false, bottom: false }); + const keys = materialize + ? data.map((item) => item.key) + : [...new Set([activeKey, focusedKey, ...retainedKeys].filter((key) => key !== null))]; + + useEffect(() => () => onViewportRef?.(null), [onViewportRef]); + + const updateFade = useCallback(() => { + const viewport = listRef.current?.getScrollableNode(); + if (!viewport) return; + const top = viewport.scrollTop > 1; + const bottom = viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 1; + setFade((previous) => + previous.top === top && previous.bottom === bottom ? previous : { top, bottom }, + ); + }, []); + + useEffect(() => { + if (!loaded) return; + if (activeKey === null) { + revealed.current = null; + return; + } + const index = data.findIndex((item) => item.key === activeKey); + if (index < 0) { + revealed.current = null; + return; + } + if (revealed.current?.key === activeKey && revealed.current.version === revealVersion) return; + const list = listRef.current; + if (!list) return; + // Wait for the new data's layout and scroll anchoring before revealing its target. + const frame = requestAnimationFrame(() => { + revealed.current = { key: activeKey, version: revealVersion }; + const viewport = list.getScrollableNode(); + const row = Array.from( + viewport.querySelectorAll("[data-sidebar-list-key]"), + ).find((element) => element.dataset.sidebarListKey === activeKey); + if (row) { + const rect = row.getBoundingClientRect(); + const bounds = viewport.getBoundingClientRect(); + if (rect.top >= bounds.top && rect.bottom <= bounds.bottom) return; + } + void list.scrollToIndex({ index, animated: false, viewPosition: 0.5 }); + }); + return () => cancelAnimationFrame(frame); + }, [activeKey, data, loaded, revealVersion]); + + return ( + item.key} + {...(getItemType ? { getItemType } : {})} + renderItem={({ item, index }) => ( +
      + {renderItem(item, index)} +
      + )} + estimatedItemSize={estimatedItemSize} + drawDistance={400} + recycleItems={false} + maintainVisibleContentPosition + className={cn( + "relative h-0 min-h-0 flex-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + // Sortable cards translate past their measured row boxes while dragging. + "[&_:has(>[data-sidebar-list-key])]:[contain:layout_style]!", + "[&_:has(>[data-sidebar-list-key][data-sidebar-dragging])]:z-20", + getVirtualizedScrollFadeClassName(fade), + )} + onLoad={() => { + onViewportRef?.(listRef.current?.getScrollableNode() ?? null); + setLoaded(true); + updateFade(); + }} + onScroll={updateFade} + onItemSizeChanged={updateFade} + onFocusCapture={(event) => { + const row = event.target.closest("[data-sidebar-list-key]"); + // Portal focus still bubbles through this list. Keep its owning row mounted. + if (row?.dataset.sidebarListKey) setFocusedKey(row.dataset.sidebarListKey); + }} + /> + ); +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 404295f5f5c1..595793482f8c 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -592,33 +592,40 @@ function SidebarContent({ className, fixedHeader, fixedFooter, + scrollable = true, ...props }: React.ComponentProps<"div"> & { fixedHeader?: React.ReactNode; fixedFooter?: React.ReactNode; + /** Virtualized children own their viewport and must receive a bounded height. */ + scrollable?: boolean; }) { + const content = ( +
      + ); return ( <> {fixedHeader ?
      {fixedHeader}
      : null} - {/* Rows take focus on click. Scroll padding would make the browser nudge - the list whenever a focused row sits under the fade. */} - -
      - + {scrollable ? ( + + {content} + + ) : ( + content + )} {fixedFooter ?
      {fixedFooter}
      : null} ); diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 98784b999549..b0d4bd25d784 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1380,6 +1380,19 @@ diff --git a/react.js b/react.js index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658458edbb1 100644 --- a/react.js +++ b/react.js +@@ -4075,6 +4075,12 @@ function handleStickyRecycling(ctx, stickyArray, scroll, drawDistance, currentStickyIdx, pendingRemoval, isPinnedRenderIndex) { + if (arrayIdx === -1) { + state.stickyContainerPool.delete(containerIndex); + set$(ctx, `containerSticky${containerIndex}`, false); ++ // Former alwaysRender rows must leave the expanded pool when no longer buffered. ++ if (state.startBuffered === null || state.endBuffered === null || ++ itemIndex < state.startBuffered || itemIndex > state.endBuffered) { ++ pendingRemoval.push(containerIndex); ++ } ++ + continue; + } + const isRecentSticky = arrayIdx >= currentStickyIdx - 1 && arrayIdx <= currentStickyIdx + 1; @@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); @@ -1417,6 +1430,19 @@ diff --git a/react.mjs b/react.mjs index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b865331b4f 100644 --- a/react.mjs +++ b/react.mjs +@@ -4054,6 +4054,12 @@ function handleStickyRecycling(ctx, stickyArray, scroll, drawDistance, currentStickyIdx, pendingRemoval, isPinnedRenderIndex) { + if (arrayIdx === -1) { + state.stickyContainerPool.delete(containerIndex); + set$(ctx, `containerSticky${containerIndex}`, false); ++ // Former alwaysRender rows must leave the expanded pool when no longer buffered. ++ if (state.startBuffered === null || state.endBuffered === null || ++ itemIndex < state.startBuffered || itemIndex > state.endBuffered) { ++ pendingRemoval.push(containerIndex); ++ } ++ + continue; + } + const isRecentSticky = arrayIdx >= currentStickyIdx - 1 && arrayIdx <= currentStickyIdx + 1; @@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8dbaf396924..5f0c88d0e4b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ patchedDependencies: '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b + '@legendapp/list@3.3.5': 9315ce8f4995047b418d7b9bd2ad79e485fe693eaf8e0f9b7de00283cd69c864 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -247,7 +247,7 @@ importers: version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.3.5(patch_hash=9315ce8f4995047b418d7b9bd2ad79e485fe693eaf8e0f9b7de00283cd69c864)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@material/material-color-utilities': specifier: 0.3.0 version: 0.3.0 @@ -606,7 +606,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=9315ce8f4995047b418d7b9bd2ad79e485fe693eaf8e0f9b7de00283cd69c864)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -13996,7 +13996,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=9315ce8f4995047b418d7b9bd2ad79e485fe693eaf8e0f9b7de00283cd69c864)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -14004,7 +14004,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=9315ce8f4995047b418d7b9bd2ad79e485fe693eaf8e0f9b7de00283cd69c864)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6)