@atlaskit/top-layer 6.0.0 → 6.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +23 -25
  3. package/dist/cjs/dialog/create-close-event.js +2 -1
  4. package/dist/cjs/internal/use-animated-visibility.js +24 -3
  5. package/dist/cjs/internal/use-focus-wrap.js +1 -1
  6. package/dist/cjs/internal/use-initial-focus.js +1 -1
  7. package/dist/cjs/placement-map/index.js +1 -1
  8. package/dist/cjs/popover/create-close-event.js +2 -1
  9. package/dist/cjs/testing/flush-popover-exit.js +94 -0
  10. package/dist/cjs/use-arrow-navigation/use-arrow-navigation.js +2 -1
  11. package/dist/es2019/dialog/create-close-event.js +2 -1
  12. package/dist/es2019/internal/use-animated-visibility.js +25 -3
  13. package/dist/es2019/internal/use-focus-wrap.js +1 -1
  14. package/dist/es2019/internal/use-initial-focus.js +1 -1
  15. package/dist/es2019/placement-map/index.js +1 -1
  16. package/dist/es2019/popover/create-close-event.js +2 -1
  17. package/dist/es2019/testing/flush-popover-exit.js +57 -0
  18. package/dist/es2019/use-arrow-navigation/use-arrow-navigation.js +2 -1
  19. package/dist/esm/dialog/create-close-event.js +2 -1
  20. package/dist/esm/internal/use-animated-visibility.js +24 -3
  21. package/dist/esm/internal/use-focus-wrap.js +1 -1
  22. package/dist/esm/internal/use-initial-focus.js +1 -1
  23. package/dist/esm/placement-map/index.js +1 -1
  24. package/dist/esm/popover/create-close-event.js +2 -1
  25. package/dist/esm/testing/flush-popover-exit.js +87 -0
  26. package/dist/esm/use-arrow-navigation/use-arrow-navigation.js +2 -1
  27. package/dist/types/dialog/create-close-event.d.ts +2 -1
  28. package/dist/types/internal/use-animated-visibility.d.ts +1 -0
  29. package/dist/types/placement-map/index.d.ts +1 -1
  30. package/dist/types/popover/create-close-event.d.ts +2 -1
  31. package/dist/types/testing/flush-popover-exit.d.ts +22 -0
  32. package/dist/types/use-arrow-navigation/use-arrow-navigation.d.ts +2 -1
  33. package/package.json +5 -5
  34. package/testing/flush-popover-exit/package.json +10 -0
  35. package/dist/cjs/internal/should-focus-into-popover.js +0 -19
  36. package/dist/es2019/internal/should-focus-into-popover.js +0 -14
  37. package/dist/esm/internal/should-focus-into-popover.js +0 -13
  38. package/dist/types/internal/should-focus-into-popover.d.ts +0 -8
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @atlaskit/top-layer
2
2
 
3
+ ## 6.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`1849f66e0bf17`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/1849f66e0bf17) -
8
+ Remove an unused internal focus helper and fix stale README examples and import paths.
9
+ - Updated dependencies
10
+
11
+ ## 6.1.0
12
+
13
+ ### Minor Changes
14
+
15
+ - [`457c77d8f6992`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/457c77d8f6992) -
16
+ Adds a `@atlaskit/top-layer/testing/flush-popover-exit` entrypoint exporting `flushPopoverExit()`,
17
+ a Jest helper for jsdom. Top-layer settles a `Popover` exit in a native promise microtask after
18
+ the closed `toggle`, which Jest fake timers do not drain, so a synchronous
19
+ `act(() => jest.runOnlyPendingTimers())` returns before `onExitFinish` fires and the host
20
+ unmounts. `await flushPopoverExit()` runs the pending fake timers and yields once inside an async
21
+ `act` (or waits one real task when fake timers are off) so tests can assert the closed state.
22
+ Requires React 18.3 or newer. Browser behaviour is unchanged.
23
+
24
+ ### Patch Changes
25
+
26
+ - [`457c77d8f6992`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/457c77d8f6992) -
27
+ `Popover` and `Dialog` no longer keep their exit styles when they reopen straight after a browser
28
+ dismiss, or when React StrictMode runs effects twice.
29
+ - Updated dependencies
30
+
3
31
  ## 6.0.0
4
32
 
5
33
  ### Major Changes
package/README.md CHANGED
@@ -12,14 +12,15 @@ placement or size — compose it with the `useAnchoredPopover` hook when you nee
12
12
  content.
13
13
 
