@atlaskit/top-layer 0.11.0 → 0.12.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 (96) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cjs/animations/presets.js +5 -0
  3. package/dist/cjs/entry-points/use-arrow-navigation.js +3 -2
  4. package/dist/cjs/internal/placement-to-position-area.js +43 -0
  5. package/dist/cjs/internal/placement-to-try-fallbacks.js +82 -0
  6. package/dist/cjs/internal/use-anchor-position.js +18 -131
  7. package/dist/cjs/internal/use-animated-visibility.js +84 -7
  8. package/dist/cjs/popover/popover.js +2 -0
  9. package/dist/cjs/popup/popup-content.js +55 -15
  10. package/dist/cjs/popup/popup-context.js +14 -33
  11. package/dist/cjs/popup/popup-provider.js +15 -0
  12. package/dist/cjs/popup/popup-trigger-function.js +9 -6
  13. package/dist/cjs/popup/popup-trigger.js +14 -9
  14. package/dist/cjs/popup/popup.js +7 -7
  15. package/dist/cjs/popup/use-maybe-popup-context.js +17 -0
  16. package/dist/cjs/popup/use-popup-context.js +20 -0
  17. package/dist/cjs/use-arrow-navigation/index.js +3 -2
  18. package/dist/cjs/use-arrow-navigation/is-at-current-menu-level.js +87 -0
  19. package/dist/cjs/use-arrow-navigation/use-arrow-navigation.js +0 -82
  20. package/dist/es2019/animations/presets.js +5 -0
  21. package/dist/es2019/entry-points/use-arrow-navigation.js +2 -1
  22. package/dist/es2019/internal/placement-to-position-area.js +40 -0
  23. package/dist/es2019/internal/placement-to-try-fallbacks.js +77 -0
  24. package/dist/es2019/internal/use-anchor-position.js +2 -116
  25. package/dist/es2019/internal/use-animated-visibility.js +84 -7
  26. package/dist/es2019/popover/popover.js +2 -0
  27. package/dist/es2019/popup/popup-content.js +55 -14
  28. package/dist/es2019/popup/popup-context.js +14 -30
  29. package/dist/es2019/popup/popup-provider.js +10 -0
  30. package/dist/es2019/popup/popup-trigger-function.js +8 -5
  31. package/dist/es2019/popup/popup-trigger.js +14 -8
  32. package/dist/es2019/popup/popup.js +4 -4
  33. package/dist/es2019/popup/use-maybe-popup-context.js +12 -0
  34. package/dist/es2019/popup/use-popup-context.js +15 -0
  35. package/dist/es2019/use-arrow-navigation/index.js +2 -1
  36. package/dist/es2019/use-arrow-navigation/is-at-current-menu-level.js +81 -0
  37. package/dist/es2019/use-arrow-navigation/use-arrow-navigation.js +0 -81
  38. package/dist/esm/animations/presets.js +5 -0
  39. package/dist/esm/entry-points/use-arrow-navigation.js +2 -1
  40. package/dist/esm/internal/placement-to-position-area.js +38 -0
  41. package/dist/esm/internal/placement-to-try-fallbacks.js +76 -0
  42. package/dist/esm/internal/use-anchor-position.js +16 -127
  43. package/dist/esm/internal/use-animated-visibility.js +84 -7
  44. package/dist/esm/popover/popover.js +2 -0
  45. package/dist/esm/popup/popup-content.js +55 -14
  46. package/dist/esm/popup/popup-context.js +14 -30
  47. package/dist/esm/popup/popup-provider.js +10 -0
  48. package/dist/esm/popup/popup-trigger-function.js +8 -5
  49. package/dist/esm/popup/popup-trigger.js +14 -8
  50. package/dist/esm/popup/popup.js +6 -6
  51. package/dist/esm/popup/use-maybe-popup-context.js +12 -0
  52. package/dist/esm/popup/use-popup-context.js +15 -0
  53. package/dist/esm/use-arrow-navigation/index.js +2 -1
  54. package/dist/esm/use-arrow-navigation/is-at-current-menu-level.js +81 -0
  55. package/dist/esm/use-arrow-navigation/use-arrow-navigation.js +0 -81
  56. package/dist/types/animations/types.d.ts +5 -1
  57. package/dist/types/entry-points/use-arrow-navigation.d.ts +2 -1
  58. package/dist/types/internal/placement-to-position-area.d.ts +21 -0
  59. package/dist/types/internal/placement-to-try-fallbacks.d.ts +36 -0
  60. package/dist/types/internal/use-anchor-position.d.ts +0 -55
  61. package/dist/types/internal/use-animated-visibility.d.ts +8 -2
  62. package/dist/types/popover/types.d.ts +8 -0
  63. package/dist/types/popup/popup-context.d.ts +25 -25
  64. package/dist/types/popup/popup-provider.d.ts +10 -0
  65. package/dist/types/popup/types.d.ts +12 -0
  66. package/dist/types/popup/use-maybe-popup-context.d.ts +8 -0
  67. package/dist/types/popup/use-popup-context.d.ts +7 -0
  68. package/dist/types/use-arrow-navigation/index.d.ts +2 -1
  69. package/dist/types/use-arrow-navigation/is-at-current-menu-level.d.ts +48 -0
  70. package/dist/types/use-arrow-navigation/use-arrow-navigation.d.ts +0 -48
  71. package/dist/types-ts4.5/animations/types.d.ts +5 -1
  72. package/dist/types-ts4.5/entry-points/use-arrow-navigation.d.ts +2 -1
  73. package/dist/types-ts4.5/internal/placement-to-position-area.d.ts +21 -0
  74. package/dist/types-ts4.5/internal/placement-to-try-fallbacks.d.ts +36 -0
  75. package/dist/types-ts4.5/internal/use-anchor-position.d.ts +0 -55
  76. package/dist/types-ts4.5/internal/use-animated-visibility.d.ts +8 -2
  77. package/dist/types-ts4.5/popover/types.d.ts +8 -0
  78. package/dist/types-ts4.5/popup/popup-context.d.ts +25 -25
  79. package/dist/types-ts4.5/popup/popup-provider.d.ts +10 -0
  80. package/dist/types-ts4.5/popup/types.d.ts +12 -0
  81. package/dist/types-ts4.5/popup/use-maybe-popup-context.d.ts +8 -0
  82. package/dist/types-ts4.5/popup/use-popup-context.d.ts +7 -0
  83. package/dist/types-ts4.5/use-arrow-navigation/index.d.ts +2 -1
  84. package/dist/types-ts4.5/use-arrow-navigation/is-at-current-menu-level.d.ts +48 -0
  85. package/dist/types-ts4.5/use-arrow-navigation/use-arrow-navigation.d.ts +0 -48
  86. package/package.json +2 -2
  87. package/dist/cjs/arrow/index.js +0 -131
  88. package/dist/cjs/arrow/types.js +0 -5
  89. package/dist/es2019/arrow/index.js +0 -269
  90. package/dist/es2019/arrow/types.js +0 -1
  91. package/dist/esm/arrow/index.js +0 -124
  92. package/dist/esm/arrow/types.js +0 -1
  93. package/dist/types/arrow/index.d.ts +0 -24
  94. package/dist/types/arrow/types.d.ts +0 -29
  95. package/dist/types-ts4.5/arrow/index.d.ts +0 -24
  96. package/dist/types-ts4.5/arrow/types.d.ts +0 -29
