@atlaskit/top-layer 4.4.2 → 4.4.4

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 (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cjs/dialog/dialog-content.js +57 -47
  3. package/dist/cjs/focus/get-closest-layer.js +10 -0
  4. package/dist/cjs/focus/get-focusables.js +45 -10
  5. package/dist/cjs/focus/get-next-focusable.js +40 -15
  6. package/dist/cjs/focus/is-nested-layer-focused.js +8 -9
  7. package/dist/cjs/internal/use-animated-visibility.js +238 -165
  8. package/dist/cjs/popover/popover.js +97 -81
  9. package/dist/cjs/popover-surface/popover-surface.js +1 -1
  10. package/dist/es2019/dialog/dialog-content.js +57 -47
  11. package/dist/es2019/focus/get-closest-layer.js +5 -0
  12. package/dist/es2019/focus/get-focusables.js +43 -6
  13. package/dist/es2019/focus/get-next-focusable.js +38 -15
  14. package/dist/es2019/focus/is-nested-layer-focused.js +8 -9
  15. package/dist/es2019/internal/use-animated-visibility.js +235 -154
  16. package/dist/es2019/popover/popover.js +98 -82
  17. package/dist/es2019/popover-surface/popover-surface.js +1 -1
  18. package/dist/esm/dialog/dialog-content.js +57 -47
  19. package/dist/esm/focus/get-closest-layer.js +4 -0
  20. package/dist/esm/focus/get-focusables.js +45 -10
  21. package/dist/esm/focus/get-next-focusable.js +40 -15
  22. package/dist/esm/focus/is-nested-layer-focused.js +8 -9
  23. package/dist/esm/internal/use-animated-visibility.js +239 -166
  24. package/dist/esm/popover/popover.js +97 -81
  25. package/dist/esm/popover-surface/popover-surface.js +1 -1
  26. package/dist/types/dialog/types.d.ts +6 -4
  27. package/dist/types/focus/get-closest-layer.d.ts +3 -0
  28. package/dist/types/focus/get-focusables.d.ts +2 -0
  29. package/dist/types/focus/get-next-focusable.d.ts +6 -2
  30. package/dist/types/focus/is-nested-layer-focused.d.ts +2 -2
  31. package/dist/types/internal/use-animated-visibility.d.ts +37 -50
  32. package/dist/types/popover/popover.d.ts +3 -1
  33. package/dist/types/popover/types.d.ts +12 -9
  34. package/package.json +14 -9
  35. package/dist/cjs/focus/is-in-same-layer.js +0 -17
  36. package/dist/cjs/focus/selectors.js +0 -19
  37. package/dist/es2019/focus/is-in-same-layer.js +0 -12
  38. package/dist/es2019/focus/selectors.js +0 -11
  39. package/dist/esm/focus/is-in-same-layer.js +0 -11
  40. package/dist/esm/focus/selectors.js +0 -13
  41. package/dist/types/focus/is-in-same-layer.d.ts +0 -8
  42. package/dist/types/focus/selectors.d.ts +0 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @atlaskit/top-layer
2
2
 
3
+ ## 4.4.4
4
+
5
+ ### Patch Changes
6
+
7
+ - [`610d564dbc5dd`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/610d564dbc5dd) -
8
+ Behind `platform-dst-top-layer`, harden the Popover and Dialog animation lifecycle state machine
9
+ and completion callbacks, restore focus before controlled Popover closure removes native
10
+ listeners, and preserve Modal Dialog open-complete callbacks across reopen cycles.
11
+
12
+ ## 4.4.3
13
+
14
+ ### Patch Changes
15
+
16
+ - [`25d19b9cc0200`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/25d19b9cc0200) -
17
+ Handle non-tabbable focus origins and skip hidden, disabled, or inert elements when wrapping focus
18
+
3
19
  ## 4.4.2
4
20
 
5
21
  ### Patch Changes
@@ -1,4 +1,4 @@
1
- /* dialog-content.tsx generated by @compiled/babel-plugin v2.0.0 */
1
+ /* dialog-content.tsx generated by @compiled/babel-plugin v3.0.1 */
2
2
  "use strict";
3
3
 
4
4
  var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
@@ -85,6 +85,13 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
85
85
  var ownRef = (0, _react.useRef)(null);
86
86
  var combinedRef = (0, _mergeRefs.default)([ownRef, ref]);
87
87
  var closeReasonRef = (0, _react.useRef)(null);
88
+ var dismissedByRef = (0, _react.useRef)(dismissedBy);
89
+ var onCloseRef = (0, _react.useRef)(onClose);
90
+ // Keep native event handlers stable while ensuring they read the latest committed props.
91
+ (0, _react.useLayoutEffect)(function () {
92
+ dismissedByRef.current = dismissedBy;
93
+ onCloseRef.current = onClose;
94
+ }, [dismissedBy, onClose]);
88
95
  var _useAnimatedVisibilit = (0, _useAnimatedVisibility.useAnimatedVisibility)({
89
96
  isOpen: isOpen,
90
97
  shouldAnimate: shouldAnimate,
@@ -93,11 +100,52 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
93
100
  onExitFinish: onExitFinish
94
101
  }),
95
102
  phase = _useAnimatedVisibilit.phase,
103
+ isMounted = _useAnimatedVisibilit.isMounted,
104
+ onBeforeToggle = _useAnimatedVisibilit.onBeforeToggle,
96
105
  onToggle = _useAnimatedVisibilit.onToggle;
97
106
 
98
- // True while the host element is mounted (any phase except `closed`).
99
- // Used as a dep so listener-rebind effects re-attach after a remount.
100
- var isVisible = phase !== 'closed';
107
+ // Ordering is important: bind native lifecycle listeners before the later
108
+ // layout effect calls showModal() or close(), because `beforetoggle` fires
109
+ // synchronously during those calls.
110
+ (0, _react.useLayoutEffect)(function () {
111
+ if (!isMounted) {
112
+ return;
113
+ }
114
+ var dialog = ownRef.current;
115
+ if (!dialog) {
116
+ return;
117
+ }
118
+ return (0, _bindEventListener.bindAll)(dialog, [{
119
+ type: 'beforetoggle',
120
+ listener: onBeforeToggle
121
+ }, {
122
+ // Browsers retarget clicks on ::backdrop to the <dialog> element.
123
+ // Close on backdrop click when outside-click dismissal is enabled.
124
+ type: 'click',
125
+ listener: function listener(event) {
126
+ if (event.target === event.currentTarget && dismissedByRef.current === 'escape-and-outside-click') {
127
+ closeReasonRef.current = 'overlay-click';
128
+ dialog.close();
129
+ }
130
+ }
131
+ }, {
132
+ type: 'toggle',
133
+ listener: function listener(event) {
134
+ if (event.newState === 'closed') {
135
+ var reason = closeReasonRef.current;
136
+ closeReasonRef.current = null;
137
+ // A reason is only set for user-initiated closes. When React closes the
138
+ // dialog because isOpen changed, the consumer already knows.
139
+ if (reason) {
140
+ onCloseRef.current({
141
+ reason: reason
142
+ });
143
+ }
144
+ }
145
+ onToggle(event);
146
+ }
147
+ }]);
148
+ }, [isMounted, onBeforeToggle, onToggle]);
101
149
 
102
150
  // Native `<dialog>.showModal()` traps focus but wraps through `<body>` at
103
151
  // the boundary (A → B → C → body → A). This hook intercepts Tab to wrap
@@ -143,7 +191,7 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
143
191
  // See notes/decisions/safari-escape-nested-popover-in-dialog.md
144
192
  var _useSafariEscapeFix = (0, _useSafariEscapeFix2.useSafariEscapeFix)({
145
193
  dialogRef: ownRef,
146
- isVisible: isVisible
194
+ isVisible: isMounted
147
195
  }),
148
196
  shouldIgnoreEscape = _useSafariEscapeFix.shouldIgnoreEscape;
149
197
 
@@ -176,49 +224,11 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
176
224
  closeReasonRef.current = 'escape';
177
225
  }, [dismissedBy, shouldIgnoreEscape]);
