@atlaskit/modal-dialog 14.18.3 → 15.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,30 +3,62 @@ import _extends from "@babel/runtime/helpers/extends";
3
3
  import "./modal-wrapper.compiled.css";
4
4
  import * as React from 'react';
5
5
  import { ax, ix } from "@compiled/react/runtime";
6
- import { useCallback } from 'react';
6
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
7
7
  import FocusLock from 'react-focus-lock';
8
8
  import ScrollLock, { TouchScrollable } from 'react-scrolllock';
9
9
  import { usePlatformLeafEventHandler } from '@atlaskit/analytics-next';
10
10
  import Blanket from '@atlaskit/blanket';
11
11
  import noop from '@atlaskit/ds-lib/noop';
12
+ import useAutoFocus from '@atlaskit/ds-lib/use-auto-focus';
13
+ import { useId } from '@atlaskit/ds-lib/use-id';
12
14
  import { Layering } from '@atlaskit/layering';
13
15
  import { useNotifyOpenLayerObserver } from '@atlaskit/layering/experimental/open-layer-observer';
14
16
  import { Motion } from '@atlaskit/motion';
17
+ import { useExitingPersistence } from '@atlaskit/motion/exiting-persistence';
15
18
  import FadeIn from '@atlaskit/motion/fade-in';
16
19
  import { fg } from '@atlaskit/platform-feature-flags';
17
20
  import Portal from '@atlaskit/portal';
21
+ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
18
22
  import { layers } from '@atlaskit/theme/constants';
23
+ import { dialogSlideUpAndFade } from '@atlaskit/top-layer/animations';
24
+ import { createCloseEvent } from '@atlaskit/top-layer/create-close-event';
25
+ import { Dialog } from '@atlaskit/top-layer/dialog';
26
+ import { DialogScrollLock } from '@atlaskit/top-layer/dialog-scroll-lock';
27
+ import { ModalContext, ScrollContext } from '../context';
19
28
  import useModalStack from '../hooks/use-modal-stack';
20
29
  import usePreventProgrammaticScroll from '../hooks/use-prevent-programmatic-scroll';
21
- import ModalDialog from './modal-dialog';
30
+ import { disableDraggingToCrossOriginIFramesForElement } from '../pragmatic-drag-and-drop/disable-dragging-to-cross-origin-iframes/element';
31
+ import { disableDraggingToCrossOriginIFramesForExternal } from '../pragmatic-drag-and-drop/disable-dragging-to-cross-origin-iframes/external';
32
+ import { disableDraggingToCrossOriginIFramesForTextSelection } from '../pragmatic-drag-and-drop/disable-dragging-to-cross-origin-iframes/text-selection';
33
+ import ModalDialog, { dialogHeight, dialogWidth as getDialogWidth } from './modal-dialog';
34
+ const modalAnimation = dialogSlideUpAndFade();
22
35
  const fillScreenStyles = null;
