@atlaskit/modal-dialog
Version:
A modal dialog displays content that requires user interaction, in a layer above the page.
491 lines (468 loc) • 21.9 kB
JavaScript
/* modal-wrapper.tsx generated by @compiled/babel-plugin v3.0.2 */
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 { bind } from 'bind-event-listener';
import FocusLock from 'react-focus-lock';
import ScrollLock, { TouchScrollable } from 'react-scrolllock';
import { usePlatformLeafEventHandler } from '@atlaskit/analytics-next/usePlatformLeafEventHandler';
import Blanket from '@atlaskit/blanket/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/layering';
import { useNotifyOpenLayerObserver } from '@atlaskit/layering/use-notify-open-layer-observer';
import Motion from '@atlaskit/motion/entering/motion';
import { useExitingPersistence } from '@atlaskit/motion/exiting-persistence/use-exiting-persistence';
import FadeIn from '@atlaskit/motion/fade-in';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import Portal from '@atlaskit/portal/portal';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/utils/combine';
import { layers } from '@atlaskit/theme/constants';
import { createCloseEvent } from '@atlaskit/top-layer/create-close-event';
import { Dialog } from '@atlaskit/top-layer/dialog-content';
import { DialogScrollLock } from '@atlaskit/top-layer/dialog-scroll-lock';
import { ModalContext } 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 { ScrollContext } from '../scroll-context';
import { width } from '../width';
import { default as ModalDialog } from './modal-dialog';
var fillScreenStyles = null;
/**
* Styles applied to the <dialog> wrapper, NOT the visual modal surface.
*/
var dialogStyles = {
'body-scroll': "_1reoewfl _18m9ewfl _19pkidpf _2hwxidpf _otyridpf _18u0idpf _1sg41wqb _maxs1wug _1jvh1wqb _1uca1wug",
'viewport-scroll': "_ln19p27b _1bsbauwl _4t3i1kxc",
'full-screen': ""
};
// 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';
/**
* Styles applied to the visual modal surface.
*/
var surfaceStyles = {
root: "_1e0c1txw _2lx21bp4 _bfhk1bhr _syazi7uo _1q1l1bhr _lcxv1wug _1mq81kw7 _m01u1kw7 _1dg11kw7 _mizu1v1w _1ah3dkaa _ra3xnqa1 _128mdkaa _zg7p130s",
borderRadius: "_2rkoidpf _epkxpb1k"
};
var topLayerScrollModeStyles = {
'full-screen': "_1bsbauwl _4t3i1kxc _kqsw1n9t _152tze3t _1e02ze3t _18m91wug _8am5i4x0",
'viewport-scroll': "_4t3i1kxc _1sg41wqb _maxs1wug _1jvh1wqb _1uca1wug _1mpv184x _1jyk17ks _g7116m9r _we1i18uh",
'body-scroll': "_1bsbauwl _4t3i1kxc _1jyk17ks _g7116m9r _we1i18uh"
};
var topLayerAutoHeightStyles = 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 (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;
};
// Analytics-wrapped close handler. Extracted so both rendering paths build it
// identically.
function useModalCloseHandler(providedOnClose) {
return usePlatformLeafEventHandler({
fn: providedOnClose || noop,
action: 'closed',
componentName: 'modalDialog',
packageName: "@atlaskit/modal-dialog",
packageVersion: "16.6.4"
});
}
function getScrollMode(_ref) {
var shouldScrollInViewport = _ref.shouldScrollInViewport,
isFullScreen = _ref.isFullScreen;
if (isFullScreen) {
return 'full-screen';
}
if (shouldScrollInViewport) {
return 'viewport-scroll';
}
return 'body-scroll';
}
function isWidthName(value) {
return width.values.includes(value);
}
function getTopLayerSurfaceWidth(input) {
if (!input) {
return 'auto';
}
if (typeof input === 'number') {
return "".concat(input, "px");
}
if (isWidthName(input)) {
return "".concat(width.widths[input], "px");
}
if (input.endsWith('%')) {
// 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.
return "calc(".concat(parseFloat(input), " * (100vw - 120px) / 100)");
}
return input;
}
function getTopLayerSurfaceHeight(input) {
if (!input) {
// Although this value would ordinarily fill the viewport height,
// there is also a `max-height: max-content` applied to keep it constrained.
// But we need to provide the modal with a fixed height (non-keyword, non-percentage)
// in order for child elements with 100% height to work correctly.
return 'calc(100vh - 120px)';
}
if (typeof input === 'number') {
return "".concat(input, "px");
}
if (input.endsWith('%')) {
// Percentage heights need special handling in the top layer.
// In legacy, the percentage resolved against the Positioner's max-height
// (100vh - 120px). In the top layer, the <dialog>'s containing block is the
// viewport (100vh), so a raw percentage would produce a taller modal.
// Transform e.g. '42%' → 'calc(42 * (100vh - 120px) / 100)' to match legacy.
return "calc(".concat(parseFloat(input), " * (100vh - 120px) / 100)");
}
return input;
}
function getDialogDismissedBy(_ref2) {
var shouldCloseOnEscapePress = _ref2.shouldCloseOnEscapePress,
shouldCloseOnOverlayClick = _ref2.shouldCloseOnOverlayClick;
if (shouldCloseOnEscapePress && shouldCloseOnOverlayClick) {
return 'escape-and-outside-click';
}
if (shouldCloseOnEscapePress) {
return 'escape';
}
return 'none';
}
/**
* Top-layer rendering 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: Dialog only forwards allowed reasons.
* - onClose event param: undefined - consumers should use close reason.
* - Focus restoration: native <dialog> behavior replaces react-focus-lock's
* returnFocus (see accessibility-criteria.md).
*
* Every hook here runs unconditionally: this component only mounts on the
* top-layer path, so it never shares a hook sequence with the legacy path.
*/
function ModalWrapperTopLayer(props) {
var autoFocus = props.autoFocus,
_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,
providedOnClose = props.onClose,
_props$onStackChange = props.onStackChange,
onStackChange = _props$onStackChange === void 0 ? noop : _props$onStackChange,
isBlanketHidden = props.isBlanketHidden,
children = props.children,
height = props.height,
_props$width = props.width,
width = _props$width === void 0 ? 'medium' : _props$width,
onCloseComplete = props.onCloseComplete,
onOpenComplete = props.onOpenComplete,
label = props.label,
testId = props.testId,
_props$isFullScreen = props.isFullScreen,
isFullScreen = _props$isFullScreen === void 0 ? false : _props$isFullScreen;
useModalStack({
onStackChange: onStackChange
});
var onCloseHandler = useModalCloseHandler(providedOnClose);
var _useExitingPersistenc = useExitingPersistence(),
isExiting = _useExitingPersistenc.isExiting,
onExitFinish = _useExitingPersistenc.onFinish;
// Native <dialog> always restores focus on close - no opt-out via shouldReturnFocus.
var defaultTestId = testId || 'modal-dialog';
var id = useId();
var titleId = "modal-dialog-title-".concat(id);
// Content container ref - used for onOpenComplete/onCloseComplete callbacks.
var contentRef = useRef(null);
// Cache last content element for onCloseComplete after children unmount
// (with reduced motion, contentRef clears before onExitFinish fires).
var lastContentElRef = useRef(null);
if (contentRef.current) {
lastContentElRef.current = contentRef.current;
}
// Native <dialog> ref - needed for ExitingPersistence to call dialog.close().
var dialogRef = useRef(null);
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]);
// Dialog has already applied the close behavior configured by `dismissedBy`.
// Pass a synthetic event to satisfy the KeyboardOrMouseEvent contract.
var onDialogClose = useCallback(function (_ref3) {
var reason = _ref3.reason;
onCloseHandler(createCloseEvent({
reason: reason
}));
}, [onCloseHandler]);
var dismissedBy = getDialogDismissedBy({
shouldCloseOnEscapePress: shouldCloseOnEscapePress,
shouldCloseOnOverlayClick: shouldCloseOnOverlayClick
});
var shouldShimCloseOnOverlayClick = shouldCloseOnOverlayClick && !shouldCloseOnEscapePress;
// Required until we fully remove `shouldCloseOnEscapePress`.
// Dialog's `dismissedBy` options do not support outside click without Escape (because it is a bad pattern)
useEffect(function () {
var dialog = dialogRef.current;
if (!dialog || !shouldShimCloseOnOverlayClick) {
return;
}
return bind(dialog, {
type: 'click',
listener: function listener(event) {
if (event.target === event.currentTarget) {
onDialogClose({
reason: 'overlay-click'
});
}
}
});
}, [onDialogClose, shouldShimCloseOnOverlayClick]);
// ExitingPersistence: isExiting → isOpen={false} → Dialog exit animation →
// onExitFinish → onCloseComplete + unmount.
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]);
var handleDialogEnterFinish = useCallback(function () {
if (onOpenComplete && contentRef.current) {
onOpenComplete(contentRef.current, true);
}
}, [onOpenComplete]);
// 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).
var shouldReturnFocusRef = useRef(shouldReturnFocus);
shouldReturnFocusRef.current = shouldReturnFocus;
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.
useAutoFocus(_typeof(autoFocus) === 'object' ? autoFocus : undefined, _typeof(autoFocus) === 'object');
// Chrome cross-origin iframe DnD workaround (crbug.com/362301053)
useEffect(function () {
return combine(disableDraggingToCrossOriginIFramesForElement(), disableDraggingToCrossOriginIFramesForTextSelection(), disableDraggingToCrossOriginIFramesForExternal());
}, []);
var scrollMode = getScrollMode({
isFullScreen: isFullScreen,
shouldScrollInViewport: shouldScrollInViewport
});
return /*#__PURE__*/React.createElement(Dialog, _extends({
ref: dialogRef,
onClose: onDialogClose,
dismissedBy: dismissedBy,
onExitFinish: handleDialogExitFinish,
shouldAnimate: !isFullScreen,
isOpen: !isExiting,
onEnterFinish: handleDialogEnterFinish,
shouldHideBackdrop: isBlanketHidden
// Dialog requires at least one of `label` or `labelledBy` (string, not undefined).
// Prefer the consumer-provided `label`; otherwise reference the internal `titleId`.
}, label ? {
label: label
} : {
labelledBy: titleId
}, {
testId: defaultTestId,
xcss: dialogStyles[scrollMode]
}), /*#__PURE__*/React.createElement(DialogScrollLock, {
isOpen: true
}), /*#__PURE__*/React.createElement("div", {
ref: contentRef,
style: {
'--modal-dialog-height': getTopLayerSurfaceHeight(height),
'--modal-dialog-width': getTopLayerSurfaceWidth(width)
},
className: ax([surfaceStyles.root, !isFullScreen && surfaceStyles.borderRadius, topLayerScrollModeStyles[scrollMode], !height && !isFullScreen && "_e8uo1ris"])
}, /*#__PURE__*/React.createElement(ModalContext.Provider, {
value: modalDialogContext
}, /*#__PURE__*/React.createElement(ScrollContext.Provider, {
value: shouldScrollInViewport
}, children))));
}
/**
* Legacy rendering path (Portal + FocusLock + ScrollLock + Blanket).
*
* Every hook here runs unconditionally: this component only mounts on the
* legacy path, so it never shares a hook sequence with the top-layer path.
*/
var ModalWrapperLegacy = /*#__PURE__*/forwardRef(function (props, ref) {
var autoFocus = props.autoFocus,
focusLockAllowlist = props.focusLockAllowlist,
_props$shouldCloseOnE2 = props.shouldCloseOnEscapePress,
shouldCloseOnEscapePress = _props$shouldCloseOnE2 === void 0 ? true : _props$shouldCloseOnE2,
_props$shouldCloseOnO2 = props.shouldCloseOnOverlayClick,
shouldCloseOnOverlayClick = _props$shouldCloseOnO2 === void 0 ? true : _props$shouldCloseOnO2,
_props$shouldScrollIn2 = props.shouldScrollInViewport,
shouldScrollInViewport = _props$shouldScrollIn2 === void 0 ? false : _props$shouldScrollIn2,
_props$shouldReturnFo2 = props.shouldReturnFocus,
shouldReturnFocus = _props$shouldReturnFo2 === void 0 ? true : _props$shouldReturnFo2,
stackIndexOverride = props.stackIndex,
providedOnClose = props.onClose,
_props$onStackChange2 = props.onStackChange,
onStackChange = _props$onStackChange2 === void 0 ? noop : _props$onStackChange2,
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 = useModalCloseHandler(providedOnClose);
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]);
// Prevent background scroll (top-layer path uses DialogScrollLock instead).
usePreventProgrammaticScroll();
// Register with the open layer observer. On the top-layer path the Dialog
// primitive does this itself, which is why it lives in this legacy-only
// component rather than the shared dispatcher.
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
});
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));
})));
});
// Choose the rendering implementation at the component boundary rather than
// gating hooks inside a single component. Each implementation owns its own
// hooks unconditionally, so a runtime feature-flag change swaps component types
// (a clean remount) instead of changing the hook order of a mounted component.
var InternalModalWrapper = /*#__PURE__*/forwardRef(function (props, ref) {
if (fg('platform-dst-top-layer')) {
// TODO: the top-layer path does not forward the external `ref` (parity
// with the pre-refactor behavior, where the top-layer branch never
// consumed it). The public type still advertises `RefAttributes`, so a
// consumer ref silently no-ops here. Follow up by forwarding `ref` to a
// sensible element (for example the content `div`) once ref parity is
// intentionally desired.
return /*#__PURE__*/React.createElement(ModalWrapperTopLayer, props);
}
return /*#__PURE__*/React.createElement(ModalWrapperLegacy, _extends({
ref: ref
}, props));
});
// eslint-disable-next-line @repo/internal/react/require-jsdoc
export default InternalModalWrapper;