178
226
 
179
- // Handle backdrop interaction
180
- // Attached via bind-event-listener rather than a React prop so we avoid
181
- // a11y lint suppressions on the <dialog> element.
182
- // The click listener provides light dismiss consistently across browsers.
183
- // `isVisible` is in deps so the listener re-binds to the new <dialog>
184
- // element after a host unmount / remount cycle.
185
- (0, _react.useLayoutEffect)(function () {
186
- var dialog = ownRef.current;
187
- if (!dialog) {
188
- return;
189
- }
190
- return (0, _bindEventListener.bindAll)(dialog, [{
191
- type: 'click',
192
- listener: function listener(event) {
193
- if (event.target === event.currentTarget && dismissedBy === 'escape-and-outside-click') {
194
- closeReasonRef.current = 'overlay-click';
195
- // Calls native close because this is emulating the native light-dismiss behavior
196
- dialog.close();
197
- }
198
- }
199
- }, {
200
- type: 'toggle',
201
- listener: function listener(event) {
202
- if (event.newState === 'closed') {
203
- var reason = closeReasonRef.current;
204
- closeReasonRef.current = null;
205
- if (reason) {
206
- onClose({
207
- reason: reason
208
- });
209
- }
210
- }
211
-
212
- // Visibility cleanup runs after close-reason handling.
213
- onToggle(event);
214
- }
215
- }]);
216
- }, [dismissedBy, onClose, onToggle, isVisible]);
217
-
218
227
  // Unmount the `<dialog>` once exit completes so it does not leave an