@@ -44,6 +44,7 @@ export function useAnimatedVisibility(_ref) {
44
44
  var isOpen = _ref.isOpen,
45
45
  animate = _ref.animate,
46
46
  elementRef = _ref.elementRef,
47
+ onEnterFinish = _ref.onEnterFinish,
47
48
  onExitFinish = _ref.onExitFinish;
48
49
  // Resolve the animation preset and inject its CSS (idempotent).
49
50
  // Returns `null` when `animate` is falsy.
@@ -80,13 +81,76 @@ export function useAnimatedVisibility(_ref) {
80
81
  setShowChildren(false);
81
82
  }
82
83
 
83
- // Keep a stable ref to the latest `onExitFinish` so the effect and
84
- // timeout closures below always call the current callback without
85
- // needing `onExitFinish` in dependency arrays (which would reset
86
- // the timeout on every new closure).
84
+ // Keep stable refs to the latest callbacks so effects and timeout closures
85
+ // always call the current callback without needing them in dependency arrays.
86
+ var onEnterFinishRef = useRef(onEnterFinish);
87
+ onEnterFinishRef.current = onEnterFinish;
87
88
  var onExitFinishRef = useRef(onExitFinish);
88
89
  onExitFinishRef.current = onExitFinish;
89
90
 
91
+ // ── Animated entry effect ──
92
+ //
93
+ // Fires `onEnterFinish` after the entry transition completes.
94
+ // When there is no animation, fires it immediately via a separate effect.
95
+ //
96
+ // A fallback timeout (enterDurationMs + 50ms buffer) ensures it always
97
+ // fires even if `transitionend` is never emitted (e.g. zero-duration,
98
+ // browser quirk, or reduced motion).
99
+ useEffect(function () {
100
+ var _preset$enterDuration;
101
+ if (!isOpen || !showChildren || !willAnimate) {
102
+ return;
103
+ }
104
+ var element = elementRef.current;
105
+ if (!element) {
106
+ return;
107
+ }
108
+
109
+ // Both the transitionend listener and the fallback timeout are guarded by
110
+ // a shared `hasFired` flag to prevent double-firing. This handles the race
111
+ // where the 350ms transition finishes after the 400ms fallback fires
112
+ // (or vice versa): whichever fires first sets `hasFired = true`, and the
113
+ // second path becomes a no-op.
114
+ var hasFired = false;
115
+ function handleTransitionEnd() {
116
+ var _onEnterFinishRef$cur;
117
+ if (hasFired) {
118
+ return;
119
+ }
120
+ hasFired = true;
121
+ clearTimeout(fallbackId);
122
+ unbind();
123
+ (_onEnterFinishRef$cur = onEnterFinishRef.current) === null || _onEnterFinishRef$cur === void 0 || _onEnterFinishRef$cur.call(onEnterFinishRef);
124
+ }
125
+ var fallbackId = setTimeout(handleTransitionEnd, ((_preset$enterDuration = preset === null || preset === void 0 ? void 0 : preset.enterDurationMs) !== null && _preset$enterDuration !== void 0 ? _preset$enterDuration : 0) + 50);
126
+ var unbind = bind(element, {
127
+ type: 'transitionend',
128
+ listener: handleTransitionEnd,
129
+ options: {
130
+ once: true
131
+ }
132
+ });
133
+ return function () {
134
+ hasFired = true;
135
+ clearTimeout(fallbackId);
136
+ unbind();
137
+ };
138
+ }, [isOpen, showChildren, willAnimate, preset === null || preset === void 0 ? void 0 : preset.enterDurationMs, elementRef]);
139
+
140
+ // ── Non-animated entry callback ──
141
+ //
142
+ // When there is no animation, fire `onEnterFinish` immediately on mount and after `isOpen` goes false->true.
143
+ // The initial value is false to make sure it fires on mount if the current `isOpen` is true.
144
+ var prevIsOpenForEntryRef = useRef(false);
145
+ useEffect(function () {
146
+ var prevIsOpen = prevIsOpenForEntryRef.current;
147
+ prevIsOpenForEntryRef.current = isOpen;
148
+ if (!prevIsOpen && isOpen && !willAnimate) {
149
+ var _onEnterFinishRef$cur2;
150
+ (_onEnterFinishRef$cur2 = onEnterFinishRef.current) === null || _onEnterFinishRef$cur2 === void 0 || _onEnterFinishRef$cur2.call(onEnterFinishRef);
151
+ }
152
+ }, [isOpen, willAnimate]);
153
+
90
154
  // ── Animated exit effect ──
91
155
  //
92
156
  // Runs when all three conditions are true:
@@ -111,20 +175,32 @@ export function useAnimatedVisibility(_ref) {
111
175
  if (!element) {
112
176
  return;
113
177
  }
114
- function finish() {
178
+
179
+ // Both the transitionend listener and the fallback timeout are guarded by
180
+ // a shared `hasFired` flag to prevent double-firing. This handles the race
181
+ // where the 350ms transition finishes after the 400ms fallback fires
182
+ // (or vice versa): whichever fires first sets `hasFired = true`, and the
183
+ // second path becomes a no-op.
184
+ var hasFired = false;
185
+ function handleTransitionEnd() {
115
186
  var _onExitFinishRef$curr;
187
+ if (hasFired) {
188
+ return;
189
+ }
190
+ hasFired = true;
116
191
  clearTimeout(fallbackId);
192
+ unbind();
117
193
  setShowChildren(false);
118
194
  (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 || _onExitFinishRef$curr.call(onExitFinishRef);
119
195
  }
120
196
 
121
197
  // Fallback: unmount even if transitionend never fires
122
- var fallbackId = setTimeout(finish, ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50);
198
+ var fallbackId = setTimeout(handleTransitionEnd, ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50);
123
199
 
124
200
  // Primary: listen for the CSS transition to complete
125
201
  var unbind = bind(element, {
126
202
  type: 'transitionend',
127
- listener: finish,
203
+ listener: handleTransitionEnd,
128
204
  options: {
129
205
  once: true
130
206
  }
@@ -132,6 +208,7 @@ export function useAnimatedVisibility(_ref) {
132
208
 
133
209
  // Cleanup on re-render or unmount: cancel pending timeout and listener
134
210
  return function () {
211
+ hasFired = true;
135
212
  clearTimeout(fallbackId);
136
213
  unbind();
137
214
  };
@@ -66,6 +66,7 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
66
66
  modeProp = _ref$mode === void 0 ? 'auto' : _ref$mode,
67
67
  onClose = _ref.onClose,
68
68
  onOpenChange = _ref.onOpenChange,
69
+ onEnterFinish = _ref.onEnterFinish,
69
70
  onExitFinish = _ref.onExitFinish,
70
71
  animate = _ref.animate,
71
72
  placement = _ref.placement,
@@ -89,6 +90,7 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
89
90
  isOpen: isOpen,
90
91
  animate: animate,
91
92
  elementRef: ownRef,
93
+ onEnterFinish: onEnterFinish,
92
94
  onExitFinish: onExitFinish
93
95
  }),
94
96
  showChildren = _useAnimatedVisibilit.showChildren,
@@ -3,11 +3,13 @@ import _extends from "@babel/runtime/helpers/extends";
3
3
  import { ax, ix } from "@compiled/react/runtime";
4
4
  import React, { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
5
5
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
6
+ import { prefersReducedMotion } from '../internal/reduced-motion';
6
7
  import { roleToAriaHasPopup, shouldFocusIntoPopover } from '../internal/role-types';
7
8
  import { useAnchorPosition } from '../internal/use-anchor-position';
8
9
  import { useWidthFromAnchor } from '../internal/use-width-from-anchor';
9
10
  import { Popover } from '../popover/popover';
10
- import { useMaybePopupContext } from './popup-context';
11
+ import { useMaybePopupContext } from './use-maybe-popup-context';
12
+
11
13
  /**
12
14
  * Thin context wrapper that composes `Popover` + `useAnchorPosition`.
13
15
  *
@@ -16,7 +18,7 @@ import { useMaybePopupContext } from './popup-context';
16
18
  * use `Popover` + `useAnchorPosition` directly.
17
19
  */
18
20
  export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, ref) {
19
- var _ref2, _ref3, _ctx$popoverRef;
21
+ var _ref2, _ctx$popoverRef;
20
22
  var children = _ref.children,
21
23
  role = _ref.role,
22
24
  label = _ref.label,
@@ -32,7 +34,9 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
32
34
  onCloseProp = _ref.onClose,
33
35
  testIdProp = _ref.testId,
34
36
  forceFallbackPositioningProp = _ref.forceFallbackPositioning,
35
- idProp = _ref.id;
37
+ idProp = _ref.id,
38
+ onEnterFinish = _ref.onEnterFinish,
39
+ onExitFinish = _ref.onExitFinish;
36
40
  // When inside <Popup>, context provides defaults. Props always win.
37
41
  var ctx = useMaybePopupContext();
38
42
  var triggerRef = triggerRefProp !== null && triggerRefProp !== void 0 ? triggerRefProp : ctx === null || ctx === void 0 ? void 0 : ctx.triggerRef;
@@ -41,16 +45,47 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
41
45
  var testId = testIdProp !== null && testIdProp !== void 0 ? testIdProp : ctx === null || ctx === void 0 ? void 0 : ctx.testId;
42
46
  var forceFallbackPositioning = forceFallbackPositioningProp !== null && forceFallbackPositioningProp !== void 0 ? forceFallbackPositioningProp : ctx === null || ctx === void 0 ? void 0 : ctx.forceFallbackPositioning;
43
47
 
44
- // Resolve isOpen: prop wins, then context.
45
- // Inside the <Popup> compound, ctx.isOpen tracks the browser's toggle state.
46
- var isOpen = (_ref3 = isOpenProp !== null && isOpenProp !== void 0 ? isOpenProp : ctx === null || ctx === void 0 ? void 0 : ctx.isOpen) !== null && _ref3 !== void 0 ? _ref3 : false;
47
-
48
- // Combine internal state sync with consumer's onOpenChange callback.
49
- // The Popup compound needs setIsOpen to keep aria-expanded on the trigger
50
- // in sync, and the consumer may also want to respond to open/close events
51
- // (e.g. for focus management).
52
- var setIsOpen = ctx === null || ctx === void 0 ? void 0 : ctx.setIsOpen;
48
+ // isOpen: prop wins, otherwise derive from context popupState.
49
+ // 'animating-open' counts as open so <Popover> stays visible during entry animation.
50
+ var isOpen = isOpenProp !== null && isOpenProp !== void 0 ? isOpenProp : ctx !== null ? ctx.popupState === 'open' || ctx.popupState === 'animating-open' : false;
51
+ var setPopupState = ctx === null || ctx === void 0 ? void 0 : ctx.setPopupState;
53
52
  var consumerOnOpenChange = ctx === null || ctx === void 0 ? void 0 : ctx.onOpenChange;
53
+
54
+ // ── TPopupState ──
55
+ //
56
+ // popupState is driven by handleOpenChange (below), which fires on every browser
57
+ // toggle event (showPopover / hidePopover / light-dismiss).
58
+ //
59
+ // Exception — initial mount with isOpen=true (prop-controlled usage):
60
+ // handleOpenChange only fires from the Popover's toggle event listener, which
61
+ // is registered in a useEffect. On the first render, useEffect hasn't run yet,
62
+ // so handleOpenChange never fires. We sync popupState here instead.
63
+ //
64
+ // Transitions when animations are active:
65
+ // closed → animating-open → open (enter)
66
+ // open → animating-closed → closed (exit, via onExitFinish)
67
+ //
68
+ // Without animation: closed ↔ open directly.
69
+ var willAnimate = Boolean(animate) && !prefersReducedMotion();
70
+ var hasMountedRef = useRef(false);
71
+ useLayoutEffect(function () {
72
+ if (hasMountedRef.current) {
73
+ return;
74
+ }
75
+ hasMountedRef.current = true;
76
+ if (isOpen) {
77
+ setPopupState === null || setPopupState === void 0 || setPopupState(willAnimate ? 'animating-open' : 'open');
78
+ }
79
+ }, [isOpen, setPopupState, willAnimate]);
80
+ var handleEnterFinish = useCallback(function () {
81
+ setPopupState === null || setPopupState === void 0 || setPopupState('open');
82
+ onEnterFinish === null || onEnterFinish === void 0 || onEnterFinish();
83
+ }, [setPopupState, onEnterFinish]);
84
+ var handleExitFinish = useCallback(function () {
85
+ setPopupState === null || setPopupState === void 0 || setPopupState('closed');
86
+ onExitFinish === null || onExitFinish === void 0 || onExitFinish();
87
+ }, [setPopupState, onExitFinish]);
88
+
54
89
  // Note: onOpenChange composition is handled by handleOpenChange below,
55
90
  // which also adds nested-popover focus restoration.
56
91
 
@@ -82,7 +117,11 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
82
117
  // See: notes/architecture/focus-restoration.md
83
118
  var handleOpenChange = useCallback(function (args) {
84
119
  // Sync compound state
85
- setIsOpen === null || setIsOpen === void 0 || setIsOpen(args.isOpen);
120
+ if (args.isOpen) {
121
+ setPopupState === null || setPopupState === void 0 || setPopupState(willAnimate ? 'animating-open' : 'open');
122
+ } else {
123
+ setPopupState === null || setPopupState === void 0 || setPopupState(willAnimate ? 'animating-closed' : 'closed');
124
+ }
86
125
  consumerOnOpenChange === null || consumerOnOpenChange === void 0 || consumerOnOpenChange(args);
87
126
 
88
127
  // ── Nested popover focus restoration fallback ──
@@ -112,7 +151,7 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
112
151
  });
113
152
  }
114
153
  }
115
- }, [setIsOpen, consumerOnOpenChange, triggerRef, role]);
154
+ }, [consumerOnOpenChange, triggerRef, role, setPopupState, willAnimate]);
116
155
 
