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 bfd112d18998..ae6dc511460d 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 9e92c4d2c76e..b0c0e41742d0 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 dba6072e9d01..04a501843bbd 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -20,7 +20,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( @@ -33,6 +33,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); const run = current ? current.offset * (1 - progress(current.animation)) : 0; @@ -56,6 +63,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" ) { @@ -133,17 +141,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) { @@ -250,11 +265,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 7b9e0e545d56..95f11b9545cd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,6 @@ import { requestCustomSnooze } from "./CustomSnoozeDialog"; +import { flushSync } from "react-dom"; +import { SidebarVirtualList } from "./sidebar/SidebarVirtualList"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; @@ -1602,11 +1604,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; @@ -2118,6 +2114,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={accessibility.current} aria-label={accessibility.label} onMouseMove={props.onHighlight} @@ -2702,13 +2700,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. @@ -3175,19 +3166,30 @@ export default function Sidebar() { }, [unsnoozeThread], ); - const threadListRef = useRef(null); + const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); - const restrictBelowPins = useCallback( - (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), - [], - ); + 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); }, []); + // Drag targets are measured from mounted rows, so pickup mounts the whole + // list first. finishThreadDrag restores virtualization. + const [materializeThreadList, setMaterializeThreadList] = useState(false); + const prepareThreadDrag = useCallback(() => { + listMotionRef.current?.suspend(); + flushSync(() => setMaterializeThreadList(true)); + }, []); // Hold the chosen section and order until every key write arrives. This // also covers first-time ordering, which assigns keys to keyless neighbors. @@ -3207,6 +3209,7 @@ export default function Sidebar() { listMotionRef.current?.release(); setDragState(null); } + setMaterializeThreadList(false); }, []); const attachDragSensor = useCallback((sensor: SidebarPointerSensor) => { dragSensorRef.current = sensor; @@ -3218,6 +3221,7 @@ export default function Sidebar() { useSensor(SidebarPointerSensor, { distance: 6, onAttach: attachDragSensor, + onBeforeStart: prepareThreadDrag, onFinish: finishThreadDrag, }), ); @@ -3371,7 +3375,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; } @@ -4442,7 +4449,8 @@ export default function Sidebar() { <> } > - + {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 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 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, bag)} + + ); + }; + const from = dragState?.activeSection ?? null; + 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, bag)} - - ); - }; - const from = dragState?.activeSection ?? null; - const items: ReactNode[] = [ - , - ]; - for (const item of sidebarListItems) { - if (item.kind === "thread") { - items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); - continue; - } - switch (item.marker) { - case "pinned-header": - items.push( + ), + }, + ]; + for (const item of sidebarListItems) { + if (item.kind === "thread") { + items.push({ + key: item.key, + render: () => renderThreadRow(threadByKey.get(item.key)!, item.section), + }); + continue; + } + if ( + item.marker === "snoozed-header" || + (item.marker === "settled-header" && snoozedThreads.length === 0) + ) { + items.push({ key: "shelf-spacer", render: () => null }); + } + 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": - items.push( + /> + ), + }); + break; + case "snoozed-header": + items.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; } - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
  • - -
  • - ) : null} -
+ } + if (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; + }} + // VoiceOver treats an exposed list as an interaction boundary, + // which hides its rows from ordinary linear navigation. The + // sidebar's navigation landmark already names this region. + role="presentation" + fillSpaceKey="shelf-spacer" + grow={sidebarListHasRows} + activeKey={routeThreadKey} + retainedKeys={[renamingThreadKey, dragState?.activeKey ?? null]} + materialize={materializeThreadList} + draggingKey={dragState?.activeKey} + onViewportRef={attachListMotionRef} + renderItem={(item) => item.render()} + /> + ); + })()} diff --git a/apps/web/src/components/sidebar/SidebarVirtualList.tsx b/apps/web/src/components/sidebar/SidebarVirtualList.tsx new file mode 100644 index 000000000000..ac419aaca50a --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarVirtualList.tsx @@ -0,0 +1,185 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { useCallback, useEffect, useMemo, 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, + fillSpaceKey, + grow = true, + role, + 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; + /** A non-interactive item that keeps the following shelves at the viewport bottom. */ + fillSpaceKey?: string; + /** Fill the remaining sidebar height. Pass false while nothing is listed so + * a following empty state stays at the top instead of below an empty viewport. */ + grow?: boolean; + /** Search results are a listbox. Thread rows stay presentational so screen + * readers traverse them linearly instead of entering a list boundary. */ + role: "listbox" | "presentation"; + 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 [fillSpace, setFillSpace] = useState(0); + const extraData = useMemo(() => ({ renderItem, fillSpace }), [renderItem, fillSpace]); + const [fade, setFade] = useState({ top: false, bottom: false }); + const keys = materialize + ? data.map((item) => item.key) + : [ + ...new Set( + [activeKey, focusedKey, fillSpaceKey, ...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(() => { + const list = listRef.current; + if (!loaded || !list) return; + const viewport: HTMLElement = list.getScrollableNode(); + let frame = 0; + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + // Resizes and content changes can move either edge without a scroll event. + updateFade(); + if (!fillSpaceKey) return; + const state = list.getState(); + // Subtract the measured spacer, not the pending React value, so a resize + // cannot feed its previous height back into the next content estimate. + const naturalHeight = state.contentLength - (state.sizes.get(fillSpaceKey) ?? 0); + setFillSpace(Math.max(0, viewport.clientHeight - naturalHeight)); + }); + }; + const unlisten = list.getState().listen("totalSize", schedule); + const observer = new ResizeObserver(schedule); + observer.observe(viewport); + schedule(); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + unlisten(); + }; + }, [fillSpaceKey, loaded, updateFade]); + + 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 min-h-0 overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + grow && "h-0 flex-auto", + // 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 58957b1477d6..f826ec50e868 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -585,39 +585,49 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { function SidebarContent({ className, fixedHeader, + scrollable = true, ...props }: React.ComponentProps<"div"> & { fixedHeader?: React.ReactNode; + /** Virtualized children own their viewport and must receive a bounded height. */ + scrollable?: boolean; }) { + const content = ( +
    [data-sidebar=group]+[data-sidebar=group]]:pt-0", + fixedHeader && "[&>[data-sidebar=group]:first-child]:pt-0", + className, + )} + data-sidebar="content" + data-slot="sidebar-content" + {...props} + /> + ); 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. */} - -
    [data-sidebar=group]+[data-sidebar=group]]:pt-0", - fixedHeader && "[&>[data-sidebar=group]:first-child]:pt-0", - className, - )} - data-sidebar="content" - data-slot="sidebar-content" - {...props} - /> - + {scrollable ? ( + + {content} + + ) : ( + content + )} ); } diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 88b739824b01..8425421192d9 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1450,6 +1450,19 @@ diff --git a/react.js b/react.js index 914d2da..0c2917d 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); @@ -1518,6 +1531,19 @@ diff --git a/react.mjs b/react.mjs index 95465f2..b73bfdf 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 e70207f2445f..a29f07f2cb64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,7 +95,7 @@ patchedDependencies: '@effect/vitest@4.0.0-rc.115': 172bd95ef73a13d322888984415d5d18d9d2aa8e13fa529eac235efac837e902 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 - '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 + '@legendapp/list@3.3.5': ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -256,7 +256,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.11.4(@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=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(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=ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5)(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 @@ -624,7 +624,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@noble/hashes': specifier: 'catalog:' version: 1.8.0 @@ -13910,7 +13910,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(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=ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5)(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) @@ -13918,7 +13918,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=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5)(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)