diff --git a/src/components/ui/Drawer/fragments/DrawerRoot.tsx b/src/components/ui/Drawer/fragments/DrawerRoot.tsx index 5745ebb2e..f5cee9e81 100644 --- a/src/components/ui/Drawer/fragments/DrawerRoot.tsx +++ b/src/components/ui/Drawer/fragments/DrawerRoot.tsx @@ -1,4 +1,5 @@ 'use client'; +import { useRegisterDocumentOverlayOpen } from '~/core/hooks/useRegisterDocumentOverlayOpen'; import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import { useComponentClass } from '~/components/ui/Theme/useComponentClass'; @@ -105,6 +106,7 @@ const DrawerRoot = forwardRef(({ const isControlled = controlledOpen !== undefined; const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const isOpen = isControlled ? controlledOpen! : uncontrolledOpen; + useRegisterDocumentOverlayOpen(isOpen); // When disablePointerDismissal is true we need to block the close that // floating-ui's useDismiss fires via onOpenChange on outside pointer events. diff --git a/src/components/ui/ScrollArea/context/ScrollAreaContext.tsx b/src/components/ui/ScrollArea/context/ScrollAreaContext.tsx index 95794dc40..0fafa5bcd 100644 --- a/src/components/ui/ScrollArea/context/ScrollAreaContext.tsx +++ b/src/components/ui/ScrollArea/context/ScrollAreaContext.tsx @@ -14,6 +14,7 @@ interface ScrollAreaContextType { type: ScrollAreaScrollbarType; scrollbarVisible: boolean; overflow: { x: boolean; y: boolean }; + overlaySuppressesScrollbar: boolean; rootRef?: RefObject; } @@ -21,5 +22,6 @@ export const ScrollAreaContext = createContext({ rootClass: '', type: 'hover', scrollbarVisible: false, - overflow: { x: false, y: false } + overflow: { x: false, y: false }, + overlaySuppressesScrollbar: false }); diff --git a/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx b/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx index 39668dbad..48590ac05 100644 --- a/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx +++ b/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx @@ -6,6 +6,7 @@ import clsx from 'clsx'; import { useComponentClass } from '~/components/ui/Theme/useComponentClass'; import { ScrollAreaContext, type ScrollAreaScrollbarType } from '../context/ScrollAreaContext'; import { useScrollbarVisibility } from '../hooks/useScrollbarVisibility'; +import { useDocumentOverlayOpenState } from '~/core/hooks/useDocumentOverlayOpenState'; const COMPONENT_NAME = 'ScrollArea'; @@ -31,6 +32,7 @@ const ScrollAreaRoot = forwardRef(({ const [overflow, setOverflow] = React.useState({ x: false, y: false }); const scrollbarVisible = useScrollbarVisibility(type, scrollAreaViewportRef, internalRootRef); + const overlaySuppressesScrollbar = useDocumentOverlayOpenState(); const mergedRootRef = (node: HTMLDivElement | null) => { (internalRootRef as any).current = node; @@ -222,6 +224,7 @@ const ScrollAreaRoot = forwardRef(({ type, scrollbarVisible, overflow, + overlaySuppressesScrollbar, rootRef: internalRootRef }}>
& { }; const ScrollAreaScrollbar = forwardRef(({ children, className = '', orientation = 'vertical', ...props }, ref) => { - const { rootClass, handleScrollbarClick, scrollXThumbRef, scrollYThumbRef, type, scrollbarVisible, overflow } = useContext(ScrollAreaContext); + const { rootClass, handleScrollbarClick, scrollXThumbRef, scrollYThumbRef, type, scrollbarVisible, overflow, overlaySuppressesScrollbar } = useContext(ScrollAreaContext); const intervalRef = useRef(null); const isScrollingRef = useRef(false); @@ -107,10 +107,11 @@ const ScrollAreaScrollbar = forwardRef & { }; const ScrollAreaThumb = forwardRef(({ children, className = '', orientation = 'vertical', ...props }, ref) => { - const { rootClass, scrollXThumbRef, scrollYThumbRef, scrollAreaViewportRef, type, scrollbarVisible, overflow } = useContext(ScrollAreaContext); + const { rootClass, scrollXThumbRef, scrollYThumbRef, scrollAreaViewportRef, type, scrollbarVisible, overflow, overlaySuppressesScrollbar } = useContext(ScrollAreaContext); const isOverflowing = orientation === 'vertical' ? overflow.y : overflow.x; - const isVisible = + const isVisible = !overlaySuppressesScrollbar && ( type === 'always' || (type === 'auto' && isOverflowing) - || (isOverflowing && (type === 'scroll' || type === 'hover') && scrollbarVisible); + || (isOverflowing && (type === 'scroll' || type === 'hover') && scrollbarVisible) + ); const isDraggingRef = useRef(false); const dragStartRef = useRef({ x: 0, y: 0, scrollTop: 0, scrollLeft: 0 }); diff --git a/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx b/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx index cc50437a6..f6636c780 100644 --- a/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx +++ b/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx @@ -176,4 +176,47 @@ describe('ScrollArea', () => { expect(screen.getByTestId('scrollbar')).toHaveAttribute('data-state', 'visible'); }); + + test('hides scrollbar while document overlay is open', () => { + document.documentElement.setAttribute('data-rad-ui-overlay-open', ''); + try { + render( + + +
content
+
+ + + +
+ ); + + expect(screen.getByTestId('scrollbar')).toHaveAttribute('data-state', 'hidden'); + expect(screen.getByTestId('thumb')).toHaveAttribute('data-state', 'hidden'); + } finally { + document.documentElement.removeAttribute('data-rad-ui-overlay-open'); + } + }); + + test('hides scrollbar while body scroll is locked', () => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + try { + render( + + +
content
+
+ + + +
+ ); + + expect(screen.getByTestId('scrollbar')).toHaveAttribute('data-state', 'hidden'); + expect(screen.getByTestId('thumb')).toHaveAttribute('data-state', 'hidden'); + } finally { + document.body.style.overflow = previousOverflow; + } + }); }); diff --git a/src/core/hooks/useDocumentOverlayOpenState/index.ts b/src/core/hooks/useDocumentOverlayOpenState/index.ts new file mode 100644 index 000000000..452cfdf44 --- /dev/null +++ b/src/core/hooks/useDocumentOverlayOpenState/index.ts @@ -0,0 +1,37 @@ +'use client'; + +import React from 'react'; +import { + DOCUMENT_OVERLAY_OPEN_ATTRIBUTE, + isDocumentOverlayOpen, + subscribeDocumentOverlayOpen +} from '~/core/utils/documentOverlayOpen'; + +export function useDocumentOverlayOpenState() { + const [overlayOpen, setOverlayOpen] = React.useState(() => isDocumentOverlayOpen()); + + React.useEffect(() => { + const update = () => { + setOverlayOpen(isDocumentOverlayOpen()); + }; + + const unsubscribe = subscribeDocumentOverlayOpen(update); + + const mutationObserver = new MutationObserver(update); + mutationObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: [DOCUMENT_OVERLAY_OPEN_ATTRIBUTE] + }); + mutationObserver.observe(document.body, { + attributes: true, + attributeFilter: ['style'] + }); + + return () => { + unsubscribe(); + mutationObserver.disconnect(); + }; + }, []); + + return overlayOpen; +} diff --git a/src/core/hooks/useRegisterDocumentOverlayOpen/index.ts b/src/core/hooks/useRegisterDocumentOverlayOpen/index.ts new file mode 100644 index 000000000..25eb3ca06 --- /dev/null +++ b/src/core/hooks/useRegisterDocumentOverlayOpen/index.ts @@ -0,0 +1,14 @@ +'use client'; + +import useLayoutEffect from '~/core/hooks/useLayoutEffect'; +import { registerDocumentOverlayOpen } from '~/core/utils/documentOverlayOpen'; + +export function useRegisterDocumentOverlayOpen(open: boolean) { + useLayoutEffect(() => { + if (!open) { + return; + } + + return registerDocumentOverlayOpen(); + }, [open]); +} diff --git a/src/core/primitives/Combobox/fragments/ComboboxPrimitiveRoot.tsx b/src/core/primitives/Combobox/fragments/ComboboxPrimitiveRoot.tsx index b458d5c90..8aba49933 100644 --- a/src/core/primitives/Combobox/fragments/ComboboxPrimitiveRoot.tsx +++ b/src/core/primitives/Combobox/fragments/ComboboxPrimitiveRoot.tsx @@ -4,6 +4,7 @@ import Primitive from '../../Primitive'; import { ComboboxPrimitiveContext } from '../contexts/ComboboxPrimitiveContext'; import useControllableState from '~/core/hooks/useControllableState'; import Floater from '~/core/primitives/Floater'; +import { useRegisterDocumentOverlayOpen } from '~/core/hooks/useRegisterDocumentOverlayOpen'; import { Middleware, Placement, Strategy } from '@floating-ui/react'; import { useIsInsideForm } from '~/core/hooks/useIsInsideForm'; @@ -76,6 +77,7 @@ const ComboboxPrimitiveRoot = React.forwardRef< ...props }, forwardedRef) => { const [isOpen, setIsOpen] = React.useState(false); + useRegisterDocumentOverlayOpen(isOpen); const [selectedValue, setSelectedValue] = useControllableState( value, defaultValue, diff --git a/src/core/primitives/Dialog/fragments/DialogPrimitiveRoot.tsx b/src/core/primitives/Dialog/fragments/DialogPrimitiveRoot.tsx index 3a73463ce..4c76fece1 100644 --- a/src/core/primitives/Dialog/fragments/DialogPrimitiveRoot.tsx +++ b/src/core/primitives/Dialog/fragments/DialogPrimitiveRoot.tsx @@ -2,6 +2,7 @@ import React, { forwardRef, useState, useEffect } from 'react'; import { DialogPrimitiveContext } from '../context/DialogPrimitiveContext'; import Floater from '~/core/primitives/Floater'; +import { useRegisterDocumentOverlayOpen } from '~/core/hooks/useRegisterDocumentOverlayOpen'; export type DialogPrimitiveRootProps = { children: React.ReactNode; @@ -18,6 +19,7 @@ const COMPONENT_NAME = 'DialogPrimitive'; const DialogPrimitiveRootInner = forwardRef(({ children, open = false, onOpenChange = () => {}, onClickOutside = () => {}, className, disablePointerDismissal = false, ...props }, ref) => { const [isOpen, setIsOpen] = useState(open); const nodeId = Floater.useFloatingNodeId(); + useRegisterDocumentOverlayOpen(isOpen); // Sync internal state with the open prop useEffect(() => { diff --git a/src/core/primitives/Menu/fragments/MenuPrimitiveRoot.tsx b/src/core/primitives/Menu/fragments/MenuPrimitiveRoot.tsx index 2a907c832..782d6d898 100644 --- a/src/core/primitives/Menu/fragments/MenuPrimitiveRoot.tsx +++ b/src/core/primitives/Menu/fragments/MenuPrimitiveRoot.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef, forwardRef, ElementRef, ComponentPropsWithoutR import MenuPrimitiveRootContext from '../contexts/MenuPrimitiveRootContext'; import Floater from '~/core/primitives/Floater'; import { useControllableState } from '~/core/hooks/useControllableState'; +import { useRegisterDocumentOverlayOpen } from '~/core/hooks/useRegisterDocumentOverlayOpen'; export type MenuPrimitiveRootElement = ElementRef<'div'>; export type MenuPrimitiveRootProps = { @@ -37,6 +38,7 @@ export const MenuComponentRoot = forwardRef(null); const [maxHeight, setMaxHeight] = useState(undefined); diff --git a/src/core/primitives/Popover/fragments/PopoverPrimitiveRoot.tsx b/src/core/primitives/Popover/fragments/PopoverPrimitiveRoot.tsx index cd50952f9..93e0ad759 100644 --- a/src/core/primitives/Popover/fragments/PopoverPrimitiveRoot.tsx +++ b/src/core/primitives/Popover/fragments/PopoverPrimitiveRoot.tsx @@ -4,6 +4,7 @@ import React, { forwardRef } from 'react'; import Floater from '~/core/primitives/Floater'; import Primitive from '~/core/primitives/Primitive'; import { useControllableState } from '~/core/hooks/useControllableState'; +import { useRegisterDocumentOverlayOpen } from '~/core/hooks/useRegisterDocumentOverlayOpen'; import { defaultPopoverPositioning, PopoverPrimitiveContext @@ -57,6 +58,7 @@ const PopoverPrimitiveRootInner = forwardRef { const [isOpen, setIsOpen] = useControllableState(controlledOpen, defaultOpen, onOpenChange); + useRegisterDocumentOverlayOpen(isOpen); const [triggerNode, setTriggerNode] = React.useState(null); const [anchorNode, setAnchorNode] = React.useState(null); const [arrowNode, setArrowNode] = React.useState(null); diff --git a/src/core/utils/documentOverlayOpen/documentOverlayOpen.ts b/src/core/utils/documentOverlayOpen/documentOverlayOpen.ts new file mode 100644 index 000000000..cf6221e79 --- /dev/null +++ b/src/core/utils/documentOverlayOpen/documentOverlayOpen.ts @@ -0,0 +1,76 @@ +export const DOCUMENT_OVERLAY_OPEN_ATTRIBUTE = 'data-rad-ui-overlay-open'; + +let overlayOpenCount = 0; +const listeners = new Set<() => void>(); + +function notifyListeners() { + listeners.forEach((listener) => listener()); +} + +function syncDocumentOverlayAttribute() { + if (typeof document === 'undefined') { + return; + } + + const root = document.documentElement; + + if (overlayOpenCount > 0) { + root.setAttribute(DOCUMENT_OVERLAY_OPEN_ATTRIBUTE, ''); + } else { + root.removeAttribute(DOCUMENT_OVERLAY_OPEN_ATTRIBUTE); + } +} + +export function isDocumentBodyScrollLocked() { + if (typeof document === 'undefined') { + return false; + } + + return document.body.style.overflow === 'hidden'; +} + +export function isDocumentOverlayOpen() { + if (typeof document === 'undefined') { + return false; + } + + return ( + overlayOpenCount > 0 + || document.documentElement.hasAttribute(DOCUMENT_OVERLAY_OPEN_ATTRIBUTE) + || isDocumentBodyScrollLocked() + ); +} + +export function registerDocumentOverlayOpen() { + overlayOpenCount += 1; + syncDocumentOverlayAttribute(); + notifyListeners(); + + let disposed = false; + + return () => { + if (disposed) { + return; + } + + disposed = true; + overlayOpenCount = Math.max(0, overlayOpenCount - 1); + syncDocumentOverlayAttribute(); + notifyListeners(); + }; +} + +export function subscribeDocumentOverlayOpen(listener: () => void) { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +/** Resets overlay bookkeeping between tests. */ +export function resetDocumentOverlayOpenForTests() { + overlayOpenCount = 0; + syncDocumentOverlayAttribute(); + notifyListeners(); +} diff --git a/src/core/utils/documentOverlayOpen/index.ts b/src/core/utils/documentOverlayOpen/index.ts new file mode 100644 index 000000000..1d706d809 --- /dev/null +++ b/src/core/utils/documentOverlayOpen/index.ts @@ -0,0 +1,8 @@ +export { + DOCUMENT_OVERLAY_OPEN_ATTRIBUTE, + isDocumentBodyScrollLocked, + isDocumentOverlayOpen, + registerDocumentOverlayOpen, + resetDocumentOverlayOpenForTests, + subscribeDocumentOverlayOpen +} from './documentOverlayOpen'; diff --git a/src/setupTests.ts b/src/setupTests.ts index 72d51e17f..a9ce594eb 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,5 +1,14 @@ import '@testing-library/jest-dom'; import { act } from '@testing-library/react'; +import { DOCUMENT_OVERLAY_OPEN_ATTRIBUTE, resetDocumentOverlayOpenForTests } from '~/core/utils/documentOverlayOpen'; + +afterEach(() => { + resetDocumentOverlayOpenForTests(); + document.documentElement.removeAttribute(DOCUMENT_OVERLAY_OPEN_ATTRIBUTE); + document.body.style.overflow = ''; + document.body.style.paddingRight = ''; +}); + // Prevent Floating UI's autoUpdate from triggering state updates outside of React // "act" by calling the update callback synchronously inside an act wrapper.