UNPKG

@atlaskit/modal-dialog

Version:

A modal dialog displays content that requires user interaction, in a layer above the page.

394 lines (368 loc) 20 kB
/* modal-wrapper.tsx generated by @compiled/babel-plugin v0.39.1 */ import _extends from "@babel/runtime/helpers/extends"; import _typeof from "@babel/runtime/helpers/typeof"; import "./modal-wrapper.compiled.css"; import * as React from 'react'; import { ax, ix } from "@compiled/react/runtime"; import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'; import FocusLock from 'react-focus-lock'; import ScrollLock, { TouchScrollable } from 'react-scrolllock'; import { usePlatformLeafEventHandler } from '@atlaskit/analytics-next'; import Blanket from '@atlaskit/blanket'; import noop from '@atlaskit/ds-lib/noop'; import useAutoFocus from '@atlaskit/ds-lib/use-auto-focus'; import { useId } from '@atlaskit/ds-lib/use-id'; import { Layering } from '@atlaskit/layering'; import { useNotifyOpenLayerObserver } from '@atlaskit/layering/experimental/open-layer-observer'; import { Motion } from '@atlaskit/motion'; import { useExitingPersistence } from '@atlaskit/motion/exiting-persistence'; import FadeIn from '@atlaskit/motion/fade-in'; import { fg } from '@atlaskit/platform-feature-flags'; import Portal from '@atlaskit/portal'; import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { layers } from '@atlaskit/theme/constants'; import { dialogSlideUpAndFade } from '@atlaskit/top-layer/animations'; import { createCloseEvent } from '@atlaskit/top-layer/create-close-event'; import { Dialog } from '@atlaskit/top-layer/dialog'; import { DialogScrollLock } from '@atlaskit/top-layer/dialog-scroll-lock'; import { ModalContext, ScrollContext } from '../context'; import useModalStack from '../hooks/use-modal-stack'; import usePreventProgrammaticScroll from '../hooks/use-prevent-programmatic-scroll'; import { disableDraggingToCrossOriginIFramesForElement } from '../pragmatic-drag-and-drop/disable-dragging-to-cross-origin-iframes/element'; import { disableDraggingToCrossOriginIFramesForExternal } from '../pragmatic-drag-and-drop/disable-dragging-to-cross-origin-iframes/external'; import { disableDraggingToCrossOriginIFramesForTextSelection } from '../pragmatic-drag-and-drop/disable-dragging-to-cross-origin-iframes/text-selection'; import ModalDialog, { dialogHeight, dialogWidth as getDialogWidth } from './modal-dialog'; var modalAnimation = dialogSlideUpAndFade(); var fillScreenStyles = null; // Visual styles for modal content inside native <dialog>. // Uses cssMap (not css) to avoid triggering no-nested-styles lint rule. var LOCAL_CURRENT_SURFACE_CSS_VAR = '--ds-elevation-surface-current'; var topLayerStyles = { content: "_1e0c1txw _4t3i1osq _2lx21bp4 _bfhk1bhr _syazi7uo _1q1l1bhr _lcxv1wug _1mq81kw7 _m01u1kw7 _1dg11kw7 _mizu1v1w _1ah3dkaa _ra3xnqa1 _128mdkaa _zg7p130s", borderRadius: "_epkxfajl", borderRadiusT26: "_epkxpb1k" }; // Scroll-mode styles for the content div. // Height overrides use ID-scoped <style> (see dialogPositionStyles) because // Compiled atomic classes have specificity (0,1,0) (increaseSpecificity is disabled). // The doubled-ID selector (#id#id > div) at (2,0,1) reliably wins. // Only non-height properties needing the && boost remain here. var topLayerBodyScrollStyles = null; var topLayerViewportScrollStyles = null; var allowlistElements = function allowlistElements(element, callback) { // Allow focus outside modal when AUI dialog is visible // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage -- legacy FocusLock allowlist if (Boolean(document.querySelector('.aui-blanket:not([hidden])'))) { return false; } // Optional callback to let consumers exclude elements from focus lock if (typeof callback === 'function') { return callback(element); } return true; }; var InternalModalWrapper = /*#__PURE__*/forwardRef(function (props, ref) { var autoFocus = props.autoFocus, focusLockAllowlist = props.focusLockAllowlist, _props$shouldCloseOnE = props.shouldCloseOnEscapePress, shouldCloseOnEscapePress = _props$shouldCloseOnE === void 0 ? true : _props$shouldCloseOnE, _props$shouldCloseOnO = props.shouldCloseOnOverlayClick, shouldCloseOnOverlayClick = _props$shouldCloseOnO === void 0 ? true : _props$shouldCloseOnO, _props$shouldScrollIn = props.shouldScrollInViewport, shouldScrollInViewport = _props$shouldScrollIn === void 0 ? false : _props$shouldScrollIn, _props$shouldReturnFo = props.shouldReturnFocus, shouldReturnFocus = _props$shouldReturnFo === void 0 ? true : _props$shouldReturnFo, stackIndexOverride = props.stackIndex, providedOnClose = props.onClose, _props$onStackChange = props.onStackChange, onStackChange = _props$onStackChange === void 0 ? noop : _props$onStackChange, isBlanketHidden = props.isBlanketHidden, children = props.children, height = props.height, width = props.width, onCloseComplete = props.onCloseComplete, onOpenComplete = props.onOpenComplete, label = props.label, testId = props.testId, isFullScreen = props.isFullScreen, _props$UNSAFE_shouldD = props.UNSAFE_shouldDisableMotionUplift, UNSAFE_shouldDisableMotionUplift = _props$UNSAFE_shouldD === void 0 ? false : _props$UNSAFE_shouldD; var calculatedStackIndex = useModalStack({ onStackChange: onStackChange }); var stackIndex = stackIndexOverride || calculatedStackIndex; var isForeground = stackIndex === 0; // If no ref is provided, autofocus on first element var autoFocusLock = !(_typeof(autoFocus) === 'object'); var onCloseHandler = usePlatformLeafEventHandler({ fn: providedOnClose || noop, action: 'closed', componentName: 'modalDialog', packageName: "@atlaskit/modal-dialog", packageVersion: "15.1.3" }); var onBlanketClicked = useCallback(function (e) { if (shouldCloseOnOverlayClick) { onCloseHandler(e); } }, [shouldCloseOnOverlayClick, onCloseHandler]); // Stable callback to avoid re-renders when focusLockAllowlist is not provided. var allowListCallback = useCallback(function (element) { return allowlistElements(element, focusLockAllowlist); }, [focusLockAllowlist]); // Called outside the feature-flag branch to keep hook order stable. // Legacy path: FadeIn calls onFinish. Top-layer path: called directly. var _useExitingPersistenc = useExitingPersistence(), isExiting = _useExitingPersistenc.isExiting, onExitFinish = _useExitingPersistenc.onFinish; // Prevent background scroll (top-layer path uses DialogScrollLock instead). // Safe conditional hook: feature flags are resolved once at startup. if (!fg('platform-dst-top-layer')) { // eslint-disable-next-line react-hooks/rules-of-hooks usePreventProgrammaticScroll(); } // On the top-layer path, the Dialog primitive registers with the observer // directly, so we skip registration here to avoid double-counting. // Safe conditional hook: feature flags are resolved once at startup. if (!fg('platform-dst-top-layer')) { // eslint-disable-next-line react-hooks/rules-of-hooks useNotifyOpenLayerObserver({ type: 'modal', // Always open — modal is conditionally rendered when visible. isOpen: true, // No-op: no current use case for programmatic close via OpenLayerObserver. onClose: noop }); } /** * Top-layer path (platform-dst-top-layer). * * Replaces Portal, FocusLock, ScrollLock, Blanket, Positioner, and z-index * management with native <dialog> via @atlaskit/top-layer/dialog. * * Key decisions: * - Animation: CSS transitions via @starting-style / allow-discrete. * - Close gating: onDialogClose only forwards allowed reasons * (see notes/guides/dialog-close-flow.md). * - onClose event param: undefined - consumers should use close reason. * - Focus restoration: native <dialog> behavior replaces react-focus-lock's * returnFocus (see accessibility-criteria.md). */ if (fg('platform-dst-top-layer')) { // Native <dialog> always restores focus on close - no opt-out via shouldReturnFocus. var defaultTestId = testId || 'modal-dialog'; // eslint-disable-next-line react-hooks/rules-of-hooks var id = useId(); var titleId = "modal-dialog-title-".concat(id); // Content container ref - used for onOpenComplete/onCloseComplete callbacks. // eslint-disable-next-line react-hooks/rules-of-hooks var contentRef = useRef(null); // Cache last content element for onCloseComplete after children unmount // (with reduced motion, contentRef clears before onExitFinish fires). // eslint-disable-next-line react-hooks/rules-of-hooks var lastContentElRef = useRef(null); if (contentRef.current) { lastContentElRef.current = contentRef.current; } // Native <dialog> ref - needed for ExitingPersistence to call dialog.close(). // eslint-disable-next-line react-hooks/rules-of-hooks var dialogRef = useRef(null); // eslint-disable-next-line react-hooks/rules-of-hooks var modalDialogContext = useMemo(function () { return { testId: defaultTestId, titleId: titleId, onClose: onCloseHandler, hasProvidedOnClose: Boolean(providedOnClose), isFullScreen: isFullScreen !== null && isFullScreen !== void 0 ? isFullScreen : false }; }, [defaultTestId, titleId, onCloseHandler, providedOnClose, isFullScreen]); // Only forward close when the reason is allowed by props. // Passes a synthetic event to satisfy the KeyboardOrMouseEvent contract. // eslint-disable-next-line react-hooks/rules-of-hooks var onDialogClose = useCallback(function (_ref) { var reason = _ref.reason; if (reason === 'escape' && shouldCloseOnEscapePress) { onCloseHandler(createCloseEvent({ reason: reason })); } if (reason === 'overlay-click' && shouldCloseOnOverlayClick) { onCloseHandler(createCloseEvent({ reason: reason })); } }, [onCloseHandler, shouldCloseOnEscapePress, shouldCloseOnOverlayClick]); // ExitingPersistence: isExiting → isOpen={false} → Dialog exit animation → // onExitFinish → onCloseComplete + unmount. // eslint-disable-next-line react-hooks/rules-of-hooks var handleDialogExitFinish = useCallback(function () { var _contentRef$current; var el = (_contentRef$current = contentRef.current) !== null && _contentRef$current !== void 0 ? _contentRef$current : lastContentElRef.current; if (onCloseComplete && el) { onCloseComplete(el); } lastContentElRef.current = null; onExitFinish === null || onExitFinish === void 0 || onExitFinish(); }, [onExitFinish, onCloseComplete]); // Fire onOpenComplete after mount. // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(function () { if (onOpenComplete && contentRef.current) { onOpenComplete(contentRef.current, true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Honor `shouldReturnFocus={ref}` on unmount. // Native <dialog>.close() restores focus to the trigger that opened it, // but the consumer asked for focus to go to a specific element instead. // Run this in an unmount cleanup so it fires after dialog.close() // (which fires in the Dialog's effect cleanup). // eslint-disable-next-line react-hooks/rules-of-hooks var shouldReturnFocusRef = useRef(shouldReturnFocus); shouldReturnFocusRef.current = shouldReturnFocus; // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(function () { return function () { var target = shouldReturnFocusRef.current; if (_typeof(target) === 'object' && target.current) { target.current.focus(); } }; }, []); // Focus a ref-targeted element after mount (when autoFocus is a ref). // When true, native <dialog>.showModal() handles focus automatically. // eslint-disable-next-line react-hooks/rules-of-hooks useAutoFocus(_typeof(autoFocus) === 'object' ? autoFocus : undefined, _typeof(autoFocus) === 'object'); // Chrome cross-origin iframe DnD workaround (crbug.com/362301053) // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(function () { return combine(disableDraggingToCrossOriginIFramesForElement(), disableDraggingToCrossOriginIFramesForTextSelection(), disableDraggingToCrossOriginIFramesForExternal()); }, []); // Responsive layout via ID-scoped <style> (same pattern as Dialog's hideBackdrop). // ID selector beats Compiled atomic classes without !important and supports @media. var namedWidth = getDialogWidth(width !== null && width !== void 0 ? width : 'medium'); var dialogId = "modal-dialog-".concat(id); var escapedDialogId = CSS.escape(dialogId); // Percentage widths need special handling in the top layer. // In legacy, the percentage resolved against the Positioner's max-width // (100vw - 120px). In the top layer, the <dialog>'s containing block is the // viewport (100vw), so a raw percentage would produce a wider modal. // Transform e.g. '42%' → 'calc(42 * (100vw - 120px) / 100)' to match legacy. var resolvedWidth = namedWidth.endsWith('%') ? "calc(".concat(parseFloat(namedWidth), " * (100vw - 120px) / 100)") : namedWidth; var dialogStyle = isFullScreen ? { width: '100vw', height: '100vh', margin: '0' } : { width: "min(".concat(resolvedWidth, ", 100vw)") }; // Shift stacked background modals down by space.100 (8px) per level. if (stackIndex > 0) { dialogStyle['transform'] = "translateY(calc(".concat(stackIndex, "px * ", "var(--ds-space-100, 8px)", "))"); } // Mobile: viewport fill. Desktop (≥ 30rem): gutter margins, auto height. // Content-div height set via #id > div to beat Compiled's atomic specificity. var desktopMargin = shouldScrollInViewport ? '60px auto' : '60px auto auto'; var resolvedHeight = dialogHeight(height); // Body-scroll: specified height or auto. Viewport-scroll: uses min-height. var desktopContentHeight = shouldScrollInViewport ? 'auto' : resolvedHeight; var desktopContentMinHeight = shouldScrollInViewport ? resolvedHeight : 'auto'; // Viewport-scroll: the legacy Positioner was a fixed 100vh container that // scrolled internally, so the modal section could fill (100vh - 60px top gutter). // In the top layer the <dialog> sizes to content with height:auto, so we need // an explicit min-height to ensure the dialog stretches to the same visible area. var desktopDialogMinHeight = shouldScrollInViewport ? 'min-height:calc(100vh - 60px);' : ''; // Doubled-ID selector (#id#id > div) at specificity (2,0,1) beats // Compiled atomic classes at (0,1,0) (increaseSpecificity is disabled). var dialogPositionStyles = isFullScreen ? '' : // Mobile: edge-to-edge. Desktop (≥ 30rem): 60px gutters, max-width. "#".concat(escapedDialogId, "#").concat(escapedDialogId, "{margin:0;height:100vh}#").concat(escapedDialogId, "#").concat(escapedDialogId, ">div{height:100%}@media(min-width:30rem){#").concat(escapedDialogId, "#").concat(escapedDialogId, "{margin:").concat(desktopMargin, ";height:auto;").concat(desktopDialogMinHeight, "max-width:calc(100vw - 120px)}#").concat(escapedDialogId, "#").concat(escapedDialogId, ">div{height:").concat(desktopContentHeight, ";min-height:").concat(desktopContentMinHeight, "}}"); return /*#__PURE__*/React.createElement(Dialog, { ref: dialogRef, id: dialogId, onClose: onDialogClose, onExitFinish: handleDialogExitFinish, animate: isFullScreen ? false : modalAnimation, isOpen: !isExiting, shouldHideBackdrop: stackIndex > 0 || Boolean(isBlanketHidden), label: label, labelledBy: label ? undefined : titleId, testId: defaultTestId // eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop , style: dialogStyle }, /*#__PURE__*/React.createElement(DialogScrollLock, null), dialogPositionStyles && /*#__PURE__*/ // eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles React.createElement("style", null, dialogPositionStyles), /*#__PURE__*/React.createElement("div", { ref: contentRef, className: ax([topLayerStyles.content, !isFullScreen && topLayerStyles.borderRadius, !isFullScreen && fg('platform-dst-shape-theme-default') && topLayerStyles.borderRadiusT26, !isFullScreen && !shouldScrollInViewport && "_bolhzwhf", !isFullScreen && shouldScrollInViewport && "_1tke1kxc _c71lglyw"]) }, /*#__PURE__*/React.createElement(ModalContext.Provider, { value: modalDialogContext }, /*#__PURE__*/React.createElement(ScrollContext.Provider, { value: shouldScrollInViewport }, children)))); } var modalDialogWithBlanket = /*#__PURE__*/React.createElement(Blanket, { isTinted: !isBlanketHidden, onBlanketClicked: onBlanketClicked, testId: testId && "".concat(testId, "--blanket") }, /*#__PURE__*/React.createElement(ModalDialog, { testId: testId, label: label, autoFocus: autoFocus, stackIndex: stackIndex, onClose: onCloseHandler, shouldCloseOnEscapePress: shouldCloseOnEscapePress && isForeground, shouldScrollInViewport: shouldScrollInViewport, height: height, width: width, onCloseComplete: onCloseComplete, onOpenComplete: onOpenComplete, hasProvidedOnClose: Boolean(providedOnClose), isFullScreen: isFullScreen, UNSAFE_shouldDisableMotionUplift: UNSAFE_shouldDisableMotionUplift, ref: ref }, children)); var returnFocus = true; var onDeactivation = noop; if ('boolean' === typeof shouldReturnFocus) { returnFocus = shouldReturnFocus; } else { onDeactivation = function onDeactivation() { window.setTimeout(function () { var _shouldReturnFocus$cu; (_shouldReturnFocus$cu = shouldReturnFocus.current) === null || _shouldReturnFocus$cu === void 0 || _shouldReturnFocus$cu.focus(); }, 0); }; } return /*#__PURE__*/React.createElement(Layering, { isDisabled: false }, /*#__PURE__*/React.createElement(Portal, { zIndex: layers.modal() }, !UNSAFE_shouldDisableMotionUplift && fg('platform-dst-motion-uplift-modal') ? /*#__PURE__*/React.createElement(Motion, { enteringAnimation: "var(--ds-blanket-enter, 250ms cubic-bezier(0.4, 0, 0, 1) FadeIn0to100)", exitingAnimation: "var(--ds-blanket-exit, 200ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)" }, /*#__PURE__*/React.createElement("div", { "aria-hidden": !isForeground, className: ax(["_1bsbauwl _4t3i1kxc _kqsw1n9t _152tze3t _1e02ze3t _18m91wug _8am5i4x0"]) }, /*#__PURE__*/React.createElement(FocusLock, { autoFocus: autoFocusLock, returnFocus: returnFocus, onDeactivation: onDeactivation, whiteList: allowListCallback }, /*#__PURE__*/React.createElement(ScrollLock, null), shouldScrollInViewport ? /*#__PURE__*/React.createElement(TouchScrollable, null, modalDialogWithBlanket) : modalDialogWithBlanket))) : /*#__PURE__*/React.createElement(FadeIn, null, function (fadeInProps) { return /*#__PURE__*/React.createElement("div", _extends({}, fadeInProps, { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop className: ax(["_1bsbauwl _4t3i1kxc _kqsw1n9t _152tze3t _1e02ze3t _18m91wug _8am5i4x0", fadeInProps.className]), "aria-hidden": !isForeground }), /*#__PURE__*/React.createElement(FocusLock, { autoFocus: autoFocusLock, returnFocus: returnFocus, onDeactivation: onDeactivation, whiteList: allowListCallback }, /*#__PURE__*/React.createElement(ScrollLock, null), shouldScrollInViewport ? /*#__PURE__*/React.createElement(TouchScrollable, null, modalDialogWithBlanket) : modalDialogWithBlanket)); }))); }); // eslint-disable-next-line @repo/internal/react/require-jsdoc export default InternalModalWrapper;