117
156
  // ── Anchor positioning ──
118
157
  // Compose useAnchorPosition with Popover. The hook positions the popover
@@ -151,6 +190,8 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
151
190
  labelledBy: labelledBy,
152
191
  onClose: onClose !== null && onClose !== void 0 ? onClose : undefined,
153
192
  onOpenChange: handleOpenChange,
193
+ onEnterFinish: handleEnterFinish,
194
+ onExitFinish: handleExitFinish,
154
195
  testId: testId,
155
196
  children: children,
156
197
  // Standalone consumers can pass an explicit `id` - used by their own
@@ -1,36 +1,20 @@
1
- import { createContext, useContext } from 'react';
2
- var PopupContext = /*#__PURE__*/createContext(null);
1
+ import { createContext } from 'react';
3
2
 
4
3
  /**
5
- * __Popup provider__
4
+ * Represents the lifecycle state of the popup.
6
5
  *
7
- * Provides shared popup state (placement, popoverId, triggerRef,
8
- * isOpen, onClose) to compound child components such as `Popup.Trigger` and
9
- * `Popup.Content`.
10
- */
11
- export var PopupProvider = PopupContext.Provider;
12
-
13
- /**
14
- * Returns the nearest `Popup` context value.
6
+ * - `'closed'`: fully closed, not visible, no animation playing.
7
+ * - `'animating-open'`: entry animation is playing (popup is opening but not yet settled).
8
+ * - `'open'`: fully open, browser popover is showing, entry animation complete.
9
+ * - `'animating-closed'`: exit animation is playing (popup is closing but still visible).
15
10
  *
16
- * Throws if called outside of a `<Popup>` compound component.
17
- */
18
- // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
19
- export function usePopupContext() {
20
- var ctx = useContext(PopupContext);
21
- if (ctx === null) {
22
- throw new Error('@atlaskit/top-layer: Popup compound components must be used within <Popup>.');
23
- }
24
- return ctx;
25
- }
26
-
27
- /**
28
- * Returns the popup context if available, or `null` when used outside `<Popup>`.
11
+ * Used to derive `aria-expanded` on the trigger:
12
+ * - `aria-expanded` is `true` in `'animating-open'`, `'open'`, and `'animating-closed'`.
29
13
  *
30
- * Used by `PopupContent` to support standalone usage (e.g. tooltip)
31
- * where values are passed as props instead of coming from a compound component.
14
+ * This avoids screen readers announcing the popup as closed while it is
15
+ * still visually present on screen during animations, and prevents consumers
16
+ * that conditionally show/hide trigger UI based on `aria-expanded` from hiding
17
+ * the trigger (and thus the popup anchor) mid-animation.
32
18
  */
