From 51769992496739c273f0de3e744ad5e3ea2fe1ee Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 1 Oct 2024 07:18:03 +1000 Subject: [PATCH 01/72] [ScrollArea] Create new ScrollArea component --- docs/data/api/scroll-area-corner.json | 16 ++ docs/data/api/scroll-area-root.json | 16 ++ docs/data/api/scroll-area-scrollbar.json | 16 ++ docs/data/api/scroll-area-thumb.json | 16 ++ docs/data/api/scroll-area-viewport.json | 16 ++ .../ScrollAreaIntroduction/system/index.js | 103 +++++++++ .../ScrollAreaIntroduction/system/index.tsx | 103 +++++++++ .../components/scroll-area/scroll-area.mdx | 62 +++++ .../scroll-area-corner.json | 10 + .../scroll-area-root/scroll-area-root.json | 10 + .../scroll-area-scrollbar.json | 10 + .../scroll-area-thumb/scroll-area-thumb.json | 10 + .../scroll-area-viewport.json | 10 + docs/src/styles/reset.css | 6 + .../ScrollArea/Corner/ScrollAreaCorner.tsx | 60 +++++ .../src/ScrollArea/Root/ScrollAreaRoot.tsx | 200 +++++++++++++++++ .../ScrollArea/Root/ScrollAreaRootContext.ts | 31 +++ .../Scrollbar/ScrollAreaScrollbar.tsx | 211 ++++++++++++++++++ .../Scrollbar/ScrollAreaScrollbarContext.ts | 21 ++ .../src/ScrollArea/Thumb/ScrollAreaThumb.tsx | 92 ++++++++ .../Viewport/ScrollAreaViewport.tsx | 156 +++++++++++++ .../mui-base/src/ScrollArea/index.barrel.ts | 5 + packages/mui-base/src/ScrollArea/index.ts | 5 + 23 files changed, 1185 insertions(+) create mode 100644 docs/data/api/scroll-area-corner.json create mode 100644 docs/data/api/scroll-area-root.json create mode 100644 docs/data/api/scroll-area-scrollbar.json create mode 100644 docs/data/api/scroll-area-thumb.json create mode 100644 docs/data/api/scroll-area-viewport.json create mode 100644 docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js create mode 100644 docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx create mode 100644 docs/data/components/scroll-area/scroll-area.mdx create mode 100644 docs/data/translations/api-docs/scroll-area-corner/scroll-area-corner.json create mode 100644 docs/data/translations/api-docs/scroll-area-root/scroll-area-root.json create mode 100644 docs/data/translations/api-docs/scroll-area-scrollbar/scroll-area-scrollbar.json create mode 100644 docs/data/translations/api-docs/scroll-area-thumb/scroll-area-thumb.json create mode 100644 docs/data/translations/api-docs/scroll-area-viewport/scroll-area-viewport.json create mode 100644 packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx create mode 100644 packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx create mode 100644 packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts create mode 100644 packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx create mode 100644 packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts create mode 100644 packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx create mode 100644 packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx create mode 100644 packages/mui-base/src/ScrollArea/index.barrel.ts create mode 100644 packages/mui-base/src/ScrollArea/index.ts diff --git a/docs/data/api/scroll-area-corner.json b/docs/data/api/scroll-area-corner.json new file mode 100644 index 0000000000..dd2da4bb49 --- /dev/null +++ b/docs/data/api/scroll-area-corner.json @@ -0,0 +1,16 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaCorner", + "imports": [ + "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaCorner = ScrollArea.Corner;" + ], + "classes": [], + "muiName": "ScrollAreaCorner", + "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..1b6d7d7847 --- /dev/null +++ b/docs/data/api/scroll-area-root.json @@ -0,0 +1,16 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaRoot", + "imports": [ + "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaRoot = ScrollArea.Root;" + ], + "classes": [], + "muiName": "ScrollAreaRoot", + "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..c06caf0473 --- /dev/null +++ b/docs/data/api/scroll-area-scrollbar.json @@ -0,0 +1,16 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaScrollbar", + "imports": [ + "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaScrollbar = ScrollArea.Scrollbar;" + ], + "classes": [], + "muiName": "ScrollAreaScrollbar", + "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..bb6c55e172 --- /dev/null +++ b/docs/data/api/scroll-area-thumb.json @@ -0,0 +1,16 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaThumb", + "imports": [ + "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaThumb = ScrollArea.Thumb;" + ], + "classes": [], + "muiName": "ScrollAreaThumb", + "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..c54dbb3cc9 --- /dev/null +++ b/docs/data/api/scroll-area-viewport.json @@ -0,0 +1,16 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ScrollAreaViewport", + "imports": [ + "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaViewport = ScrollArea.Viewport;" + ], + "classes": [], + "muiName": "ScrollAreaViewport", + "filename": "/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} 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..dda02d185a --- /dev/null +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js @@ -0,0 +1,103 @@ +'use client'; +import * as React from 'react'; +import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { styled } from '@mui/system'; + +const data = Array.from({ length: 30 }, (_, i) => i + 1); + +export default function ScrollAreaIntroduction() { + return ( + + +
    + {data.map((value) => ( +
  • + List item {value} +
  • + ))} +
+
+ + + + + + +
+ ); +} + +const ScrollAreaRoot = styled(ScrollArea.Root)` + width: 250px; + height: 250px; + border-radius: 6px; + background: #f5f5f5; + overflow: hidden; +`; + +const ScrollAreaViewport = styled(ScrollArea.Viewport)` + width: 100%; + height: 100%; + scrollbar-width: none; +`; + +const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` + width: 10px; + padding: 2px; + visibility: hidden; + background: transparent; + transition: + opacity 0.2s, + background 0.2s, + visibility 0.2s; + opacity: 0; + + &:hover { + background: rgb(0 0 0 / 0.1); + } + + &[data-orientation='horizontal'] { + width: 100%; + height: 10px; + } + + &[data-scrolling] { + transition: none; + } + + &[data-hovering], + &[data-scrolling], + &:hover { + visibility: visible; + opacity: 1; + } +`; + +const ScrollAreaThumb = styled(ScrollArea.Thumb)` + background: rgb(0 0 0 / 0.5); + border-radius: 20px; + height: var(--scroll-area-thumb-height, 6px); + width: var(--scroll-area-thumb-width, 6px); + + &::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..dda02d185a --- /dev/null +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx @@ -0,0 +1,103 @@ +'use client'; +import * as React from 'react'; +import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { styled } from '@mui/system'; + +const data = Array.from({ length: 30 }, (_, i) => i + 1); + +export default function ScrollAreaIntroduction() { + return ( + + +
    + {data.map((value) => ( +
  • + List item {value} +
  • + ))} +
+
+ + + + + + +
+ ); +} + +const ScrollAreaRoot = styled(ScrollArea.Root)` + width: 250px; + height: 250px; + border-radius: 6px; + background: #f5f5f5; + overflow: hidden; +`; + +const ScrollAreaViewport = styled(ScrollArea.Viewport)` + width: 100%; + height: 100%; + scrollbar-width: none; +`; + +const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` + width: 10px; + padding: 2px; + visibility: hidden; + background: transparent; + transition: + opacity 0.2s, + background 0.2s, + visibility 0.2s; + opacity: 0; + + &:hover { + background: rgb(0 0 0 / 0.1); + } + + &[data-orientation='horizontal'] { + width: 100%; + height: 10px; + } + + &[data-scrolling] { + transition: none; + } + + &[data-hovering], + &[data-scrolling], + &:hover { + visibility: visible; + opacity: 1; + } +`; + +const ScrollAreaThumb = styled(ScrollArea.Thumb)` + background: rgb(0 0 0 / 0.5); + border-radius: 20px; + height: var(--scroll-area-thumb-height, 6px); + width: var(--scroll-area-thumb-width, 6px); + + &::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..c1565442db --- /dev/null +++ b/docs/data/components/scroll-area/scroll-area.mdx @@ -0,0 +1,62 @@ +--- +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' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/radio/ +--- + +# Scroll Area + + + + + +## Introduction + + + +## Installation + + + +## Anatomy + +```jsx + + + + + + + + + + +``` + +## Styling + +The thumb element can be styled using `--scroll-area-thumb-height` and `--scroll-area-thumb-width` CSS variables. + +These are available depending on the `orientation` prop supplied to the `Scrollbar` subcomponent: + +- `height` is available for `vertical` orientation (default) +- `width` is available for `horizontal` orientation + +```css +.ScrollAreaThumb { + height: var(--scroll-area-thumb-height, 6px); + width: var(--scroll-area-thumb-width, 6px); +} +``` + +The Scrollbar element 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; +} +``` 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..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/scroll-area-root/scroll-area-root.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-scrollbar/scroll-area-scrollbar.json b/docs/data/translations/api-docs/scroll-area-scrollbar/scroll-area-scrollbar.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/scroll-area-scrollbar/scroll-area-scrollbar.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-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.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx new file mode 100644 index 0000000000..8ed2603848 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; + +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 { renderElement } = useComponentRenderer({ + render: render ?? 'div', + ref: forwardedRef, + className, + ownerState, + extraProps: otherProps, + }); + + 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.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx new file mode 100644 index 0000000000..e82350e5d5 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx @@ -0,0 +1,200 @@ +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 { ScrollAreaRootContext } from './ScrollAreaRootContext'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { useControlled } from '../../utils/useControlled'; + +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: dirProp, ...otherProps } = props; + + const [hovering, setHovering] = React.useState(false); + const [scrolling, setScrolling] = React.useState(false); + + const viewportRef = React.useRef(null); + + const scrollbarYRef = React.useRef(null); + const scrollbarXRef = React.useRef(null); + const thumbYRef = React.useRef(null); + const thumbXRef = React.useRef(null); + + const thumbDraggingRef = React.useRef(false); + const startYRef = React.useRef(0); + const startXRef = React.useRef(0); + const startScrollTopRef = React.useRef(0); + const startScrollLeftRef = React.useRef(0); + const currentOrientation = React.useRef<'vertical' | 'horizontal'>('vertical'); + + const [dir, setDir] = useControlled({ + controlled: dirProp, + default: dirProp ?? 'ltr', + name: 'ScrollArea', + }); + + useEnhancedEffect(() => { + if (viewportRef.current) { + setDir(getComputedStyle(viewportRef.current).direction); + } + }, [setDir]); + + const handlePointerDown = useEventCallback((event: React.PointerEvent) => { + thumbDraggingRef.current = true; + startYRef.current = event.clientY; + startXRef.current = event.clientX; + currentOrientation.current = event.currentTarget.getAttribute('data-orientation') as + | 'vertical' + | 'horizontal'; + + if (viewportRef.current) { + startScrollTopRef.current = viewportRef.current.scrollTop; + startScrollLeftRef.current = viewportRef.current.scrollLeft; + } + if (thumbYRef.current && currentOrientation.current === 'vertical') { + thumbYRef.current.setPointerCapture(event.pointerId); + } + if (thumbXRef.current && currentOrientation.current === 'horizontal') { + thumbXRef.current.setPointerCapture(event.pointerId); + } + }); + + const handlePointerMove = useEventCallback((event: React.PointerEvent) => { + if (!thumbDraggingRef.current) { + return; + } + + const deltaY = event.clientY - startYRef.current; + const deltaX = event.clientX - startXRef.current; + + if (viewportRef.current) { + const scrollableContentHeight = viewportRef.current.scrollHeight; + const viewportHeight = viewportRef.current.clientHeight; + const scrollableContentWidth = viewportRef.current.scrollWidth; + const viewportWidth = viewportRef.current.clientWidth; + + if (thumbYRef.current && scrollbarYRef.current && currentOrientation.current === 'vertical') { + const thumbHeight = thumbYRef.current.offsetHeight; + const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight; + const scrollRatioY = deltaY / maxThumbOffsetY; + viewportRef.current.scrollTop = + startScrollTopRef.current + scrollRatioY * (scrollableContentHeight - viewportHeight); + event.preventDefault(); + } + + if ( + thumbXRef.current && + scrollbarXRef.current && + currentOrientation.current === 'horizontal' + ) { + const thumbWidth = thumbXRef.current.offsetWidth; + const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth; + const scrollRatioX = deltaX / maxThumbOffsetX; + viewportRef.current.scrollLeft = + startScrollLeftRef.current + scrollRatioX * (scrollableContentWidth - viewportWidth); + event.preventDefault(); + } + } + }); + + const handlePointerUp = useEventCallback((event: React.PointerEvent) => { + thumbDraggingRef.current = false; + + if (thumbYRef.current && currentOrientation.current === 'vertical') { + thumbYRef.current.releasePointerCapture(event.pointerId); + } + if (thumbXRef.current && currentOrientation.current === 'horizontal') { + thumbXRef.current.releasePointerCapture(event.pointerId); + } + }); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'div', + ref: forwardedRef, + className, + ownerState, + extraProps: mergeReactProps(otherProps, { + onMouseEnter() { + setHovering(true); + }, + onMouseLeave() { + setHovering(false); + }, + style: { + position: 'relative', + }, + }), + }); + + const contextValue = React.useMemo( + () => ({ + dir, + hovering, + setHovering, + scrolling, + setScrolling, + viewportRef, + scrollbarYRef, + thumbYRef, + scrollbarXRef, + thumbXRef, + handlePointerDown, + handlePointerMove, + handlePointerUp, + }), + [dir, hovering, scrolling, handlePointerDown, handlePointerMove, handlePointerUp], + ); + + return ( + + {renderElement()} + + ); +}); + +namespace ScrollAreaRoot { + export interface OwnerState {} + + export interface Props extends BaseUIComponentProps<'div', OwnerState> {} +} + +ScrollAreaRoot.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]), + /** + * @ignore + */ + dir: PropTypes.string, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { ScrollAreaRoot }; diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts new file mode 100644 index 0000000000..367f613190 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; + +export interface ScrollAreaRootContext { + dir: string | undefined; + hovering: boolean; + setHovering: React.Dispatch>; + scrolling: boolean; + setScrolling: React.Dispatch>; + viewportRef: React.RefObject; + scrollbarYRef: React.RefObject; + thumbYRef: React.RefObject; + scrollbarXRef: React.RefObject; + thumbXRef: React.RefObject; + handlePointerDown: (event: React.PointerEvent) => void; + handlePointerMove: (event: React.PointerEvent) => void; + handlePointerUp: (event: React.PointerEvent) => void; +} + +export const ScrollAreaRootContext = React.createContext(null); + +if (process.env.NODE_ENV !== 'production') { + ScrollAreaRootContext.displayName = 'ScrollAreaRootContext'; +} + +export function useScrollAreaRootContext() { + const context = React.useContext(ScrollAreaRootContext); + if (context === null) { + throw new Error('Base UI: ScrollAreaRootContext is undefined.'); + } + return context; +} diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx new file mode 100644 index 0000000000..8112be5f5c --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx @@ -0,0 +1,211 @@ +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'; +import { ScrollAreaScrollbarContext } from './ScrollAreaScrollbarContext'; + +/** + * + * Demos: + * + * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/) + * + * API: + * + * - [ScrollAreaScrollbar API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaScrollbar) + */ +const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar( + props: ScrollAreaScrollbar.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, orientation = 'vertical', ...otherProps } = props; + + const { + dir, + hovering, + scrolling, + scrollbarYRef, + scrollbarXRef, + viewportRef, + thumbYRef, + thumbXRef, + handlePointerDown, + handlePointerUp, + } = useScrollAreaRootContext(); + + const mergedRef = useForkRef( + forwardedRef, + orientation === 'vertical' ? scrollbarYRef : scrollbarXRef, + ); + + const ownerState: ScrollAreaScrollbar.OwnerState = React.useMemo( + () => ({ + hovering, + scrolling, + orientation, + }), + [hovering, scrolling, orientation], + ); + + React.useEffect(() => { + const viewportEl = viewportRef.current; + const scrollbarEl = orientation === 'vertical' ? scrollbarYRef.current : scrollbarXRef.current; + + function handleWheel(event: WheelEvent) { + if (!viewportEl || !scrollbarEl) { + return; + } + + if (orientation === 'vertical') { + if (viewportEl.scrollTop === 0 && event.deltaY < 0) { + return; + } + } else if (viewportEl.scrollLeft === 0 && event.deltaX < 0) { + return; + } + + if (orientation === 'vertical') { + if ( + viewportEl.scrollTop === viewportEl.scrollHeight - viewportEl.clientHeight && + event.deltaY > 0 + ) { + return; + } + } else if ( + viewportEl.scrollLeft === viewportEl.scrollWidth - viewportEl.clientWidth && + event.deltaX > 0 + ) { + return; + } + + event.preventDefault(); + + if (orientation === 'vertical') { + viewportEl.scrollTop += event.deltaY; + } else { + viewportEl.scrollLeft += event.deltaX; + } + } + + scrollbarEl?.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + scrollbarEl?.removeEventListener('wheel', handleWheel); + }; + }, [orientation, scrollbarXRef, scrollbarYRef, thumbYRef, viewportRef]); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'div', + ref: mergedRef, + className, + ownerState, + extraProps: mergeReactProps<'div'>(otherProps, { + onPointerDown(event) { + // Ignore clicks on thumb + if (event.currentTarget !== event.target) { + return; + } + + if (!viewportRef.current) { + return; + } + + // Handle Y-axis (vertical) scroll + if (thumbYRef.current && scrollbarYRef.current && orientation === 'vertical') { + const thumbHeight = thumbYRef.current.offsetHeight; + const trackRectY = scrollbarYRef.current.getBoundingClientRect(); + const clickY = event.clientY - trackRectY.top - thumbHeight / 2; + + const scrollableContentHeight = viewportRef.current.scrollHeight; + const viewportHeight = viewportRef.current.clientHeight; + + const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight; + const scrollRatioY = clickY / maxThumbOffsetY; + const newScrollTop = scrollRatioY * (scrollableContentHeight - viewportHeight); + + viewportRef.current.scrollTop = newScrollTop; + } + + // Handle X-axis (horizontal) scroll + if (thumbXRef.current && scrollbarXRef.current && orientation === 'horizontal') { + const thumbWidth = thumbXRef.current.offsetWidth; + const trackRectX = scrollbarXRef.current.getBoundingClientRect(); + const clickX = event.clientX - trackRectX.left - thumbWidth / 2; + + const scrollableContentWidth = viewportRef.current.scrollWidth; + const viewportWidth = viewportRef.current.clientWidth; + + const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth; + const scrollRatioX = clickX / maxThumbOffsetX; + const newScrollLeft = scrollRatioX * (scrollableContentWidth - viewportWidth); + + viewportRef.current.scrollLeft = newScrollLeft; + } + + handlePointerDown(event); + }, + onPointerUp: handlePointerUp, + style: { + position: 'absolute', + ...(orientation === 'vertical' && { + top: 0, + bottom: 0, + [dir === 'rtl' ? 'left' : 'right']: 0, + }), + ...(orientation === 'horizontal' && { + left: 0, + right: 0, + bottom: 0, + }), + }, + }), + }); + + const contextValue = React.useMemo(() => ({ orientation }), [orientation]); + + return ( + + {renderElement()} + + ); +}); + +namespace ScrollAreaScrollbar { + export interface OwnerState { + hovering: boolean; + scrolling: boolean; + orientation: 'vertical' | 'horizontal'; + } + + export interface Props extends BaseUIComponentProps<'div', OwnerState> { + orientation?: 'vertical' | 'horizontal'; + } +} + +ScrollAreaScrollbar.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]), + /** + * @ignore + */ + orientation: PropTypes.oneOf(['horizontal', 'vertical']), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { ScrollAreaScrollbar }; diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts new file mode 100644 index 0000000000..658c780d43 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; + +export interface ScrollAreaScrollbarContext { + orientation: 'horizontal' | 'vertical'; +} + +export const ScrollAreaScrollbarContext = React.createContext( + null, +); + +if (process.env.NODE_ENV !== 'production') { + ScrollAreaScrollbarContext.displayName = 'ScrollAreaScrollbarContext'; +} + +export function useScrollAreaScrollbarContext() { + const context = React.useContext(ScrollAreaScrollbarContext); + if (context === null) { + throw new Error('Base UI: ScrollAreaScrollbarContext is undefined.'); + } + return context; +} diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx new file mode 100644 index 0000000000..1b00630104 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext'; +import { useForkRef } from '../../utils/useForkRef'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { useScrollAreaScrollbarContext } from '../Scrollbar/ScrollAreaScrollbarContext'; + +/** + * + * Demos: + * + * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/) + * + * API: + * + * - [ScrollAreaThumb API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaThumb) + */ +const ScrollAreaThumb = React.forwardRef(function ScrollAreaThumb( + props: ScrollAreaThumb.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, ...otherProps } = props; + + const { + thumbYRef, + thumbXRef, + handlePointerDown, + handlePointerMove, + handlePointerUp, + setScrolling, + } = useScrollAreaRootContext(); + + const { orientation } = useScrollAreaScrollbarContext(); + + const ownerState: ScrollAreaThumb.OwnerState = React.useMemo( + () => ({ orientation }), + [orientation], + ); + + const mergedRef = useForkRef(forwardedRef, orientation === 'vertical' ? thumbYRef : thumbXRef); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'div', + ref: mergedRef, + className, + ownerState, + extraProps: mergeReactProps<'div'>(otherProps, { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp(event) { + setScrolling(false); + handlePointerUp(event); + }, + style: { + touchAction: 'none', + }, + }), + }); + + return renderElement(); +}); + +namespace ScrollAreaThumb { + export interface OwnerState { + orientation?: 'horizontal' | 'vertical'; + } + + export interface Props extends BaseUIComponentProps<'div', OwnerState> {} +} + +ScrollAreaThumb.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 { ScrollAreaThumb }; diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx new file mode 100644 index 0000000000..59c18aef37 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx @@ -0,0 +1,156 @@ +'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 { useEventCallback } from '../../utils/useEventCallback'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { useForkRef } from '../../utils/useForkRef'; + +const ownerState = {}; + +/** + * + * Demos: + * + * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/) + * + * API: + * + * - [ScrollAreaViewport API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaViewport) + */ +const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( + props: ScrollAreaViewport.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, ...otherProps } = props; + const { viewportRef, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, setScrolling } = + useScrollAreaRootContext(); + + const mergedRef = useForkRef(forwardedRef, viewportRef); + + const timeoutRef = React.useRef(-1); + + const computeThumb = useEventCallback(() => { + const viewportEl = viewportRef.current; + const scrollbarYEl = scrollbarYRef.current; + const scrollbarXEl = scrollbarXRef.current; + const thumbYEl = thumbYRef.current; + const thumbXEl = thumbXRef.current; + + if (viewportEl) { + const scrollableContentHeight = viewportEl.scrollHeight; + const scrollableContentWidth = viewportEl.scrollWidth; + const viewportHeight = viewportEl.clientHeight; + const viewportWidth = viewportEl.clientWidth; + const scrollTop = viewportEl.scrollTop; + const scrollLeft = viewportEl.scrollLeft; + + // Handle Y (vertical) scroll + if (scrollbarYEl && thumbYEl) { + const thumbHeight = thumbYEl.offsetHeight; + const scrollbarStylesY = getComputedStyle(scrollbarYEl); + const paddingTop = parseFloat(scrollbarStylesY.paddingTop); + const paddingBottom = parseFloat(scrollbarStylesY.paddingBottom); + + const maxThumbOffsetY = + scrollbarYEl.offsetHeight - thumbHeight - (paddingTop + paddingBottom); + const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight); + const thumbOffsetY = scrollRatioY * maxThumbOffsetY; + + thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; + + scrollbarYEl.style.setProperty( + '--scroll-area-thumb-height', + `${(viewportHeight / scrollableContentHeight) * viewportHeight}px`, + ); + } + + // Handle X (horizontal) scroll + if (scrollbarXEl && thumbXEl) { + const thumbWidth = thumbXEl.offsetWidth; + const scrollbarStylesX = getComputedStyle(scrollbarXEl); + const paddingLeft = parseFloat(scrollbarStylesX.paddingLeft); + const paddingRight = parseFloat(scrollbarStylesX.paddingRight); + + const maxThumbOffsetX = + scrollbarXEl.offsetWidth - thumbWidth - (paddingLeft + paddingRight); + const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth); + const thumbOffsetX = scrollRatioX * maxThumbOffsetX; + + thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; + + scrollbarXEl.style.setProperty( + '--scroll-area-thumb-width', + `${(viewportWidth / scrollableContentWidth) * viewportWidth}px`, + ); + } + } + }); + + useEnhancedEffect(() => { + computeThumb(); + }, [computeThumb]); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'div', + ref: mergedRef, + className, + ownerState, + extraProps: mergeReactProps<'div'>(otherProps, { + onScroll() { + computeThumb(); + setScrolling(true); + + window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { + setScrolling(false); + }, 500); + }, + style: { + overflow: 'scroll', + }, + children: ( +
+ {props.children} +
+ ), + }), + }); + + return renderElement(); +}); + +namespace ScrollAreaViewport { + export interface OwnerState {} + + export interface Props extends BaseUIComponentProps<'div', OwnerState> {} +} + +ScrollAreaViewport.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 { ScrollAreaViewport }; diff --git a/packages/mui-base/src/ScrollArea/index.barrel.ts b/packages/mui-base/src/ScrollArea/index.barrel.ts new file mode 100644 index 0000000000..84e502fc77 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/index.barrel.ts @@ -0,0 +1,5 @@ +export { ScrollAreaRoot } from './Root/ScrollAreaRoot'; +export { ScrollAreaViewport } from './Viewport/ScrollAreaViewport'; +export { ScrollAreaScrollbar } from './Scrollbar/ScrollAreaScrollbar'; +export { ScrollAreaThumb } from './Thumb/ScrollAreaThumb'; +export { ScrollAreaCorner } from './Corner/ScrollAreaCorner'; diff --git a/packages/mui-base/src/ScrollArea/index.ts b/packages/mui-base/src/ScrollArea/index.ts new file mode 100644 index 0000000000..a6193a16bd --- /dev/null +++ b/packages/mui-base/src/ScrollArea/index.ts @@ -0,0 +1,5 @@ +export { ScrollAreaRoot as Root } from './Root/ScrollAreaRoot'; +export { ScrollAreaViewport as Viewport } from './Viewport/ScrollAreaViewport'; +export { ScrollAreaScrollbar as Scrollbar } from './Scrollbar/ScrollAreaScrollbar'; +export { ScrollAreaThumb as Thumb } from './Thumb/ScrollAreaThumb'; +export { ScrollAreaCorner as Corner } from './Corner/ScrollAreaCorner'; From 1b718e2258fd6a2df6c2d8e1eeec8c59928e88e7 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 1 Oct 2024 07:25:51 +1000 Subject: [PATCH 02/72] Updates --- docs/data/api/scroll-area-scrollbar.json | 4 ++++ .../scroll-area-scrollbar.json | 1 + .../ScrollArea/Corner/ScrollAreaCorner.tsx | 1 + .../src/ScrollArea/Root/ScrollAreaRoot.tsx | 21 ++++++++++++------- .../Scrollbar/ScrollAreaScrollbar.tsx | 9 +++++++- .../src/ScrollArea/Thumb/ScrollAreaThumb.tsx | 4 +--- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/docs/data/api/scroll-area-scrollbar.json b/docs/data/api/scroll-area-scrollbar.json index c06caf0473..02b6d0d0af 100644 --- a/docs/data/api/scroll-area-scrollbar.json +++ b/docs/data/api/scroll-area-scrollbar.json @@ -1,6 +1,10 @@ { "props": { "className": { "type": { "name": "union", "description": "func
| string" } }, + "orientation": { + "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" }, + "default": "'vertical'" + }, "render": { "type": { "name": "union", "description": "element
| func" } } }, "name": "ScrollAreaScrollbar", 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 index 4bc12cf1e0..e99546e5da 100644 --- 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 @@ -4,6 +4,7 @@ "className": { "description": "Class names applied to the element or a function that returns them based on the component's state." }, + "orientation": { "description": "The orientation of the scrollbar." }, "render": { "description": "A function to customize rendering of the component." } }, "classDescriptions": {} diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx index 8ed2603848..7206da806a 100644 --- a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx @@ -1,3 +1,4 @@ +'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; import type { BaseUIComponentProps } from '../../utils/types'; diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx index e82350e5d5..10299a108a 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx @@ -1,3 +1,4 @@ +'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; import type { BaseUIComponentProps } from '../../utils/types'; @@ -41,7 +42,7 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( const startXRef = React.useRef(0); const startScrollTopRef = React.useRef(0); const startScrollLeftRef = React.useRef(0); - const currentOrientation = React.useRef<'vertical' | 'horizontal'>('vertical'); + const currentOrientationRef = React.useRef<'vertical' | 'horizontal'>('vertical'); const [dir, setDir] = useControlled({ controlled: dirProp, @@ -59,7 +60,7 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( thumbDraggingRef.current = true; startYRef.current = event.clientY; startXRef.current = event.clientX; - currentOrientation.current = event.currentTarget.getAttribute('data-orientation') as + currentOrientationRef.current = event.currentTarget.getAttribute('data-orientation') as | 'vertical' | 'horizontal'; @@ -67,10 +68,10 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( startScrollTopRef.current = viewportRef.current.scrollTop; startScrollLeftRef.current = viewportRef.current.scrollLeft; } - if (thumbYRef.current && currentOrientation.current === 'vertical') { + if (thumbYRef.current && currentOrientationRef.current === 'vertical') { thumbYRef.current.setPointerCapture(event.pointerId); } - if (thumbXRef.current && currentOrientation.current === 'horizontal') { + if (thumbXRef.current && currentOrientationRef.current === 'horizontal') { thumbXRef.current.setPointerCapture(event.pointerId); } }); @@ -89,7 +90,11 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( const scrollableContentWidth = viewportRef.current.scrollWidth; const viewportWidth = viewportRef.current.clientWidth; - if (thumbYRef.current && scrollbarYRef.current && currentOrientation.current === 'vertical') { + if ( + thumbYRef.current && + scrollbarYRef.current && + currentOrientationRef.current === 'vertical' + ) { const thumbHeight = thumbYRef.current.offsetHeight; const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight; const scrollRatioY = deltaY / maxThumbOffsetY; @@ -101,7 +106,7 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( if ( thumbXRef.current && scrollbarXRef.current && - currentOrientation.current === 'horizontal' + currentOrientationRef.current === 'horizontal' ) { const thumbWidth = thumbXRef.current.offsetWidth; const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth; @@ -116,10 +121,10 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( const handlePointerUp = useEventCallback((event: React.PointerEvent) => { thumbDraggingRef.current = false; - if (thumbYRef.current && currentOrientation.current === 'vertical') { + if (thumbYRef.current && currentOrientationRef.current === 'vertical') { thumbYRef.current.releasePointerCapture(event.pointerId); } - if (thumbXRef.current && currentOrientation.current === 'horizontal') { + if (thumbXRef.current && currentOrientationRef.current === 'horizontal') { thumbXRef.current.releasePointerCapture(event.pointerId); } }); diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx index 8112be5f5c..78eb6b7440 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx @@ -1,3 +1,4 @@ +'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; import type { BaseUIComponentProps } from '../../utils/types'; @@ -150,6 +151,7 @@ const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar( onPointerUp: handlePointerUp, style: { position: 'absolute', + touchAction: 'none', ...(orientation === 'vertical' && { top: 0, bottom: 0, @@ -181,6 +183,10 @@ namespace ScrollAreaScrollbar { } export interface Props extends BaseUIComponentProps<'div', OwnerState> { + /** + * The orientation of the scrollbar. + * @default 'vertical' + */ orientation?: 'vertical' | 'horizontal'; } } @@ -199,7 +205,8 @@ ScrollAreaScrollbar.propTypes /* remove-proptypes */ = { */ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** - * @ignore + * The orientation of the scrollbar. + * @default 'vertical' */ orientation: PropTypes.oneOf(['horizontal', 'vertical']), /** diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx index 1b00630104..ad0e925205 100644 --- a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx +++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx @@ -1,3 +1,4 @@ +'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; import type { BaseUIComponentProps } from '../../utils/types'; @@ -53,9 +54,6 @@ const ScrollAreaThumb = React.forwardRef(function ScrollAreaThumb( setScrolling(false); handlePointerUp(event); }, - style: { - touchAction: 'none', - }, }), }); From 1bdc90e5a94d00af5ac4cb2f6f79fdbd218b78c3 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 1 Oct 2024 07:32:47 +1000 Subject: [PATCH 03/72] Exports --- docs/data/pages.ts | 1 + 1 file changed, 1 insertion(+) 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' }, From ae816d727de5f90fa2ce62f50619a5ace09df9db Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 1 Oct 2024 08:06:48 +1000 Subject: [PATCH 04/72] Fix ref timing --- .../Viewport/ScrollAreaViewport.tsx | 103 +++++++++--------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx index 59c18aef37..ef6ce004b7 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx @@ -26,13 +26,14 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( forwardedRef: React.ForwardedRef, ) { const { render, className, ...otherProps } = props; + const { viewportRef, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, setScrolling } = useScrollAreaRootContext(); - const mergedRef = useForkRef(forwardedRef, viewportRef); - const timeoutRef = React.useRef(-1); + const mergedRef = useForkRef(forwardedRef, viewportRef); + const computeThumb = useEventCallback(() => { const viewportEl = viewportRef.current; const scrollbarYEl = scrollbarYRef.current; @@ -40,58 +41,60 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( const thumbYEl = thumbYRef.current; const thumbXEl = thumbXRef.current; - if (viewportEl) { - const scrollableContentHeight = viewportEl.scrollHeight; - const scrollableContentWidth = viewportEl.scrollWidth; - const viewportHeight = viewportEl.clientHeight; - const viewportWidth = viewportEl.clientWidth; - const scrollTop = viewportEl.scrollTop; - const scrollLeft = viewportEl.scrollLeft; - - // Handle Y (vertical) scroll - if (scrollbarYEl && thumbYEl) { - const thumbHeight = thumbYEl.offsetHeight; - const scrollbarStylesY = getComputedStyle(scrollbarYEl); - const paddingTop = parseFloat(scrollbarStylesY.paddingTop); - const paddingBottom = parseFloat(scrollbarStylesY.paddingBottom); - - const maxThumbOffsetY = - scrollbarYEl.offsetHeight - thumbHeight - (paddingTop + paddingBottom); - const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight); - const thumbOffsetY = scrollRatioY * maxThumbOffsetY; - - thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; - - scrollbarYEl.style.setProperty( - '--scroll-area-thumb-height', - `${(viewportHeight / scrollableContentHeight) * viewportHeight}px`, - ); - } - - // Handle X (horizontal) scroll - if (scrollbarXEl && thumbXEl) { - const thumbWidth = thumbXEl.offsetWidth; - const scrollbarStylesX = getComputedStyle(scrollbarXEl); - const paddingLeft = parseFloat(scrollbarStylesX.paddingLeft); - const paddingRight = parseFloat(scrollbarStylesX.paddingRight); - - const maxThumbOffsetX = - scrollbarXEl.offsetWidth - thumbWidth - (paddingLeft + paddingRight); - const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth); - const thumbOffsetX = scrollRatioX * maxThumbOffsetX; - - thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; - - scrollbarXEl.style.setProperty( - '--scroll-area-thumb-width', - `${(viewportWidth / scrollableContentWidth) * viewportWidth}px`, - ); - } + if (!viewportEl) { + return; + } + + const scrollableContentHeight = viewportEl.scrollHeight; + const scrollableContentWidth = viewportEl.scrollWidth; + const viewportHeight = viewportEl.clientHeight; + const viewportWidth = viewportEl.clientWidth; + const scrollTop = viewportEl.scrollTop; + const scrollLeft = viewportEl.scrollLeft; + + // Handle Y (vertical) scroll + if (scrollbarYEl && thumbYEl) { + const thumbHeight = thumbYEl.offsetHeight; + const scrollbarStylesY = getComputedStyle(scrollbarYEl); + const paddingTop = parseFloat(scrollbarStylesY.paddingTop); + const paddingBottom = parseFloat(scrollbarStylesY.paddingBottom); + + const maxThumbOffsetY = + scrollbarYEl.offsetHeight - thumbHeight - (paddingTop + paddingBottom); + const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight); + const thumbOffsetY = scrollRatioY * maxThumbOffsetY; + + thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; + + scrollbarYEl.style.setProperty( + '--scroll-area-thumb-height', + `${(viewportHeight / scrollableContentHeight) * viewportHeight}px`, + ); + } + + // Handle X (horizontal) scroll + if (scrollbarXEl && thumbXEl) { + const thumbWidth = thumbXEl.offsetWidth; + const scrollbarStylesX = getComputedStyle(scrollbarXEl); + const paddingLeft = parseFloat(scrollbarStylesX.paddingLeft); + const paddingRight = parseFloat(scrollbarStylesX.paddingRight); + + const maxThumbOffsetX = scrollbarXEl.offsetWidth - thumbWidth - (paddingLeft + paddingRight); + const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth); + const thumbOffsetX = scrollRatioX * maxThumbOffsetX; + + thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; + + scrollbarXEl.style.setProperty( + '--scroll-area-thumb-width', + `${(viewportWidth / scrollableContentWidth) * viewportWidth}px`, + ); } }); useEnhancedEffect(() => { - computeThumb(); + // Wait for the scrollbar-related refs to be set. + queueMicrotask(computeThumb); }, [computeThumb]); const { renderElement } = useComponentRenderer({ From 91f01e594d32c9ba94727ee8e99a0b607edbd9fe Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 1 Oct 2024 08:35:12 +1000 Subject: [PATCH 05/72] Handle content resizing and hidden scrollbars --- .../Viewport/ScrollAreaViewport.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx index ef6ce004b7..327f7f47c5 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx @@ -25,7 +25,7 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( props: ScrollAreaViewport.Props, forwardedRef: React.ForwardedRef, ) { - const { render, className, ...otherProps } = props; + const { render, className, children, ...otherProps } = props; const { viewportRef, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, setScrolling } = useScrollAreaRootContext(); @@ -33,6 +33,7 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( const timeoutRef = React.useRef(-1); const mergedRef = useForkRef(forwardedRef, viewportRef); + const tableWrapperRef = React.useRef(null); const computeThumb = useEventCallback(() => { const viewportEl = viewportRef.current; @@ -66,9 +67,17 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; + const scrollbarYHidden = viewportHeight >= scrollableContentHeight; + + if (scrollbarYHidden) { + scrollbarYEl.setAttribute('hidden', ''); + } + scrollbarYEl.style.setProperty( '--scroll-area-thumb-height', - `${(viewportHeight / scrollableContentHeight) * viewportHeight}px`, + scrollbarYHidden + ? '0px' + : `${(viewportHeight / scrollableContentHeight) * viewportHeight}px`, ); } @@ -85,9 +94,15 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; + const scrollbarXHidden = viewportWidth >= scrollableContentWidth; + + if (scrollbarXHidden) { + scrollbarXEl.setAttribute('hidden', ''); + } + scrollbarXEl.style.setProperty( '--scroll-area-thumb-width', - `${(viewportWidth / scrollableContentWidth) * viewportWidth}px`, + scrollbarXHidden ? '0px' : `${(viewportWidth / scrollableContentWidth) * viewportWidth}px`, ); } }); @@ -97,6 +112,19 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( queueMicrotask(computeThumb); }, [computeThumb]); + React.useEffect(() => { + if (!tableWrapperRef.current) { + return undefined; + } + + const ro = new ResizeObserver(computeThumb); + ro.observe(tableWrapperRef.current); + + return () => { + ro.disconnect(); + }; + }, [computeThumb]); + const { renderElement } = useComponentRenderer({ render: render ?? 'div', ref: mergedRef, @@ -117,6 +145,7 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( }, children: (
Date: Tue, 1 Oct 2024 10:51:27 +1000 Subject: [PATCH 06/72] Handle pinch-zoom --- .../mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx index 78eb6b7440..27b8a05ad1 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx @@ -56,7 +56,7 @@ const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar( const scrollbarEl = orientation === 'vertical' ? scrollbarYRef.current : scrollbarXRef.current; function handleWheel(event: WheelEvent) { - if (!viewportEl || !scrollbarEl) { + if (!viewportEl || !scrollbarEl || event.ctrlKey) { return; } From f58fe52b38d228a4321dafd964bb0e065e92750c Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 1 Oct 2024 11:38:12 +1000 Subject: [PATCH 07/72] Fix RTL handling --- .../src/ScrollArea/Root/ScrollAreaRoot.tsx | 1 + .../ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx index 10299a108a..65dd23c9d7 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx @@ -135,6 +135,7 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( className, ownerState, extraProps: mergeReactProps(otherProps, { + dir, onMouseEnter() { setHovering(true); }, diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx index 27b8a05ad1..6f7ee0be08 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx @@ -130,7 +130,6 @@ const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar( viewportRef.current.scrollTop = newScrollTop; } - // Handle X-axis (horizontal) scroll if (thumbXRef.current && scrollbarXRef.current && orientation === 'horizontal') { const thumbWidth = thumbXRef.current.offsetWidth; const trackRectX = scrollbarXRef.current.getBoundingClientRect(); @@ -141,7 +140,19 @@ const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar( const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth; const scrollRatioX = clickX / maxThumbOffsetX; - const newScrollLeft = scrollRatioX * (scrollableContentWidth - viewportWidth); + + let newScrollLeft: number; + if (dir === 'rtl') { + // In RTL, we need to invert the scroll direction + newScrollLeft = (1 - scrollRatioX) * (scrollableContentWidth - viewportWidth); + + // Adjust for browsers that use negative scrollLeft in RTL + if (viewportRef.current.scrollLeft <= 0) { + newScrollLeft = -newScrollLeft; + } + } else { + newScrollLeft = scrollRatioX * (scrollableContentWidth - viewportWidth); + } viewportRef.current.scrollLeft = newScrollLeft; } From 1d488fd6cf6385ed4e2ff38eb9798d70b1e1d2ee Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 2 Oct 2024 14:59:30 +1000 Subject: [PATCH 08/72] Support inlay scrollbars --- docs/data/api/scroll-area-corner.json | 3 ++ docs/data/api/scroll-area-root.json | 9 +++- docs/data/api/scroll-area-scrollbar.json | 3 ++ docs/data/api/scroll-area-thumb.json | 3 ++ docs/data/api/scroll-area-viewport.json | 12 ++++- .../ScrollAreaIntroduction/system/index.js | 2 +- .../ScrollAreaIntroduction/system/index.tsx | 2 +- .../scroll-area-root/scroll-area-root.json | 3 +- .../scroll-area-viewport.json | 5 +- .../Corner/ScrollAreaCorner.test.tsx | 13 +++++ .../ScrollArea/Root/ScrollAreaRoot.test.tsx | 13 +++++ .../src/ScrollArea/Root/ScrollAreaRoot.tsx | 31 ++++++++---- .../ScrollArea/Root/ScrollAreaRootContext.ts | 1 + .../Scrollbar/ScrollAreaScrollbar.test.tsx | 13 +++++ .../ScrollArea/Thumb/ScrollAreaThumb.test.tsx | 13 +++++ .../Viewport/ScrollAreaViewport.test.tsx | 13 +++++ .../Viewport/ScrollAreaViewport.tsx | 48 ++++++++++++++++--- 17 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx create mode 100644 packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx create mode 100644 packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx create mode 100644 packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx create mode 100644 packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx diff --git a/docs/data/api/scroll-area-corner.json b/docs/data/api/scroll-area-corner.json index dd2da4bb49..f8f8337dbd 100644 --- a/docs/data/api/scroll-area-corner.json +++ b/docs/data/api/scroll-area-corner.json @@ -8,7 +8,10 @@ "import * as 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": "", diff --git a/docs/data/api/scroll-area-root.json b/docs/data/api/scroll-area-root.json index 1b6d7d7847..afa42682ce 100644 --- a/docs/data/api/scroll-area-root.json +++ b/docs/data/api/scroll-area-root.json @@ -1,14 +1,21 @@ { "props": { "className": { "type": { "name": "union", "description": "func
| string" } }, - "render": { "type": { "name": "union", "description": "element
| func" } } + "render": { "type": { "name": "union", "description": "element
| func" } }, + "type": { + "type": { "name": "enum", "description": "'inlay'
| 'overlay'" }, + "default": "'overlay'" + } }, "name": "ScrollAreaRoot", "imports": [ "import * as 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": "", diff --git a/docs/data/api/scroll-area-scrollbar.json b/docs/data/api/scroll-area-scrollbar.json index 02b6d0d0af..dd6585e663 100644 --- a/docs/data/api/scroll-area-scrollbar.json +++ b/docs/data/api/scroll-area-scrollbar.json @@ -12,7 +12,10 @@ "import * as 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": "", diff --git a/docs/data/api/scroll-area-thumb.json b/docs/data/api/scroll-area-thumb.json index bb6c55e172..285c974dd9 100644 --- a/docs/data/api/scroll-area-thumb.json +++ b/docs/data/api/scroll-area-thumb.json @@ -8,7 +8,10 @@ "import * as 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": "", diff --git a/docs/data/api/scroll-area-viewport.json b/docs/data/api/scroll-area-viewport.json index c54dbb3cc9..b6f0d35843 100644 --- a/docs/data/api/scroll-area-viewport.json +++ b/docs/data/api/scroll-area-viewport.json @@ -1,14 +1,24 @@ { "props": { "className": { "type": { "name": "union", "description": "func
| string" } }, - "render": { "type": { "name": "union", "description": "element
| func" } } + "render": { "type": { "name": "union", "description": "element
| func" } }, + "scrollbarGutter": { + "type": { + "name": "enum", + "description": "'both-edges'
| 'none'
| 'stable'" + }, + "default": "'stable'" + } }, "name": "ScrollAreaViewport", "imports": [ "import * as 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": "", diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js index dda02d185a..0c678407b6 100644 --- a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js @@ -20,7 +20,7 @@ export default function ScrollAreaIntroduction() { }} > {data.map((value) => ( -
  • +
  • List item {value}
  • ))} diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx index dda02d185a..0c678407b6 100644 --- a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx @@ -20,7 +20,7 @@ export default function ScrollAreaIntroduction() { }} > {data.map((value) => ( -
  • +
  • List item {value}
  • ))} 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 index 4bc12cf1e0..859f861cdb 100644 --- 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 @@ -4,7 +4,8 @@ "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." } + "render": { "description": "A function to customize rendering of the component." }, + "type": { "description": "The type of scrollbars." } }, "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 index 4bc12cf1e0..79f65576cf 100644 --- 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 @@ -4,7 +4,10 @@ "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." } + "render": { "description": "A function to customize rendering of the component." }, + "scrollbarGutter": { + "description": "Determines whether to add a scrollbar gutter when using the inlay type." + } }, "classDescriptions": {} } 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..4fc6b50738 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { createRenderer } from '@mui/internal-test-utils'; +import { describeConformance } from '../../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render, + })); +}); 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..a2d5c976f3 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { createRenderer } from '@mui/internal-test-utils'; +import { describeConformance } from '../../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render, + })); +}); diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx index 65dd23c9d7..d67ef1e246 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx @@ -25,17 +25,16 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( props: ScrollAreaRoot.Props, forwardedRef: React.ForwardedRef, ) { - const { render, className, dir: dirProp, ...otherProps } = props; + const { render, className, dir: dirProp, type = 'overlay', ...otherProps } = props; const [hovering, setHovering] = React.useState(false); const [scrolling, setScrolling] = React.useState(false); - const viewportRef = React.useRef(null); - - const scrollbarYRef = React.useRef(null); - const scrollbarXRef = React.useRef(null); - const thumbYRef = React.useRef(null); - const thumbXRef = React.useRef(null); + const viewportRef = React.useRef(null); + const scrollbarYRef = React.useRef(null); + const scrollbarXRef = React.useRef(null); + const thumbYRef = React.useRef(null); + const thumbXRef = React.useRef(null); const thumbDraggingRef = React.useRef(false); const startYRef = React.useRef(0); @@ -151,6 +150,7 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( const contextValue = React.useMemo( () => ({ dir, + type, hovering, setHovering, scrolling, @@ -164,7 +164,7 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( handlePointerMove, handlePointerUp, }), - [dir, hovering, scrolling, handlePointerDown, handlePointerMove, handlePointerUp], + [dir, type, hovering, scrolling, handlePointerDown, handlePointerMove, handlePointerUp], ); return ( @@ -175,9 +175,15 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( }); namespace ScrollAreaRoot { - export interface OwnerState {} + export interface Props extends BaseUIComponentProps<'div', OwnerState> { + /** + * The type of scrollbars. + * @default 'overlay' + */ + type?: 'overlay' | 'inlay'; + } - export interface Props extends BaseUIComponentProps<'div', OwnerState> {} + export interface OwnerState {} } ScrollAreaRoot.propTypes /* remove-proptypes */ = { @@ -201,6 +207,11 @@ ScrollAreaRoot.propTypes /* remove-proptypes */ = { * A function to customize rendering of the component. */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * The type of scrollbars. + * @default 'overlay' + */ + type: PropTypes.oneOf(['inlay', 'overlay']), } as any; export { ScrollAreaRoot }; diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts index 367f613190..05a5b5bb12 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts @@ -2,6 +2,7 @@ import * as React from 'react'; export interface ScrollAreaRootContext { dir: string | undefined; + type: 'overlay' | 'inlay'; hovering: boolean; setHovering: React.Dispatch>; scrolling: boolean; diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx new file mode 100644 index 0000000000..3df7c13428 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { createRenderer } from '@mui/internal-test-utils'; +import { describeConformance } from '../../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render, + })); +}); diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx new file mode 100644 index 0000000000..cf9fe633db --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { createRenderer } from '@mui/internal-test-utils'; +import { describeConformance } from '../../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render, + })); +}); diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx new file mode 100644 index 0000000000..b6e7e2f705 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { createRenderer } from '@mui/internal-test-utils'; +import { describeConformance } from '../../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render, + })); +}); diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx index 327f7f47c5..02cea8a4d4 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx @@ -25,16 +25,32 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( props: ScrollAreaViewport.Props, forwardedRef: React.ForwardedRef, ) { - const { render, className, children, ...otherProps } = props; - - const { viewportRef, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, setScrolling } = - useScrollAreaRootContext(); + const { render, className, children, scrollbarGutter = 'stable', ...otherProps } = props; + + const { + type, + viewportRef, + scrollbarYRef, + scrollbarXRef, + thumbYRef, + thumbXRef, + setScrolling, + dir, + } = useScrollAreaRootContext(); const timeoutRef = React.useRef(-1); const mergedRef = useForkRef(forwardedRef, viewportRef); const tableWrapperRef = React.useRef(null); + const [paddingX, setPaddingX] = React.useState(0); + + useEnhancedEffect(() => { + if (scrollbarYRef.current) { + setPaddingX(parseFloat(getComputedStyle(scrollbarYRef.current).width)); + } + }, [scrollbarYRef, scrollbarXRef]); + const computeThumb = useEventCallback(() => { const viewportEl = viewportRef.current; const scrollbarYEl = scrollbarYRef.current; @@ -149,9 +165,16 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( style={{ minWidth: '100%', display: 'table', + ...(type === 'inlay' && + scrollbarGutter !== 'none' && { + [dir === 'rtl' ? 'paddingLeft' : 'paddingRight']: paddingX, + ...(scrollbarGutter === 'both-edges' && { + [dir === 'rtl' ? 'paddingRight' : 'paddingLeft']: paddingX, + }), + }), }} > - {props.children} + {children}
    ), }), @@ -161,9 +184,15 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( }); namespace ScrollAreaViewport { - export interface OwnerState {} + export interface Props extends BaseUIComponentProps<'div', OwnerState> { + /** + * Determines whether to add a scrollbar gutter when using the `inlay` type. + * @default 'stable' + */ + scrollbarGutter?: 'none' | 'stable' | 'both-edges'; + } - export interface Props extends BaseUIComponentProps<'div', OwnerState> {} + export interface OwnerState {} } ScrollAreaViewport.propTypes /* remove-proptypes */ = { @@ -183,6 +212,11 @@ ScrollAreaViewport.propTypes /* remove-proptypes */ = { * A function to customize rendering of the component. */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * Determines whether to add a scrollbar gutter when using the `inlay` type. + * @default 'stable' + */ + scrollbarGutter: PropTypes.oneOf(['both-edges', 'none', 'stable']), } as any; export { ScrollAreaViewport }; From fff37c5f483be3e6072aac3ef73b8147d0c3de17 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 2 Oct 2024 16:42:12 +1000 Subject: [PATCH 09/72] Corner logic --- docs/data/api/scroll-area-root.json | 7 ++ docs/data/api/scroll-area-viewport.json | 9 +- .../components/scroll-area/ScrollAreaInlay.js | 113 ++++++++++++++++++ .../scroll-area/ScrollAreaInlay.tsx | 113 ++++++++++++++++++ .../components/scroll-area/scroll-area.mdx | 44 +++++++ .../scroll-area-root/scroll-area-root.json | 3 + .../scroll-area-viewport.json | 5 +- .../Corner/ScrollAreaCorner.test.tsx | 4 +- .../ScrollArea/Corner/ScrollAreaCorner.tsx | 17 ++- .../src/ScrollArea/Root/ScrollAreaRoot.tsx | 54 ++++++++- .../ScrollArea/Root/ScrollAreaRootContext.ts | 4 + .../Scrollbar/ScrollAreaScrollbar.test.tsx | 4 +- .../ScrollArea/Thumb/ScrollAreaThumb.test.tsx | 8 +- .../Viewport/ScrollAreaViewport.test.tsx | 4 +- .../Viewport/ScrollAreaViewport.tsx | 76 ++++++++---- 15 files changed, 420 insertions(+), 45 deletions(-) create mode 100644 docs/data/components/scroll-area/ScrollAreaInlay.js create mode 100644 docs/data/components/scroll-area/ScrollAreaInlay.tsx diff --git a/docs/data/api/scroll-area-root.json b/docs/data/api/scroll-area-root.json index afa42682ce..9bcd9c91c1 100644 --- a/docs/data/api/scroll-area-root.json +++ b/docs/data/api/scroll-area-root.json @@ -1,6 +1,13 @@ { "props": { "className": { "type": { "name": "union", "description": "func
    | string" } }, + "gutter": { + "type": { + "name": "enum", + "description": "'both-edges'
    | 'none'
    | 'stable'" + }, + "default": "'stable'" + }, "render": { "type": { "name": "union", "description": "element
    | func" } }, "type": { "type": { "name": "enum", "description": "'inlay'
    | 'overlay'" }, diff --git a/docs/data/api/scroll-area-viewport.json b/docs/data/api/scroll-area-viewport.json index b6f0d35843..eec87f9e9f 100644 --- a/docs/data/api/scroll-area-viewport.json +++ b/docs/data/api/scroll-area-viewport.json @@ -1,14 +1,7 @@ { "props": { "className": { "type": { "name": "union", "description": "func
    | string" } }, - "render": { "type": { "name": "union", "description": "element
    | func" } }, - "scrollbarGutter": { - "type": { - "name": "enum", - "description": "'both-edges'
    | 'none'
    | 'stable'" - }, - "default": "'stable'" - } + "render": { "type": { "name": "union", "description": "element
    | func" } } }, "name": "ScrollAreaViewport", "imports": [ diff --git a/docs/data/components/scroll-area/ScrollAreaInlay.js b/docs/data/components/scroll-area/ScrollAreaInlay.js new file mode 100644 index 0000000000..1dcddb9cdd --- /dev/null +++ b/docs/data/components/scroll-area/ScrollAreaInlay.js @@ -0,0 +1,113 @@ +'use client'; +import * as React from 'react'; +import * as 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 ScrollAreaInlay() { + return ( + + +
    +

    User IDs

    +
      + {data.map((value) => ( +
    • + {value} +
    • + ))} +
    +
    +
    + + + + + + + +
    + ); +} + +const ScrollAreaRoot = styled(ScrollArea.Root)` + width: 250px; + height: 250px; + border-radius: 6px; + background: #f5f5f5; + overflow: hidden; +`; + +const ScrollAreaViewport = styled(ScrollArea.Viewport)` + width: 100%; + height: 100%; + scrollbar-width: none; +`; + +const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` + width: 10px; + height: calc(100% - var(--scroll-area-corner-height)); + background: rgb(220 220 220); + + &[data-orientation='horizontal'] { + width: calc(100% - var(--scroll-area-corner-width)); + height: 10px; + } +`; + +const ScrollAreaThumb = styled(ScrollArea.Thumb)` + background: rgb(180 180 180); + + &[data-orientation='vertical'] { + width: 10px; + height: var(--scroll-area-thumb-height, 0); + } + + &[data-orientation='horizontal'] { + width: var(--scroll-area-thumb-width, 0); + height: 10px; + } + + &: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)` + width: var(--scroll-area-corner-width, 10px); + height: var(--scroll-area-corner-height, 10px); + background: rgb(220 220 220); +`; diff --git a/docs/data/components/scroll-area/ScrollAreaInlay.tsx b/docs/data/components/scroll-area/ScrollAreaInlay.tsx new file mode 100644 index 0000000000..1dcddb9cdd --- /dev/null +++ b/docs/data/components/scroll-area/ScrollAreaInlay.tsx @@ -0,0 +1,113 @@ +'use client'; +import * as React from 'react'; +import * as 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 ScrollAreaInlay() { + return ( + + +
    +

    User IDs

    +
      + {data.map((value) => ( +
    • + {value} +
    • + ))} +
    +
    +
    + + + + + + + +
    + ); +} + +const ScrollAreaRoot = styled(ScrollArea.Root)` + width: 250px; + height: 250px; + border-radius: 6px; + background: #f5f5f5; + overflow: hidden; +`; + +const ScrollAreaViewport = styled(ScrollArea.Viewport)` + width: 100%; + height: 100%; + scrollbar-width: none; +`; + +const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` + width: 10px; + height: calc(100% - var(--scroll-area-corner-height)); + background: rgb(220 220 220); + + &[data-orientation='horizontal'] { + width: calc(100% - var(--scroll-area-corner-width)); + height: 10px; + } +`; + +const ScrollAreaThumb = styled(ScrollArea.Thumb)` + background: rgb(180 180 180); + + &[data-orientation='vertical'] { + width: 10px; + height: var(--scroll-area-thumb-height, 0); + } + + &[data-orientation='horizontal'] { + width: var(--scroll-area-thumb-width, 0); + height: 10px; + } + + &: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)` + width: var(--scroll-area-corner-width, 10px); + height: var(--scroll-area-corner-height, 10px); + background: rgb(220 220 220); +`; diff --git a/docs/data/components/scroll-area/scroll-area.mdx b/docs/data/components/scroll-area/scroll-area.mdx index c1565442db..afa08ac29f 100644 --- a/docs/data/components/scroll-area/scroll-area.mdx +++ b/docs/data/components/scroll-area/scroll-area.mdx @@ -36,6 +36,30 @@ waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/radio/ ``` +## Type + +The scrollbars can be either `overlay` (default) or `inlay`. The `overlay` type will overlay the scrollbar on top of the content, while the `inlay` type will push the content to make space for the scrollbar. + +```jsx + +``` + + + +### Gutter stability + +When using `inlay` scrollbars, the `gutter` prop can be used to keep a gutter present when the scrollbar is hidden. This is useful to prevent layout shift when the scrollbar is shown or hidden based on the content. + +Possible values match the CSS `scrollbar-gutter` property: + +- `stable` (default; padding added to account for vertical scrollbar) +- `both-edges` (symmetrical padding added for vertical scrollbar) +- `none` (layout shift allowed) + +```jsx + +``` + ## Styling The thumb element can be styled using `--scroll-area-thumb-height` and `--scroll-area-thumb-width` CSS variables. @@ -60,3 +84,23 @@ The Scrollbar element can be shown conditionally based on the user's interaction opacity: 1; } ``` + +### Corner + +When using `inlay` scrollbars, the scrollbar elements can adjust their size to account for a corner using the `--scroll-area-corner-width` and `--scroll-area-corner-height` CSS variables: + +```jsx + + + +``` + +```css +.ScrollAreaScrollbar[data-orientation='vertical'] { + height: calc(100% - var(--scroll-area-corner-height)); +} + +.ScrollAreaScrollbar[data-orientation='horizontal'] { + width: calc(100% - var(--scroll-area-corner-width)); +} +``` 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 index 859f861cdb..4d5966ed56 100644 --- 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 @@ -4,6 +4,9 @@ "className": { "description": "Class names applied to the element or a function that returns them based on the component's state." }, + "gutter": { + "description": "Determines the permanent scrollbar gutter when using the inlay type to prevent layout shifts when the scrollbar is hidden/shown." + }, "render": { "description": "A function to customize rendering of the component." }, "type": { "description": "The type of scrollbars." } }, 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 index 79f65576cf..4bc12cf1e0 100644 --- 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 @@ -4,10 +4,7 @@ "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." }, - "scrollbarGutter": { - "description": "Determines whether to add a scrollbar gutter when using the inlay type." - } + "render": { "description": "A function to customize rendering of the component." } }, "classDescriptions": {} } diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx index 4fc6b50738..137428d40c 100644 --- a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx @@ -8,6 +8,8 @@ describe('', () => { describeConformance(, () => ({ refInstanceof: window.HTMLDivElement, - render, + render(node) { + return render({node}); + }, })); }); diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx index 7206da806a..e14d978076 100644 --- a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx @@ -3,6 +3,9 @@ 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 = {}; @@ -22,12 +25,22 @@ const ScrollAreaCorner = React.forwardRef(function ScrollAreaCorner( ) { const { render, className, ...otherProps } = props; + const { dir, cornerRef } = useScrollAreaRootContext(); + + const mergedRef = useForkRef(cornerRef, forwardedRef); + const { renderElement } = useComponentRenderer({ render: render ?? 'div', - ref: forwardedRef, + ref: mergedRef, className, ownerState, - extraProps: otherProps, + extraProps: mergeReactProps(otherProps, { + style: { + position: 'absolute', + bottom: 0, + [dir === 'rtl' ? 'left' : 'right']: 0, + }, + }), }); return renderElement(); diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx index d67ef1e246..fea238d1c4 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx @@ -25,16 +25,28 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( props: ScrollAreaRoot.Props, forwardedRef: React.ForwardedRef, ) { - const { render, className, dir: dirProp, type = 'overlay', ...otherProps } = props; + const { + render, + className, + dir: dirProp, + type = 'overlay', + gutter = 'stable', + ...otherProps + } = props; const [hovering, setHovering] = React.useState(false); const [scrolling, setScrolling] = React.useState(false); + const [cornerSize, setCornerSize] = React.useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); const viewportRef = React.useRef(null); const scrollbarYRef = React.useRef(null); const scrollbarXRef = React.useRef(null); const thumbYRef = React.useRef(null); const thumbXRef = React.useRef(null); + const cornerRef = React.useRef(null); const thumbDraggingRef = React.useRef(false); const startYRef = React.useRef(0); @@ -43,6 +55,12 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( const startScrollLeftRef = React.useRef(0); const currentOrientationRef = React.useRef<'vertical' | 'horizontal'>('vertical'); + useEnhancedEffect(() => { + const width = scrollbarYRef.current?.offsetWidth || 0; + const height = scrollbarXRef.current?.offsetHeight || 0; + setCornerSize({ width, height }); + }, []); + const [dir, setDir] = useControlled({ controlled: dirProp, default: dirProp ?? 'ltr', @@ -143,6 +161,12 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( }, style: { position: 'relative', + ...(cornerSize.width > 0 && { + '--scroll-area-corner-width': `${cornerSize.width}px`, + }), + ...(cornerSize.height > 0 && { + '--scroll-area-corner-height': `${cornerSize.height}px`, + }), }, }), }); @@ -151,6 +175,9 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( () => ({ dir, type, + gutter, + cornerSize, + setCornerSize, hovering, setHovering, scrolling, @@ -160,11 +187,22 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( thumbYRef, scrollbarXRef, thumbXRef, + cornerRef, handlePointerDown, handlePointerMove, handlePointerUp, }), - [dir, type, hovering, scrolling, handlePointerDown, handlePointerMove, handlePointerUp], + [ + dir, + type, + gutter, + cornerSize, + hovering, + scrolling, + handlePointerDown, + handlePointerMove, + handlePointerUp, + ], ); return ( @@ -181,6 +219,12 @@ namespace ScrollAreaRoot { * @default 'overlay' */ type?: 'overlay' | 'inlay'; + /** + * Determines the permanent scrollbar gutter when using the `inlay` type to prevent layout + * shifts when the scrollbar is hidden/shown. + * @default 'stable' + */ + gutter?: 'none' | 'stable' | 'both-edges'; } export interface OwnerState {} @@ -203,6 +247,12 @@ ScrollAreaRoot.propTypes /* remove-proptypes */ = { * @ignore */ dir: PropTypes.string, + /** + * Determines the permanent scrollbar gutter when using the `inlay` type to prevent layout + * shifts when the scrollbar is hidden/shown. + * @default 'stable' + */ + gutter: PropTypes.oneOf(['both-edges', 'none', 'stable']), /** * A function to customize rendering of the component. */ diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts index 05a5b5bb12..fd9ef63491 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts @@ -3,6 +3,9 @@ import * as React from 'react'; export interface ScrollAreaRootContext { dir: string | undefined; type: 'overlay' | 'inlay'; + gutter?: 'stable' | 'both-edges' | 'none'; + cornerSize: { width: number; height: number }; + setCornerSize: React.Dispatch>; hovering: boolean; setHovering: React.Dispatch>; scrolling: boolean; @@ -12,6 +15,7 @@ export interface ScrollAreaRootContext { thumbYRef: React.RefObject; scrollbarXRef: React.RefObject; thumbXRef: React.RefObject; + cornerRef: React.RefObject; handlePointerDown: (event: React.PointerEvent) => void; handlePointerMove: (event: React.PointerEvent) => void; handlePointerUp: (event: React.PointerEvent) => void; diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx index 3df7c13428..a254828a00 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx @@ -8,6 +8,8 @@ describe('', () => { describeConformance(, () => ({ refInstanceof: window.HTMLDivElement, - render, + render(node) { + return render({node}); + }, })); }); diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx index cf9fe633db..8ba07e6921 100644 --- a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx +++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx @@ -8,6 +8,12 @@ describe('', () => { describeConformance(, () => ({ refInstanceof: window.HTMLDivElement, - render, + render(node) { + return render( + + {node} + , + ); + }, })); }); diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx index b6e7e2f705..d7e02e4bd7 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx @@ -8,6 +8,8 @@ describe('', () => { describeConformance(, () => ({ refInstanceof: window.HTMLDivElement, - render, + render(node) { + return render({node}); + }, })); }); diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx index 02cea8a4d4..a0bd2b2252 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx @@ -25,7 +25,7 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( props: ScrollAreaViewport.Props, forwardedRef: React.ForwardedRef, ) { - const { render, className, children, scrollbarGutter = 'stable', ...otherProps } = props; + const { render, className, children, ...otherProps } = props; const { type, @@ -34,8 +34,11 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( scrollbarXRef, thumbYRef, thumbXRef, + cornerRef, setScrolling, dir, + gutter, + setCornerSize, } = useScrollAreaRootContext(); const timeoutRef = React.useRef(-1); @@ -44,6 +47,8 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( const tableWrapperRef = React.useRef(null); const [paddingX, setPaddingX] = React.useState(0); + const [hiddenX, setHiddenX] = React.useState(false); + const [hiddenY, setHiddenY] = React.useState(false); useEnhancedEffect(() => { if (scrollbarYRef.current) { @@ -57,6 +62,7 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( const scrollbarXEl = scrollbarXRef.current; const thumbYEl = thumbYRef.current; const thumbXEl = thumbXRef.current; + const cornerEl = cornerRef.current; if (!viewportEl) { return; @@ -69,6 +75,9 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( const scrollTop = viewportEl.scrollTop; const scrollLeft = viewportEl.scrollLeft; + const scrollbarYHidden = viewportHeight >= scrollableContentHeight; + const scrollbarXHidden = viewportWidth >= scrollableContentWidth; + // Handle Y (vertical) scroll if (scrollbarYEl && thumbYEl) { const thumbHeight = thumbYEl.offsetHeight; @@ -83,12 +92,14 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; - const scrollbarYHidden = viewportHeight >= scrollableContentHeight; - if (scrollbarYHidden) { scrollbarYEl.setAttribute('hidden', ''); + } else { + scrollbarYEl.removeAttribute('hidden'); } + setHiddenY(scrollbarYHidden); + scrollbarYEl.style.setProperty( '--scroll-area-thumb-height', scrollbarYHidden @@ -110,17 +121,29 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; - const scrollbarXHidden = viewportWidth >= scrollableContentWidth; - if (scrollbarXHidden) { scrollbarXEl.setAttribute('hidden', ''); + } else { + scrollbarXEl.removeAttribute('hidden'); } + setHiddenX(scrollbarXHidden); + scrollbarXEl.style.setProperty( '--scroll-area-thumb-width', scrollbarXHidden ? '0px' : `${(viewportWidth / scrollableContentWidth) * viewportWidth}px`, ); } + + if (cornerEl) { + if (scrollbarXHidden || scrollbarYHidden) { + cornerEl.setAttribute('hidden', ''); + setCornerSize({ width: 0, height: 0 }); + } else if (!scrollbarXHidden && !scrollbarYHidden) { + cornerEl.removeAttribute('hidden'); + setCornerSize({ width: cornerEl.offsetWidth, height: cornerEl.offsetHeight }); + } + } }); useEnhancedEffect(() => { @@ -141,6 +164,26 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( }; }, [computeThumb]); + const wrapperStyles: React.CSSProperties = {}; + + if (type === 'inlay') { + if (!hiddenY) { + wrapperStyles.paddingRight = paddingX; + } + if (!hiddenX) { + wrapperStyles.paddingBottom = paddingX; + } + + if (hiddenY) { + if (gutter === 'stable') { + wrapperStyles[dir === 'rtl' ? 'paddingLeft' : 'paddingRight'] = paddingX; + } else if (gutter === 'both-edges') { + wrapperStyles.paddingLeft = paddingX; + wrapperStyles.paddingRight = paddingX; + } + } + } + const { renderElement } = useComponentRenderer({ render: render ?? 'div', ref: mergedRef, @@ -163,15 +206,9 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport(
    {children} @@ -184,13 +221,7 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( }); namespace ScrollAreaViewport { - export interface Props extends BaseUIComponentProps<'div', OwnerState> { - /** - * Determines whether to add a scrollbar gutter when using the `inlay` type. - * @default 'stable' - */ - scrollbarGutter?: 'none' | 'stable' | 'both-edges'; - } + export interface Props extends BaseUIComponentProps<'div', OwnerState> {} export interface OwnerState {} } @@ -212,11 +243,6 @@ ScrollAreaViewport.propTypes /* remove-proptypes */ = { * A function to customize rendering of the component. */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), - /** - * Determines whether to add a scrollbar gutter when using the `inlay` type. - * @default 'stable' - */ - scrollbarGutter: PropTypes.oneOf(['both-edges', 'none', 'stable']), } as any; export { ScrollAreaViewport }; From ad746d38cb31c1a71dcb4ba84cefc2d161f022cf Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 3 Oct 2024 18:01:44 +1000 Subject: [PATCH 10/72] Check for ResizeObserver existence --- .../mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx index a0bd2b2252..9386ac5960 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx @@ -152,7 +152,7 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( }, [computeThumb]); React.useEffect(() => { - if (!tableWrapperRef.current) { + if (!tableWrapperRef.current || typeof ResizeObserver === 'undefined') { return undefined; } From 42a42615e1adc756ffebffaca51b1ad7c0133049 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 7 Oct 2024 13:40:39 +1100 Subject: [PATCH 11/72] Refactor into hooks and add tests --- .../Corner/ScrollAreaCorner.test.tsx | 26 +- .../ScrollArea/Root/ScrollAreaRoot.test.tsx | 193 ++++++++++++++- .../src/ScrollArea/Root/ScrollAreaRoot.tsx | 174 +------------- .../src/ScrollArea/Root/useScrollAreaRoot.ts | 200 ++++++++++++++++ .../Scrollbar/ScrollAreaScrollbar.tsx | 138 +---------- .../Scrollbar/useScrollAreaScrollbar.ts | 165 +++++++++++++ .../src/ScrollArea/Thumb/ScrollAreaThumb.tsx | 4 +- .../Viewport/ScrollAreaViewport.tsx | 190 +-------------- .../Viewport/useScrollAreaViewport.tsx | 222 ++++++++++++++++++ 9 files changed, 826 insertions(+), 486 deletions(-) create mode 100644 packages/mui-base/src/ScrollArea/Root/useScrollAreaRoot.ts create mode 100644 packages/mui-base/src/ScrollArea/Scrollbar/useScrollAreaScrollbar.ts create mode 100644 packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx index 137428d40c..a2a62c0dbc 100644 --- a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx @@ -1,8 +1,11 @@ import * as React from 'react'; import * as ScrollArea from '@base_ui/react/ScrollArea'; -import { createRenderer } from '@mui/internal-test-utils'; +import { expect } from 'chai'; +import { createRenderer } from '#test-utils'; import { describeConformance } from '../../../test/describeConformance'; +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + describe('', () => { const { render } = createRenderer(); @@ -12,4 +15,25 @@ describe('', () => { return render({node}); }, })); + + it('should apply correct corner size when both scrollbars are present', async function test() { + if (isJSDOM) { + this.skip(); + } + + const { getByTestId } = await render( + + + + + + , + ); + + const corner = getByTestId('corner'); + const style = window.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/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx index a2d5c976f3..67ed2c2b8f 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -1,8 +1,17 @@ import * as React from 'react'; import * as ScrollArea from '@base_ui/react/ScrollArea'; -import { createRenderer } from '@mui/internal-test-utils'; +import { screen } 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 = 15; + describe('', () => { const { render } = createRenderer(); @@ -10,4 +19,186 @@ describe('', () => { refInstanceof: window.HTMLDivElement, render, })); + + it('should correctly set thumb height and width based on scrollable content', async function test() { + if (isJSDOM) { + this.skip(); + } + + const { getByTestId } = await render( + + +
    + + + + + + + + , + ); + + const verticalThumb = getByTestId('vertical-thumb'); + const horizontalThumb = 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: type', () => { + it('should not add padding for overlay scrollbars', async function test() { + if (isJSDOM) { + this.skip(); + } + + await render( + + +
    + + + + , + ); + + const tableWrapper = screen.getByTestId('viewport').firstElementChild!; + + const style = getComputedStyle(tableWrapper); + + expect(style.paddingLeft).to.equal('0px'); + expect(style.paddingRight).to.equal('0px'); + expect(style.paddingBottom).to.equal('0px'); + }); + + it('should add padding for inlay scrollbars', async function test() { + if (isJSDOM) { + this.skip(); + } + + await render( + + +
    + + + + , + ); + + const tableWrapper = screen.getByTestId('viewport').firstElementChild!; + + const style = getComputedStyle(tableWrapper); + + expect(style.paddingRight).to.equal(`${SCROLLBAR_WIDTH}px`); + expect(style.paddingBottom).to.equal(`${SCROLLBAR_HEIGHT}px`); + }); + }); + + describe('prop: dir', () => { + it('should adjust padding for rtl', async function test() { + if (isJSDOM) { + this.skip(); + } + + await render( + + +
    + + + + , + ); + + const tableWrapper = screen.getByTestId('viewport').firstElementChild!; + + const style = getComputedStyle(tableWrapper); + + expect(style.paddingLeft).not.to.equal(`${SCROLLBAR_WIDTH}px`); + expect(style.paddingRight).to.equal(`${SCROLLBAR_WIDTH}px`); + expect(style.paddingBottom).to.equal(`${SCROLLBAR_HEIGHT}px`); + }); + }); + + describe('prop: gutter', () => { + it('should adjust padding for gutter: both-edges', async function test() { + if (isJSDOM) { + this.skip(); + } + + await render( + + + + + , + ); + + const tableWrapper = screen.getByTestId('viewport').firstElementChild!; + + const style = getComputedStyle(tableWrapper); + + expect(style.paddingLeft).to.equal(`${SCROLLBAR_WIDTH}px`); + expect(style.paddingRight).to.equal(`${SCROLLBAR_WIDTH}px`); + }); + + it('should not add padding for gutter: none', async function test() { + if (isJSDOM) { + this.skip(); + } + + await render( + + + + + , + ); + + const tableWrapper = screen.getByTestId('viewport').firstElementChild!; + + const style = getComputedStyle(tableWrapper); + + expect(style.paddingLeft).to.equal('0px'); + expect(style.paddingRight).to.equal('0px'); + }); + }); }); diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx index fea238d1c4..f9506804b9 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx @@ -3,11 +3,8 @@ 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 { ScrollAreaRootContext } from './ScrollAreaRootContext'; -import { useEventCallback } from '../../utils/useEventCallback'; -import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; -import { useControlled } from '../../utils/useControlled'; +import { useScrollAreaRoot } from './useScrollAreaRoot'; const ownerState = {}; @@ -25,150 +22,17 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( props: ScrollAreaRoot.Props, forwardedRef: React.ForwardedRef, ) { - const { - render, - className, - dir: dirProp, - type = 'overlay', - gutter = 'stable', - ...otherProps - } = props; - - const [hovering, setHovering] = React.useState(false); - const [scrolling, setScrolling] = React.useState(false); - const [cornerSize, setCornerSize] = React.useState<{ width: number; height: number }>({ - width: 0, - height: 0, - }); - - const viewportRef = React.useRef(null); - const scrollbarYRef = React.useRef(null); - const scrollbarXRef = React.useRef(null); - const thumbYRef = React.useRef(null); - const thumbXRef = React.useRef(null); - const cornerRef = React.useRef(null); - - const thumbDraggingRef = React.useRef(false); - const startYRef = React.useRef(0); - const startXRef = React.useRef(0); - const startScrollTopRef = React.useRef(0); - const startScrollLeftRef = React.useRef(0); - const currentOrientationRef = React.useRef<'vertical' | 'horizontal'>('vertical'); - - useEnhancedEffect(() => { - const width = scrollbarYRef.current?.offsetWidth || 0; - const height = scrollbarXRef.current?.offsetHeight || 0; - setCornerSize({ width, height }); - }, []); - - const [dir, setDir] = useControlled({ - controlled: dirProp, - default: dirProp ?? 'ltr', - name: 'ScrollArea', - }); - - useEnhancedEffect(() => { - if (viewportRef.current) { - setDir(getComputedStyle(viewportRef.current).direction); - } - }, [setDir]); - - const handlePointerDown = useEventCallback((event: React.PointerEvent) => { - thumbDraggingRef.current = true; - startYRef.current = event.clientY; - startXRef.current = event.clientX; - currentOrientationRef.current = event.currentTarget.getAttribute('data-orientation') as - | 'vertical' - | 'horizontal'; - - if (viewportRef.current) { - startScrollTopRef.current = viewportRef.current.scrollTop; - startScrollLeftRef.current = viewportRef.current.scrollLeft; - } - if (thumbYRef.current && currentOrientationRef.current === 'vertical') { - thumbYRef.current.setPointerCapture(event.pointerId); - } - if (thumbXRef.current && currentOrientationRef.current === 'horizontal') { - thumbXRef.current.setPointerCapture(event.pointerId); - } - }); - - const handlePointerMove = useEventCallback((event: React.PointerEvent) => { - if (!thumbDraggingRef.current) { - return; - } + const { render, className, dir, type = 'overlay', gutter = 'stable', ...otherProps } = props; - const deltaY = event.clientY - startYRef.current; - const deltaX = event.clientX - startXRef.current; - - if (viewportRef.current) { - const scrollableContentHeight = viewportRef.current.scrollHeight; - const viewportHeight = viewportRef.current.clientHeight; - const scrollableContentWidth = viewportRef.current.scrollWidth; - const viewportWidth = viewportRef.current.clientWidth; - - if ( - thumbYRef.current && - scrollbarYRef.current && - currentOrientationRef.current === 'vertical' - ) { - const thumbHeight = thumbYRef.current.offsetHeight; - const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight; - const scrollRatioY = deltaY / maxThumbOffsetY; - viewportRef.current.scrollTop = - startScrollTopRef.current + scrollRatioY * (scrollableContentHeight - viewportHeight); - event.preventDefault(); - } - - if ( - thumbXRef.current && - scrollbarXRef.current && - currentOrientationRef.current === 'horizontal' - ) { - const thumbWidth = thumbXRef.current.offsetWidth; - const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth; - const scrollRatioX = deltaX / maxThumbOffsetX; - viewportRef.current.scrollLeft = - startScrollLeftRef.current + scrollRatioX * (scrollableContentWidth - viewportWidth); - event.preventDefault(); - } - } - }); - - const handlePointerUp = useEventCallback((event: React.PointerEvent) => { - thumbDraggingRef.current = false; - - if (thumbYRef.current && currentOrientationRef.current === 'vertical') { - thumbYRef.current.releasePointerCapture(event.pointerId); - } - if (thumbXRef.current && currentOrientationRef.current === 'horizontal') { - thumbXRef.current.releasePointerCapture(event.pointerId); - } - }); + const scrollAreaRoot = useScrollAreaRoot({ dir, type, gutter }); const { renderElement } = useComponentRenderer({ + propGetter: scrollAreaRoot.getRootProps, render: render ?? 'div', ref: forwardedRef, className, ownerState, - extraProps: mergeReactProps(otherProps, { - dir, - onMouseEnter() { - setHovering(true); - }, - onMouseLeave() { - setHovering(false); - }, - style: { - position: 'relative', - ...(cornerSize.width > 0 && { - '--scroll-area-corner-width': `${cornerSize.width}px`, - }), - ...(cornerSize.height > 0 && { - '--scroll-area-corner-height': `${cornerSize.height}px`, - }), - }, - }), + extraProps: otherProps, }); const contextValue = React.useMemo( @@ -176,33 +40,9 @@ const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot( dir, type, gutter, - cornerSize, - setCornerSize, - hovering, - setHovering, - scrolling, - setScrolling, - viewportRef, - scrollbarYRef, - thumbYRef, - scrollbarXRef, - thumbXRef, - cornerRef, - handlePointerDown, - handlePointerMove, - handlePointerUp, + ...scrollAreaRoot, }), - [ - dir, - type, - gutter, - cornerSize, - hovering, - scrolling, - handlePointerDown, - handlePointerMove, - handlePointerUp, - ], + [dir, gutter, type, scrollAreaRoot], ); return ( diff --git a/packages/mui-base/src/ScrollArea/Root/useScrollAreaRoot.ts b/packages/mui-base/src/ScrollArea/Root/useScrollAreaRoot.ts new file mode 100644 index 0000000000..65914b9096 --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Root/useScrollAreaRoot.ts @@ -0,0 +1,200 @@ +import * as React from 'react'; +import { useControlled } from '../../utils/useControlled'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { ownerWindow } from '../../utils/owner'; + +export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) { + const { dir: dirProp } = params; + + const [hovering, setHovering] = React.useState(false); + const [scrolling, setScrolling] = React.useState(false); + const [cornerSize, setCornerSize] = React.useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + + const viewportRef = React.useRef(null); + const scrollbarYRef = React.useRef(null); + const scrollbarXRef = React.useRef(null); + const thumbYRef = React.useRef(null); + const thumbXRef = React.useRef(null); + const cornerRef = React.useRef(null); + + const thumbDraggingRef = React.useRef(false); + const startYRef = React.useRef(0); + const startXRef = React.useRef(0); + const startScrollTopRef = React.useRef(0); + const startScrollLeftRef = React.useRef(0); + const currentOrientationRef = React.useRef<'vertical' | 'horizontal'>('vertical'); + + React.useEffect(() => { + if (!viewportRef.current) { + return undefined; + } + + const win = ownerWindow(viewportRef.current); + + function handleResize() { + if (viewportRef.current) { + const width = scrollbarYRef.current?.offsetWidth || 0; + const height = scrollbarXRef.current?.offsetHeight || 0; + setCornerSize({ width, height }); + } + } + + handleResize(); + + win.addEventListener('resize', handleResize); + return () => { + win.removeEventListener('resize', handleResize); + }; + }, []); + + const [dir, setDir] = useControlled({ + controlled: dirProp, + default: dirProp ?? 'ltr', + name: 'ScrollArea', + }); + + useEnhancedEffect(() => { + if (viewportRef.current) { + setDir(getComputedStyle(viewportRef.current).direction); + } + }, [setDir]); + + const handlePointerDown = useEventCallback((event: React.PointerEvent) => { + thumbDraggingRef.current = true; + startYRef.current = event.clientY; + startXRef.current = event.clientX; + currentOrientationRef.current = event.currentTarget.getAttribute('data-orientation') as + | 'vertical' + | 'horizontal'; + + if (viewportRef.current) { + startScrollTopRef.current = viewportRef.current.scrollTop; + startScrollLeftRef.current = viewportRef.current.scrollLeft; + } + if (thumbYRef.current && currentOrientationRef.current === 'vertical') { + thumbYRef.current.setPointerCapture(event.pointerId); + } + if (thumbXRef.current && currentOrientationRef.current === 'horizontal') { + thumbXRef.current.setPointerCapture(event.pointerId); + } + }); + + const handlePointerMove = useEventCallback((event: React.PointerEvent) => { + if (!thumbDraggingRef.current) { + return; + } + + const deltaY = event.clientY - startYRef.current; + const deltaX = event.clientX - startXRef.current; + + if (viewportRef.current) { + const scrollableContentHeight = viewportRef.current.scrollHeight; + const viewportHeight = viewportRef.current.clientHeight; + const scrollableContentWidth = viewportRef.current.scrollWidth; + const viewportWidth = viewportRef.current.clientWidth; + + if ( + thumbYRef.current && + scrollbarYRef.current && + currentOrientationRef.current === 'vertical' + ) { + const thumbHeight = thumbYRef.current.offsetHeight; + const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight; + const scrollRatioY = deltaY / maxThumbOffsetY; + viewportRef.current.scrollTop = + startScrollTopRef.current + scrollRatioY * (scrollableContentHeight - viewportHeight); + event.preventDefault(); + } + + if ( + thumbXRef.current && + scrollbarXRef.current && + currentOrientationRef.current === 'horizontal' + ) { + const thumbWidth = thumbXRef.current.offsetWidth; + const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth; + const scrollRatioX = deltaX / maxThumbOffsetX; + viewportRef.current.scrollLeft = + startScrollLeftRef.current + scrollRatioX * (scrollableContentWidth - viewportWidth); + event.preventDefault(); + } + } + }); + + const handlePointerUp = useEventCallback((event: React.PointerEvent) => { + thumbDraggingRef.current = false; + + if (thumbYRef.current && currentOrientationRef.current === 'vertical') { + thumbYRef.current.releasePointerCapture(event.pointerId); + } + if (thumbXRef.current && currentOrientationRef.current === 'horizontal') { + thumbXRef.current.releasePointerCapture(event.pointerId); + } + }); + + const getRootProps = React.useCallback( + (externalProps = {}) => + mergeReactProps<'div'>(externalProps, { + dir, + onMouseEnter() { + setHovering(true); + }, + onMouseLeave() { + setHovering(false); + }, + style: { + position: 'relative', + ...(cornerSize.width > 0 && { + '--scroll-area-corner-width': `${cornerSize.width}px`, + }), + ...(cornerSize.height > 0 && { + '--scroll-area-corner-height': `${cornerSize.height}px`, + }), + }, + }), + [cornerSize, dir], + ); + + return React.useMemo( + () => ({ + getRootProps, + handlePointerDown, + handlePointerMove, + handlePointerUp, + cornerSize, + setCornerSize, + cornerRef, + scrolling, + setScrolling, + hovering, + setHovering, + viewportRef, + scrollbarYRef, + scrollbarXRef, + thumbYRef, + thumbXRef, + }), + [ + cornerSize, + getRootProps, + handlePointerDown, + handlePointerMove, + handlePointerUp, + hovering, + scrolling, + ], + ); +} + +namespace useScrollAreaRoot { + export interface Parameters { + dir: string | undefined; + gutter: string; + type: string; + } +} diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx index 6f7ee0be08..e773ecdc68 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx @@ -3,10 +3,10 @@ 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'; import { ScrollAreaScrollbarContext } from './ScrollAreaScrollbarContext'; +import { useScrollAreaScrollbar } from './useScrollAreaScrollbar'; /** * @@ -24,18 +24,7 @@ const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar( ) { const { render, className, orientation = 'vertical', ...otherProps } = props; - const { - dir, - hovering, - scrolling, - scrollbarYRef, - scrollbarXRef, - viewportRef, - thumbYRef, - thumbXRef, - handlePointerDown, - handlePointerUp, - } = useScrollAreaRootContext(); + const { hovering, scrolling, scrollbarYRef, scrollbarXRef } = useScrollAreaRootContext(); const mergedRef = useForkRef( forwardedRef, @@ -51,130 +40,17 @@ const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar( [hovering, scrolling, orientation], ); - React.useEffect(() => { - const viewportEl = viewportRef.current; - const scrollbarEl = orientation === 'vertical' ? scrollbarYRef.current : scrollbarXRef.current; - - function handleWheel(event: WheelEvent) { - if (!viewportEl || !scrollbarEl || event.ctrlKey) { - return; - } - - if (orientation === 'vertical') { - if (viewportEl.scrollTop === 0 && event.deltaY < 0) { - return; - } - } else if (viewportEl.scrollLeft === 0 && event.deltaX < 0) { - return; - } - - if (orientation === 'vertical') { - if ( - viewportEl.scrollTop === viewportEl.scrollHeight - viewportEl.clientHeight && - event.deltaY > 0 - ) { - return; - } - } else if ( - viewportEl.scrollLeft === viewportEl.scrollWidth - viewportEl.clientWidth && - event.deltaX > 0 - ) { - return; - } - - event.preventDefault(); - - if (orientation === 'vertical') { - viewportEl.scrollTop += event.deltaY; - } else { - viewportEl.scrollLeft += event.deltaX; - } - } - - scrollbarEl?.addEventListener('wheel', handleWheel, { passive: false }); - - return () => { - scrollbarEl?.removeEventListener('wheel', handleWheel); - }; - }, [orientation, scrollbarXRef, scrollbarYRef, thumbYRef, viewportRef]); + const { getScrollbarProps } = useScrollAreaScrollbar({ + orientation, + }); const { renderElement } = useComponentRenderer({ + propGetter: getScrollbarProps, render: render ?? 'div', ref: mergedRef, className, ownerState, - extraProps: mergeReactProps<'div'>(otherProps, { - onPointerDown(event) { - // Ignore clicks on thumb - if (event.currentTarget !== event.target) { - return; - } - - if (!viewportRef.current) { - return; - } - - // Handle Y-axis (vertical) scroll - if (thumbYRef.current && scrollbarYRef.current && orientation === 'vertical') { - const thumbHeight = thumbYRef.current.offsetHeight; - const trackRectY = scrollbarYRef.current.getBoundingClientRect(); - const clickY = event.clientY - trackRectY.top - thumbHeight / 2; - - const scrollableContentHeight = viewportRef.current.scrollHeight; - const viewportHeight = viewportRef.current.clientHeight; - - const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight; - const scrollRatioY = clickY / maxThumbOffsetY; - const newScrollTop = scrollRatioY * (scrollableContentHeight - viewportHeight); - - viewportRef.current.scrollTop = newScrollTop; - } - - if (thumbXRef.current && scrollbarXRef.current && orientation === 'horizontal') { - const thumbWidth = thumbXRef.current.offsetWidth; - const trackRectX = scrollbarXRef.current.getBoundingClientRect(); - const clickX = event.clientX - trackRectX.left - thumbWidth / 2; - - const scrollableContentWidth = viewportRef.current.scrollWidth; - const viewportWidth = viewportRef.current.clientWidth; - - const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth; - const scrollRatioX = clickX / maxThumbOffsetX; - - let newScrollLeft: number; - if (dir === 'rtl') { - // In RTL, we need to invert the scroll direction - newScrollLeft = (1 - scrollRatioX) * (scrollableContentWidth - viewportWidth); - - // Adjust for browsers that use negative scrollLeft in RTL - if (viewportRef.current.scrollLeft <= 0) { - newScrollLeft = -newScrollLeft; - } - } else { - newScrollLeft = scrollRatioX * (scrollableContentWidth - viewportWidth); - } - - viewportRef.current.scrollLeft = newScrollLeft; - } - - handlePointerDown(event); - }, - onPointerUp: handlePointerUp, - style: { - position: 'absolute', - touchAction: 'none', - ...(orientation === 'vertical' && { - top: 0, - bottom: 0, - [dir === 'rtl' ? 'left' : 'right']: 0, - }), - ...(orientation === 'horizontal' && { - left: 0, - right: 0, - bottom: 0, - }), - }, - }), + extraProps: otherProps, }); const contextValue = React.useMemo(() => ({ orientation }), [orientation]); diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/useScrollAreaScrollbar.ts b/packages/mui-base/src/ScrollArea/Scrollbar/useScrollAreaScrollbar.ts new file mode 100644 index 0000000000..30aa3f281f --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Scrollbar/useScrollAreaScrollbar.ts @@ -0,0 +1,165 @@ +import * as React from 'react'; +import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext'; +import { mergeReactProps } from '../../utils/mergeReactProps'; + +export function useScrollAreaScrollbar(params: useScrollAreaScrollbar.Parameters) { + const { orientation } = params; + + const { + dir, + scrollbarYRef, + scrollbarXRef, + viewportRef, + thumbYRef, + thumbXRef, + handlePointerDown, + handlePointerUp, + } = useScrollAreaRootContext(); + + React.useEffect(() => { + const viewportEl = viewportRef.current; + const scrollbarEl = orientation === 'vertical' ? scrollbarYRef.current : scrollbarXRef.current; + + function handleWheel(event: WheelEvent) { + if (!viewportEl || !scrollbarEl || event.ctrlKey) { + return; + } + + if (orientation === 'vertical') { + if (viewportEl.scrollTop === 0 && event.deltaY < 0) { + return; + } + } else if (viewportEl.scrollLeft === 0 && event.deltaX < 0) { + return; + } + + if (orientation === 'vertical') { + if ( + viewportEl.scrollTop === viewportEl.scrollHeight - viewportEl.clientHeight && + event.deltaY > 0 + ) { + return; + } + } else if ( + viewportEl.scrollLeft === viewportEl.scrollWidth - viewportEl.clientWidth && + event.deltaX > 0 + ) { + return; + } + + event.preventDefault(); + + if (orientation === 'vertical') { + viewportEl.scrollTop += event.deltaY; + } else { + viewportEl.scrollLeft += event.deltaX; + } + } + + scrollbarEl?.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + scrollbarEl?.removeEventListener('wheel', handleWheel); + }; + }, [orientation, scrollbarXRef, scrollbarYRef, thumbYRef, viewportRef]); + + const getScrollbarProps = React.useCallback( + (externalProps = {}) => + mergeReactProps<'div'>(externalProps, { + onPointerDown(event) { + // Ignore clicks on thumb + if (event.currentTarget !== event.target) { + return; + } + + if (!viewportRef.current) { + return; + } + + // Handle Y-axis (vertical) scroll + if (thumbYRef.current && scrollbarYRef.current && orientation === 'vertical') { + const thumbHeight = thumbYRef.current.offsetHeight; + const trackRectY = scrollbarYRef.current.getBoundingClientRect(); + const clickY = event.clientY - trackRectY.top - thumbHeight / 2; + + const scrollableContentHeight = viewportRef.current.scrollHeight; + const viewportHeight = viewportRef.current.clientHeight; + + const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight; + const scrollRatioY = clickY / maxThumbOffsetY; + const newScrollTop = scrollRatioY * (scrollableContentHeight - viewportHeight); + + viewportRef.current.scrollTop = newScrollTop; + } + + if (thumbXRef.current && scrollbarXRef.current && orientation === 'horizontal') { + const thumbWidth = thumbXRef.current.offsetWidth; + const trackRectX = scrollbarXRef.current.getBoundingClientRect(); + const clickX = event.clientX - trackRectX.left - thumbWidth / 2; + + const scrollableContentWidth = viewportRef.current.scrollWidth; + const viewportWidth = viewportRef.current.clientWidth; + + const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth; + const scrollRatioX = clickX / maxThumbOffsetX; + + let newScrollLeft: number; + if (dir === 'rtl') { + // In RTL, we need to invert the scroll direction + newScrollLeft = (1 - scrollRatioX) * (scrollableContentWidth - viewportWidth); + + // Adjust for browsers that use negative scrollLeft in RTL + if (viewportRef.current.scrollLeft <= 0) { + newScrollLeft = -newScrollLeft; + } + } else { + newScrollLeft = scrollRatioX * (scrollableContentWidth - viewportWidth); + } + + viewportRef.current.scrollLeft = newScrollLeft; + } + + handlePointerDown(event); + }, + onPointerUp: handlePointerUp, + style: { + position: 'absolute', + touchAction: 'none', + ...(orientation === 'vertical' && { + top: 0, + bottom: 0, + [dir === 'rtl' ? 'left' : 'right']: 0, + }), + ...(orientation === 'horizontal' && { + left: 0, + right: 0, + bottom: 0, + }), + }, + }), + [ + dir, + handlePointerDown, + handlePointerUp, + orientation, + scrollbarXRef, + scrollbarYRef, + thumbXRef, + thumbYRef, + viewportRef, + ], + ); + + return React.useMemo( + () => ({ + getScrollbarProps, + }), + [getScrollbarProps], + ); +} + +namespace useScrollAreaScrollbar { + export interface Parameters { + orientation: 'vertical' | 'horizontal'; + } +} diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx index ad0e925205..6e4bc311cc 100644 --- a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx +++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx @@ -35,13 +35,13 @@ const ScrollAreaThumb = React.forwardRef(function ScrollAreaThumb( const { orientation } = useScrollAreaScrollbarContext(); + const mergedRef = useForkRef(forwardedRef, orientation === 'vertical' ? thumbYRef : thumbXRef); + const ownerState: ScrollAreaThumb.OwnerState = React.useMemo( () => ({ orientation }), [orientation], ); - const mergedRef = useForkRef(forwardedRef, orientation === 'vertical' ? thumbYRef : thumbXRef); - const { renderElement } = useComponentRenderer({ render: render ?? 'div', ref: mergedRef, diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx index 9386ac5960..ccd54a9532 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx @@ -3,11 +3,9 @@ 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 { useEventCallback } from '../../utils/useEventCallback'; -import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { useForkRef } from '../../utils/useForkRef'; +import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext'; +import { useScrollAreaViewport } from './useScrollAreaViewport'; const ownerState = {}; @@ -27,194 +25,18 @@ const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( ) { const { render, className, children, ...otherProps } = props; - const { - type, - viewportRef, - scrollbarYRef, - scrollbarXRef, - thumbYRef, - thumbXRef, - cornerRef, - setScrolling, - dir, - gutter, - setCornerSize, - } = useScrollAreaRootContext(); - - const timeoutRef = React.useRef(-1); + const { viewportRef } = useScrollAreaRootContext(); + const { getViewportProps } = useScrollAreaViewport({ children }); const mergedRef = useForkRef(forwardedRef, viewportRef); - const tableWrapperRef = React.useRef(null); - - const [paddingX, setPaddingX] = React.useState(0); - const [hiddenX, setHiddenX] = React.useState(false); - const [hiddenY, setHiddenY] = React.useState(false); - - useEnhancedEffect(() => { - if (scrollbarYRef.current) { - setPaddingX(parseFloat(getComputedStyle(scrollbarYRef.current).width)); - } - }, [scrollbarYRef, scrollbarXRef]); - - const computeThumb = useEventCallback(() => { - const viewportEl = viewportRef.current; - const scrollbarYEl = scrollbarYRef.current; - const scrollbarXEl = scrollbarXRef.current; - const thumbYEl = thumbYRef.current; - const thumbXEl = thumbXRef.current; - const cornerEl = cornerRef.current; - - if (!viewportEl) { - return; - } - - const scrollableContentHeight = viewportEl.scrollHeight; - const scrollableContentWidth = viewportEl.scrollWidth; - const viewportHeight = viewportEl.clientHeight; - const viewportWidth = viewportEl.clientWidth; - const scrollTop = viewportEl.scrollTop; - const scrollLeft = viewportEl.scrollLeft; - - const scrollbarYHidden = viewportHeight >= scrollableContentHeight; - const scrollbarXHidden = viewportWidth >= scrollableContentWidth; - - // Handle Y (vertical) scroll - if (scrollbarYEl && thumbYEl) { - const thumbHeight = thumbYEl.offsetHeight; - const scrollbarStylesY = getComputedStyle(scrollbarYEl); - const paddingTop = parseFloat(scrollbarStylesY.paddingTop); - const paddingBottom = parseFloat(scrollbarStylesY.paddingBottom); - - const maxThumbOffsetY = - scrollbarYEl.offsetHeight - thumbHeight - (paddingTop + paddingBottom); - const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight); - const thumbOffsetY = scrollRatioY * maxThumbOffsetY; - - thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; - - if (scrollbarYHidden) { - scrollbarYEl.setAttribute('hidden', ''); - } else { - scrollbarYEl.removeAttribute('hidden'); - } - - setHiddenY(scrollbarYHidden); - - scrollbarYEl.style.setProperty( - '--scroll-area-thumb-height', - scrollbarYHidden - ? '0px' - : `${(viewportHeight / scrollableContentHeight) * viewportHeight}px`, - ); - } - - // Handle X (horizontal) scroll - if (scrollbarXEl && thumbXEl) { - const thumbWidth = thumbXEl.offsetWidth; - const scrollbarStylesX = getComputedStyle(scrollbarXEl); - const paddingLeft = parseFloat(scrollbarStylesX.paddingLeft); - const paddingRight = parseFloat(scrollbarStylesX.paddingRight); - - const maxThumbOffsetX = scrollbarXEl.offsetWidth - thumbWidth - (paddingLeft + paddingRight); - const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth); - const thumbOffsetX = scrollRatioX * maxThumbOffsetX; - - thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; - - if (scrollbarXHidden) { - scrollbarXEl.setAttribute('hidden', ''); - } else { - scrollbarXEl.removeAttribute('hidden'); - } - - setHiddenX(scrollbarXHidden); - - scrollbarXEl.style.setProperty( - '--scroll-area-thumb-width', - scrollbarXHidden ? '0px' : `${(viewportWidth / scrollableContentWidth) * viewportWidth}px`, - ); - } - - if (cornerEl) { - if (scrollbarXHidden || scrollbarYHidden) { - cornerEl.setAttribute('hidden', ''); - setCornerSize({ width: 0, height: 0 }); - } else if (!scrollbarXHidden && !scrollbarYHidden) { - cornerEl.removeAttribute('hidden'); - setCornerSize({ width: cornerEl.offsetWidth, height: cornerEl.offsetHeight }); - } - } - }); - - useEnhancedEffect(() => { - // Wait for the scrollbar-related refs to be set. - queueMicrotask(computeThumb); - }, [computeThumb]); - - React.useEffect(() => { - if (!tableWrapperRef.current || typeof ResizeObserver === 'undefined') { - return undefined; - } - - const ro = new ResizeObserver(computeThumb); - ro.observe(tableWrapperRef.current); - - return () => { - ro.disconnect(); - }; - }, [computeThumb]); - - const wrapperStyles: React.CSSProperties = {}; - - if (type === 'inlay') { - if (!hiddenY) { - wrapperStyles.paddingRight = paddingX; - } - if (!hiddenX) { - wrapperStyles.paddingBottom = paddingX; - } - - if (hiddenY) { - if (gutter === 'stable') { - wrapperStyles[dir === 'rtl' ? 'paddingLeft' : 'paddingRight'] = paddingX; - } else if (gutter === 'both-edges') { - wrapperStyles.paddingLeft = paddingX; - wrapperStyles.paddingRight = paddingX; - } - } - } const { renderElement } = useComponentRenderer({ + propGetter: getViewportProps, render: render ?? 'div', ref: mergedRef, className, ownerState, - extraProps: mergeReactProps<'div'>(otherProps, { - onScroll() { - computeThumb(); - setScrolling(true); - - window.clearTimeout(timeoutRef.current); - timeoutRef.current = window.setTimeout(() => { - setScrolling(false); - }, 500); - }, - style: { - overflow: 'scroll', - }, - children: ( -
    - {children} -
    - ), - }), + extraProps: otherProps, }); return renderElement(); diff --git a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx new file mode 100644 index 0000000000..497c6a22dd --- /dev/null +++ b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx @@ -0,0 +1,222 @@ +import * as React from 'react'; +import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { ownerWindow } from '../../utils/owner'; + +export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) { + const { children } = params; + + const { + type, + viewportRef, + scrollbarYRef, + scrollbarXRef, + thumbYRef, + thumbXRef, + cornerRef, + setScrolling, + dir, + gutter, + setCornerSize, + } = useScrollAreaRootContext(); + + const timeoutRef = React.useRef(-1); + const tableWrapperRef = React.useRef(null); + + const [paddingX, setPaddingX] = React.useState(0); + const [hiddenX, setHiddenX] = React.useState(false); + const [hiddenY, setHiddenY] = React.useState(false); + + const computeThumb = useEventCallback(() => { + const viewportEl = viewportRef.current; + const scrollbarYEl = scrollbarYRef.current; + const scrollbarXEl = scrollbarXRef.current; + const thumbYEl = thumbYRef.current; + const thumbXEl = thumbXRef.current; + const cornerEl = cornerRef.current; + + if (!viewportEl) { + return; + } + + const scrollableContentHeight = viewportEl.scrollHeight; + const scrollableContentWidth = viewportEl.scrollWidth; + const viewportHeight = viewportEl.clientHeight; + const viewportWidth = viewportEl.clientWidth; + const scrollTop = viewportEl.scrollTop; + const scrollLeft = viewportEl.scrollLeft; + + const scrollbarYHidden = viewportHeight >= scrollableContentHeight; + const scrollbarXHidden = viewportWidth >= scrollableContentWidth; + + // Handle Y (vertical) scroll + if (scrollbarYEl && thumbYEl) { + const thumbHeight = thumbYEl.offsetHeight; + const scrollbarStylesY = getComputedStyle(scrollbarYEl); + const paddingTop = parseFloat(scrollbarStylesY.paddingTop); + const paddingBottom = parseFloat(scrollbarStylesY.paddingBottom); + + const maxThumbOffsetY = + scrollbarYEl.offsetHeight - thumbHeight - (paddingTop + paddingBottom); + const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight); + const thumbOffsetY = scrollRatioY * maxThumbOffsetY; + + thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; + + if (scrollbarYHidden) { + scrollbarYEl.setAttribute('hidden', ''); + } else { + scrollbarYEl.removeAttribute('hidden'); + } + + setHiddenY(scrollbarYHidden); + + scrollbarYEl.style.setProperty( + '--scroll-area-thumb-height', + scrollbarYHidden + ? '0px' + : `${(viewportHeight / scrollableContentHeight) * viewportHeight}px`, + ); + } + + // Handle X (horizontal) scroll + if (scrollbarXEl && thumbXEl) { + const thumbWidth = thumbXEl.offsetWidth; + const scrollbarStylesX = getComputedStyle(scrollbarXEl); + const paddingLeft = parseFloat(scrollbarStylesX.paddingLeft); + const paddingRight = parseFloat(scrollbarStylesX.paddingRight); + + const maxThumbOffsetX = scrollbarXEl.offsetWidth - thumbWidth - (paddingLeft + paddingRight); + const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth); + const thumbOffsetX = scrollRatioX * maxThumbOffsetX; + + thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; + + if (scrollbarXHidden) { + scrollbarXEl.setAttribute('hidden', ''); + } else { + scrollbarXEl.removeAttribute('hidden'); + } + + setHiddenX(scrollbarXHidden); + + scrollbarXEl.style.setProperty( + '--scroll-area-thumb-width', + scrollbarXHidden ? '0px' : `${(viewportWidth / scrollableContentWidth) * viewportWidth}px`, + ); + } + + if (cornerEl) { + if (scrollbarXHidden || scrollbarYHidden) { + cornerEl.setAttribute('hidden', ''); + setCornerSize({ width: 0, height: 0 }); + } else if (!scrollbarXHidden && !scrollbarYHidden) { + cornerEl.removeAttribute('hidden'); + setCornerSize({ width: cornerEl.offsetWidth, height: cornerEl.offsetHeight }); + } + } + }); + + useEnhancedEffect(() => { + if (!viewportRef.current) { + return undefined; + } + + function handleResize() { + if (scrollbarYRef.current) { + setPaddingX(parseFloat(getComputedStyle(scrollbarYRef.current).width)); + } + computeThumb(); + } + + // Wait for the scrollbar-related refs to be set. + queueMicrotask(handleResize); + + const win = ownerWindow(viewportRef.current); + + win.addEventListener('resize', handleResize); + + return () => { + win.removeEventListener('resize', handleResize); + }; + }, [scrollbarYRef, scrollbarXRef, viewportRef, computeThumb]); + + React.useEffect(() => { + if (!tableWrapperRef.current || typeof ResizeObserver === 'undefined') { + return undefined; + } + + const ro = new ResizeObserver(computeThumb); + ro.observe(tableWrapperRef.current); + + return () => { + ro.disconnect(); + }; + }, [computeThumb]); + + const wrapperStyles: React.CSSProperties = React.useMemo(() => ({}), []); + + if (type === 'inlay') { + if (!hiddenY) { + wrapperStyles.paddingRight = paddingX; + } + if (!hiddenX) { + wrapperStyles.paddingBottom = paddingX; + } + + if (hiddenY) { + if (gutter === 'stable') { + wrapperStyles[dir === 'rtl' ? 'paddingLeft' : 'paddingRight'] = paddingX; + } else if (gutter === 'both-edges') { + wrapperStyles.paddingLeft = paddingX; + wrapperStyles.paddingRight = paddingX; + } + } + } + + const getViewportProps = React.useCallback( + (externalProps = {}) => + mergeReactProps<'div'>(externalProps, { + style: { + overflow: 'scroll', + }, + onScroll() { + computeThumb(); + setScrolling(true); + + window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { + setScrolling(false); + }, 500); + }, + children: ( +
    + {children} +
    + ), + }), + [children, computeThumb, setScrolling, wrapperStyles], + ); + + return React.useMemo( + () => ({ + getViewportProps, + }), + [getViewportProps], + ); +} + +namespace useScrollAreaViewport { + export interface Parameters { + children?: React.ReactNode; + } +} From 28947b4300df0940f91ee383dc4bb14adc6c4d0f Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 7 Oct 2024 14:04:40 +1100 Subject: [PATCH 12/72] Adjust tests --- .../Corner/ScrollAreaCorner.test.tsx | 13 +++++---- .../ScrollArea/Root/ScrollAreaRoot.test.tsx | 29 ++++++++++++++++--- .../Viewport/useScrollAreaViewport.tsx | 4 ++- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx index a2a62c0dbc..1e243c3c8c 100644 --- a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import * as ScrollArea from '@base_ui/react/ScrollArea'; import { expect } from 'chai'; +import { screen } from '@mui/internal-test-utils'; import { createRenderer } from '#test-utils'; import { describeConformance } from '../../../test/describeConformance'; @@ -21,17 +22,19 @@ describe('', () => { this.skip(); } - const { getByTestId } = await render( - - + await render( + + +
    + , ); - const corner = getByTestId('corner'); - const style = window.getComputedStyle(corner); + 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/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx index 67ed2c2b8f..1bfe0441d9 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -15,6 +15,22 @@ const SCROLLBAR_HEIGHT = 15; describe('', () => { const { render } = createRenderer(); + beforeEach(() => { + const style = document.createElement('style'); + style.innerHTML = ` + *, + *::before, + *::after { + box-sizing: border-box; + } + `; + document.head.appendChild(style); + }); + + afterEach(() => { + document.head.innerHTML = ''; + }); + describeConformance(, () => ({ refInstanceof: window.HTMLDivElement, render, @@ -25,7 +41,7 @@ describe('', () => { this.skip(); } - const { getByTestId } = await render( + await render(
    @@ -39,8 +55,8 @@ describe('', () => { , ); - const verticalThumb = getByTestId('vertical-thumb'); - const horizontalThumb = getByTestId('horizontal-thumb'); + 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`, @@ -151,6 +167,7 @@ describe('', () => { await render( @@ -180,7 +197,11 @@ describe('', () => { } await render( - + Date: Mon, 7 Oct 2024 14:29:51 +1100 Subject: [PATCH 13/72] Fix tests --- .../mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx | 5 +++-- .../src/ScrollArea/Viewport/useScrollAreaViewport.tsx | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx index 1bfe0441d9..da0e1888b8 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -22,6 +22,7 @@ describe('', () => { *::before, *::after { box-sizing: border-box; + scrollbar-width: none; } `; document.head.appendChild(style); @@ -153,8 +154,8 @@ describe('', () => { const style = getComputedStyle(tableWrapper); - expect(style.paddingLeft).not.to.equal(`${SCROLLBAR_WIDTH}px`); - expect(style.paddingRight).to.equal(`${SCROLLBAR_WIDTH}px`); + 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/Viewport/useScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx index 7d837e957b..a0354037b8 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx @@ -26,6 +26,7 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) const tableWrapperRef = React.useRef(null); const [paddingX, setPaddingX] = React.useState(0); + const [paddingY, setPaddingY] = React.useState(0); const [hiddenX, setHiddenX] = React.useState(false); const [hiddenY, setHiddenY] = React.useState(false); @@ -128,7 +129,10 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) function handleResize() { if (scrollbarYRef.current) { - setPaddingX(parseFloat(getComputedStyle(scrollbarYRef.current).width)); + setPaddingX(scrollbarYRef.current.offsetHeight); + } + if (scrollbarXRef.current) { + setPaddingY(scrollbarXRef.current.offsetWidth); } computeThumb(); } @@ -165,7 +169,7 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) wrapperStyles.paddingRight = paddingX; } if (!hiddenX) { - wrapperStyles.paddingBottom = paddingX; + wrapperStyles.paddingBottom = paddingY; } if (hiddenY) { From 0a8108b424c114ff63a86679980bb1197cc18c83 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 7 Oct 2024 14:42:25 +1100 Subject: [PATCH 14/72] Reverse measurements --- .../src/ScrollArea/Viewport/useScrollAreaViewport.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx index a0354037b8..5e1dedaa3f 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx @@ -129,10 +129,10 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) function handleResize() { if (scrollbarYRef.current) { - setPaddingX(scrollbarYRef.current.offsetHeight); + setPaddingX(scrollbarYRef.current.offsetWidth); } if (scrollbarXRef.current) { - setPaddingY(scrollbarXRef.current.offsetWidth); + setPaddingY(scrollbarXRef.current.offsetHeight); } computeThumb(); } From 2baee83a7301b6f7a50bc232242350927bf1efdf Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 7 Oct 2024 15:05:23 +1100 Subject: [PATCH 15/72] Fix tests --- .../ScrollArea/Root/ScrollAreaRoot.test.tsx | 5 -- .../Viewport/useScrollAreaViewport.tsx | 53 ++++++++++--------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx index da0e1888b8..ffeab466b7 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -90,7 +90,6 @@ describe('', () => { ); const tableWrapper = screen.getByTestId('viewport').firstElementChild!; - const style = getComputedStyle(tableWrapper); expect(style.paddingLeft).to.equal('0px'); @@ -120,7 +119,6 @@ describe('', () => { ); const tableWrapper = screen.getByTestId('viewport').firstElementChild!; - const style = getComputedStyle(tableWrapper); expect(style.paddingRight).to.equal(`${SCROLLBAR_WIDTH}px`); @@ -151,7 +149,6 @@ describe('', () => { ); const tableWrapper = screen.getByTestId('viewport').firstElementChild!; - const style = getComputedStyle(tableWrapper); expect(style.paddingLeft).to.equal(`${SCROLLBAR_WIDTH}px`); @@ -185,7 +182,6 @@ describe('', () => { ); const tableWrapper = screen.getByTestId('viewport').firstElementChild!; - const style = getComputedStyle(tableWrapper); expect(style.paddingLeft).to.equal(`${SCROLLBAR_WIDTH}px`); @@ -216,7 +212,6 @@ describe('', () => { ); const tableWrapper = screen.getByTestId('viewport').firstElementChild!; - const style = getComputedStyle(tableWrapper); expect(style.paddingLeft).to.equal('0px'); diff --git a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx index 5e1dedaa3f..3105242836 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx @@ -53,18 +53,20 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) const scrollbarXHidden = viewportWidth >= scrollableContentWidth; // Handle Y (vertical) scroll - if (scrollbarYEl && thumbYEl) { - const thumbHeight = thumbYEl.offsetHeight; - const scrollbarStylesY = getComputedStyle(scrollbarYEl); - const paddingTop = parseFloat(scrollbarStylesY.paddingTop); - const paddingBottom = parseFloat(scrollbarStylesY.paddingBottom); - - const maxThumbOffsetY = - scrollbarYEl.offsetHeight - thumbHeight - (paddingTop + paddingBottom); - const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight); - const thumbOffsetY = scrollRatioY * maxThumbOffsetY; - - thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; + if (scrollbarYEl) { + if (thumbYEl) { + const thumbHeight = thumbYEl.offsetHeight; + const scrollbarStylesY = getComputedStyle(scrollbarYEl); + const paddingTop = parseFloat(scrollbarStylesY.paddingTop); + const paddingBottom = parseFloat(scrollbarStylesY.paddingBottom); + + const maxThumbOffsetY = + scrollbarYEl.offsetHeight - thumbHeight - (paddingTop + paddingBottom); + const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight); + const thumbOffsetY = scrollRatioY * maxThumbOffsetY; + + thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; + } if (scrollbarYHidden) { scrollbarYEl.setAttribute('hidden', ''); @@ -83,17 +85,20 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) } // Handle X (horizontal) scroll - if (scrollbarXEl && thumbXEl) { - const thumbWidth = thumbXEl.offsetWidth; - const scrollbarStylesX = getComputedStyle(scrollbarXEl); - const paddingLeft = parseFloat(scrollbarStylesX.paddingLeft); - const paddingRight = parseFloat(scrollbarStylesX.paddingRight); - - const maxThumbOffsetX = scrollbarXEl.offsetWidth - thumbWidth - (paddingLeft + paddingRight); - const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth); - const thumbOffsetX = scrollRatioX * maxThumbOffsetX; - - thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; + if (scrollbarXEl) { + if (thumbXEl) { + const thumbWidth = thumbXEl.offsetWidth; + const scrollbarStylesX = getComputedStyle(scrollbarXEl); + const paddingLeft = parseFloat(scrollbarStylesX.paddingLeft); + const paddingRight = parseFloat(scrollbarStylesX.paddingRight); + + const maxThumbOffsetX = + scrollbarXEl.offsetWidth - thumbWidth - (paddingLeft + paddingRight); + const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth); + const thumbOffsetX = scrollRatioX * maxThumbOffsetX; + + thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; + } if (scrollbarXHidden) { scrollbarXEl.setAttribute('hidden', ''); @@ -166,7 +171,7 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) if (type === 'inlay') { if (!hiddenY) { - wrapperStyles.paddingRight = paddingX; + wrapperStyles[dir === 'rtl' ? 'paddingLeft' : 'paddingRight'] = paddingX; } if (!hiddenX) { wrapperStyles.paddingBottom = paddingY; From 05158cd4da5a35aebc3ce85f5485d116353b8ff5 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 7 Oct 2024 15:17:26 +1100 Subject: [PATCH 16/72] Parse computed styles --- .../src/ScrollArea/Root/ScrollAreaRoot.test.tsx | 12 ++++++++---- .../ScrollArea/Viewport/useScrollAreaViewport.tsx | 11 +++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx index ffeab466b7..76e93cfd0e 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -127,13 +127,17 @@ describe('', () => { }); describe('prop: dir', () => { - it('should adjust padding for rtl', async function test() { + it('should adjust inlay padding for rtl', async function test() { if (isJSDOM) { this.skip(); } await render( - +
    @@ -158,7 +162,7 @@ describe('', () => { }); describe('prop: gutter', () => { - it('should adjust padding for gutter: both-edges', async function test() { + it('should adjust inlay padding for gutter: both-edges', async function test() { if (isJSDOM) { this.skip(); } @@ -188,7 +192,7 @@ describe('', () => { expect(style.paddingRight).to.equal(`${SCROLLBAR_WIDTH}px`); }); - it('should not add padding for gutter: none', async function test() { + it('should not add inlay padding for gutter: none', async function test() { if (isJSDOM) { this.skip(); } diff --git a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx index 3105242836..362a7bff1e 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx @@ -133,11 +133,18 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) } function handleResize() { + // Parse computed styles as the scrollbars may not be rendered to measure. if (scrollbarYRef.current) { - setPaddingX(scrollbarYRef.current.offsetWidth); + setPaddingX( + scrollbarYRef.current.offsetWidth || + parseFloat(getComputedStyle(scrollbarYRef.current).width), + ); } if (scrollbarXRef.current) { - setPaddingY(scrollbarXRef.current.offsetHeight); + setPaddingY( + scrollbarXRef.current.offsetHeight || + parseFloat(getComputedStyle(scrollbarXRef.current).height), + ); } computeThumb(); } From 0898af8d3698f1f24dbc1287d27dcc8785476aa4 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 14 Oct 2024 20:12:54 +1100 Subject: [PATCH 17/72] Migrate to new exports --- docs/data/api/scroll-area-corner.json | 2 +- docs/data/api/scroll-area-root.json | 2 +- docs/data/api/scroll-area-scrollbar.json | 2 +- docs/data/api/scroll-area-thumb.json | 2 +- docs/data/api/scroll-area-viewport.json | 2 +- docs/data/components/scroll-area/ScrollAreaInlay.js | 2 +- docs/data/components/scroll-area/ScrollAreaInlay.tsx | 2 +- .../scroll-area/ScrollAreaIntroduction/system/index.js | 2 +- .../scroll-area/ScrollAreaIntroduction/system/index.tsx | 2 +- .../src/ScrollArea/Corner/ScrollAreaCorner.test.tsx | 2 +- .../mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx | 2 +- .../src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx | 2 +- .../mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx | 2 +- .../src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx | 2 +- packages/mui-base/src/ScrollArea/index.barrel.ts | 5 ----- packages/mui-base/src/ScrollArea/index.parts.ts | 5 +++++ packages/mui-base/src/ScrollArea/index.ts | 6 +----- 17 files changed, 20 insertions(+), 24 deletions(-) delete mode 100644 packages/mui-base/src/ScrollArea/index.barrel.ts create mode 100644 packages/mui-base/src/ScrollArea/index.parts.ts diff --git a/docs/data/api/scroll-area-corner.json b/docs/data/api/scroll-area-corner.json index f8f8337dbd..a3c7a849e3 100644 --- a/docs/data/api/scroll-area-corner.json +++ b/docs/data/api/scroll-area-corner.json @@ -5,7 +5,7 @@ }, "name": "ScrollAreaCorner", "imports": [ - "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaCorner = ScrollArea.Corner;" + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaCorner = ScrollArea.Corner;" ], "classes": [], "spread": true, diff --git a/docs/data/api/scroll-area-root.json b/docs/data/api/scroll-area-root.json index 9bcd9c91c1..8952ef5bbd 100644 --- a/docs/data/api/scroll-area-root.json +++ b/docs/data/api/scroll-area-root.json @@ -16,7 +16,7 @@ }, "name": "ScrollAreaRoot", "imports": [ - "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaRoot = ScrollArea.Root;" + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaRoot = ScrollArea.Root;" ], "classes": [], "spread": true, diff --git a/docs/data/api/scroll-area-scrollbar.json b/docs/data/api/scroll-area-scrollbar.json index dd6585e663..add0fe790f 100644 --- a/docs/data/api/scroll-area-scrollbar.json +++ b/docs/data/api/scroll-area-scrollbar.json @@ -9,7 +9,7 @@ }, "name": "ScrollAreaScrollbar", "imports": [ - "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaScrollbar = ScrollArea.Scrollbar;" + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaScrollbar = ScrollArea.Scrollbar;" ], "classes": [], "spread": true, diff --git a/docs/data/api/scroll-area-thumb.json b/docs/data/api/scroll-area-thumb.json index 285c974dd9..0e401ae75c 100644 --- a/docs/data/api/scroll-area-thumb.json +++ b/docs/data/api/scroll-area-thumb.json @@ -5,7 +5,7 @@ }, "name": "ScrollAreaThumb", "imports": [ - "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaThumb = ScrollArea.Thumb;" + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaThumb = ScrollArea.Thumb;" ], "classes": [], "spread": true, diff --git a/docs/data/api/scroll-area-viewport.json b/docs/data/api/scroll-area-viewport.json index eec87f9e9f..614419f1a8 100644 --- a/docs/data/api/scroll-area-viewport.json +++ b/docs/data/api/scroll-area-viewport.json @@ -5,7 +5,7 @@ }, "name": "ScrollAreaViewport", "imports": [ - "import * as ScrollArea from '@base_ui/react/ScrollArea';\nconst ScrollAreaViewport = ScrollArea.Viewport;" + "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaViewport = ScrollArea.Viewport;" ], "classes": [], "spread": true, diff --git a/docs/data/components/scroll-area/ScrollAreaInlay.js b/docs/data/components/scroll-area/ScrollAreaInlay.js index 1dcddb9cdd..9951d90f8d 100644 --- a/docs/data/components/scroll-area/ScrollAreaInlay.js +++ b/docs/data/components/scroll-area/ScrollAreaInlay.js @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; import { styled } from '@mui/system'; const data = [ diff --git a/docs/data/components/scroll-area/ScrollAreaInlay.tsx b/docs/data/components/scroll-area/ScrollAreaInlay.tsx index 1dcddb9cdd..9951d90f8d 100644 --- a/docs/data/components/scroll-area/ScrollAreaInlay.tsx +++ b/docs/data/components/scroll-area/ScrollAreaInlay.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; import { styled } from '@mui/system'; const data = [ diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js index 0c678407b6..c676846505 100644 --- a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; import { styled } from '@mui/system'; const data = Array.from({ length: 30 }, (_, i) => i + 1); diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx index 0c678407b6..c676846505 100644 --- a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { ScrollArea } from '@base_ui/react/ScrollArea'; import { styled } from '@mui/system'; const data = Array.from({ length: 30 }, (_, i) => i + 1); diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx index 1e243c3c8c..b042ccb9d7 100644 --- a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; import { expect } from 'chai'; import { screen } from '@mui/internal-test-utils'; import { createRenderer } from '#test-utils'; diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx index 76e93cfd0e..64ffee2638 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; import { screen } from '@mui/internal-test-utils'; import { createRenderer } from '#test-utils'; import { expect } from 'chai'; diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx index a254828a00..891456efb7 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; import { createRenderer } from '@mui/internal-test-utils'; import { describeConformance } from '../../../test/describeConformance'; diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx index 8ba07e6921..476bc48afd 100644 --- a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx +++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; import { createRenderer } from '@mui/internal-test-utils'; import { describeConformance } from '../../../test/describeConformance'; diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx index d7e02e4bd7..eea5e6757f 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import * as ScrollArea from '@base_ui/react/ScrollArea'; +import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; import { createRenderer } from '@mui/internal-test-utils'; import { describeConformance } from '../../../test/describeConformance'; diff --git a/packages/mui-base/src/ScrollArea/index.barrel.ts b/packages/mui-base/src/ScrollArea/index.barrel.ts deleted file mode 100644 index 84e502fc77..0000000000 --- a/packages/mui-base/src/ScrollArea/index.barrel.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { ScrollAreaRoot } from './Root/ScrollAreaRoot'; -export { ScrollAreaViewport } from './Viewport/ScrollAreaViewport'; -export { ScrollAreaScrollbar } from './Scrollbar/ScrollAreaScrollbar'; -export { ScrollAreaThumb } from './Thumb/ScrollAreaThumb'; -export { ScrollAreaCorner } from './Corner/ScrollAreaCorner'; diff --git a/packages/mui-base/src/ScrollArea/index.parts.ts b/packages/mui-base/src/ScrollArea/index.parts.ts new file mode 100644 index 0000000000..a6193a16bd --- /dev/null +++ b/packages/mui-base/src/ScrollArea/index.parts.ts @@ -0,0 +1,5 @@ +export { ScrollAreaRoot as Root } from './Root/ScrollAreaRoot'; +export { ScrollAreaViewport as Viewport } from './Viewport/ScrollAreaViewport'; +export { ScrollAreaScrollbar as Scrollbar } from './Scrollbar/ScrollAreaScrollbar'; +export { ScrollAreaThumb as Thumb } from './Thumb/ScrollAreaThumb'; +export { ScrollAreaCorner as Corner } from './Corner/ScrollAreaCorner'; diff --git a/packages/mui-base/src/ScrollArea/index.ts b/packages/mui-base/src/ScrollArea/index.ts index a6193a16bd..0b92c3a71f 100644 --- a/packages/mui-base/src/ScrollArea/index.ts +++ b/packages/mui-base/src/ScrollArea/index.ts @@ -1,5 +1 @@ -export { ScrollAreaRoot as Root } from './Root/ScrollAreaRoot'; -export { ScrollAreaViewport as Viewport } from './Viewport/ScrollAreaViewport'; -export { ScrollAreaScrollbar as Scrollbar } from './Scrollbar/ScrollAreaScrollbar'; -export { ScrollAreaThumb as Thumb } from './Thumb/ScrollAreaThumb'; -export { ScrollAreaCorner as Corner } from './Corner/ScrollAreaCorner'; +export * as ScrollArea from './index.parts'; From 70063d1db15813d0553b9064cee81a4892bdd7b0 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 14 Oct 2024 20:20:39 +1100 Subject: [PATCH 18/72] Fix imports --- .../mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx | 2 +- packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx | 2 +- .../src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx | 2 +- packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx | 2 +- .../src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx index b042ccb9d7..bf7b18b26c 100644 --- a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; +import { ScrollArea } from '@base_ui/react/ScrollArea''; import { expect } from 'chai'; import { screen } from '@mui/internal-test-utils'; import { createRenderer } from '#test-utils'; diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx index 64ffee2638..bb58c91b95 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; +import { ScrollArea } from '@base_ui/react/ScrollArea''; import { screen } from '@mui/internal-test-utils'; import { createRenderer } from '#test-utils'; import { expect } from 'chai'; diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx index 891456efb7..7345696c34 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; +import { ScrollArea } from '@base_ui/react/ScrollArea''; import { createRenderer } from '@mui/internal-test-utils'; import { describeConformance } from '../../../test/describeConformance'; diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx index 476bc48afd..369f0bc86d 100644 --- a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx +++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; +import { ScrollArea } from '@base_ui/react/ScrollArea''; import { createRenderer } from '@mui/internal-test-utils'; import { describeConformance } from '../../../test/describeConformance'; diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx index eea5e6757f..da030c4554 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea/index.parts'; +import { ScrollArea } from '@base_ui/react/ScrollArea''; import { createRenderer } from '@mui/internal-test-utils'; import { describeConformance } from '../../../test/describeConformance'; From 859220dab754caecaf0b1992b6edc4bdfaf0f8e4 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 14 Oct 2024 21:52:57 +1100 Subject: [PATCH 19/72] CSS demo fixes --- docs/data/components/scroll-area/ScrollAreaInlay.js | 5 +++++ docs/data/components/scroll-area/ScrollAreaInlay.tsx | 5 +++++ .../scroll-area/ScrollAreaIntroduction/system/index.js | 9 +++++---- .../scroll-area/ScrollAreaIntroduction/system/index.tsx | 9 +++++---- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/data/components/scroll-area/ScrollAreaInlay.js b/docs/data/components/scroll-area/ScrollAreaInlay.js index 9951d90f8d..2b6a3f3560 100644 --- a/docs/data/components/scroll-area/ScrollAreaInlay.js +++ b/docs/data/components/scroll-area/ScrollAreaInlay.js @@ -62,12 +62,17 @@ const ScrollAreaViewport = styled(ScrollArea.Viewport)` width: 100%; height: 100%; scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } `; const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` width: 10px; height: calc(100% - var(--scroll-area-corner-height)); background: rgb(220 220 220); + box-sizing: border-box; &[data-orientation='horizontal'] { width: calc(100% - var(--scroll-area-corner-width)); diff --git a/docs/data/components/scroll-area/ScrollAreaInlay.tsx b/docs/data/components/scroll-area/ScrollAreaInlay.tsx index 9951d90f8d..2b6a3f3560 100644 --- a/docs/data/components/scroll-area/ScrollAreaInlay.tsx +++ b/docs/data/components/scroll-area/ScrollAreaInlay.tsx @@ -62,12 +62,17 @@ const ScrollAreaViewport = styled(ScrollArea.Viewport)` width: 100%; height: 100%; scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } `; const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` width: 10px; height: calc(100% - var(--scroll-area-corner-height)); background: rgb(220 220 220); + box-sizing: border-box; &[data-orientation='horizontal'] { width: calc(100% - var(--scroll-area-corner-width)); diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js index c676846505..2f7e71664f 100644 --- a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js @@ -48,6 +48,10 @@ const ScrollAreaViewport = styled(ScrollArea.Viewport)` width: 100%; height: 100%; scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } `; const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` @@ -55,6 +59,7 @@ const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` padding: 2px; visibility: hidden; background: transparent; + box-sizing: border-box; transition: opacity 0.2s, background 0.2s, @@ -70,10 +75,6 @@ const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` height: 10px; } - &[data-scrolling] { - transition: none; - } - &[data-hovering], &[data-scrolling], &:hover { diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx index c676846505..2f7e71664f 100644 --- a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx @@ -48,6 +48,10 @@ const ScrollAreaViewport = styled(ScrollArea.Viewport)` width: 100%; height: 100%; scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } `; const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` @@ -55,6 +59,7 @@ const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` padding: 2px; visibility: hidden; background: transparent; + box-sizing: border-box; transition: opacity 0.2s, background 0.2s, @@ -70,10 +75,6 @@ const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)` height: 10px; } - &[data-scrolling] { - transition: none; - } - &[data-hovering], &[data-scrolling], &:hover { From afc0722ec53b600cb8d610ff7a7f767c6560130b Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 15 Oct 2024 15:16:54 +1100 Subject: [PATCH 20/72] Fix syntax replace error --- .../mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx | 2 +- packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx | 2 +- .../src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx | 2 +- packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx | 2 +- .../src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx index bf7b18b26c..36a6399a3a 100644 --- a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx +++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea''; +import { ScrollArea } from '@base_ui/react/ScrollArea'; import { expect } from 'chai'; import { screen } from '@mui/internal-test-utils'; import { createRenderer } from '#test-utils'; diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx index bb58c91b95..3e39efe9f9 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea''; +import { ScrollArea } from '@base_ui/react/ScrollArea'; import { screen } from '@mui/internal-test-utils'; import { createRenderer } from '#test-utils'; import { expect } from 'chai'; diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx index 7345696c34..ba6fb8429d 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea''; +import { ScrollArea } from '@base_ui/react/ScrollArea'; import { createRenderer } from '@mui/internal-test-utils'; import { describeConformance } from '../../../test/describeConformance'; diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx index 369f0bc86d..e065f028e7 100644 --- a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx +++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea''; +import { ScrollArea } from '@base_ui/react/ScrollArea'; import { createRenderer } from '@mui/internal-test-utils'; import { describeConformance } from '../../../test/describeConformance'; diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx index da030c4554..1a8eb7a15a 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ScrollArea } from '@base_ui/react/ScrollArea''; +import { ScrollArea } from '@base_ui/react/ScrollArea'; import { createRenderer } from '@mui/internal-test-utils'; import { describeConformance } from '../../../test/describeConformance'; From a294e124e273630b92d869ef8d3d85f8d04cfadc Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 15 Oct 2024 15:22:12 +1100 Subject: [PATCH 21/72] Add style hook tests --- .../Scrollbar/ScrollAreaScrollbar.test.tsx | 72 ++++++++++++++++++- .../Viewport/useScrollAreaViewport.tsx | 3 +- packages/mui-base/src/ScrollArea/constants.ts | 1 + 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 packages/mui-base/src/ScrollArea/constants.ts diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx index ba6fb8429d..d4bb7ecc3a 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx @@ -1,10 +1,15 @@ import * as React from 'react'; import { ScrollArea } from '@base_ui/react/ScrollArea'; -import { createRenderer } from '@mui/internal-test-utils'; +import { screen, fireEvent } from '@mui/internal-test-utils'; +import { createRenderer } from '#test-utils'; +import { expect } from 'chai'; import { describeConformance } from '../../../test/describeConformance'; +import { SCROLL_TIMEOUT } from '../constants'; describe('', () => { - const { render } = createRenderer(); + const { render, clock } = createRenderer(); + + clock.withFakeTimers(); describeConformance(, () => ({ refInstanceof: window.HTMLDivElement, @@ -12,4 +17,67 @@ describe('', () => { return render({node}); }, })); + + it('adds [data-hovering] attribute when viewport is hovered', async () => { + await render( + + +
    + + + + + , + ); + + const verticalScrollbar = screen.getByTestId('vertical'); + const horizontalScrollbar = screen.getByTestId('horizontal'); + + expect(verticalScrollbar).not.to.have.attribute('data-hovering'); + expect(horizontalScrollbar).not.to.have.attribute('data-hovering'); + + fireEvent.mouseEnter(screen.getByTestId('viewport')); + + expect(verticalScrollbar).to.have.attribute('data-hovering', 'true'); + expect(horizontalScrollbar).to.have.attribute('data-hovering', 'true'); + + fireEvent.mouseLeave(screen.getByTestId('viewport')); + + expect(verticalScrollbar).not.to.have.attribute('data-hovering'); + expect(horizontalScrollbar).not.to.have.attribute('data-hovering'); + }); + + it('adds [data-scrolling] attribute when viewport is scrolled', async () => { + await render( + + +
    + + + + + , + ); + + const verticalScrollbar = screen.getByTestId('vertical'); + const horizontalScrollbar = screen.getByTestId('horizontal'); + + expect(verticalScrollbar).not.to.have.attribute('data-scrolling'); + expect(horizontalScrollbar).not.to.have.attribute('data-scrolling'); + + fireEvent.scroll(screen.getByTestId('viewport')); + + expect(verticalScrollbar).to.have.attribute('data-scrolling', 'true'); + expect(horizontalScrollbar).to.have.attribute('data-scrolling', 'true'); + + clock.tick(SCROLL_TIMEOUT - 1); + + expect(verticalScrollbar).to.have.attribute('data-scrolling', 'true'); + expect(horizontalScrollbar).to.have.attribute('data-scrolling', 'true'); + + clock.tick(1); + + expect(verticalScrollbar).not.to.have.attribute('data-scrolling'); + expect(horizontalScrollbar).not.to.have.attribute('data-scrolling'); + }); }); diff --git a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx index 362a7bff1e..9940c4816e 100644 --- a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx +++ b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx @@ -4,6 +4,7 @@ import { useEventCallback } from '../../utils/useEventCallback'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { mergeReactProps } from '../../utils/mergeReactProps'; import { ownerWindow } from '../../utils/owner'; +import { SCROLL_TIMEOUT } from '../constants'; export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) { const { children } = params; @@ -207,7 +208,7 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) window.clearTimeout(timeoutRef.current); timeoutRef.current = window.setTimeout(() => { setScrolling(false); - }, 500); + }, SCROLL_TIMEOUT); }, children: (
    Date: Tue, 15 Oct 2024 19:30:53 +1100 Subject: [PATCH 22/72] Use new context pattern --- .../mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts | 6 ++++-- .../ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts index fd9ef63491..93de236006 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts @@ -21,7 +21,9 @@ export interface ScrollAreaRootContext { handlePointerUp: (event: React.PointerEvent) => void; } -export const ScrollAreaRootContext = React.createContext(null); +export const ScrollAreaRootContext = React.createContext( + undefined, +); if (process.env.NODE_ENV !== 'production') { ScrollAreaRootContext.displayName = 'ScrollAreaRootContext'; @@ -29,7 +31,7 @@ if (process.env.NODE_ENV !== 'production') { export function useScrollAreaRootContext() { const context = React.useContext(ScrollAreaRootContext); - if (context === null) { + if (context === undefined) { throw new Error('Base UI: ScrollAreaRootContext is undefined.'); } return context; diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts index 658c780d43..f24e6edc8e 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts @@ -4,9 +4,9 @@ export interface ScrollAreaScrollbarContext { orientation: 'horizontal' | 'vertical'; } -export const ScrollAreaScrollbarContext = React.createContext( - null, -); +export const ScrollAreaScrollbarContext = React.createContext< + ScrollAreaScrollbarContext | undefined +>(undefined); if (process.env.NODE_ENV !== 'production') { ScrollAreaScrollbarContext.displayName = 'ScrollAreaScrollbarContext'; @@ -14,7 +14,7 @@ if (process.env.NODE_ENV !== 'production') { export function useScrollAreaScrollbarContext() { const context = React.useContext(ScrollAreaScrollbarContext); - if (context === null) { + if (context === undefined) { throw new Error('Base UI: ScrollAreaScrollbarContext is undefined.'); } return context; From d5141049c61f20dc37dc861c76dc76fff4c310a3 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 15 Oct 2024 19:36:44 +1100 Subject: [PATCH 23/72] Change context error messages --- .../mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts | 4 +++- .../src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts index 93de236006..fd6855359a 100644 --- a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts +++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts @@ -32,7 +32,9 @@ if (process.env.NODE_ENV !== 'production') { export function useScrollAreaRootContext() { const context = React.useContext(ScrollAreaRootContext); if (context === undefined) { - throw new Error('Base UI: ScrollAreaRootContext is undefined.'); + throw new Error( + 'Base UI: ScrollAreaRootContext is missing. ScrollArea parts must be placed within .', + ); } return context; } diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts index f24e6edc8e..a400a292ab 100644 --- a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts +++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts @@ -15,7 +15,9 @@ if (process.env.NODE_ENV !== 'production') { export function useScrollAreaScrollbarContext() { const context = React.useContext(ScrollAreaScrollbarContext); if (context === undefined) { - throw new Error('Base UI: ScrollAreaScrollbarContext is undefined.'); + throw new Error( + 'Base UI: ScrollAreaScrollbarContext is missing. ScrollAreaScrollbar parts must be placed within .', + ); } return context; } From 33a00bf63b07e8efe4ec05501329e0650d4bee81 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 16 Oct 2024 14:19:17 +1100 Subject: [PATCH 24/72] Hide scrollbars with style tag, support overscroll-behavior, observe resizes on viewport --- .../ScrollAreaIntroduction/system/index.js | 7 +---- .../ScrollAreaIntroduction/system/index.tsx | 7 +---- .../src/ScrollArea/Root/ScrollAreaRoot.tsx | 12 +++++++++ .../ScrollArea/Root/ScrollAreaRootContext.ts | 1 + .../src/ScrollArea/Root/useScrollAreaRoot.ts | 5 ++++ .../Scrollbar/useScrollAreaScrollbar.ts | 26 ++++++++++++++++++- .../Viewport/useScrollAreaViewport.tsx | 7 +++-- 7 files changed, 50 insertions(+), 15 deletions(-) diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js index 2f7e71664f..3abc4eade2 100644 --- a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js +++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js @@ -8,7 +8,7 @@ const data = Array.from({ length: 30 }, (_, i) => i + 1); export default function ScrollAreaIntroduction() { return ( - +
      i + 1); export default function ScrollAreaIntroduction() { return ( - +
        + {rootId && ( +