219
- // empty `role="dialog"` element in the accessibility tree. On the next
220
- // open the element remounts and its effects run against the fresh element.
221
- if (!isVisible) {
228
+ // empty `role="dialog"` element in the accessibility tree. A later open
229
+ // remounts a fresh element, so the listener-binding and native-visibility
230
+ // effects run against that element.
231
+ if (!isMounted) {
222
232
  return null;
223
233
  }
224
234
  var shouldAnimateBackdrop = !shouldHideBackdrop && shouldAnimate;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.getClosestLayer = getClosestLayer;
7
+ function getClosestLayer(_ref) {
8
+ var element = _ref.element;
9
+ return element.closest('[popover], dialog');
10
+ }
@@ -4,10 +4,35 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.getFocusables = getFocusables;
7
- var _isInSameLayer = require("./is-in-same-layer");
8
- var _selectors = require("./selectors");
7
+ var _getClosestLayer = require("./get-closest-layer");
8
+ var focusableExclusions = ['[tabindex="-1"]', ':disabled', '[aria-disabled="true"]', '[aria-hidden="true"]'];
9
+ var exclusionSelector = focusableExclusions.map(function (selector) {
10
+ return ":not(".concat(selector, ")");
11
+ }).join('');
12
+ var focusableSelector = ['a[href]', 'area[href]', 'input:not([type="hidden"])', 'select', 'textarea', 'button', 'iframe', 'audio[controls]', 'video[controls]', '[contenteditable]:not([contenteditable="false"])', '[tabindex]'].map(function (selector) {
13
+ return "".concat(selector).concat(exclusionSelector);
14
+ }).join(',');
15
+ function isVisible(element) {
16
+ // Visibility filtering must not break focus navigation when the API is unsupported.
17
+ if (typeof element.checkVisibility !== 'function') {
18
+ return true;
19
+ }
20
+ try {
21
+ // Exclude elements hidden by display, content-visibility or visibility because
22
+ // they cannot receive focus. Offscreen, occluded and opacity-zero elements can
23
+ // receive focus, so they intentionally remain included.
24
+ return element.checkVisibility({
25
+ checkVisibilityCSS: true
26
+ });
27
+ } catch (_unused) {
28
+ return true;
29
+ }
30
+ }
31
+
9
32
  /**
10
33
  * Returns all focusable HTMLElements within the container, optionally filtered.
34
+ * Elements with `tabindex="-1"` are excluded because they can be focused
35
+ * programmatically but are not sequential focus navigation destinations.
11
36
  *
12
37
  * Focusables that belong to a nested top-layer scope (a `[popover]` or
13
38
  * `<dialog>` descendant of the container) are excluded - those elements
@@ -17,14 +42,24 @@ var _selectors = require("./selectors");
17
42
  function getFocusables(_ref) {
18
43
  var container = _ref.container,
19
44
  filter = _ref.filter;
20
- return Array.from(container.querySelectorAll(_selectors.selectors.focusable)).filter(function (element) {
21
- return element instanceof HTMLElement;
22
- }).filter(function (element) {
23
- return (0, _isInSameLayer.isInSameLayer)({
24
- element: element,
25
- container: container
26
- });
27
- }).filter(function (element) {
45
+ var containerLayer = (0, _getClosestLayer.getClosestLayer)({
46
+ element: container
47
+ });
48
+ return Array.from(container.querySelectorAll(focusableSelector)).filter(function (element) {
49
+ if (!(element instanceof HTMLElement)) {
50
+ return false;
51
+ }
52
+ if ((0, _getClosestLayer.getClosestLayer)({
53
+ element: element
54
+ }) !== containerLayer) {
55
+ return false;
56
+ }
57
+ if (element.closest('[inert]')) {
58
+ return false;
59
+ }
60
+ if (!isVisible(element)) {
61
+ return false;
62
+ }
28
63
  return !filter || filter(element, container);
29
64
  });
30
65
  }
@@ -5,17 +5,20 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.getNextFocusable = getNextFocusable;
7
7
  var _getFocusables = require("./get-focusables");
8
- var _selectors = require("./selectors");
9
8
  /**
10
9
  * Returns the next focusable element in the given direction relative to the
11
10
  * currently focused element within the container. Wraps around at both ends.
12
11
  *
13
- * Returns `null` if there is no focused element within the container or if
14
- * the focused element is not in the list of focusable elements.
12
+ * The currently focused element does not need to be tabbable. This prevents
13
+ * navigation from getting stuck on an element that was programmatically or
14
+ * pointer focused with `tabindex="-1"`. Negative-tab-index elements remain
15
+ * excluded as destinations.
16
+ *
17
+ * Returns `null` if there is no focused element within the container.
15
18
  */
16
19
 
17
20
  function getNextFocusable(_ref) {
18
- var _all$nextIndex;
21
+ var _all3;
19
22
  var container = _ref.container,
20
23
  direction = _ref.direction,
21
24
  filter = _ref.filter;
@@ -23,21 +26,43 @@ function getNextFocusable(_ref) {
23
26
  container: container,
24
27
  filter: filter
25
28
  });
26
- var current = container.querySelector(_selectors.selectors.focused);
27
- if (!(current instanceof HTMLElement)) {
29
+ var first = all[0];
30
+ var last = all[all.length - 1];
31
+
32
+ // Checking the elements directly narrows types from `HTMLElement | undefined`.
33
+ if (!first || !last) {
28
34
  return null;
29
35
  }
30
- var index = all.indexOf(current);
31
- if (index === -1) {
36
+ var current = container.ownerDocument.activeElement;
37
+ if (!(current instanceof HTMLElement) || !container.contains(current)) {
32
38
  return null;
33
39
  }
34
- var nextIndex = function () {
40
+ var index = all.indexOf(current);
41
+
42
+ // The focused element can be absent from `all` when it is non-tabbable or
43
+ // excluded by the filter. Find the first focusable element that follows it in
44
+ // the DOM, which identifies the nearest focusable destination in either direction.
45
+ if (index === -1) {
46
+ var _all;
47
+ var followingIndex = all.findIndex(function (element) {
48
+ return Boolean(current.compareDocumentPosition(element) & current.DOCUMENT_POSITION_FOLLOWING);
49
+ });
35
50
  if (direction === 'forwards') {
36
- return (index + 1) % all.length;
51
+ var _all$followingIndex;
52
+ // If nothing follows the current element, then wrap to the start.
53
+ return (_all$followingIndex = all[followingIndex]) !== null && _all$followingIndex !== void 0 ? _all$followingIndex : first;
37
54
  }
38
- var proposed = index - 1;
39
- // if going into negative numbers, loop back to the last item
40
- return proposed < 0 ? all.length - 1 : proposed;
41
- }();
42
- return (_all$nextIndex = all[nextIndex]) !== null && _all$nextIndex !== void 0 ? _all$nextIndex : null;
55
+
56
+ // The closest preceding element is immediately before the first following one.
57
+ // A missing adjacent entry means the backward sequence wraps to the end.
58
+ return (_all = all[followingIndex - 1]) !== null && _all !== void 0 ? _all : last;
59
+ }
60
+
61
+ // The current element is tabbable, so use its adjacent list entry and wrap
62
+ // when it is already at the boundary.
63
+ if (direction === 'forwards') {
64
+ var _all2;
65
+ return (_all2 = all[index + 1]) !== null && _all2 !== void 0 ? _all2 : first;
66
+ }
67
+ return (_all3 = all[index - 1]) !== null && _all3 !== void 0 ? _all3 : last;
43
68
  }
@@ -4,17 +4,15 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.isNestedLayerFocused = isNestedLayerFocused;
7
- var _browserApis = require("@atlaskit/browser-apis");
8
- var _isInSameLayer = require("./is-in-same-layer");
7
+ var _getClosestLayer = require("./get-closest-layer");
9
8
  /**
10
9
  * Returns `true` when `document.activeElement` is inside a nested top-layer
11
- * descendant of `container` (a `[popover]`, `<dialog>`, `[role="dialog"]` or
12
- * `[role="alertdialog"]` that is itself a descendant of `container`).
10
+ * descendant of `container` (a `[popover]` or `<dialog>` that is itself a
11
+ * descendant of `container`).
13
12
  */
14
13
  function isNestedLayerFocused(_ref) {
15
- var _getDocument;
16
14
  var container = _ref.container;
17
- var focused = (_getDocument = (0, _browserApis.getDocument)()) === null || _getDocument === void 0 ? void 0 : _getDocument.activeElement;
15
+ var focused = container.ownerDocument.activeElement;
18
16
  if (!(focused instanceof HTMLElement)) {
19
17
  return false;
20
18
  }
@@ -25,8 +23,9 @@ function isNestedLayerFocused(_ref) {
25
23
  }
26
24
 
27
25
  // If not in the same layer, must be in a nested layer
28
- return !(0, _isInSameLayer.isInSameLayer)({
29
- element: focused,
30
- container: container
26
+ return (0, _getClosestLayer.getClosestLayer)({
27
+ element: focused
28
+ }) !== (0, _getClosestLayer.getClosestLayer)({
29
+ element: container
31
30
  });
32
31
  }