From 2999d308de65f3314f3fda8291454bbaaf943aa4 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Thu, 27 Jul 2023 15:50:34 +0200 Subject: [PATCH 01/24] [joy] Add `useModal` internal hook --- packages/mui-joy/src/Modal/Modal.tsx | 153 ++---------------- packages/mui-joy/src/Modal/useModal.ts | 213 +++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 142 deletions(-) create mode 100644 packages/mui-joy/src/Modal/useModal.ts diff --git a/packages/mui-joy/src/Modal/Modal.tsx b/packages/mui-joy/src/Modal/Modal.tsx index ea0be026b306a8..7641ec6122100b 100644 --- a/packages/mui-joy/src/Modal/Modal.tsx +++ b/packages/mui-joy/src/Modal/Modal.tsx @@ -2,31 +2,17 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { OverridableComponent } from '@mui/types'; -import { - elementAcceptingRef, - HTMLElementType, - unstable_ownerDocument as ownerDocument, - unstable_useForkRef as useForkRef, - unstable_useEventCallback as useEventCallback, -} from '@mui/utils'; +import { elementAcceptingRef, HTMLElementType } from '@mui/utils'; import composeClasses from '@mui/base/composeClasses'; import Portal from '@mui/base/Portal'; import FocusTrap from '@mui/base/FocusTrap'; -import { ModalManager } from '@mui/base/Modal'; +import useModal from './useModal'; import { styled, useThemeProps } from '../styles'; import useSlot from '../utils/useSlot'; import { getModalUtilityClass } from './modalClasses'; import { ModalOwnerState, ModalTypeMap } from './ModalProps'; import CloseModalContext from './CloseModalContext'; -function ariaHidden(element: Element, show: boolean): void { - if (show) { - element.setAttribute('aria-hidden', 'true'); - } else { - element.removeAttribute('aria-hidden'); - } -} - const useUtilityClasses = (ownerState: ModalOwnerState) => { const { open } = ownerState; @@ -38,14 +24,6 @@ const useUtilityClasses = (ownerState: ModalOwnerState) => { return composeClasses(slots, getModalUtilityClass, {}); }; -function getContainer(container: ModalOwnerState['container']) { - return (typeof container === 'function' ? container() : container) as HTMLElement; -} - -// A modal manager used to track and manage the state of open Modals. -// Modals don't open on the server so this won't conflict with concurrent requests. -const manager = new ModalManager(); - const ModalRoot = styled('div', { name: 'JoyModal', slot: 'Root', @@ -120,81 +98,6 @@ const Modal = React.forwardRef(function ModalU(inProps, ref) { ...other } = props; - // @ts-ignore internal logic - const modal = React.useRef<{ modalRef: HTMLDivElement; mount: HTMLElement }>({}); - const mountNodeRef = React.useRef(null); - const modalRef = React.useRef(null); - const handleRef = useForkRef(modalRef, ref); - - let ariaHiddenProp = true; - if ( - props['aria-hidden'] === 'false' || - (typeof props['aria-hidden'] === 'boolean' && !props['aria-hidden']) - ) { - ariaHiddenProp = false; - } - - const getDoc = () => ownerDocument(mountNodeRef.current); - const getModal = () => { - modal.current.modalRef = modalRef.current as HTMLDivElement; - modal.current.mount = mountNodeRef.current as HTMLElement; - return modal.current; - }; - - const handleMounted = () => { - manager.mount(getModal(), { disableScrollLock }); - - // Fix a bug on Chrome where the scroll isn't initially 0. - if (modalRef.current) { - modalRef.current.scrollTop = 0; - } - }; - - const handleOpen = useEventCallback(() => { - const resolvedContainer = getContainer(container) || getDoc().body; - - manager.add(getModal(), resolvedContainer); - - // The element was already mounted. - if (modalRef.current) { - handleMounted(); - } - }); - - const isTopModal = () => manager.isTopModal(getModal()); - - const handlePortalRef = useEventCallback((node: HTMLElement) => { - mountNodeRef.current = node; - - if (!node) { - return; - } - - if (open && isTopModal()) { - handleMounted(); - } else if (modalRef.current) { - ariaHidden(modalRef.current, ariaHiddenProp); - } - }); - - const handleClose = React.useCallback(() => { - manager.remove(getModal(), ariaHiddenProp); - }, [ariaHiddenProp]); - - React.useEffect(() => { - return () => { - handleClose(); - }; - }, [handleClose]); - - React.useEffect(() => { - if (open) { - handleOpen(); - } else { - handleClose(); - } - }, [open, handleClose, handleOpen]); - const ownerState = { ...props, disableAutoFocus, @@ -207,62 +110,28 @@ const Modal = React.forwardRef(function ModalU(inProps, ref) { keepMounted, }; + const { getRootProps, getBackdropProps, rootRef, portalRef, isTopModal } = useModal({ + ...ownerState, + ref, + }); + const classes = useUtilityClasses(ownerState); const externalForwardedProps = { ...other, component, slots, slotProps }; - const handleBackdropClick = (event: React.MouseEvent) => { - if (event.target !== event.currentTarget) { - return; - } - - if (onClose) { - onClose(event, 'backdropClick'); - } - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (onKeyDown) { - onKeyDown(event); - } - - // The handler doesn't take event.defaultPrevented into account: - // - // event.preventDefault() is meant to stop default behaviors like - // clicking a checkbox to check it, hitting a button to submit a form, - // and hitting left arrow to move the cursor in a text input etc. - // Only special HTML elements have these default behaviors. - if (event.key !== 'Escape' || !isTopModal()) { - return; - } - - if (!disableEscapeKeyDown) { - // Swallow the event, in case someone is listening for the escape key on the body. - event.stopPropagation(); - - if (onClose) { - onClose(event, 'escapeKeyDown'); - } - } - }; - const [SlotRoot, rootProps] = useSlot('root', { - additionalProps: { role: 'presentation', onKeyDown: handleKeyDown }, - ref: handleRef, + ref: rootRef, className: classes.root, elementType: ModalRoot, externalForwardedProps, + getSlotProps: getRootProps, ownerState, }); const [SlotBackdrop, backdropProps] = useSlot('backdrop', { - additionalProps: { - 'aria-hidden': true, - onClick: handleBackdropClick, - open, - }, className: classes.backdrop, elementType: ModalBackdrop, externalForwardedProps, + getSlotProps: getBackdropProps, ownerState, }); @@ -272,7 +141,7 @@ const Modal = React.forwardRef(function ModalU(inProps, ref) { return ( - + {/* * Marking an element with the role presentation indicates to assistive technology * that this element should be ignored; it exists to support the web application and diff --git a/packages/mui-joy/src/Modal/useModal.ts b/packages/mui-joy/src/Modal/useModal.ts new file mode 100644 index 00000000000000..d56dbeee74414b --- /dev/null +++ b/packages/mui-joy/src/Modal/useModal.ts @@ -0,0 +1,213 @@ +import * as React from 'react'; +import { + unstable_ownerDocument as ownerDocument, + unstable_useForkRef as useForkRef, + unstable_useEventCallback as useEventCallback, +} from '@mui/utils'; +import { ModalManager, ModalOwnProps } from '@mui/base/Modal'; +import { EventHandlers, extractEventHandlers } from '@mui/base/utils'; + +export interface UseModalRootSlotOwnProps { + role: React.AriaRole; + onKeyDown: React.KeyboardEventHandler; + ref: React.RefCallback | null; +} + +export interface UseModalBackdropSlotOwnProps { + 'aria-hidden': React.AriaAttributes['aria-hidden']; + onClick: React.MouseEventHandler; + open?: boolean; +} + +export type UseModalBackdropSlotProps = TOther & UseModalBackdropSlotOwnProps; + +export type UseModalRootSlotProps = TOther & UseModalRootSlotOwnProps; + +type UseModalParameters = Pick< + ModalOwnProps, + 'container' | 'disableEscapeKeyDown' | 'disableScrollLock' | 'open' +> & { + 'aria-hidden'?: React.AriaAttributes['aria-hidden']; + onClose?: { + bivarianceHack(event: {}, reason: 'backdropClick' | 'escapeKeyDown' | 'closeClick'): void; + }['bivarianceHack']; + onKeyDown?: React.KeyboardEventHandler; + ref: React.Ref; +}; + +function ariaHidden(element: Element, show: boolean): void { + if (show) { + element.setAttribute('aria-hidden', 'true'); + } else { + element.removeAttribute('aria-hidden'); + } +} + +function getContainer(container: UseModalParameters['container']) { + return (typeof container === 'function' ? container() : container) as HTMLElement; +} + +// A modal manager used to track and manage the state of open Modals. +// Modals don't open on the server so this won't conflict with concurrent requests. +const manager = new ModalManager(); + +const useModal = (parameters: UseModalParameters) => { + const { + container, + disableEscapeKeyDown = false, + disableScrollLock = false, + onClose, + open, + ref, + } = parameters; + + // @ts-ignore internal logic + const modal = React.useRef<{ modalRef: HTMLDivElement; mount: HTMLElement }>({}); + const mountNodeRef = React.useRef(null); + const modalRef = React.useRef(null); + const handleRef = useForkRef(modalRef, ref); + + let ariaHiddenProp = true; + if ( + parameters['aria-hidden'] === 'false' || + (typeof parameters['aria-hidden'] === 'boolean' && !parameters['aria-hidden']) + ) { + ariaHiddenProp = false; + } + + const getDoc = () => ownerDocument(mountNodeRef.current); + const getModal = () => { + modal.current.modalRef = modalRef.current as HTMLDivElement; + modal.current.mount = mountNodeRef.current as HTMLElement; + return modal.current; + }; + + const handleMounted = () => { + manager.mount(getModal(), { disableScrollLock }); + + // Fix a bug on Chrome where the scroll isn't initially 0. + if (modalRef.current) { + modalRef.current.scrollTop = 0; + } + }; + + const handleOpen = useEventCallback(() => { + const resolvedContainer = getContainer(container) || getDoc().body; + + manager.add(getModal(), resolvedContainer); + + // The element was already mounted. + if (modalRef.current) { + handleMounted(); + } + }); + + const isTopModal = () => manager.isTopModal(getModal()); + + const handlePortalRef = useEventCallback((node: HTMLElement) => { + mountNodeRef.current = node; + + if (!node) { + return; + } + + if (open && isTopModal()) { + handleMounted(); + } else if (modalRef.current) { + ariaHidden(modalRef.current, ariaHiddenProp); + } + }); + + const handleClose = React.useCallback(() => { + manager.remove(getModal(), ariaHiddenProp); + }, [ariaHiddenProp]); + + React.useEffect(() => { + return () => { + handleClose(); + }; + }, [handleClose]); + + React.useEffect(() => { + if (open) { + handleOpen(); + } else { + handleClose(); + } + }, [open, handleClose, handleOpen]); + + const createHandleKeyDown = (otherHandlers: EventHandlers) => (event: React.KeyboardEvent) => { + otherHandlers.onKeyDown?.(event); + + // The handler doesn't take event.defaultPrevented into account: + // + // event.preventDefault() is meant to stop default behaviors like + // clicking a checkbox to check it, hitting a button to submit a form, + // and hitting left arrow to move the cursor in a text input etc. + // Only special HTML elements have these default behaviors. + if (event.key !== 'Escape' || !isTopModal()) { + return; + } + + if (!disableEscapeKeyDown) { + // Swallow the event, in case someone is listening for the escape key on the body. + event.stopPropagation(); + + if (onClose) { + onClose(event, 'escapeKeyDown'); + } + } + }; + + const createHandleBackdropClick = (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { + otherHandlers.onClick?.(event); + + if (event.target !== event.currentTarget) { + return; + } + + if (onClose) { + onClose(event, 'backdropClick'); + } + }; + + const getRootProps = ( + otherHandlers: TOther = {} as TOther, + ): UseModalRootSlotProps => { + const propsEventHandlers = extractEventHandlers(parameters) as Partial; + const externalEventHandlers = { + ...propsEventHandlers, + ...otherHandlers, + }; + + return { + role: 'presentation', + ...externalEventHandlers, + onKeyDown: createHandleKeyDown(externalEventHandlers), + ref: handleRef, + }; + }; + + const getBackdropProps = ( + otherHandlers: TOther = {} as TOther, + ): UseModalBackdropSlotProps => { + const externalEventHandlers = otherHandlers; + + return { + 'aria-hidden': true, + ...externalEventHandlers, + onClick: createHandleBackdropClick(externalEventHandlers), + open, + }; + }; + + return { + getRootProps, + getBackdropProps, + rootRef: handleRef, + portalRef: handlePortalRef, + isTopModal, + }; +}; + +export default useModal; From d459eefc7d4f1151cd87bfddc9e7be5fa3ed0531 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Thu, 27 Jul 2023 17:16:11 +0200 Subject: [PATCH 02/24] Update packages/mui-joy/src/Modal/useModal.ts Signed-off-by: Marija Najdova --- packages/mui-joy/src/Modal/useModal.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mui-joy/src/Modal/useModal.ts b/packages/mui-joy/src/Modal/useModal.ts index d56dbeee74414b..6426d379e4bcc2 100644 --- a/packages/mui-joy/src/Modal/useModal.ts +++ b/packages/mui-joy/src/Modal/useModal.ts @@ -1,3 +1,4 @@ +'use client'; import * as React from 'react'; import { unstable_ownerDocument as ownerDocument, From 8483b6ff47d1348eab0272ccdd8c2bf5812d64de Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Mon, 31 Jul 2023 08:12:04 +0300 Subject: [PATCH 03/24] Import ariaHidden from Base UI --- packages/mui-joy/src/Modal/useModal.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/mui-joy/src/Modal/useModal.ts b/packages/mui-joy/src/Modal/useModal.ts index d56dbeee74414b..d6eefc42a5bb69 100644 --- a/packages/mui-joy/src/Modal/useModal.ts +++ b/packages/mui-joy/src/Modal/useModal.ts @@ -4,7 +4,7 @@ import { unstable_useForkRef as useForkRef, unstable_useEventCallback as useEventCallback, } from '@mui/utils'; -import { ModalManager, ModalOwnProps } from '@mui/base/Modal'; +import { ModalManager, ModalOwnProps, ariaHidden } from '@mui/base/Modal'; import { EventHandlers, extractEventHandlers } from '@mui/base/utils'; export interface UseModalRootSlotOwnProps { @@ -35,14 +35,6 @@ type UseModalParameters = Pick< ref: React.Ref; }; -function ariaHidden(element: Element, show: boolean): void { - if (show) { - element.setAttribute('aria-hidden', 'true'); - } else { - element.removeAttribute('aria-hidden'); - } -} - function getContainer(container: UseModalParameters['container']) { return (typeof container === 'function' ? container() : container) as HTMLElement; } From fa82187bd9639159079975a2e55700fc37160026 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Tue, 1 Aug 2023 10:06:57 +0300 Subject: [PATCH 04/24] wip useModal in base UI --- docs/data/base/components/modal/modal.md | 1 + packages/mui-base/src/Modal/Modal.tsx | 208 ++++-------------- packages/mui-base/src/index.d.ts | 3 + packages/mui-base/src/index.js | 3 + .../mui-base/src/unstable_useModal/index.ts | 4 + .../src/unstable_useModal}/useModal.ts | 91 +++++--- .../src/unstable_useModal/useModal.types.ts | 35 +++ packages/mui-joy/src/Modal/Modal.tsx | 4 +- 8 files changed, 148 insertions(+), 201 deletions(-) create mode 100644 packages/mui-base/src/unstable_useModal/index.ts rename packages/{mui-joy/src/Modal => mui-base/src/unstable_useModal}/useModal.ts (71%) create mode 100644 packages/mui-base/src/unstable_useModal/useModal.types.ts diff --git a/docs/data/base/components/modal/modal.md b/docs/data/base/components/modal/modal.md index 1637622d1ea982..3e086792ba75ae 100644 --- a/docs/data/base/components/modal/modal.md +++ b/docs/data/base/components/modal/modal.md @@ -2,6 +2,7 @@ productId: base-ui title: React Modal component components: Modal +hooks: useModal githubLabel: 'component: modal' waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ --- diff --git a/packages/mui-base/src/Modal/Modal.tsx b/packages/mui-base/src/Modal/Modal.tsx index b7e6558cf57a67..bb8bfe9c05846b 100644 --- a/packages/mui-base/src/Modal/Modal.tsx +++ b/packages/mui-base/src/Modal/Modal.tsx @@ -6,14 +6,14 @@ import { HTMLElementType, unstable_ownerDocument as ownerDocument, unstable_useForkRef as useForkRef, - unstable_createChainedFunction as createChainedFunction, unstable_useEventCallback as useEventCallback, } from '@mui/utils'; +import { EventHandlers } from '../utils'; import { PolymorphicComponent } from '../utils/PolymorphicComponent'; -import { ModalOwnerState, ModalOwnProps, ModalProps, ModalTypeMap } from './Modal.types'; +import { ModalOwnerState, ModalProps, ModalTypeMap } from './Modal.types'; import composeClasses from '../composeClasses'; import Portal from '../Portal'; -import ModalManager, { ariaHidden } from './ModalManager'; +import useModal from '../unstable_useModal'; import FocusTrap from '../FocusTrap'; import { getModalUtilityClass } from './modalClasses'; import { useSlotProps } from '../utils'; @@ -30,18 +30,6 @@ const useUtilityClasses = (ownerState: ModalOwnerState) => { return composeClasses(slots, useClassNamesOverride(getModalUtilityClass)); }; -function getContainer(container: ModalOwnProps['container']) { - return typeof container === 'function' ? container() : container; -} - -function getHasTransition(children: ModalOwnProps['children']) { - return children ? children.props.hasOwnProperty('in') : false; -} - -// A modal manager used to track and manage the state of open Modals. -// Modals don't open on the server so this won't conflict with concurrent requests. -const defaultManager = new ModalManager(); - /** * Modal is a lower-level construct that is leveraged by the following components: * @@ -65,7 +53,7 @@ const defaultManager = new ModalManager(); */ const Modal = React.forwardRef(function Modal( props: ModalProps, - forwardedRef: React.ForwardedRef, + forwardedRef: React.ForwardedRef, ) { const { children, @@ -79,8 +67,6 @@ const Modal = React.forwardRef(function Modal({}); - const mountNodeRef = React.useRef(null); - const modalRef = React.useRef(null); - const handleRef = useForkRef(modalRef, forwardedRef); - const hasTransition = getHasTransition(children); - - const ariaHiddenProp = props['aria-hidden'] ?? true; - - const getDoc = () => ownerDocument(mountNodeRef.current); - const getModal = () => { - modal.current.modalRef = modalRef.current; - modal.current.mountNode = mountNodeRef.current; - return modal.current; - }; - - const handleMounted = () => { - manager.mount(getModal(), { disableScrollLock }); - - // Fix a bug on Chrome where the scroll isn't initially 0. - if (modalRef.current) { - modalRef.current.scrollTop = 0; - } - }; - - const handleOpen = useEventCallback(() => { - const resolvedContainer = getContainer(container) || getDoc().body; - - manager.add(getModal(), resolvedContainer); - - // The element was already mounted. - if (modalRef.current) { - handleMounted(); - } - }); - - const isTopModal = React.useCallback(() => manager.isTopModal(getModal()), [manager]); - - const handlePortalRef = useEventCallback((node: Node) => { - mountNodeRef.current = node; - - if (!node || !modalRef.current) { - return; - } - - if (open && isTopModal()) { - handleMounted(); - } else { - ariaHidden(modalRef.current, ariaHiddenProp); - } - }); - - const handleClose = React.useCallback(() => { - manager.remove(getModal(), ariaHiddenProp); - }, [manager, ariaHiddenProp]); - - React.useEffect(() => { - return () => { - handleClose(); - }; - }, [handleClose]); - - React.useEffect(() => { - if (open) { - handleOpen(); - } else if (!hasTransition || !closeAfterTransition) { - handleClose(); - } - }, [open, handleClose, hasTransition, closeAfterTransition, handleOpen]); - const ownerState: ModalOwnerState = { + const propsWithDefaults: Omit = { ...props, closeAfterTransition, disableAutoFocus, @@ -176,71 +87,31 @@ const Modal = React.forwardRef(function Modal { - setExited(false); - - if (onTransitionEnter) { - onTransitionEnter(); - } - }; - - const handleExited = () => { - setExited(true); - - if (onTransitionExited) { - onTransitionExited(); - } - - if (closeAfterTransition) { - handleClose(); - } - }; - - const handleBackdropClick = (event: React.MouseEvent) => { - if (event.target !== event.currentTarget) { - return; - } - - if (onBackdropClick) { - onBackdropClick(event); - } + const { + getRootProps, + getBackdropProps, + getTransitionProps, + rootRef, + portalRef, + isTopModal, + exited, + hasTransition, + } = useModal({ + ...propsWithDefaults, + ref: forwardedRef, + }); - if (onClose) { - onClose(event, 'backdropClick'); - } + const ownerState = { + ...propsWithDefaults, + exited, + hasTransition, }; - const handleKeyDown = (event: React.KeyboardEvent) => { - if (onKeyDown) { - onKeyDown(event); - } - - // The handler doesn't take event.defaultPrevented into account: - // - // event.preventDefault() is meant to stop default behaviors like - // clicking a checkbox to check it, hitting a button to submit a form, - // and hitting left arrow to move the cursor in a text input etc. - // Only special HTML elements have these default behaviors. - if (event.key !== 'Escape' || !isTopModal()) { - return; - } - - if (!disableEscapeKeyDown) { - // Swallow the event, in case someone is listening for the escape key on the body. - event.stopPropagation(); - - if (onClose) { - onClose(event, 'escapeKeyDown'); - } - } - }; + const classes = useUtilityClasses(ownerState); const childProps: { onEnter?: () => void; @@ -253,8 +124,9 @@ const Modal = React.forwardRef(function Modal { + return getBackdropProps({ + ...otherHandlers, + onClick: (e: React.MouseEvent) => { + if (onBackdropClick) { + onBackdropClick(e); + } + if (otherHandlers?.onClick) { + otherHandlers.onClick(e); + } + }, + }); + }, className: classes.backdrop, ownerState, }); @@ -289,19 +174,14 @@ const Modal = React.forwardRef(function Modal + {/* * Marking an element with the role presentation indicates to assistive technology * that this element should be ignored; it exists to support the web application and * is not meant for humans to interact with directly. * https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md */} - + {!hideBackdrop && BackdropComponent ? : null} | null; +function getContainer(container: ModalOwnProps['container']) { + return typeof container === 'function' ? container() : container; } -export interface UseModalBackdropSlotOwnProps { - 'aria-hidden': React.AriaAttributes['aria-hidden']; - onClick: React.MouseEventHandler; - open?: boolean; -} - -export type UseModalBackdropSlotProps = TOther & UseModalBackdropSlotOwnProps; - -export type UseModalRootSlotProps = TOther & UseModalRootSlotOwnProps; - -type UseModalParameters = Pick< - ModalOwnProps, - 'container' | 'disableEscapeKeyDown' | 'disableScrollLock' | 'open' -> & { - 'aria-hidden'?: React.AriaAttributes['aria-hidden']; - onClose?: { - bivarianceHack(event: {}, reason: 'backdropClick' | 'escapeKeyDown' | 'closeClick'): void; - }['bivarianceHack']; - onKeyDown?: React.KeyboardEventHandler; - ref: React.Ref; -}; - -function getContainer(container: UseModalParameters['container']) { - return (typeof container === 'function' ? container() : container) as HTMLElement; +function getHasTransition(children: ModalOwnProps['children']) { + return children ? children.props.hasOwnProperty('in') : false; } // A modal manager used to track and manage the state of open Modals. // Modals don't open on the server so this won't conflict with concurrent requests. -const manager = new ModalManager(); +const defaultManager = new ModalManager(); const useModal = (parameters: UseModalParameters) => { const { container, disableEscapeKeyDown = false, disableScrollLock = false, + // @ts-ignore internal logic - Base UI supports the manager as a prop too + manager = defaultManager, + closeAfterTransition = false, + onTransitionEnter, + onTransitionExited, + children, onClose, open, ref, @@ -59,6 +48,8 @@ const useModal = (parameters: UseModalParameters) => { const mountNodeRef = React.useRef(null); const modalRef = React.useRef(null); const handleRef = useForkRef(modalRef, ref); + const [exited, setExited] = React.useState(!open); + const hasTransition = getHasTransition(children); let ariaHiddenProp = true; if ( @@ -95,7 +86,7 @@ const useModal = (parameters: UseModalParameters) => { } }); - const isTopModal = () => manager.isTopModal(getModal()); + const isTopModal = React.useCallback(() => manager.isTopModal(getModal()), [manager]); const handlePortalRef = useEventCallback((node: HTMLElement) => { mountNodeRef.current = node; @@ -113,7 +104,7 @@ const useModal = (parameters: UseModalParameters) => { const handleClose = React.useCallback(() => { manager.remove(getModal(), ariaHiddenProp); - }, [ariaHiddenProp]); + }, [ariaHiddenProp, manager]); React.useEffect(() => { return () => { @@ -124,10 +115,10 @@ const useModal = (parameters: UseModalParameters) => { React.useEffect(() => { if (open) { handleOpen(); - } else { + } else if (!hasTransition || !closeAfterTransition) { handleClose(); } - }, [open, handleClose, handleOpen]); + }, [open, handleClose, hasTransition, closeAfterTransition, handleOpen]); const createHandleKeyDown = (otherHandlers: EventHandlers) => (event: React.KeyboardEvent) => { otherHandlers.onKeyDown?.(event); @@ -194,12 +185,42 @@ const useModal = (parameters: UseModalParameters) => { }; }; + const getTransitionProps = () => { + const handleEnter = () => { + setExited(false); + + if (onTransitionEnter) { + onTransitionEnter(); + } + }; + + const handleExited = () => { + setExited(true); + + if (onTransitionExited) { + onTransitionExited(); + } + + if (closeAfterTransition) { + handleClose(); + } + }; + + return { + onEnter: createChainedFunction(handleEnter, children.props.onEnter), + onExited: createChainedFunction(handleExited, children.props.onExited), + }; + }; + return { getRootProps, getBackdropProps, + getTransitionProps, rootRef: handleRef, portalRef: handlePortalRef, isTopModal, + exited, + hasTransition, }; }; diff --git a/packages/mui-base/src/unstable_useModal/useModal.types.ts b/packages/mui-base/src/unstable_useModal/useModal.types.ts new file mode 100644 index 00000000000000..c8fc71b2209fa1 --- /dev/null +++ b/packages/mui-base/src/unstable_useModal/useModal.types.ts @@ -0,0 +1,35 @@ +import { ModalOwnProps, ariaHidden } from '../Modal'; + +export interface UseModalRootSlotOwnProps { + role: React.AriaRole; + onKeyDown: React.KeyboardEventHandler; + ref: React.RefCallback | null; +} + +export interface UseModalBackdropSlotOwnProps { + 'aria-hidden': React.AriaAttributes['aria-hidden']; + onClick: React.MouseEventHandler; + open?: boolean; +} + +export type UseModalBackdropSlotProps = TOther & UseModalBackdropSlotOwnProps; + +export type UseModalRootSlotProps = TOther & UseModalRootSlotOwnProps; + +export type UseModalParameters = Pick< + ModalOwnProps, + 'container' | 'disableEscapeKeyDown' | 'disableScrollLock' | 'open' +> & { + 'aria-hidden'?: React.AriaAttributes['aria-hidden']; + onClose?: { + bivarianceHack(event: {}, reason: 'backdropClick' | 'escapeKeyDown' | 'closeClick'): void; + }['bivarianceHack']; + onKeyDown?: React.KeyboardEventHandler; + ref: React.Ref; + closeAfterTransition?: boolean; + onTransitionEnter?: () => void; + onTransitionExited?: () => void; + children: ModalOwnProps['children']; +}; + +// TODO: add UseModalReturnValue type diff --git a/packages/mui-joy/src/Modal/Modal.tsx b/packages/mui-joy/src/Modal/Modal.tsx index 7641ec6122100b..d20936a207c9d4 100644 --- a/packages/mui-joy/src/Modal/Modal.tsx +++ b/packages/mui-joy/src/Modal/Modal.tsx @@ -6,7 +6,7 @@ import { elementAcceptingRef, HTMLElementType } from '@mui/utils'; import composeClasses from '@mui/base/composeClasses'; import Portal from '@mui/base/Portal'; import FocusTrap from '@mui/base/FocusTrap'; -import useModal from './useModal'; +import useModal from '@mui/base/unstable_useModal'; import { styled, useThemeProps } from '../styles'; import useSlot from '../utils/useSlot'; import { getModalUtilityClass } from './modalClasses'; @@ -72,7 +72,7 @@ const ModalBackdrop = styled('div', { * * - [Modal API](https://mui.com/joy-ui/api/modal/) */ -const Modal = React.forwardRef(function ModalU(inProps, ref) { +const Modal = React.forwardRef(function Modal(inProps, ref) { const props = useThemeProps({ props: inProps, name: 'JoyModal', From 0307cb05027d87b30c100087f2a58c0de1775fbc Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Tue, 1 Aug 2023 10:29:15 +0300 Subject: [PATCH 05/24] docs:api --- docs/data/base/pagesApi.js | 1 + docs/pages/base-ui/api/use-modal.json | 7 +++++++ docs/pages/base-ui/react-modal/[docsTab]/index.js | 12 ++++++++++-- .../api-docs/use-modal/use-modal.json | 1 + .../mui-base/src/unstable_useModal/useModal.ts | 15 +++++++++++---- 5 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 docs/pages/base-ui/api/use-modal.json create mode 100644 docs/translations/api-docs/use-modal/use-modal.json diff --git a/docs/data/base/pagesApi.js b/docs/data/base/pagesApi.js index 87fb37f2e44ef1..59812c5c4ebef1 100644 --- a/docs/data/base/pagesApi.js +++ b/docs/data/base/pagesApi.js @@ -68,6 +68,7 @@ module.exports = [ title: 'useMenuButton', }, { pathname: '/base-ui/react-menu/hooks-api/#use-menu-item', title: 'useMenuItem' }, + { pathname: '/base-ui/react-modal/hooks-api/#use-modal', title: 'useModal' }, { pathname: '/base-ui/react-select/hooks-api/#use-option', title: 'useOption' }, { pathname: '/base-ui/react-select/hooks-api/#use-select', title: 'useSelect' }, { pathname: '/base-ui/react-slider/hooks-api/#use-slider', title: 'useSlider' }, diff --git a/docs/pages/base-ui/api/use-modal.json b/docs/pages/base-ui/api/use-modal.json new file mode 100644 index 00000000000000..17e014b338a336 --- /dev/null +++ b/docs/pages/base-ui/api/use-modal.json @@ -0,0 +1,7 @@ +{ + "parameters": {}, + "returnValue": {}, + "name": "useModal", + "filename": "/packages/mui-base/src/unstable_useModal/useModal.ts", + "demos": "" +} diff --git a/docs/pages/base-ui/react-modal/[docsTab]/index.js b/docs/pages/base-ui/react-modal/[docsTab]/index.js index a43f3c5d52f105..ca7f56b6b4c6c4 100644 --- a/docs/pages/base-ui/react-modal/[docsTab]/index.js +++ b/docs/pages/base-ui/react-modal/[docsTab]/index.js @@ -4,6 +4,7 @@ import AppFrame from 'docs/src/modules/components/AppFrame'; import * as pageProps from 'docs/data/base/components/modal/modal.md?@mui/markdown'; import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; import ModalApiJsonPageContent from '../../api/modal.json'; +import useModalApiJsonPageContent from '../../api/use-modal.json'; export default function Page(props) { const { userLanguage, ...other } = props; @@ -29,12 +30,19 @@ export const getStaticProps = () => { ); const ModalApiDescriptions = mapApiPageTranslations(ModalApiReq); + const useModalApiReq = require.context( + 'docs/translations/api-docs/use-modal', + false, + /use-modal.*.json$/, + ); + const useModalApiDescriptions = mapApiPageTranslations(useModalApiReq); + return { props: { componentsApiDescriptions: { Modal: ModalApiDescriptions }, componentsApiPageContents: { Modal: ModalApiJsonPageContent }, - hooksApiDescriptions: {}, - hooksApiPageContents: {}, + hooksApiDescriptions: { useModal: useModalApiDescriptions }, + hooksApiPageContents: { useModal: useModalApiJsonPageContent }, }, }; }; diff --git a/docs/translations/api-docs/use-modal/use-modal.json b/docs/translations/api-docs/use-modal/use-modal.json new file mode 100644 index 00000000000000..e3eb65c6e43006 --- /dev/null +++ b/docs/translations/api-docs/use-modal/use-modal.json @@ -0,0 +1 @@ +{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/packages/mui-base/src/unstable_useModal/useModal.ts b/packages/mui-base/src/unstable_useModal/useModal.ts index 9379e2eb43d649..81caa5dbe292f9 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.ts @@ -26,8 +26,17 @@ function getHasTransition(children: ModalOwnProps['children']) { // A modal manager used to track and manage the state of open Modals. // Modals don't open on the server so this won't conflict with concurrent requests. const defaultManager = new ModalManager(); - -const useModal = (parameters: UseModalParameters) => { +/** + * + * Demos: + * + * - [Modal](https://mui.com/base-ui/react-modal/#hook) + * + * API: + * + * - [useModal API](https://mui.com/base-ui/react-modal/hooks-api/#use-modal) + */ +export default function useModal(parameters: UseModalParameters) { const { container, disableEscapeKeyDown = false, @@ -223,5 +232,3 @@ const useModal = (parameters: UseModalParameters) => { hasTransition, }; }; - -export default useModal; From cb78f37c0f663fcd2a9ef24edd9457a610008af2 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 08:22:34 +0300 Subject: [PATCH 06/24] Fix propagation of custom event handlers --- packages/mui-base/src/unstable_useModal/useModal.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/mui-base/src/unstable_useModal/useModal.ts b/packages/mui-base/src/unstable_useModal/useModal.ts index 81caa5dbe292f9..b13ed8d35789ba 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.ts @@ -168,6 +168,11 @@ export default function useModal(parameters: UseModalParameters) { otherHandlers: TOther = {} as TOther, ): UseModalRootSlotProps => { const propsEventHandlers = extractEventHandlers(parameters) as Partial; + + // The custom event handlers shouldn't be spreaded on the root element + delete propsEventHandlers['onTransitionEnter']; + delete propsEventHandlers['onTransitionExited']; + const externalEventHandlers = { ...propsEventHandlers, ...otherHandlers, From b28568f6bfbe7153c5d92cc7b31f38db171134f2 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 08:49:38 +0300 Subject: [PATCH 07/24] Define the hook's types --- docs/pages/base-ui/api/use-modal.json | 82 +++++++++++++++- .../api-docs/use-modal/use-modal.json | 37 ++++++- .../src/unstable_useModal/useModal.ts | 4 +- .../src/unstable_useModal/useModal.types.ts | 98 +++++++++++++++++-- 4 files changed, 206 insertions(+), 15 deletions(-) diff --git a/docs/pages/base-ui/api/use-modal.json b/docs/pages/base-ui/api/use-modal.json index 17e014b338a336..4129b15c0b83ff 100644 --- a/docs/pages/base-ui/api/use-modal.json +++ b/docs/pages/base-ui/api/use-modal.json @@ -1,6 +1,84 @@ { - "parameters": {}, - "returnValue": {}, + "parameters": { + "children": { + "type": { "name": "React.ReactElement", "description": "React.ReactElement" }, + "required": true + }, + "open": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, + "ref": { + "type": { "name": "React.Ref<Element>", "description": "React.Ref<Element>" }, + "required": true + }, + "aria-hidden": { + "type": { + "name": "React.AriaAttributes['aria-hidden']", + "description": "React.AriaAttributes['aria-hidden']" + } + }, + "closeAfterTransition": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false" + }, + "container": { + "type": { + "name": "PortalProps['container']", + "description": "PortalProps['container']" + } + }, + "disableEscapeKeyDown": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false" + }, + "disableScrollLock": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false" + }, + "onClose": { + "type": { + "name": "{\n bivarianceHack(event: {}, reason: 'backdropClick' | 'escapeKeyDown'): void\n}['bivarianceHack']", + "description": "{\n bivarianceHack(event: {}, reason: 'backdropClick' | 'escapeKeyDown'): void\n}['bivarianceHack']" + } + }, + "onKeyDown": { + "type": { "name": "React.KeyboardEventHandler", "description": "React.KeyboardEventHandler" } + }, + "onTransitionEnter": { "type": { "name": "() => void", "description": "() => void" } }, + "onTransitionExited": { "type": { "name": "() => void", "description": "() => void" } } + }, + "returnValue": { + "exited": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, + "getBackdropProps": { + "type": { + "name": "(externalProps?: any) => React.HTMLAttributes<HTMLDivElement>", + "description": "(externalProps?: any) => React.HTMLAttributes<HTMLDivElement>" + }, + "required": true + }, + "getRootProps": { + "type": { + "name": "(externalProps?: any) => React.HTMLAttributes<HTMLDivElement>", + "description": "(externalProps?: any) => React.HTMLAttributes<HTMLDivElement>" + }, + "required": true + }, + "getTransitionProps": { + "type": { + "name": "(externalProps?: any) => { onEnter: () => void; onExited: () => void }", + "description": "(externalProps?: any) => { onEnter: () => void; onExited: () => void }" + }, + "required": true + }, + "hasTransition": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, + "isTopModal": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, + "portalRef": { + "type": { "name": "React.Ref<Element>", "description": "React.Ref<Element>" }, + "required": true + }, + "rootRef": { + "type": { "name": "React.Ref<Element>", "description": "React.Ref<Element>" }, + "required": true + } + }, "name": "useModal", "filename": "/packages/mui-base/src/unstable_useModal/useModal.ts", "demos": "" diff --git a/docs/translations/api-docs/use-modal/use-modal.json b/docs/translations/api-docs/use-modal/use-modal.json index e3eb65c6e43006..60e2f18f544939 100644 --- a/docs/translations/api-docs/use-modal/use-modal.json +++ b/docs/translations/api-docs/use-modal/use-modal.json @@ -1 +1,36 @@ -{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } +{ + "hookDescription": "", + "parametersDescriptions": { + "children": { "description": "A single child content element." }, + "closeAfterTransition": { + "description": "When set to true the Modal waits until a nested Transition is completed before closing." + }, + "container": { + "description": "An HTML element or function that returns one.\nThe container will have the portal children appended to it.\n\nBy default, it uses the body of the top-level document object,\nso it's simply document.body most of the time." + }, + "disableEscapeKeyDown": { + "description": "If true, hitting escape will not fire the onClose callback." + }, + "disableScrollLock": { "description": "Disable the scroll lock behavior." }, + "onClose": { + "description": "Callback fired when the component requests to be closed.\nThe reason parameter can optionally be used to control the response to onClose." + }, + "onTransitionEnter": { "description": "A function called when a transition enters." }, + "onTransitionExited": { "description": "A function called when a transition has exited." }, + "open": { "description": "If true, the component is shown." } + }, + "returnValueDescriptions": { + "exited": { + "description": "If true, the exiting transition finished (to be used for unmounting the component)." + }, + "getBackdropProps": { "description": "Resolver for the backdrop slot's props." }, + "getRootProps": { "description": "Resolver for the root slot's props." }, + "getTransitionProps": { "description": "Resolver for the transition related props." }, + "hasTransition": { + "description": "If true, the component's child is transition component." + }, + "isTopModal": { "description": "If true, the modal is the top most one." }, + "portalRef": { "description": "A ref to the component's portal DOM element." }, + "rootRef": { "description": "A ref to the component's root DOM element." } + } +} diff --git a/packages/mui-base/src/unstable_useModal/useModal.ts b/packages/mui-base/src/unstable_useModal/useModal.ts index b13ed8d35789ba..9709d950ff867b 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.ts @@ -36,7 +36,7 @@ const defaultManager = new ModalManager(); * * - [useModal API](https://mui.com/base-ui/react-modal/hooks-api/#use-modal) */ -export default function useModal(parameters: UseModalParameters) { +export default function useModal(parameters: UseModalParameters): UseModalReturnValue { const { container, disableEscapeKeyDown = false, @@ -236,4 +236,4 @@ export default function useModal(parameters: UseModalParameters) { exited, hasTransition, }; -}; +} diff --git a/packages/mui-base/src/unstable_useModal/useModal.types.ts b/packages/mui-base/src/unstable_useModal/useModal.types.ts index c8fc71b2209fa1..419bdad1392f20 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.types.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.types.ts @@ -1,4 +1,4 @@ -import { ModalOwnProps, ariaHidden } from '../Modal'; +import { PortalProps } from '../Portal'; export interface UseModalRootSlotOwnProps { role: React.AriaRole; @@ -16,20 +16,98 @@ export type UseModalBackdropSlotProps = TOther & UseModalBackdropSl export type UseModalRootSlotProps = TOther & UseModalRootSlotOwnProps; -export type UseModalParameters = Pick< - ModalOwnProps, - 'container' | 'disableEscapeKeyDown' | 'disableScrollLock' | 'open' -> & { +export type UseModalParameters = { 'aria-hidden'?: React.AriaAttributes['aria-hidden']; + /** + * A single child content element. + */ + children: React.ReactElement; + /** + * When set to true the Modal waits until a nested Transition is completed before closing. + * @default false + */ + closeAfterTransition?: boolean; + /** + * An HTML element or function that returns one. + * The `container` will have the portal children appended to it. + * + * By default, it uses the body of the top-level document object, + * so it's simply `document.body` most of the time. + */ + container?: PortalProps['container']; + /** + * If `true`, hitting escape will not fire the `onClose` callback. + * @default false + */ + disableEscapeKeyDown?: boolean; + /** + * Disable the scroll lock behavior. + * @default false + */ + disableScrollLock?: boolean; + /** + * Callback fired when the component requests to be closed. + * The `reason` parameter can optionally be used to control the response to `onClose`. + * + * @param {object} event The event source of the callback. + * @param {string} reason Can be: `"escapeKeyDown"`, `"backdropClick"`. + */ onClose?: { - bivarianceHack(event: {}, reason: 'backdropClick' | 'escapeKeyDown' | 'closeClick'): void; + bivarianceHack(event: {}, reason: 'backdropClick' | 'escapeKeyDown'): void; }['bivarianceHack']; onKeyDown?: React.KeyboardEventHandler; - ref: React.Ref; - closeAfterTransition?: boolean; + /** + * A function called when a transition enters. + */ onTransitionEnter?: () => void; + /** + * A function called when a transition has exited. + */ onTransitionExited?: () => void; - children: ModalOwnProps['children']; + /** + * If `true`, the component is shown. + */ + open: boolean; + ref: React.Ref; }; -// TODO: add UseModalReturnValue type +export interface UseModalReturnValue { + /** + * Resolver for the root slot's props. + * @param externalProps props for the root slot + * @returns props that should be spread on the root slot + */ + getRootProps: (externalProps?: any) => React.HTMLAttributes; + /** + * Resolver for the backdrop slot's props. + * @param externalProps props for the backdrop slot + * @returns props that should be spread on the backdrop slot + */ + getBackdropProps: (externalProps?: any) => React.HTMLAttributes; + /** + * Resolver for the transition related props. + * @param externalProps props for the transition element + * @returns props that should be spread on the transition element + */ + getTransitionProps: (externalProps?: any) => { onEnter: () => void; onExited: () => void }; + /** + * A ref to the component's root DOM element. + */ + rootRef: React.Ref; + /** + * A ref to the component's portal DOM element. + */ + portalRef: React.Ref; + /** + * If `true`, the modal is the top most one. + */ + isTopModal: boolean; + /** + * If `true`, the exiting transition finished (to be used for unmounting the component). + */ + exited: boolean; + /** + * If `true`, the component's child is transition component. + */ + hasTransition: boolean; +} From 83277a72de4b54467adbfe913215ea934059646b Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 09:02:52 +0300 Subject: [PATCH 08/24] Fix import name for unstable hooks --- docs/src/modules/components/HooksApiContent.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/src/modules/components/HooksApiContent.js b/docs/src/modules/components/HooksApiContent.js index b8063707519649..a776484fac2ece 100644 --- a/docs/src/modules/components/HooksApiContent.js +++ b/docs/src/modules/components/HooksApiContent.js @@ -63,6 +63,9 @@ export default function HooksApiContent(props) { const hookNameKebabCase = kebabCase(hookName); + const hookImportModule = source.split('/').slice(2, 3)[0]; + const hookImportModuleCleaned = hookImportModule.replace('unstable_', ''); + return ( @@ -72,7 +75,10 @@ export default function HooksApiContent(props) { code={` import ${hookName} from '${source.split('/').slice(0, -1).join('/')}'; // ${t('or')} -import { ${hookName} } from '${source.split('/').slice(0, 2).join('/')}';`} +import { ${hookName === hookImportModuleCleaned ? hookImportModule : hookName} } from '${source + .split('/') + .slice(0, 2) + .join('/')}';`} language="jsx" /> From 3a1578fc57a8a91f59224834f37b91a4970830c1 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 09:10:23 +0300 Subject: [PATCH 09/24] Fix imports and circular dependency --- packages/mui-base/src/unstable_useModal/useModal.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mui-base/src/unstable_useModal/useModal.ts b/packages/mui-base/src/unstable_useModal/useModal.ts index 9709d950ff867b..452b0c76ab4bb8 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.ts @@ -7,19 +7,19 @@ import { unstable_createChainedFunction as createChainedFunction, } from '@mui/utils'; import { EventHandlers, extractEventHandlers } from '../utils'; -import { ModalOwnProps, ariaHidden } from '../Modal'; -import ModalManager from '../Modal/ModalManager'; +import ModalManager, { ariaHidden } from '../Modal/ModalManager'; import { UseModalParameters, + UseModalReturnValue, UseModalRootSlotProps, UseModalBackdropSlotProps, } from './useModal.types'; -function getContainer(container: ModalOwnProps['container']) { +function getContainer(container: UseModalParameters['container']) { return typeof container === 'function' ? container() : container; } -function getHasTransition(children: ModalOwnProps['children']) { +function getHasTransition(children: UseModalParameters['children']) { return children ? children.props.hasOwnProperty('in') : false; } From 797908112d2ac75c479edb5e5fb270bfdb2560b6 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 10:13:45 +0300 Subject: [PATCH 10/24] Add hook demo --- docs/data/base/components/modal/UseModal.js | 260 +++++++++++++++++++ docs/data/base/components/modal/UseModal.tsx | 252 ++++++++++++++++++ docs/data/base/components/modal/modal.md | 29 +++ packages/mui-base/src/Modal/Modal.tsx | 4 +- 4 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 docs/data/base/components/modal/UseModal.js create mode 100644 docs/data/base/components/modal/UseModal.tsx diff --git a/docs/data/base/components/modal/UseModal.js b/docs/data/base/components/modal/UseModal.js new file mode 100644 index 00000000000000..6346fc37440e81 --- /dev/null +++ b/docs/data/base/components/modal/UseModal.js @@ -0,0 +1,260 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { Box, styled } from '@mui/system'; +import Portal from '@mui/base/Portal'; +import FocusTrap from '@mui/base/FocusTrap'; +import Button from '@mui/base/Button'; +import useModal from '@mui/base/unstable_useModal'; +import Fade from '@mui/material/Fade'; + +export default function UseModal() { + const [open, setOpen] = React.useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + return ( +
+ Open modal + + + +

Text in a modal

+ + Duis mollis, est non commodo luctus, nisi erat porttitor ligula. + +
+
+
+
+ ); +} + +const Modal = React.forwardRef(function Modal(props, forwardedRef) { + const { + children, + closeAfterTransition = false, + container, + disableAutoFocus = false, + disableEnforceFocus = false, + disableEscapeKeyDown = false, + disablePortal = false, + disableRestoreFocus = false, + disableScrollLock = false, + hideBackdrop = false, + keepMounted = false, + onClose, + open, + onTransitionEnter, + onTransitionExited, + ...other + } = props; + + const propsWithDefaults = { + ...props, + closeAfterTransition, + disableAutoFocus, + disableEnforceFocus, + disableEscapeKeyDown, + disablePortal, + disableRestoreFocus, + disableScrollLock, + hideBackdrop, + keepMounted, + }; + + const { + getRootProps, + getBackdropProps, + getTransitionProps, + rootRef, + portalRef, + isTopModal, + exited, + hasTransition, + } = useModal({ + ...propsWithDefaults, + ref: forwardedRef, + }); + + const classes = { + hidden: !open && exited, + }; + + const childProps = {}; + if (children.props.tabIndex === undefined) { + childProps.tabIndex = '-1'; + } + + // It's a Transition like component + if (hasTransition) { + const { onEnter, onExited } = getTransitionProps(); + childProps.onEnter = onEnter; + childProps.onExited = onExited; + } + + const rootProps = { + ...other, + ref: rootRef, + role: 'presentation', + className: clsx(classes), + ...getRootProps(other), + }; + + const backdropProps = { + 'aria-hidden': true, + open, + ...getBackdropProps(), + }; + + if (!keepMounted && !open && (!hasTransition || exited)) { + return null; + } + + return ( + + {/* + * Marking an element with the role presentation indicates to assistive technology + * that this element should be ignored; it exists to support the web application and + * is not meant for humans to interact with directly. + * https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md + */} + + {!hideBackdrop ? : null} + isTopModal} + open={open} + > + {React.cloneElement(children, childProps)} + + + + ); +}); + +Modal.propTypes = { + children: PropTypes.element.isRequired, + closeAfterTransition: PropTypes.bool, + container: PropTypes.oneOfType([ + function (props, propName) { + if (props[propName] == null) { + return new Error("Prop '" + propName + "' is required but wasn't specified"); + } else if ( + typeof props[propName] !== 'object' || + props[propName].nodeType !== 1 + ) { + return new Error("Expected prop '" + propName + "' to be of type Element"); + } + }, + PropTypes.func, + ]), + disableAutoFocus: PropTypes.bool, + disableEnforceFocus: PropTypes.bool, + disableEscapeKeyDown: PropTypes.bool, + disablePortal: PropTypes.bool, + disableRestoreFocus: PropTypes.bool, + disableScrollLock: PropTypes.bool, + hideBackdrop: PropTypes.bool, + keepMounted: PropTypes.bool, + onClose: PropTypes.func, + onTransitionEnter: PropTypes.func, + onTransitionExited: PropTypes.func, + open: PropTypes.bool.isRequired, +}; + +const Backdrop = React.forwardRef((props, ref) => { + const { open, ...other } = props; + return ( + +
+ + ); +}); + +Backdrop.propTypes = { + open: PropTypes.bool, +}; + +const blue = { + 200: '#99CCF3', + 400: '#3399FF', + 500: '#007FFF', +}; + +const grey = { + 50: '#f6f8fa', + 100: '#eaeef2', + 200: '#d0d7de', + 300: '#afb8c1', + 400: '#8c959f', + 500: '#6e7781', + 600: '#57606a', + 700: '#424a53', + 800: '#32383f', + 900: '#24292f', +}; + +const style = (theme) => ({ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 400, + borderRadius: '12px', + padding: '16px 32px 24px 32px', + backgroundColor: theme.palette.mode === 'dark' ? '#0A1929' : 'white', + boxShadow: `0px 2px 24px ${theme.palette.mode === 'dark' ? '#000' : '#383838'}`, +}); + +const CustomModalRoot = styled('div')` + position: fixed; + z-index: 1300; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +`; + +const CustomModalBackdrop = styled(Backdrop)` + z-index: -1; + position: fixed; + inset: 0; + background-color: rgb(0 0 0 / 0.5); + -webkit-tap-highlight-color: transparent; +`; + +const TriggerButton = styled(Button)( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + font-weight: 600; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + border-radius: 12px; + padding: 6px 12px; + line-height: 1.5; + background: transparent; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[200]}; + color: ${theme.palette.mode === 'dark' ? grey[100] : grey[900]}; + + &:hover { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]}; + border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]}; + } + + &:focus-visible { + border-color: ${blue[400]}; + outline: 3px solid ${theme.palette.mode === 'dark' ? blue[500] : blue[200]}; + } + `, +); diff --git a/docs/data/base/components/modal/UseModal.tsx b/docs/data/base/components/modal/UseModal.tsx new file mode 100644 index 00000000000000..805452db4ef0d5 --- /dev/null +++ b/docs/data/base/components/modal/UseModal.tsx @@ -0,0 +1,252 @@ +'use client'; +import * as React from 'react'; +import clsx from 'clsx'; +import { Box, styled, Theme } from '@mui/system'; +import Portal, { PortalProps } from '@mui/base/Portal'; +import FocusTrap from '@mui/base/FocusTrap'; +import Button from '@mui/base/Button'; +import useModal from '@mui/base/unstable_useModal'; +import Fade from '@mui/material/Fade'; + +export default function UseModal() { + const [open, setOpen] = React.useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + return ( +
+ Open modal + + + +

Text in a modal

+ + Duis mollis, est non commodo luctus, nisi erat porttitor ligula. + +
+
+
+
+ ); +} + +interface ModalProps { + children: React.ReactElement; + closeAfterTransition?: boolean; + container?: PortalProps['container']; + disableAutoFocus?: boolean; + disableEnforceFocus?: boolean; + disableEscapeKeyDown?: boolean; + disablePortal?: boolean; + disableRestoreFocus?: boolean; + disableScrollLock?: boolean; + hideBackdrop?: boolean; + keepMounted?: boolean; + onClose?: (event: {}, reason: 'backdropClick' | 'escapeKeyDown') => void; + onTransitionEnter?: () => void; + onTransitionExited?: () => void; + open: boolean; +} + +const Modal = React.forwardRef(function Modal( + props: ModalProps, + forwardedRef: React.ForwardedRef, +) { + const { + children, + closeAfterTransition = false, + container, + disableAutoFocus = false, + disableEnforceFocus = false, + disableEscapeKeyDown = false, + disablePortal = false, + disableRestoreFocus = false, + disableScrollLock = false, + hideBackdrop = false, + keepMounted = false, + onClose, + open, + onTransitionEnter, + onTransitionExited, + ...other + } = props; + + const propsWithDefaults = { + ...props, + closeAfterTransition, + disableAutoFocus, + disableEnforceFocus, + disableEscapeKeyDown, + disablePortal, + disableRestoreFocus, + disableScrollLock, + hideBackdrop, + keepMounted, + }; + + const { + getRootProps, + getBackdropProps, + getTransitionProps, + rootRef, + portalRef, + isTopModal, + exited, + hasTransition, + } = useModal({ + ...propsWithDefaults, + ref: forwardedRef, + }); + + const classes = { + hidden: !open && exited, + }; + + const childProps: { + onEnter?: () => void; + onExited?: () => void; + tabIndex?: string; + } = {}; + if (children.props.tabIndex === undefined) { + childProps.tabIndex = '-1'; + } + + // It's a Transition like component + if (hasTransition) { + const { onEnter, onExited } = getTransitionProps(); + childProps.onEnter = onEnter; + childProps.onExited = onExited; + } + + const rootProps = { + ...other, + ref: rootRef as React.Ref, + role: 'presentation', + className: clsx(classes), + ...getRootProps(other), + }; + + const backdropProps = { + 'aria-hidden': true, + open, + ...getBackdropProps(), + }; + + if (!keepMounted && !open && (!hasTransition || exited)) { + return null; + } + + return ( + + {/* + * Marking an element with the role presentation indicates to assistive technology + * that this element should be ignored; it exists to support the web application and + * is not meant for humans to interact with directly. + * https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md + */} + + {!hideBackdrop ? : null} + isTopModal} + open={open} + > + {React.cloneElement(children, childProps)} + + + + ); +}); + +const Backdrop = React.forwardRef( + (props, ref) => { + const { open, ...other } = props; + return ( + +
+ + ); + }, +); + +const blue = { + 200: '#99CCF3', + 400: '#3399FF', + 500: '#007FFF', +}; + +const grey = { + 50: '#f6f8fa', + 100: '#eaeef2', + 200: '#d0d7de', + 300: '#afb8c1', + 400: '#8c959f', + 500: '#6e7781', + 600: '#57606a', + 700: '#424a53', + 800: '#32383f', + 900: '#24292f', +}; + +const style = (theme: Theme) => ({ + position: 'absolute' as 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 400, + borderRadius: '12px', + padding: '16px 32px 24px 32px', + backgroundColor: theme.palette.mode === 'dark' ? '#0A1929' : 'white', + boxShadow: `0px 2px 24px ${theme.palette.mode === 'dark' ? '#000' : '#383838'}`, +}); + +const CustomModalRoot = styled('div')` + position: fixed; + z-index: 1300; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +`; + +const CustomModalBackdrop = styled(Backdrop)` + z-index: -1; + position: fixed; + inset: 0; + background-color: rgb(0 0 0 / 0.5); + -webkit-tap-highlight-color: transparent; +`; + +const TriggerButton = styled(Button)( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + font-weight: 600; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + border-radius: 12px; + padding: 6px 12px; + line-height: 1.5; + background: transparent; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[200]}; + color: ${theme.palette.mode === 'dark' ? grey[100] : grey[900]}; + + &:hover { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]}; + border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]}; + } + + &:focus-visible { + border-color: ${blue[400]}; + outline: 3px solid ${theme.palette.mode === 'dark' ? blue[500] : blue[200]}; + } + `, +); diff --git a/docs/data/base/components/modal/modal.md b/docs/data/base/components/modal/modal.md index 3e086792ba75ae..472bf37baee117 100644 --- a/docs/data/base/components/modal/modal.md +++ b/docs/data/base/components/modal/modal.md @@ -151,6 +151,35 @@ This is done for accessibility purposes, but it can potentially create issues fo If the user needs to interact with another part of the page—for example, to interact with a chatbot window while a modal is open in the parent app—you can disable the default behavior with the `disableEnforceFocus` prop. +## Hook + +```js +import useModal from '@mui/base/unstable_useModal'; +``` + +The `useModal` hook lets you apply the functionality of a modal to a fully custom component. +It returns props to be placed on the custom component, along with fields representing the component's internal state. + +Hooks _do not_ support [slot props](#slot-props), but they do support [customization props](#customization). + +:::info +Hooks give you the most room for customization, but require more work to implement. +With hooks, you can take full control over how your component is rendered, and define all the custom props and CSS classes you need. + +You may not need to use hooks unless you find that you're limited by the customization options of their component counterparts—for instance, if your component requires significantly different [structure](#anatomy). +::: + +The following demo shows how to build the same modal as the one found in the section above, but with the `useModal` hook: + +{{"demo": "UseModal.js", "defaultCodeOpen": true}} + +If you use a ref to store a reference to the root element, pass it to the `useModal`'s `ref` parameter, as shown in the demo above. +It will get merged with a ref used internally in the hook. + +:::warning +Do not add the `ref` parameter to the root element manually, as the correct ref is already a part of the object returned by the `getRootProps` function. +::: + ## Accessibility See the [WAI-ARIA guide on the Dialog (Modal) pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) for complete details on accessibility best practices. diff --git a/packages/mui-base/src/Modal/Modal.tsx b/packages/mui-base/src/Modal/Modal.tsx index bb8bfe9c05846b..57c26642b2b47a 100644 --- a/packages/mui-base/src/Modal/Modal.tsx +++ b/packages/mui-base/src/Modal/Modal.tsx @@ -135,7 +135,6 @@ const Modal = React.forwardRef(function Modal { @@ -187,7 +185,7 @@ const Modal = React.forwardRef(function Modal isTopModal} open={open} > {React.cloneElement(children, childProps)} From 283633ce417f7ec6ec7a5ed5cdbf4881d1f85f22 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 10:21:58 +0300 Subject: [PATCH 11/24] Fix isTopModal type & usage --- docs/data/base/components/modal/UseModal.js | 2 +- docs/data/base/components/modal/UseModal.tsx | 2 +- packages/mui-base/src/Modal/Modal.tsx | 2 +- packages/mui-base/src/unstable_useModal/useModal.types.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/data/base/components/modal/UseModal.js b/docs/data/base/components/modal/UseModal.js index 6346fc37440e81..0677b8d04b8410 100644 --- a/docs/data/base/components/modal/UseModal.js +++ b/docs/data/base/components/modal/UseModal.js @@ -132,7 +132,7 @@ const Modal = React.forwardRef(function Modal(props, forwardedRef) { disableEnforceFocus={disableEnforceFocus} disableAutoFocus={disableAutoFocus} disableRestoreFocus={disableRestoreFocus} - isEnabled={() => isTopModal} + isEnabled={isTopModal} open={open} > {React.cloneElement(children, childProps)} diff --git a/docs/data/base/components/modal/UseModal.tsx b/docs/data/base/components/modal/UseModal.tsx index 805452db4ef0d5..331f9b8cfaed33 100644 --- a/docs/data/base/components/modal/UseModal.tsx +++ b/docs/data/base/components/modal/UseModal.tsx @@ -156,7 +156,7 @@ const Modal = React.forwardRef(function Modal( disableEnforceFocus={disableEnforceFocus} disableAutoFocus={disableAutoFocus} disableRestoreFocus={disableRestoreFocus} - isEnabled={() => isTopModal} + isEnabled={isTopModal} open={open} > {React.cloneElement(children, childProps)} diff --git a/packages/mui-base/src/Modal/Modal.tsx b/packages/mui-base/src/Modal/Modal.tsx index 57c26642b2b47a..370d6c1e2cbaec 100644 --- a/packages/mui-base/src/Modal/Modal.tsx +++ b/packages/mui-base/src/Modal/Modal.tsx @@ -185,7 +185,7 @@ const Modal = React.forwardRef(function Modal isTopModal} + isEnabled={isTopModal} open={open} > {React.cloneElement(children, childProps)} diff --git a/packages/mui-base/src/unstable_useModal/useModal.types.ts b/packages/mui-base/src/unstable_useModal/useModal.types.ts index 419bdad1392f20..c64dd43517c70a 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.types.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.types.ts @@ -101,7 +101,7 @@ export interface UseModalReturnValue { /** * If `true`, the modal is the top most one. */ - isTopModal: boolean; + isTopModal: () => boolean; /** * If `true`, the exiting transition finished (to be used for unmounting the component). */ From 06d0475a209d208f56cbcbc1df97f1f4b0ce18b1 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 13:27:44 +0300 Subject: [PATCH 12/24] demos fixes --- .eslintrc.js | 13 +++++++++++++ docs/data/base/components/modal/UseModal.tsx | 4 ++-- packages/mui-base/src/Modal/Modal.tsx | 15 ++++----------- packages/mui-base/src/unstable_useModal/index.ts | 1 - .../mui-base/src/unstable_useModal/useModal.ts | 4 ++-- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 93b9a674c28484..6b8e742803c739 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -299,6 +299,19 @@ module.exports = { 'no-console': 'off', }, }, + // demos - proptype generation + { + files: [ + 'docs/data/base/components/modal/UseModal.js', + ], + rules: { + 'func-names': 'off', + 'consistent-return': 'off', + 'prefer-template': 'off', + 'no-else-return': 'off', + 'prefer-template': 'off' + } + }, { files: ['docs/data/**/*.tsx'], excludedFiles: [ diff --git a/docs/data/base/components/modal/UseModal.tsx b/docs/data/base/components/modal/UseModal.tsx index 331f9b8cfaed33..612a010e8f1b33 100644 --- a/docs/data/base/components/modal/UseModal.tsx +++ b/docs/data/base/components/modal/UseModal.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import clsx from 'clsx'; import { Box, styled, Theme } from '@mui/system'; -import Portal, { PortalProps } from '@mui/base/Portal'; +import Portal from '@mui/base/Portal'; import FocusTrap from '@mui/base/FocusTrap'; import Button from '@mui/base/Button'; import useModal from '@mui/base/unstable_useModal'; @@ -39,7 +39,7 @@ export default function UseModal() { interface ModalProps { children: React.ReactElement; closeAfterTransition?: boolean; - container?: PortalProps['container']; + container?: Element | (() => Element | null) | null; disableAutoFocus?: boolean; disableEnforceFocus?: boolean; disableEscapeKeyDown?: boolean; diff --git a/packages/mui-base/src/Modal/Modal.tsx b/packages/mui-base/src/Modal/Modal.tsx index 370d6c1e2cbaec..0bf3b81266de70 100644 --- a/packages/mui-base/src/Modal/Modal.tsx +++ b/packages/mui-base/src/Modal/Modal.tsx @@ -1,23 +1,16 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; -import { - elementAcceptingRef, - HTMLElementType, - unstable_ownerDocument as ownerDocument, - unstable_useForkRef as useForkRef, - unstable_useEventCallback as useEventCallback, -} from '@mui/utils'; -import { EventHandlers } from '../utils'; +import { elementAcceptingRef, HTMLElementType } from '@mui/utils'; +import { EventHandlers, useSlotProps } from '../utils'; +import { useClassNamesOverride } from '../utils/ClassNameConfigurator'; import { PolymorphicComponent } from '../utils/PolymorphicComponent'; -import { ModalOwnerState, ModalProps, ModalTypeMap } from './Modal.types'; import composeClasses from '../composeClasses'; import Portal from '../Portal'; import useModal from '../unstable_useModal'; import FocusTrap from '../FocusTrap'; +import { ModalOwnerState, ModalProps, ModalTypeMap } from './Modal.types'; import { getModalUtilityClass } from './modalClasses'; -import { useSlotProps } from '../utils'; -import { useClassNamesOverride } from '../utils/ClassNameConfigurator'; const useUtilityClasses = (ownerState: ModalOwnerState) => { const { open, exited } = ownerState; diff --git a/packages/mui-base/src/unstable_useModal/index.ts b/packages/mui-base/src/unstable_useModal/index.ts index 2cb269db4bfd4b..8a0350de1da2d9 100644 --- a/packages/mui-base/src/unstable_useModal/index.ts +++ b/packages/mui-base/src/unstable_useModal/index.ts @@ -1,4 +1,3 @@ export { default } from './useModal'; -export * from './useModal'; export * from './useModal.types'; diff --git a/packages/mui-base/src/unstable_useModal/useModal.ts b/packages/mui-base/src/unstable_useModal/useModal.ts index 452b0c76ab4bb8..6bed5b408b56b0 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.ts @@ -170,8 +170,8 @@ export default function useModal(parameters: UseModalParameters): UseModalReturn const propsEventHandlers = extractEventHandlers(parameters) as Partial; // The custom event handlers shouldn't be spreaded on the root element - delete propsEventHandlers['onTransitionEnter']; - delete propsEventHandlers['onTransitionExited']; + delete propsEventHandlers.onTransitionEnter; + delete propsEventHandlers.onTransitionExited; const externalEventHandlers = { ...propsEventHandlers, From 40d5174f4bedcb9ad3b958f8cbe25e740e02cabb Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 13:36:09 +0300 Subject: [PATCH 13/24] Add use client directive --- packages/mui-base/src/unstable_useModal/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/unstable_useModal/index.ts b/packages/mui-base/src/unstable_useModal/index.ts index 8a0350de1da2d9..f1069346297350 100644 --- a/packages/mui-base/src/unstable_useModal/index.ts +++ b/packages/mui-base/src/unstable_useModal/index.ts @@ -1,3 +1,3 @@ +"use client"; export { default } from './useModal'; - export * from './useModal.types'; From 7bd41428bba3fbb4402dbd8435019056c70dd84b Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 13:40:33 +0300 Subject: [PATCH 14/24] prettier --- .eslintrc.js | 10 ++++------ packages/mui-base/src/unstable_useModal/index.ts | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 6b8e742803c739..1cc7893a3e8ace 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -299,18 +299,16 @@ module.exports = { 'no-console': 'off', }, }, - // demos - proptype generation + // demos - proptype generation { - files: [ - 'docs/data/base/components/modal/UseModal.js', - ], + files: ['docs/data/base/components/modal/UseModal.js'], rules: { 'func-names': 'off', 'consistent-return': 'off', 'prefer-template': 'off', 'no-else-return': 'off', - 'prefer-template': 'off' - } + 'prefer-template': 'off', + }, }, { files: ['docs/data/**/*.tsx'], diff --git a/packages/mui-base/src/unstable_useModal/index.ts b/packages/mui-base/src/unstable_useModal/index.ts index f1069346297350..ac9dea6376298d 100644 --- a/packages/mui-base/src/unstable_useModal/index.ts +++ b/packages/mui-base/src/unstable_useModal/index.ts @@ -1,3 +1,3 @@ -"use client"; +'use client'; export { default } from './useModal'; export * from './useModal.types'; From 122cc4e05446f2e855a5f825ac8a01d86ec906b6 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 13:55:34 +0300 Subject: [PATCH 15/24] lint & docs:api --- .eslintrc.js | 3 +-- docs/pages/base-ui/api/use-modal.json | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 1cc7893a3e8ace..5fc1df167a5125 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -303,9 +303,8 @@ module.exports = { { files: ['docs/data/base/components/modal/UseModal.js'], rules: { - 'func-names': 'off', 'consistent-return': 'off', - 'prefer-template': 'off', + 'func-names': 'off', 'no-else-return': 'off', 'prefer-template': 'off', }, diff --git a/docs/pages/base-ui/api/use-modal.json b/docs/pages/base-ui/api/use-modal.json index 4129b15c0b83ff..fb983ff41d17f3 100644 --- a/docs/pages/base-ui/api/use-modal.json +++ b/docs/pages/base-ui/api/use-modal.json @@ -69,7 +69,10 @@ "required": true }, "hasTransition": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, - "isTopModal": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, + "isTopModal": { + "type": { "name": "() => boolean", "description": "() => boolean" }, + "required": true + }, "portalRef": { "type": { "name": "React.Ref<Element>", "description": "React.Ref<Element>" }, "required": true From 50a2cfb4ee5b51851482dae4eb40b16910231a29 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 14:05:57 +0300 Subject: [PATCH 16/24] ci fixes --- docs/translations/translations.json | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/translations/translations.json b/docs/translations/translations.json index e4cdd4d1e8ef6f..c42dd3b14a9244 100644 --- a/docs/translations/translations.json +++ b/docs/translations/translations.json @@ -300,6 +300,7 @@ "/base-ui/react-menu/hooks-api/#use-menu": "useMenu", "/base-ui/react-menu/hooks-api/#use-menu-button": "useMenuButton", "/base-ui/react-menu/hooks-api/#use-menu-item": "useMenuItem", + "/base-ui/react-modal/hooks-api/#use-modal": "useModal", "/base-ui/react-select/hooks-api/#use-option": "useOption", "/base-ui/react-select/hooks-api/#use-select": "useSelect", "/base-ui/react-slider/hooks-api/#use-slider": "useSlider", From 2331dc33a61c55a6b1b85e48742b4eae17a4d546 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 2 Aug 2023 14:57:41 +0300 Subject: [PATCH 17/24] Fix useAutocomplete issue --- docs/src/modules/components/HooksApiContent.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/modules/components/HooksApiContent.js b/docs/src/modules/components/HooksApiContent.js index a776484fac2ece..e5e45452daa63e 100644 --- a/docs/src/modules/components/HooksApiContent.js +++ b/docs/src/modules/components/HooksApiContent.js @@ -56,10 +56,10 @@ export default function HooksApiContent(props) { const { parametersDescriptions, returnValueDescriptions } = descriptions[key][userLanguage]; - const source = filename - .replace(/\/packages\/mui(-(.+?))?\/src/, (match, dash, pkg) => `@mui/${pkg}`) - // convert things like `/Table/Table.js` to `` - .replace(/\/([^/]+)\/\1\.(js|tsx)$/, ''); + const source = filename.replace( + /\/packages\/mui(-(.+?))?\/src/, + (match, dash, pkg) => `@mui/${pkg}`, + ); const hookNameKebabCase = kebabCase(hookName); From b8103b76d19ef06efd4074cccc94d14508b68c41 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Thu, 3 Aug 2023 15:26:42 +0300 Subject: [PATCH 18/24] docs:api --- docs/pages/base-ui/api/number-input.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/base-ui/api/number-input.json b/docs/pages/base-ui/api/number-input.json index ab15ad7756169b..f289da20b97203 100644 --- a/docs/pages/base-ui/api/number-input.json +++ b/docs/pages/base-ui/api/number-input.json @@ -36,7 +36,7 @@ "description": "{ decrementButton?: elementType, incrementButton?: elementType, input?: elementType, root?: elementType }" }, "default": "{}", - "additionalPropsInfo": { "slotsApi": true } + "additionalInfo": { "slotsApi": true } }, "step": { "type": { "name": "number" } }, "value": { "type": { "name": "any" } } From e54dfda7e0bccc1746d7d56ee50f83ae967ac295 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Fri, 4 Aug 2023 09:51:42 +0300 Subject: [PATCH 19/24] Review comments --- docs/pages/base-ui/api/use-modal.json | 24 ++++++++++++------- packages/mui-base/src/Modal/index.ts | 1 - packages/mui-base/src/index.d.ts | 2 -- packages/mui-base/src/index.js | 2 -- .../ModalManager.test.ts | 0 .../ModalManager.ts | 0 .../mui-base/src/unstable_useModal/index.ts | 3 ++- .../src/unstable_useModal/useModal.ts | 8 +++---- .../src/unstable_useModal/useModal.types.ts | 19 ++++++++++----- packages/mui-joy/src/Modal/Modal.tsx | 5 +--- 10 files changed, 35 insertions(+), 29 deletions(-) rename packages/mui-base/src/{Modal => unstable_useModal}/ModalManager.test.ts (100%) rename packages/mui-base/src/{Modal => unstable_useModal}/ModalManager.ts (100%) diff --git a/docs/pages/base-ui/api/use-modal.json b/docs/pages/base-ui/api/use-modal.json index fb983ff41d17f3..f40ebdef554aef 100644 --- a/docs/pages/base-ui/api/use-modal.json +++ b/docs/pages/base-ui/api/use-modal.json @@ -5,7 +5,7 @@ "required": true }, "open": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, - "ref": { + "rootRef": { "type": { "name": "React.Ref<Element>", "description": "React.Ref<Element>" }, "required": true }, @@ -49,22 +49,22 @@ "exited": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, "getBackdropProps": { "type": { - "name": "(externalProps?: any) => React.HTMLAttributes<HTMLDivElement>", - "description": "(externalProps?: any) => React.HTMLAttributes<HTMLDivElement>" + "name": "<TOther extends EventHandlers = {}>(externalProps?: TOther) => UseModalBackdropSlotProps<TOther>", + "description": "<TOther extends EventHandlers = {}>(externalProps?: TOther) => UseModalBackdropSlotProps<TOther>" }, "required": true }, "getRootProps": { "type": { - "name": "(externalProps?: any) => React.HTMLAttributes<HTMLDivElement>", - "description": "(externalProps?: any) => React.HTMLAttributes<HTMLDivElement>" + "name": "<TOther extends EventHandlers = {}>(externalProps?: TOther) => UseModalRootSlotProps<TOther>", + "description": "<TOther extends EventHandlers = {}>(externalProps?: TOther) => UseModalRootSlotProps<TOther>" }, "required": true }, "getTransitionProps": { "type": { - "name": "(externalProps?: any) => { onEnter: () => void; onExited: () => void }", - "description": "(externalProps?: any) => { onEnter: () => void; onExited: () => void }" + "name": "<TOther extends EventHandlers = {}>(externalProps?: TOther) => { onEnter: () => void; onExited: () => void }", + "description": "<TOther extends EventHandlers = {}>(externalProps?: TOther) => { onEnter: () => void; onExited: () => void }" }, "required": true }, @@ -74,11 +74,17 @@ "required": true }, "portalRef": { - "type": { "name": "React.Ref<Element>", "description": "React.Ref<Element>" }, + "type": { + "name": "React.RefCallback<Element> | null", + "description": "React.RefCallback<Element> | null" + }, "required": true }, "rootRef": { - "type": { "name": "React.Ref<Element>", "description": "React.Ref<Element>" }, + "type": { + "name": "React.RefCallback<Element> | null", + "description": "React.RefCallback<Element> | null" + }, "required": true } }, diff --git a/packages/mui-base/src/Modal/index.ts b/packages/mui-base/src/Modal/index.ts index fd8d286a78f3f7..37908e8173f35b 100644 --- a/packages/mui-base/src/Modal/index.ts +++ b/packages/mui-base/src/Modal/index.ts @@ -1,5 +1,4 @@ 'use client'; export { Modal } from './Modal'; export * from './Modal.types'; -export * from './ModalManager'; export * from './modalClasses'; diff --git a/packages/mui-base/src/index.d.ts b/packages/mui-base/src/index.d.ts index d4eae9c550aaaf..e86c31a40c256a 100644 --- a/packages/mui-base/src/index.d.ts +++ b/packages/mui-base/src/index.d.ts @@ -45,6 +45,4 @@ export * from './useTab'; export * from './useTabPanel'; export * from './useTabs'; export * from './useTabsList'; - -export { default as unstable_useModal } from './unstable_useModal'; export * from './unstable_useModal'; diff --git a/packages/mui-base/src/index.js b/packages/mui-base/src/index.js index 3ce013e6b57a99..e82145bee7be81 100644 --- a/packages/mui-base/src/index.js +++ b/packages/mui-base/src/index.js @@ -47,6 +47,4 @@ export * from './useTab'; export * from './useTabPanel'; export * from './useTabs'; export * from './useTabsList'; - -export { default as unstable_useModal } from './unstable_useModal'; export * from './unstable_useModal'; diff --git a/packages/mui-base/src/Modal/ModalManager.test.ts b/packages/mui-base/src/unstable_useModal/ModalManager.test.ts similarity index 100% rename from packages/mui-base/src/Modal/ModalManager.test.ts rename to packages/mui-base/src/unstable_useModal/ModalManager.test.ts diff --git a/packages/mui-base/src/Modal/ModalManager.ts b/packages/mui-base/src/unstable_useModal/ModalManager.ts similarity index 100% rename from packages/mui-base/src/Modal/ModalManager.ts rename to packages/mui-base/src/unstable_useModal/ModalManager.ts diff --git a/packages/mui-base/src/unstable_useModal/index.ts b/packages/mui-base/src/unstable_useModal/index.ts index ac9dea6376298d..cdfed6b550e5de 100644 --- a/packages/mui-base/src/unstable_useModal/index.ts +++ b/packages/mui-base/src/unstable_useModal/index.ts @@ -1,3 +1,4 @@ 'use client'; -export { default } from './useModal'; +export { useModal as unstable_useModal } from './useModal'; export * from './useModal.types'; +export * from './ModalManager'; diff --git a/packages/mui-base/src/unstable_useModal/useModal.ts b/packages/mui-base/src/unstable_useModal/useModal.ts index 6bed5b408b56b0..aae58837239645 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.ts @@ -7,7 +7,7 @@ import { unstable_createChainedFunction as createChainedFunction, } from '@mui/utils'; import { EventHandlers, extractEventHandlers } from '../utils'; -import ModalManager, { ariaHidden } from '../Modal/ModalManager'; +import { ModalManager, ariaHidden } from './ModalManager'; import { UseModalParameters, UseModalReturnValue, @@ -36,7 +36,7 @@ const defaultManager = new ModalManager(); * * - [useModal API](https://mui.com/base-ui/react-modal/hooks-api/#use-modal) */ -export default function useModal(parameters: UseModalParameters): UseModalReturnValue { +export function useModal(parameters: UseModalParameters): UseModalReturnValue { const { container, disableEscapeKeyDown = false, @@ -49,14 +49,14 @@ export default function useModal(parameters: UseModalParameters): UseModalReturn children, onClose, open, - ref, + rootRef, } = parameters; // @ts-ignore internal logic const modal = React.useRef<{ modalRef: HTMLDivElement; mount: HTMLElement }>({}); const mountNodeRef = React.useRef(null); const modalRef = React.useRef(null); - const handleRef = useForkRef(modalRef, ref); + const handleRef = useForkRef(modalRef, rootRef); const [exited, setExited] = React.useState(!open); const hasTransition = getHasTransition(children); diff --git a/packages/mui-base/src/unstable_useModal/useModal.types.ts b/packages/mui-base/src/unstable_useModal/useModal.types.ts index c64dd43517c70a..3d72a58ed8e8af 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.types.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.types.ts @@ -1,4 +1,5 @@ import { PortalProps } from '../Portal'; +import { EventHandlers } from '../utils'; export interface UseModalRootSlotOwnProps { role: React.AriaRole; @@ -68,7 +69,7 @@ export type UseModalParameters = { * If `true`, the component is shown. */ open: boolean; - ref: React.Ref; + rootRef: React.Ref; }; export interface UseModalReturnValue { @@ -77,27 +78,33 @@ export interface UseModalReturnValue { * @param externalProps props for the root slot * @returns props that should be spread on the root slot */ - getRootProps: (externalProps?: any) => React.HTMLAttributes; + getRootProps: ( + externalProps?: TOther, + ) => UseModalRootSlotProps; /** * Resolver for the backdrop slot's props. * @param externalProps props for the backdrop slot * @returns props that should be spread on the backdrop slot */ - getBackdropProps: (externalProps?: any) => React.HTMLAttributes; + getBackdropProps: ( + externalProps?: TOther, + ) => UseModalBackdropSlotProps; /** * Resolver for the transition related props. * @param externalProps props for the transition element * @returns props that should be spread on the transition element */ - getTransitionProps: (externalProps?: any) => { onEnter: () => void; onExited: () => void }; + getTransitionProps: ( + externalProps?: TOther, + ) => { onEnter: () => void; onExited: () => void }; /** * A ref to the component's root DOM element. */ - rootRef: React.Ref; + rootRef: React.RefCallback | null; /** * A ref to the component's portal DOM element. */ - portalRef: React.Ref; + portalRef: React.RefCallback | null; /** * If `true`, the modal is the top most one. */ diff --git a/packages/mui-joy/src/Modal/Modal.tsx b/packages/mui-joy/src/Modal/Modal.tsx index 3e3e395450d173..c1392ad366c6c0 100644 --- a/packages/mui-joy/src/Modal/Modal.tsx +++ b/packages/mui-joy/src/Modal/Modal.tsx @@ -2,10 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { OverridableComponent } from '@mui/types'; -import { - elementAcceptingRef, - HTMLElementType, -} from '@mui/utils'; +import { elementAcceptingRef, HTMLElementType } from '@mui/utils'; import { unstable_composeClasses as composeClasses } from '@mui/base/composeClasses'; import { Portal } from '@mui/base/Portal'; import { FocusTrap } from '@mui/base/FocusTrap'; From ebe554daf4216dce6e15f7d6aa03b2a9320fa9b0 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Fri, 4 Aug 2023 10:26:54 +0300 Subject: [PATCH 20/24] ref -> rootRef --- packages/mui-base/src/Modal/Modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/Modal/Modal.tsx b/packages/mui-base/src/Modal/Modal.tsx index f9390504f925b8..d55e5c6424b151 100644 --- a/packages/mui-base/src/Modal/Modal.tsx +++ b/packages/mui-base/src/Modal/Modal.tsx @@ -95,7 +95,7 @@ const Modal = React.forwardRef(function Modal Date: Fri, 4 Aug 2023 10:52:43 +0300 Subject: [PATCH 21/24] fixes --- docs/data/base/components/modal/UseModal.js | 14 +++++--------- docs/data/base/components/modal/UseModal.tsx | 14 +++++--------- packages/mui-base/src/Modal/Modal.tsx | 3 +-- packages/mui-material/src/Modal/index.d.ts | 2 +- packages/mui-material/src/Modal/index.js | 2 +- 5 files changed, 13 insertions(+), 22 deletions(-) diff --git a/docs/data/base/components/modal/UseModal.js b/docs/data/base/components/modal/UseModal.js index 0677b8d04b8410..2223c591e8f59c 100644 --- a/docs/data/base/components/modal/UseModal.js +++ b/docs/data/base/components/modal/UseModal.js @@ -3,10 +3,10 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import { Box, styled } from '@mui/system'; -import Portal from '@mui/base/Portal'; -import FocusTrap from '@mui/base/FocusTrap'; -import Button from '@mui/base/Button'; -import useModal from '@mui/base/unstable_useModal'; +import { Portal } from '@mui/base/Portal'; +import { FocusTrap } from '@mui/base/FocusTrap'; +import { Button } from '@mui/base/Button'; +import { unstable_useModal as useModal } from '@mui/base/unstable_useModal'; import Fade from '@mui/material/Fade'; export default function UseModal() { @@ -74,14 +74,13 @@ const Modal = React.forwardRef(function Modal(props, forwardedRef) { getRootProps, getBackdropProps, getTransitionProps, - rootRef, portalRef, isTopModal, exited, hasTransition, } = useModal({ ...propsWithDefaults, - ref: forwardedRef, + rootRef: forwardedRef, }); const classes = { @@ -102,14 +101,11 @@ const Modal = React.forwardRef(function Modal(props, forwardedRef) { const rootProps = { ...other, - ref: rootRef, - role: 'presentation', className: clsx(classes), ...getRootProps(other), }; const backdropProps = { - 'aria-hidden': true, open, ...getBackdropProps(), }; diff --git a/docs/data/base/components/modal/UseModal.tsx b/docs/data/base/components/modal/UseModal.tsx index 612a010e8f1b33..6d69e3f7b9884b 100644 --- a/docs/data/base/components/modal/UseModal.tsx +++ b/docs/data/base/components/modal/UseModal.tsx @@ -2,10 +2,10 @@ import * as React from 'react'; import clsx from 'clsx'; import { Box, styled, Theme } from '@mui/system'; -import Portal from '@mui/base/Portal'; -import FocusTrap from '@mui/base/FocusTrap'; -import Button from '@mui/base/Button'; -import useModal from '@mui/base/unstable_useModal'; +import { Portal } from '@mui/base/Portal'; +import { FocusTrap } from '@mui/base/FocusTrap'; +import { Button } from '@mui/base/Button'; +import { unstable_useModal as useModal } from '@mui/base/unstable_useModal'; import Fade from '@mui/material/Fade'; export default function UseModal() { @@ -94,14 +94,13 @@ const Modal = React.forwardRef(function Modal( getRootProps, getBackdropProps, getTransitionProps, - rootRef, portalRef, isTopModal, exited, hasTransition, } = useModal({ ...propsWithDefaults, - ref: forwardedRef, + rootRef: forwardedRef, }); const classes = { @@ -126,14 +125,11 @@ const Modal = React.forwardRef(function Modal( const rootProps = { ...other, - ref: rootRef as React.Ref, - role: 'presentation', className: clsx(classes), ...getRootProps(other), }; const backdropProps = { - 'aria-hidden': true, open, ...getBackdropProps(), }; diff --git a/packages/mui-base/src/Modal/Modal.tsx b/packages/mui-base/src/Modal/Modal.tsx index d55e5c6424b151..f0d3b49ad02ff3 100644 --- a/packages/mui-base/src/Modal/Modal.tsx +++ b/packages/mui-base/src/Modal/Modal.tsx @@ -88,7 +88,6 @@ const Modal = React.forwardRef(function Modal + {!hideBackdrop && BackdropComponent ? : null} Date: Fri, 4 Aug 2023 11:48:36 +0300 Subject: [PATCH 22/24] more fixes --- packages/mui-base/src/Modal/Modal.tsx | 7 ------- packages/mui-joy/src/Modal/Modal.tsx | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/mui-base/src/Modal/Modal.tsx b/packages/mui-base/src/Modal/Modal.tsx index f0d3b49ad02ff3..074e0e83cf5c5a 100644 --- a/packages/mui-base/src/Modal/Modal.tsx +++ b/packages/mui-base/src/Modal/Modal.tsx @@ -126,9 +126,6 @@ const Modal = React.forwardRef(function Modal { return getBackdropProps({ ...otherHandlers, diff --git a/packages/mui-joy/src/Modal/Modal.tsx b/packages/mui-joy/src/Modal/Modal.tsx index c1392ad366c6c0..e6740c75483c99 100644 --- a/packages/mui-joy/src/Modal/Modal.tsx +++ b/packages/mui-joy/src/Modal/Modal.tsx @@ -112,7 +112,7 @@ const Modal = React.forwardRef(function Modal(inProps, ref) { const { getRootProps, getBackdropProps, rootRef, portalRef, isTopModal } = useModal({ ...ownerState, - ref, + rootRef: ref, }); const classes = useUtilityClasses(ownerState); From 1e7ced762e3099ffd37c4879799156ae7f3d85ec Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 9 Aug 2023 11:34:34 +0200 Subject: [PATCH 23/24] Update packages/mui-base/src/unstable_useModal/useModal.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Dudak Signed-off-by: Marija Najdova --- packages/mui-base/src/unstable_useModal/useModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/unstable_useModal/useModal.ts b/packages/mui-base/src/unstable_useModal/useModal.ts index aae58837239645..366beeeecac7d4 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.ts @@ -169,7 +169,7 @@ export function useModal(parameters: UseModalParameters): UseModalReturnValue { ): UseModalRootSlotProps => { const propsEventHandlers = extractEventHandlers(parameters) as Partial; - // The custom event handlers shouldn't be spreaded on the root element + // The custom event handlers shouldn't be spread on the root element delete propsEventHandlers.onTransitionEnter; delete propsEventHandlers.onTransitionExited; From 5ce713a0d094393990386ff00291c66676ae507b Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 9 Aug 2023 12:03:49 +0200 Subject: [PATCH 24/24] simplify condition --- packages/mui-base/src/unstable_useModal/useModal.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/mui-base/src/unstable_useModal/useModal.ts b/packages/mui-base/src/unstable_useModal/useModal.ts index 366beeeecac7d4..1845810a01f7b2 100644 --- a/packages/mui-base/src/unstable_useModal/useModal.ts +++ b/packages/mui-base/src/unstable_useModal/useModal.ts @@ -61,10 +61,7 @@ export function useModal(parameters: UseModalParameters): UseModalReturnValue { const hasTransition = getHasTransition(children); let ariaHiddenProp = true; - if ( - parameters['aria-hidden'] === 'false' || - (typeof parameters['aria-hidden'] === 'boolean' && !parameters['aria-hidden']) - ) { + if (parameters['aria-hidden'] === 'false' || parameters['aria-hidden'] === false) { ariaHiddenProp = false; }