@atlaskit/modal-dialog 17.0.3 → 17.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @atlaskit/modal-dialog
2
2
 
3
+ ## 17.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`f026aadea067f`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/f026aadea067f) -
8
+ On the top-layer path (`platform-dst-top-layer`), `onClose` now runs in the same cases as on the
9
+ legacy path while the modal is open, and the modal stays open until you unmount it. Unlike legacy,
10
+ Escape and backdrop clicks during the exit animation do not call `onClose` again.
11
+ - Updated dependencies
12
+
13
+ ## 17.1.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [`62c3c0411e84b`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/62c3c0411e84b) -
18
+ Cleanup `feature_gate` `platform_dst-a11y_accessible-error-modal`. The accessible modal error
19
+ experience is now permanent. `ModalTrigger` and `FullScreenModalTrigger` support localized
20
+ `title`, `errorText`, and `errorCloseText` strings for the default error fallback. `ModalTrigger`
21
+ also supports a per-trigger `errorFallback`. `ModalHeader` supports a localized close-button
22
+ label.
23
+
3
24
  ## 17.0.3
4
25
 
5
26
  ### Patch Changes
@@ -0,0 +1,286 @@
1
+ /* eslint-disable @repo/internal/dom-events/no-unsafe-event-listeners -- `page.evaluate` code runs in the page and cannot import `bind-event-listener` */
2
+
3
+ import { expect, type Page, test } from '@af/integration-testing';
4
+
5
+ const featureFlag = 'platform-dst-top-layer';
6
+
7
+ // Dialog takes the Escape `keydown`, so the browser makes no close request and never closes
8
+ // the `<dialog>` itself, even after many refused Escapes (the close-watcher rule in Chrome and
9
+ // Firefox 149+).
10
+ test.describe('Modal dialog: top-layer Escape never closes the dialog natively', () => {
11
+ test('when onClose keeps the modal open', async ({ page }) => {
12
+ await openModal({ page, trigger: 'refusing-trigger' });
13
+ const focused = page.getByTestId('modal-body-button');
14
+ await focused.focus();
15
+ await recordDialogState({ page });
16
+
17
+ await mashEscape({ page, onSpacedPress: (i) => expectCloseCount({ page, count: i }) });
18
+
19
+ // A held key asks once: 5 + 20 + 1.
20
+ await expectCloseCount({ page, count: 26 });
21
+ await expectNeverClosed({ page, escapes: 45, focusedTestId: 'modal-body-button' });
22
+ await expect(focused).toBeFocused();
23
+ });
24
+
25
+ test('when shouldCloseOnEscapePress is false', async ({ page }) => {
26
+ await openModal({ page, trigger: 'escape-disabled-trigger' });
27
+ const focused = page.getByTestId('modal-body-button');
28
+ await focused.focus();
29
+ await recordDialogState({ page });
30
+
31
+ await mashEscape({ page, onSpacedPress: () => nextFrames({ page }) });
32
+
33
+ await expectNeverClosed({ page, escapes: 45, focusedTestId: 'modal-body-button' });
34
+ await expectCloseCount({ page, count: 0 });
35
+ await expect(focused).toBeFocused();
36
+ });
37
+
38
+ // A click on the title moves focus to the `<dialog>`, so the modal still takes the Escape.
39
+ test('after a click on the title', async ({ page }) => {
40
+ await openModal({ page, trigger: 'refusing-trigger' });
41
+ await page.getByText('Escape refused', { exact: true }).click();
42
+ await expect(page.getByTestId('modal')).toBeFocused();
43
+ await recordDialogState({ page });
44
+
45
+ await mashEscape({ page, onSpacedPress: (i) => expectCloseCount({ page, count: i }) });
46
+
47
+ await expectCloseCount({ page, count: 26 });
48
+ await expectNeverClosed({ page, escapes: 45, focusedTestId: 'modal' });
49
+ });
50
+
51
+ test('a child popup takes the first Escape, the modal is asked the rest', async ({ page }) => {
52
+ await openModal({ page, trigger: 'refusing-trigger' });
53
+ await page.getByTestId('child-popup-trigger').click();
54
+ await expect(page.getByTestId('child-popup-button')).toBeVisible();
55
+ await page.getByTestId('child-popup-button').focus();
56
+ await recordDialogState({ page });
57
+
58
+ await page.keyboard.press('Escape');
59
+ await expect(page.getByTestId('child-popup-button')).toBeHidden();
60
+ await expectCloseCount({ page, count: 0 });
61
+ // Not every engine restores focus to the trigger, so focus a button.
62
+ const focused = page.getByTestId('modal-body-button');
63
+ await focused.focus();
64
+
65
+ for (let i = 0; i < 20; i++) {
66
+ await page.keyboard.press('Escape');
67
+ }
68
+
69
+ await expectCloseCount({ page, count: 20 });
70
+ await expectNeverClosed({ page, escapes: 21, focusedTestId: 'modal-body-button', skip: 1 });
71
+ await expect(focused).toBeFocused();
72
+ });
73
+ });
74
+
75
+ // Focus on `<body>`: focus was last in the modal, so it takes the Escape and asks once per press,
76
+ // as legacy does. No close request, so the close-watcher rule (Chrome, Firefox 149+) never forces
77
+ // a close. Nothing runs between the Escapes: Playwright's `page.evaluate` and `expect` grant user
78
+ // activation, which would hide a forced close.
79
+ test('Modal dialog: top-layer Escape after the focused element is removed asks once per press', async ({
80
+ page,
81
+ }) => {
82
+ await openModal({ page, trigger: 'refusing-trigger' });
83
+ await page.getByTestId('removable-button').focus();
84
+ await page.keyboard.press('Enter');
85
+ await expect(page.getByTestId('removable-button')).toHaveCount(0);
86
+ await recordDialogState({ page });
87
+
88
+ for (let i = 0; i < 20; i++) {
89
+ await page.keyboard.press('Escape');
90
+ }
91
+
92
+ await expectCloseCount({ page, count: 20 });
93
+ await expectNeverClosed({ page, escapes: 20 });
94
+ expect(await page.evaluate(() => document.activeElement === document.body)).toBe(true);
95
+ });
96
+
97
+ // As in Forge: the modal is inside an open popup. Escape closes only the modal.
98
+ test('Modal dialog: top-layer Escape in a modal inside a popup closes only the modal', async ({
99
+ page,
100
+ }) => {
101
+ await page.visitExample<typeof import('../../examples/104-testing-escape-refused.tsx')>(
102
+ 'design-system',
103
+ 'modal-dialog',
104
+ 'testing-escape-refused',
105
+ { featureFlag },
106
+ );
107
+ await page.getByTestId('host-popup-trigger').click();
108
+ await page.getByTestId('hosted-modal-trigger').click();
109
+ await expect(page.getByTestId('hosted-modal')).toBeVisible();
110
+ await page.getByTestId('hosted-modal-button').focus();
111
+
112
+ await page.keyboard.press('Escape');
113
+
114
+ await expect(page.getByTestId('hosted-modal')).toHaveCount(0);
115
+ await expect(page.getByTestId('host-popup-content')).toBeVisible();
116
+ await expect(page.getByTestId('host-popup-close-count')).toHaveText('0');
117
+ });
118
+
119
+ // Unlike legacy: an Escape during the exit, with focus back on the trigger, does not call onClose
120
+ // again. The modal is already closing.
121
+ test('Modal dialog: top-layer Escape during the exit does not call onClose again', async ({
122
+ page,
123
+ }) => {
124
+ await openModal({ page, trigger: 'closing-trigger' });
125
+ await page.getByTestId('modal-body-button').focus();
126
+ // Hold the exit open, so the next Escape is during the exit.
127
+ await page.addStyleTag({
128
+ content: 'dialog, dialog::backdrop { animation-duration: 100s !important; }',
129
+ });
130
+
131
+ await page.keyboard.press('Escape');
132
+ await expect(page.getByTestId('closing-trigger')).toBeFocused();
133
+ await expect(page.getByTestId('modal')).toHaveCount(1);
134
+ await page.keyboard.press('Escape');
135
+
136
+ await expectCloseCount({ page, count: 1 });
137
+ });
138
+
139
+ // Unlike legacy: an Escape during a nested modal's exit, with focus back on its trigger in the
140
+ // outer modal, asks the outer modal. The nested modal is not asked again.
141
+ test('Modal dialog: top-layer Escape during a nested modal exit asks the outer modal', async ({
142
+ page,
143
+ }) => {
144
+ await openModal({ page, trigger: 'refusing-trigger' });
145
+ await page.getByTestId('nested-modal-trigger').click();
146
+ await expect(page.getByTestId('nested-modal')).toBeVisible();
147
+ await page.getByTestId('nested-modal-button').focus();
148
+ // Hold the exit open, so the next Escape is during the exit.
149
+ await page.addStyleTag({
150
+ content: 'dialog, dialog::backdrop { animation-duration: 100s !important; }',
151
+ });
152
+
153
+ await page.keyboard.press('Escape');
154
+ await expect(page.getByTestId('nested-modal-trigger')).toBeFocused();
155
+ await expect(page.getByTestId('nested-modal')).toHaveCount(1);
156
+ await page.keyboard.press('Escape');
157
+
158
+ await expectCloseCount({ page, count: 1 });
159
+ await expect(page.getByTestId('nested-close-count')).toHaveText('1');
160
+ // The outer modal refuses, so it stays open.
161
+ await expect(page.getByTestId('modal')).toHaveAttribute('open', '');
162
+ });
163
+
164
+ async function openModal({ page, trigger }: { page: Page; trigger: string }) {
165
+ await page.visitExample<typeof import('../../examples/104-testing-escape-refused.tsx')>(
166
+ 'design-system',
167
+ 'modal-dialog',
168
+ 'testing-escape-refused',
169
+ { featureFlag },
170
+ );
171
+ await page.getByTestId(trigger).click();
172
+ await expect(page.getByTestId('modal')).toBeVisible();
173
+ }
174
+
175
+ async function expectCloseCount({ page, count }: { page: Page; count: number }) {
176
+ await expect(page.getByTestId('close-count')).toHaveText(String(count));
177
+ }
178
+
179
+ async function nextFrames({ page }: { page: Page }) {
180
+ await page.evaluate(
181
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
182
+ );
183
+ }
184
+
185
+ // 5 spaced presses, then 20 back to back, then 20 held (repeated `keydown`, one `keyup`).
186
+ async function mashEscape({
187
+ page,
188
+ onSpacedPress,
189
+ }: {
190
+ page: Page;
191
+ onSpacedPress: (pressCount: number) => Promise<void>;
192
+ }) {
193
+ for (let i = 1; i <= 5; i++) {
194
+ await page.keyboard.press('Escape');
195
+ await onSpacedPress(i);
196
+ }
197
+ for (let i = 0; i < 20; i++) {
198
+ await page.keyboard.press('Escape');
199
+ }
200
+ for (let i = 0; i < 20; i++) {
201
+ await page.keyboard.down('Escape');
202
+ }
203
+ await page.keyboard.up('Escape');
204
+ }
205
+
206
+ type TDialogLog = {
207
+ escapes: number;
208
+ // The Escape count when each `cancel` fired.
209
+ cancelsAtEscape: number[];
210
+ nativeCloses: number;
211
+ closedToggles: number;
212
+ closedSamples: number;
213
+ focusedTestIds: (string | null)[];
214
+ };
215
+
216
+ // Records native close events, and samples `dialog.open` on every Escape and every frame.
217
+ async function recordDialogState({ page }: { page: Page }) {
218
+ await page.evaluate(() => {
219
+ const dialog = document.querySelector('dialog');
220
+ if (!dialog) {
221
+ throw new Error('No dialog');
222
+ }
223
+ const log: TDialogLog = {
224
+ escapes: 0,
225
+ cancelsAtEscape: [],
226
+ nativeCloses: 0,
227
+ closedToggles: 0,
228
+ closedSamples: 0,
229
+ focusedTestIds: [],
230
+ };
231
+ const sample = () => {
232
+ if (!dialog.open) {
233
+ log.closedSamples++;
234
+ }
235
+ };
236
+ dialog.addEventListener('cancel', () => log.cancelsAtEscape.push(log.escapes));
237
+ dialog.addEventListener('close', () => log.nativeCloses++);
238
+ dialog.addEventListener('toggle', (event) => {
239
+ if ((event as ToggleEvent).newState === 'closed') {
240
+ log.closedToggles++;
241
+ }
242
+ });
243
+ window.addEventListener(
244
+ 'keydown',
245
+ (event) => {
246
+ if (event.key === 'Escape') {
247
+ log.escapes++;
248
+ log.focusedTestIds.push(document.activeElement?.getAttribute('data-testid') ?? null);
249
+ sample();
250
+ }
251
+ },
252
+ true,
253
+ );
254
+ window.addEventListener('keyup', sample);
255
+ const onFrame = () => {
256
+ sample();
257
+ requestAnimationFrame(onFrame);
258
+ };
259
+ requestAnimationFrame(onFrame);
260
+ (window as unknown as { dialogLog: TDialogLog }).dialogLog = log;
261
+ });
262
+ }
263
+
264
+ async function expectNeverClosed({
265
+ page,
266
+ escapes,
267
+ focusedTestId,
268
+ skip = 0,
269
+ }: {
270
+ page: Page;
271
+ escapes: number;
272
+ focusedTestId?: string;
273
+ skip?: number;
274
+ }) {
275
+ const log = await page.evaluate(() => (window as unknown as { dialogLog: TDialogLog }).dialogLog);
276
+ expect(log.escapes).toBe(escapes);
277
+ // No close request after `skip`: ModalDialog took every Escape.
278
+ expect(log.cancelsAtEscape.filter((escape) => escape > skip)).toEqual([]);
279
+ expect(log.nativeCloses).toBe(0);
280
+ expect(log.closedToggles).toBe(0);
281
+ expect(log.closedSamples).toBe(0);
282
+ if (focusedTestId) {
283
+ expect(log.focusedTestIds.slice(skip)).toEqual(Array(escapes - skip).fill(focusedTestId));
284
+ }
285
+ await expect(page.getByTestId('modal')).toHaveAttribute('open', '');
286
+ }
@@ -107,6 +107,7 @@ test.describe('Modal dialog: top-layer focus contract', () => {
107
107
  },
108
108
  });
