From 51392ce29967f3faa281140479a3aa10616e35d5 Mon Sep 17 00:00:00 2001 From: atomiks Date: Sat, 9 Aug 2025 18:10:57 +1000 Subject: [PATCH 1/5] [scroll area] Overflow presence state attributes --- .../reference/generated/scroll-area-root.json | 21 ++- .../generated/scroll-area-scrollbar.json | 18 +++ .../generated/scroll-area-viewport.json | 21 ++- .../scroll-area/scroll-area-inset.tsx | 7 +- .../scroll-area/root/ScrollAreaRoot.test.tsx | 148 +++++++++++++++++- .../src/scroll-area/root/ScrollAreaRoot.tsx | 43 ++++- .../scroll-area/root/ScrollAreaRootContext.ts | 22 +++ .../root/ScrollAreaRootDataAttributes.ts | 26 +++ .../react/src/scroll-area/root/styleHooks.ts | 12 ++ .../scrollbar/ScrollAreaScrollbar.test.tsx | 51 +++++- .../scrollbar/ScrollAreaScrollbar.tsx | 17 +- .../ScrollAreaScrollbarDataAttributes.ts | 24 +++ .../viewport/ScrollAreaViewport.test.tsx | 29 +++- .../viewport/ScrollAreaViewport.tsx | 45 +++++- .../ScrollAreaViewportDataAttributes.ts | 26 +++ 15 files changed, 494 insertions(+), 16 deletions(-) create mode 100644 packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts create mode 100644 packages/react/src/scroll-area/root/styleHooks.ts create mode 100644 packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts diff --git a/docs/reference/generated/scroll-area-root.json b/docs/reference/generated/scroll-area-root.json index 4efeffe4e4..cc19389417 100644 --- a/docs/reference/generated/scroll-area-root.json +++ b/docs/reference/generated/scroll-area-root.json @@ -11,7 +11,26 @@ "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." } }, - "dataAttributes": {}, + "dataAttributes": { + "data-has-overflow-x": { + "description": "Present when the scroll area has horizontal overflow." + }, + "data-has-overflow-y": { + "description": "Present when the scroll area has vertical overflow." + }, + "data-overflow-x-end": { + "description": "Present when there is overflow on the inline end side for the horizontal axis." + }, + "data-overflow-x-start": { + "description": "Present when there is overflow on the inline start side for the horizontal axis." + }, + "data-overflow-y-end": { + "description": "Present when there is overflow on the block end (bottom) side." + }, + "data-overflow-y-start": { + "description": "Present when there is overflow on the block start (top) side." + } + }, "cssVariables": { "--scroll-area-corner-height": { "description": "The scroll area's corner height.", diff --git a/docs/reference/generated/scroll-area-scrollbar.json b/docs/reference/generated/scroll-area-scrollbar.json index 1d65abb554..e95118e169 100644 --- a/docs/reference/generated/scroll-area-scrollbar.json +++ b/docs/reference/generated/scroll-area-scrollbar.json @@ -26,9 +26,27 @@ "description": "Indicates the orientation of the scrollbar.", "type": "'horizontal' | 'vertical'" }, + "data-has-overflow-x": { + "description": "Present when the scroll area has horizontal overflow." + }, + "data-has-overflow-y": { + "description": "Present when the scroll area has vertical overflow." + }, "data-hovering": { "description": "Present when the pointer is over the scroll area." }, + "data-overflow-x-end": { + "description": "Present when there is overflow on the inline end side for the horizontal axis." + }, + "data-overflow-x-start": { + "description": "Present when there is overflow on the inline start side for the horizontal axis." + }, + "data-overflow-y-end": { + "description": "Present when there is overflow on the block end (bottom) side." + }, + "data-overflow-y-start": { + "description": "Present when there is overflow on the block start (top) side." + }, "data-scrolling": { "description": "Present when the users scrolls inside the scroll area." } diff --git a/docs/reference/generated/scroll-area-viewport.json b/docs/reference/generated/scroll-area-viewport.json index 5001962521..6b4429b253 100644 --- a/docs/reference/generated/scroll-area-viewport.json +++ b/docs/reference/generated/scroll-area-viewport.json @@ -11,6 +11,25 @@ "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." } }, - "dataAttributes": {}, + "dataAttributes": { + "data-has-overflow-x": { + "description": "Present when the scroll area has horizontal overflow." + }, + "data-has-overflow-y": { + "description": "Present when the scroll area has vertical overflow." + }, + "data-overflow-x-end": { + "description": "Present when there is overflow on the inline end side for the horizontal axis." + }, + "data-overflow-x-start": { + "description": "Present when there is overflow on the inline start side for the horizontal axis." + }, + "data-overflow-y-end": { + "description": "Present when there is overflow on the block end (bottom) side." + }, + "data-overflow-y-start": { + "description": "Present when there is overflow on the block start (top) side." + } + }, "cssVariables": {} } diff --git a/docs/src/app/(private)/experiments/scroll-area/scroll-area-inset.tsx b/docs/src/app/(private)/experiments/scroll-area/scroll-area-inset.tsx index a77a437c5b..7e740b020b 100644 --- a/docs/src/app/(private)/experiments/scroll-area/scroll-area-inset.tsx +++ b/docs/src/app/(private)/experiments/scroll-area/scroll-area-inset.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; import { ScrollArea } from '@base-ui-components/react/scroll-area'; +import { DirectionProvider } from '@base-ui-components/react/direction-provider'; import styles from './scroll-area-inset.module.css'; export default function ScrollAreaInset() { return ( -
+

Scroll content is not clipped by inset scrollbars (user-defined paddings)

- +

@@ -25,6 +26,6 @@ export default function ScrollAreaInset() { -

+
); } diff --git a/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx b/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx index 74a1ebffb9..4c62fcd772 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx +++ b/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import { ScrollArea } from '@base-ui-components/react/scroll-area'; -import { screen } from '@mui/internal-test-utils'; +import { fireEvent, flushMicrotasks, screen } from '@mui/internal-test-utils'; import { createRenderer, isJSDOM } from '#test-utils'; import { expect } from 'chai'; import { describeConformance } from '../../../test/describeConformance'; +import { DirectionProvider } from '../../direction-provider/DirectionProvider'; const VIEWPORT_SIZE = 200; const SCROLLABLE_CONTENT_SIZE = 1000; @@ -171,4 +172,149 @@ describe('', () => { ).to.equal(`${(VIEWPORT_SIZE - MARGIN * 2) * (VIEWPORT_SIZE / SCROLLABLE_CONTENT_SIZE)}px`); }); }); + + describe.skipIf(isJSDOM)('overflow data attributes', () => { + it('applies data attributes on root, viewport and scrollbars based on overflow and edges', async () => { + await render( + + +
+ + + + + + + + , + ); + + const root = screen.getByTestId('root'); + const viewport = screen.getByTestId('viewport'); + const vScrollbar = screen.getByTestId('scrollbar-vertical'); + const hScrollbar = screen.getByTestId('scrollbar-horizontal'); + + // Initial: at start (top/left) + expect(root).to.have.attribute('data-has-overflow-x'); + expect(root).to.have.attribute('data-has-overflow-y'); + expect(root).not.to.have.attribute('data-overflow-x-start'); + expect(root).to.have.attribute('data-overflow-x-end'); + expect(root).not.to.have.attribute('data-overflow-y-start'); + expect(root).to.have.attribute('data-overflow-y-end'); + + expect(viewport).to.have.attribute('data-has-overflow-x'); + expect(viewport).to.have.attribute('data-has-overflow-y'); + expect(viewport).not.to.have.attribute('data-overflow-x-start'); + expect(viewport).to.have.attribute('data-overflow-x-end'); + expect(viewport).not.to.have.attribute('data-overflow-y-start'); + expect(viewport).to.have.attribute('data-overflow-y-end'); + + expect(vScrollbar).to.have.attribute('data-has-overflow-y'); + expect(vScrollbar).not.to.have.attribute('data-overflow-y-start'); + expect(vScrollbar).to.have.attribute('data-overflow-y-end'); + expect(hScrollbar).to.have.attribute('data-has-overflow-x'); + expect(hScrollbar).not.to.have.attribute('data-overflow-x-start'); + expect(hScrollbar).to.have.attribute('data-overflow-x-end'); + + // Scroll to middle + const halfY = (viewport.scrollHeight - viewport.clientHeight) / 2; + const halfX = (viewport.scrollWidth - viewport.clientWidth) / 2; + fireEvent.scroll(viewport, { + target: { scrollTop: halfY, scrollLeft: halfX }, + }); + await flushMicrotasks(); + + expect(root).to.have.attribute('data-overflow-y-start'); + expect(root).to.have.attribute('data-overflow-y-end'); + expect(root).to.have.attribute('data-overflow-x-start'); + expect(root).to.have.attribute('data-overflow-x-end'); + + expect(viewport).to.have.attribute('data-overflow-y-start'); + expect(viewport).to.have.attribute('data-overflow-y-end'); + expect(viewport).to.have.attribute('data-overflow-x-start'); + expect(viewport).to.have.attribute('data-overflow-x-end'); + + expect(vScrollbar).to.have.attribute('data-overflow-y-start'); + expect(vScrollbar).to.have.attribute('data-overflow-y-end'); + expect(hScrollbar).to.have.attribute('data-overflow-x-start'); + expect(hScrollbar).to.have.attribute('data-overflow-x-end'); + + // Scroll to end (bottom-right) + fireEvent.scroll(viewport, { + target: { + scrollTop: viewport.scrollHeight - viewport.clientHeight, + scrollLeft: viewport.scrollWidth - viewport.clientWidth, + }, + }); + await flushMicrotasks(); + + expect(root).to.have.attribute('data-overflow-y-start'); + expect(root).not.to.have.attribute('data-overflow-y-end'); + expect(root).to.have.attribute('data-overflow-x-start'); + expect(root).not.to.have.attribute('data-overflow-x-end'); + + expect(viewport).to.have.attribute('data-overflow-y-start'); + expect(viewport).not.to.have.attribute('data-overflow-y-end'); + expect(viewport).to.have.attribute('data-overflow-x-start'); + expect(viewport).not.to.have.attribute('data-overflow-x-end'); + + expect(vScrollbar).to.have.attribute('data-overflow-y-start'); + expect(vScrollbar).not.to.have.attribute('data-overflow-y-end'); + expect(hScrollbar).to.have.attribute('data-overflow-x-start'); + expect(hScrollbar).not.to.have.attribute('data-overflow-x-end'); + }); + + it('rtl', async () => { + await render( + + + +
+ + + + + + , + ); + + const root = screen.getByTestId('root'); + const viewport = screen.getByTestId('viewport'); + + const maxScrollLeft = viewport.scrollWidth - viewport.clientWidth; + fireEvent.scroll(viewport, { + target: { + scrollLeft: 0, + }, + }); + await flushMicrotasks(); + + expect(root).to.have.attribute('data-has-overflow-x'); + expect(root).not.to.have.attribute('data-overflow-x-start'); + expect(root).to.have.attribute('data-overflow-x-end'); + + fireEvent.scroll(viewport, { + target: { + scrollLeft: -maxScrollLeft / 2, + }, + }); + await flushMicrotasks(); + + expect(root).to.have.attribute('data-overflow-x-start'); + expect(root).to.have.attribute('data-overflow-x-end'); + + fireEvent.scroll(viewport, { + target: { + scrollLeft: -maxScrollLeft, + }, + }); + await flushMicrotasks(); + + expect(root).to.have.attribute('data-overflow-x-start'); + expect(root).not.to.have.attribute('data-overflow-x-end'); + }); + }); }); diff --git a/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx b/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx index 4b0ba81b7a..d196051d2f 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx +++ b/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx @@ -11,6 +11,7 @@ import { getOffset } from '../utils/getOffset'; import { ScrollAreaScrollbarDataAttributes } from '../scrollbar/ScrollAreaScrollbarDataAttributes'; import { styleDisableScrollbar } from '../../utils/styles'; import { useBaseUiId } from '../../utils/useBaseUiId'; +import { scrollAreaStyleHookMapping } from './styleHooks'; interface Size { width: number; @@ -35,6 +36,12 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( const [cornerSize, setCornerSize] = React.useState({ width: 0, height: 0 }); const [thumbSize, setThumbSize] = React.useState({ width: 0, height: 0 }); const [touchModality, setTouchModality] = React.useState(false); + const [overflowEdges, setOverflowEdges] = React.useState({ + xStart: false, + xEnd: false, + yStart: false, + yEnd: false, + }); const rootId = useBaseUiId(); @@ -188,6 +195,18 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( } } + const state: ScrollAreaRoot.State = React.useMemo( + () => ({ + hasOverflowX: !hiddenState.scrollbarXHidden, + hasOverflowY: !hiddenState.scrollbarYHidden, + overflowXStart: overflowEdges.xStart, + overflowXEnd: overflowEdges.xEnd, + overflowYStart: overflowEdges.yStart, + overflowYEnd: overflowEdges.yEnd, + }), + [hiddenState.scrollbarXHidden, hiddenState.scrollbarYHidden, overflowEdges], + ); + const props: HTMLProps = { role: 'presentation', onPointerEnter: handlePointerEnterOrMove, @@ -207,7 +226,9 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( const element = useRenderElement('div', componentProps, { ref: forwardedRef, + state, props: [props, elementProps], + customStyleHookMapping: scrollAreaStyleHookMapping, }); const contextValue = React.useMemo( @@ -236,6 +257,9 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( rootId, hiddenState, setHiddenState, + overflowEdges, + setOverflowEdges, + viewportState: state, }), [ handlePointerDown, @@ -259,6 +283,8 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( thumbXRef, rootId, hiddenState, + overflowEdges, + state, ], ); @@ -271,7 +297,20 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( }); export namespace ScrollAreaRoot { - export interface Props extends BaseUIComponentProps<'div', State> {} + export interface State { + /** Whether horizontal overflow is present. */ + hasOverflowX: boolean; + /** Whether vertical overflow is present. */ + hasOverflowY: boolean; + /** Whether there is overflow on the inline start side for the horizontal axis. */ + overflowXStart: boolean; + /** Whether there is overflow on the inline end side for the horizontal axis. */ + overflowXEnd: boolean; + /** Whether there is overflow on the block start (top) side. */ + overflowYStart: boolean; + /** Whether there is overflow on the block end (bottom) side. */ + overflowYEnd: boolean; + } - export interface State {} + export interface Props extends BaseUIComponentProps<'div', State> {} } diff --git a/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts b/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts index 55a4431fdf..aaa44f516d 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts +++ b/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts @@ -35,6 +35,28 @@ export interface ScrollAreaRootContext { cornerHidden: boolean; }> >; + overflowEdges: { + xStart: boolean; + xEnd: boolean; + yStart: boolean; + yEnd: boolean; + }; + setOverflowEdges: React.Dispatch< + React.SetStateAction<{ + xStart: boolean; + xEnd: boolean; + yStart: boolean; + yEnd: boolean; + }> + >; + viewportState: { + hasOverflowX: boolean; + hasOverflowY: boolean; + overflowXStart: boolean; + overflowXEnd: boolean; + overflowYStart: boolean; + overflowYEnd: boolean; + }; } export const ScrollAreaRootContext = React.createContext( diff --git a/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts b/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts new file mode 100644 index 0000000000..59ded5dc40 --- /dev/null +++ b/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts @@ -0,0 +1,26 @@ +export enum ScrollAreaRootDataAttributes { + /** + * Present when the scroll area has horizontal overflow. + */ + hasOverflowX = 'data-has-overflow-x', + /** + * Present when the scroll area has vertical overflow. + */ + hasOverflowY = 'data-has-overflow-y', + /** + * Present when there is overflow on the inline start side for the horizontal axis. + */ + overflowXStart = 'data-overflow-x-start', + /** + * Present when there is overflow on the inline end side for the horizontal axis. + */ + overflowXEnd = 'data-overflow-x-end', + /** + * Present when there is overflow on the block start (top) side. + */ + overflowYStart = 'data-overflow-y-start', + /** + * Present when there is overflow on the block end (bottom) side. + */ + overflowYEnd = 'data-overflow-y-end', +} diff --git a/packages/react/src/scroll-area/root/styleHooks.ts b/packages/react/src/scroll-area/root/styleHooks.ts new file mode 100644 index 0000000000..5056506793 --- /dev/null +++ b/packages/react/src/scroll-area/root/styleHooks.ts @@ -0,0 +1,12 @@ +import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import type { ScrollAreaRoot } from './ScrollAreaRoot'; +import { ScrollAreaRootDataAttributes } from './ScrollAreaRootDataAttributes'; + +export const scrollAreaStyleHookMapping: CustomStyleHookMapping = { + hasOverflowX: (value) => (value ? { [ScrollAreaRootDataAttributes.hasOverflowX]: '' } : null), + hasOverflowY: (value) => (value ? { [ScrollAreaRootDataAttributes.hasOverflowY]: '' } : null), + overflowXStart: (value) => (value ? { [ScrollAreaRootDataAttributes.overflowXStart]: '' } : null), + overflowXEnd: (value) => (value ? { [ScrollAreaRootDataAttributes.overflowXEnd]: '' } : null), + overflowYStart: (value) => (value ? { [ScrollAreaRootDataAttributes.overflowYStart]: '' } : null), + overflowYEnd: (value) => (value ? { [ScrollAreaRootDataAttributes.overflowYEnd]: '' } : null), +}; diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx index a8d7754006..d6fc4f43bc 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx @@ -1,9 +1,8 @@ import * as React from 'react'; import { ScrollArea } from '@base-ui-components/react/scroll-area'; -import { screen, fireEvent } from '@mui/internal-test-utils'; -import { createRenderer } from '#test-utils'; +import { screen, fireEvent, flushMicrotasks } from '@mui/internal-test-utils'; +import { createRenderer, isJSDOM, describeConformance } from '#test-utils'; import { expect } from 'chai'; -import { describeConformance } from '../../../test/describeConformance'; import { SCROLL_TIMEOUT } from '../constants'; describe('', () => { @@ -74,4 +73,50 @@ describe('', () => { expect(verticalScrollbar).not.to.have.attribute('data-scrolling'); expect(horizontalScrollbar).not.to.have.attribute('data-scrolling'); }); + + describe.skipIf(isJSDOM)('data overflow attributes (scrollbars)', () => { + const VIEWPORT_SIZE = 200; + const SCROLLABLE_CONTENT_SIZE = 1000; + + it('applies data attributes on vertical and horizontal scrollbars', async () => { + await render( + + +
+ + + + + + + + , + ); + + const viewport = screen.getByTestId('viewport'); + const vScrollbar = screen.getByTestId('scrollbar-vertical'); + const hScrollbar = screen.getByTestId('scrollbar-horizontal'); + + expect(vScrollbar).to.have.attribute('data-has-overflow-y'); + expect(vScrollbar).not.to.have.attribute('data-overflow-y-start'); + expect(vScrollbar).to.have.attribute('data-overflow-y-end'); + + expect(hScrollbar).to.have.attribute('data-has-overflow-x'); + expect(hScrollbar).not.to.have.attribute('data-overflow-x-start'); + expect(hScrollbar).to.have.attribute('data-overflow-x-end'); + + // Scroll to middle + const halfY = (viewport.scrollHeight - viewport.clientHeight) / 2; + const halfX = (viewport.scrollWidth - viewport.clientWidth) / 2; + fireEvent.scroll(viewport, { + target: { scrollTop: halfY, scrollLeft: halfX }, + }); + await flushMicrotasks(); + + expect(vScrollbar).to.have.attribute('data-overflow-y-start'); + expect(vScrollbar).to.have.attribute('data-overflow-y-end'); + expect(hScrollbar).to.have.attribute('data-overflow-x-start'); + expect(hScrollbar).to.have.attribute('data-overflow-x-end'); + }); + }); }); diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx index e1451e9742..50d702ba20 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx @@ -8,6 +8,8 @@ import { getOffset } from '../utils/getOffset'; import { ScrollAreaRootCssVars } from '../root/ScrollAreaRootCssVars'; import { ScrollAreaScrollbarCssVars } from './ScrollAreaScrollbarCssVars'; import { useDirection } from '../../direction-provider/DirectionContext'; +import { scrollAreaStyleHookMapping } from '../root/styleHooks'; +import type { ScrollAreaRoot } from '../root/ScrollAreaRoot'; /** * A vertical or horizontal scrollbar for the scroll area. @@ -32,6 +34,7 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar scrollingX, scrollingY, hiddenState, + overflowEdges, scrollbarYRef, scrollbarXRef, viewportRef, @@ -51,8 +54,14 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar vertical: scrollingY, }[orientation], orientation, + hasOverflowX: !hiddenState.scrollbarXHidden, + hasOverflowY: !hiddenState.scrollbarYHidden, + overflowXStart: overflowEdges.xStart, + overflowXEnd: overflowEdges.xEnd, + overflowYStart: overflowEdges.yStart, + overflowYEnd: overflowEdges.yEnd, }), - [hovering, scrollingX, scrollingY, orientation], + [hovering, scrollingX, scrollingY, orientation, hiddenState, overflowEdges], ); const direction = useDirection(); @@ -202,6 +211,7 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar ref: [forwardedRef, orientation === 'vertical' ? scrollbarYRef : scrollbarXRef], state, props: [props, elementProps], + customStyleHookMapping: scrollAreaStyleHookMapping, }); const contextValue = React.useMemo(() => ({ orientation }), [orientation]); @@ -222,9 +232,12 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar }); export namespace ScrollAreaScrollbar { - export interface State { + export interface State extends ScrollAreaRoot.State { + /** Whether the scroll area is being hovered. */ hovering: boolean; + /** Whether the scroll area is being scrolled. */ scrolling: boolean; + /** The orientation of the scrollbar. */ orientation: 'vertical' | 'horizontal'; } diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts index 9841d84651..1201be756b 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts @@ -12,4 +12,28 @@ export enum ScrollAreaScrollbarDataAttributes { * Present when the users scrolls inside the scroll area. */ scrolling = 'data-scrolling', + /** + * Present when the scroll area has horizontal overflow. + */ + hasOverflowX = 'data-has-overflow-x', + /** + * Present when the scroll area has vertical overflow. + */ + hasOverflowY = 'data-has-overflow-y', + /** + * Present when there is overflow on the inline start side for the horizontal axis. + */ + overflowXStart = 'data-overflow-x-start', + /** + * Present when there is overflow on the inline end side for the horizontal axis. + */ + overflowXEnd = 'data-overflow-x-end', + /** + * Present when there is overflow on the block start (top) side. + */ + overflowYStart = 'data-overflow-y-start', + /** + * Present when there is overflow on the block end (bottom) side. + */ + overflowYEnd = 'data-overflow-y-end', } diff --git a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.test.tsx b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.test.tsx index 090b48c752..18c13dbe7b 100644 --- a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.test.tsx +++ b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.test.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { ScrollArea } from '@base-ui-components/react/scroll-area'; -import { createRenderer } from '#test-utils'; -import { describeConformance } from '../../../test/describeConformance'; +import { createRenderer, isJSDOM, describeConformance } from '#test-utils'; +import { screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; describe('', () => { const { render } = createRenderer(); @@ -12,4 +13,28 @@ describe('', () => { return render({node}); }, })); + + describe.skipIf(isJSDOM)('overflow data attributes (viewport)', () => { + const VIEWPORT_SIZE = 200; + const SCROLLABLE_CONTENT_SIZE = 1000; + + it('applies data attributes on viewport', async () => { + await render( + + +
+ + , + ); + + const viewport = screen.getByTestId('viewport'); + + expect(viewport).to.have.attribute('data-has-overflow-x'); + expect(viewport).to.have.attribute('data-has-overflow-y'); + expect(viewport).not.to.have.attribute('data-overflow-x-start'); + expect(viewport).to.have.attribute('data-overflow-x-end'); + expect(viewport).not.to.have.attribute('data-overflow-y-start'); + expect(viewport).to.have.attribute('data-overflow-y-end'); + }); + }); }); diff --git a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx index 46dd94af45..e2682bb876 100644 --- a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx +++ b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx @@ -13,6 +13,8 @@ import { MIN_THUMB_SIZE } from '../constants'; import { clamp } from '../../utils/clamp'; import { styleDisableScrollbar } from '../../utils/styles'; import { onVisible } from '../utils/onVisible'; +import { scrollAreaStyleHookMapping } from '../root/styleHooks'; +import type { ScrollAreaRoot } from '../root/ScrollAreaRoot'; /** * The actual scrollable container of the scroll area. @@ -40,6 +42,8 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( hiddenState, handleScroll, setHovering, + setOverflowEdges, + overflowEdges, } = useScrollAreaRootContext(); const direction = useDirection(); @@ -162,6 +166,31 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( cornerHidden, }; }); + + const nextOverflowEdges = { + xStart: + direction === 'rtl' + ? !scrollbarXHidden && scrollLeft < 0 + : !scrollbarXHidden && scrollLeft > 0, + xEnd: + direction === 'rtl' + ? !scrollbarXHidden && scrollLeft > -(scrollableContentWidth - viewportWidth) + : !scrollbarXHidden && scrollLeft < scrollableContentWidth - viewportWidth, + yStart: !scrollbarYHidden && scrollTop > 0, + yEnd: !scrollbarYHidden && scrollTop < scrollableContentHeight - viewportHeight, + }; + + setOverflowEdges((prev) => { + if ( + prev.xStart === nextOverflowEdges.xStart && + prev.xEnd === nextOverflowEdges.xEnd && + prev.yStart === nextOverflowEdges.yStart && + prev.yEnd === nextOverflowEdges.yEnd + ) { + return prev; + } + return nextOverflowEdges; + }); }); useIsoLayoutEffect(() => { @@ -246,9 +275,23 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( onKeyDown: handleUserInteraction, }; + const viewportState: ScrollAreaViewport.State = React.useMemo( + () => ({ + hasOverflowX: !hiddenState.scrollbarXHidden, + hasOverflowY: !hiddenState.scrollbarYHidden, + overflowXStart: overflowEdges.xStart, + overflowXEnd: overflowEdges.xEnd, + overflowYStart: overflowEdges.yStart, + overflowYEnd: overflowEdges.yEnd, + }), + [hiddenState.scrollbarXHidden, hiddenState.scrollbarYHidden, overflowEdges], + ); + const element = useRenderElement('div', componentProps, { ref: [forwardedRef, viewportRef], + state: viewportState, props: [props, elementProps], + customStyleHookMapping: scrollAreaStyleHookMapping, }); const contextValue: ScrollAreaViewportContext = React.useMemo( @@ -268,5 +311,5 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( export namespace ScrollAreaViewport { export interface Props extends BaseUIComponentProps<'div', State> {} - export interface State {} + export interface State extends ScrollAreaRoot.State {} } diff --git a/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts b/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts new file mode 100644 index 0000000000..c12ebf43a2 --- /dev/null +++ b/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts @@ -0,0 +1,26 @@ +export enum ScrollAreaViewportDataAttributes { + /** + * Present when the scroll area has horizontal overflow. + */ + hasOverflowX = 'data-has-overflow-x', + /** + * Present when the scroll area has vertical overflow. + */ + hasOverflowY = 'data-has-overflow-y', + /** + * Present when there is overflow on the inline start side for the horizontal axis. + */ + overflowXStart = 'data-overflow-x-start', + /** + * Present when there is overflow on the inline end side for the horizontal axis. + */ + overflowXEnd = 'data-overflow-x-end', + /** + * Present when there is overflow on the block start (top) side. + */ + overflowYStart = 'data-overflow-y-start', + /** + * Present when there is overflow on the block end (bottom) side. + */ + overflowYEnd = 'data-overflow-y-end', +} From 3940537629beee0456495fc58bb206e3681088f5 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 17 Sep 2025 14:04:40 +1000 Subject: [PATCH 2/5] lint --- docs/reference/generated/scroll-area-root.json | 4 ++-- docs/reference/generated/scroll-area-scrollbar.json | 4 ++-- docs/reference/generated/scroll-area-viewport.json | 4 ++-- packages/react/src/scroll-area/root/ScrollAreaRoot.tsx | 4 ++-- .../src/scroll-area/root/ScrollAreaRootDataAttributes.ts | 4 ++-- .../scroll-area/root/{styleHooks.ts => stateAttributes.ts} | 4 ++-- .../react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx | 4 ++-- .../scrollbar/ScrollAreaScrollbarDataAttributes.ts | 4 ++-- .../react/src/scroll-area/viewport/ScrollAreaViewport.tsx | 4 ++-- .../scroll-area/viewport/ScrollAreaViewportDataAttributes.ts | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) rename packages/react/src/scroll-area/root/{styleHooks.ts => stateAttributes.ts} (80%) diff --git a/docs/reference/generated/scroll-area-root.json b/docs/reference/generated/scroll-area-root.json index d097988bff..9c3de6f348 100644 --- a/docs/reference/generated/scroll-area-root.json +++ b/docs/reference/generated/scroll-area-root.json @@ -14,10 +14,10 @@ }, "dataAttributes": { "data-has-overflow-x": { - "description": "Present when the scroll area has horizontal overflow." + "description": "Present when the scroll area content is wider than the viewport." }, "data-has-overflow-y": { - "description": "Present when the scroll area has vertical overflow." + "description": "Present when the scroll area content is taller than the viewport." }, "data-overflow-x-end": { "description": "Present when there is overflow on the inline end side for the horizontal axis." diff --git a/docs/reference/generated/scroll-area-scrollbar.json b/docs/reference/generated/scroll-area-scrollbar.json index 3ef3479c41..7b92e50c90 100644 --- a/docs/reference/generated/scroll-area-scrollbar.json +++ b/docs/reference/generated/scroll-area-scrollbar.json @@ -31,10 +31,10 @@ "type": "'horizontal' | 'vertical'" }, "data-has-overflow-x": { - "description": "Present when the scroll area has horizontal overflow." + "description": "Present when the scroll area content is wider than the viewport." }, "data-has-overflow-y": { - "description": "Present when the scroll area has vertical overflow." + "description": "Present when the scroll area content is taller than the viewport." }, "data-hovering": { "description": "Present when the pointer is over the scroll area." diff --git a/docs/reference/generated/scroll-area-viewport.json b/docs/reference/generated/scroll-area-viewport.json index cd04d4bc81..5b5cc0cbca 100644 --- a/docs/reference/generated/scroll-area-viewport.json +++ b/docs/reference/generated/scroll-area-viewport.json @@ -15,10 +15,10 @@ }, "dataAttributes": { "data-has-overflow-x": { - "description": "Present when the scroll area has horizontal overflow." + "description": "Present when the scroll area content is wider than the viewport." }, "data-has-overflow-y": { - "description": "Present when the scroll area has vertical overflow." + "description": "Present when the scroll area content is taller than the viewport." }, "data-overflow-x-end": { "description": "Present when there is overflow on the inline end side for the horizontal axis." diff --git a/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx b/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx index e2ad7061a0..0136105790 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx +++ b/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx @@ -11,7 +11,7 @@ import { getOffset } from '../utils/getOffset'; import { ScrollAreaScrollbarDataAttributes } from '../scrollbar/ScrollAreaScrollbarDataAttributes'; import { styleDisableScrollbar } from '../../utils/styles'; import { useBaseUiId } from '../../utils/useBaseUiId'; -import { scrollAreaStyleHookMapping } from './styleHooks'; +import { scrollAreaStateAttributesMapping } from './stateAttributes'; import { contains } from '../../floating-ui-react/utils'; interface Size { @@ -231,7 +231,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( state, ref: [forwardedRef, rootRef], props: [props, elementProps], - customStyleHookMapping: scrollAreaStyleHookMapping, + stateAttributesMapping: scrollAreaStateAttributesMapping, }); const contextValue = React.useMemo( diff --git a/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts b/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts index 59ded5dc40..4ed1c33bda 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts +++ b/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts @@ -1,10 +1,10 @@ export enum ScrollAreaRootDataAttributes { /** - * Present when the scroll area has horizontal overflow. + * Present when the scroll area content is wider than the viewport. */ hasOverflowX = 'data-has-overflow-x', /** - * Present when the scroll area has vertical overflow. + * Present when the scroll area content is taller than the viewport. */ hasOverflowY = 'data-has-overflow-y', /** diff --git a/packages/react/src/scroll-area/root/styleHooks.ts b/packages/react/src/scroll-area/root/stateAttributes.ts similarity index 80% rename from packages/react/src/scroll-area/root/styleHooks.ts rename to packages/react/src/scroll-area/root/stateAttributes.ts index 5056506793..c172e30e09 100644 --- a/packages/react/src/scroll-area/root/styleHooks.ts +++ b/packages/react/src/scroll-area/root/stateAttributes.ts @@ -1,8 +1,8 @@ -import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import type { StateAttributesMapping } from '../../utils/getStateAttributesProps'; import type { ScrollAreaRoot } from './ScrollAreaRoot'; import { ScrollAreaRootDataAttributes } from './ScrollAreaRootDataAttributes'; -export const scrollAreaStyleHookMapping: CustomStyleHookMapping = { +export const scrollAreaStateAttributesMapping: StateAttributesMapping = { hasOverflowX: (value) => (value ? { [ScrollAreaRootDataAttributes.hasOverflowX]: '' } : null), hasOverflowY: (value) => (value ? { [ScrollAreaRootDataAttributes.hasOverflowY]: '' } : null), overflowXStart: (value) => (value ? { [ScrollAreaRootDataAttributes.overflowXStart]: '' } : null), diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx index 50d702ba20..41229fc783 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx @@ -8,7 +8,7 @@ import { getOffset } from '../utils/getOffset'; import { ScrollAreaRootCssVars } from '../root/ScrollAreaRootCssVars'; import { ScrollAreaScrollbarCssVars } from './ScrollAreaScrollbarCssVars'; import { useDirection } from '../../direction-provider/DirectionContext'; -import { scrollAreaStyleHookMapping } from '../root/styleHooks'; +import { scrollAreaStateAttributesMapping } from '../root/stateAttributes'; import type { ScrollAreaRoot } from '../root/ScrollAreaRoot'; /** @@ -211,7 +211,7 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar ref: [forwardedRef, orientation === 'vertical' ? scrollbarYRef : scrollbarXRef], state, props: [props, elementProps], - customStyleHookMapping: scrollAreaStyleHookMapping, + stateAttributesMapping: scrollAreaStateAttributesMapping, }); const contextValue = React.useMemo(() => ({ orientation }), [orientation]); diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts index 1201be756b..c0549dde31 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts @@ -13,11 +13,11 @@ export enum ScrollAreaScrollbarDataAttributes { */ scrolling = 'data-scrolling', /** - * Present when the scroll area has horizontal overflow. + * Present when the scroll area content is wider than the viewport. */ hasOverflowX = 'data-has-overflow-x', /** - * Present when the scroll area has vertical overflow. + * Present when the scroll area content is taller than the viewport. */ hasOverflowY = 'data-has-overflow-y', /** diff --git a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx index e2682bb876..64642efe66 100644 --- a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx +++ b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx @@ -13,7 +13,7 @@ import { MIN_THUMB_SIZE } from '../constants'; import { clamp } from '../../utils/clamp'; import { styleDisableScrollbar } from '../../utils/styles'; import { onVisible } from '../utils/onVisible'; -import { scrollAreaStyleHookMapping } from '../root/styleHooks'; +import { scrollAreaStateAttributesMapping } from '../root/stateAttributes'; import type { ScrollAreaRoot } from '../root/ScrollAreaRoot'; /** @@ -291,7 +291,7 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( ref: [forwardedRef, viewportRef], state: viewportState, props: [props, elementProps], - customStyleHookMapping: scrollAreaStyleHookMapping, + stateAttributesMapping: scrollAreaStateAttributesMapping, }); const contextValue: ScrollAreaViewportContext = React.useMemo( diff --git a/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts b/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts index c12ebf43a2..0fc25bee1f 100644 --- a/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts +++ b/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts @@ -1,10 +1,10 @@ export enum ScrollAreaViewportDataAttributes { /** - * Present when the scroll area has horizontal overflow. + * Present when the scroll area content is wider than the viewport. */ hasOverflowX = 'data-has-overflow-x', /** - * Present when the scroll area has vertical overflow. + * Present when the scroll area content is taller than the viewport. */ hasOverflowY = 'data-has-overflow-y', /** From b1effebb2e3643a09f6949989f36119e7666dd70 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 17 Sep 2025 14:53:49 +1000 Subject: [PATCH 3/5] feat: thresholds, css vars --- .../reference/generated/scroll-area-root.json | 26 +++- .../generated/scroll-area-scrollbar.json | 4 +- .../generated/scroll-area-viewport.json | 4 +- .../scroll-area/content/ScrollAreaContent.tsx | 8 +- .../scroll-area/root/ScrollAreaRoot.test.tsx | 129 ++++++++++++++++-- .../src/scroll-area/root/ScrollAreaRoot.tsx | 86 ++++++++++-- .../scroll-area/root/ScrollAreaRootContext.ts | 15 +- .../scroll-area/root/ScrollAreaRootCssVars.ts | 20 +++ .../root/ScrollAreaRootDataAttributes.ts | 4 +- .../src/scroll-area/root/stateAttributes.ts | 1 + .../scrollbar/ScrollAreaScrollbar.tsx | 1 + .../ScrollAreaScrollbarDataAttributes.ts | 4 +- .../viewport/ScrollAreaViewport.tsx | 66 +++++++-- .../ScrollAreaViewportDataAttributes.ts | 4 +- 14 files changed, 316 insertions(+), 56 deletions(-) diff --git a/docs/reference/generated/scroll-area-root.json b/docs/reference/generated/scroll-area-root.json index 9c3de6f348..9c9aea31f0 100644 --- a/docs/reference/generated/scroll-area-root.json +++ b/docs/reference/generated/scroll-area-root.json @@ -2,6 +2,12 @@ "name": "ScrollAreaRoot", "description": "Groups all parts of the scroll area.\nRenders a `
` element.", "props": { + "overflowEdgeThreshold": { + "type": "number | Partial<{ xStart: number, xEnd: number, yStart: number, yEnd: number }>", + "default": "0", + "description": "The threshold in pixels that must be passed before the overflow edge attributes are applied.\nAccepts a single number for all edges or an object to configure them individually.", + "detailedType": "| number\n| {\n xStart?: number\n xEnd?: number\n yStart?: number\n yEnd?: number\n }\n| undefined" + }, "className": { "type": "string | ((state: ScrollArea.Root.State) => string)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." @@ -26,10 +32,10 @@ "description": "Present when there is overflow on the inline start side for the horizontal axis." }, "data-overflow-y-end": { - "description": "Present when there is overflow on the block end (bottom) side." + "description": "Present when there is overflow on the block end side." }, "data-overflow-y-start": { - "description": "Present when there is overflow on the block start (top) side." + "description": "Present when there is overflow on the block start side." } }, "cssVariables": { @@ -40,6 +46,22 @@ "--scroll-area-corner-width": { "description": "The scroll area's corner width.", "type": "number" + }, + "--scroll-area-overflow-x-end": { + "description": "The distance from the horizontal inline end edge in pixels.", + "type": "number" + }, + "--scroll-area-overflow-x-start": { + "description": "The distance from the horizontal inline start edge in pixels.", + "type": "number" + }, + "--scroll-area-overflow-y-end": { + "description": "The distance from the vertical block end edge in pixels.", + "type": "number" + }, + "--scroll-area-overflow-y-start": { + "description": "The distance from the vertical block start edge in pixels.", + "type": "number" } } } diff --git a/docs/reference/generated/scroll-area-scrollbar.json b/docs/reference/generated/scroll-area-scrollbar.json index 7b92e50c90..34056e813d 100644 --- a/docs/reference/generated/scroll-area-scrollbar.json +++ b/docs/reference/generated/scroll-area-scrollbar.json @@ -46,10 +46,10 @@ "description": "Present when there is overflow on the inline start side for the horizontal axis." }, "data-overflow-y-end": { - "description": "Present when there is overflow on the block end (bottom) side." + "description": "Present when there is overflow on the block end side." }, "data-overflow-y-start": { - "description": "Present when there is overflow on the block start (top) side." + "description": "Present when there is overflow on the block start side." }, "data-scrolling": { "description": "Present when the users scrolls inside the scroll area." diff --git a/docs/reference/generated/scroll-area-viewport.json b/docs/reference/generated/scroll-area-viewport.json index 5b5cc0cbca..bab8acca43 100644 --- a/docs/reference/generated/scroll-area-viewport.json +++ b/docs/reference/generated/scroll-area-viewport.json @@ -27,10 +27,10 @@ "description": "Present when there is overflow on the inline start side for the horizontal axis." }, "data-overflow-y-end": { - "description": "Present when there is overflow on the block end (bottom) side." + "description": "Present when there is overflow on the block end side." }, "data-overflow-y-start": { - "description": "Present when there is overflow on the block start (top) side." + "description": "Present when there is overflow on the block start side." } }, "cssVariables": {} diff --git a/packages/react/src/scroll-area/content/ScrollAreaContent.tsx b/packages/react/src/scroll-area/content/ScrollAreaContent.tsx index 64dee890ef..314a36afa3 100644 --- a/packages/react/src/scroll-area/content/ScrollAreaContent.tsx +++ b/packages/react/src/scroll-area/content/ScrollAreaContent.tsx @@ -4,6 +4,9 @@ import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect import type { BaseUIComponentProps } from '../../utils/types'; import { useScrollAreaViewportContext } from '../viewport/ScrollAreaViewportContext'; import { useRenderElement } from '../../utils/useRenderElement'; +import { useScrollAreaRootContext } from '../root/ScrollAreaRootContext'; +import { scrollAreaStateAttributesMapping } from '../root/stateAttributes'; +import type { ScrollAreaRoot } from '../root/ScrollAreaRoot'; /** * A container for the content of the scroll area. @@ -20,6 +23,7 @@ export const ScrollAreaContent = React.forwardRef(function ScrollAreaContent( const contentWrapperRef = React.useRef(null); const { computeThumbPosition } = useScrollAreaViewportContext(); + const { viewportState } = useScrollAreaRootContext(); useIsoLayoutEffect(() => { if (typeof ResizeObserver === 'undefined') { @@ -39,6 +43,8 @@ export const ScrollAreaContent = React.forwardRef(function ScrollAreaContent( const element = useRenderElement('div', componentProps, { ref: [forwardedRef, contentWrapperRef], + state: viewportState, + stateAttributesMapping: scrollAreaStateAttributesMapping, props: [ { role: 'presentation', @@ -54,7 +60,7 @@ export const ScrollAreaContent = React.forwardRef(function ScrollAreaContent( }); export namespace ScrollAreaContent { - export interface State {} + export interface State extends ScrollAreaRoot.State {} export interface Props extends BaseUIComponentProps<'div', State> {} } diff --git a/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx b/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx index 4c62fcd772..0fd6ecec27 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx +++ b/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { ScrollArea } from '@base-ui-components/react/scroll-area'; -import { fireEvent, flushMicrotasks, screen } from '@mui/internal-test-utils'; +import { fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils'; import { createRenderer, isJSDOM } from '#test-utils'; import { expect } from 'chai'; import { describeConformance } from '../../../test/describeConformance'; @@ -178,7 +178,9 @@ describe('', () => { await render( -
+ +
+ @@ -191,6 +193,7 @@ describe('', () => { const root = screen.getByTestId('root'); const viewport = screen.getByTestId('viewport'); + const content = screen.getByTestId('content'); const vScrollbar = screen.getByTestId('scrollbar-vertical'); const hScrollbar = screen.getByTestId('scrollbar-horizontal'); @@ -208,6 +211,12 @@ describe('', () => { expect(viewport).to.have.attribute('data-overflow-x-end'); expect(viewport).not.to.have.attribute('data-overflow-y-start'); expect(viewport).to.have.attribute('data-overflow-y-end'); + expect(content).to.have.attribute('data-has-overflow-x'); + expect(content).to.have.attribute('data-has-overflow-y'); + expect(content).not.to.have.attribute('data-overflow-x-start'); + expect(content).to.have.attribute('data-overflow-x-end'); + expect(content).not.to.have.attribute('data-overflow-y-start'); + expect(content).to.have.attribute('data-overflow-y-end'); expect(vScrollbar).to.have.attribute('data-has-overflow-y'); expect(vScrollbar).not.to.have.attribute('data-overflow-y-start'); @@ -233,13 +242,17 @@ describe('', () => { expect(viewport).to.have.attribute('data-overflow-y-end'); expect(viewport).to.have.attribute('data-overflow-x-start'); expect(viewport).to.have.attribute('data-overflow-x-end'); + expect(content).to.have.attribute('data-overflow-y-start'); + expect(content).to.have.attribute('data-overflow-y-end'); + expect(content).to.have.attribute('data-overflow-x-start'); + expect(content).to.have.attribute('data-overflow-x-end'); expect(vScrollbar).to.have.attribute('data-overflow-y-start'); expect(vScrollbar).to.have.attribute('data-overflow-y-end'); expect(hScrollbar).to.have.attribute('data-overflow-x-start'); expect(hScrollbar).to.have.attribute('data-overflow-x-end'); - // Scroll to end (bottom-right) + // Scroll to end -right) fireEvent.scroll(viewport, { target: { scrollTop: viewport.scrollHeight - viewport.clientHeight, @@ -257,6 +270,10 @@ describe('', () => { expect(viewport).not.to.have.attribute('data-overflow-y-end'); expect(viewport).to.have.attribute('data-overflow-x-start'); expect(viewport).not.to.have.attribute('data-overflow-x-end'); + expect(content).to.have.attribute('data-overflow-y-start'); + expect(content).not.to.have.attribute('data-overflow-y-end'); + expect(content).to.have.attribute('data-overflow-x-start'); + expect(content).not.to.have.attribute('data-overflow-x-end'); expect(vScrollbar).to.have.attribute('data-overflow-y-start'); expect(vScrollbar).not.to.have.attribute('data-overflow-y-end'); @@ -264,7 +281,102 @@ describe('', () => { expect(hScrollbar).not.to.have.attribute('data-overflow-x-end'); }); - it('rtl', async () => { + it('respects overflowEdgeThreshold and exposes scroll metrics', async () => { + await render( + + + +
+ + + + + + + + + , + ); + + const root = screen.getByTestId('root'); + const viewport = screen.getByTestId('viewport'); + + fireEvent.scroll(viewport, { + target: { scrollLeft: 15, scrollTop: 7 }, + }); + + await waitFor(() => expect(viewport).not.to.have.attribute('data-overflow-x-start')); + expect(viewport).to.have.attribute('data-overflow-y-start'); + + fireEvent.scroll(viewport, { + target: { scrollLeft: 35, scrollTop: 7 }, + }); + + await waitFor(() => expect(viewport).to.have.attribute('data-overflow-x-start')); + + const rootStyle = root.style; + const startPx = rootStyle.getPropertyValue('--scroll-area-overflow-x-start'); + expect(startPx).to.equal('35px'); + + const horizontalEndPx = rootStyle.getPropertyValue('--scroll-area-overflow-x-end'); + expect(horizontalEndPx).to.not.equal(''); + expect(horizontalEndPx).to.not.equal('0px'); + }); + + it('does not add state attributes when content does not overflow', async () => { + await render( + + + +
+ + + + + + + + + , + ); + + const root = screen.getByTestId('root'); + const viewport = screen.getByTestId('viewport'); + const content = screen.getByTestId('content'); + const vScrollbar = screen.getByTestId('scrollbar-vertical'); + const hScrollbar = screen.getByTestId('scrollbar-horizontal'); + + expect(root).not.to.have.attribute('data-has-overflow-x'); + expect(root).not.to.have.attribute('data-has-overflow-y'); + expect(root).not.to.have.attribute('data-overflow-x-start'); + expect(root).not.to.have.attribute('data-overflow-x-end'); + expect(root).not.to.have.attribute('data-overflow-y-start'); + expect(root).not.to.have.attribute('data-overflow-y-end'); + + expect(viewport).not.to.have.attribute('data-overflow-x-start'); + expect(viewport).not.to.have.attribute('data-overflow-x-end'); + expect(viewport).not.to.have.attribute('data-overflow-y-start'); + expect(viewport).not.to.have.attribute('data-overflow-y-end'); + expect(content).not.to.have.attribute('data-overflow-x-start'); + expect(content).not.to.have.attribute('data-overflow-x-end'); + expect(content).not.to.have.attribute('data-overflow-y-start'); + expect(content).not.to.have.attribute('data-overflow-y-end'); + + expect(vScrollbar).not.to.have.attribute('data-overflow-y-start'); + expect(vScrollbar).not.to.have.attribute('data-overflow-y-end'); + expect(hScrollbar).not.to.have.attribute('data-overflow-x-start'); + expect(hScrollbar).not.to.have.attribute('data-overflow-x-end'); + }); + + it('correctly handles RTL', async () => { await render( ', () => { scrollLeft: 0, }, }); - await flushMicrotasks(); - expect(root).to.have.attribute('data-has-overflow-x'); + await waitFor(() => expect(root).to.have.attribute('data-has-overflow-x')); expect(root).not.to.have.attribute('data-overflow-x-start'); expect(root).to.have.attribute('data-overflow-x-end'); @@ -301,9 +412,8 @@ describe('', () => { scrollLeft: -maxScrollLeft / 2, }, }); - await flushMicrotasks(); - expect(root).to.have.attribute('data-overflow-x-start'); + await waitFor(() => expect(root).to.have.attribute('data-overflow-x-start')); expect(root).to.have.attribute('data-overflow-x-end'); fireEvent.scroll(viewport, { @@ -311,9 +421,8 @@ describe('', () => { scrollLeft: -maxScrollLeft, }, }); - await flushMicrotasks(); - expect(root).to.have.attribute('data-overflow-x-start'); + await waitFor(() => expect(root).to.have.attribute('data-overflow-x-start')); expect(root).not.to.have.attribute('data-overflow-x-end'); }); }); diff --git a/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx b/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx index 0136105790..95e0e9fbac 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx +++ b/packages/react/src/scroll-area/root/ScrollAreaRoot.tsx @@ -19,6 +19,17 @@ interface Size { height: number; } +const DEFAULT_SIZE = { + width: 0, + height: 0, +}; +const DEFAULT_OVERFLOW_EDGES = { + xStart: false, + xEnd: false, + yStart: false, + yEnd: false, +}; + /** * Groups all parts of the scroll area. * Renders a `
` element. @@ -29,20 +40,20 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( componentProps: ScrollAreaRoot.Props, forwardedRef: React.ForwardedRef, ) { - const { render, className, ...elementProps } = componentProps; + const { + render, + className, + overflowEdgeThreshold: overflowEdgeThresholdProp, + ...elementProps + } = componentProps; const [hovering, setHovering] = React.useState(false); const [scrollingX, setScrollingX] = React.useState(false); const [scrollingY, setScrollingY] = React.useState(false); - const [cornerSize, setCornerSize] = React.useState({ width: 0, height: 0 }); - const [thumbSize, setThumbSize] = React.useState({ width: 0, height: 0 }); + const [cornerSize, setCornerSize] = React.useState(DEFAULT_SIZE); + const [thumbSize, setThumbSize] = React.useState(DEFAULT_SIZE); const [touchModality, setTouchModality] = React.useState(false); - const [overflowEdges, setOverflowEdges] = React.useState({ - xStart: false, - xEnd: false, - yStart: false, - yEnd: false, - }); + const [overflowEdges, setOverflowEdges] = React.useState(DEFAULT_OVERFLOW_EDGES); const rootId = useBaseUiId(); @@ -70,6 +81,8 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( cornerHidden: false, }); + const overflowEdgeThreshold = normalizeOverflowEdgeThreshold(overflowEdgeThresholdProp); + const handleScroll = useEventCallback((scrollPosition: { x: number; y: number }) => { const offsetX = scrollPosition.x - scrollPositionRef.current.x; const offsetY = scrollPosition.y - scrollPositionRef.current.y; @@ -206,8 +219,14 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( overflowXEnd: overflowEdges.xEnd, overflowYStart: overflowEdges.yStart, overflowYEnd: overflowEdges.yEnd, + cornerHidden: hiddenState.cornerHidden, }), - [hiddenState.scrollbarXHidden, hiddenState.scrollbarYHidden, overflowEdges], + [ + hiddenState.scrollbarXHidden, + hiddenState.scrollbarYHidden, + hiddenState.cornerHidden, + overflowEdges, + ], ); const props: HTMLProps = { @@ -253,6 +272,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( hovering, setHovering, viewportRef, + rootRef, scrollbarYRef, scrollbarXRef, thumbYRef, @@ -263,6 +283,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( overflowEdges, setOverflowEdges, viewportState: state, + overflowEdgeThreshold, }), [ handlePointerDown, @@ -280,6 +301,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( hovering, setHovering, viewportRef, + rootRef, scrollbarYRef, scrollbarXRef, thumbYRef, @@ -288,6 +310,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( hiddenState, overflowEdges, state, + overflowEdgeThreshold, ], ); @@ -309,11 +332,48 @@ export namespace ScrollAreaRoot { overflowXStart: boolean; /** Whether there is overflow on the inline end side for the horizontal axis. */ overflowXEnd: boolean; - /** Whether there is overflow on the block start (top) side. */ + /** Whether there is overflow on the block start side. */ overflowYStart: boolean; - /** Whether there is overflow on the block end (bottom) side. */ + /** Whether there is overflow on the block end side. */ overflowYEnd: boolean; + /** Whether the scrollbar corner is hidden. */ + cornerHidden: boolean; } - export interface Props extends BaseUIComponentProps<'div', State> {} + export interface Props extends BaseUIComponentProps<'div', State> { + /** + * The threshold in pixels that must be passed before the overflow edge attributes are applied. + * Accepts a single number for all edges or an object to configure them individually. + * @default 0 + */ + overflowEdgeThreshold?: + | number + | Partial<{ + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; + }>; + } +} + +function normalizeOverflowEdgeThreshold( + threshold: ScrollAreaRoot.Props['overflowEdgeThreshold'] | undefined, +) { + if (typeof threshold === 'number') { + const value = Math.max(0, threshold); + return { + xStart: value, + xEnd: value, + yStart: value, + yEnd: value, + }; + } + + return { + xStart: Math.max(0, threshold?.xStart || 0), + xEnd: Math.max(0, threshold?.xEnd || 0), + yStart: Math.max(0, threshold?.yStart || 0), + yEnd: Math.max(0, threshold?.yEnd || 0), + }; } diff --git a/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts b/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts index aaa44f516d..27943b0013 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts +++ b/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { ScrollAreaRoot } from './ScrollAreaRoot'; export interface ScrollAreaRootContext { cornerSize: { width: number; height: number }; @@ -13,6 +14,7 @@ export interface ScrollAreaRootContext { scrollingY: boolean; setScrollingY: React.Dispatch>; viewportRef: React.RefObject; + rootRef: React.RefObject; scrollbarYRef: React.RefObject; thumbYRef: React.RefObject; scrollbarXRef: React.RefObject; @@ -49,13 +51,12 @@ export interface ScrollAreaRootContext { yEnd: boolean; }> >; - viewportState: { - hasOverflowX: boolean; - hasOverflowY: boolean; - overflowXStart: boolean; - overflowXEnd: boolean; - overflowYStart: boolean; - overflowYEnd: boolean; + viewportState: ScrollAreaRoot.State; + overflowEdgeThreshold: { + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; }; } diff --git a/packages/react/src/scroll-area/root/ScrollAreaRootCssVars.ts b/packages/react/src/scroll-area/root/ScrollAreaRootCssVars.ts index fb37d7ed97..5224bc1936 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRootCssVars.ts +++ b/packages/react/src/scroll-area/root/ScrollAreaRootCssVars.ts @@ -9,4 +9,24 @@ export enum ScrollAreaRootCssVars { * @type {number} */ scrollAreaCornerWidth = '--scroll-area-corner-width', + /** + * The distance from the horizontal inline start edge in pixels. + * @type {number} + */ + scrollAreaOverflowXStart = '--scroll-area-overflow-x-start', + /** + * The distance from the horizontal inline end edge in pixels. + * @type {number} + */ + scrollAreaOverflowXEnd = '--scroll-area-overflow-x-end', + /** + * The distance from the vertical block start edge in pixels. + * @type {number} + */ + scrollAreaOverflowYStart = '--scroll-area-overflow-y-start', + /** + * The distance from the vertical block end edge in pixels. + * @type {number} + */ + scrollAreaOverflowYEnd = '--scroll-area-overflow-y-end', } diff --git a/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts b/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts index 4ed1c33bda..1747320a4d 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts +++ b/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts @@ -16,11 +16,11 @@ export enum ScrollAreaRootDataAttributes { */ overflowXEnd = 'data-overflow-x-end', /** - * Present when there is overflow on the block start (top) side. + * Present when there is overflow on the block start side. */ overflowYStart = 'data-overflow-y-start', /** - * Present when there is overflow on the block end (bottom) side. + * Present when there is overflow on the block end side. */ overflowYEnd = 'data-overflow-y-end', } diff --git a/packages/react/src/scroll-area/root/stateAttributes.ts b/packages/react/src/scroll-area/root/stateAttributes.ts index c172e30e09..2522bbcd95 100644 --- a/packages/react/src/scroll-area/root/stateAttributes.ts +++ b/packages/react/src/scroll-area/root/stateAttributes.ts @@ -9,4 +9,5 @@ export const scrollAreaStateAttributesMapping: StateAttributesMapping (value ? { [ScrollAreaRootDataAttributes.overflowXEnd]: '' } : null), overflowYStart: (value) => (value ? { [ScrollAreaRootDataAttributes.overflowYStart]: '' } : null), overflowYEnd: (value) => (value ? { [ScrollAreaRootDataAttributes.overflowYEnd]: '' } : null), + cornerHidden: () => null, }; diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx index 41229fc783..8d4a415ff6 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx @@ -60,6 +60,7 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar overflowXEnd: overflowEdges.xEnd, overflowYStart: overflowEdges.yStart, overflowYEnd: overflowEdges.yEnd, + cornerHidden: hiddenState.cornerHidden, }), [hovering, scrollingX, scrollingY, orientation, hiddenState, overflowEdges], ); diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts index c0549dde31..465ad773b0 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts @@ -29,11 +29,11 @@ export enum ScrollAreaScrollbarDataAttributes { */ overflowXEnd = 'data-overflow-x-end', /** - * Present when there is overflow on the block start (top) side. + * Present when there is overflow on the block start side. */ overflowYStart = 'data-overflow-y-start', /** - * Present when there is overflow on the block end (bottom) side. + * Present when there is overflow on the block end side. */ overflowYEnd = 'data-overflow-y-end', } diff --git a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx index 64642efe66..832a9eba25 100644 --- a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx +++ b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx @@ -14,6 +14,7 @@ import { clamp } from '../../utils/clamp'; import { styleDisableScrollbar } from '../../utils/styles'; import { onVisible } from '../utils/onVisible'; import { scrollAreaStateAttributesMapping } from '../root/stateAttributes'; +import { ScrollAreaRootCssVars } from '../root/ScrollAreaRootCssVars'; import type { ScrollAreaRoot } from '../root/ScrollAreaRoot'; /** @@ -30,6 +31,7 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( const { viewportRef, + rootRef, scrollbarYRef, scrollbarXRef, thumbYRef, @@ -44,6 +46,7 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( setHovering, setOverflowEdges, overflowEdges, + overflowEdgeThreshold, } = useScrollAreaRootContext(); const direction = useDirection(); @@ -78,6 +81,22 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( const scrollbarXHidden = viewportWidth >= scrollableContentWidth; const ratioX = viewportWidth / scrollableContentWidth; const ratioY = viewportHeight / scrollableContentHeight; + const maxScrollLeft = Math.max(0, scrollableContentWidth - viewportWidth); + const maxScrollTop = Math.max(0, scrollableContentHeight - viewportHeight); + + let scrollLeftFromStart = 0; + let scrollLeftFromEnd = 0; + if (!scrollbarXHidden) { + if (direction === 'rtl') { + scrollLeftFromStart = clamp(-scrollLeft, 0, maxScrollLeft); + } else { + scrollLeftFromStart = clamp(scrollLeft, 0, maxScrollLeft); + } + scrollLeftFromEnd = maxScrollLeft - scrollLeftFromStart; + } + + const scrollTopFromStart = !scrollbarYHidden ? clamp(scrollTop, 0, maxScrollTop) : 0; + const scrollTopFromEnd = !scrollbarYHidden ? maxScrollTop - scrollTopFromStart : 0; const nextWidth = scrollbarXHidden ? 0 : viewportWidth; const nextHeight = scrollbarYHidden ? 0 : viewportHeight; @@ -114,7 +133,8 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( if (scrollbarYEl && thumbYEl) { const maxThumbOffsetY = scrollbarYEl.offsetHeight - clampedNextHeight - scrollbarYOffset - thumbYOffset; - const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight); + const scrollRangeY = scrollableContentHeight - viewportHeight; + const scrollRatioY = scrollRangeY === 0 ? 0 : scrollTop / scrollRangeY; // In Safari, don't allow it to go negative or too far as `scrollTop` considers the rubber // band effect. @@ -127,7 +147,8 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( if (scrollbarXEl && thumbXEl) { const maxThumbOffsetX = scrollbarXEl.offsetWidth - clampedNextWidth - scrollbarXOffset - thumbXOffset; - const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth); + const scrollRangeX = scrollableContentWidth - viewportWidth; + const scrollRatioX = scrollRangeX === 0 ? 0 : scrollLeft / scrollRangeX; // In Safari, don't allow it to go negative or too far as `scrollLeft` considers the rubber // band effect. @@ -139,6 +160,25 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; } + const clampedScrollLeftStart = clamp(scrollLeftFromStart, 0, maxScrollLeft); + const clampedScrollLeftEnd = clamp(scrollLeftFromEnd, 0, maxScrollLeft); + const clampedScrollTopStart = clamp(scrollTopFromStart, 0, maxScrollTop); + const clampedScrollTopEnd = clamp(scrollTopFromEnd, 0, maxScrollTop); + + const overflowMetricsPx: Array<[ScrollAreaRootCssVars, number]> = [ + [ScrollAreaRootCssVars.scrollAreaOverflowXStart, clampedScrollLeftStart], + [ScrollAreaRootCssVars.scrollAreaOverflowXEnd, clampedScrollLeftEnd], + [ScrollAreaRootCssVars.scrollAreaOverflowYStart, clampedScrollTopStart], + [ScrollAreaRootCssVars.scrollAreaOverflowYEnd, clampedScrollTopEnd], + ]; + + const rootEl = rootRef.current; + if (rootEl) { + for (const [cssVar, value] of overflowMetricsPx) { + rootEl.style.setProperty(cssVar, `${value}px`); + } + } + if (cornerEl) { if (scrollbarXHidden || scrollbarYHidden) { setCornerSize({ width: 0, height: 0 }); @@ -168,16 +208,10 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( }); const nextOverflowEdges = { - xStart: - direction === 'rtl' - ? !scrollbarXHidden && scrollLeft < 0 - : !scrollbarXHidden && scrollLeft > 0, - xEnd: - direction === 'rtl' - ? !scrollbarXHidden && scrollLeft > -(scrollableContentWidth - viewportWidth) - : !scrollbarXHidden && scrollLeft < scrollableContentWidth - viewportWidth, - yStart: !scrollbarYHidden && scrollTop > 0, - yEnd: !scrollbarYHidden && scrollTop < scrollableContentHeight - viewportHeight, + xStart: !scrollbarXHidden && clampedScrollLeftStart > overflowEdgeThreshold.xStart, + xEnd: !scrollbarXHidden && clampedScrollLeftEnd > overflowEdgeThreshold.xEnd, + yStart: !scrollbarYHidden && clampedScrollTopStart > overflowEdgeThreshold.yStart, + yEnd: !scrollbarYHidden && clampedScrollTopEnd > overflowEdgeThreshold.yEnd, }; setOverflowEdges((prev) => { @@ -283,8 +317,14 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( overflowXEnd: overflowEdges.xEnd, overflowYStart: overflowEdges.yStart, overflowYEnd: overflowEdges.yEnd, + cornerHidden: hiddenState.cornerHidden, }), - [hiddenState.scrollbarXHidden, hiddenState.scrollbarYHidden, overflowEdges], + [ + hiddenState.scrollbarXHidden, + hiddenState.scrollbarYHidden, + hiddenState.cornerHidden, + overflowEdges, + ], ); const element = useRenderElement('div', componentProps, { diff --git a/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts b/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts index 0fc25bee1f..28f79ecfbc 100644 --- a/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts +++ b/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts @@ -16,11 +16,11 @@ export enum ScrollAreaViewportDataAttributes { */ overflowXEnd = 'data-overflow-x-end', /** - * Present when there is overflow on the block start (top) side. + * Present when there is overflow on the block start side. */ overflowYStart = 'data-overflow-y-start', /** - * Present when there is overflow on the block end (bottom) side. + * Present when there is overflow on the block end side. */ overflowYEnd = 'data-overflow-y-end', } From 6aeb30246e0d3b056ba89114ebf3d3606c28163e Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 17 Sep 2025 15:26:45 +1000 Subject: [PATCH 4/5] docs: fix anatomy --- .../(public)/(content)/react/components/scroll-area/page.mdx | 4 +++- packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/src/app/(public)/(content)/react/components/scroll-area/page.mdx b/docs/src/app/(public)/(content)/react/components/scroll-area/page.mdx index 6fc06a3d66..2f68ac53c9 100644 --- a/docs/src/app/(public)/(content)/react/components/scroll-area/page.mdx +++ b/docs/src/app/(public)/(content)/react/components/scroll-area/page.mdx @@ -17,7 +17,9 @@ Import the component and assemble its parts: import { ScrollArea } from '@base-ui-components/react/scroll-area'; - + + + diff --git a/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx b/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx index 0fd6ecec27..1db2683231 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx +++ b/packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx @@ -252,7 +252,7 @@ describe('', () => { expect(hScrollbar).to.have.attribute('data-overflow-x-start'); expect(hScrollbar).to.have.attribute('data-overflow-x-end'); - // Scroll to end -right) + // Scroll to end fireEvent.scroll(viewport, { target: { scrollTop: viewport.scrollHeight - viewport.clientHeight, From 35109fab0d5623d31cdc539aa07b92926b55c68b Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 18 Sep 2025 18:08:50 +1000 Subject: [PATCH 5/5] lint --- docs/reference/generated/scroll-area-root.json | 16 ++++++++-------- .../generated/scroll-area-scrollbar.json | 8 ++++---- .../generated/scroll-area-viewport.json | 8 ++++---- .../scroll-area/root/ScrollAreaRootCssVars.ts | 8 ++++---- .../root/ScrollAreaRootDataAttributes.ts | 8 ++++---- .../ScrollAreaScrollbarDataAttributes.ts | 8 ++++---- .../viewport/ScrollAreaViewportDataAttributes.ts | 8 ++++---- 7 files changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/reference/generated/scroll-area-root.json b/docs/reference/generated/scroll-area-root.json index 9c9aea31f0..3880f335c3 100644 --- a/docs/reference/generated/scroll-area-root.json +++ b/docs/reference/generated/scroll-area-root.json @@ -26,16 +26,16 @@ "description": "Present when the scroll area content is taller than the viewport." }, "data-overflow-x-end": { - "description": "Present when there is overflow on the inline end side for the horizontal axis." + "description": "Present when there is overflow on the horizontal end side." }, "data-overflow-x-start": { - "description": "Present when there is overflow on the inline start side for the horizontal axis." + "description": "Present when there is overflow on the horizontal start side." }, "data-overflow-y-end": { - "description": "Present when there is overflow on the block end side." + "description": "Present when there is overflow on the vertical end side." }, "data-overflow-y-start": { - "description": "Present when there is overflow on the block start side." + "description": "Present when there is overflow on the vertical start side." } }, "cssVariables": { @@ -48,19 +48,19 @@ "type": "number" }, "--scroll-area-overflow-x-end": { - "description": "The distance from the horizontal inline end edge in pixels.", + "description": "The distance from the horizontal end edge in pixels.", "type": "number" }, "--scroll-area-overflow-x-start": { - "description": "The distance from the horizontal inline start edge in pixels.", + "description": "The distance from the horizontal start edge in pixels.", "type": "number" }, "--scroll-area-overflow-y-end": { - "description": "The distance from the vertical block end edge in pixels.", + "description": "The distance from the vertical end edge in pixels.", "type": "number" }, "--scroll-area-overflow-y-start": { - "description": "The distance from the vertical block start edge in pixels.", + "description": "The distance from the vertical start edge in pixels.", "type": "number" } } diff --git a/docs/reference/generated/scroll-area-scrollbar.json b/docs/reference/generated/scroll-area-scrollbar.json index 34056e813d..67f99a3e2d 100644 --- a/docs/reference/generated/scroll-area-scrollbar.json +++ b/docs/reference/generated/scroll-area-scrollbar.json @@ -40,16 +40,16 @@ "description": "Present when the pointer is over the scroll area." }, "data-overflow-x-end": { - "description": "Present when there is overflow on the inline end side for the horizontal axis." + "description": "Present when there is overflow on the horizontal end side." }, "data-overflow-x-start": { - "description": "Present when there is overflow on the inline start side for the horizontal axis." + "description": "Present when there is overflow on the horizontal start side." }, "data-overflow-y-end": { - "description": "Present when there is overflow on the block end side." + "description": "Present when there is overflow on the vertical end side." }, "data-overflow-y-start": { - "description": "Present when there is overflow on the block start side." + "description": "Present when there is overflow on the vertical start side." }, "data-scrolling": { "description": "Present when the users scrolls inside the scroll area." diff --git a/docs/reference/generated/scroll-area-viewport.json b/docs/reference/generated/scroll-area-viewport.json index bab8acca43..62d6e0b2b7 100644 --- a/docs/reference/generated/scroll-area-viewport.json +++ b/docs/reference/generated/scroll-area-viewport.json @@ -21,16 +21,16 @@ "description": "Present when the scroll area content is taller than the viewport." }, "data-overflow-x-end": { - "description": "Present when there is overflow on the inline end side for the horizontal axis." + "description": "Present when there is overflow on the horizontal end side." }, "data-overflow-x-start": { - "description": "Present when there is overflow on the inline start side for the horizontal axis." + "description": "Present when there is overflow on the horizontal start side." }, "data-overflow-y-end": { - "description": "Present when there is overflow on the block end side." + "description": "Present when there is overflow on the vertical end side." }, "data-overflow-y-start": { - "description": "Present when there is overflow on the block start side." + "description": "Present when there is overflow on the vertical start side." } }, "cssVariables": {} diff --git a/packages/react/src/scroll-area/root/ScrollAreaRootCssVars.ts b/packages/react/src/scroll-area/root/ScrollAreaRootCssVars.ts index 5224bc1936..99d7671182 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRootCssVars.ts +++ b/packages/react/src/scroll-area/root/ScrollAreaRootCssVars.ts @@ -10,22 +10,22 @@ export enum ScrollAreaRootCssVars { */ scrollAreaCornerWidth = '--scroll-area-corner-width', /** - * The distance from the horizontal inline start edge in pixels. + * The distance from the horizontal start edge in pixels. * @type {number} */ scrollAreaOverflowXStart = '--scroll-area-overflow-x-start', /** - * The distance from the horizontal inline end edge in pixels. + * The distance from the horizontal end edge in pixels. * @type {number} */ scrollAreaOverflowXEnd = '--scroll-area-overflow-x-end', /** - * The distance from the vertical block start edge in pixels. + * The distance from the vertical start edge in pixels. * @type {number} */ scrollAreaOverflowYStart = '--scroll-area-overflow-y-start', /** - * The distance from the vertical block end edge in pixels. + * The distance from the vertical end edge in pixels. * @type {number} */ scrollAreaOverflowYEnd = '--scroll-area-overflow-y-end', diff --git a/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts b/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts index 1747320a4d..faca1c3e45 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts +++ b/packages/react/src/scroll-area/root/ScrollAreaRootDataAttributes.ts @@ -8,19 +8,19 @@ export enum ScrollAreaRootDataAttributes { */ hasOverflowY = 'data-has-overflow-y', /** - * Present when there is overflow on the inline start side for the horizontal axis. + * Present when there is overflow on the horizontal start side. */ overflowXStart = 'data-overflow-x-start', /** - * Present when there is overflow on the inline end side for the horizontal axis. + * Present when there is overflow on the horizontal end side. */ overflowXEnd = 'data-overflow-x-end', /** - * Present when there is overflow on the block start side. + * Present when there is overflow on the vertical start side. */ overflowYStart = 'data-overflow-y-start', /** - * Present when there is overflow on the block end side. + * Present when there is overflow on the vertical end side. */ overflowYEnd = 'data-overflow-y-end', } diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts index 465ad773b0..77f9fb5135 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts @@ -21,19 +21,19 @@ export enum ScrollAreaScrollbarDataAttributes { */ hasOverflowY = 'data-has-overflow-y', /** - * Present when there is overflow on the inline start side for the horizontal axis. + * Present when there is overflow on the horizontal start side. */ overflowXStart = 'data-overflow-x-start', /** - * Present when there is overflow on the inline end side for the horizontal axis. + * Present when there is overflow on the horizontal end side. */ overflowXEnd = 'data-overflow-x-end', /** - * Present when there is overflow on the block start side. + * Present when there is overflow on the vertical start side. */ overflowYStart = 'data-overflow-y-start', /** - * Present when there is overflow on the block end side. + * Present when there is overflow on the vertical end side. */ overflowYEnd = 'data-overflow-y-end', } diff --git a/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts b/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts index 28f79ecfbc..73de4115c2 100644 --- a/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts +++ b/packages/react/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts @@ -8,19 +8,19 @@ export enum ScrollAreaViewportDataAttributes { */ hasOverflowY = 'data-has-overflow-y', /** - * Present when there is overflow on the inline start side for the horizontal axis. + * Present when there is overflow on the horizontal start side. */ overflowXStart = 'data-overflow-x-start', /** - * Present when there is overflow on the inline end side for the horizontal axis. + * Present when there is overflow on the horizontal end side. */ overflowXEnd = 'data-overflow-x-end', /** - * Present when there is overflow on the block start side. + * Present when there is overflow on the vertical start side. */ overflowYStart = 'data-overflow-y-start', /** - * Present when there is overflow on the block end side. + * Present when there is overflow on the vertical end side. */ overflowYEnd = 'data-overflow-y-end', }