33
- // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
34
- export function useMaybePopupContext() {
35
- return useContext(PopupContext);
36
- }
19
+
20
+ export var PopupContext = /*#__PURE__*/createContext(null);
@@ -0,0 +1,10 @@
1
+ import { PopupContext } from './popup-context';
2
+
3
+ /**
4
+ * __Popup provider__
5
+ *
6
+ * Provides shared popup state (placement, popoverId, triggerRef,
7
+ * isOpen, onClose) to compound child components such as `Popup.Trigger` and
8
+ * `Popup.Content`.
9
+ */
10
+ export var PopupProvider = PopupContext.Provider;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useMemo } from 'react';
2
- import { usePopupContext } from './popup-context';
2
+ import { usePopupContext } from './use-popup-context';
3
3
 
4
4
  /**
5
5
  * Props passed to the `TriggerFunction` render-prop child.
@@ -41,7 +41,7 @@ export function PopupTriggerFunction(_ref) {
41
41
  triggerRef = _usePopupContext.triggerRef,
42
42
  popoverRef = _usePopupContext.popoverRef,
43
43
  popoverId = _usePopupContext.popoverId,
44
- isOpen = _usePopupContext.isOpen,
44
+ popupState = _usePopupContext.popupState,
45
45
  ariaHasPopup = _usePopupContext.ariaHasPopup;
46
46
  var ref = useCallback(function (node) {
47
47
  triggerRef.current = node;
@@ -57,14 +57,17 @@ export function PopupTriggerFunction(_ref) {
57
57
  }, [popoverRef]);
58
58
  var ariaAttributes = useMemo(function () {
59
59
  return {
60
- 'aria-expanded': isOpen,
60
+ // aria-expanded stays true throughout entry and exit animations so screen readers
61
+ // don't announce the popup as closed while it's still visible, and consumers
62
+ // relying on aria-expanded to show/hide trigger UI don't lose the anchor mid-animation.
63
+ 'aria-expanded': popupState === 'open' || popupState === 'animating-open' || popupState === 'animating-closed',
61
64
  'aria-controls': popoverId,
62
65
  'aria-haspopup': ariaHasPopup
63
66
  };
64
- }, [isOpen, popoverId, ariaHasPopup]);
67
+ }, [popupState, popoverId, ariaHasPopup]);
65
68
  return children({
66
69
  ref: ref,
67
- isOpen: isOpen,
70
+ isOpen: popupState === 'open' || popupState === 'animating-open',
68
71
  popoverId: popoverId,
69
72
  toggle: toggle,
70
73
  ariaAttributes: ariaAttributes
@@ -1,6 +1,7 @@
1
1
  import React, { isValidElement, useCallback } from 'react';
2
2
  import { Slot } from '../internal/slot';
3
- import { usePopupContext } from './popup-context';
3
+ import { usePopupContext } from './use-popup-context';
4
+
4
5
  /**
5
6
  * Wraps a single trigger element via `cloneElement`. Attaches aria
6
7
  * attributes, the toggle handler, and the anchor-name. `aria-haspopup`
@@ -18,14 +19,15 @@ export function PopupTrigger(_ref) {
18
19
  triggerRef = _usePopupContext.triggerRef,
19
20
  popoverRef = _usePopupContext.popoverRef,
20
21
  popoverId = _usePopupContext.popoverId,
21
- isOpen = _usePopupContext.isOpen,
22
+ popupState = _usePopupContext.popupState,
22
23
  ariaHasPopup = _usePopupContext.ariaHasPopup;
23
24
  var childOnClick = /*#__PURE__*/isValidElement(children) ? children.props.onClick : undefined;
