perf(ui): isolate scroll shadow effects

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent dd6a632bc5
commit 9b9d7069c7
15 changed files with 283 additions and 189 deletions
@@ -327,7 +327,8 @@ const ChatViewport = React.memo(({
>
<div className="absolute inset-0">
<ScrollShadow
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
viewportClassName="absolute inset-0"
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden chat-scroll overlay-scrollbar-target"
ref={scrollRef}
style={CHAT_SCROLL_STYLE}
observeMutations={false}
@@ -335,7 +336,6 @@ const ChatViewport = React.memo(({
tabIndex={0}
onClick={focusScrollContainer}
onScroll={handleHistoryScroll}
data-scroll-shadow="true"
data-scrollbar="chat"
>
<div className="relative z-0 min-h-full">
@@ -242,6 +242,7 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
<WorkStatusPresenceProvider onChange={setRenderedSections}>
<ScrollShadow
ref={restore}
viewportClassName="min-h-0 flex-1 [--scroll-shadow-color:color-mix(in_srgb,var(--surface-muted)_var(--oc-glass-opacity),transparent)]"
onScroll={handleScroll}
size={24}
className="oc-hide-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-2"
@@ -545,8 +545,9 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
? Math.min(STICKY_FADE_MIN_SIZE + scroller.scrollTop, STICKY_FADE_MAX_SIZE)
: 0;
stickyFadeSizeRef.current = fadeSize;
scroller.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
scroller.style.setProperty(
const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
fadeRoot?.style.setProperty(
'--scroll-shadow-top-clear-size',
`${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`,
);
@@ -876,24 +877,23 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
<div
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
// SAFETY: these custom properties configure the viewport-owned edge fade.
style={stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
onPointerDownCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
onClickCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
onContextMenuCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
>
<ScrollableOverlay
ref={scrollRef}
useScrollShadow={stickyHeaders}
hideBottomScrollShadow
scrollShadowSize={12}
outerClassName={maxHeightClassName}
className="oc-sticky-fade-scroller overlay-scrollbar-target--no-gutter"
style={{
...(stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : {}),
...maxHeightStyle,
}}
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
>
<div className="px-1">
<ScrollableOverlay
ref={scrollRef}
useScrollShadow={stickyHeaders}
hideBottomScrollShadow
scrollShadowSize={12}
outerClassName={maxHeightClassName}
className="overlay-scrollbar-target--no-gutter"
style={maxHeightStyle}
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
>
<div className="px-1">
{includeNotSelected ? (
<>
<button
@@ -964,16 +964,16 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
</div>
))
)}
</div>
</ScrollableOverlay>
{stickyHeaders && leadingSectionKey ? (
<div
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-2 px-3 py-1.5 typography-micro font-semibold uppercase tracking-wider text-muted-foreground"
aria-hidden="true"
>
{renderSectionIdentity(leadingSectionKey)}
</div>
) : null}
</div>
</ScrollableOverlay>
{stickyHeaders && leadingSectionKey ? (
<div
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-2 px-3 py-1.5 typography-micro font-semibold uppercase tracking-wider text-muted-foreground"
aria-hidden="true"
>
{renderSectionIdentity(leadingSectionKey)}
</div>
) : null}
</div>
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
@@ -152,7 +152,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
// Find the nearest dialog or overflow ancestor to constrain within
let container: HTMLElement | null = triggerRef.current.parentElement;
while (container) {
if (container.getAttribute('role') === 'dialog' || container.hasAttribute('data-scroll-shadow')) {
if (container.matches('[role="dialog"], [data-scroll-shadow-scroller]')) {
break;
}
const style = getComputedStyle(container);
@@ -447,7 +447,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
</header>
) : null}
<ScrollShadow className="flex-1 min-h-0 overflow-auto" size={64} hideTopShadow>
<ScrollShadow viewportClassName="flex-1 min-h-0" className="flex-1 min-h-0 overflow-auto" size={64} hideTopShadow>
<div className="mx-auto w-full max-w-2xl px-4 sm:px-6 py-5">
<div className="flex flex-col gap-5">
@@ -1782,6 +1782,7 @@ export function ScheduledTaskEditorDialog(props: {
</header>
<ScrollShadow
viewportClassName="flex-1 min-h-0"
className="flex-1 min-h-0 overflow-auto [scrollbar-gutter:stable_both-edges]"
size={64}
hideTopShadow
@@ -10,7 +10,7 @@ import {
} from '@dnd-kit/core';
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
import type { SessionGroup } from '../types';
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
@@ -155,7 +155,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
// Keep per-scroll measurements out of React state so the interaction guard
// can read the current fade boundary without rerendering the sidebar.
const topFadeSizeRef = React.useRef(0);
// Update the compositor-owned mask on every scroll, but cross the React
// Update the viewport-owned fade on every scroll, but cross the React
// render boundary only when the sticky identity overlay appears or hides.
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
const hasTopScroll = scroller.scrollTop > 1;
@@ -163,8 +163,9 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE)
: 0;
topFadeSizeRef.current = topFadeSize;
scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
scroller.style.setProperty(
const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
fadeRoot?.style.setProperty(
'--scroll-shadow-top-clear-size',
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
);
@@ -192,7 +193,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
}
}
// The IntersectionObserver reports the stuck header asynchronously, a frame or
// two after the (synchronous) mask has already hidden the real header — which
// two after the synchronous fade has already hidden the real header — which
// otherwise leaves a one-frame gap where the title blinks out with no crisp
// replacement. Seed the overlay with the topmost rendered project so it is
// ready in the same frame; the observer then corrects it. When shared sessions
@@ -202,11 +203,11 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
if (model.projectSections.length === 0) {
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.topContent}{model.emptyState}</ScrollableOverlay>;
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.topContent}{model.emptyState}</ScrollableOverlay>;
}
if (model.sectionsForRender.length === 0) {
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.searchEmptyState}</ScrollableOverlay>;
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.searchEmptyState}</ScrollableOverlay>;
}
return (
@@ -217,21 +218,21 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
// rows appear below naturally.
<div
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
// SAFETY: this custom property configures the viewport-owned edge fade.
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
onPointerDownCapture={enableStickyFade ? blockObscuredInteraction : undefined}
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
>
<ScrollableOverlay
ref={scrollContainerRef}
useScrollShadow
hideTopScrollShadow={!enableStickyFade}
scrollShadowSize={96}
outerClassName="flex-1 min-h-0"
className={cn('oc-sidebar-scroller oc-sticky-fade-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', view.mobileVariant ? '' : '')}
// SAFETY: the custom property is the only dynamic CSS declaration here.
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
>
<ScrollableOverlay
ref={scrollContainerRef}
useScrollShadow
hideTopScrollShadow={!enableStickyFade}
scrollShadowSize={96}
outerClassName="flex-1 min-h-0"
className="oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]"
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
>
{model.topContent}
{view.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1">
@@ -372,7 +373,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
<DragOverlay dropAnimation={null} />
</DndContext>
)}
</ScrollableOverlay>
</ScrollableOverlay>
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
<div
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-1.5 py-1 pl-4 pr-5"
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { ScrollShadow } from './ScrollShadow';
describe('ScrollShadow', () => {
let windowInstance: Window;
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
windowInstance = new Window();
Object.assign(globalThis, {
window: windowInstance,
document: windowInstance.document,
HTMLElement: windowInstance.HTMLElement,
Element: windowInstance.Element,
Node: windowInstance.Node,
IS_REACT_ACT_ENVIRONMENT: true,
});
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
windowInstance.close();
});
test('keeps edge effects outside the forwarded scroll element', async () => {
let scrollElement: HTMLElement | null = null;
let scrollTop = 0;
await act(async () => {
root.render(
<ScrollShadow
ref={(element) => { scrollElement = element; }}
viewportClassName="absolute inset-0"
observeMutations={false}
>
<div>content</div>
</ScrollShadow>,
);
});
const scroller = host.querySelector<HTMLElement>('[data-scroll-shadow-scroller]');
if (!scroller) throw new Error('ScrollShadow did not render its scroll element');
expect(scrollElement).toBe(scroller);
Object.defineProperties(scroller, {
clientHeight: { configurable: true, get: () => 100 },
scrollHeight: { configurable: true, get: () => 500 },
scrollTop: {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value; },
},
});
const viewport = scroller.parentElement;
expect(viewport?.hasAttribute('data-scroll-shadow-viewport')).toBe(true);
expect(viewport?.classList.contains('absolute')).toBe(true);
expect(viewport?.classList.contains('relative')).toBe(false);
expect(scroller.hasAttribute('data-scroll-shadow-scroller')).toBe(true);
expect(viewport?.style.getPropertyValue('--scroll-shadow-size')).toBe('48px');
await act(async () => scroller.dispatchEvent(new window.Event('scroll')));
expect(viewport?.getAttribute('data-bottom-scroll')).toBe('true');
scrollTop = 200;
await act(async () => scroller.dispatchEvent(new window.Event('scroll')));
expect(viewport?.getAttribute('data-top-bottom-scroll')).toBe('true');
expect(viewport?.hasAttribute('data-bottom-scroll')).toBe(false);
let attributeWrites = 0;
if (!viewport) throw new Error('ScrollShadow did not render its viewport');
const setAttribute = viewport.setAttribute.bind(viewport);
viewport.setAttribute = (name, value) => {
attributeWrites += 1;
setAttribute(name, value);
};
await act(async () => scroller.dispatchEvent(new window.Event('scroll')));
expect(attributeWrites).toBe(0);
});
});
+73 -65
View File
@@ -1,7 +1,9 @@
import React from "react";
import { cn } from "@/lib/utils";
export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
as?: React.ElementType;
viewportClassName?: string;
orientation?: "vertical" | "horizontal";
offset?: number;
size?: number;
@@ -12,22 +14,23 @@ export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
};
function mergeRefs<T>(...refs: Array<React.Ref<T>>): React.RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<T | null>).current = value;
}
});
};
}
type EdgeState = "both" | "none" | "top" | "bottom" | "left" | "right";
type ScrollShadowViewportStyle = React.CSSProperties & { "--scroll-shadow-size": string };
const EDGE_ATTRIBUTES = [
"data-top-scroll",
"data-bottom-scroll",
"data-top-bottom-scroll",
"data-left-scroll",
"data-right-scroll",
"data-left-right-scroll",
] as const;
export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
(
(
{
as: Component = "div",
viewportClassName,
orientation = "vertical",
offset = 0,
size = 48,
@@ -40,52 +43,46 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
className,
children,
...rest
},
},
ref,
) => {
const internalRef = React.useRef<HTMLElement>(null);
const visibleRef = React.useRef<"both" | "none" | "top" | "bottom" | "left" | "right">("none");
const viewportRef = React.useRef<HTMLDivElement>(null);
const visibleRef = React.useRef<EdgeState>("none");
const edgeStateRef = React.useRef("");
React.useImperativeHandle(ref, () => {
const element = internalRef.current;
if (!element) throw new Error("ScrollShadow scroll element is unavailable");
return element;
}, []);
const dataScrollShadow = (rest as Record<string, unknown>)["data-scroll-shadow"];
delete (rest as Record<string, unknown>)["data-scroll-shadow"];
const mergedStyle = React.useMemo<React.CSSProperties>(() => {
const next: React.CSSProperties = {
...(style as React.CSSProperties),
};
(next as Record<string, string>)["--scroll-shadow-size"] = `${size}px`;
return next;
}, [size, style]);
const setAttributes = React.useCallback(
(el: HTMLElement, hasBefore: boolean, hasAfter: boolean, prefix: "top" | "left", suffix: "bottom" | "right") => {
const bothKey = `${prefix}${suffix.charAt(0).toUpperCase()}${suffix.slice(1)}Scroll` as const;
if (hasBefore && hasAfter) {
(el.dataset as Record<string, string>)[bothKey] = "true";
el.removeAttribute(`data-${prefix}-scroll`);
el.removeAttribute(`data-${suffix}-scroll`);
} else {
el.dataset[`${prefix}Scroll`] = String(hasBefore);
el.dataset[`${suffix}Scroll`] = String(hasAfter);
el.removeAttribute(`data-${prefix}-${suffix}-scroll`);
}
},
[],
const viewportStyle = React.useMemo<ScrollShadowViewportStyle>(
() => ({ "--scroll-shadow-size": `${size}px` }),
[size],
);
const clearAttributes = React.useCallback((el: HTMLElement) => {
["top", "bottom", "top-bottom", "left", "right", "left-right"].forEach((attr) => {
el.removeAttribute(`data-${attr}-scroll`);
});
EDGE_ATTRIBUTES.forEach((attribute) => el.removeAttribute(attribute));
}, []);
const setEdgeAttribute = React.useCallback((el: HTMLElement, state: EdgeState) => {
clearAttributes(el);
if (state === "none") return;
const attribute = state === "both"
? orientation === "vertical" ? "data-top-bottom-scroll" : "data-left-right-scroll"
: `data-${state}-scroll`;
el.setAttribute(attribute, "true");
}, [clearAttributes, orientation]);
const checkOverflow = React.useCallback(() => {
const el = internalRef.current;
if (!el) return;
const viewport = viewportRef.current;
if (!el || !viewport) return;
if (!isEnabled) {
clearAttributes(el);
clearAttributes(viewport);
edgeStateRef.current = "";
visibleRef.current = "none";
return;
}
@@ -97,25 +94,29 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
orientation === "vertical"
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
let hasAfter =
const hasAfter =
orientation === "vertical"
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
const effectiveHasBefore = hideTopShadow && orientation === "vertical" ? false : hasBefore;
if (hideBottomShadow && orientation === "vertical") {
hasAfter = false;
const effectiveHasBefore = hasBefore && !(orientation === "vertical" && hideTopShadow);
const effectiveHasAfter = hasAfter && !(orientation === "vertical" && hideBottomShadow);
const beforeEdge = orientation === "vertical" ? "top" : "left";
const afterEdge = orientation === "vertical" ? "bottom" : "right";
let next: EdgeState = "none";
if (effectiveHasBefore && effectiveHasAfter) next = "both";
else if (effectiveHasBefore) next = beforeEdge;
else if (effectiveHasAfter) next = afterEdge;
const edgeState = `${orientation}:${next}`;
if (edgeState !== edgeStateRef.current) {
edgeStateRef.current = edgeState;
setEdgeAttribute(viewport, next);
}
setAttributes(el, effectiveHasBefore, hasAfter, orientation === "vertical" ? "top" : "left", orientation === "vertical" ? "bottom" : "right");
const next = effectiveHasBefore && hasAfter ? "both" : effectiveHasBefore ? (orientation === "vertical" ? "top" : "left") : hasAfter ? (orientation === "vertical" ? "bottom" : "right") : "none";
if (next !== visibleRef.current) {
visibleRef.current = next;
onVisibilityChange?.(next);
}
}, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setAttributes]);
}, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setEdgeAttribute]);
React.useEffect(() => {
const el = internalRef.current;
@@ -132,9 +133,9 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
};
const handleScroll = () => checkOverflow(); // Scroll should be immediate
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
const resizeObserver = "ResizeObserver" in globalThis ? new ResizeObserver(throttledCheck) : null;
const mutationObserver =
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
observeMutations && "MutationObserver" in globalThis ? new MutationObserver(throttledCheck) : null;
checkOverflow();
@@ -157,16 +158,23 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
}, [checkOverflow, observeMutations]);
return (
<Component
{...rest}
ref={mergeRefs(internalRef, ref)}
className={className}
<div
ref={viewportRef}
className={cn("relative flex min-h-0 min-w-0 flex-col", viewportClassName)}
data-scroll-shadow-viewport
data-orientation={orientation}
data-scroll-shadow={dataScrollShadow ?? true}
style={mergedStyle}
style={viewportStyle}
>
{children}
</Component>
<Component
{...rest}
ref={internalRef}
className={className}
data-scroll-shadow-scroller
style={style}
>
{children}
</Component>
</div>
);
},
);
@@ -47,6 +47,16 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
...rest
}, ref) => {
const containerRef = React.useRef<HTMLElement | null>(null);
const containerSizeClassName = fillContainer
? "flex-1 min-h-0 w-full"
: "flex-none w-full h-auto";
const containerClassName = cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
containerSizeClassName,
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className,
);
React.useImperativeHandle(ref, () => containerRef.current as HTMLElement, []);
@@ -62,16 +72,11 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
<ScrollShadow
as={Component}
ref={containerRef as React.Ref<HTMLElement>}
viewportClassName={containerSizeClassName}
size={scrollShadowSize}
hideTopShadow={hideTopScrollShadow}
hideBottomShadow={hideBottomScrollShadow}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
)}
className={containerClassName}
style={style as React.CSSProperties}
observeMutations={observeMutations}
{...rest}
@@ -81,13 +86,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
) : (
<Component
ref={containerRef as React.Ref<HTMLElement>}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
)}
className={containerClassName}
style={style}
{...rest}
>
@@ -488,6 +488,7 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
<ScrollShadow
ref={scrollRef}
viewportClassName="min-h-0 w-full flex-1"
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
>
{shouldVirtualize ? (