14
14
  ```tsx
15
- import { useRef } from 'react';
15
+ import { useRef, useState } from 'react';
16
16
  import { getAriaForTrigger } from '@atlaskit/top-layer/get-aria-for-trigger';
17
- import { Popover } from '@atlaskit/top-layer/popover';
17
+ import { Popover } from '@atlaskit/top-layer/popover/popover';
18
18
  import { PopoverSurface } from '@atlaskit/top-layer/popover-surface';
19
19
  import { usePopoverId } from '@atlaskit/top-layer/use-popover-id';
20
20
  import { useAnchoredPopover } from '@atlaskit/top-layer/use-anchored-popover';
21
21
 
22
- function MyPopup({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
22
+ function MyPopup() {
23
+ const [isOpen, setIsOpen] = useState(false);
23
24
  const triggerRef = useRef<HTMLButtonElement>(null);
24
25
  const popoverRef = useRef<HTMLDivElement>(null);
25
26
  const popoverId = usePopoverId();
@@ -35,7 +36,7 @@ function MyPopup({ isOpen, onClose }: { isOpen: boolean; onClose: () => void })
35
36
  <>
36
37
  <button
37
38
  ref={triggerRef}
38
- onClick={() => popoverRef.current?.togglePopover()}
39
+ onClick={() => setIsOpen((value) => !value)}
39
40
  {...getAriaForTrigger({ role: 'dialog', isOpen, popoverId })}
40
41
  >
41
42
  Open
@@ -47,7 +48,7 @@ function MyPopup({ isOpen, onClose }: { isOpen: boolean; onClose: () => void })
47
48
  label="Settings"
48
49
  isOpen={isOpen}
49
50
  shouldAnimate
50
- onClose={onClose}
51
+ onClose={() => setIsOpen(false)}
51
52
  >
52
53
  <PopoverSurface>Content here</PopoverSurface>
53
54
  </Popover>
@@ -72,27 +73,24 @@ function MyPopup({ isOpen, onClose }: { isOpen: boolean; onClose: () => void })
72
73
 
73
74
  ## Dialog
74
75
 
75
- Compound component for modal dialogs using the native `<dialog>` element with `.showModal()`.
76
+ A modal dialog on the native `<dialog>` element. `isOpen` calls `showModal()` and `close()`. It has
77
+ no visual opinions (no width, surface or layout); consumers such as `@atlaskit/modal-dialog` add
78
+ their own. A `label` or `labelledBy` is required.
76
79
 
77
80
  ```tsx
78
- {
79
- isOpen && (
80
- <Dialog onClose={handleClose} width="medium">
81
- <Dialog.Content>
82
- <Dialog.Surface>
83
- <Dialog.Header>
84
- <Dialog.Title>Heading</Dialog.Title>
85
- <Dialog.CloseButton />
86
- </Dialog.Header>
87
- <Dialog.Body>Content</Dialog.Body>
88
- <Dialog.Footer>Actions</Dialog.Footer>
89
- </Dialog.Surface>
90
- </Dialog.Content>
91
- </Dialog>
92
- );
93
- }
81
+ import { Dialog } from '@atlaskit/top-layer/dialog-content';
82
+
83
+ <Dialog isOpen={isOpen} onClose={() => setIsOpen(false)} labelledBy={titleId}>
84
+ <h2 id={titleId}>Heading</h2>
85
+ Content
86
+ </Dialog>;
94
87
  ```
95
88
 
89
+ - `onClose({ reason })` fires after an allowed Escape (`'escape'`) or backdrop click
90
+ (`'overlay-click'`). Set `isOpen={false}` in response.
91
+ - `dismissedBy` limits user dismissal: `'escape-and-outside-click'` (default), `'escape'` or
92
+ `'none'`.
93
+
96
94
  ## Animations
97
95
 
98
96
  Both `Popover` and `Dialog` support CSS-based entry/exit animations via the `shouldAnimate` prop.
@@ -106,9 +104,9 @@ for entry and
106
104
  Pass `shouldAnimate` to enable the default popover entry and exit animation:
107
105
 
108
106
  ```tsx
109
- import { Popover } from '@atlaskit/top-layer/popover';
107
+ import { Popover } from '@atlaskit/top-layer/popover/popover';
110
108
 
111
- <Popover shouldAnimate role="dialog" label="My popover" isOpen={isOpen}>
109
+ <Popover shouldAnimate role="dialog" label="My popover" isOpen={isOpen} onClose={onClose}>
112
110
  Content
113
111
  </Popover>;
114
112
  ```
@@ -124,7 +122,7 @@ the phase-specific animation styles.
124
122
  Pass `shouldAnimate` to enable the default dialog entry and exit animation:
125
123
 
126
124
  ```tsx
127
- import { Dialog } from '@atlaskit/top-layer/dialog';
125
+ import { Dialog } from '@atlaskit/top-layer/dialog-content';
128
126
 
129
127
  <Dialog shouldAnimate isOpen={isOpen} onClose={onClose} label="My dialog">
130
128
  Content
@@ -16,7 +16,8 @@ exports.createCloseEvent = createCloseEvent;
16
16
  *
17
17
  * @example
18
18
  * ```tsx
19
- * import { createCloseEvent, type TDialogCloseReason } from '@atlaskit/top-layer/dialog';
19
+ * import { createCloseEvent } from '@atlaskit/top-layer/create-close-event';
20
+ * import { type TDialogCloseReason } from '@atlaskit/top-layer/dialog/types';
20
21
  *
21
22
  * function onDialogClose({ reason }: { reason: TDialogCloseReason }) {
22
23
  * legacyOnClose(createCloseEvent({ reason }));
@@ -71,6 +71,7 @@ function runOnAnimationsSettled(_ref2) {
71
71
  * interrupts: entering → exiting (close mid-entry)
72
72
  * exiting → entering (reopen mid-exit, animated)
73
73
  * exiting → open (reopen mid-handshake, non-animated)
74
+ * exiting → open (native reopen while controlled intent is open)
74
75
  * ```
75
76
  */
76
77
 
@@ -134,6 +135,19 @@ function getNextVisibilityState(state, action) {
134
135
  phase: 'exiting'
135
136
  });
136
137
  }