24
25
 
25
- // Track latest isOpen via ref so the click handler keeps a stable
26
- // identity (cloneElement relies on it).
27
- var isOpenRef = React.useRef(isOpen);
28
- isOpenRef.current = isOpen;
26
+ // Use a ref to track the latest isOpen value so the click handler
27
+ // does not need isOpen in its dependency array (which would recreate
28
+ // the handler on every open/close, breaking cloneElement identity).
29
+ var isOpenRef = React.useRef(popupState === 'open' || popupState === 'animating-open');
30
+ isOpenRef.current = popupState === 'open' || popupState === 'animating-open';
29
31
  var handleClick = useCallback(function (event) {
30
32
  var popoverEl = popoverRef.current;
31
33
  if (popoverEl) {
@@ -45,8 +47,12 @@ export function PopupTrigger(_ref) {
45
47
  }, [popoverRef, childOnClick]);
46
48
  return /*#__PURE__*/React.createElement(Slot, {
47
49
  ref: triggerRef,
48
- onClick: handleClick,
49
- "aria-expanded": isOpen,
50
+ onClick: handleClick
51
+ // aria-expanded stays true throughout entry and exit animations so screen readers
52
+ // don't announce the popup as closed while it's still visible, and consumers
53
+ // relying on aria-expanded to show/hide trigger UI don't lose the anchor mid-animation.
54
+ ,
55
+ "aria-expanded": popupState === 'open' || popupState === 'animating-open' || popupState === 'animating-closed',
50
56
  "aria-controls": popoverId,
51
57
  "aria-haspopup": ariaHasPopup
52
58
  }, children);
@@ -1,6 +1,6 @@
1
1
  import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
2
2
  import React, { useCallback, useId, useRef, useState } from 'react';
3
- import { PopupProvider } from './popup-context';
3
+ import { PopupProvider } from './popup-provider';
4
4
  /**
5
5
  * Popup compound: trigger + popover content.
6
6
  *
@@ -35,10 +35,10 @@ export function PopupRoot(props) {
35
35
  var popoverId = "popover-".concat(sanitizedId);
36
36
  var triggerRef = useRef(null);
37
37
  var popoverRef = useRef(null);
38
- var _useState = useState(false),
38
+ var _useState = useState('closed'),
39
39
  _useState2 = _slicedToArray(_useState, 2),
40
- isOpen = _useState2[0],
41
- setIsOpen = _useState2[1];
40
+ popupState = _useState2[0],
41
+ setPopupState = _useState2[1];
42
42
  // Initial seed before `Popup.Content`'s layout effect resolves the
43
43
  // real role on first paint.
44
44
  var _useState3 = useState('dialog'),
@@ -57,8 +57,8 @@ export function PopupRoot(props) {
57
57
  onClose: onClose ? handleClose : null,
58
58
  triggerRef: triggerRef,
59
59
  popoverRef: popoverRef,
60
- isOpen: isOpen,
61
- setIsOpen: setIsOpen,
60
+ popupState: popupState,
61
+ setPopupState: setPopupState,
62
62
  onOpenChange: onOpenChange,
63
63
  mode: mode,
64
64
  ariaHasPopup: ariaHasPopup,
@@ -0,0 +1,12 @@
1
+ import { useContext } from 'react';
2
+ import { PopupContext } from './popup-context';
3
+
4
+ /**
5
+ * Returns the popup context if available, or `null` when used outside `<Popup>`.
6
+ *
7
+ * Used by `PopupContent` to support standalone usage (e.g. tooltip)
8
+ * where values are passed as props instead of coming from a compound component.
9
+ */
10
+ export function useMaybePopupContext() {
11
+ return useContext(PopupContext);
12
+ }
@@ -0,0 +1,15 @@
1
+ import { useContext } from 'react';
2
+ import { PopupContext } from './popup-context';
3
+
4
+ /**
5
+ * Returns the nearest `Popup` context value.
6
+ *
7
+ * Throws if called outside of a `<Popup>` compound component.
8
+ */
9
+ export function usePopupContext() {
10
+ var ctx = useContext(PopupContext);
11
+ if (ctx === null) {
12
+ throw new Error('@atlaskit/top-layer: Popup compound components must be used within <Popup>.');
13
+ }
14
+ return ctx;
15
+ }
@@ -1 +1,2 @@
1
- export { useArrowNavigation, isAtCurrentMenuLevel } from './use-arrow-navigation';
1
+ export { useArrowNavigation } from './use-arrow-navigation';
2
+ export { isAtCurrentMenuLevel } from './is-at-current-menu-level';
@@ -0,0 +1,81 @@
1
+ var menuScope = '[role="menu"]';
2
+
3
+ /**
4
+ * Returns true if the element belongs to the current menu level of the
5
+ * container, i.e. it is NOT inside a nested `role="menu"` sub-container.
6
+ *
7
+ * Uses `element.closest('[role="menu"]')` to determine the nearest menu
8
+ * scope. If the nearest scope is the container's own `role="menu"` element,
9
+ * the element belongs to this level. If it is a different (nested) menu,
10
+ * the element belongs to a child level and should be excluded.
11
+ *
12
+ * Supports two container patterns:
13
+ *
14
+ * **1. Container has `role="menu"` directly** (preferred, used by avatar-group):
15
+ * ```
16
+ * <div ref={containerRef} role="menu">
17
+ * <button role="menuitem" /> ← isAtCurrentMenuLevel → true
18
+ * <div role="menu"> ← nested sub-menu
19
+ * <button role="menuitem" /> ← isAtCurrentMenuLevel → false
20
+ * </div>
21
+ * </div>
22
+ * ```
23
+ * Uses the simple check: `element.closest('[role="menu"]') === container`
24
+ *
25
+ * **2. Container is a wrapper without `role="menu"`** (used by dropdown-menu,
26
+ * where `MenuGroup` owns `role="menu"` but does not accept a ref):
27
+ * ```
28
+ * <div ref={containerRef}> ← wrapper (no role="menu")
29
+ * <MenuGroup role="menu">
30
+ * <button role="menuitem" /> ← isAtCurrentMenuLevel → true
31
+ * <div role="menu"> ← nested sub-menu
32
+ * <button role="menuitem" /> ← isAtCurrentMenuLevel → false
33
+ * </div>
34
+ * </MenuGroup>
35
+ * </div>
36
+ * ```
37
+ * Falls back to walking the DOM to check that the element's closest
38
+ * `role="menu"` is the container's own menu (not doubly nested).
39
+ *
40
+ * Ideally all consumers should use pattern 1 (place the ref directly on the
41
+ * `role="menu"` element). Pattern 2 exists because `@atlaskit/menu`'s
42
+ * `MenuGroup` does not currently accept a ref. If `MenuGroup` gains ref
43
+ * support in the future, dropdown-menu should switch to pattern 1 and the
44
+ * fallback path can be removed.
45
+ *
46
+ * Used both as a `TFocusableFilter` (to scope getNextFocusable etc.) and as
47
+ * a guard to prevent parent menu handlers from acting on events that
48
+ * belong to a nested menu.
49
+ */
50
+ export function isAtCurrentMenuLevel(element, container) {
51
+ var closestMenu = element.closest(menuScope);
52
+
53
+ // Container itself has role="menu". Element belongs to this level
54
+ // if its closest menu is the container.
55
+ if (container.getAttribute('role') === 'menu') {
56
+ return closestMenu === container;
57
+ }
58
+
59
+ // Container is a wrapper without role="menu" (e.g. dropdown-menu
60
+ // wraps a MenuGroup). The element belongs to the top level if its
61
+ // closest role="menu" ancestor is a direct child of the container,
62
+ // i.e. there is no nested role="menu" between them.
63
+ if (closestMenu === null) {
64
+ return true;
65
+ }
66
+
67
+ // Walk up from the closest menu to see if it is "owned" by this
68
+ // container without passing through another role="menu" first.
69
+ var parent = closestMenu.parentElement;
70
+ while (parent && parent !== container) {
71
+ if (parent.getAttribute('role') === 'menu') {
72
+ // There is a parent menu between the closest menu and the
73
+ // container, so the element is in a nested sub-menu.
74
+ return false;
75
+ }
76
+ parent = parent.parentElement;
77
+ }
78
+
79
+ // The closest menu is directly inside the container (no nesting).
80
+ return parent === container;
81
+ }
@@ -5,86 +5,6 @@ import { KEY_DOWN, KEY_END, KEY_HOME, KEY_TAB, KEY_UP } from '@atlaskit/ds-lib/k
5
5
  import { getFirstFocusable, getLastFocusable, getNextFocusable } from '../focus';
6
6
  var menuScope = '[role="menu"]';
7
7
 
8
- /**
9
- * Returns true if the element belongs to the current menu level of the
10
- * container, i.e. it is NOT inside a nested `role="menu"` sub-container.
11
- *
12
- * Uses `element.closest('[role="menu"]')` to determine the nearest menu
13
- * scope. If the nearest scope is the container's own `role="menu"` element,
14
- * the element belongs to this level. If it is a different (nested) menu,
15
- * the element belongs to a child level and should be excluded.
16
- *
17
- * Supports two container patterns:
18
- *
19
- * **1. Container has `role="menu"` directly** (preferred, used by avatar-group):
20
- * ```
21
- * <div ref={containerRef} role="menu">
22
- * <button role="menuitem" /> ← isAtCurrentMenuLevel → true
23
- * <div role="menu"> ← nested sub-menu
24
- * <button role="menuitem" /> ← isAtCurrentMenuLevel → false
25
- * </div>
26
- * </div>
27
- * ```
28
- * Uses the simple check: `element.closest('[role="menu"]') === container`
29
- *
30
- * **2. Container is a wrapper without `role="menu"`** (used by dropdown-menu,
31
- * where `MenuGroup` owns `role="menu"` but does not accept a ref):
32
- * ```
33
- * <div ref={containerRef}> ← wrapper (no role="menu")
34
- * <MenuGroup role="menu">
35
- * <button role="menuitem" /> ← isAtCurrentMenuLevel → true
36
- * <div role="menu"> ← nested sub-menu
37
- * <button role="menuitem" /> ← isAtCurrentMenuLevel → false
38
- * </div>
39
- * </MenuGroup>
40
- * </div>
41
- * ```
42
- * Falls back to walking the DOM to check that the element's closest
43
- * `role="menu"` is the container's own menu (not doubly nested).
44
- *
45
- * Ideally all consumers should use pattern 1 (place the ref directly on the
46
- * `role="menu"` element). Pattern 2 exists because `@atlaskit/menu`'s
47
- * `MenuGroup` does not currently accept a ref. If `MenuGroup` gains ref
48
- * support in the future, dropdown-menu should switch to pattern 1 and the
49
- * fallback path can be removed.
50
- *
51
- * Used both as a `TFocusableFilter` (to scope getNextFocusable etc.) and as
52
- * a guard to prevent parent menu handlers from acting on events that
53
- * belong to a nested menu.
54
- */
55
- export function isAtCurrentMenuLevel(element, container) {
56
- var closestMenu = element.closest(menuScope);
57
-
58
- // Container itself has role="menu". Element belongs to this level
59
- // if its closest menu is the container.
60
- if (container.getAttribute('role') === 'menu') {
61
- return closestMenu === container;
62
- }
63
-
64
- // Container is a wrapper without role="menu" (e.g. dropdown-menu
65
- // wraps a MenuGroup). The element belongs to the top level if its
66
- // closest role="menu" ancestor is a direct child of the container,
67
- // i.e. there is no nested role="menu" between them.
68
- if (closestMenu === null) {
69
- return true;
70
- }
71
-
72
- // Walk up from the closest menu to see if it is "owned" by this
73
- // container without passing through another role="menu" first.
74
- var parent = closestMenu.parentElement;
75
- while (parent && parent !== container) {
76
- if (parent.getAttribute('role') === 'menu') {
77
- // There is a parent menu between the closest menu and the
78
- // container, so the element is in a nested sub-menu.
79
- return false;
80
- }
81
- parent = parent.parentElement;
82
- }
83
-
84
- // The closest menu is directly inside the container (no nesting).
85
- return parent === container;
86
- }
87
-
88
8
  /**
89
9
  * Args for `useArrowNavigation`.
90
10
  *
@@ -135,7 +55,6 @@ export function isAtCurrentMenuLevel(element, container) {
135
55
  * });
136
56
  * ```
137
57
  */
138
- // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
139
58
  export function useArrowNavigation(_ref) {
140
59
  var containerRef = _ref.containerRef,
141
60
  onClose = _ref.onClose,