Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[base-ui][material-ui][joy-ui][Modal] Add scrollLockContainer prop #34697

Closed
wants to merge 14 commits into from
1 change: 1 addition & 0 deletions docs/pages/base-ui/api/modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"onTransitionEnter": { "type": { "name": "func" } },
"onTransitionExited": { "type": { "name": "func" } },
"scrollLockContainer": { "type": { "name": "custom", "description": "HTML element" } },
"slotProps": {
"type": {
"name": "shape",
Expand Down
5 changes: 4 additions & 1 deletion docs/pages/base-ui/api/use-modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@
"type": { "name": "React.KeyboardEventHandler", "description": "React.KeyboardEventHandler" }
},
"onTransitionEnter": { "type": { "name": "() => void", "description": "() => void" } },
"onTransitionExited": { "type": { "name": "() => void", "description": "() => void" } }
"onTransitionExited": { "type": { "name": "() => void", "description": "() => void" } },
"scrollLockContainer": {
"type": { "name": "HTMLElement | undefined", "description": "HTMLElement | undefined" }
}
},
"returnValue": {
"exited": { "type": { "name": "boolean", "description": "boolean" }, "required": true },
Expand Down
1 change: 1 addition & 0 deletions docs/pages/joy-ui/api/drawer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"describedArgs": ["event", "reason"]
}
},
"scrollLockContainer": { "type": { "name": "custom", "description": "HTML element" } },
"size": {
"type": { "name": "enum", "description": "'sm'<br>&#124;&nbsp;'md'<br>&#124;&nbsp;'lg'" },
"default": "'md'",
Expand Down
1 change: 1 addition & 0 deletions docs/pages/joy-ui/api/modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"describedArgs": ["event", "reason"]
}
},
"scrollLockContainer": { "type": { "name": "custom", "description": "HTML element" } },
"slotProps": {
"type": {
"name": "shape",
Expand Down
1 change: 1 addition & 0 deletions docs/pages/material-ui/api/modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
},
"onTransitionEnter": { "type": { "name": "func" } },
"onTransitionExited": { "type": { "name": "func" } },
"scrollLockContainer": { "type": { "name": "custom", "description": "HTML element" } },
"slotProps": {
"type": {
"name": "shape",
Expand Down
3 changes: 3 additions & 0 deletions docs/translations/api-docs-base/modal/modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
"onTransitionEnter": { "description": "A function called when a transition enters." },
"onTransitionExited": { "description": "A function called when a transition has exited." },
"open": { "description": "If <code>true</code>, the component is shown." },
"scrollLockContainer": {
"description": "An HTML element. The <code>scrollLockContainer</code> will have scroll lock styles applied to it.<br>By default, it uses the the body of the top-level document object, so it&#39;s simply <code>document.body</code> most of the time."
},
"slotProps": { "description": "The props used for each slot inside the Modal." },
"slots": {
"description": "The components used for each slot inside the Modal. Either a string to use a HTML element or a component."
Expand Down
3 changes: 3 additions & 0 deletions docs/translations/api-docs-joy/drawer/drawer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
}
},
"open": { "description": "If <code>true</code>, the component is shown." },
"scrollLockContainer": {
"description": "An HTML element. The <code>scrollLockContainer</code> will have scroll lock styles applied to it.<br>By default, it uses the the body of the top-level document object, so it&#39;s simply <code>document.body</code> most of the time."
},
"size": { "description": "The size of the component." },
"slotProps": { "description": "The props used for each slot inside." },
"slots": { "description": "The components used for each slot inside." },
Expand Down
3 changes: 3 additions & 0 deletions docs/translations/api-docs-joy/modal/modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
}
},
"open": { "description": "If <code>true</code>, the component is shown." },
"scrollLockContainer": {
"description": "An HTML element. The <code>scrollLockContainer</code> will have scroll lock styles applied to it.<br>By default, it uses the the body of the top-level document object, so it&#39;s simply <code>document.body</code> most of the time."
},
"slotProps": { "description": "The props used for each slot inside." },
"slots": { "description": "The components used for each slot inside." },
"sx": {
Expand Down
3 changes: 3 additions & 0 deletions docs/translations/api-docs/modal/modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
"onTransitionEnter": { "description": "A function called when a transition enters." },
"onTransitionExited": { "description": "A function called when a transition has exited." },
"open": { "description": "If <code>true</code>, the component is shown." },
"scrollLockContainer": {
"description": "An HTML element. The <code>scrollLockContainer</code> will have scroll lock styles applied to it.<br>By default, it uses the the body of the top-level document object, so it&#39;s simply <code>document.body</code> most of the time."
},
"slotProps": { "description": "The props used for each slot inside the Modal." },
"slots": {
"description": "The components used for each slot inside the Modal. Either a string to use a HTML element or a component."
Expand Down
5 changes: 4 additions & 1 deletion docs/translations/api-docs/use-modal/use-modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
},
"onTransitionEnter": { "description": "A function called when a transition enters." },
"onTransitionExited": { "description": "A function called when a transition has exited." },
"open": { "description": "If <code>true</code>, the component is shown." }
"open": { "description": "If <code>true</code>, the component is shown." },
"scrollLockContainer": {
"description": "An HTML element. The <code>scrollLockContainer</code> will have scroll lock styles applied to it.<br>By default, it uses the the body of the top-level document object, so it&#39;s simply <code>document.body</code> most of the time."
}
},
"returnValueDescriptions": {
"exited": {
Expand Down
20 changes: 20 additions & 0 deletions packages/mui-base/src/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,24 @@ describe('<Modal />', () => {
const { current: element } = elementRef;
expect(element!.getAttribute('aria-hidden'), 'null when modal closed').to.equal(null);
});

it('should work with scrollLockContainer', function test() {
if (/jsdom/.test(window.navigator.userAgent)) {
// jsdom cannot emulate scrollbar, we'll test this in real browser.
this.skip();
}
const scrollLockContainer = document.body.parentElement!;
scrollLockContainer.style.paddingRight = '344px';
const { setProps } = render(
<Modal open scrollLockContainer={scrollLockContainer}>
<div style={{ height: 10000 }} />
</Modal>,
);

expect(scrollLockContainer.style.overflow).to.equal('hidden');
expect(parseInt(scrollLockContainer.style.paddingRight, 10) > 344).to.equal(true);
setProps({ open: false });
expect(scrollLockContainer.style.overflow).to.equal('');
expect(scrollLockContainer.style.paddingRight).to.equal('344px');
});
});
10 changes: 10 additions & 0 deletions packages/mui-base/src/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const Modal = React.forwardRef(function Modal<RootComponentType extends React.El
open,
onTransitionEnter,
onTransitionExited,
scrollLockContainer,
slotProps = {},
slots = {},
...other
Expand Down Expand Up @@ -95,6 +96,7 @@ const Modal = React.forwardRef(function Modal<RootComponentType extends React.El
} = useModal({
...propsWithDefaults,
rootRef: forwardedRef,
scrollLockContainer,
});

const ownerState = {
Expand Down Expand Up @@ -284,6 +286,14 @@ Modal.propTypes /* remove-proptypes */ = {
* If `true`, the component is shown.
*/
open: PropTypes.bool.isRequired,
/**
* An HTML element.
* The `scrollLockContainer` will have scroll lock styles applied to it.
*
* By default, it uses the the body of the top-level document object,
* so it's simply `document.body` most of the time.
*/
scrollLockContainer: HTMLElementType,
/**
* The props used for each slot inside the Modal.
* @default {}
Expand Down
8 changes: 8 additions & 0 deletions packages/mui-base/src/Modal/Modal.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export interface ModalOwnProps {
* so it's simply `document.body` most of the time.
*/
container?: PortalProps['container'];
/**
* An HTML element.
* The `scrollLockContainer` will have scroll lock styles applied to it.
*
* By default, it uses the the body of the top-level document object,
* so it's simply `document.body` most of the time.
*/
scrollLockContainer?: HTMLElement | undefined;
/**
* If `true`, the modal will not automatically shift focus to itself when it opens, and
* replace it to the last focused element when it closes.
Expand Down
24 changes: 24 additions & 0 deletions packages/mui-base/src/unstable_useModal/ModalManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,30 @@ describe('ModalManager', () => {
expect(fixedNode.style.paddingRight).to.equal('14px');
});

it('should not handle the scroll with custom scroll lock container', function test() {
if (/jsdom/.test(window.navigator.userAgent)) {
// jsdom cannot emulate scrollbar, we'll test this in real browser.
this.skip();
}
const html = document.querySelector('html')!;
html.style.paddingRight = '32px';
html.style.height = '1000px';

const scrollbarSize = getScrollbarSize(document);
const modal = getDummyModal();
modalManager.add(modal, container1);
modalManager.mount(modal, {
scrollLockContainer: html,
});
expect(container1.style.overflow).to.equal('');
expect(container1.style.paddingRight).to.equal('20px');
expect(html.style.overflow).to.equal('hidden');
expect(html.style.paddingRight).to.equal(`${32 + scrollbarSize}px`);
modalManager.remove(modal);
expect(html.style.overflow).to.equal('');
expect(html.style.paddingRight).to.equal('32px');
});

it('should disable the scroll even when not overflowing', () => {
// simulate non-overflowing container
const container2 = document.createElement('div');
Expand Down
50 changes: 25 additions & 25 deletions packages/mui-base/src/unstable_useModal/ModalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {

export interface ManagedModalProps {
disableScrollLock?: boolean;
scrollLockContainer?: HTMLElement;
}

// Is a vertical scrollbar displayed?
Expand Down Expand Up @@ -86,7 +87,7 @@ function findIndexOf<T>(items: readonly T[], callback: (item: T) => boolean): nu
return idx;
}

function handleContainer(containerInfo: Container, props: ManagedModalProps) {
function handleContainer(container: HTMLElement, props: ManagedModalProps) {
const restoreStyle: Array<{
/**
* CSS property name (HYPHEN CASE) to be modified.
Expand All @@ -95,23 +96,38 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) {
el: HTMLElement | SVGElement;
value: string;
}> = [];
const container = containerInfo.container;
let scrollContainer: HTMLElement;

if (props.scrollLockContainer) {
scrollContainer = props.scrollLockContainer;
} else if (container.parentNode instanceof DocumentFragment) {
scrollContainer = ownerDocument(container).body;
} else {
// Improve Gatsby support
// https://css-tricks.com/snippets/css/force-vertical-scrollbar/
const parent = container.parentElement;
const containerWindow = ownerWindow(container);
scrollContainer =
parent?.nodeName === 'HTML' && containerWindow.getComputedStyle(parent).overflowY === 'scroll'
? parent
: container;
}

if (!props.disableScrollLock) {
if (isOverflowing(container)) {
if (isOverflowing(scrollContainer)) {
// Compute the size before applying overflow hidden to avoid any scroll jumps.
const scrollbarSize = getScrollbarSize(ownerDocument(container));
const scrollbarSize = getScrollbarSize(ownerDocument(scrollContainer));

restoreStyle.push({
value: container.style.paddingRight,
value: scrollContainer.style.paddingRight,
property: 'padding-right',
el: container,
el: scrollContainer,
});
// Use computed style, here to get the real padding to add our scrollbar width.
container.style.paddingRight = `${getPaddingRight(container) + scrollbarSize}px`;
scrollContainer.style.paddingRight = `${getPaddingRight(scrollContainer) + scrollbarSize}px`;

// .mui-fixed is a global helper.
const fixedElements = ownerDocument(container).querySelectorAll('.mui-fixed');
const fixedElements = ownerDocument(scrollContainer).querySelectorAll('.mui-fixed');
[].forEach.call(fixedElements, (element: HTMLElement | SVGElement) => {
restoreStyle.push({
value: element.style.paddingRight,
Expand All @@ -122,22 +138,6 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) {
});
}

let scrollContainer: HTMLElement;

if (container.parentNode instanceof DocumentFragment) {
scrollContainer = ownerDocument(container).body;
} else {
// Support html overflow-y: auto for scroll stability between pages
// https://css-tricks.com/snippets/css/force-vertical-scrollbar/
const parent = container.parentElement;
const containerWindow = ownerWindow(container);
scrollContainer =
parent?.nodeName === 'HTML' &&
containerWindow.getComputedStyle(parent).overflowY === 'scroll'
? parent
: container;
}

// Block the scroll even if no scrollbar is visible to account for mobile keyboard
// screensize shrink.
restoreStyle.push(
Expand Down Expand Up @@ -254,7 +254,7 @@ export class ModalManager {
const containerInfo = this.containers[containerIndex];

if (!containerInfo.restore) {
containerInfo.restore = handleContainer(containerInfo, props);
containerInfo.restore = handleContainer(containerInfo.container, props);
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/mui-base/src/unstable_useModal/useModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function useModal(parameters: UseModalParameters): UseModalReturnValue {
container,
disableEscapeKeyDown = false,
disableScrollLock = false,
scrollLockContainer,
// @ts-ignore internal logic - Base UI supports the manager as a prop too
manager = defaultManager,
closeAfterTransition = false,
Expand Down Expand Up @@ -73,7 +74,7 @@ export function useModal(parameters: UseModalParameters): UseModalReturnValue {
};

const handleMounted = () => {
manager.mount(getModal(), { disableScrollLock });
(manager as ModalManager).mount(getModal(), { disableScrollLock, scrollLockContainer });

// Fix a bug on Chrome where the scroll isn't initially 0.
if (modalRef.current) {
Expand Down
8 changes: 8 additions & 0 deletions packages/mui-base/src/unstable_useModal/useModal.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export type UseModalParameters = {
* so it's simply `document.body` most of the time.
*/
container?: PortalProps['container'];
/**
* An HTML element.
* The `scrollLockContainer` will have scroll lock styles applied to it.
*
* By default, it uses the the body of the top-level document object,
* so it's simply `document.body` most of the time.
*/
scrollLockContainer?: HTMLElement | undefined;
/**
* If `true`, hitting escape will not fire the `onClose` callback.
* @default false
Expand Down
9 changes: 9 additions & 0 deletions packages/mui-joy/src/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ const Drawer = React.forwardRef(function Drawer(inProps, ref) {
children,
anchor = 'left',
container,
scrollLockContainer,
disableAutoFocus = false,
disableEnforceFocus = false,
disableEscapeKeyDown = false,
Expand Down Expand Up @@ -373,6 +374,14 @@ Drawer.propTypes /* remove-proptypes */ = {
* If `true`, the component is shown.
*/
open: PropTypes.bool.isRequired,
/**
* An HTML element.
* The `scrollLockContainer` will have scroll lock styles applied to it.
*
* By default, it uses the the body of the top-level document object,
* so it's simply `document.body` most of the time.
*/
scrollLockContainer: HTMLElementType,
/**
* The size of the component.
* @default 'md'
Expand Down
9 changes: 9 additions & 0 deletions packages/mui-joy/src/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const Modal = React.forwardRef(function Modal(inProps, ref) {
onKeyDown,
open,
component,
scrollLockContainer,
slots = {},
slotProps = {},
...other
Expand Down Expand Up @@ -269,6 +270,14 @@ Modal.propTypes /* remove-proptypes */ = {
* If `true`, the component is shown.
*/
open: PropTypes.bool.isRequired,
/**
* An HTML element.
* The `scrollLockContainer` will have scroll lock styles applied to it.
*
* By default, it uses the the body of the top-level document object,
* so it's simply `document.body` most of the time.
*/
scrollLockContainer: HTMLElementType,
/**
* The props used for each slot inside.
* @default {}
Expand Down
1 change: 1 addition & 0 deletions packages/mui-joy/src/Modal/ModalProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type ModalOwnProps = Pick<
BaseModalOwnProps,
| 'children'
| 'container'
| 'scrollLockContainer'
| 'disableAutoFocus'
| 'disableEnforceFocus'
| 'disableEscapeKeyDown'
Expand Down
8 changes: 8 additions & 0 deletions packages/mui-material/src/Modal/Modal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ export interface ModalOwnProps {
* so it's simply `document.body` most of the time.
*/
container?: PortalProps['container'];
/**
* An HTML element.
* The `scrollLockContainer` will have scroll lock styles applied to it.
*
* By default, it uses the the body of the top-level document object,
* so it's simply `document.body` most of the time.
*/
scrollLockContainer?: HTMLElement | undefined;
/**
* If `true`, the modal will not automatically shift focus to itself when it opens, and
* replace it to the last focused element when it closes.
Expand Down
Loading