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