138
+
139
+ // A browser dismiss started the exit, and something showed the element
140
+ // again before the exit settled, while controlled intent stayed open. For
141
+ // example, `@atlaskit/tooltip` re-shows after a light dismiss in the same
142
+ // task. The element never painted closed, so go back to `open`.
143
+ if (action.type === 'native-open-started') {
144
+ if (state.phase !== 'exiting' || state.controlledIntent !== 'open') {
145
+ return state;
146
+ }
147
+ return _objectSpread(_objectSpread({}, state), {}, {
148
+ phase: 'open'
149
+ });
150
+ }
137
151
  if (action.type === 'entry-settled') {
138
152
  if (state.phase !== 'entering') {
139
153
  return state;
@@ -251,9 +265,16 @@ function useAnimatedVisibility(_ref4) {
251
265
  cancelExitSettlementRef.current();
252
266
  // Snapshot currentTarget because the DOM clears it after event dispatch.
253
267
  var element = event.currentTarget;
254
- if (event.newState === 'open' && element instanceof HTMLElement) {
255
- element.removeAttribute('inert');
256
- element.removeAttribute('aria-hidden');
268
+ if (event.newState === 'open') {
269
+ if (element instanceof HTMLElement) {
270
+ element.removeAttribute('inert');
271
+ element.removeAttribute('aria-hidden');
272
+ }
273
+ // Dispatched on every native open. The reducer only acts when a browser
274
+ // dismiss left the host `exiting` while `isOpen` stayed `true`.
275
+ dispatch({
276
+ type: 'native-open-started'
277
+ });
257
278
  }
258
279
  if (event.newState === 'closed') {
259
280
  if (element instanceof HTMLElement) {
@@ -27,7 +27,7 @@ var _isNestedLayerFocused = require("../focus/is-nested-layer-focused");
27
27
  * impossible to implement generically here. The consumer component
28
28
  * (e.g. `dropdown-menu`) owns all menu keyboard navigation.
29
29
  *
30
- * See: notes/outputs/menu-keyboard-decision.md
30
+ * See: notes/decisions/menu-keyboard.md
31
31
  */
32
32
  function roleRequiresFocusWrap(role) {
33
33
  return role === 'dialog' || role === 'alertdialog';
@@ -150,7 +150,7 @@ function getInitialFocusTarget(_ref2) {
150
150
  // type-ahead, Enter/Space, submenu coordination) is the responsibility
151
151
  // of the consumer component (e.g. dropdown-menu), because menu keyboard
152
152
  // behavior varies by context (orientation, nesting, item type, control
153
- // pattern). See: notes/outputs/menu-keyboard-decision.md
153
+ // pattern). See: notes/decisions/menu-keyboard.md
154
154
  return (0, _getFirstFocusable.getFirstFocusable)({
155
155
  container: container
156
156
  });
@@ -43,7 +43,7 @@ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t =
43
43
  *
44
44
  * @example
45
45
  * ```ts
46
- * import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
46
+ * import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map/index';
47
47
  *
48
48
  * // In a migration from @atlaskit/popup:
49
49
  * const oldPlacement: TLegacyPlacement = 'bottom-start';
@@ -18,7 +18,8 @@ exports.createPopoverCloseEvent = createPopoverCloseEvent;
18
18
  *
19
19
  * @example
20
20
  * ```tsx
21
- * import { createPopoverCloseEvent, type TPopoverCloseReason } from '@atlaskit/top-layer/popover';
21
+ * import { createPopoverCloseEvent } from '@atlaskit/top-layer/popover/create-close-event';
22
+ * import { type TPopoverCloseReason } from '@atlaskit/top-layer/popover/types';
22
23
  *
23
24
  * function onPopoverClose({ reason }: { reason: TPopoverCloseReason }) {
24
25
  * legacyOnClose(createPopoverCloseEvent({ reason }));
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.flushPopoverExit = flushPopoverExit;
8
+ var _regenerator = _interopRequireDefault(require("@babel/runtime/regenerator"));
9
+ var _asyncToGenerator2 = _interopRequireDefault(require("@babel/runtime/helpers/asyncToGenerator"));
10
+ var _react = require("react");
11
+ /**
12
+ * Flushes a `Popover` exit in jsdom so that `onExitFinish` has fired and the
13
+ * host element has unmounted before the caller asserts.
14
+ *
15
+ * Requires React 18.3 or newer (`act` is imported from `react`).
16
+ *
17
+ * Hiding is two steps: the testing polyfill fires the closed `toggle` as a task,
18
+ * then top-layer settles the exit in a native promise microtask. Jest fake
19
+ * timers drain the task but not that microtask, so a synchronous
20
+ * `act(() => jest.runOnlyPendingTimers())` returns too early. Real browsers are
21
+ * unaffected. See `notes/architecture/animations.md` ("settles in a microtask").
22
+ *
23
+ * Flush any delay before `hidePopover()` first (for example the `delay` prop on
24
+ * `@atlaskit/tooltip`). Only `Popover` is covered - `Dialog` chains a second
25
+ * timer for its `close` event, which this helper does not wait for.
26
+ *
27
+ * @example
28
+ * rerender(<Popover isOpen={false} shouldAnimate onExitFinish={onExitFinish} />);
29
+ * await flushPopoverExit();
30
+ * expect(onExitFinish).toHaveBeenCalledTimes(1);
31
+ */
32
+
33
+ // `react` is a peer dependency, so this entrypoint adds no testing-library dependency.
34
+
35
+ // Mirrors `jestFakeTimersAreEnabled` in `@testing-library/dom`.
36
+ function areJestFakeTimersEnabled() {
37
+ if (typeof jest === 'undefined') {
38
+ return false;
39
+ }
40
+ var timer = setTimeout;
41
+ // Legacy fake timers mark the function. Modern (sinon) fake timers attach a `clock`.
42
+ return timer._isMockFunction === true || Object.prototype.hasOwnProperty.call(setTimeout, 'clock');
43
+ }
44
+ function waitForOneRealTask() {
45
+ return new Promise(function (resolve) {
46
+ setTimeout(resolve, 0);
47
+ });
48
+ }
49
+ function flushPopoverExit() {
50
+ return _flushPopoverExit.apply(this, arguments);
51
+ }
52
+ function _flushPopoverExit() {
53
+ _flushPopoverExit = (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee2() {
54
+ return _regenerator.default.wrap(function (_context2) {
55
+ while (1) switch (_context2.prev = _context2.next) {
56
+ case 0:
57
+ if (!(typeof _react.act !== 'function')) {
58
+ _context2.next = 1;
59
+ break;
60
+ }
61
+ throw new Error('flushPopoverExit() requires React 18.3 or newer: `act` is not exported by this version of `react`');
62
+ case 1:
63
+ _context2.next = 2;
64
+ return (0, _react.act)( /*#__PURE__*/(0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee() {
65
+ return _regenerator.default.wrap(function (_context) {
66
+ while (1) switch (_context.prev = _context.next) {
67
+ case 0:
68
+ if (!areJestFakeTimersEnabled()) {
69
+ _context.next = 2;
70
+ break;
71
+ }
72
+ jest.runOnlyPendingTimers();
73
+ // Yield once so the settlement job runs before `act` commits.
74
+ _context.next = 1;
75
+ return Promise.resolve();
76
+ case 1:
77
+ return _context.abrupt("return");
78
+ case 2:
79
+ _context.next = 3;
80
+ return waitForOneRealTask();
81
+ case 3:
82
+ case "end":
83
+ return _context.stop();
84
+ }
85
+ }, _callee);
86
+ })));
87
+ case 2:
88
+ case "end":
89
+ return _context2.stop();
90
+ }
91
+ }, _callee2);
92
+ }));
93
+ return _flushPopoverExit.apply(this, arguments);
94
+ }
@@ -53,7 +53,8 @@ var menuScope = '[role="menu"]';
53
53
  *
54
54
  * @example With nested menu filter
55
55
  * ```tsx
56
- * import { useArrowNavigation, isAtCurrentMenuLevel } from '@atlaskit/top-layer/use-arrow-navigation';
56
+ * import { isAtCurrentMenuLevel } from '@atlaskit/top-layer/is-at-current-menu-level';
57
+ * import { useArrowNavigation } from '@atlaskit/top-layer/use-arrow-navigation/use-arrow-navigation';
57
58
  *
58
59
  * useArrowNavigation({
59
60
  * containerRef: menuRef,
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * @example
12
12
  * ```tsx
13
- * import { createCloseEvent, type TDialogCloseReason } from '@atlaskit/top-layer/dialog';
13
+ * import { createCloseEvent } from '@atlaskit/top-layer/create-close-event';
14
+ * import { type TDialogCloseReason } from '@atlaskit/top-layer/dialog/types';
14
15
  *
15
16
  * function onDialogClose({ reason }: { reason: TDialogCloseReason }) {
16
17
  * legacyOnClose(createCloseEvent({ reason }));
@@ -60,6 +60,7 @@ function runOnAnimationsSettled({
60
60
  * interrupts: entering → exiting (close mid-entry)
61
61
  * exiting → entering (reopen mid-exit, animated)
62
62
  * exiting → open (reopen mid-handshake, non-animated)
63
+ * exiting → open (native reopen while controlled intent is open)
63
64
  * ```
64
65
  */
65
66
 
@@ -127,6 +128,20 @@ function getNextVisibilityState(state, action) {
127
128
  phase: 'exiting'
128
129
  };
129
130
  }
131
+
132
+ // A browser dismiss started the exit, and something showed the element
133
+ // again before the exit settled, while controlled intent stayed open. For
134
+ // example, `@atlaskit/tooltip` re-shows after a light dismiss in the same
135
+ // task. The element never painted closed, so go back to `open`.
136
+ if (action.type === 'native-open-started') {
137
+ if (state.phase !== 'exiting' || state.controlledIntent !== 'open') {
138
+ return state;
139
+ }
140
+ return {
141
+ ...state,
142
+ phase: 'open'
143
+ };
144
+ }
130
145
  if (action.type === 'entry-settled') {
131
146
  if (state.phase !== 'entering') {
132
147
  return state;
@@ -246,9 +261,16 @@ export function useAnimatedVisibility({
246
261
  cancelExitSettlementRef.current();
247
262
  // Snapshot currentTarget because the DOM clears it after event dispatch.
248
263
  const element = event.currentTarget;
249
- if (event.newState === 'open' && element instanceof HTMLElement) {
250
- element.removeAttribute('inert');
251
- element.removeAttribute('aria-hidden');
264
+ if (event.newState === 'open') {
265
+ if (element instanceof HTMLElement) {
266
+ element.removeAttribute('inert');
267
+ element.removeAttribute('aria-hidden');
268
+ }
269
+ // Dispatched on every native open. The reducer only acts when a browser
270
+ // dismiss left the host `exiting` while `isOpen` stayed `true`.
271
+ dispatch({
272
+ type: 'native-open-started'
273
+ });
252
274
  }
253
275
  if (event.newState === 'closed') {
254
276
  if (element instanceof HTMLElement) {
@@ -21,7 +21,7 @@ import { isNestedLayerFocused } from '../focus/is-nested-layer-focused';
21
21
  * impossible to implement generically here. The consumer component
22
22
  * (e.g. `dropdown-menu`) owns all menu keyboard navigation.
23
23
  *
24
- * See: notes/outputs/menu-keyboard-decision.md
24
+ * See: notes/decisions/menu-keyboard.md
25
25
  */
26
26
  function roleRequiresFocusWrap(role) {
27
27
  return role === 'dialog' || role === 'alertdialog';
@@ -144,7 +144,7 @@ function getInitialFocusTarget({
144
144
  // type-ahead, Enter/Space, submenu coordination) is the responsibility
145
145
  // of the consumer component (e.g. dropdown-menu), because menu keyboard
146
146
  // behavior varies by context (orientation, nesting, item type, control
147
- // pattern). See: notes/outputs/menu-keyboard-decision.md
147
+ // pattern). See: notes/decisions/menu-keyboard.md
148
148
  return getFirstFocusable({
149
149
  container
150
150
  });
@@ -33,7 +33,7 @@ import { placementMapping } from './placement-mapping';
33
33
  *
34
34
  * @example
35
35
  * ```ts
36
- * import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
36
+ * import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map/index';
37
37
  *
38
38
  * // In a migration from @atlaskit/popup:
39
39
  * const oldPlacement: TLegacyPlacement = 'bottom-start';
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * @example
14
14
  * ```tsx
15
- * import { createPopoverCloseEvent, type TPopoverCloseReason } from '@atlaskit/top-layer/popover';
15
+ * import { createPopoverCloseEvent } from '@atlaskit/top-layer/popover/create-close-event';
16
+ * import { type TPopoverCloseReason } from '@atlaskit/top-layer/popover/types';
16
17
  *
17
18
  * function onPopoverClose({ reason }: { reason: TPopoverCloseReason }) {
18
19
  * legacyOnClose(createPopoverCloseEvent({ reason }));
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Flushes a `Popover` exit in jsdom so that `onExitFinish` has fired and the
3
+ * host element has unmounted before the caller asserts.
4
+ *
5
+ * Requires React 18.3 or newer (`act` is imported from `react`).
6
+ *
7
+ * Hiding is two steps: the testing polyfill fires the closed `toggle` as a task,
8
+ * then top-layer settles the exit in a native promise microtask. Jest fake
9
+ * timers drain the task but not that microtask, so a synchronous
10
+ * `act(() => jest.runOnlyPendingTimers())` returns too early. Real browsers are
11
+ * unaffected. See `notes/architecture/animations.md` ("settles in a microtask").
12
+ *
13
+ * Flush any delay before `hidePopover()` first (for example the `delay` prop on
14
+ * `@atlaskit/tooltip`). Only `Popover` is covered - `Dialog` chains a second
15
+ * timer for its `close` event, which this helper does not wait for.
16
+ *
17
+ * @example
18
+ * rerender(<Popover isOpen={false} shouldAnimate onExitFinish={onExitFinish} />);
19
+ * await flushPopoverExit();
20
+ * expect(onExitFinish).toHaveBeenCalledTimes(1);
21
+ */
22
+
23
+ // `react` is a peer dependency, so this entrypoint adds no testing-library dependency.
24
+ import { act } from 'react';
25
+
26
+ // Mirrors `jestFakeTimersAreEnabled` in `@testing-library/dom`.
27
+ function areJestFakeTimersEnabled() {
28
+ if (typeof jest === 'undefined') {
29
+ return false;
30
+ }
31
+ const timer = setTimeout;
32
+ // Legacy fake timers mark the function. Modern (sinon) fake timers attach a `clock`.
33
+ return timer._isMockFunction === true || Object.prototype.hasOwnProperty.call(setTimeout, 'clock');
34
+ }
35
+ function waitForOneRealTask() {
36
+ return new Promise(resolve => {
37
+ setTimeout(resolve, 0);
38
+ });
39
+ }
40
+ export async function flushPopoverExit() {
41
+ // The peer range allows React 18.2, which does not export `act`. A plain throw rather than
42
+ // `tiny-invariant`, which is only a devDependency here.
43
+ if (typeof act !== 'function') {
44
+ throw new Error('flushPopoverExit() requires React 18.3 or newer: `act` is not exported by this version of `react`');
45
+ }
46
+ await act(async () => {
47
+ if (areJestFakeTimersEnabled()) {
48
+ jest.runOnlyPendingTimers();
49
+ // Yield once so the settlement job runs before `act` commits.
50
+ await Promise.resolve();
51
+ return;
52
+ }
53
+ // The polyfill's `toggle` task was queued first, so it and its settlement microtask both
54
+ // run before this timer resolves.
55
+ await waitForOneRealTask();
56
+ });
57
+ }
@@ -47,7 +47,8 @@ const menuScope = '[role="menu"]';
47
47
  *
48
48
  * @example With nested menu filter
49
49
  * ```tsx
50
- * import { useArrowNavigation, isAtCurrentMenuLevel } from '@atlaskit/top-layer/use-arrow-navigation';
50
+ * import { isAtCurrentMenuLevel } from '@atlaskit/top-layer/is-at-current-menu-level';
51
+ * import { useArrowNavigation } from '@atlaskit/top-layer/use-arrow-navigation/use-arrow-navigation';
51
52
  *
52
53
  * useArrowNavigation({
53
54
  * containerRef: menuRef,
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * @example
12
12
  * ```tsx
13
- * import { createCloseEvent, type TDialogCloseReason } from '@atlaskit/top-layer/dialog';
13
+ * import { createCloseEvent } from '@atlaskit/top-layer/create-close-event';
14
+ * import { type TDialogCloseReason } from '@atlaskit/top-layer/dialog/types';
14
15
  *
15
16
  * function onDialogClose({ reason }: { reason: TDialogCloseReason }) {
16
17
  * legacyOnClose(createCloseEvent({ reason }));
@@ -64,6 +64,7 @@ function runOnAnimationsSettled(_ref2) {
64
64
  * interrupts: entering → exiting (close mid-entry)
65
65
  * exiting → entering (reopen mid-exit, animated)
66
66
  * exiting → open (reopen mid-handshake, non-animated)
67
+ * exiting → open (native reopen while controlled intent is open)
67
68
  * ```
68
69
  */
69
70
 
@@ -127,6 +128,19 @@ function getNextVisibilityState(state, action) {
127
128
  phase: 'exiting'
128
129
  });
129
130
  }
131
+
132
+ // A browser dismiss started the exit, and something showed the element
133
+ // again before the exit settled, while controlled intent stayed open. For
134
+ // example, `@atlaskit/tooltip` re-shows after a light dismiss in the same
135
+ // task. The element never painted closed, so go back to `open`.
136
+ if (action.type === 'native-open-started') {
137
+ if (state.phase !== 'exiting' || state.controlledIntent !== 'open') {
138
+ return state;
139
+ }
140
+ return _objectSpread(_objectSpread({}, state), {}, {
141
+ phase: 'open'
142
+ });
143
+ }
130
144
  if (action.type === 'entry-settled') {
131
145
  if (state.phase !== 'entering') {
132
146
  return state;
@@ -244,9 +258,16 @@ export function useAnimatedVisibility(_ref4) {
244
258
  cancelExitSettlementRef.current();
245
259
  // Snapshot currentTarget because the DOM clears it after event dispatch.
246
260
  var element = event.currentTarget;
247
- if (event.newState === 'open' && element instanceof HTMLElement) {
248
- element.removeAttribute('inert');
249
- element.removeAttribute('aria-hidden');
261
+ if (event.newState === 'open') {
262
+ if (element instanceof HTMLElement) {
263
+ element.removeAttribute('inert');
264
+ element.removeAttribute('aria-hidden');
265
+ }
266
+ // Dispatched on every native open. The reducer only acts when a browser
267
+ // dismiss left the host `exiting` while `isOpen` stayed `true`.
268
+ dispatch({
269
+ type: 'native-open-started'
270
+ });
250
271
  }
251
272
  if (event.newState === 'closed') {
252
273
  if (element instanceof HTMLElement) {
@@ -21,7 +21,7 @@ import { isNestedLayerFocused } from '../focus/is-nested-layer-focused';
21
21
  * impossible to implement generically here. The consumer component
22
22
  * (e.g. `dropdown-menu`) owns all menu keyboard navigation.
23
23
  *
24
- * See: notes/outputs/menu-keyboard-decision.md
24
+ * See: notes/decisions/menu-keyboard.md
25
25
  */
26
26
  function roleRequiresFocusWrap(role) {
27
27
  return role === 'dialog' || role === 'alertdialog';
@@ -144,7 +144,7 @@ function getInitialFocusTarget(_ref2) {
144
144
  // type-ahead, Enter/Space, submenu coordination) is the responsibility
145
145
  // of the consumer component (e.g. dropdown-menu), because menu keyboard
146
146
  // behavior varies by context (orientation, nesting, item type, control
147
- // pattern). See: notes/outputs/menu-keyboard-decision.md
147
+ // pattern). See: notes/decisions/menu-keyboard.md
148
148
  return getFirstFocusable({
149
149
  container: container
150
150
  });
@@ -37,7 +37,7 @@ import { placementMapping } from './placement-mapping';
37
37
  *
38
38
  * @example
39
39
  * ```ts
40
- * import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
40
+ * import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map/index';
41
41
  *
42
42
  * // In a migration from @atlaskit/popup:
43
43
  * const oldPlacement: TLegacyPlacement = 'bottom-start';
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * @example
14
14
  * ```tsx
15
- * import { createPopoverCloseEvent, type TPopoverCloseReason } from '@atlaskit/top-layer/popover';
15
+ * import { createPopoverCloseEvent } from '@atlaskit/top-layer/popover/create-close-event';
16
+ * import { type TPopoverCloseReason } from '@atlaskit/top-layer/popover/types';
16
17
  *
17
18
  * function onPopoverClose({ reason }: { reason: TPopoverCloseReason }) {
18
19
  * legacyOnClose(createPopoverCloseEvent({ reason }));
@@ -0,0 +1,87 @@
1
+ import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator";
2
+ import _regeneratorRuntime from "@babel/runtime/regenerator";
3
+ /**
4
+ * Flushes a `Popover` exit in jsdom so that `onExitFinish` has fired and the
5
+ * host element has unmounted before the caller asserts.
6
+ *
7
+ * Requires React 18.3 or newer (`act` is imported from `react`).
8
+ *
9
+ * Hiding is two steps: the testing polyfill fires the closed `toggle` as a task,
10
+ * then top-layer settles the exit in a native promise microtask. Jest fake
11
+ * timers drain the task but not that microtask, so a synchronous
12
+ * `act(() => jest.runOnlyPendingTimers())` returns too early. Real browsers are
13
+ * unaffected. See `notes/architecture/animations.md` ("settles in a microtask").
14
+ *
15
+ * Flush any delay before `hidePopover()` first (for example the `delay` prop on
16
+ * `@atlaskit/tooltip`). Only `Popover` is covered - `Dialog` chains a second
17
+ * timer for its `close` event, which this helper does not wait for.
18
+ *
19
+ * @example
20
+ * rerender(<Popover isOpen={false} shouldAnimate onExitFinish={onExitFinish} />);
21
+ * await flushPopoverExit();
22
+ * expect(onExitFinish).toHaveBeenCalledTimes(1);
23
+ */
24
+
25
+ // `react` is a peer dependency, so this entrypoint adds no testing-library dependency.
26
+ import { act } from 'react';
27
+
28
+ // Mirrors `jestFakeTimersAreEnabled` in `@testing-library/dom`.
29
+ function areJestFakeTimersEnabled() {
30
+ if (typeof jest === 'undefined') {
31
+ return false;
32
+ }
33
+ var timer = setTimeout;
34
+ // Legacy fake timers mark the function. Modern (sinon) fake timers attach a `clock`.
35
+ return timer._isMockFunction === true || Object.prototype.hasOwnProperty.call(setTimeout, 'clock');
36
+ }
37
+ function waitForOneRealTask() {
38
+ return new Promise(function (resolve) {
39
+ setTimeout(resolve, 0);
40
+ });
41
+ }
42
+ export function flushPopoverExit() {
43
+ return _flushPopoverExit.apply(this, arguments);
44
+ }
45
+ function _flushPopoverExit() {
46
+ _flushPopoverExit = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee2() {
47
+ return _regeneratorRuntime.wrap(function (_context2) {
48
+ while (1) switch (_context2.prev = _context2.next) {
49
+ case 0:
50
+ if (!(typeof act !== 'function')) {
51
+ _context2.next = 1;
52
+ break;
53
+ }
54
+ throw new Error('flushPopoverExit() requires React 18.3 or newer: `act` is not exported by this version of `react`');
55
+ case 1:
56
+ _context2.next = 2;
57
+ return act( /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee() {
58
+ return _regeneratorRuntime.wrap(function (_context) {
59
+ while (1) switch (_context.prev = _context.next) {
60
+ case 0:
61
+ if (!areJestFakeTimersEnabled()) {
62
+ _context.next = 2;
63
+ break;
64
+ }
65
+ jest.runOnlyPendingTimers();
66
+ // Yield once so the settlement job runs before `act` commits.
67
+ _context.next = 1;
68
+ return Promise.resolve();
69
+ case 1:
70
+ return _context.abrupt("return");
71
+ case 2:
72
+ _context.next = 3;
73
+ return waitForOneRealTask();
74
+ case 3:
75
+ case "end":
76
+ return _context.stop();
77
+ }
78
+ }, _callee);
79
+ })));
80
+ case 2:
81
+ case "end":
82
+ return _context2.stop();
83
+ }
84
+ }, _callee2);
85
+ }));
86
+ return _flushPopoverExit.apply(this, arguments);
87
+ }
@@ -47,7 +47,8 @@ var menuScope = '[role="menu"]';
47
47
  *
48
48
  * @example With nested menu filter
49
49
  * ```tsx
50
- * import { useArrowNavigation, isAtCurrentMenuLevel } from '@atlaskit/top-layer/use-arrow-navigation';
50
+ * import { isAtCurrentMenuLevel } from '@atlaskit/top-layer/is-at-current-menu-level';
51
+ * import { useArrowNavigation } from '@atlaskit/top-layer/use-arrow-navigation/use-arrow-navigation';
51
52
  *
52
53
  * useArrowNavigation({
53
54
  * containerRef: menuRef,
@@ -11,7 +11,8 @@ import type { TDialogCloseReason } from './types';
11
11
  *
12
12
  * @example
13
13
  * ```tsx
14
- * import { createCloseEvent, type TDialogCloseReason } from '@atlaskit/top-layer/dialog';
14
+ * import { createCloseEvent } from '@atlaskit/top-layer/create-close-event';
15
+ * import { type TDialogCloseReason } from '@atlaskit/top-layer/dialog/types';
15
16
  *
16
17
  * function onDialogClose({ reason }: { reason: TDialogCloseReason }) {
17
18
  * legacyOnClose(createCloseEvent({ reason }));
@@ -70,6 +70,7 @@ type TUseAnimatedVisibilityResult = {
70
70
  * interrupts: entering → exiting (close mid-entry)
71
71
  * exiting → entering (reopen mid-exit, animated)
72
72
  * exiting → open (reopen mid-handshake, non-animated)
73
+ * exiting → open (native reopen while controlled intent is open)
73
74
  * ```
74
75
  */
75
76
  export type TPhase = 'closed' | 'entering' | 'open' | 'exiting';
@@ -33,7 +33,7 @@ import type { TLegacyPlacement } from './legacy-placements';
33
33
  *
34
34
  * @example
35
35
  * ```ts
36
- * import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
36
+ * import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map/index';
37
37
  *
38
38
  * // In a migration from @atlaskit/popup:
39
39
  * const oldPlacement: TLegacyPlacement = 'bottom-start';
@@ -13,7 +13,8 @@ import type { TPopoverCloseReason } from './types';
13
13
  *
14
14
  * @example
15
15
  * ```tsx
16
- * import { createPopoverCloseEvent, type TPopoverCloseReason } from '@atlaskit/top-layer/popover';
16
+ * import { createPopoverCloseEvent } from '@atlaskit/top-layer/popover/create-close-event';
17
+ * import { type TPopoverCloseReason } from '@atlaskit/top-layer/popover/types';
17
18
  *
18
19
  * function onPopoverClose({ reason }: { reason: TPopoverCloseReason }) {
19
20
  * legacyOnClose(createPopoverCloseEvent({ reason }));
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Flushes a `Popover` exit in jsdom so that `onExitFinish` has fired and the
3
+ * host element has unmounted before the caller asserts.
4
+ *
5
+ * Requires React 18.3 or newer (`act` is imported from `react`).
6
+ *
7
+ * Hiding is two steps: the testing polyfill fires the closed `toggle` as a task,
8
+ * then top-layer settles the exit in a native promise microtask. Jest fake
9
+ * timers drain the task but not that microtask, so a synchronous
10
+ * `act(() => jest.runOnlyPendingTimers())` returns too early. Real browsers are
11
+ * unaffected. See `notes/architecture/animations.md` ("settles in a microtask").
12
+ *
13
+ * Flush any delay before `hidePopover()` first (for example the `delay` prop on
14
+ * `@atlaskit/tooltip`). Only `Popover` is covered - `Dialog` chains a second
15
+ * timer for its `close` event, which this helper does not wait for.
16
+ *
17
+ * @example
18
+ * rerender(<Popover isOpen={false} shouldAnimate onExitFinish={onExitFinish} />);
19
+ * await flushPopoverExit();
20
+ * expect(onExitFinish).toHaveBeenCalledTimes(1);
21
+ */
22
+ export declare function flushPopoverExit(): Promise<void>;
@@ -89,7 +89,8 @@ export type TUseArrowNavigationArgs = {
89
89
  *
90
90
  * @example With nested menu filter
91
91
  * ```tsx
92
- * import { useArrowNavigation, isAtCurrentMenuLevel } from '@atlaskit/top-layer/use-arrow-navigation';
92
+ * import { isAtCurrentMenuLevel } from '@atlaskit/top-layer/is-at-current-menu-level';
93
+ * import { useArrowNavigation } from '@atlaskit/top-layer/use-arrow-navigation/use-arrow-navigation';
93
94
  *
94
95
  * useArrowNavigation({
95
96
  * containerRef: menuRef,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/top-layer",
3
- "version": "6.0.0",
3
+ "version": "6.1.1",
4
4
  "description": "Low-level layering primitives based on popover and dialog",
5
5
  "homepage": "https://atlassian.design/components/top-layer",
6
6
  "repository": "https://bitbucket.org/atlassian/atlassian-frontend-mirror",
@@ -40,7 +40,7 @@
40
40
  "@atlaskit/ds-lib": "^8.0.0",
41
41
  "@atlaskit/layering": "^6.0.0",
42
42
  "@atlaskit/react-compiler-gating": "^0.2.0",
43
- "@atlaskit/tokens": "^20.0.0",
43
+ "@atlaskit/tokens": "^20.3.0",
44
44
  "@babel/runtime": "^7.0.0",
45
45
  "@compiled/react": "^1.0.2",
46
46
  "bind-event-listener": "^3.0.0",
@@ -54,12 +54,12 @@
54
54
  "@af/integration-testing": "workspace:^",
55
55
  "@af/visual-regression": "workspace:^",
56
56
  "@atlaskit/button": "^25.4.0",
57
- "@atlaskit/checkbox": "^19.2.0",
57
+ "@atlaskit/checkbox": "^19.3.0",
58
58
  "@atlaskit/heading": "^8.0.0",
59
- "@atlaskit/lozenge": "^15.4.0",
59
+ "@atlaskit/lozenge": "^17.0.0",
60
60
  "@atlaskit/pragmatic-drag-and-drop": "^4.0.0",
61
61
  "@atlaskit/primitives": "^22.5.0",
62
- "@atlaskit/select": "^23.0.0",
62
+ "@atlaskit/select": "^23.1.0",
63
63
  "@atlaskit/ssr": "workspace:^",
64
64
  "@atlaskit/textfield": "^10.2.0",
65
65
  "@atlaskit/toggle": "^17.3.0",
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@atlaskit/top-layer/testing/flush-popover-exit",
3
+ "main": "../../dist/cjs/testing/flush-popover-exit.js",
4
+ "module": "../../dist/esm/testing/flush-popover-exit.js",
5
+ "module:es2019": "../../dist/es2019/testing/flush-popover-exit.js",
6
+ "sideEffects": [
7
+ "*.compiled.css"
8
+ ],
9
+ "types": "../../dist/types/testing/flush-popover-exit.d.ts"
10
+ }
@@ -1,19 +0,0 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.shouldFocusIntoPopover = shouldFocusIntoPopover;
7
- var rolesThatMoveFocus = new Set(['dialog', 'alertdialog', 'menu', 'listbox', 'tree', 'grid']);
8
-
9
- /**
10
- * Returns `true` when the given popover role moves focus into the popover
11
- * on open, and `false` otherwise (including when `role` is `undefined`).
12
- */
13
- function shouldFocusIntoPopover(_ref) {
14
- var role = _ref.role;
15
- if (!role) {
16
- return false;
17
- }
18
- return rolesThatMoveFocus.has(role);
19
- }
@@ -1,14 +0,0 @@
1
- const rolesThatMoveFocus = new Set(['dialog', 'alertdialog', 'menu', 'listbox', 'tree', 'grid']);
2
-
3
- /**
4
- * Returns `true` when the given popover role moves focus into the popover
5
- * on open, and `false` otherwise (including when `role` is `undefined`).
6
- */
7
- export function shouldFocusIntoPopover({
8
- role
9
- }) {
10
- if (!role) {
11
- return false;
12
- }
13
- return rolesThatMoveFocus.has(role);
14
- }
@@ -1,13 +0,0 @@
1
- var rolesThatMoveFocus = new Set(['dialog', 'alertdialog', 'menu', 'listbox', 'tree', 'grid']);
2
-
3
- /**
4
- * Returns `true` when the given popover role moves focus into the popover
5
- * on open, and `false` otherwise (including when `role` is `undefined`).
6
- */
7
- export function shouldFocusIntoPopover(_ref) {
8
- var role = _ref.role;
9
- if (!role) {
10
- return false;
11
- }
12
- return rolesThatMoveFocus.has(role);
13
- }
@@ -1,8 +0,0 @@
1
- import type { TRoleRequiringAccessibleName, TRoleWithImplicitName } from './role-types';
2
- /**
3
- * Returns `true` when the given popover role moves focus into the popover
4
- * on open, and `false` otherwise (including when `role` is `undefined`).
5
- */
6
- export declare function shouldFocusIntoPopover({ role, }: {
7
- role: TRoleRequiringAccessibleName | TRoleWithImplicitName | undefined;
8
- }): boolean;