diff --git a/.changeset/scroll-area-content-props.md b/.changeset/scroll-area-content-props.md new file mode 100644 index 000000000..42fc48bbb --- /dev/null +++ b/.changeset/scroll-area-content-props.md @@ -0,0 +1,5 @@ +--- +'@radix-ui/react-scroll-area': minor +--- + +Added `contentProps` to `ScrollArea.Viewport` for forwarding props to the internal content wrapper div, enabling layout customization (e.g. overriding the default `display: table`) without `!important`. diff --git a/apps/storybook/stories/scroll-area.stories.tsx b/apps/storybook/stories/scroll-area.stories.tsx index d7a9aadf5..1d4a97a50 100644 --- a/apps/storybook/stories/scroll-area.stories.tsx +++ b/apps/storybook/stories/scroll-area.stories.tsx @@ -105,6 +105,51 @@ export const Animated = () => { ); }; +export const ContentProps = () => { + return ( +
+

Default (display: table)

+ + {Array.from({ length: 20 }).map((_, index) => ( +
+ Item {index + 1} - This is a long text that should be constrained by the container width +
+ ))} +
+ +

With contentProps (display: flex)

+ + + {Array.from({ length: 20 }).map((_, index) => ( +
+ Item {index + 1} - This is a long text that should be constrained by the container + width +
+ ))} +
+ + + +
+
+ ); +}; + export const Chromatic = () => (

Vertical

diff --git a/packages/react/scroll-area/src/scroll-area.test.tsx b/packages/react/scroll-area/src/scroll-area.test.tsx new file mode 100644 index 000000000..34fdb7df1 --- /dev/null +++ b/packages/react/scroll-area/src/scroll-area.test.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, it, expect } from 'vitest'; +import * as ScrollArea from './scroll-area'; + +const VIEWPORT_TEST_ID = 'scroll-area-viewport'; +const CONTENT_TEXT = 'Scroll content'; + +const ScrollAreaTest = (props: { + contentProps?: React.HTMLAttributes; +}) => ( + + +
{CONTENT_TEXT}
+
+ + + +
+); + +describe('ScrollArea', () => { + afterEach(cleanup); + + describe('given a default ScrollArea', () => { + it('should render content inside the viewport', () => { + const rendered = render(); + expect(rendered.getByText(CONTENT_TEXT)).toBeInTheDocument(); + }); + + it('should render the internal content wrapper with default display: table', () => { + const rendered = render(); + const viewport = rendered.getByTestId(VIEWPORT_TEST_ID); + const contentWrapper = viewport.firstElementChild as HTMLElement; + expect(contentWrapper).toBeTruthy(); + expect(contentWrapper.style.display).toBe('table'); + expect(contentWrapper.style.minWidth).toBe('100%'); + }); + }); + + describe('given contentProps', () => { + it('should forward style overrides to the content wrapper', () => { + const rendered = render( + + ); + const viewport = rendered.getByTestId(VIEWPORT_TEST_ID); + const contentWrapper = viewport.firstElementChild as HTMLElement; + expect(contentWrapper.style.display).toBe('flex'); + expect(contentWrapper.style.flexDirection).toBe('column'); + // minWidth should still be present as a base style + expect(contentWrapper.style.minWidth).toBe('100%'); + }); + + it('should forward className to the content wrapper', () => { + const rendered = render( + + ); + const viewport = rendered.getByTestId(VIEWPORT_TEST_ID); + const contentWrapper = viewport.firstElementChild as HTMLElement; + expect(contentWrapper.classList.contains('custom-content')).toBe(true); + }); + + it('should forward data attributes to the content wrapper', () => { + const rendered = render( + + ); + const viewport = rendered.getByTestId(VIEWPORT_TEST_ID); + const contentWrapper = viewport.firstElementChild as HTMLElement; + expect(contentWrapper.getAttribute('data-slot')).toBe('content'); + }); + + it('should allow overriding only display while preserving minWidth', () => { + const rendered = render( + + ); + const viewport = rendered.getByTestId(VIEWPORT_TEST_ID); + const contentWrapper = viewport.firstElementChild as HTMLElement; + expect(contentWrapper.style.display).toBe('block'); + expect(contentWrapper.style.minWidth).toBe('100%'); + }); + + it('should allow overriding minWidth', () => { + const rendered = render( + + ); + const viewport = rendered.getByTestId(VIEWPORT_TEST_ID); + const contentWrapper = viewport.firstElementChild as HTMLElement; + expect(contentWrapper.style.minWidth).toBe('0'); + expect(contentWrapper.style.display).toBe('table'); + }); + + it('should still render children correctly when contentProps is provided', () => { + const rendered = render( + + ); + expect(rendered.getByText(CONTENT_TEXT)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/react/scroll-area/src/scroll-area.tsx b/packages/react/scroll-area/src/scroll-area.tsx index 2db5b971d..50faa49e9 100644 --- a/packages/react/scroll-area/src/scroll-area.tsx +++ b/packages/react/scroll-area/src/scroll-area.tsx @@ -135,11 +135,21 @@ const VIEWPORT_NAME = 'ScrollAreaViewport'; type ScrollAreaViewportElement = React.ComponentRef; interface ScrollAreaViewportProps extends PrimitiveDivProps { nonce?: string; + /** + * Optional props to pass to the inner content wrapper `div`. + * Useful for overriding the default `display: table` layout when it conflicts + * with your content's sizing needs (e.g. flex or grid layouts). + * + * Note: The content wrapper uses `display: table` by default to ensure accurate + * scroll width/height measurement for thumb sizing. Overriding `display` may + * affect thumb size accuracy for horizontally-scrolling content. + */ + contentProps?: React.HTMLAttributes; } const ScrollAreaViewport = React.forwardRef( (props: ScopedProps, forwardedRef) => { - const { __scopeScrollArea, children, nonce, ...viewportProps } = props; + const { __scopeScrollArea, children, nonce, contentProps, ...viewportProps } = props; const context = useScrollAreaContext(VIEWPORT_NAME, __scopeScrollArea); const ref = React.useRef(null); const composedRefs = useComposedRefs(forwardedRef, ref, context.onViewportChange); @@ -180,7 +190,11 @@ const ScrollAreaViewport = React.forwardRef +
{children}