109
109
  });
110
+
110
111
  test('focus movement: Tab traverses each control before wrapping', async ({ page }) => {
111
112
  await page.visitExample<typeof import('../../examples/98-testing-initial-focus-matrix.tsx')>(
112
113
  'design-system',
@@ -13,7 +13,6 @@ var React = _react;
13
13
  var _runtime = require("@compiled/react/runtime");
14
14
  var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends"));
15
15
  var _typeof2 = _interopRequireDefault(require("@babel/runtime/helpers/typeof"));
16
- var _bindEventListener = require("bind-event-listener");
17
16
  var _reactFocusLock = _interopRequireDefault(require("react-focus-lock"));
18
17
  var _reactScrolllock = _interopRequireWildcard(require("react-scrolllock"));
19
18
  var _usePlatformLeafEventHandler = require("@atlaskit/analytics-next/usePlatformLeafEventHandler");
@@ -95,7 +94,7 @@ function useModalCloseHandler(providedOnClose) {
95
94
  action: 'closed',
96
95
  componentName: 'modalDialog',
97
96
  packageName: "@atlaskit/modal-dialog",
98
- packageVersion: "17.0.2"
97
+ packageVersion: "17.1.0"
99
98
  });
100
99
  }
101
100
  function getScrollMode(_ref) {
@@ -153,7 +152,7 @@ function getTopLayerSurfaceHeight(input) {
153
152
  }
154
153
  return input;
155
154
  }
156
- function getDialogDismissedBy(_ref2) {
155
+ function getDismissedBy(_ref2) {
157
156
  var shouldCloseOnEscapePress = _ref2.shouldCloseOnEscapePress,
158
157
  shouldCloseOnOverlayClick = _ref2.shouldCloseOnOverlayClick;
159
158
  if (shouldCloseOnEscapePress && shouldCloseOnOverlayClick) {
@@ -162,6 +161,10 @@ function getDialogDismissedBy(_ref2) {
162
161
  if (shouldCloseOnEscapePress) {
163
162
  return 'escape';
164
163
  }
164
+ // Backdrop clicks only: `Dialog` asks on both, and `onDialogAsk` ignores Escape.
165
+ if (shouldCloseOnOverlayClick) {
166
+ return 'escape-and-outside-click';
167
+ }
165
168
  return 'none';
166
169
  }
167
170
 
@@ -173,7 +176,11 @@ function getDialogDismissedBy(_ref2) {
173
176
  *
174
177
  * Key decisions:
175
178
  * - Animation: CSS transitions via @starting-style / allow-discrete.
176
- * - Close gating: Dialog only forwards allowed reasons.
179
+ * - Close gating: matches legacy while open. Dialog asks on Escape and backdrop
180
+ * clicks (the consumer may refuse any number of times). During the exit they
181
+ * do not call `onClose` again.
182
+ * - A close that Dialog did not ask about (e.g. `<form method="dialog">`): not
183
+ * supported. Dialog shows the modal again, and `onClose` does not run.
177
184
  * - onClose event param: undefined - consumers should use close reason.
178
185
  * - Focus restoration: native <dialog> behavior replaces react-focus-lock's
179
186
  * returnFocus (see accessibility-criteria.md).
@@ -241,38 +248,39 @@ function ModalWrapperTopLayer(props) {
241
248
  };
242
249
  }, [defaultTestId, titleId, onCloseHandler, hasProvidedOnClose, isFullScreen]);
243
250
 
244
- // Dialog has already applied the close behavior configured by `dismissedBy`.
245
- // Pass a synthetic event to satisfy the KeyboardOrMouseEvent contract.
251
+ // Pass a synthetic event to satisfy the KeyboardOrMouseEvent contract. Dialog calls this
252
+ // for an allowed Escape or backdrop click.
246
253
  var onDialogClose = (0, _react.useCallback)(function (_ref3) {
247
254
  var reason = _ref3.reason;
248
255
  onCloseHandler((0, _createCloseEvent.createCloseEvent)({
249
256
  reason: reason
250
257
  }));
251
258
  }, [onCloseHandler]);
252
- var dismissedBy = getDialogDismissedBy({
253
- shouldCloseOnEscapePress: shouldCloseOnEscapePress,
254
- shouldCloseOnOverlayClick: shouldCloseOnOverlayClick
255
- });
256
- var shouldShimCloseOnOverlayClick = shouldCloseOnOverlayClick && !shouldCloseOnEscapePress;
257
259
 
258
- // Required until we fully remove `shouldCloseOnEscapePress`.
259
- // Dialog's `dismissedBy` options do not support outside click without Escape (because it is a bad pattern)
260
- (0, _react.useEffect)(function () {
261
- var dialog = dialogRef.current;
262
- if (!dialog || !shouldShimCloseOnOverlayClick) {
260
+ /**
261
+ * Dialog asks on an allowed Escape or backdrop click. Ignoring an Escape ask refuses it:
262
+ * Dialog still cancels the Escape `keydown`, so nothing else closes.
263
+ */
264
+ var onDialogAsk = (0, _react.useCallback)(function (_ref4) {
265
+ var reason = _ref4.reason;
266
+ if (reason === 'escape' && !shouldCloseOnEscapePress) {
263
267
  return;
264
268
  }
265
- return (0, _bindEventListener.bind)(dialog, {
266
- type: 'click',
267
- listener: function listener(event) {
268
- if (event.target === event.currentTarget) {
269
- onDialogClose({
270
- reason: 'overlay-click'
271
- });
272
- }
273
- }
269
+ onDialogClose({
270
+ reason: reason
274
271
  });
275
- }, [onDialogClose, shouldShimCloseOnOverlayClick]);
272
+ }, [onDialogClose, shouldCloseOnEscapePress]);
273
+
274
+ // Unlike legacy, Escape and backdrop clicks during the exit do not call `onClose` again.
275
+ // See `@atlaskit/top-layer` notes: `decisions/dialog-close-flow.md`.
276
+
277
+ // Dialog could not reopen after a close it did not ask about (its parent layer is closed), so
278
+ // the `<dialog>` is already gone. End the exit.
279
+ (0, _react.useEffect)(function () {
280
+ if (isExiting && !dialogRef.current) {
281
+ onExitFinish === null || onExitFinish === void 0 || onExitFinish();
282
+ }
283
+ }, [isExiting, onExitFinish]);
276
284
 
277
285
  // ExitingPersistence: isExiting → isOpen={false} → Dialog exit animation →
278
286
  // onExitFinish → onCloseComplete + unmount.
@@ -321,8 +329,11 @@ function ModalWrapperTopLayer(props) {
321
329
  });
322
330
  return /*#__PURE__*/React.createElement(_dialogContent.Dialog, (0, _extends2.default)({
323
331
  ref: dialogRef,
324
- onClose: onDialogClose,
325
- dismissedBy: dismissedBy,
332
+ onClose: onDialogAsk,
333
+ dismissedBy: getDismissedBy({
334
+ shouldCloseOnEscapePress: shouldCloseOnEscapePress,
335
+ shouldCloseOnOverlayClick: shouldCloseOnOverlayClick
336
+ }),
326
337
  onExitFinish: handleDialogExitFinish,
327
338
  shouldAnimate: !isFullScreen,
328
339
  isOpen: !isExiting,
@@ -30,7 +30,8 @@ var ModalHeader = function ModalHeader(props) {
30
30
  var children = props.children,
31
31
  userDefinedTestId = props.testId,
32
32
  _props$hasCloseButton = props.hasCloseButton,
33
- hasCloseButton = _props$hasCloseButton === void 0 ? false : _props$hasCloseButton;
33
+ hasCloseButton = _props$hasCloseButton === void 0 ? false : _props$hasCloseButton,
34
+ closeButtonLabel = props.closeButtonLabel;
34
35
  var _useModal = (0, _hooks.useModal)(),
35
36
  hasProvidedOnClose = _useModal.hasProvidedOnClose,
36
37
  onClose = _useModal.onClose,
@@ -49,6 +50,7 @@ var ModalHeader = function ModalHeader(props) {
49
50
  }, /*#__PURE__*/_react.default.createElement(_flex.Flex, {
50
51
  justifyContent: "end"
51
52
  }, /*#__PURE__*/_react.default.createElement(_closeButton.CloseButton, {
53
+ label: closeButtonLabel,
52
54
  onClick: onClose,
53
55
  testId: modalTestId
54
56
  })), /*#__PURE__*/_react.default.createElement(_flex.Flex, {
@@ -4,7 +4,6 @@ import "./modal-wrapper.compiled.css";
4
4
  import * as React from 'react';
5
5
  import { ax, ix } from "@compiled/react/runtime";
6
6
  import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react';
7
- import { bind } from 'bind-event-listener';
8
7
  import FocusLock from 'react-focus-lock';
9
8
  import ScrollLock, { TouchScrollable } from 'react-scrolllock';
10
9
  import { usePlatformLeafEventHandler } from '@atlaskit/analytics-next/usePlatformLeafEventHandler';
@@ -85,7 +84,7 @@ function useModalCloseHandler(providedOnClose) {
85
84
  action: 'closed',
86
85
  componentName: 'modalDialog',
87
86
  packageName: "@atlaskit/modal-dialog",
88
- packageVersion: "17.0.2"
87
+ packageVersion: "17.1.0"
89
88
  });
90
89
  }
91
90
  function getScrollMode({
@@ -144,7 +143,7 @@ function getTopLayerSurfaceHeight(input) {
144
143
  }
145
144
  return input;
146
145
  }
147
- function getDialogDismissedBy({
146
+ function getDismissedBy({
148
147
  shouldCloseOnEscapePress,
149
148
  shouldCloseOnOverlayClick
150
149
  }) {
@@ -154,6 +153,10 @@ function getDialogDismissedBy({
154
153
  if (shouldCloseOnEscapePress) {
155
154
  return 'escape';
156
155
  }
156
+ // Backdrop clicks only: `Dialog` asks on both, and `onDialogAsk` ignores Escape.
157
+ if (shouldCloseOnOverlayClick) {
158
+ return 'escape-and-outside-click';
159
+ }
157
160
  return 'none';
158
161
  }
159
162
 
@@ -165,7 +168,11 @@ function getDialogDismissedBy({
165
168
  *
166
169
  * Key decisions:
167
170
  * - Animation: CSS transitions via @starting-style / allow-discrete.
168
- * - Close gating: Dialog only forwards allowed reasons.
171
+ * - Close gating: matches legacy while open. Dialog asks on Escape and backdrop
172
+ * clicks (the consumer may refuse any number of times). During the exit they
173
+ * do not call `onClose` again.
174
+ * - A close that Dialog did not ask about (e.g. `<form method="dialog">`): not
175
+ * supported. Dialog shows the modal again, and `onClose` does not run.
169
176
  * - onClose event param: undefined - consumers should use close reason.
170
177
  * - Focus restoration: native <dialog> behavior replaces react-focus-lock's
171
178
  * returnFocus (see accessibility-criteria.md).
@@ -227,8 +234,8 @@ function ModalWrapperTopLayer(props) {
227
234
  isFullScreen: isFullScreen !== null && isFullScreen !== void 0 ? isFullScreen : false
228
235
  }), [defaultTestId, titleId, onCloseHandler, hasProvidedOnClose, isFullScreen]);
229
236
 
230
- // Dialog has already applied the close behavior configured by `dismissedBy`.
231
- // Pass a synthetic event to satisfy the KeyboardOrMouseEvent contract.
237
+ // Pass a synthetic event to satisfy the KeyboardOrMouseEvent contract. Dialog calls this
238
+ // for an allowed Escape or backdrop click.
232
239
  const onDialogClose = useCallback(({
233
240
  reason
234
241
  }) => {
@@ -236,30 +243,32 @@ function ModalWrapperTopLayer(props) {
236
243
  reason
237
244
  }));
238
245
  }, [onCloseHandler]);
239
- const dismissedBy = getDialogDismissedBy({
240
- shouldCloseOnEscapePress,
241
- shouldCloseOnOverlayClick
242
- });
243
- const shouldShimCloseOnOverlayClick = shouldCloseOnOverlayClick && !shouldCloseOnEscapePress;
244
246
 
245
- // Required until we fully remove `shouldCloseOnEscapePress`.
246
- // Dialog's `dismissedBy` options do not support outside click without Escape (because it is a bad pattern)
247
- useEffect(() => {
248
- const dialog = dialogRef.current;
249
- if (!dialog || !shouldShimCloseOnOverlayClick) {
247
+ /**
248
+ * Dialog asks on an allowed Escape or backdrop click. Ignoring an Escape ask refuses it:
249
+ * Dialog still cancels the Escape `keydown`, so nothing else closes.
250
+ */
251
+ const onDialogAsk = useCallback(({
252
+ reason
253
+ }) => {
254
+ if (reason === 'escape' && !shouldCloseOnEscapePress) {
250
255
  return;
251
256
  }
252
- return bind(dialog, {
253
- type: 'click',
254
- listener(event) {
255
- if (event.target === event.currentTarget) {
256
- onDialogClose({
257
- reason: 'overlay-click'
258
- });
259
- }
260
- }
257
+ onDialogClose({
258
+ reason
261
259
  });
262
- }, [onDialogClose, shouldShimCloseOnOverlayClick]);
260
+ }, [onDialogClose, shouldCloseOnEscapePress]);
261
+
262
+ // Unlike legacy, Escape and backdrop clicks during the exit do not call `onClose` again.
263
+ // See `@atlaskit/top-layer` notes: `decisions/dialog-close-flow.md`.
264
+
265
+ // Dialog could not reopen after a close it did not ask about (its parent layer is closed), so
266
+ // the `<dialog>` is already gone. End the exit.
267
+ useEffect(() => {
268
+ if (isExiting && !dialogRef.current) {
269
+ onExitFinish === null || onExitFinish === void 0 ? void 0 : onExitFinish();
270
+ }
271
+ }, [isExiting, onExitFinish]);
263
272
 
264
273
  // ExitingPersistence: isExiting → isOpen={false} → Dialog exit animation →
265
274
  // onExitFinish → onCloseComplete + unmount.
@@ -308,8 +317,11 @@ function ModalWrapperTopLayer(props) {
308
317
  });
309
318
  return /*#__PURE__*/React.createElement(Dialog, _extends({
310
319
  ref: dialogRef,
311
- onClose: onDialogClose,
312
- dismissedBy: dismissedBy,
320
+ onClose: onDialogAsk,
321
+ dismissedBy: getDismissedBy({
322
+ shouldCloseOnEscapePress,
323
+ shouldCloseOnOverlayClick
324
+ }),
313
325
  onExitFinish: handleDialogExitFinish,
314
326
  shouldAnimate: !isFullScreen,
315
327
  isOpen: !isExiting,
@@ -23,7 +23,8 @@ const ModalHeader = props => {
23
23
  const {
24
24
  children,
25
25
  testId: userDefinedTestId,
26
- hasCloseButton = false
26
+ hasCloseButton = false,
27
+ closeButtonLabel
27
28
  } = props;
28
29
  const {
29
30
  hasProvidedOnClose,
@@ -44,6 +45,7 @@ const ModalHeader = props => {
44
45
  }, /*#__PURE__*/React.createElement(Flex, {
45
46
  justifyContent: "end"
46
47
  }, /*#__PURE__*/React.createElement(CloseButton, {
48
+ label: closeButtonLabel,
47
49
  onClick: onClose,
48
50
  testId: modalTestId
49
51
  })), /*#__PURE__*/React.createElement(Flex, {
@@ -5,7 +5,6 @@ import "./modal-wrapper.compiled.css";
5
5
  import * as React from 'react';
6
6
  import { ax, ix } from "@compiled/react/runtime";
7
7
  import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react';
8
- import { bind } from 'bind-event-listener';
9
8
  import FocusLock from 'react-focus-lock';
10
9
  import ScrollLock, { TouchScrollable } from 'react-scrolllock';
11
10
  import { usePlatformLeafEventHandler } from '@atlaskit/analytics-next/usePlatformLeafEventHandler';
@@ -86,7 +85,7 @@ function useModalCloseHandler(providedOnClose) {
86
85
  action: 'closed',
87
86
  componentName: 'modalDialog',
88
87
  packageName: "@atlaskit/modal-dialog",
89
- packageVersion: "17.0.2"
88
+ packageVersion: "17.1.0"
90
89
  });
91
90
  }
92
91
  function getScrollMode(_ref) {
@@ -144,7 +143,7 @@ function getTopLayerSurfaceHeight(input) {
144
143
  }
145
144
  return input;
146
145
  }
147
- function getDialogDismissedBy(_ref2) {
146
+ function getDismissedBy(_ref2) {
148
147
  var shouldCloseOnEscapePress = _ref2.shouldCloseOnEscapePress,
149
148
  shouldCloseOnOverlayClick = _ref2.shouldCloseOnOverlayClick;
150
149
  if (shouldCloseOnEscapePress && shouldCloseOnOverlayClick) {
@@ -153,6 +152,10 @@ function getDialogDismissedBy(_ref2) {
153
152
  if (shouldCloseOnEscapePress) {
154
153
  return 'escape';
155
154
  }
155
+ // Backdrop clicks only: `Dialog` asks on both, and `onDialogAsk` ignores Escape.
156
+ if (shouldCloseOnOverlayClick) {
157
+ return 'escape-and-outside-click';
158
+ }
156
159
  return 'none';
157
160
  }
158
161
 
@@ -164,7 +167,11 @@ function getDialogDismissedBy(_ref2) {
164
167
  *
165
168
  * Key decisions:
166
169
  * - Animation: CSS transitions via @starting-style / allow-discrete.
167
- * - Close gating: Dialog only forwards allowed reasons.
170
+ * - Close gating: matches legacy while open. Dialog asks on Escape and backdrop
171
+ * clicks (the consumer may refuse any number of times). During the exit they
172
+ * do not call `onClose` again.
173
+ * - A close that Dialog did not ask about (e.g. `<form method="dialog">`): not
174
+ * supported. Dialog shows the modal again, and `onClose` does not run.
168
175
  * - onClose event param: undefined - consumers should use close reason.
169
176
  * - Focus restoration: native <dialog> behavior replaces react-focus-lock's
170
177
  * returnFocus (see accessibility-criteria.md).
@@ -232,38 +239,39 @@ function ModalWrapperTopLayer(props) {
232
239
  };
233
240
  }, [defaultTestId, titleId, onCloseHandler, hasProvidedOnClose, isFullScreen]);
234
241
 
235
- // Dialog has already applied the close behavior configured by `dismissedBy`.
236
- // Pass a synthetic event to satisfy the KeyboardOrMouseEvent contract.
242
+ // Pass a synthetic event to satisfy the KeyboardOrMouseEvent contract. Dialog calls this
243
+ // for an allowed Escape or backdrop click.
237
244
  var onDialogClose = useCallback(function (_ref3) {
238
245
  var reason = _ref3.reason;
239
246
  onCloseHandler(createCloseEvent({
240
247
  reason: reason
241
248
  }));
242
249
  }, [onCloseHandler]);
243
- var dismissedBy = getDialogDismissedBy({
244
- shouldCloseOnEscapePress: shouldCloseOnEscapePress,
245
- shouldCloseOnOverlayClick: shouldCloseOnOverlayClick
246
- });
247
- var shouldShimCloseOnOverlayClick = shouldCloseOnOverlayClick && !shouldCloseOnEscapePress;
248
250
 
249
- // Required until we fully remove `shouldCloseOnEscapePress`.
250
- // Dialog's `dismissedBy` options do not support outside click without Escape (because it is a bad pattern)
251
- useEffect(function () {
252
- var dialog = dialogRef.current;
253
- if (!dialog || !shouldShimCloseOnOverlayClick) {
251
+ /**
252
+ * Dialog asks on an allowed Escape or backdrop click. Ignoring an Escape ask refuses it:
253
+ * Dialog still cancels the Escape `keydown`, so nothing else closes.
254
+ */
255
+ var onDialogAsk = useCallback(function (_ref4) {
256
+ var reason = _ref4.reason;
257
+ if (reason === 'escape' && !shouldCloseOnEscapePress) {
254
258
  return;
255
259
  }
256
- return bind(dialog, {
257
- type: 'click',
258
- listener: function listener(event) {
259
- if (event.target === event.currentTarget) {
260
- onDialogClose({
261
- reason: 'overlay-click'
262
- });
263
- }
264
- }
260
+ onDialogClose({
261
+ reason: reason
265
262
  });
266
- }, [onDialogClose, shouldShimCloseOnOverlayClick]);
263
+ }, [onDialogClose, shouldCloseOnEscapePress]);
264
+
265
+ // Unlike legacy, Escape and backdrop clicks during the exit do not call `onClose` again.
266
+ // See `@atlaskit/top-layer` notes: `decisions/dialog-close-flow.md`.
267
+
268
+ // Dialog could not reopen after a close it did not ask about (its parent layer is closed), so
269
+ // the `<dialog>` is already gone. End the exit.
270
+ useEffect(function () {
271
+ if (isExiting && !dialogRef.current) {
272
+ onExitFinish === null || onExitFinish === void 0 || onExitFinish();
273
+ }
274
+ }, [isExiting, onExitFinish]);
267
275
 
268
276
  // ExitingPersistence: isExiting → isOpen={false} → Dialog exit animation →
269
277
  // onExitFinish → onCloseComplete + unmount.
@@ -312,8 +320,11 @@ function ModalWrapperTopLayer(props) {
312
320
  });
313
321
  return /*#__PURE__*/React.createElement(Dialog, _extends({
314
322
  ref: dialogRef,
315
- onClose: onDialogClose,
316
- dismissedBy: dismissedBy,
323
+ onClose: onDialogAsk,
324
+ dismissedBy: getDismissedBy({
325
+ shouldCloseOnEscapePress: shouldCloseOnEscapePress,
326
+ shouldCloseOnOverlayClick: shouldCloseOnOverlayClick
327
+ }),
317
328
  onExitFinish: handleDialogExitFinish,
318
329
  shouldAnimate: !isFullScreen,
319
330
  isOpen: !isExiting,
@@ -23,7 +23,8 @@ var ModalHeader = function ModalHeader(props) {
23
23
  var children = props.children,
24
24
  userDefinedTestId = props.testId,
25
25
  _props$hasCloseButton = props.hasCloseButton,
26
- hasCloseButton = _props$hasCloseButton === void 0 ? false : _props$hasCloseButton;
26
+ hasCloseButton = _props$hasCloseButton === void 0 ? false : _props$hasCloseButton,
27
+ closeButtonLabel = props.closeButtonLabel;
27
28
  var _useModal = useModal(),
28
29
  hasProvidedOnClose = _useModal.hasProvidedOnClose,
29
30
  onClose = _useModal.onClose,
@@ -42,6 +43,7 @@ var ModalHeader = function ModalHeader(props) {
42
43
  }, /*#__PURE__*/React.createElement(Flex, {
43
44
  justifyContent: "end"
44
45
  }, /*#__PURE__*/React.createElement(CloseButton, {
46
+ label: closeButtonLabel,
45
47
  onClick: onClose,
46
48
  testId: modalTestId
47
49
  })), /*#__PURE__*/React.createElement(Flex, {
@@ -13,6 +13,10 @@ export interface ModalHeaderProps {
13
13
  * @default false
14
14
  */
15
15
  hasCloseButton?: boolean;
16
+ /**
17
+ * The accessible name for the close button when `hasCloseButton` is true.
18
+ */
19
+ closeButtonLabel?: string;
16
20
  /**
17
21
  * A `testId` prop is provided for specified elements,
18
22
  * which is a unique string that appears as a data attribute `data-testid` in the rendered code,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/modal-dialog",
3
- "version": "17.0.3",
3
+ "version": "17.1.1",
4
4
  "description": "A modal dialog displays content that requires user interaction, in a layer above the page.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/"
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@atlaskit/analytics-next": "^12.6.0",
41
41
  "@atlaskit/blanket": "^16.4.0",
42
- "@atlaskit/button": "^25.4.0",
42
+ "@atlaskit/button": "^25.5.0",
43
43
  "@atlaskit/css": "^1.1.0",
44
44
  "@atlaskit/ds-lib": "^8.0.0",
45
45
  "@atlaskit/icon": "^38.0.0",
@@ -48,11 +48,11 @@
48
48
  "@atlaskit/platform-feature-flags": "^2.2.0",
49
49
  "@atlaskit/portal": "^7.0.0",
50
50
  "@atlaskit/pragmatic-drag-and-drop": "^4.0.0",
51
- "@atlaskit/primitives": "^22.5.0",
51
+ "@atlaskit/primitives": "^22.6.0",
52
52
  "@atlaskit/react-compiler-gating": "^0.2.0",
53
53
  "@atlaskit/theme": "^28.2.0",
54
- "@atlaskit/tokens": "^20.4.0",
55
- "@atlaskit/top-layer": "^6.2.0",
54
+ "@atlaskit/tokens": "^20.6.0",
55
+ "@atlaskit/top-layer": "^6.4.0",
56
56
  "@babel/runtime": "^7.0.0",
57
57
  "@compiled/react": "^1.0.2",
58
58
  "bind-event-listener": "^3.0.0",
@@ -80,7 +80,7 @@
80
80
  "@atlaskit/form": "^18.2.0",
81
81
  "@atlaskit/heading": "^8.0.0",
82
82
  "@atlaskit/link": "^5.1.0",
83
- "@atlaskit/popup": "^9.0.0",
83
+ "@atlaskit/popup": "^10.0.0",
84
84
  "@atlaskit/radio": "^10.2.0",
85
85
  "@atlaskit/section-message": "^10.2.0",
86
86
  "@atlaskit/select": "^23.1.0",
@@ -90,7 +90,7 @@
90
90
  "@atlassian/feature-flags-test-utils": "^1.3.0",
91
91
  "@atlassian/ssr-tests": "workspace:^",
92
92
  "@atlassian/structured-docs-types": "workspace:^",
93
- "@atlassian/testing-library": "^0.11.0",
93
+ "@atlassian/testing-library": "^0.12.0",
94
94
  "@atlassian/workbench": "workspace:*",
95
95
  "@testing-library/dom": "^10.1.0",
96
96
  "@types/raf-schd": "^4.0.1",