36
+
37
+ // Visual styles for modal content inside native <dialog>.
38
+ // Uses cssMap (not css) to avoid triggering no-nested-styles lint rule.
39
+
40
+ const LOCAL_CURRENT_SURFACE_CSS_VAR = '--ds-elevation-surface-current';
41
+ const topLayerStyles = {
42
+ content: "_1e0c1txw _4t3i1osq _2lx21bp4 _bfhk1bhr _syazi7uo _1q1l1bhr _lcxv1wug _1mq81kw7 _m01u1kw7 _1dg11kw7 _mizu1v1w _1ah3dkaa _ra3xnqa1 _128mdkaa _zg7p130s",
43
+ borderRadius: "_epkxfajl",
44
+ borderRadiusT26: "_epkxpb1k"
45
+ };
46
+
47
+ // Scroll-mode styles for the content div.
48
+ // Height overrides use ID-scoped <style> (see dialogPositionStyles) because
49
+ // Compiled atomic classes have specificity (0,1,0) (increaseSpecificity is disabled).
50
+ // The doubled-ID selector (#id#id > div) at (2,0,1) reliably wins.
51
+ // Only non-height properties needing the && boost remain here.
52
+
53
+ const topLayerBodyScrollStyles = null;
54
+ const topLayerViewportScrollStyles = null;
23
55
  const allowlistElements = (element, callback) => {
24
- // Allow focus to reach elements outside the modal:
25
- // if AUI dialog is allowListed and visible
26
- if (document.querySelector('.aui-blanket:not([hidden])')) {
56
+ // Allow focus outside modal when AUI dialog is visible
57
+ // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage -- legacy FocusLock allowlist
58
+ if (Boolean(document.querySelector('.aui-blanket:not([hidden])'))) {
27
59
  return false;
28
60
  }
29
- // allows to pass a callback function to allow elements be ignored by focus lock
61
+ // Optional callback to let consumers exclude elements from focus lock
30
62
  if (typeof callback === 'function') {
31
63
  return callback(element);
32
64
  }
@@ -45,7 +77,7 @@ const allowlistElements = (element, callback) => {
45
77
  */
46
78
  const InternalModalWrapper = props => {
47
79
  const {
48
- autoFocus = true,
80
+ autoFocus,
49
81
  focusLockAllowlist,
50
82
  shouldCloseOnEscapePress = true,
51
83
  shouldCloseOnOverlayClick = true,
@@ -71,10 +103,8 @@ const InternalModalWrapper = props => {
71
103
  const stackIndex = stackIndexOverride || calculatedStackIndex;
72
104
  const isForeground = stackIndex === 0;
73
105
 
74
- // When a user supplies a ref to focus we skip auto focus via react-focus-lock
75
- // When flag is true and a ref is not supplied, autofocus is true. See https://product-fabric.atlassian.net/browse/DSP-24307
76
- // When we remove boolean `autoFocus`, we won't have to worry about this
77
- const autoFocusLock = typeof autoFocus === 'boolean';
106
+ // If no ref is provided, autofocus on first element
107
+ const autoFocusLock = !(typeof autoFocus === 'object');
78
108
  const onCloseHandler = usePlatformLeafEventHandler({
79
109
  fn: providedOnClose || noop,
80
110
  action: 'closed',
@@ -88,18 +118,214 @@ const InternalModalWrapper = props => {
88
118
  }
89
119
  }, [shouldCloseOnOverlayClick, onCloseHandler]);
90
120
 
91
- // This ensures to prevent additional re-renders while nothing is passed to focusLockAllowlist explicitly.
121
+ // Stable callback to avoid re-renders when focusLockAllowlist is not provided.
92
122
  const allowListCallback = useCallback(element => allowlistElements(element, focusLockAllowlist), [focusLockAllowlist]);
93
- usePreventProgrammaticScroll();
123
+
124
+ // Called outside the feature-flag branch to keep hook order stable.
125
+ // Legacy path: FadeIn calls onFinish. Top-layer path: called directly.
126
+ const {
127
+ isExiting,
128
+ onFinish: onExitFinish
129
+ } = useExitingPersistence();
130
+
131
+ // Prevent background scroll (top-layer path uses DialogScrollLock instead).
132
+ // Safe conditional hook: feature flags are resolved once at startup.
133
+ if (!fg('platform-dst-top-layer')) {
134
+ // eslint-disable-next-line react-hooks/rules-of-hooks
135
+ usePreventProgrammaticScroll();
136
+ }
94
137
  useNotifyOpenLayerObserver({
95
138
  type: 'modal',
96
- // Modal dialog is conditionally rendered when visible, so when this runs it is always open.
139
+ // Always open — modal is conditionally rendered when visible.
97
140
  isOpen: true,
98
- // Passing a no-op for now, as there isn't a real use case for closing the modal dialog programmatically
99
- // by the OpenLayerObserver. The only current use case is closing layers when resizing the nav layout,
100
- // which cannot happen while a modal dialog is open.
141
+ // No-op: no current use case for programmatic close via OpenLayerObserver.
101
142
  onClose: noop
102
143
  });
144
+
145
+ /**
146
+ * Top-layer path (platform-dst-top-layer).
147
+ *
148
+ * Replaces Portal, FocusLock, ScrollLock, Blanket, Positioner, and z-index
149
+ * management with native <dialog> via @atlaskit/top-layer/dialog.
150
+ *
151
+ * Key decisions:
152
+ * - Animation: CSS transitions via @starting-style / allow-discrete.
153
+ * - Close gating: onDialogClose only forwards allowed reasons
154
+ * (see notes/guides/dialog-close-flow.md).
155
+ * - onClose event param: undefined - consumers should use close reason.
156
+ * - Focus restoration: native <dialog> behavior replaces react-focus-lock's
157
+ * returnFocus (see accessibility-criteria.md).
158
+ */
159
+ if (fg('platform-dst-top-layer')) {
160
+ // Native <dialog> always restores focus on close - no opt-out via shouldReturnFocus.
161
+ const defaultTestId = testId || 'modal-dialog';
162
+
163
+ // eslint-disable-next-line react-hooks/rules-of-hooks
164
+ const id = useId();
165
+ const titleId = `modal-dialog-title-${id}`;
166
+
167
+ // Content container ref - used for onOpenComplete/onCloseComplete callbacks.
168
+ // eslint-disable-next-line react-hooks/rules-of-hooks
169
+ const contentRef = useRef(null);
170
+
171
+ // Cache last content element for onCloseComplete after children unmount
172
+ // (with reduced motion, contentRef clears before onExitFinish fires).
173
+ // eslint-disable-next-line react-hooks/rules-of-hooks
174
+ const lastContentElRef = useRef(null);
175
+ if (contentRef.current) {
176
+ lastContentElRef.current = contentRef.current;
177
+ }
178
+
179
+ // Native <dialog> ref - needed for ExitingPersistence to call dialog.close().
180
+ // eslint-disable-next-line react-hooks/rules-of-hooks
181
+ const dialogRef = useRef(null);
182
+
183
+ // eslint-disable-next-line react-hooks/rules-of-hooks
184
+ const modalDialogContext = useMemo(() => ({
185
+ testId: defaultTestId,
186
+ titleId,
187
+ onClose: onCloseHandler,
188
+ hasProvidedOnClose: Boolean(providedOnClose),
189
+ isFullScreen: isFullScreen !== null && isFullScreen !== void 0 ? isFullScreen : false
190
+ }), [defaultTestId, titleId, onCloseHandler, providedOnClose, isFullScreen]);
191
+
192
+ // Only forward close when the reason is allowed by props.
193
+ // Passes a synthetic event to satisfy the KeyboardOrMouseEvent contract.
194
+ // eslint-disable-next-line react-hooks/rules-of-hooks
195
+ const onDialogClose = useCallback(({
196
+ reason
197
+ }) => {
198
+ if (reason === 'escape' && shouldCloseOnEscapePress) {
199
+ onCloseHandler(createCloseEvent({
200
+ reason
201
+ }));
202
+ }
203
+ if (reason === 'overlay-click' && shouldCloseOnOverlayClick) {
204
+ onCloseHandler(createCloseEvent({
205
+ reason
206
+ }));
207
+ }
208
+ }, [onCloseHandler, shouldCloseOnEscapePress, shouldCloseOnOverlayClick]);
209
+
210
+ // ExitingPersistence: isExiting → isOpen={false} → Dialog exit animation →
211
+ // onExitFinish → onCloseComplete + unmount.
212
+ // eslint-disable-next-line react-hooks/rules-of-hooks
213
+ const handleDialogExitFinish = useCallback(() => {
214
+ var _contentRef$current;
215
+ const el = (_contentRef$current = contentRef.current) !== null && _contentRef$current !== void 0 ? _contentRef$current : lastContentElRef.current;
216
+ if (onCloseComplete && el) {
217
+ onCloseComplete(el);
218
+ }
219
+ lastContentElRef.current = null;
220
+ onExitFinish === null || onExitFinish === void 0 ? void 0 : onExitFinish();
221
+ }, [onExitFinish, onCloseComplete]);
222
+
223
+ // Fire onOpenComplete after mount.
224
+ // eslint-disable-next-line react-hooks/rules-of-hooks
225
+ useEffect(() => {
226
+ if (onOpenComplete && contentRef.current) {
227
+ onOpenComplete(contentRef.current, true);
228
+ }
229
+ // eslint-disable-next-line react-hooks/exhaustive-deps
230
+ }, []);
231
+
232
+ // Honor `shouldReturnFocus={ref}` on unmount.
233
+ // Native <dialog>.close() restores focus to the trigger that opened it,
234
+ // but the consumer asked for focus to go to a specific element instead.
235
+ // Run this in an unmount cleanup so it fires after dialog.close()
236
+ // (which fires in the Dialog's effect cleanup).
237
+ // eslint-disable-next-line react-hooks/rules-of-hooks
238
+ const shouldReturnFocusRef = useRef(shouldReturnFocus);
239
+ shouldReturnFocusRef.current = shouldReturnFocus;
240
+ // eslint-disable-next-line react-hooks/rules-of-hooks
241
+ useEffect(() => {
242
+ return () => {
243
+ const target = shouldReturnFocusRef.current;
244
+ if (typeof target === 'object' && target.current) {
245
+ target.current.focus();
246
+ }
247
+ };
248
+ }, []);
249
+
250
+ // Focus a ref-targeted element after mount (when autoFocus is a ref).
251
+ // When true, native <dialog>.showModal() handles focus automatically.
252
+ // eslint-disable-next-line react-hooks/rules-of-hooks
253
+ useAutoFocus(typeof autoFocus === 'object' ? autoFocus : undefined, typeof autoFocus === 'object');
254
+
255
+ // Chrome cross-origin iframe DnD workaround (crbug.com/362301053)
256
+ // eslint-disable-next-line react-hooks/rules-of-hooks
257
+ useEffect(() => {
258
+ return combine(disableDraggingToCrossOriginIFramesForElement(), disableDraggingToCrossOriginIFramesForTextSelection(), disableDraggingToCrossOriginIFramesForExternal());
259
+ }, []);
260
+
261
+ // Responsive layout via ID-scoped <style> (same pattern as Dialog's hideBackdrop).
262
+ // ID selector beats Compiled atomic classes without !important and supports @media.
263
+ const namedWidth = getDialogWidth(width !== null && width !== void 0 ? width : 'medium');
264
+ const dialogId = `modal-dialog-${id}`;
265
+ const escapedDialogId = CSS.escape(dialogId);
266
+
267
+ // Percentage widths need special handling in the top layer.
268
+ // In legacy, the percentage resolved against the Positioner's max-width
269
+ // (100vw - 120px). In the top layer, the <dialog>'s containing block is the
270
+ // viewport (100vw), so a raw percentage would produce a wider modal.
271
+ // Transform e.g. '42%' → 'calc(42 * (100vw - 120px) / 100)' to match legacy.
272
+ const resolvedWidth = namedWidth.endsWith('%') ? `calc(${parseFloat(namedWidth)} * (100vw - 120px) / 100)` : namedWidth;
273
+ const dialogStyle = isFullScreen ? {
274
+ width: '100vw',
275
+ height: '100vh',
276
+ margin: '0'
277
+ } : {
278
+ width: `min(${resolvedWidth}, 100vw)`
279
+ };
280
+
281
+ // Shift stacked background modals down by space.100 (8px) per level.
282
+ if (stackIndex > 0) {
283
+ dialogStyle['transform'] = `translateY(calc(${stackIndex}px * ${"var(--ds-space-100, 8px)"}))`;
284
+ }
285
+
286
+ // Mobile: viewport fill. Desktop (≥ 30rem): gutter margins, auto height.
287
+ // Content-div height set via #id > div to beat Compiled's atomic specificity.
288
+ const desktopMargin = shouldScrollInViewport ? '60px auto' : '60px auto auto';
289
+ const resolvedHeight = dialogHeight(height);
290
+ // Body-scroll: specified height or auto. Viewport-scroll: uses min-height.
291
+ const desktopContentHeight = shouldScrollInViewport ? 'auto' : resolvedHeight;
292
+ const desktopContentMinHeight = shouldScrollInViewport ? resolvedHeight : 'auto';
293
+ // Viewport-scroll: the legacy Positioner was a fixed 100vh container that
294
+ // scrolled internally, so the modal section could fill (100vh - 60px top gutter).
295
+ // In the top layer the <dialog> sizes to content with height:auto, so we need
296
+ // an explicit min-height to ensure the dialog stretches to the same visible area.
297
+ const desktopDialogMinHeight = shouldScrollInViewport ? 'min-height:calc(100vh - 60px);' : '';
298
+ // Doubled-ID selector (#id#id > div) at specificity (2,0,1) beats
299
+ // Compiled atomic classes at (0,1,0) (increaseSpecificity is disabled).
300
+ const dialogPositionStyles = isFullScreen ? ''
301
+ // Mobile: edge-to-edge. Desktop (≥ 30rem): 60px gutters, max-width.
302
+ : `#${escapedDialogId}#${escapedDialogId}{margin:0;height:100vh}#${escapedDialogId}#${escapedDialogId}>div{height:100%}@media(min-width:30rem){#${escapedDialogId}#${escapedDialogId}{margin:${desktopMargin};height:auto;${desktopDialogMinHeight}max-width:calc(100vw - 120px)}#${escapedDialogId}#${escapedDialogId}>div{height:${desktopContentHeight};min-height:${desktopContentMinHeight}}}`;
303
+ return /*#__PURE__*/React.createElement(Dialog, {
304
+ ref: dialogRef,
305
+ id: dialogId,
306
+ onClose: onDialogClose,
307
+ onExitFinish: handleDialogExitFinish,
308
+ animate: isFullScreen ? false : modalAnimation,
309
+ isOpen: !isExiting,
310
+ shouldHideBackdrop: stackIndex > 0 || Boolean(isBlanketHidden),
311
+ label: label,
312
+ labelledBy: label ? undefined : titleId,
313
+ testId: defaultTestId
314
+ // eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop
315
+ ,
316
+ style: dialogStyle
317
+ }, /*#__PURE__*/React.createElement(DialogScrollLock, null), dialogPositionStyles &&
318
+ /*#__PURE__*/
319
+ // eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles
320
+ React.createElement("style", null, dialogPositionStyles), /*#__PURE__*/React.createElement("div", {
321
+ ref: contentRef,
322
+ className: ax([topLayerStyles.content, !isFullScreen && topLayerStyles.borderRadius, !isFullScreen && fg('platform-dst-shape-theme-default') && topLayerStyles.borderRadiusT26, !isFullScreen && !shouldScrollInViewport && "_bolhzwhf", !isFullScreen && shouldScrollInViewport && "_1tke1kxc _c71lglyw"])
323
+ }, /*#__PURE__*/React.createElement(ModalContext.Provider, {
324
+ value: modalDialogContext
325
+ }, /*#__PURE__*/React.createElement(ScrollContext.Provider, {
326
+ value: shouldScrollInViewport
327
+ }, children))));
328
+ }
103
329
  const modalDialogWithBlanket = /*#__PURE__*/React.createElement(Blanket, {
104
330
  isTinted: !isBlanketHidden,
105
331
  onBlanketClicked: onBlanketClicked,
@@ -23,7 +23,7 @@ import { disableDraggingToCrossOriginIFramesForTextSelection } from '../pragmati
23
23
  import { ScrollContext } from '../scroll-context';
24
24
  import { width } from '../width';
25
25
  import Positioner from './positioner';
26
- var dialogWidth = function dialogWidth(input) {
26
+ export var dialogWidth = function dialogWidth(input) {
27
27
  if (!input) {
28
28
  return 'auto';
29
29
  }
@@ -34,7 +34,7 @@ var dialogWidth = function dialogWidth(input) {
34
34
  }
35
35
  return typeof input === 'number' ? "".concat(input, "px") : input;
36
36
  };
37
- var dialogHeight = function dialogHeight(input) {
37
+ export var dialogHeight = function dialogHeight(input) {
38
38
  if (!input) {
39
39
  return 'auto';
40
40
  }
@@ -56,7 +56,7 @@ var ModalDialog = function ModalDialog(props) {
56
56
  _props$shouldScrollIn = props.shouldScrollInViewport,
57
57
  shouldScrollInViewport = _props$shouldScrollIn === void 0 ? false : _props$shouldScrollIn,
58
58
  shouldCloseOnEscapePress = props.shouldCloseOnEscapePress,
59
- providedAutoFocus = props.autoFocus,
59
+ autoFocus = props.autoFocus,
60
60
  stackIndex = props.stackIndex,
61
61
  onClose = props.onClose,
62
62
  onCloseComplete = props.onCloseComplete,
@@ -73,10 +73,6 @@ var ModalDialog = function ModalDialog(props) {
73
73
  var id = useId();
74
74
  var titleId = "modal-dialog-title-".concat(id);
75
75
  var defaultTestId = testId || 'modal-dialog';
76
- // https://product-fabric.atlassian.net/browse/DSP-24307
77
- // If flag and falsy, use true instead.
78
- // When we remove boolean `autoFocus`, we won't have to worry about this
79
- var autoFocus = !providedAutoFocus ? true : providedAutoFocus;
80
76
  useEffect(function () {
81
77
  // Modal dialogs can appear on top of iframe elements that are on another domain.
82
78
  // There is a Chrome bug where drag and drop in an element on top of a cross domain
@@ -88,7 +84,7 @@ var ModalDialog = function ModalDialog(props) {
88
84
 
89
85
  return combine(disableDraggingToCrossOriginIFramesForElement(), disableDraggingToCrossOriginIFramesForTextSelection(), disableDraggingToCrossOriginIFramesForExternal());
90
86
  }, []);
91
- useAutoFocus(_typeof(autoFocus) === 'object' ? autoFocus : undefined,
87
+ useAutoFocus(autoFocus,
92
88
  // When a user supplies a ref to focus we enable this hook
93
89
  _typeof(autoFocus) === 'object');
94
90
  var _useOnMotionFinish = useOnMotionFinish({
@@ -1,7 +1,24 @@
1
+
1
2
  ._152tze3t{inset-block-start:var(--ds-space-0,0)}
2
3
  ._18m91wug{overflow-y:auto}
3
4
  ._1bsbauwl{width:100vw}
5
+ ._1dg11kw7>form:only-child{flex-direction:inherit}
4
6
  ._1e02ze3t{inset-inline-start:var(--ds-space-0,0)}
7
+ ._1e0c1txw{display:flex}
8
+ ._1mq81kw7>form:only-child{display:inherit}
9
+ ._1q1l1bhr{--ds-elevation-surface-current:var(--ds-surface-overlay,#fff)}
10
+ ._1tke1kxc{min-height:100vh}
11
+ ._2lx21bp4{flex-direction:column}
5
12
  ._4t3i1kxc{height:100vh}
13
+ ._4t3i1osq{height:100%}
6
14
  ._8am5i4x0{-webkit-overflow-scrolling:touch}
7
- ._kqsw1n9t{position:fixed}
15
+ ._bfhk1bhr{background-color:var(--ds-surface-overlay,#fff)}
16
+ ._c71lglyw{max-height:none}
17
+ ._kqsw1n9t{position:fixed}
18
+ ._lcxv1wug{pointer-events:auto}
19
+ ._m01u1kw7>form:only-child{max-height:inherit}
20
+ ._syazi7uo{color:var(--ds-text,#292a2e)}._128mdkaa:focus-visible{outline-width:var(--ds-border-width-focused,2px)}
21
+ ._1ah3dkaa:focus-visible{outline-offset:var(--ds-border-width-focused,2px)}
22
+ ._mizu1v1w:focus-visible{outline-color:var(--ds-border-focused,#4688ec)}
23
+ ._ra3xnqa1:focus-visible{outline-style:solid}
24
+ @media (min-width:30rem){._bolhzwhf._bolhzwhf{max-height:calc(100vh - 119px)}._epkxfajl{border-radius:var(--ds-radius-small,3px)}._epkxpb1k{border-radius:var(--ds-radius-xlarge,9pt)}._zg7p130s{box-shadow:var(--ds-shadow-overlay,0 8px 9pt #1e1f2126,0 0 1px #1e1f214f)}}