perf(ui): isolate scroll shadow effects
This commit is contained in:
@@ -628,6 +628,7 @@ const MobileDiffDetail: React.FC<{
|
||||
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
|
||||
) : (
|
||||
<ScrollShadow
|
||||
viewportClassName="h-full"
|
||||
className="h-full overflow-y-auto overflow-x-hidden p-3"
|
||||
data-diff-virtual-root
|
||||
data-diff-virtual-content
|
||||
|
||||
@@ -278,7 +278,7 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
|
||||
<ScrollShadow viewportClassName="min-h-0 flex-1" className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
|
||||
{directoryError ? (
|
||||
<MobileFilesState message={directoryError} />
|
||||
) : query.trim() ? (
|
||||
|
||||
@@ -1455,7 +1455,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
// clipped overflow swallowed the footer.
|
||||
const surfaceContent = (
|
||||
<div ref={contentRootRef} className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto pb-4">
|
||||
<ScrollShadow viewportClassName="min-h-0 flex-1" className="min-h-0 flex-1 overflow-y-auto pb-4">
|
||||
{/* The search bar scrolls WITH the list (iOS-style): the open-time
|
||||
auto-scroll to the current session naturally tucks it away, and
|
||||
scrolling to the very top brings it back. */}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
+54
-59
@@ -256,86 +256,81 @@ div[data-chat-input-footer="true"] {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */
|
||||
[data-scroll-shadow="true"] {
|
||||
--scroll-shadow-size: var(--scroll-shadow-size, 48px);
|
||||
/* Scroll edge fades live on the viewport, never on the scrolling content. */
|
||||
[data-scroll-shadow-viewport]::before,
|
||||
[data-scroll-shadow-viewport]::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-scroll-shadow="true"][data-orientation="vertical"] {
|
||||
mask-mode: alpha;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
[data-scroll-shadow-viewport][data-orientation="vertical"]::before,
|
||||
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-bottom-scroll="true"] {
|
||||
mask-image: linear-gradient(
|
||||
[data-scroll-shadow-viewport][data-orientation="vertical"]::before {
|
||||
top: 0;
|
||||
height: var(--scroll-shadow-top-size, var(--scroll-shadow-size));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
var(--scroll-shadow-color, var(--surface-background)) 0,
|
||||
var(--scroll-shadow-color, var(--surface-background)) var(--scroll-shadow-top-clear-size, 0px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-scroll="true"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 100%
|
||||
);
|
||||
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
|
||||
bottom: 0;
|
||||
height: var(--scroll-shadow-size);
|
||||
background: linear-gradient(to top, var(--scroll-shadow-color, var(--surface-background)), transparent);
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before,
|
||||
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
|
||||
inset-block: 0;
|
||||
width: var(--scroll-shadow-size);
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before {
|
||||
left: 0;
|
||||
background: linear-gradient(to right, var(--scroll-shadow-color, var(--surface-background)), transparent);
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
|
||||
right: 0;
|
||||
background: linear-gradient(to left, var(--scroll-shadow-color, var(--surface-background)), transparent);
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-top-scroll="true"]::before,
|
||||
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::before,
|
||||
[data-scroll-shadow-viewport][data-bottom-scroll="true"]::after,
|
||||
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::after,
|
||||
[data-scroll-shadow-viewport][data-left-scroll="true"]::before,
|
||||
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::before,
|
||||
[data-scroll-shadow-viewport][data-right-scroll="true"]::after,
|
||||
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Sticky-fade overlay: the crisp duplicate identity stays
|
||||
mounted and its visibility is driven synchronously by the scroller's own
|
||||
top-scroll data attribute — the same signal that engages the mask — so the
|
||||
overlay appears in the exact frame the mask hides the real header (no blink).
|
||||
mounted and its visibility is driven synchronously by the viewport's
|
||||
top-scroll data attribute, so the overlay appears in the exact frame the
|
||||
edge fade covers the real header (no blink).
|
||||
ScrollShadow sets data-top-scroll when nothing is below the viewport and
|
||||
data-top-bottom-scroll when content is both above and below. */
|
||||
.oc-sticky-fade-overlay {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-scroll="true"]) .oc-sticky-fade-overlay,
|
||||
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-bottom-scroll="true"]) .oc-sticky-fade-overlay {
|
||||
.oc-sticky-fade-root:has(
|
||||
[data-scroll-shadow-viewport]:is([data-top-scroll="true"], [data-top-bottom-scroll="true"])
|
||||
) .oc-sticky-fade-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-scroll-shadow="true"][data-orientation="vertical"][data-bottom-scroll="true"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0%,
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0%,
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@keyframes spin-once {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
Reference in New Issue
Block a user