diff --git a/docs/data/api/scroll-area-corner.json b/docs/data/api/scroll-area-corner.json new file mode 100644 index 0000000000..a3c7a849e3 --- /dev/null +++ b/docs/data/api/scroll-area-corner.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaCorner", + "imports": [ + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaCorner = ScrollArea.Corner;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "ScrollAreaCorner", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/scroll-area-root.json b/docs/data/api/scroll-area-root.json new file mode 100644 index 0000000000..5882aab749 --- /dev/null +++ b/docs/data/api/scroll-area-root.json @@ -0,0 +1,23 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "gutter": { + "type": { "name": "union", "description": "number
| string" }, + "default": "0" + }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaRoot", + "imports": [ + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaRoot = ScrollArea.Root;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "ScrollAreaRoot", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/scroll-area-scrollbar.json b/docs/data/api/scroll-area-scrollbar.json new file mode 100644 index 0000000000..9664aee8b6 --- /dev/null +++ b/docs/data/api/scroll-area-scrollbar.json @@ -0,0 +1,24 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "keepMounted": { "type": { "name": "bool" }, "default": "false" }, + "orientation": { + "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" }, + "default": "'vertical'" + }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaScrollbar", + "imports": [ + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaScrollbar = ScrollArea.Scrollbar;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "ScrollAreaScrollbar", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/scroll-area-thumb.json b/docs/data/api/scroll-area-thumb.json new file mode 100644 index 0000000000..0e401ae75c --- /dev/null +++ b/docs/data/api/scroll-area-thumb.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaThumb", + "imports": [ + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaThumb = ScrollArea.Thumb;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "ScrollAreaThumb", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/scroll-area-viewport.json b/docs/data/api/scroll-area-viewport.json new file mode 100644 index 0000000000..614419f1a8 --- /dev/null +++ b/docs/data/api/scroll-area-viewport.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaViewport", + "imports": [ + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaViewport = ScrollArea.Viewport;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "ScrollAreaViewport", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/components/scroll-area/ScrollAreaInset.js b/docs/data/components/scroll-area/ScrollAreaInset.js new file mode 100644 index 0000000000..4325f067ee --- /dev/null +++ b/docs/data/components/scroll-area/ScrollAreaInset.js @@ -0,0 +1,113 @@ +'use client'; +import * as React from 'react'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; +import { styled } from '@mui/system'; + +const data = [ + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '550e8400-e29b-41d4-a716-446655440000', + '9b2b38e2-4c7b-4e53-a228-c89c535c5072', + '3fa85f64-5717-4562-b3fc-2c963f66afa6', + '4dfbdfc4-2d0e-4e6c-8bd6-7c8d765f0a1c', + 'aa9e5d30-cf2a-4234-bc9b-6a5d965c6a00', + '16fd2706-8baf-433b-82eb-8c7fada847da', + '66ed7a57-e4b7-4b82-8b1e-2a8942f8ec6e', + 'f9e87c8f-7b4f-4c7e-bb72-ebe8e2277c5e', +]; + +const scrollbarSize = 10; + +export default function ScrollAreaInset() { + return ( + + +
+

User IDs

+
    + {data.map((value) => ( +
  • + {value} +
  • + ))} +
+
+
+ + + + + + + +
+ ); +} + +const ScrollAreaRoot = styled(ScrollArea.Root)` + width: 250px; + height: 250px; + border-radius: 2px; + background: #f5f5f5; + + --scrollbar-size: ${scrollbarSize}px; +`; + +const ScrollAreaViewport = styled(ScrollArea.Viewport)` + width: 100%; + height: 100%; + border-radius: 2px; + + &:focus-visible { + outline: 2px solid rgb(0 0 0 / 0.5); + } +`; + +const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` + background: rgb(220 220 220); + box-sizing: border-box; + display: flex; + + &[data-orientation='vertical'] { + width: var(--scrollbar-size); + } + + &[data-orientation='horizontal'] { + flex-direction: column; + height: var(--scrollbar-size); + } +`; + +const ScrollAreaThumb = styled(ScrollArea.Thumb)` + background: rgb(180 180 180); + flex: 1; + + &:hover { + background: rgb(150 150 150); + } + + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + min-width: 22px; + min-height: 22px; + } +`; + +const ScrollAreaCorner = styled(ScrollArea.Corner)` + background: rgb(220 220 220); +`; diff --git a/docs/data/components/scroll-area/ScrollAreaInset.tsx b/docs/data/components/scroll-area/ScrollAreaInset.tsx new file mode 100644 index 0000000000..4325f067ee --- /dev/null +++ b/docs/data/components/scroll-area/ScrollAreaInset.tsx @@ -0,0 +1,113 @@ +'use client'; +import * as React from 'react'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; +import { styled } from '@mui/system'; + +const data = [ + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '550e8400-e29b-41d4-a716-446655440000', + '9b2b38e2-4c7b-4e53-a228-c89c535c5072', + '3fa85f64-5717-4562-b3fc-2c963f66afa6', + '4dfbdfc4-2d0e-4e6c-8bd6-7c8d765f0a1c', + 'aa9e5d30-cf2a-4234-bc9b-6a5d965c6a00', + '16fd2706-8baf-433b-82eb-8c7fada847da', + '66ed7a57-e4b7-4b82-8b1e-2a8942f8ec6e', + 'f9e87c8f-7b4f-4c7e-bb72-ebe8e2277c5e', +]; + +const scrollbarSize = 10; + +export default function ScrollAreaInset() { + return ( + + +
+

User IDs

+
    + {data.map((value) => ( +
  • + {value} +
  • + ))} +
+
+
+ + + + + + + +
+ ); +} + +const ScrollAreaRoot = styled(ScrollArea.Root)` + width: 250px; + height: 250px; + border-radius: 2px; + background: #f5f5f5; + + --scrollbar-size: ${scrollbarSize}px; +`; + +const ScrollAreaViewport = styled(ScrollArea.Viewport)` + width: 100%; + height: 100%; + border-radius: 2px; + + &:focus-visible { + outline: 2px solid rgb(0 0 0 / 0.5); + } +`; + +const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` + background: rgb(220 220 220); + box-sizing: border-box; + display: flex; + + &[data-orientation='vertical'] { + width: var(--scrollbar-size); + } + + &[data-orientation='horizontal'] { + flex-direction: column; + height: var(--scrollbar-size); + } +`; + +const ScrollAreaThumb = styled(ScrollArea.Thumb)` + background: rgb(180 180 180); + flex: 1; + + &:hover { + background: rgb(150 150 150); + } + + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + min-width: 22px; + min-height: 22px; + } +`; + +const ScrollAreaCorner = styled(ScrollArea.Corner)` + background: rgb(220 220 220); +`; diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js new file mode 100644 index 0000000000..958cd97fd3 --- /dev/null +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js @@ -0,0 +1,123 @@ +'use client'; +import * as React from 'react'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; +import { styled } from '@mui/system'; + +const data = [ + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '550e8400-e29b-41d4-a716-446655440000', + '9b2b38e2-4c7b-4e53-a228-c89c535c5072', + '3fa85f64-5717-4562-b3fc-2c963f66afa6', + '4dfbdfc4-2d0e-4e6c-8bd6-7c8d765f0a1c', + 'aa9e5d30-cf2a-4234-bc9b-6a5d965c6a00', + '16fd2706-8baf-433b-82eb-8c7fada847da', + '66ed7a57-e4b7-4b82-8b1e-2a8942f8ec6e', + 'f9e87c8f-7b4f-4c7e-bb72-ebe8e2277c5e', +]; + +export default function ScrollAreaIntroduction() { + return ( + + +
+

User IDs

+
    + {data.map((value) => ( +
  • + {value} +
  • + ))} +
+
+
+ + + + + + +
+ ); +} + +const ScrollAreaRoot = styled(ScrollArea.Root)` + width: 250px; + height: 250px; + border-radius: 6px; + background: #f5f5f5; +`; + +const ScrollAreaViewport = styled(ScrollArea.Viewport)` + width: 100%; + height: 100%; + border-radius: 6px; + + &:focus-visible { + outline: 2px solid rgb(0 0 0 / 0.5); + } +`; + +const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` + padding: 2px; + visibility: hidden; + background: transparent; + box-sizing: border-box; + transition: + opacity 0.2s, + background 0.2s, + visibility 0.2s; + opacity: 0; + display: flex; + + &:hover { + background: rgb(0 0 0 / 0.1); + } + + &[data-orientation='vertical'] { + width: 10px; + } + + &[data-orientation='horizontal'] { + flex-direction: column; + height: 10px; + } + + &[data-hovering], + &[data-scrolling], + &:hover { + visibility: visible; + opacity: 1; + } + + &[data-scrolling]:not(:hover) { + transition: none; + } +`; + +const ScrollAreaThumb = styled(ScrollArea.Thumb)` + background: rgb(0 0 0 / 0.5); + border-radius: 20px; + flex: 1; + + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + min-width: 22px; + min-height: 22px; + } +`; diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx new file mode 100644 index 0000000000..958cd97fd3 --- /dev/null +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx @@ -0,0 +1,123 @@ +'use client'; +import * as React from 'react'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; +import { styled } from '@mui/system'; + +const data = [ + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '550e8400-e29b-41d4-a716-446655440000', + '9b2b38e2-4c7b-4e53-a228-c89c535c5072', + '3fa85f64-5717-4562-b3fc-2c963f66afa6', + '4dfbdfc4-2d0e-4e6c-8bd6-7c8d765f0a1c', + 'aa9e5d30-cf2a-4234-bc9b-6a5d965c6a00', + '16fd2706-8baf-433b-82eb-8c7fada847da', + '66ed7a57-e4b7-4b82-8b1e-2a8942f8ec6e', + 'f9e87c8f-7b4f-4c7e-bb72-ebe8e2277c5e', +]; + +export default function ScrollAreaIntroduction() { + return ( + + +
+

User IDs

+
    + {data.map((value) => ( +
  • + {value} +
  • + ))} +
+
+
+ + + + + + +
+ ); +} + +const ScrollAreaRoot = styled(ScrollArea.Root)` + width: 250px; + height: 250px; + border-radius: 6px; + background: #f5f5f5; +`; + +const ScrollAreaViewport = styled(ScrollArea.Viewport)` + width: 100%; + height: 100%; + border-radius: 6px; + + &:focus-visible { + outline: 2px solid rgb(0 0 0 / 0.5); + } +`; + +const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` + padding: 2px; + visibility: hidden; + background: transparent; + box-sizing: border-box; + transition: + opacity 0.2s, + background 0.2s, + visibility 0.2s; + opacity: 0; + display: flex; + + &:hover { + background: rgb(0 0 0 / 0.1); + } + + &[data-orientation='vertical'] { + width: 10px; + } + + &[data-orientation='horizontal'] { + flex-direction: column; + height: 10px; + } + + &[data-hovering], + &[data-scrolling], + &:hover { + visibility: visible; + opacity: 1; + } + + &[data-scrolling]:not(:hover) { + transition: none; + } +`; + +const ScrollAreaThumb = styled(ScrollArea.Thumb)` + background: rgb(0 0 0 / 0.5); + border-radius: 20px; + flex: 1; + + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + min-width: 22px; + min-height: 22px; + } +`; diff --git a/docs/data/components/scroll-area/scroll-area.mdx b/docs/data/components/scroll-area/scroll-area.mdx new file mode 100644 index 0000000000..a3e533396b --- /dev/null +++ b/docs/data/components/scroll-area/scroll-area.mdx @@ -0,0 +1,67 @@ +--- +productId: base-ui +title: React Scroll Area component +description: Scroll Area creates a scrollable region with custom scrollbars. +components: ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaCorner +githubLabel: 'component: scrollarea' +--- + +# Scroll Area + + + + + +## Introduction + + + +## Installation + + + +## Anatomy + +```jsx + + + + + + + + + + +``` + +## Styling + +The scrollbar elements can be shown conditionally based on the user's interaction with the Scroll Area. The `[data-scrolling]` and `[data-hovering]` attributes are added to the scrollbar element while the user is scrolling or hovering over the Scroll Area. + +```css +.ScrollAreaScrollbar[data-scrolling], +.ScrollAreaScrollbar[data-hovering] { + opacity: 1; +} +``` + +## Inset scrollbars + +By specifying a `gutter` prop, you can create inset scrollbars that make space for the scrollbar, preventing them from overlapping content. The value should match the size of the scrollbar width/height. + +```jsx + +``` + + + +## Corner + +The vertical and horizontal scrollbar elements can prevent overlapping each other by rendering a `Corner`: + +```jsx + + + +``` diff --git a/docs/data/pages.ts b/docs/data/pages.ts index c7064d8bda..282f4fac00 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -37,6 +37,7 @@ const pages: readonly RouteMetadata[] = [ { pathname: '/components/react-preview-card', title: 'Preview Card' }, { pathname: '/components/react-progress', title: 'Progress' }, { pathname: '/components/react-radio-group', title: 'Radio Group' }, + { pathname: '/components/react-scroll-area', title: 'Scroll Area' }, { pathname: '/components/react-separator', title: 'Separator' }, { pathname: '/components/react-slider', title: 'Slider' }, { pathname: '/components/react-switch', title: 'Switch' }, diff --git a/docs/data/translations/api-docs/scroll-area-corner/scroll-area-corner.json b/docs/data/translations/api-docs/scroll-area-corner/scroll-area-corner.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/scroll-area-corner/scroll-area-corner.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/scroll-area-root/scroll-area-root.json b/docs/data/translations/api-docs/scroll-area-root/scroll-area-root.json new file mode 100644 index 0000000000..b163a6e188 --- /dev/null +++ b/docs/data/translations/api-docs/scroll-area-root/scroll-area-root.json @@ -0,0 +1,11 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "gutter": { "description": "Determines the space to account for inset scrollbars." }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/scroll-area-scrollbar/scroll-area-scrollbar.json b/docs/data/translations/api-docs/scroll-area-scrollbar/scroll-area-scrollbar.json new file mode 100644 index 0000000000..ececcec4f7 --- /dev/null +++ b/docs/data/translations/api-docs/scroll-area-scrollbar/scroll-area-scrollbar.json @@ -0,0 +1,14 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "keepMounted": { + "description": "Whether the scrollbars remain mounted in the DOM when there is no overflow." + }, + "orientation": { "description": "The orientation of the scrollbar." }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/scroll-area-thumb/scroll-area-thumb.json b/docs/data/translations/api-docs/scroll-area-thumb/scroll-area-thumb.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/scroll-area-thumb/scroll-area-thumb.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/scroll-area-viewport/scroll-area-viewport.json b/docs/data/translations/api-docs/scroll-area-viewport/scroll-area-viewport.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/scroll-area-viewport/scroll-area-viewport.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/src/styles/reset.css b/docs/src/styles/reset.css index 7b809609a1..b578b16a7c 100644 --- a/docs/src/styles/reset.css +++ b/docs/src/styles/reset.css @@ -4,6 +4,12 @@ body { padding-top: 49px; } +*, +*::before, +*::after { + box-sizing: border-box; +} + ::selection { background: var(--gray-container-3); } diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx new file mode 100644 index 0000000000..dd185c84b3 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; +import { expect } from 'chai'; +import { screen, describeSkipIf } from '@mui/internal-test-utils'; +import { createRenderer } from '#test-utils'; +import { describeConformance } from '../../../test/describeConformance'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); + + describeSkipIf(isJSDOM)('interactions', () => { + it('should apply correct corner size when both scrollbars are present', async () => { + await render( + + +
+ + + + + , + ); + + const corner = screen.getByTestId('corner'); + const style = getComputedStyle(corner); + + expect(style.getPropertyValue('--scroll-area-corner-width')).to.equal('10px'); + expect(style.getPropertyValue('--scroll-area-corner-height')).to.equal('10px'); + }); + }); +}); diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx new file mode 100644 index 0000000000..1dd3a9099a --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx @@ -0,0 +1,80 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext'; +import { useForkRef } from '../../utils/useForkRef'; + +const ownerState = {}; + +/** + * + * Demos: + * + * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/) + * + * API: + * + * - [ScrollAreaCorner API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaCorner) + */ +const ScrollAreaCorner = React.forwardRef(function ScrollAreaCorner( + props: ScrollAreaCorner.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, ...otherProps } = props; + + const { dir, cornerRef, cornerSize, hiddenState } = useScrollAreaRootContext(); + + const mergedRef = useForkRef(cornerRef, forwardedRef); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'div', + ref: mergedRef, + className, + ownerState, + extraProps: mergeReactProps(otherProps, { + style: { + position: 'absolute', + bottom: 0, + [dir === 'rtl' ? 'left' : 'right']: 0, + width: cornerSize.width, + height: cornerSize.height, + }, + }), + }); + + if (hiddenState.cornerHidden) { + return null; + } + + return renderElement(); +}); + +namespace ScrollAreaCorner { + export interface OwnerState {} + + export interface Props extends BaseUIComponentProps<'div', OwnerState> {} +} + +ScrollAreaCorner.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { ScrollAreaCorner }; diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx new file mode 100644 index 0000000000..85c0b95d0c --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; +import { screen, describeSkipIf } from '@mui/internal-test-utils'; +import { createRenderer } from '#test-utils'; +import { expect } from 'chai'; +import { describeConformance } from '../../../test/describeConformance'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +const VIEWPORT_SIZE = 200; +const SCROLLABLE_CONTENT_SIZE = 1000; +const SCROLLBAR_WIDTH = 10; +const SCROLLBAR_HEIGHT = 10; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render, + })); + + describeSkipIf(isJSDOM)('interactions', () => { + it('should correctly set thumb height and width based on scrollable content', async () => { + await render( + + +
+ + + + + + + + , + ); + + const verticalThumb = screen.getByTestId('vertical-thumb'); + const horizontalThumb = screen.getByTestId('horizontal-thumb'); + + expect( + getComputedStyle(verticalThumb).getPropertyValue('--scroll-area-thumb-height'), + ).to.equal(`${(VIEWPORT_SIZE / SCROLLABLE_CONTENT_SIZE) * VIEWPORT_SIZE}px`); + expect( + getComputedStyle(horizontalThumb).getPropertyValue('--scroll-area-thumb-width'), + ).to.equal(`${(VIEWPORT_SIZE / SCROLLABLE_CONTENT_SIZE) * VIEWPORT_SIZE}px`); + }); + + describe('prop: gutter', () => { + it('should not add padding for overlay scrollbars', async () => { + await render( + + +
+ + + + , + ); + + const contentWrapper = screen.getByTestId('viewport').firstElementChild!; + const style = getComputedStyle(contentWrapper); + + expect(style.paddingLeft).to.equal('0px'); + expect(style.paddingRight).to.equal('0px'); + expect(style.paddingBottom).to.equal('0px'); + }); + + it('should add padding for inset scrollbars', async () => { + await render( + + +
+ + + + , + ); + + const contentWrapper = screen.getByTestId('viewport').firstElementChild!; + const style = getComputedStyle(contentWrapper); + + expect(style.paddingRight).to.equal(`${SCROLLBAR_WIDTH}px`); + expect(style.paddingBottom).to.equal(`${SCROLLBAR_HEIGHT}px`); + }); + }); + + describe('prop: dir', () => { + it('should adjust inset padding for rtl', async () => { + await render( + + +
+ + + + , + ); + + const contentWrapper = screen.getByTestId('viewport').firstElementChild!; + const style = getComputedStyle(contentWrapper); + + expect(style.paddingLeft).to.equal(`${SCROLLBAR_WIDTH}px`); + expect(style.paddingRight).not.to.equal(`${SCROLLBAR_WIDTH}px`); + expect(style.paddingBottom).to.equal(`${SCROLLBAR_HEIGHT}px`); + }); + }); + }); +}); diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx new file mode 100644 index 0000000000..9b23399f37 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx @@ -0,0 +1,111 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { ScrollAreaRootContext } from './ScrollAreaRootContext'; +import { useScrollAreaRoot } from './useScrollAreaRoot'; + +const ownerState = {}; + +/** + * + * Demos: + * + * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/) + * + * API: + * + * - [ScrollAreaRoot API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaRoot) + */ +const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( + props: ScrollAreaRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, dir, gutter = 0, ...otherProps } = props; + + const scrollAreaRoot = useScrollAreaRoot({ dir, gutter }); + + const { rootId } = scrollAreaRoot; + + const { renderElement } = useComponentRenderer({ + propGetter: scrollAreaRoot.getRootProps, + render: render ?? 'div', + ref: forwardedRef, + className, + ownerState, + extraProps: otherProps, + }); + + const contextValue = React.useMemo( + () => ({ + dir, + gutter, + ...scrollAreaRoot, + }), + [dir, gutter, scrollAreaRoot], + ); + + const viewportId = `[data-id="${rootId}-viewport"]`; + + const html = React.useMemo( + () => ({ + __html: `${viewportId}{scrollbar-width:none}${viewportId}::-webkit-scrollbar{display:none}`, + }), + [viewportId], + ); + + return ( + + {rootId && ( +