@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
@@ -1,4 +1,4 @@
1
- /* popover.tsx generated by @compiled/babel-plugin v2.0.0 */
1
+ /* popover.tsx generated by @compiled/babel-plugin v3.0.1 */
2
2
  import "./popover.compiled.css";
3
3
  import { ax, ix } from "@compiled/react/runtime";
4
4
  import React, { forwardRef, useCallback, useId, useLayoutEffect, useMemo, useRef } from 'react';
@@ -89,7 +89,9 @@ const POPUP_ROLES = new Set(['menu', 'listbox', 'dialog', 'alertdialog', 'tree',
89
89
  * ### 📜 Browser dismiss is non-cancellable
90
90
  *
91
91
  * Per the popover spec, `beforetoggle` is cancellable on open transitions
92
- * only, not on close.
92
+ * only, not on close. Consumers should update controlled intent by setting
93
+ * `isOpen` to `false` from `onClose`. If controlled intent remains open after
94
+ * native close, the Popover remains closed until the prop changes.
93
95
  *
94
96
  * - Spec: <https://html.spec.whatwg.org/multipage/popover.html#the-popover-attribute>
95
97
  * - <https://github.com/whatwg/html/issues/8973>
@@ -128,45 +130,25 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
128
130
  const combinedRef = mergeRefs([ownRef, ref].filter(Boolean));
129
131
  // `useId()` colons are invalid in CSS selectors and popover target attributes.
130
132
  const popoverId = idProp !== null && idProp !== void 0 ? idProp : `popover-${autoId.replace(/:/g, '')}`;
131
- const {
132
- phase,
133
- onToggle
134
- } = useAnimatedVisibility({
135
- isOpen,
136
- shouldAnimate,
137
- elementRef: ownRef,
138
- onEnterFinish,
139
- onExitFinish
140
- });
141
-
142
- // True while the host element is mounted (any phase except `closed`).
143
- // Used as a dep so listener-rebind effects re-attach after a remount.
144
- const isVisible = phase !== 'closed';
145
133
 
146
- // Focus management: initial focus on entry (role-dependent), Tab cycling
147
- // for dialog roles. Restoration is native for outermost popovers; nested
148
- // focus-capturing roles are handled below via the `beforetoggle` snapshot.
149
- // Passing `phase` keeps `useFocusWrap` attached through the animated-exit
150
- // window (WCAG 2.4.3 guard). See notes/architecture/focus-restoration.md.
151
- useFocusWrap({
152
- elementRef: ownRef,
153
- role,
154
- phase
155
- });
156
- useInitialFocus({
157
- elementRef: ownRef,
158
- phase,
159
- role
160
- });
134
+ // Keep semantic callbacks current without rebinding native lifecycle
135
+ // listeners during the same commit that calls showPopover()/hidePopover().
136
+ const onCloseRef = useRef(onClose);
137
+ const roleRef = useRef(role);
138
+ useLayoutEffect(() => {
139
+ onCloseRef.current = onClose;
140
+ roleRef.current = role;
141
+ }, [onClose, role]);
161
142
 
162
143
  // Register with open layer observer so `closeLayers()` and open-count
163
144
  // subscriptions work. Only popup-like roles register as `popup`; passive
164
145
  // roles (tooltip, status, etc.) leave `type` undefined.
165
146
  const handleObserverClose = useCallback(() => {
166
- onClose === null || onClose === void 0 ? void 0 : onClose({
147
+ var _onCloseRef$current;
148
+ (_onCloseRef$current = onCloseRef.current) === null || _onCloseRef$current === void 0 ? void 0 : _onCloseRef$current.call(onCloseRef, {
167
149
  reason: 'programmatic'
168
150
  });
169
- }, [onClose]);
151
+ }, []);
170
152
  useNotifyOpenLayerObserver({
171
153
  type: role && POPUP_ROLES.has(role) ? 'popup' : undefined,
172
154
  isOpen,
@@ -208,51 +190,76 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
208
190
  const active = document.activeElement;
209
191
  previouslyFocusedElementRef.current = active instanceof HTMLElement ? active : null;
210
192
  }, []);
211
- const handleToggle = useCallback(event => {
212
- const element = ownRef.current;
213
- if (event.newState === 'open') {
214
- onToggle(event);
215
- return;
193
+ const restorePreviouslyFocusedElement = useCallback(() => {
194
+ const previouslyFocused = previouslyFocusedElementRef.current;
195
+ previouslyFocusedElementRef.current = null;
196
+ if (previouslyFocused !== null && previouslyFocused.isConnected && shouldFocusIntoPopover({
197
+ role: roleRef.current
198
+ })) {
199
+ previouslyFocused.focus({
200
+ preventScroll: true
201
+ });
202
+ }
203
+ }, []);
204
+ const handleToggleClosed = useCallback(_event => {
205
+ var _onCloseRef$current2;
206
+ // Nested-popover focus restoration fallback (browser only
207
+ // restores outermost; Firefox skips nested entirely).
208
+ // Restore only when the role moved focus in on open AND the
209
+ // close was Escape/programmatic. For light dismiss, the
210
+ // click target keeps focus per HTML spec
211
+ // (`focusPreviousElement=false`).
212
+ const reason = closeReasonRef.current;
213
+ const isProgrammatic = programmaticCloseRef.current;
214
+ if (reason === 'escape' || isProgrammatic) {
215
+ restorePreviouslyFocusedElement();
216
+ } else {
217
+ previouslyFocusedElementRef.current = null;
216
218
  }
217
- if (event.newState === 'closed') {
218
- if (element) {
219
- // Nested-popover focus restoration fallback (browser only
220
- // restores outermost; Firefox skips nested entirely).
221
- // Restore only when the role moved focus in on open AND the
222
- // close was Escape/programmatic. For light dismiss, the
223
- // click target keeps focus per HTML spec
224
- // (`focusPreviousElement=false`).
225
- const previouslyFocused = previouslyFocusedElementRef.current;
226
- const reason = closeReasonRef.current;
227
- const isProgrammatic = programmaticCloseRef.current;
228
- const shouldRestore = previouslyFocused !== null && previouslyFocused.isConnected && shouldFocusIntoPopover({
229
- role
230
- }) && (reason === 'escape' || isProgrammatic);
231
- if (shouldRestore && previouslyFocused) {
232
- previouslyFocused.focus({
233
- preventScroll: true
234
- });
235
- }
236
- previouslyFocusedElementRef.current = null;
237
- }
238
219
 
239
- // Programmatic close: consumer already knows.
240
- if (programmaticCloseRef.current) {
241
- // Reset reason so a stale 'escape' from a race with Escape keydown
242
- // does not corrupt the next browser-dismiss cycle.
243
- closeReasonRef.current = 'light-dismiss';
244
- } else {
245
- // Browser dismiss (Escape/click-outside).
246
- const reason = closeReasonRef.current;
247
- closeReasonRef.current = 'light-dismiss';
248
- // Optional: `manual` mode has no `onClose` in the forwarded type.
249
- onClose === null || onClose === void 0 ? void 0 : onClose({
250
- reason
251
- });
252
- }
220
+ // Reset reason so a stale 'escape' from a race with Escape keydown
221
+ // does not corrupt the next browser-dismiss cycle.
222
+ closeReasonRef.current = 'light-dismiss';
223
+
224
+ // Programmatic close: consumer already knows.
225
+ if (isProgrammatic) {
226
+ return;
253
227
  }
254
- onToggle(event);
255
- }, [onClose, onToggle, role]);
228
+
229
+ // Browser dismiss (Escape/click-outside).
230
+ // Optional: `manual` mode has no `onClose` in the forwarded type.
231
+ (_onCloseRef$current2 = onCloseRef.current) === null || _onCloseRef$current2 === void 0 ? void 0 : _onCloseRef$current2.call(onCloseRef, {
232
+ reason
233
+ });
234
+ }, [restorePreviouslyFocusedElement]);
235
+ const {
236
+ phase,
237
+ isMounted,
238
+ onBeforeToggle,
239
+ onToggle
240
+ } = useAnimatedVisibility({
241
+ isOpen,
242
+ shouldAnimate,
243
+ elementRef: ownRef,
244
+ onEnterFinish,
245
+ onExitFinish
246
+ });
247
+
248
+ // Focus management: initial focus on entry (role-dependent), Tab cycling
249
+ // for dialog roles. Restoration is native for outermost popovers; nested
250
+ // focus-capturing roles are handled above via the `beforetoggle` snapshot.
251
+ // Passing `phase` keeps `useFocusWrap` attached through the animated-exit
252
+ // window (WCAG 2.4.3 guard). See notes/architecture/focus-restoration.md.
253
+ useFocusWrap({
254
+ elementRef: ownRef,
255
+ role,
256
+ phase
257
+ });
258
+ useInitialFocus({
259
+ elementRef: ownRef,
260
+ phase,
261
+ role
262
+ });
256
263
 
257
264
  // Bind via `useLayoutEffect`, and BEFORE the show/hide layout effect
258
265
  // below, so listeners are attached when `showPopover()` synchronously
@@ -280,19 +287,28 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
280
287
  });
281
288
  const unbindToggle = bind(element, {
282
289
  type: 'toggle',
283
- listener: handleToggle
290
+ listener: event => {
291
+ if (event.newState === 'closed') {
292
+ handleToggleClosed(event);
293
+ }
294
+ onToggle(event);
295
+ }
284
296
  });
285
- // Snapshot `document.activeElement` before `useInitialFocus` moves it.
297
+ // Snapshot `document.activeElement` before `useInitialFocus` moves it,
298
+ // then advance the shared visibility lifecycle.
286
299
  const unbindBeforeToggle = bind(element, {
287
300
  type: 'beforetoggle',
288
- listener: handleBeforeToggle
301
+ listener: event => {
302
+ handleBeforeToggle(event);
303
+ onBeforeToggle(event);
304
+ }
289
305
  });
290
306
  return () => {
291
307
  unbindEscape();
292
308
  unbindToggle();
293
309
  unbindBeforeToggle();
294
310
  };
295
- }, [handleToggle, handleBeforeToggle, isVisible]);
311
+ }, [handleBeforeToggle, handleToggleClosed, isMounted, onBeforeToggle, onToggle]);
296
312
 
