From 74cb26d31ee2925f44df9baea0914f4ae3958928 Mon Sep 17 00:00:00 2001 From: Ricardo Amaral Date: Tue, 4 Aug 2026 00:52:29 +0100 Subject: [PATCH] feat!: migrate Modal from Ariakit to Base UI Spike to reimplement Modal on top of @base-ui/react's Dialog instead of @ariakit/react, as part of the Reactist Ariakit -> Base UI migration. Base UI now owns focus trapping, scroll locking, outside-press detection, and hiding background content from assistive tech, replacing the react-focus-lock + aria-hidden combination (both dependencies dropped). The public component API is unchanged; isOpen/onDismiss/width/height/ autoFocus/data-autofocus and the inner components all behave as before. --- package-lock.json | 198 +++++++++++++++---------------- package.json | 4 +- scripts/jestSetup.ts | 17 +++ src/modal/modal.module.css | 10 +- src/modal/modal.stories.tsx | 10 +- src/modal/modal.tsx | 230 +++++++++++++++++------------------- 6 files changed, 231 insertions(+), 238 deletions(-) diff --git a/package-lock.json b/package-lock.json index e644a1bf3..96d4d7a5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,8 @@ "license": "MIT", "dependencies": { "@babel/runtime": "7.29.7", - "aria-hidden": "1.2.6", "dayjs": "1.11.21", "patch-package": "8.0.1", - "react-focus-lock": "2.13.7", "react-keyed-flatten-children": "1.3.0", "react-markdown": "5.0.3", "use-callback-ref": "1.3.3" @@ -28,6 +26,7 @@ "@babel/preset-react": "7.29.7", "@babel/preset-typescript": "7.29.7", "@babel/register": "7.29.7", + "@base-ui/react": "1.6.0", "@doist/eslint-config": "13.1.1", "@doist/prettier-config": "4.0.1", "@doist/product-libraries-tokens": "1.0.0", @@ -108,6 +107,7 @@ }, "peerDependencies": { "@ariakit/react": "~0.4.19", + "@base-ui/react": "^1.6.0", "classnames": "^2.2.5", "react": ">=18.0.0 <20.0.0", "react-compiler-runtime": "^1.0.0", @@ -2317,6 +2317,68 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", + "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.1", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -3677,30 +3739,44 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "dev": true, "license": "MIT" }, @@ -8166,18 +8242,6 @@ "dev": true, "license": "MIT" }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -10448,12 +10512,6 @@ "node": ">=8" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, "node_modules/diff-sequences": { "version": "28.1.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.1.1.tgz", @@ -12449,18 +12507,6 @@ "dev": true, "license": "ISC" }, - "node_modules/focus-lock": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.6.tgz", - "integrity": "sha512-Ik/6OCk9RQQ0T5Xw+hKNLWrjSMtv51dD4GRmJjbD5a58TIEpI5a5iXagKVl3Z5UuyslMCA8Xwnu76jQob62Yhg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -21678,18 +21724,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-clientside-effect": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.8.tgz", - "integrity": "sha512-ma2FePH0z3px2+WOu6h+YycZcEvFmmxIlAb62cF52bG86eMySciO/EQZeQMXd07kPCYB0a1dWDT5J+KE9mCDUw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.13" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - } - }, "node_modules/react-compiler-runtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/react-compiler-runtime/-/react-compiler-runtime-1.0.0.tgz", @@ -21783,29 +21817,6 @@ "loose-envify": "^1.1.0" } }, - "node_modules/react-focus-lock": { - "version": "2.13.7", - "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.13.7.tgz", - "integrity": "sha512-20lpZHEQrXPb+pp1tzd4ULL6DyO5D2KnR0G69tTDdydrmNhU7pdFmbQUYVyHUgp+xN29IuFR0PVuhOmvaZL9Og==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.0.0", - "focus-lock": "^1.3.6", - "prop-types": "^15.6.2", - "react-clientside-effect": "^1.2.7", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/react-is": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", @@ -22227,6 +22238,13 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -25576,28 +25594,6 @@ } } }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/package.json b/package.json index 38421ed79..8d1e6affa 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ }, "peerDependencies": { "@ariakit/react": "~0.4.19", + "@base-ui/react": "^1.6.0", "classnames": "^2.2.5", "react": ">=18.0.0 <20.0.0", "react-compiler-runtime": "^1.0.0", @@ -73,6 +74,7 @@ }, "devDependencies": { "@ariakit/react": "0.4.19", + "@base-ui/react": "1.6.0", "@babel/cli": "7.29.7", "@babel/core": "7.29.7", "@babel/plugin-transform-runtime": "7.29.7", @@ -156,10 +158,8 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "aria-hidden": "1.2.6", "dayjs": "1.11.21", "patch-package": "8.0.1", - "react-focus-lock": "2.13.7", "react-keyed-flatten-children": "1.3.0", "react-markdown": "5.0.3", "use-callback-ref": "1.3.3" diff --git a/scripts/jestSetup.ts b/scripts/jestSetup.ts index fa0f1b745..df270c5de 100644 --- a/scripts/jestSetup.ts +++ b/scripts/jestSetup.ts @@ -14,3 +14,20 @@ if (!window.ResizeObserver) { window.ResizeObserver = ResizeObserver global.ResizeObserver = ResizeObserver } + +/** + * When focus falls back to the body, jsdom fires focusout events with the document as the + * `relatedTarget`, something browsers never do. Base UI's focus manager then calls + * `relatedTarget.hasAttribute(...)`, which does not exist on documents and throws. Give the + * document a no-op `hasAttribute` so those events are processed as not matching any element. + * + * @see https://github.com/mui/base-ui/blob/master/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx + */ +if (!('hasAttribute' in document)) { + Object.defineProperty(Document.prototype, 'hasAttribute', { + value: function hasAttribute() { + return false + }, + configurable: true, + }) +} diff --git a/src/modal/modal.module.css b/src/modal/modal.module.css index 77ab393b0..563f10249 100644 --- a/src/modal/modal.module.css +++ b/src/modal/modal.module.css @@ -30,7 +30,7 @@ z-index: var(--reactist-stacking-order-modal); } -.overlay > [data-focus-lock-disabled] { +.overlay > .wrapper { display: flex; flex-direction: column; align-items: center; @@ -40,10 +40,10 @@ padding: var(--reactist-spacing-xxlarge); } -.overlay.fitContent > [data-focus-lock-disabled] { +.overlay.fitContent > .wrapper { padding-top: var(--reactist-modal-padding-top); } -.overlay.fitContent > [data-focus-lock-disabled] .container { +.overlay.fitContent > .wrapper .container { max-height: calc(100vh - 2 * var(--reactist-modal-padding-top)); } @@ -96,7 +96,7 @@ width: 100% !important; max-height: none; } - .overlay.expand:not(.xsmall):not(.small) > [data-focus-lock-disabled] { + .overlay.expand:not(.xsmall):not(.small) > .wrapper { padding-left: 0; padding-right: 0; padding-bottom: 0; @@ -112,7 +112,7 @@ width: 100% !important; max-height: none; } - .overlay.expand > [data-focus-lock-disabled] { + .overlay.expand > .wrapper { padding-left: 0; padding-right: 0; padding-bottom: 0; diff --git a/src/modal/modal.stories.tsx b/src/modal/modal.stories.tsx index 45fab9a5e..0cd83f7a7 100644 --- a/src/modal/modal.stories.tsx +++ b/src/modal/modal.stories.tsx @@ -383,15 +383,7 @@ export function ModalAutofocus() { By default the `autoFocus` prop is `true`, which shifts the focus onto the first focusable element in the modal. You can further refine this by using the `data-autofocus` attribute if you wish to focus on elements other than the first - one. This is made possible using React Focus Lock, please see its{' '} - - documentation - {' '} - for more details. + one. diff --git a/src/modal/modal.tsx b/src/modal/modal.tsx index 2ec3cb7ba..5581665ed 100644 --- a/src/modal/modal.tsx +++ b/src/modal/modal.tsx @@ -1,9 +1,7 @@ import * as React from 'react' import { forwardRef } from 'react' -import FocusLock from 'react-focus-lock' -import { Dialog, Portal, useDialogStore } from '@ariakit/react' -import { hideOthers } from 'aria-hidden' +import { Dialog } from '@base-ui/react/dialog' import classNames from 'classnames' import { Box } from '../box' @@ -15,7 +13,6 @@ import { Inline } from '../inline' import styles from './modal.module.css' -import type { DialogOptions, PortalOptions } from '@ariakit/react' import type { IconButtonProps } from '../button' import type { DividerProps } from '../divider' import type { ObfuscatedClassName } from '../utils/common-types' @@ -100,12 +97,12 @@ export interface ModalProps extends DivProps, ObfuscatedClassName { /** * Controls if the modal is dismissed when pressing "Escape". */ - hideOnEscape?: DialogOptions['hideOnEscape'] + hideOnEscape?: boolean | ((event: KeyboardEvent) => boolean) /** * Controls if the modal is dismissed when clicking outside the modal body, on the overlay. */ - hideOnInteractOutside?: DialogOptions['hideOnInteractOutside'] + hideOnInteractOutside?: boolean | ((event: Event) => boolean) /** * An escape hatch in case you need to provide a custom class name to the overlay element. @@ -131,23 +128,19 @@ export interface ModalProps extends DivProps, ObfuscatedClassName { * * @example * const [portal, setPortal] = useState(null); - * ; + * ; *
; * * @example - * const getPortalElement = useCallback(() => { + * const getPortalElement = () => { * const div = document.createElement("div"); * const portalRoot = document.getElementById("portal-root"); * portalRoot.appendChild(div); * return div; - * }, []); - * ; + * }; + * ; */ - portalElement?: PortalOptions['portalElement'] -} - -function isNotInternalFrame(element: HTMLElement) { - return !(element.ownerDocument === document && element.tagName.toLowerCase() === 'iframe') + portalElement?: HTMLElement | (() => HTMLElement | null) | null } /** @@ -173,135 +166,130 @@ export function Modal({ children, portalElement, onKeyDown, - // @ts-expect-error we want to make sure to not pass it to the Dialog component + // @ts-expect-error we want to make sure to not pass it to the dialog element className, ...props }: ModalProps) { - const setOpen = React.useCallback( - (visible: boolean) => { - if (!visible) { - onDismiss?.() - } - }, - [onDismiss], - ) - const store = useDialogStore({ open: isOpen, setOpen }) + const contextValue: ModalContextValue = { onDismiss, height, dividers } - const contextValue: ModalContextValue = React.useMemo( - () => ({ onDismiss, height, dividers }), - [onDismiss, height, dividers], - ) - - const portalRef = React.useRef(null) const dialogRef = React.useRef(null) - const backdropRef = React.useRef(null) - const handleBackdropClick = React.useCallback( - (event: React.MouseEvent) => { - if ( - // The focus lock element takes up the same space as the backdrop and is where the event bubbles up from, - // so instead of checking the backdrop as the event target, we need to make sure it's just above the dialog - !dialogRef.current?.contains(event.target as Node) && - // Events fired from other portals will bubble up to the backdrop, even if it isn't a child in the DOM - backdropRef.current?.contains(event.target as Node) - ) { - event.stopPropagation() - onDismiss?.() + + // Resolve the portal element upfront when given as a function, so that the dialog is only + // rendered once its final portal element is known + const [resolvedPortalElement, setResolvedPortalElement] = React.useState( + null, + ) + React.useLayoutEffect( + function resolvePortalElement() { + if (typeof portalElement === 'function') { + // eslint-disable-next-line react-hooks/set-state-in-effect + setResolvedPortalElement(portalElement()) } }, - [onDismiss], + [portalElement], ) - React.useLayoutEffect( - function disableAccessibilityTreeOutside() { - if (!isOpen || !portalRef.current) { + function handleOpenChange(open: boolean, eventDetails: Dialog.Root.ChangeEventDetails) { + if (open) { + return + } + + if (eventDetails.reason === 'outside-press') { + const shouldHide = + typeof hideOnInteractOutside === 'function' + ? hideOnInteractOutside(eventDetails.event) + : hideOnInteractOutside + if (shouldHide && onDismiss != null) { + onDismiss() return } + } - return hideOthers(portalRef.current) - }, - [isOpen], - ) + // Reject any other close request. Escape is handled by our own keydown handler below, so a + // nested widget that handles Escape (and stops React propagation) does not also close the + // modal, and so the key does not propagate beyond the modal when it triggers the dismissal + eventDetails.cancel() + } - const handleKeyDown = React.useCallback( - function handleKeyDown(event: React.KeyboardEvent) { - if ( - hideOnEscape && - onDismiss != null && - event.key === 'Escape' && - !event.defaultPrevented - ) { + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Escape' && onDismiss != null && !event.defaultPrevented) { + const shouldHide = + typeof hideOnEscape === 'function' ? hideOnEscape(event.nativeEvent) : hideOnEscape + if (shouldHide) { event.stopPropagation() onDismiss() } - onKeyDown?.(event) - }, - [onDismiss, hideOnEscape, onKeyDown], - ) + } + onKeyDown?.(event) + } + + function handleOverlayMouseDown(event: React.MouseEvent) { + // Keep focus inside the modal when pressing on the overlay, so keyboard handling (such as + // Escape to dismiss) keeps working even when the press does not close the modal + if (!dialogRef.current?.contains(event.target as Node)) { + event.preventDefault() + } + } - if (!isOpen) { + /** + * Focus the element marked with the `data-autofocus` attribute if present, or fall back to + * the default behaviour of focusing the first focusable element inside the modal. + */ + function getInitialFocus() { + const autofocusElement = dialogRef.current?.querySelector('[data-autofocus]') + return autofocusElement instanceof HTMLElement ? autofocusElement : true + } + + if (typeof portalElement === 'function' && resolvedPortalElement == null) { return null } return ( - - + - - - } - className={classNames(exceptionallySetClassName, styles.container)} - store={store} - preventBodyScroll - // Disable focus lock as we set up our own using ReactFocusLock - modal={false} - autoFocus={false} - autoFocusOnShow={false} - autoFocusOnHide={false} - // Disable portal and backdrop as we control their markup - portal={false} - backdrop={false} - hideOnInteractOutside={false} - hideOnEscape={false} - onKeyDown={handleKeyDown} - > - - {children} - - - - - +
+ + } + className={classNames(exceptionallySetClassName, styles.container)} + > + + {children} + + +
+ + + ) }