@atlaskit/modal-dialog 16.2.1 → 16.2.3

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.
@@ -64,10 +64,39 @@ const allowlistElements = (element, callback) => {
64
64
  }
65
65
  return true;
66
66
  };
67
- const InternalModalWrapper = /*#__PURE__*/forwardRef((props, ref) => {
67
+
68
+ // Analytics-wrapped close handler. Extracted so both rendering paths build it
69
+ // identically.
70
+ function useModalCloseHandler(providedOnClose) {
71
+ return usePlatformLeafEventHandler({
72
+ fn: providedOnClose || noop,
73
+ action: 'closed',
74
+ componentName: 'modalDialog',
75
+ packageName: "@atlaskit/modal-dialog",
76
+ packageVersion: "16.2.2"
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Top-layer rendering path (platform-dst-top-layer).
82
+ *
83
+ * Replaces Portal, FocusLock, ScrollLock, Blanket, Positioner, and z-index
84
+ * management with native <dialog> via @atlaskit/top-layer/dialog.
85
+ *
86
+ * Key decisions:
87
+ * - Animation: CSS transitions via @starting-style / allow-discrete.
88
+ * - Close gating: onDialogClose only forwards allowed reasons
89
+ * (see notes/guides/dialog-close-flow.md).
90
+ * - onClose event param: undefined - consumers should use close reason.
91
+ * - Focus restoration: native <dialog> behavior replaces react-focus-lock's
92
+ * returnFocus (see accessibility-criteria.md).
93
+ *
94
+ * Every hook here runs unconditionally: this component only mounts on the
95
+ * top-layer path, so it never shares a hook sequence with the legacy path.
96
+ */
97
+ function ModalWrapperTopLayer(props) {
68
98
  const {
69
99
  autoFocus,
70
- focusLockAllowlist,
71
100
  shouldCloseOnEscapePress = true,
72
101
  shouldCloseOnOverlayClick = true,
73
102
  shouldScrollInViewport = false,
@@ -83,252 +112,240 @@ const InternalModalWrapper = /*#__PURE__*/forwardRef((props, ref) => {
83
112
  onOpenComplete,
84
113
  label,
85
114
  testId,
86
- isFullScreen,
87
- UNSAFE_shouldDisableMotionUplift = false
115
+ isFullScreen
88
116
  } = props;
89
117
  const calculatedStackIndex = useModalStack({
90
118
  onStackChange
91
119
  });
92
120
  const stackIndex = stackIndexOverride || calculatedStackIndex;
93
- const isForeground = stackIndex === 0;
94
-
95
- // If no ref is provided, autofocus on first element
96
- const autoFocusLock = !(typeof autoFocus === 'object');
97
- const onCloseHandler = usePlatformLeafEventHandler({
98
- fn: providedOnClose || noop,
99
- action: 'closed',
100
- componentName: 'modalDialog',
101
- packageName: "@atlaskit/modal-dialog",
102
- packageVersion: "16.2.0"
103
- });
104
- const onBlanketClicked = useCallback(e => {
105
- if (shouldCloseOnOverlayClick) {
106
- onCloseHandler(e);
107
- }
108
- }, [shouldCloseOnOverlayClick, onCloseHandler]);
109
-
110
- // Stable callback to avoid re-renders when focusLockAllowlist is not provided.
111
- const allowListCallback = useCallback(element => allowlistElements(element, focusLockAllowlist), [focusLockAllowlist]);
112
-
113
- // Called outside the feature-flag branch to keep hook order stable.
114
- // Legacy path: FadeIn calls onFinish. Top-layer path: called directly.
121
+ const onCloseHandler = useModalCloseHandler(providedOnClose);
115
122
  const {
116
123
  isExiting,
117
124
  onFinish: onExitFinish
118
125
  } = useExitingPersistence();
119
126
 
120
- // Prevent background scroll (top-layer path uses DialogScrollLock instead).
121
- // Safe conditional hook: feature flags are resolved once at startup.
122
- if (!fg('platform-dst-top-layer')) {
123
- // eslint-disable-next-line react-hooks/rules-of-hooks
124
- usePreventProgrammaticScroll();
125
- }
126
-
127
- // On the top-layer path, the Dialog primitive registers with the observer
128
- // directly, so we skip registration here to avoid double-counting.
129
- // Safe conditional hook: feature flags are resolved once at startup.
130
- if (!fg('platform-dst-top-layer')) {
131
- // eslint-disable-next-line react-hooks/rules-of-hooks
132
- useNotifyOpenLayerObserver({
133
- type: 'modal',
134
- // Always open — modal is conditionally rendered when visible.
135
- isOpen: true,
136
- // No-op: no current use case for programmatic close via OpenLayerObserver.
137
- onClose: noop
138
- });
139
- }
127
+ // Native <dialog> always restores focus on close - no opt-out via shouldReturnFocus.
128
+ const defaultTestId = testId || 'modal-dialog';
129
+ const id = useId();
130
+ const titleId = `modal-dialog-title-${id}`;
140
131
 
141
- /**
142
- * Top-layer path (platform-dst-top-layer).
143
- *
144
- * Replaces Portal, FocusLock, ScrollLock, Blanket, Positioner, and z-index
145
- * management with native <dialog> via @atlaskit/top-layer/dialog.
146
- *
147
- * Key decisions:
148
- * - Animation: CSS transitions via @starting-style / allow-discrete.
149
- * - Close gating: onDialogClose only forwards allowed reasons
150
- * (see notes/guides/dialog-close-flow.md).
151
- * - onClose event param: undefined - consumers should use close reason.
152
- * - Focus restoration: native <dialog> behavior replaces react-focus-lock's
153
- * returnFocus (see accessibility-criteria.md).
154
- */
155
- if (fg('platform-dst-top-layer')) {
156
- // Native <dialog> always restores focus on close - no opt-out via shouldReturnFocus.
157
- const defaultTestId = testId || 'modal-dialog';
132
+ // Content container ref - used for onOpenComplete/onCloseComplete callbacks.
133
+ const contentRef = useRef(null);
158
134
 
159
- // eslint-disable-next-line react-hooks/rules-of-hooks
160
- const id = useId();
161
- const titleId = `modal-dialog-title-${id}`;
135
+ // Cache last content element for onCloseComplete after children unmount
136
+ // (with reduced motion, contentRef clears before onExitFinish fires).
137
+ const lastContentElRef = useRef(null);
138
+ if (contentRef.current) {
139
+ lastContentElRef.current = contentRef.current;
140
+ }
162
141
 
163
- // Content container ref - used for onOpenComplete/onCloseComplete callbacks.
164
- // eslint-disable-next-line react-hooks/rules-of-hooks
165
- const contentRef = useRef(null);
142
+ // Native <dialog> ref - needed for ExitingPersistence to call dialog.close().
143
+ const dialogRef = useRef(null);
144
+ const modalDialogContext = useMemo(() => ({
145
+ testId: defaultTestId,
146
+ titleId,
147
+ onClose: onCloseHandler,
148
+ hasProvidedOnClose: Boolean(providedOnClose),
149
+ isFullScreen: isFullScreen !== null && isFullScreen !== void 0 ? isFullScreen : false
150
+ }), [defaultTestId, titleId, onCloseHandler, providedOnClose, isFullScreen]);
166
151
 
167
- // Cache last content element for onCloseComplete after children unmount
168
- // (with reduced motion, contentRef clears before onExitFinish fires).
169
- // eslint-disable-next-line react-hooks/rules-of-hooks
170
- const lastContentElRef = useRef(null);
171
- if (contentRef.current) {
172
- lastContentElRef.current = contentRef.current;
152
+ // Only forward close when the reason is allowed by props.
153
+ // Passes a synthetic event to satisfy the KeyboardOrMouseEvent contract.
154
+ const onDialogClose = useCallback(({
155
+ reason
156
+ }) => {
157
+ if (reason === 'escape' && shouldCloseOnEscapePress) {
158
+ onCloseHandler(createCloseEvent({
159
+ reason
160
+ }));
161
+ }
162
+ if (reason === 'overlay-click' && shouldCloseOnOverlayClick) {
163
+ onCloseHandler(createCloseEvent({
164
+ reason
165
+ }));
173
166
  }
167
+ }, [onCloseHandler, shouldCloseOnEscapePress, shouldCloseOnOverlayClick]);
174
168
 
175
- // Native <dialog> ref - needed for ExitingPersistence to call dialog.close().
176
- // eslint-disable-next-line react-hooks/rules-of-hooks
177
- const dialogRef = useRef(null);
169
+ // ExitingPersistence: isExiting → isOpen={false} → Dialog exit animation →
170
+ // onExitFinish → onCloseComplete + unmount.
171
+ const handleDialogExitFinish = useCallback(() => {
172
+ var _contentRef$current;
173
+ const el = (_contentRef$current = contentRef.current) !== null && _contentRef$current !== void 0 ? _contentRef$current : lastContentElRef.current;
174
+ if (onCloseComplete && el) {
175
+ onCloseComplete(el);
176
+ }
177
+ lastContentElRef.current = null;
178
+ onExitFinish === null || onExitFinish === void 0 ? void 0 : onExitFinish();
179
+ }, [onExitFinish, onCloseComplete]);
178
180
 
179
- // eslint-disable-next-line react-hooks/rules-of-hooks
180
- const modalDialogContext = useMemo(() => ({
181
- testId: defaultTestId,
182
- titleId,
183
- onClose: onCloseHandler,
184
- hasProvidedOnClose: Boolean(providedOnClose),
185
- isFullScreen: isFullScreen !== null && isFullScreen !== void 0 ? isFullScreen : false
186
- }), [defaultTestId, titleId, onCloseHandler, providedOnClose, isFullScreen]);
181
+ // Fire onOpenComplete after mount.
182
+ useEffect(() => {
183
+ if (onOpenComplete && contentRef.current) {
184
+ onOpenComplete(contentRef.current, true);
185
+ }
186
+ // eslint-disable-next-line react-hooks/exhaustive-deps
187
+ }, []);
187
188
 
188
- // Only forward close when the reason is allowed by props.
189
- // Passes a synthetic event to satisfy the KeyboardOrMouseEvent contract.
190
- // eslint-disable-next-line react-hooks/rules-of-hooks
191
- const onDialogClose = useCallback(({
192
- reason
193
- }) => {
194
- if (reason === 'escape' && shouldCloseOnEscapePress) {
195
- onCloseHandler(createCloseEvent({
196
- reason
197
- }));
198
- }
199
- if (reason === 'overlay-click' && shouldCloseOnOverlayClick) {
200
- onCloseHandler(createCloseEvent({
201
- reason
202
- }));
189
+ // Honor `shouldReturnFocus={ref}` on unmount.
190
+ // Native <dialog>.close() restores focus to the trigger that opened it,
191
+ // but the consumer asked for focus to go to a specific element instead.
192
+ // Run this in an unmount cleanup so it fires after dialog.close()
193
+ // (which fires in the Dialog's effect cleanup).
194
+ const shouldReturnFocusRef = useRef(shouldReturnFocus);
195
+ shouldReturnFocusRef.current = shouldReturnFocus;
196
+ useEffect(() => {
197
+ return () => {
198
+ const target = shouldReturnFocusRef.current;
199
+ if (typeof target === 'object' && target.current) {
200
+ target.current.focus();
203
201
  }
204
- }, [onCloseHandler, shouldCloseOnEscapePress, shouldCloseOnOverlayClick]);
202
+ };
203
+ }, []);
205
204
 
206
- // ExitingPersistence: isExiting → isOpen={false} → Dialog exit animation →
207
- // onExitFinish → onCloseComplete + unmount.
208
- // eslint-disable-next-line react-hooks/rules-of-hooks
209
- const handleDialogExitFinish = useCallback(() => {
210
- var _contentRef$current;
211
- const el = (_contentRef$current = contentRef.current) !== null && _contentRef$current !== void 0 ? _contentRef$current : lastContentElRef.current;
212
- if (onCloseComplete && el) {
213
- onCloseComplete(el);
214
- }
215
- lastContentElRef.current = null;
216
- onExitFinish === null || onExitFinish === void 0 ? void 0 : onExitFinish();
217
- }, [onExitFinish, onCloseComplete]);
205
+ // Focus a ref-targeted element after mount (when autoFocus is a ref).
206
+ // When true, native <dialog>.showModal() handles focus automatically.
207
+ useAutoFocus(typeof autoFocus === 'object' ? autoFocus : undefined, typeof autoFocus === 'object');
218
208
 
219
- // Fire onOpenComplete after mount.
220
- // eslint-disable-next-line react-hooks/rules-of-hooks
221
- useEffect(() => {
222
- if (onOpenComplete && contentRef.current) {
223
- onOpenComplete(contentRef.current, true);
224
- }
225
- // eslint-disable-next-line react-hooks/exhaustive-deps
226
- }, []);
209
+ // Chrome cross-origin iframe DnD workaround (crbug.com/362301053)
210
+ useEffect(() => {
211
+ return combine(disableDraggingToCrossOriginIFramesForElement(), disableDraggingToCrossOriginIFramesForTextSelection(), disableDraggingToCrossOriginIFramesForExternal());
212
+ }, []);
227
213
 
228
- // Honor `shouldReturnFocus={ref}` on unmount.
229
- // Native <dialog>.close() restores focus to the trigger that opened it,
230
- // but the consumer asked for focus to go to a specific element instead.
231
- // Run this in an unmount cleanup so it fires after dialog.close()
232
- // (which fires in the Dialog's effect cleanup).
233
- // eslint-disable-next-line react-hooks/rules-of-hooks
234
- const shouldReturnFocusRef = useRef(shouldReturnFocus);
235
- shouldReturnFocusRef.current = shouldReturnFocus;
236
- // eslint-disable-next-line react-hooks/rules-of-hooks
237
- useEffect(() => {
238
- return () => {
239
- const target = shouldReturnFocusRef.current;
240
- if (typeof target === 'object' && target.current) {
241
- target.current.focus();
242
- }
243
- };
244
- }, []);
214
+ // Responsive layout via ID-scoped <style> (same pattern as Dialog's hideBackdrop).
215
+ // ID selector beats Compiled atomic classes without !important and supports @media.
216
+ const namedWidth = getDialogWidth(width !== null && width !== void 0 ? width : 'medium');
217
+ const dialogId = `modal-dialog-${id}`;
218
+ const escapedDialogId = CSS.escape(dialogId);
245
219
 
246
- // Focus a ref-targeted element after mount (when autoFocus is a ref).
247
- // When true, native <dialog>.showModal() handles focus automatically.
248
- // eslint-disable-next-line react-hooks/rules-of-hooks
249
- useAutoFocus(typeof autoFocus === 'object' ? autoFocus : undefined, typeof autoFocus === 'object');
220
+ // Percentage widths need special handling in the top layer.
221
+ // In legacy, the percentage resolved against the Positioner's max-width
222
+ // (100vw - 120px). In the top layer, the <dialog>'s containing block is the
223
+ // viewport (100vw), so a raw percentage would produce a wider modal.
224
+ // Transform e.g. '42%' → 'calc(42 * (100vw - 120px) / 100)' to match legacy.
225
+ const resolvedWidth = namedWidth.endsWith('%') ? `calc(${parseFloat(namedWidth)} * (100vw - 120px) / 100)` : namedWidth;
226
+ const dialogStyle = isFullScreen ? {
227
+ width: '100vw',
228
+ height: '100vh',
229
+ margin: '0'
230
+ } : {
231
+ width: `min(${resolvedWidth}, 100vw)`
232
+ };
250
233
 
251
- // Chrome cross-origin iframe DnD workaround (crbug.com/362301053)
252
- // eslint-disable-next-line react-hooks/rules-of-hooks
253
- useEffect(() => {
254
- return combine(disableDraggingToCrossOriginIFramesForElement(), disableDraggingToCrossOriginIFramesForTextSelection(), disableDraggingToCrossOriginIFramesForExternal());
255
- }, []);
234
+ // Shift stacked background modals down by space.100 (8px) per level.
235
+ if (stackIndex > 0) {
236
+ dialogStyle['transform'] = `translateY(calc(${stackIndex}px * ${"var(--ds-space-100, 8px)"}))`;
237
+ }
256
238
 
257
- // Responsive layout via ID-scoped <style> (same pattern as Dialog's hideBackdrop).
258
- // ID selector beats Compiled atomic classes without !important and supports @media.
259
- const namedWidth = getDialogWidth(width !== null && width !== void 0 ? width : 'medium');
260
- const dialogId = `modal-dialog-${id}`;
261
- const escapedDialogId = CSS.escape(dialogId);
239
+ // Mobile: viewport fill. Desktop (≥ 30rem): gutter margins, auto height.
240
+ // Content-div height set via #id > div to beat Compiled's atomic specificity.
241
+ const desktopMargin = shouldScrollInViewport ? '60px auto' : '60px auto auto';
242
+ const resolvedHeight = dialogHeight(height);
243
+ // Body-scroll: specified height or auto. Viewport-scroll: uses min-height.
244
+ const desktopContentHeight = shouldScrollInViewport ? 'auto' : resolvedHeight;
245
+ const desktopContentMinHeight = shouldScrollInViewport ? resolvedHeight : 'auto';
246
+ // Viewport-scroll: the legacy Positioner was a fixed 100vh container that
247
+ // scrolled internally, so the modal section could fill (100vh - 60px top gutter).
248
+ // In the top layer the <dialog> sizes to content with height:auto, so we need
249
+ // an explicit min-height to ensure the dialog stretches to the same visible area.
250
+ const desktopDialogMinHeight = shouldScrollInViewport ? 'min-height:calc(100vh - 60px);' : '';
251
+ // Doubled-ID selector (#id#id > div) at specificity (2,0,1) beats
252
+ // Compiled atomic classes at (0,1,0) (increaseSpecificity is disabled).
253
+ const dialogPositionStyles = isFullScreen ? '' :
254
+ // Mobile: edge-to-edge. Desktop (≥ 30rem): 60px gutters, max-width.
255
+ `#${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}}}`;
256
+ return /*#__PURE__*/React.createElement(Dialog, _extends({
257
+ ref: dialogRef,
258
+ id: dialogId,
259
+ onClose: onDialogClose,
260
+ onExitFinish: handleDialogExitFinish,
261
+ shouldAnimate: !isFullScreen,
262
+ isOpen: !isExiting,
263
+ shouldHideBackdrop: stackIndex > 0 || Boolean(isBlanketHidden)
264
+ // Dialog requires at least one of `label` or `labelledBy` (string, not undefined).
265
+ // Prefer the consumer-provided `label`; otherwise reference the internal `titleId`.
266
+ }, label ? {
267
+ label
268
+ } : {
269
+ labelledBy: titleId
270
+ }, {
271
+ testId: defaultTestId
272
+ // eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop
273
+ ,
274
+ style: dialogStyle
275
+ }), /*#__PURE__*/React.createElement(DialogScrollLock, {
276
+ isOpen: true
277
+ }), dialogPositionStyles &&
278
+ /*#__PURE__*/
279
+ // eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles
280
+ React.createElement("style", null, dialogPositionStyles), /*#__PURE__*/React.createElement("div", {
281
+ ref: contentRef,
282
+ className: ax([topLayerStyles.content, !isFullScreen && topLayerStyles.borderRadius, !isFullScreen && fg('platform-dst-shape-theme-default') && topLayerStyles.borderRadiusT26, !isFullScreen && !shouldScrollInViewport && "_bolhzwhf", !isFullScreen && shouldScrollInViewport && "_1tke1kxc _c71lglyw"])
283
+ }, /*#__PURE__*/React.createElement(ModalContext.Provider, {
284
+ value: modalDialogContext
285
+ }, /*#__PURE__*/React.createElement(ScrollContext.Provider, {
286
+ value: shouldScrollInViewport
287
+ }, children))));
288
+ }
262
289
 
263
- // Percentage widths need special handling in the top layer.
264
- // In legacy, the percentage resolved against the Positioner's max-width
265
- // (100vw - 120px). In the top layer, the <dialog>'s containing block is the
266
- // viewport (100vw), so a raw percentage would produce a wider modal.
267
- // Transform e.g. '42%' → 'calc(42 * (100vw - 120px) / 100)' to match legacy.
268
- const resolvedWidth = namedWidth.endsWith('%') ? `calc(${parseFloat(namedWidth)} * (100vw - 120px) / 100)` : namedWidth;
269
- const dialogStyle = isFullScreen ? {
270
- width: '100vw',
271
- height: '100vh',
272
- margin: '0'
273
- } : {
274
- width: `min(${resolvedWidth}, 100vw)`
275
- };
290
+ /**
291
+ * Legacy rendering path (Portal + FocusLock + ScrollLock + Blanket).
292
+ *
293
+ * Every hook here runs unconditionally: this component only mounts on the
294
+ * legacy path, so it never shares a hook sequence with the top-layer path.
295
+ */
296
+ const ModalWrapperLegacy = /*#__PURE__*/forwardRef((props, ref) => {
297
+ const {
298
+ autoFocus,
299
+ focusLockAllowlist,
300
+ shouldCloseOnEscapePress = true,
301
+ shouldCloseOnOverlayClick = true,
302
+ shouldScrollInViewport = false,
303
+ shouldReturnFocus = true,
304
+ stackIndex: stackIndexOverride,
305
+ onClose: providedOnClose,
306
+ onStackChange = noop,
307
+ isBlanketHidden,
308
+ children,
309
+ height,
310
+ width,
311
+ onCloseComplete,
312
+ onOpenComplete,
313
+ label,
314
+ testId,
315
+ isFullScreen,
316
+ UNSAFE_shouldDisableMotionUplift = false
317
+ } = props;
318
+ const calculatedStackIndex = useModalStack({
319
+ onStackChange
320
+ });
321
+ const stackIndex = stackIndexOverride || calculatedStackIndex;
322
+ const isForeground = stackIndex === 0;
276
323
 
277
- // Shift stacked background modals down by space.100 (8px) per level.
278
- if (stackIndex > 0) {
279
- dialogStyle['transform'] = `translateY(calc(${stackIndex}px * ${"var(--ds-space-100, 8px)"}))`;
324
+ // If no ref is provided, autofocus on first element
325
+ const autoFocusLock = !(typeof autoFocus === 'object');
326
+ const onCloseHandler = useModalCloseHandler(providedOnClose);
327
+ const onBlanketClicked = useCallback(e => {
328
+ if (shouldCloseOnOverlayClick) {
329
+ onCloseHandler(e);
280
330
  }
331
+ }, [shouldCloseOnOverlayClick, onCloseHandler]);
281
332
 
282
- // Mobile: viewport fill. Desktop (≥ 30rem): gutter margins, auto height.
283
- // Content-div height set via #id > div to beat Compiled's atomic specificity.
284
- const desktopMargin = shouldScrollInViewport ? '60px auto' : '60px auto auto';
285
- const resolvedHeight = dialogHeight(height);
286
- // Body-scroll: specified height or auto. Viewport-scroll: uses min-height.
287
- const desktopContentHeight = shouldScrollInViewport ? 'auto' : resolvedHeight;
288
- const desktopContentMinHeight = shouldScrollInViewport ? resolvedHeight : 'auto';
289
- // Viewport-scroll: the legacy Positioner was a fixed 100vh container that
290
- // scrolled internally, so the modal section could fill (100vh - 60px top gutter).
291
- // In the top layer the <dialog> sizes to content with height:auto, so we need
292
- // an explicit min-height to ensure the dialog stretches to the same visible area.
293
- const desktopDialogMinHeight = shouldScrollInViewport ? 'min-height:calc(100vh - 60px);' : '';
294
- // Doubled-ID selector (#id#id > div) at specificity (2,0,1) beats
295
- // Compiled atomic classes at (0,1,0) (increaseSpecificity is disabled).
296
- const dialogPositionStyles = isFullScreen ? '' :
297
- // Mobile: edge-to-edge. Desktop (≥ 30rem): 60px gutters, max-width.
298
- `#${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}}}`;
299
- return /*#__PURE__*/React.createElement(Dialog, _extends({
300
- ref: dialogRef,
301
- id: dialogId,
302
- onClose: onDialogClose,
303
- onExitFinish: handleDialogExitFinish,
304
- shouldAnimate: !isFullScreen,
305
- isOpen: !isExiting,
306
- shouldHideBackdrop: stackIndex > 0 || Boolean(isBlanketHidden)
307
- // Dialog requires at least one of `label` or `labelledBy` (string, not undefined).
308
- // Prefer the consumer-provided `label`; otherwise reference the internal `titleId`.
309
- }, label ? {
310
- label
311
- } : {
312
- labelledBy: titleId
313
- }, {
314
- testId: defaultTestId
315
- // eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop
316
- ,
317
- style: dialogStyle
318
- }), /*#__PURE__*/React.createElement(DialogScrollLock, {
319
- isOpen: true
320
- }), dialogPositionStyles &&
321
- /*#__PURE__*/
322
- // eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles
323
- React.createElement("style", null, dialogPositionStyles), /*#__PURE__*/React.createElement("div", {
324
- ref: contentRef,
325
- className: ax([topLayerStyles.content, !isFullScreen && topLayerStyles.borderRadius, !isFullScreen && fg('platform-dst-shape-theme-default') && topLayerStyles.borderRadiusT26, !isFullScreen && !shouldScrollInViewport && "_bolhzwhf", !isFullScreen && shouldScrollInViewport && "_1tke1kxc _c71lglyw"])
326
- }, /*#__PURE__*/React.createElement(ModalContext.Provider, {
327
- value: modalDialogContext
328
- }, /*#__PURE__*/React.createElement(ScrollContext.Provider, {
329
- value: shouldScrollInViewport
330
- }, children))));
331
- }
333
+ // Stable callback to avoid re-renders when focusLockAllowlist is not provided.
334
+ const allowListCallback = useCallback(element => allowlistElements(element, focusLockAllowlist), [focusLockAllowlist]);
335
+
336
+ // Prevent background scroll (top-layer path uses DialogScrollLock instead).
337
+ usePreventProgrammaticScroll();
338
+
339
+ // Register with the open layer observer. On the top-layer path the Dialog
340
+ // primitive does this itself, which is why it lives in this legacy-only
341
+ // component rather than the shared dispatcher.
342
+ useNotifyOpenLayerObserver({
343
+ type: 'modal',
344
+ // Always open — modal is conditionally rendered when visible.
345
+ isOpen: true,
346
+ // No-op: no current use case for programmatic close via OpenLayerObserver.
347
+ onClose: noop
348
+ });
332
349
  const modalDialogWithBlanket = /*#__PURE__*/React.createElement(Blanket, {
333
350
  isTinted: !isBlanketHidden,
334
351
  onBlanketClicked: onBlanketClicked,
@@ -389,5 +406,24 @@ const InternalModalWrapper = /*#__PURE__*/forwardRef((props, ref) => {
389
406
  }, /*#__PURE__*/React.createElement(ScrollLock, null), shouldScrollInViewport ? /*#__PURE__*/React.createElement(TouchScrollable, null, modalDialogWithBlanket) : modalDialogWithBlanket)))));
390
407
  });
391
408
 
409
+ // Choose the rendering implementation at the component boundary rather than
410
+ // gating hooks inside a single component. Each implementation owns its own
411
+ // hooks unconditionally, so a runtime feature-flag change swaps component types
412
+ // (a clean remount) instead of changing the hook order of a mounted component.
413
+ const InternalModalWrapper = /*#__PURE__*/forwardRef((props, ref) => {
414
+ if (fg('platform-dst-top-layer')) {
415
+ // TODO: the top-layer path does not forward the external `ref` (parity
416
+ // with the pre-refactor behavior, where the top-layer branch never
417
+ // consumed it). The public type still advertises `RefAttributes`, so a
418
+ // consumer ref silently no-ops here. Follow up by forwarding `ref` to a
419
+ // sensible element (for example the content `div`) once ref parity is
420
+ // intentionally desired.
421
+ return /*#__PURE__*/React.createElement(ModalWrapperTopLayer, props);
422
+ }
423
+ return /*#__PURE__*/React.createElement(ModalWrapperLegacy, _extends({
424
+ ref: ref
425
+ }, props));
426
+ });
427
+
392
428
  // eslint-disable-next-line @repo/internal/react/require-jsdoc
393
429
  export default InternalModalWrapper;