297
313
  // Show/hide based on isOpen. `showPopover`/`hidePopover` are no-ops when
298
314
  // already in the target state. Try/catch guards `InvalidStateError` on
@@ -305,8 +321,8 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
305
321
  if (isOpen) {
306
322
  programmaticCloseRef.current = false;
307
323
  // Clear stale 'escape' from a prior cycle: the ref outlives
308
- // the host element, and the no-animation path unmounts before
309
- // `handleToggle` can reset it.
324
+ // the host element, and interrupted close lifecycles may not reach
325
+ // `handleToggleClosed` to reset it.
310
326
  closeReasonRef.current = 'light-dismiss';
311
327
  try {
312
328
  element.showPopover();
@@ -327,7 +343,7 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
327
343
  // Unmount the host once exit completes so it does not leave an empty
328
344
  // `role`/`popover` element in the accessibility tree. The element
329
345
  // remounts on the next open; the `popoverId` is stable across opens.
330
- if (!isVisible) {
346
+ if (!isMounted) {
331
347
  return null;
332
348
  }
333
349
  return /*#__PURE__*/React.createElement("div", {
@@ -1,4 +1,4 @@
1
- /* popover-surface.tsx generated by @compiled/babel-plugin v2.0.0 */
1
+ /* popover-surface.tsx generated by @compiled/babel-plugin v3.0.1 */
2
2
  import "./popover-surface.compiled.css";
3
3
  import * as React from 'react';
4
4
  import { ax, ix } from "@compiled/react/runtime";
@@ -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
  import "./dialog-content.compiled.css";
3
3
  import { ax, ix } from "@compiled/react/runtime";
4
4
  import React, { forwardRef, useCallback, useId, useLayoutEffect, useRef } from 'react';
@@ -76,6 +76,13 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
76
76
  var ownRef = useRef(null);
77
77
  var combinedRef = mergeRefs([ownRef, ref]);
78
78
  var closeReasonRef = useRef(null);
79
+ var dismissedByRef = useRef(dismissedBy);
80
+ var onCloseRef = useRef(onClose);
81
+ // Keep native event handlers stable while ensuring they read the latest committed props.
82
+ useLayoutEffect(function () {
83
+ dismissedByRef.current = dismissedBy;
84
+ onCloseRef.current = onClose;
85
+ }, [dismissedBy, onClose]);
79
86
  var _useAnimatedVisibilit = useAnimatedVisibility({
80
87
  isOpen: isOpen,
81
88
  shouldAnimate: shouldAnimate,
@@ -84,11 +91,52 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
84
91
  onExitFinish: onExitFinish
85
92
  }),
86
93
  phase = _useAnimatedVisibilit.phase,
94
+ isMounted = _useAnimatedVisibilit.isMounted,
95
+ onBeforeToggle = _useAnimatedVisibilit.onBeforeToggle,
87
96
  onToggle = _useAnimatedVisibilit.onToggle;
88
97
 
89
- // True while the host element is mounted (any phase except `closed`).
90
- // Used as a dep so listener-rebind effects re-attach after a remount.
91
- var isVisible = phase !== 'closed';
98
+ // Ordering is important: bind native lifecycle listeners before the later
99
+ // layout effect calls showModal() or close(), because `beforetoggle` fires
100
+ // synchronously during those calls.
101
+ useLayoutEffect(function () {
102
+ if (!isMounted) {
103
+ return;
104
+ }
105
+ var dialog = ownRef.current;
106
+ if (!dialog) {
107
+ return;
108
+ }
109
+ return bindAll(dialog, [{
110
+ type: 'beforetoggle',
111
+ listener: onBeforeToggle
112
+ }, {
113
+ // Browsers retarget clicks on ::backdrop to the <dialog> element.
114
+ // Close on backdrop click when outside-click dismissal is enabled.
115
+ type: 'click',
116
+ listener: function listener(event) {
117
+ if (event.target === event.currentTarget && dismissedByRef.current === 'escape-and-outside-click') {
118
+ closeReasonRef.current = 'overlay-click';
119
+ dialog.close();
120
+ }
121
+ }
122
+ }, {
123
+ type: 'toggle',
124
+ listener: function listener(event) {
125
+ if (event.newState === 'closed') {
126
+ var reason = closeReasonRef.current;
127
+ closeReasonRef.current = null;
128
+ // A reason is only set for user-initiated closes. When React closes the
129
+ // dialog because isOpen changed, the consumer already knows.
130
+ if (reason) {
131
+ onCloseRef.current({
132
+ reason: reason
133
+ });
134
+ }
135
+ }
136
+ onToggle(event);
137
+ }
138
+ }]);
139
+ }, [isMounted, onBeforeToggle, onToggle]);
92
140
 
93
141
  // Native `<dialog>.showModal()` traps focus but wraps through `<body>` at
94
142
  // the boundary (A → B → C → body → A). This hook intercepts Tab to wrap
@@ -134,7 +182,7 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
134
182
  // See notes/decisions/safari-escape-nested-popover-in-dialog.md
135
183
  var _useSafariEscapeFix = useSafariEscapeFix({
136
184
  dialogRef: ownRef,
137
- isVisible: isVisible
185
+ isVisible: isMounted
138
186
  }),
139
187
  shouldIgnoreEscape = _useSafariEscapeFix.shouldIgnoreEscape;
140
188
 
@@ -167,49 +215,11 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
167
215
  closeReasonRef.current = 'escape';
168
216
  }, [dismissedBy, shouldIgnoreEscape]);
169
217
 
170
- // Handle backdrop interaction
171
- // Attached via bind-event-listener rather than a React prop so we avoid
172
- // a11y lint suppressions on the <dialog> element.
173
- // The click listener provides light dismiss consistently across browsers.
174
- // `isVisible` is in deps so the listener re-binds to the new <dialog>
175
- // element after a host unmount / remount cycle.
176
- useLayoutEffect(function () {
177
- var dialog = ownRef.current;
178
- if (!dialog) {
179
- return;
180
- }
181
- return bindAll(dialog, [{
182
- type: 'click',
183
- listener: function listener(event) {
184
- if (event.target === event.currentTarget && dismissedBy === 'escape-and-outside-click') {
185
- closeReasonRef.current = 'overlay-click';
186
- // Calls native close because this is emulating the native light-dismiss behavior
187
- dialog.close();
188
- }
189
- }
190
- }, {
191
- type: 'toggle',
192
- listener: function listener(event) {
193
- if (event.newState === 'closed') {
194
- var reason = closeReasonRef.current;
195
- closeReasonRef.current = null;
196
- if (reason) {
197
- onClose({
198
- reason: reason
199
- });
200
- }
201
- }
202
-
203
- // Visibility cleanup runs after close-reason handling.
204
- onToggle(event);
205
- }
206
- }]);
207
- }, [dismissedBy, onClose, onToggle, isVisible]);
208
-
209
218
  // Unmount the `<dialog>` once exit completes so it does not leave an
210
- // empty `role="dialog"` element in the accessibility tree. On the next
211
- // open the element remounts and its effects run against the fresh element.
212
- if (!isVisible) {
219
+ // empty `role="dialog"` element in the accessibility tree. A later open
220
+ // remounts a fresh element, so the listener-binding and native-visibility
221
+ // effects run against that element.
222
+ if (!isMounted) {
213
223
  return null;
214
224
  }
215
225
  var shouldAnimateBackdrop = !shouldHideBackdrop && shouldAnimate;
@@ -0,0 +1,4 @@
1
+ export function getClosestLayer(_ref) {
2
+ var element = _ref.element;
3
+ return element.closest('[popover], dialog');
4
+ }
@@ -1,7 +1,32 @@
1
- import { isInSameLayer } from './is-in-same-layer';
2
- import { selectors } from './selectors';
1
+ import { getClosestLayer } from './get-closest-layer';
2
+ var focusableExclusions = ['[tabindex="-1"]', ':disabled', '[aria-disabled="true"]', '[aria-hidden="true"]'];
3
+ var exclusionSelector = focusableExclusions.map(function (selector) {
4
+ return ":not(".concat(selector, ")");
5
+ }).join('');
6
+ 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) {
7
+ return "".concat(selector).concat(exclusionSelector);
8
+ }).join(',');
9
+ function isVisible(element) {
10
+ // Visibility filtering must not break focus navigation when the API is unsupported.
11
+ if (typeof element.checkVisibility !== 'function') {
12
+ return true;
13
+ }
14
+ try {
15
+ // Exclude elements hidden by display, content-visibility or visibility because
16
+ // they cannot receive focus. Offscreen, occluded and opacity-zero elements can
17
+ // receive focus, so they intentionally remain included.
18
+ return element.checkVisibility({
19
+ checkVisibilityCSS: true
20
+ });
21
+ } catch (_unused) {
22
+ return true;
23
+ }
24
+ }
25
+
3
26
  /**
4
27
  * Returns all focusable HTMLElements within the container, optionally filtered.
28
+ * Elements with `tabindex="-1"` are excluded because they can be focused
29
+ * programmatically but are not sequential focus navigation destinations.
5
30
  *
6
31
  * Focusables that belong to a nested top-layer scope (a `[popover]` or
7
32
  * `<dialog>` descendant of the container) are excluded - those elements
@@ -11,14 +36,24 @@ import { selectors } from './selectors';
11
36
  export function getFocusables(_ref) {
12
37
  var container = _ref.container,
13
38
  filter = _ref.filter;
14
- return Array.from(container.querySelectorAll(selectors.focusable)).filter(function (element) {
15
- return element instanceof HTMLElement;
16
- }).filter(function (element) {
17
- return isInSameLayer({
18
- element: element,
19
- container: container
20
- });
21
- }).filter(function (element) {
39
+ var containerLayer = getClosestLayer({
40
+ element: container
41
+ });
42
+ return Array.from(container.querySelectorAll(focusableSelector)).filter(function (element) {
43
+ if (!(element instanceof HTMLElement)) {
44
+ return false;
45
+ }
46
+ if (getClosestLayer({
47
+ element: element
48
+ }) !== containerLayer) {
49
+ return false;
50
+ }
51
+ if (element.closest('[inert]')) {
52
+ return false;
53
+ }
54
+ if (!isVisible(element)) {
55
+ return false;
56
+ }
22
57
  return !filter || filter(element, container);
23
58
  });
24
59
  }
@@ -2,14 +2,17 @@
2
2
  * Returns the next focusable element in the given direction relative to the
3
3
  * currently focused element within the container. Wraps around at both ends.
4
4
  *
5
- * Returns `null` if there is no focused element within the container or if
6
- * the focused element is not in the list of focusable elements.
5
+ * The currently focused element does not need to be tabbable. This prevents
6
+ * navigation from getting stuck on an element that was programmatically or
7
+ * pointer focused with `tabindex="-1"`. Negative-tab-index elements remain
8
+ * excluded as destinations.
9
+ *
10
+ * Returns `null` if there is no focused element within the container.
7
11
  */
8
12
 
9
13
  import { getFocusables } from './get-focusables';
10
- import { selectors } from './selectors';
11
14
  export function getNextFocusable(_ref) {
12
- var _all$nextIndex;
15
+ var _all3;
13
16
  var container = _ref.container,
14
17
  direction = _ref.direction,
15
18
  filter = _ref.filter;
@@ -17,21 +20,43 @@ export function getNextFocusable(_ref) {
17
20
  container: container,
18
21
  filter: filter
19
22
  });
20
- var current = container.querySelector(selectors.focused);
21
- if (!(current instanceof HTMLElement)) {
23
+ var first = all[0];
24
+ var last = all[all.length - 1];
25
+
26
+ // Checking the elements directly narrows types from `HTMLElement | undefined`.
27
+ if (!first || !last) {
22
28
  return null;
23
29
  }
24
- var index = all.indexOf(current);
25
- if (index === -1) {
30
+ var current = container.ownerDocument.activeElement;
31
+ if (!(current instanceof HTMLElement) || !container.contains(current)) {
26
32
  return null;
27
33
  }
28
- var nextIndex = function () {
34
+ var index = all.indexOf(current);
35
+
36
+ // The focused element can be absent from `all` when it is non-tabbable or
37
+ // excluded by the filter. Find the first focusable element that follows it in
38
+ // the DOM, which identifies the nearest focusable destination in either direction.
39
+ if (index === -1) {
40
+ var _all;
41
+ var followingIndex = all.findIndex(function (element) {
42
+ return Boolean(current.compareDocumentPosition(element) & current.DOCUMENT_POSITION_FOLLOWING);
43
+ });
29
44
  if (direction === 'forwards') {
30
- return (index + 1) % all.length;
45
+ var _all$followingIndex;
46
+ // If nothing follows the current element, then wrap to the start.
47
+ return (_all$followingIndex = all[followingIndex]) !== null && _all$followingIndex !== void 0 ? _all$followingIndex : first;
31
48
  }
32
- var proposed = index - 1;
33
- // if going into negative numbers, loop back to the last item
34
- return proposed < 0 ? all.length - 1 : proposed;
35
- }();
36
- return (_all$nextIndex = all[nextIndex]) !== null && _all$nextIndex !== void 0 ? _all$nextIndex : null;
49
+
50
+ // The closest preceding element is immediately before the first following one.
51
+ // A missing adjacent entry means the backward sequence wraps to the end.
52
+ return (_all = all[followingIndex - 1]) !== null && _all !== void 0 ? _all : last;
53
+ }
54
+
55
+ // The current element is tabbable, so use its adjacent list entry and wrap
56
+ // when it is already at the boundary.
57
+ if (direction === 'forwards') {
58
+ var _all2;
59
+ return (_all2 = all[index + 1]) !== null && _all2 !== void 0 ? _all2 : first;
60
+ }
61
+ return (_all3 = all[index - 1]) !== null && _all3 !== void 0 ? _all3 : last;
37
62
  }
@@ -1,15 +1,13 @@
1
- import { getDocument } from '@atlaskit/browser-apis';
2
- import { isInSameLayer } from './is-in-same-layer';
1
+ import { getClosestLayer } from './get-closest-layer';
3
2
 
4
3
  /**
5
4
  * Returns `true` when `document.activeElement` is inside a nested top-layer
6
- * descendant of `container` (a `[popover]`, `<dialog>`, `[role="dialog"]` or
7
- * `[role="alertdialog"]` that is itself a descendant of `container`).
5
+ * descendant of `container` (a `[popover]` or `<dialog>` that is itself a
6
+ * descendant of `container`).
8
7
  */
9
8
  export function isNestedLayerFocused(_ref) {
10
- var _getDocument;
11
9
  var container = _ref.container;
12
- var focused = (_getDocument = getDocument()) === null || _getDocument === void 0 ? void 0 : _getDocument.activeElement;
10
+ var focused = container.ownerDocument.activeElement;
13
11
  if (!(focused instanceof HTMLElement)) {
14
12
  return false;
15
13
  }
@@ -20,8 +18,9 @@ export function isNestedLayerFocused(_ref) {
20
18
  }
21
19
 
22
20
  // If not in the same layer, must be in a nested layer
23
- return !isInSameLayer({
24
- element: focused,
25
- container: container
21
+ return getClosestLayer({
22
+ element: focused
23
+ }) !== getClosestLayer({
24
+ element: container
26
25
  });
27
26
  }