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')} />
|
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
|
||||||
) : (
|
) : (
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
|
viewportClassName="h-full"
|
||||||
className="h-full overflow-y-auto overflow-x-hidden p-3"
|
className="h-full overflow-y-auto overflow-x-hidden p-3"
|
||||||
data-diff-virtual-root
|
data-diff-virtual-root
|
||||||
data-diff-virtual-content
|
data-diff-virtual-content
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{directoryError ? (
|
||||||
<MobileFilesState message={directoryError} />
|
<MobileFilesState message={directoryError} />
|
||||||
) : query.trim() ? (
|
) : query.trim() ? (
|
||||||
|
|||||||
@@ -1455,7 +1455,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
// clipped overflow swallowed the footer.
|
// clipped overflow swallowed the footer.
|
||||||
const surfaceContent = (
|
const surfaceContent = (
|
||||||
<div ref={contentRootRef} className="flex min-h-0 flex-1 flex-col">
|
<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
|
{/* The search bar scrolls WITH the list (iOS-style): the open-time
|
||||||
auto-scroll to the current session naturally tucks it away, and
|
auto-scroll to the current session naturally tucks it away, and
|
||||||
scrolling to the very top brings it back. */}
|
scrolling to the very top brings it back. */}
|
||||||
|
|||||||
@@ -327,7 +327,8 @@ const ChatViewport = React.memo(({
|
|||||||
>
|
>
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<ScrollShadow
|
<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}
|
ref={scrollRef}
|
||||||
style={CHAT_SCROLL_STYLE}
|
style={CHAT_SCROLL_STYLE}
|
||||||
observeMutations={false}
|
observeMutations={false}
|
||||||
@@ -335,7 +336,6 @@ const ChatViewport = React.memo(({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={focusScrollContainer}
|
onClick={focusScrollContainer}
|
||||||
onScroll={handleHistoryScroll}
|
onScroll={handleHistoryScroll}
|
||||||
data-scroll-shadow="true"
|
|
||||||
data-scrollbar="chat"
|
data-scrollbar="chat"
|
||||||
>
|
>
|
||||||
<div className="relative z-0 min-h-full">
|
<div className="relative z-0 min-h-full">
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
|
|||||||
<WorkStatusPresenceProvider onChange={setRenderedSections}>
|
<WorkStatusPresenceProvider onChange={setRenderedSections}>
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
ref={restore}
|
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}
|
onScroll={handleScroll}
|
||||||
size={24}
|
size={24}
|
||||||
className="oc-hide-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-2"
|
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)
|
? Math.min(STICKY_FADE_MIN_SIZE + scroller.scrollTop, STICKY_FADE_MAX_SIZE)
|
||||||
: 0;
|
: 0;
|
||||||
stickyFadeSizeRef.current = fadeSize;
|
stickyFadeSizeRef.current = fadeSize;
|
||||||
scroller.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
|
const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
|
||||||
scroller.style.setProperty(
|
fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
|
||||||
|
fadeRoot?.style.setProperty(
|
||||||
'--scroll-shadow-top-clear-size',
|
'--scroll-shadow-top-clear-size',
|
||||||
`${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`,
|
`${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`,
|
||||||
);
|
);
|
||||||
@@ -876,24 +877,23 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
|
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}
|
onPointerDownCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
||||||
onClickCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
onClickCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
||||||
onContextMenuCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
onContextMenuCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
||||||
>
|
>
|
||||||
<ScrollableOverlay
|
<ScrollableOverlay
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
useScrollShadow={stickyHeaders}
|
useScrollShadow={stickyHeaders}
|
||||||
hideBottomScrollShadow
|
hideBottomScrollShadow
|
||||||
scrollShadowSize={12}
|
scrollShadowSize={12}
|
||||||
outerClassName={maxHeightClassName}
|
outerClassName={maxHeightClassName}
|
||||||
className="oc-sticky-fade-scroller overlay-scrollbar-target--no-gutter"
|
className="overlay-scrollbar-target--no-gutter"
|
||||||
style={{
|
style={maxHeightStyle}
|
||||||
...(stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : {}),
|
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
|
||||||
...maxHeightStyle,
|
>
|
||||||
}}
|
<div className="px-1">
|
||||||
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
|
|
||||||
>
|
|
||||||
<div className="px-1">
|
|
||||||
{includeNotSelected ? (
|
{includeNotSelected ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -964,16 +964,16 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
{stickyHeaders && leadingSectionKey ? (
|
{stickyHeaders && leadingSectionKey ? (
|
||||||
<div
|
<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"
|
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"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{renderSectionIdentity(leadingSectionKey)}
|
{renderSectionIdentity(leadingSectionKey)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
|
<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
|
// Find the nearest dialog or overflow ancestor to constrain within
|
||||||
let container: HTMLElement | null = triggerRef.current.parentElement;
|
let container: HTMLElement | null = triggerRef.current.parentElement;
|
||||||
while (container) {
|
while (container) {
|
||||||
if (container.getAttribute('role') === 'dialog' || container.hasAttribute('data-scroll-shadow')) {
|
if (container.matches('[role="dialog"], [data-scroll-shadow-scroller]')) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const style = getComputedStyle(container);
|
const style = getComputedStyle(container);
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
</header>
|
</header>
|
||||||
) : null}
|
) : 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="mx-auto w-full max-w-2xl px-4 sm:px-6 py-5">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
|||||||
@@ -1782,6 +1782,7 @@ export function ScheduledTaskEditorDialog(props: {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
|
viewportClassName="flex-1 min-h-0"
|
||||||
className="flex-1 min-h-0 overflow-auto [scrollbar-gutter:stable_both-edges]"
|
className="flex-1 min-h-0 overflow-auto [scrollbar-gutter:stable_both-edges]"
|
||||||
size={64}
|
size={64}
|
||||||
hideTopShadow
|
hideTopShadow
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
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 type { SessionGroup } from '../types';
|
||||||
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||||
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
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
|
// Keep per-scroll measurements out of React state so the interaction guard
|
||||||
// can read the current fade boundary without rerendering the sidebar.
|
// can read the current fade boundary without rerendering the sidebar.
|
||||||
const topFadeSizeRef = React.useRef(0);
|
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.
|
// render boundary only when the sticky identity overlay appears or hides.
|
||||||
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
|
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
|
||||||
const hasTopScroll = scroller.scrollTop > 1;
|
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)
|
? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE)
|
||||||
: 0;
|
: 0;
|
||||||
topFadeSizeRef.current = topFadeSize;
|
topFadeSizeRef.current = topFadeSize;
|
||||||
scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
|
const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
|
||||||
scroller.style.setProperty(
|
fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
|
||||||
|
fadeRoot?.style.setProperty(
|
||||||
'--scroll-shadow-top-clear-size',
|
'--scroll-shadow-top-clear-size',
|
||||||
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
|
`${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
|
// 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
|
// 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
|
// 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
|
// 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;
|
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
|
||||||
|
|
||||||
if (model.projectSections.length === 0) {
|
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) {
|
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 (
|
return (
|
||||||
@@ -217,21 +218,21 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
|||||||
// rows appear below naturally.
|
// rows appear below naturally.
|
||||||
<div
|
<div
|
||||||
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
|
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}
|
onPointerDownCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||||
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||||
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||||
>
|
>
|
||||||
<ScrollableOverlay
|
<ScrollableOverlay
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
useScrollShadow
|
useScrollShadow
|
||||||
hideTopScrollShadow={!enableStickyFade}
|
hideTopScrollShadow={!enableStickyFade}
|
||||||
scrollShadowSize={96}
|
scrollShadowSize={96}
|
||||||
outerClassName="flex-1 min-h-0"
|
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 ? '' : '')}
|
className="oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]"
|
||||||
// SAFETY: the custom property is the only dynamic CSS declaration here.
|
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
||||||
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
>
|
||||||
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
|
||||||
>
|
|
||||||
{model.topContent}
|
{model.topContent}
|
||||||
{view.showOnlyMainWorkspace ? (
|
{view.showOnlyMainWorkspace ? (
|
||||||
<div className="space-y-[0.6rem] py-1">
|
<div className="space-y-[0.6rem] py-1">
|
||||||
@@ -372,7 +373,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
|||||||
<DragOverlay dropAnimation={null} />
|
<DragOverlay dropAnimation={null} />
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
|
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
|
||||||
<div
|
<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"
|
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 React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
|
export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
|
||||||
as?: React.ElementType;
|
as?: React.ElementType;
|
||||||
|
viewportClassName?: string;
|
||||||
orientation?: "vertical" | "horizontal";
|
orientation?: "vertical" | "horizontal";
|
||||||
offset?: number;
|
offset?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -12,22 +14,23 @@ export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
|
|||||||
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
|
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function mergeRefs<T>(...refs: Array<React.Ref<T>>): React.RefCallback<T> {
|
type EdgeState = "both" | "none" | "top" | "bottom" | "left" | "right";
|
||||||
return (value) => {
|
type ScrollShadowViewportStyle = React.CSSProperties & { "--scroll-shadow-size": string };
|
||||||
refs.forEach((ref) => {
|
|
||||||
if (typeof ref === "function") {
|
const EDGE_ATTRIBUTES = [
|
||||||
ref(value);
|
"data-top-scroll",
|
||||||
} else if (ref && typeof ref === "object") {
|
"data-bottom-scroll",
|
||||||
(ref as React.MutableRefObject<T | null>).current = value;
|
"data-top-bottom-scroll",
|
||||||
}
|
"data-left-scroll",
|
||||||
});
|
"data-right-scroll",
|
||||||
};
|
"data-left-right-scroll",
|
||||||
}
|
] as const;
|
||||||
|
|
||||||
export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
|
export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
as: Component = "div",
|
as: Component = "div",
|
||||||
|
viewportClassName,
|
||||||
orientation = "vertical",
|
orientation = "vertical",
|
||||||
offset = 0,
|
offset = 0,
|
||||||
size = 48,
|
size = 48,
|
||||||
@@ -40,52 +43,46 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const internalRef = React.useRef<HTMLElement>(null);
|
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"];
|
const viewportStyle = React.useMemo<ScrollShadowViewportStyle>(
|
||||||
delete (rest as Record<string, unknown>)["data-scroll-shadow"];
|
() => ({ "--scroll-shadow-size": `${size}px` }),
|
||||||
|
[size],
|
||||||
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 clearAttributes = React.useCallback((el: HTMLElement) => {
|
const clearAttributes = React.useCallback((el: HTMLElement) => {
|
||||||
["top", "bottom", "top-bottom", "left", "right", "left-right"].forEach((attr) => {
|
EDGE_ATTRIBUTES.forEach((attribute) => el.removeAttribute(attribute));
|
||||||
el.removeAttribute(`data-${attr}-scroll`);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 checkOverflow = React.useCallback(() => {
|
||||||
const el = internalRef.current;
|
const el = internalRef.current;
|
||||||
if (!el) return;
|
const viewport = viewportRef.current;
|
||||||
|
if (!el || !viewport) return;
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
clearAttributes(el);
|
clearAttributes(viewport);
|
||||||
|
edgeStateRef.current = "";
|
||||||
|
visibleRef.current = "none";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,25 +94,29 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
|
|||||||
orientation === "vertical"
|
orientation === "vertical"
|
||||||
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
|
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
|
||||||
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
|
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
|
||||||
let hasAfter =
|
const hasAfter =
|
||||||
orientation === "vertical"
|
orientation === "vertical"
|
||||||
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
|
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
|
||||||
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
|
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
|
||||||
|
|
||||||
const effectiveHasBefore = hideTopShadow && orientation === "vertical" ? false : hasBefore;
|
const effectiveHasBefore = hasBefore && !(orientation === "vertical" && hideTopShadow);
|
||||||
|
const effectiveHasAfter = hasAfter && !(orientation === "vertical" && hideBottomShadow);
|
||||||
if (hideBottomShadow && orientation === "vertical") {
|
const beforeEdge = orientation === "vertical" ? "top" : "left";
|
||||||
hasAfter = false;
|
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) {
|
if (next !== visibleRef.current) {
|
||||||
visibleRef.current = next;
|
visibleRef.current = next;
|
||||||
onVisibilityChange?.(next);
|
onVisibilityChange?.(next);
|
||||||
}
|
}
|
||||||
}, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setAttributes]);
|
}, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setEdgeAttribute]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const el = internalRef.current;
|
const el = internalRef.current;
|
||||||
@@ -132,9 +133,9 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = () => checkOverflow(); // Scroll should be immediate
|
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 =
|
const mutationObserver =
|
||||||
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
|
observeMutations && "MutationObserver" in globalThis ? new MutationObserver(throttledCheck) : null;
|
||||||
|
|
||||||
checkOverflow();
|
checkOverflow();
|
||||||
|
|
||||||
@@ -157,16 +158,23 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
|
|||||||
}, [checkOverflow, observeMutations]);
|
}, [checkOverflow, observeMutations]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<div
|
||||||
{...rest}
|
ref={viewportRef}
|
||||||
ref={mergeRefs(internalRef, ref)}
|
className={cn("relative flex min-h-0 min-w-0 flex-col", viewportClassName)}
|
||||||
className={className}
|
data-scroll-shadow-viewport
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
data-scroll-shadow={dataScrollShadow ?? true}
|
style={viewportStyle}
|
||||||
style={mergedStyle}
|
|
||||||
>
|
>
|
||||||
{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
|
...rest
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const containerRef = React.useRef<HTMLElement | null>(null);
|
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, []);
|
React.useImperativeHandle(ref, () => containerRef.current as HTMLElement, []);
|
||||||
|
|
||||||
@@ -62,16 +72,11 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
|||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
as={Component}
|
as={Component}
|
||||||
ref={containerRef as React.Ref<HTMLElement>}
|
ref={containerRef as React.Ref<HTMLElement>}
|
||||||
|
viewportClassName={containerSizeClassName}
|
||||||
size={scrollShadowSize}
|
size={scrollShadowSize}
|
||||||
hideTopShadow={hideTopScrollShadow}
|
hideTopShadow={hideTopScrollShadow}
|
||||||
hideBottomShadow={hideBottomScrollShadow}
|
hideBottomShadow={hideBottomScrollShadow}
|
||||||
className={cn(
|
className={containerClassName}
|
||||||
"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
|
|
||||||
)}
|
|
||||||
style={style as React.CSSProperties}
|
style={style as React.CSSProperties}
|
||||||
observeMutations={observeMutations}
|
observeMutations={observeMutations}
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -81,13 +86,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
|||||||
) : (
|
) : (
|
||||||
<Component
|
<Component
|
||||||
ref={containerRef as React.Ref<HTMLElement>}
|
ref={containerRef as React.Ref<HTMLElement>}
|
||||||
className={cn(
|
className={containerClassName}
|
||||||
"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
|
|
||||||
)}
|
|
||||||
style={style}
|
style={style}
|
||||||
{...rest}
|
{...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">
|
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
ref={scrollRef}
|
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"
|
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
>
|
>
|
||||||
{shouldVirtualize ? (
|
{shouldVirtualize ? (
|
||||||
|
|||||||
+54
-59
@@ -256,86 +256,81 @@ div[data-chat-input-footer="true"] {
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */
|
/* Scroll edge fades live on the viewport, never on the scrolling content. */
|
||||||
[data-scroll-shadow="true"] {
|
[data-scroll-shadow-viewport]::before,
|
||||||
--scroll-shadow-size: var(--scroll-shadow-size, 48px);
|
[data-scroll-shadow-viewport]::after {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-scroll-shadow="true"][data-orientation="vertical"] {
|
[data-scroll-shadow-viewport][data-orientation="vertical"]::before,
|
||||||
mask-mode: alpha;
|
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
|
||||||
-webkit-mask-repeat: no-repeat;
|
inset-inline: 0;
|
||||||
mask-repeat: no-repeat;
|
|
||||||
-webkit-mask-size: 100% 100%;
|
|
||||||
mask-size: 100% 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-bottom-scroll="true"] {
|
[data-scroll-shadow-viewport][data-orientation="vertical"]::before {
|
||||||
mask-image: linear-gradient(
|
top: 0;
|
||||||
|
height: var(--scroll-shadow-top-size, var(--scroll-shadow-size));
|
||||||
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
transparent 0%,
|
var(--scroll-shadow-color, var(--surface-background)) 0,
|
||||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
var(--scroll-shadow-color, var(--surface-background)) 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)),
|
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-scroll="true"] {
|
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
|
||||||
mask-image: linear-gradient(
|
bottom: 0;
|
||||||
to bottom,
|
height: var(--scroll-shadow-size);
|
||||||
transparent 0%,
|
background: linear-gradient(to top, var(--scroll-shadow-color, var(--surface-background)), transparent);
|
||||||
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="horizontal"]::before,
|
||||||
);
|
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
|
||||||
-webkit-mask-image: linear-gradient(
|
inset-block: 0;
|
||||||
to bottom,
|
width: var(--scroll-shadow-size);
|
||||||
transparent 0%,
|
}
|
||||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
|
||||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before {
|
||||||
#000 100%
|
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
|
/* Sticky-fade overlay: the crisp duplicate identity stays
|
||||||
mounted and its visibility is driven synchronously by the scroller's own
|
mounted and its visibility is driven synchronously by the viewport's
|
||||||
top-scroll data attribute — the same signal that engages the mask — so the
|
top-scroll data attribute, so the overlay appears in the exact frame the
|
||||||
overlay appears in the exact frame the mask hides the real header (no blink).
|
edge fade covers the real header (no blink).
|
||||||
ScrollShadow sets data-top-scroll when nothing is below the viewport and
|
ScrollShadow sets data-top-scroll when nothing is below the viewport and
|
||||||
data-top-bottom-scroll when content is both above and below. */
|
data-top-bottom-scroll when content is both above and below. */
|
||||||
.oc-sticky-fade-overlay {
|
.oc-sticky-fade-overlay {
|
||||||
opacity: 0;
|
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-root:has(.oc-sticky-fade-scroller[data-top-bottom-scroll="true"]) .oc-sticky-fade-overlay {
|
[data-scroll-shadow-viewport]:is([data-top-scroll="true"], [data-top-bottom-scroll="true"])
|
||||||
|
) .oc-sticky-fade-overlay {
|
||||||
opacity: 1;
|
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 {
|
@keyframes spin-once {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
|
|||||||
Reference in New Issue
Block a user