@atlaskit/popup 4.25.0 → 4.26.0

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 (51) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/__tests__/playwright/top-layer-focus.spec.tsx +274 -0
  3. package/compositional/popup/package.json +1 -8
  4. package/compositional/popup-content/package.json +1 -8
  5. package/compositional/popup-trigger/package.json +1 -8
  6. package/dist/cjs/compositional/popup-content-top-layer.js +91 -6
  7. package/dist/cjs/internal/top-layer-bridge.js +14 -9
  8. package/dist/cjs/popup-top-layer.js +89 -5
  9. package/dist/es2019/compositional/popup-content-top-layer.js +92 -4
  10. package/dist/es2019/internal/top-layer-bridge.js +14 -9
  11. package/dist/es2019/popup-top-layer.js +90 -3
  12. package/dist/esm/compositional/popup-content-top-layer.js +92 -7
  13. package/dist/esm/internal/top-layer-bridge.js +14 -9
  14. package/dist/esm/popup-top-layer.js +90 -6
  15. package/dist/types/internal/top-layer-bridge.d.ts +14 -0
  16. package/experimental/package.json +1 -8
  17. package/package.json +2 -2
  18. package/popup/package.json +1 -8
  19. package/types/package.json +1 -8
  20. package/dist/types-ts4.5/compositional/id-context.d.ts +0 -2
  21. package/dist/types-ts4.5/compositional/is-inside-popup-context.d.ts +0 -11
  22. package/dist/types-ts4.5/compositional/is-open-context.d.ts +0 -11
  23. package/dist/types-ts4.5/compositional/popup-content-top-layer.d.ts +0 -20
  24. package/dist/types-ts4.5/compositional/popup-content.d.ts +0 -35
  25. package/dist/types-ts4.5/compositional/popup-trigger.d.ts +0 -13
  26. package/dist/types-ts4.5/compositional/popup.d.ts +0 -27
  27. package/dist/types-ts4.5/compositional/role-context.d.ts +0 -2
  28. package/dist/types-ts4.5/compositional/set-trigger-ref-context.d.ts +0 -2
  29. package/dist/types-ts4.5/compositional/trigger-ref-context.d.ts +0 -2
  30. package/dist/types-ts4.5/compositional/trigger-ref-object-context.d.ts +0 -10
  31. package/dist/types-ts4.5/compositional/use-ensure-is-inside-popup.d.ts +0 -1
  32. package/dist/types-ts4.5/entry-points/compositional-popup-content.d.ts +0 -2
  33. package/dist/types-ts4.5/entry-points/compositional-popup-trigger.d.ts +0 -2
  34. package/dist/types-ts4.5/entry-points/compositional-popup.d.ts +0 -2
  35. package/dist/types-ts4.5/entry-points/experimental/compositional.d.ts +0 -6
  36. package/dist/types-ts4.5/entry-points/popup.d.ts +0 -1
  37. package/dist/types-ts4.5/entry-points/types.d.ts +0 -1
  38. package/dist/types-ts4.5/index.d.ts +0 -4
  39. package/dist/types-ts4.5/internal/top-layer-bridge.d.ts +0 -29
  40. package/dist/types-ts4.5/popper-wrapper.d.ts +0 -3
  41. package/dist/types-ts4.5/popup-top-layer.d.ts +0 -16
  42. package/dist/types-ts4.5/popup.d.ts +0 -7
  43. package/dist/types-ts4.5/reposition-on-update.d.ts +0 -3
  44. package/dist/types-ts4.5/types.d.ts +0 -357
  45. package/dist/types-ts4.5/use-appearance.d.ts +0 -15
  46. package/dist/types-ts4.5/use-close-manager.d.ts +0 -2
  47. package/dist/types-ts4.5/use-focus-manager.d.ts +0 -2
  48. package/dist/types-ts4.5/use-get-memoized-merged-trigger-ref-new.d.ts +0 -9
  49. package/dist/types-ts4.5/use-get-memoized-merged-trigger-ref.d.ts +0 -3
  50. package/dist/types-ts4.5/utils/is-element-interactive.d.ts +0 -1
  51. package/dist/types-ts4.5/utils/use-animation-frame.d.ts +0 -5
@@ -2,9 +2,10 @@
2
2
  import _extends from "@babel/runtime/helpers/extends";
3
3
  import "./popup-content-top-layer.compiled.css";
4
4
  import * as React from 'react';
5
- import { useCallback, useContext, useMemo, useRef, useState } from 'react';
5
+ import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
6
6
  import { ax, ix } from '@compiled/react/runtime';
7
7
  import noop from '@atlaskit/ds-lib/noop';
8
+ import { useNotifyOpenLayerObserver } from '@atlaskit/layering/experimental/open-layer-observer';
8
9
  import { popupMotion } from '@atlaskit/top-layer/animations';
9
10
  import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
10
11
  import { createPopoverCloseEvent, Popover } from '@atlaskit/top-layer/popover';
@@ -76,9 +77,26 @@ export function PopupContentTopLayer({
76
77
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
77
78
  shouldDisableGpuAcceleration: _shouldDisableGpuAcceleration
78
79
  }) {
79
- const [, setInitialFocusRef] = useState(null);
80
+ /**
81
+ * Escape hatch for consumer-chosen initial focus target.
82
+ *
83
+ * The legacy compositional `PopupContent` surfaced
84
+ * `setInitialFocusRef` on the content children render prop with a
85
+ * `Dispatch<SetStateAction<HTMLElement | null>>` signature so it
86
+ * could be passed as a React `ref` callback. We keep the same shape
87
+ * for API compatibility, but store the value in a ref rather than
88
+ * component state. Flipping state on every open caused a needless
89
+ * re-render and, more importantly, raced the initial-focus effect
90
+ * below. The ref is read in the open-side effect to override the
91
+ * role-based default focus chosen by `useInitialFocus` inside the
92
+ * underlying `Popover`.
93
+ */
94
+ const consumerInitialFocusRef = useRef(null);
95
+ const setInitialFocusRef = useCallback(value => {
96
+ consumerInitialFocusRef.current = typeof value === 'function' ? value(consumerInitialFocusRef.current) : value;
97
+ }, []);
80
98
 
81
- // ── Placement conversion ──
99
+ // Placement conversion
82
100
  // Legacy `offset` is the popper `[along, away]` tuple; `fromLegacyPlacement`
83
101
  // folds it into the new `placement.offset` shape.
84
102
  const topLayerPlacement = useMemo(() => fromLegacyPlacement({
@@ -104,7 +122,7 @@ export function PopupContentTopLayer({
104
122
  update: noop,
105
123
  onClose,
106
124
  setInitialFocusRef
107
- }), [isOpen, onClose]);
125
+ }), [isOpen, onClose, setInitialFocusRef]);
108
126
 
109
127
  // ── Role mapping ──
110
128
  const roleProps = useRoleProps({
@@ -115,6 +133,49 @@ export function PopupContentTopLayer({
115
133
  const popoverRef = useRef(null);
116
134
  const anchorRef = useContext(TriggerRefObjectContext);
117
135
 
136
+ /**
137
+ * Override the role-based initial focus chosen by the underlying
138
+ * `Popover` (its internal `useInitialFocus`).
139
+ *
140
+ * React flushes child effects before parent effects, so by the time
141
+ * this effect runs the role-based default focus has already moved.
142
+ * We then apply the legacy `Popup` contract:
143
+ *
144
+ * 1. If the consumer wired `setInitialFocusRef`, focus that element
145
+ * (escape hatch wins).
146
+ * 2. Otherwise, if `autoFocus={false}`, return focus to the trigger.
147
+ * The `autoFocus` prop on `Popup` opts out of any initial-focus
148
+ * movement, even for roles like `dialog`.
149
+ *
150
+ * When neither applies the role-based default from the underlying
151
+ * `Popover` stays in place.
152
+ */
153
+ useEffect(() => {
154
+ if (!isOpen) {
155
+ /**
156
+ * Clear the consumer-supplied focus target on close so a
157
+ * subsequent open does not silently inherit a stale ref from
158
+ * the previous open cycle. The next open will re-collect a
159
+ * target only if the content render prop re-wires
160
+ * `setInitialFocusRef`.
161
+ */
162
+ consumerInitialFocusRef.current = null;
163
+ return;
164
+ }
165
+ const consumerTarget = consumerInitialFocusRef.current;
166
+ if (consumerTarget && consumerTarget.isConnected) {
167
+ consumerTarget.focus();
168
+ return;
169
+ }
170
+ if (!autoFocus) {
171
+ var _anchorRef$current;
172
+ const trigger = (_anchorRef$current = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.current) !== null && _anchorRef$current !== void 0 ? _anchorRef$current : null;
173
+ if (trigger && trigger.isConnected) {
174
+ trigger.focus();
175
+ }
176
+ }
177
+ }, [isOpen, autoFocus, anchorRef]);
178
+
118
179
  // `isOpen` is included so the anchor positioning effect re-runs when
119
180
  // the Popover host element is unmounted/remounted across open cycles.
120
181
  useAnchorPosition({
@@ -130,6 +191,33 @@ export function PopupContentTopLayer({
130
191
  isOpen
131
192
  });
132
193
 
194
+ /**
195
+ * Register with the open-layer observer as a `popup` while open.
196
+ *
197
+ * The legacy compositional `PopupContent` registered every open
198
+ * popup with the observer regardless of role, and product surfaces
199
+ * such as the side-nav panel splitter rely on this signal to detect
200
+ * open popups via `getCount({ type: 'popup' }) > 0`. The underlying
201
+ * top-layer `Popover` only self-registers when its role is one of
202
+ * `menu | listbox | dialog | alertdialog | tree | grid`, which would
203
+ * miss role-less popups and silently regress the splitter.
204
+ *
205
+ * Registering here unconditionally preserves the legacy contract.
206
+ * When the role does opt the `Popover` into observer registration
207
+ * the count is incremented twice, which is safe because every known
208
+ * consumer treats the count as a boolean (`> 0` / `=== 0`).
209
+ */
210
+ const handleObserverClose = useCallback(() => {
211
+ onClose === null || onClose === void 0 ? void 0 : onClose(createPopoverCloseEvent({
212
+ reason: 'programmatic'
213
+ }));
214
+ }, [onClose]);
215
+ useNotifyOpenLayerObserver({
216
+ type: 'popup',
217
+ isOpen,
218
+ onClose: handleObserverClose
219
+ });
220
+
133
221
  // Narrow to ForwardRefExoticComponent so JSX accepts the ref prop.
134
222
  // All popupComponent implementations use forwardRef per the PopupComponentProps contract.
135
223
  const Container = PopupContainer;
@@ -2,6 +2,13 @@ import { useMemo } from 'react';
2
2
 
3
3
  /**
4
4
  * Props shape passed from legacy `role` / `label` / `titleId` into `@atlaskit/top-layer` `Popup.Content`.
5
+ *
6
+ * The `{ role: undefined }` variant covers the legacy contract where a
7
+ * consumer omits `role` to opt out of any popup semantics (and the
8
+ * associated role-based initial-focus movement). Legacy `Popup` left the
9
+ * popup element role-less in that case; we preserve that behaviour here
10
+ * rather than defaulting to `'dialog'`, which would move focus into the
11
+ * popup on open.
5
12
  */
6
13
 
7
14
  /**
@@ -11,6 +18,10 @@ import { useMemo } from 'react';
11
18
  * `Popup.Content` enforces at the type level that dialog/alertdialog/menu
12
19
  * roles must have `label` or `labelledBy`. This hook bridges the legacy
13
20
  * flat-prop API (`role`, `label`, `titleId`) to that shape.
21
+ *
22
+ * When `role` is omitted entirely, the result carries `role: undefined`
23
+ * (no implicit default). This matches the legacy `Popup` contract and
24
+ * keeps the underlying `Popover` from running role-based initial focus.
14
25
  */
15
26
  export function useRoleProps({
16
27
  role,
@@ -36,16 +47,10 @@ export function useRoleProps({
36
47
  label
37
48
  };
38
49
  }
39
- // Default: dialog role with label or labelledBy
40
- if (titleId) {
41
- return {
42
- role: 'dialog',
43
- labelledBy: titleId
44
- };
45
- }
50
+ // No role supplied: preserve the legacy "no popup semantics" contract.
46
51
  return {
47
- role: 'dialog',
48
- label: label !== null && label !== void 0 ? label : 'Popup'
52
+ role: undefined,
53
+ label
49
54
  };
50
55
  }, [role, label, titleId]);
51
56
  }
@@ -3,8 +3,9 @@ import _extends from "@babel/runtime/helpers/extends";
3
3
  import "./popup-top-layer.compiled.css";
4
4
  import * as React from 'react';
5
5
  import { ax, ix } from "@compiled/react/runtime";
6
- import { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
6
+ import { Fragment, memo, useCallback, useEffect, useMemo, useRef } from 'react';
7
7
  import noop from '@atlaskit/ds-lib/noop';
8
+ import { useNotifyOpenLayerObserver } from '@atlaskit/layering/experimental/open-layer-observer';
8
9
  import { popupMotion } from '@atlaskit/top-layer/animations';
9
10
  import { getAriaForTrigger } from '@atlaskit/top-layer/get-aria-for-trigger';
10
11
  import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
@@ -92,7 +93,66 @@ export const PopupTopLayer = /*#__PURE__*/memo(function PopupTopLayer({
92
93
  const triggerRef = useRef(null);
93
94
  const popoverRef = useRef(null);
94
95
  const popupContainerRef = useRef(null);
95
- const [, setInitialFocusRef] = useState(null);
96
+
97
+ /**
98
+ * Escape hatch for consumer-chosen initial focus target.
99
+ *
100
+ * The legacy `Popup` surfaced `setInitialFocusRef` on the content
101
+ * render prop with a `Dispatch<SetStateAction<HTMLElement | null>>`
102
+ * signature so it could be passed as a React `ref` callback. We
103
+ * keep the same shape for API compatibility, but store the value in
104
+ * a ref rather than component state. Flipping state on every open
105
+ * caused a needless re-render and, more importantly, raced the
106
+ * initial-focus effect below. The ref is read in the open-side
107
+ * effect to override the role-based default focus chosen by
108
+ * `useInitialFocus` inside the underlying `Popover`.
109
+ */
110
+ const consumerInitialFocusRef = useRef(null);
111
+ const setInitialFocusRef = useCallback(value => {
112
+ consumerInitialFocusRef.current = typeof value === 'function' ? value(consumerInitialFocusRef.current) : value;
113
+ }, []);
114
+
115
+ /**
116
+ * Override the role-based initial focus chosen by the underlying
117
+ * `Popover` (its internal `useInitialFocus`).
118
+ *
119
+ * React flushes child effects before parent effects, so by the time
120
+ * this effect runs the role-based default focus has already moved.
121
+ * We then apply the legacy `Popup` contract:
122
+ *
123
+ * 1. If the consumer wired `setInitialFocusRef`, focus that element
124
+ * (escape hatch wins).
125
+ * 2. Otherwise, if `autoFocus={false}`, return focus to the trigger.
126
+ * The `autoFocus` prop on `Popup` opts out of any initial-focus
127
+ * movement, even for roles like `dialog`.
128
+ *
129
+ * When neither applies the role-based default from the underlying
130
+ * `Popover` stays in place.
131
+ */
132
+ useEffect(() => {
133
+ if (!isOpen) {
134
+ /**
135
+ * Clear the consumer-supplied focus target on close so a
136
+ * subsequent open does not silently inherit a stale ref from
137
+ * the previous open cycle. The next open will re-collect a
138
+ * target only if the content render prop re-wires
139
+ * `setInitialFocusRef`.
140
+ */
141
+ consumerInitialFocusRef.current = null;
142
+ return;
143
+ }
144
+ const consumerTarget = consumerInitialFocusRef.current;
145
+ if (consumerTarget && consumerTarget.isConnected) {
146
+ consumerTarget.focus();
147
+ return;
148
+ }
149
+ if (!autoFocus) {
150
+ const trigger = triggerRef.current;
151
+ if (trigger && trigger.isConnected) {
152
+ trigger.focus();
153
+ }
154
+ }
155
+ }, [isOpen, autoFocus]);
96
156
  const popoverId = usePopoverId();
97
157
 
98
158
  // Placement conversion.
@@ -115,6 +175,33 @@ export const PopupTopLayer = /*#__PURE__*/memo(function PopupTopLayer({
115
175
  isOpen
116
176
  });
117
177
 
178
+ /**
179
+ * Register with the open-layer observer as a `popup` while open.
180
+ *
181
+ * The legacy `Popup` registered every open popup with the observer
182
+ * regardless of role, and product surfaces such as the side-nav
183
+ * panel splitter rely on this signal to detect open popups via
184
+ * `getCount({ type: 'popup' }) > 0`. The underlying top-layer
185
+ * `Popover` only self-registers when its role is one of
186
+ * `menu | listbox | dialog | alertdialog | tree | grid`, which would
187
+ * miss role-less popups and silently regress the splitter.
188
+ *
189
+ * Registering here unconditionally preserves the legacy contract.
190
+ * When the role does opt the `Popover` into observer registration
191
+ * the count is incremented twice, which is safe because every known
192
+ * consumer treats the count as a boolean (`> 0` / `=== 0`).
193
+ */
194
+ const handleObserverClose = useCallback(() => {
195
+ onClose === null || onClose === void 0 ? void 0 : onClose(createPopoverCloseEvent({
196
+ reason: 'programmatic'
197
+ }));
198
+ }, [onClose]);
199
+ useNotifyOpenLayerObserver({
200
+ type: 'popup',
201
+ isOpen,
202
+ onClose: handleObserverClose
203
+ });
204
+
118
205
  // onClose bridge.
119
206
  // Translates top-layer's { reason: TPopoverCloseReason } into legacy
120
207
  // onClose(event, currentLevel?) by synthesizing DOM events.
@@ -142,7 +229,7 @@ export const PopupTopLayer = /*#__PURE__*/memo(function PopupTopLayer({
142
229
  update: noop,
143
230
  onClose,
144
231
  setInitialFocusRef
145
- }), [isOpen, onClose]);
232
+ }), [isOpen, onClose, setInitialFocusRef]);
146
233
 
147
234
  // Role mapping.
148
235
  // Build the role/label props for Popover.
@@ -1,11 +1,11 @@
1
1
  /* popup-content-top-layer.tsx generated by @compiled/babel-plugin v0.39.1 */
2
2
  import _extends from "@babel/runtime/helpers/extends";
3
- import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
4
3
  import "./popup-content-top-layer.compiled.css";
5
4
  import * as React from 'react';
6
- import { useCallback, useContext, useMemo, useRef, useState } from 'react';
5
+ import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
7
6
  import { ax, ix } from '@compiled/react/runtime';
8
7
  import noop from '@atlaskit/ds-lib/noop';
8
+ import { useNotifyOpenLayerObserver } from '@atlaskit/layering/experimental/open-layer-observer';
9
9
  import { popupMotion } from '@atlaskit/top-layer/animations';
10
10
  import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
11
11
  import { createPopoverCloseEvent, Popover } from '@atlaskit/top-layer/popover';
@@ -57,11 +57,26 @@ export function PopupContentTopLayer(_ref) {
57
57
  _shouldFlip = _ref.shouldFlip,
58
58
  _appearance = _ref.appearance,
59
59
  _shouldDisableGpuAcceleration = _ref.shouldDisableGpuAcceleration;
60
- var _useState = useState(null),
61
- _useState2 = _slicedToArray(_useState, 2),
62
- setInitialFocusRef = _useState2[1];
60
+ /**
61
+ * Escape hatch for consumer-chosen initial focus target.
62
+ *
63
+ * The legacy compositional `PopupContent` surfaced
64
+ * `setInitialFocusRef` on the content children render prop with a
65
+ * `Dispatch<SetStateAction<HTMLElement | null>>` signature so it
66
+ * could be passed as a React `ref` callback. We keep the same shape
67
+ * for API compatibility, but store the value in a ref rather than
68
+ * component state. Flipping state on every open caused a needless
69
+ * re-render and, more importantly, raced the initial-focus effect
70
+ * below. The ref is read in the open-side effect to override the
71
+ * role-based default focus chosen by `useInitialFocus` inside the
72
+ * underlying `Popover`.
73
+ */
74
+ var consumerInitialFocusRef = useRef(null);
75
+ var setInitialFocusRef = useCallback(function (value) {
76
+ consumerInitialFocusRef.current = typeof value === 'function' ? value(consumerInitialFocusRef.current) : value;
77
+ }, []);
63
78
 
64
- // ── Placement conversion ──
79
+ // Placement conversion
65
80
  // Legacy `offset` is the popper `[along, away]` tuple; `fromLegacyPlacement`
66
81
  // folds it into the new `placement.offset` shape.
67
82
  var topLayerPlacement = useMemo(function () {
@@ -90,7 +105,7 @@ export function PopupContentTopLayer(_ref) {
90
105
  onClose: onClose,
91
106
  setInitialFocusRef: setInitialFocusRef
92
107
  };
93
- }, [isOpen, onClose]);
108
+ }, [isOpen, onClose, setInitialFocusRef]);
94
109
 
95
110
  // ── Role mapping ──
96
111
  var roleProps = useRoleProps({
@@ -101,6 +116,49 @@ export function PopupContentTopLayer(_ref) {
101
116
  var popoverRef = useRef(null);
102
117
  var anchorRef = useContext(TriggerRefObjectContext);
103
118
 
119
+ /**
120
+ * Override the role-based initial focus chosen by the underlying
121
+ * `Popover` (its internal `useInitialFocus`).
122
+ *
123
+ * React flushes child effects before parent effects, so by the time
124
+ * this effect runs the role-based default focus has already moved.
125
+ * We then apply the legacy `Popup` contract:
126
+ *
127
+ * 1. If the consumer wired `setInitialFocusRef`, focus that element
128
+ * (escape hatch wins).
129
+ * 2. Otherwise, if `autoFocus={false}`, return focus to the trigger.
130
+ * The `autoFocus` prop on `Popup` opts out of any initial-focus
131
+ * movement, even for roles like `dialog`.
132
+ *
133
+ * When neither applies the role-based default from the underlying
134
+ * `Popover` stays in place.
135
+ */
136
+ useEffect(function () {
137
+ if (!isOpen) {
138
+ /**
139
+ * Clear the consumer-supplied focus target on close so a
140
+ * subsequent open does not silently inherit a stale ref from
141
+ * the previous open cycle. The next open will re-collect a
142
+ * target only if the content render prop re-wires
143
+ * `setInitialFocusRef`.
144
+ */
145
+ consumerInitialFocusRef.current = null;
146
+ return;
147
+ }
148
+ var consumerTarget = consumerInitialFocusRef.current;
149
+ if (consumerTarget && consumerTarget.isConnected) {
150
+ consumerTarget.focus();
151
+ return;
152
+ }
153
+ if (!autoFocus) {
154
+ var _anchorRef$current;
155
+ var trigger = (_anchorRef$current = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.current) !== null && _anchorRef$current !== void 0 ? _anchorRef$current : null;
156
+ if (trigger && trigger.isConnected) {
157
+ trigger.focus();
158
+ }
159
+ }
160
+ }, [isOpen, autoFocus, anchorRef]);
161
+
104
162
  // `isOpen` is included so the anchor positioning effect re-runs when
105
163
  // the Popover host element is unmounted/remounted across open cycles.
106
164
  useAnchorPosition({
@@ -116,6 +174,33 @@ export function PopupContentTopLayer(_ref) {
116
174
  isOpen: isOpen
117
175
  });
118
176
 
177
+ /**
178
+ * Register with the open-layer observer as a `popup` while open.
179
+ *
180
+ * The legacy compositional `PopupContent` registered every open
181
+ * popup with the observer regardless of role, and product surfaces
182
+ * such as the side-nav panel splitter rely on this signal to detect
183
+ * open popups via `getCount({ type: 'popup' }) > 0`. The underlying
184
+ * top-layer `Popover` only self-registers when its role is one of
185
+ * `menu | listbox | dialog | alertdialog | tree | grid`, which would
186
+ * miss role-less popups and silently regress the splitter.
187
+ *
188
+ * Registering here unconditionally preserves the legacy contract.
189
+ * When the role does opt the `Popover` into observer registration
190
+ * the count is incremented twice, which is safe because every known
191
+ * consumer treats the count as a boolean (`> 0` / `=== 0`).
192
+ */
193
+ var handleObserverClose = useCallback(function () {
194
+ onClose === null || onClose === void 0 || onClose(createPopoverCloseEvent({
195
+ reason: 'programmatic'
196
+ }));
197
+ }, [onClose]);
198
+ useNotifyOpenLayerObserver({
199
+ type: 'popup',
200
+ isOpen: isOpen,
201
+ onClose: handleObserverClose
202
+ });
203
+
119
204
  // Narrow to ForwardRefExoticComponent so JSX accepts the ref prop.
120
205
  // All popupComponent implementations use forwardRef per the PopupComponentProps contract.
121
206
  var Container = PopupContainer;
@@ -2,6 +2,13 @@ import { useMemo } from 'react';
2
2
 
3
3
  /**
4
4
  * Props shape passed from legacy `role` / `label` / `titleId` into `@atlaskit/top-layer` `Popup.Content`.
5
+ *
6
+ * The `{ role: undefined }` variant covers the legacy contract where a
7
+ * consumer omits `role` to opt out of any popup semantics (and the
8
+ * associated role-based initial-focus movement). Legacy `Popup` left the
9
+ * popup element role-less in that case; we preserve that behaviour here
10
+ * rather than defaulting to `'dialog'`, which would move focus into the
11
+ * popup on open.
5
12
  */
6
13
 
7
14
  /**
@@ -11,6 +18,10 @@ import { useMemo } from 'react';
11
18
  * `Popup.Content` enforces at the type level that dialog/alertdialog/menu
12
19
  * roles must have `label` or `labelledBy`. This hook bridges the legacy
13
20
  * flat-prop API (`role`, `label`, `titleId`) to that shape.
21
+ *
22
+ * When `role` is omitted entirely, the result carries `role: undefined`
23
+ * (no implicit default). This matches the legacy `Popup` contract and
24
+ * keeps the underlying `Popover` from running role-based initial focus.
14
25
  */
15
26
  export function useRoleProps(_ref) {
16
27
  var role = _ref.role,
@@ -35,16 +46,10 @@ export function useRoleProps(_ref) {
35
46
  label: label
36
47
  };
37
48
  }
38
- // Default: dialog role with label or labelledBy
39
- if (titleId) {
40
- return {
41
- role: 'dialog',
42
- labelledBy: titleId
43
- };
44
- }
49
+ // No role supplied: preserve the legacy "no popup semantics" contract.
45
50
  return {
46
- role: 'dialog',
47
- label: label !== null && label !== void 0 ? label : 'Popup'
51
+ role: undefined,
52
+ label: label
48
53
  };
49
54
  }, [role, label, titleId]);
50
55
  }
@@ -1,11 +1,11 @@
1
1
  /* popup-top-layer.tsx generated by @compiled/babel-plugin v0.39.1 */
2
2
  import _extends from "@babel/runtime/helpers/extends";
3
- import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
4
3
  import "./popup-top-layer.compiled.css";
5
4
  import * as React from 'react';
6
5
  import { ax, ix } from "@compiled/react/runtime";
7
- import { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
6
+ import { Fragment, memo, useCallback, useEffect, useMemo, useRef } from 'react';
8
7
  import noop from '@atlaskit/ds-lib/noop';
8
+ import { useNotifyOpenLayerObserver } from '@atlaskit/layering/experimental/open-layer-observer';
9
9
  import { popupMotion } from '@atlaskit/top-layer/animations';
10
10
  import { getAriaForTrigger } from '@atlaskit/top-layer/get-aria-for-trigger';
11
11
  import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
@@ -71,9 +71,66 @@ export var PopupTopLayer = /*#__PURE__*/memo(function PopupTopLayer(_ref) {
71
71
  var triggerRef = useRef(null);
72
72
  var popoverRef = useRef(null);
73
73
  var popupContainerRef = useRef(null);
74
- var _useState = useState(null),
75
- _useState2 = _slicedToArray(_useState, 2),
76
- setInitialFocusRef = _useState2[1];
74
+
75
+ /**
76
+ * Escape hatch for consumer-chosen initial focus target.
77
+ *
78
+ * The legacy `Popup` surfaced `setInitialFocusRef` on the content
79
+ * render prop with a `Dispatch<SetStateAction<HTMLElement | null>>`
80
+ * signature so it could be passed as a React `ref` callback. We
81
+ * keep the same shape for API compatibility, but store the value in
82
+ * a ref rather than component state. Flipping state on every open
83
+ * caused a needless re-render and, more importantly, raced the
84
+ * initial-focus effect below. The ref is read in the open-side
85
+ * effect to override the role-based default focus chosen by
86
+ * `useInitialFocus` inside the underlying `Popover`.
87
+ */
88
+ var consumerInitialFocusRef = useRef(null);
89
+ var setInitialFocusRef = useCallback(function (value) {
90
+ consumerInitialFocusRef.current = typeof value === 'function' ? value(consumerInitialFocusRef.current) : value;
91
+ }, []);
92
+
93
+ /**
94
+ * Override the role-based initial focus chosen by the underlying
95
+ * `Popover` (its internal `useInitialFocus`).
96
+ *
97
+ * React flushes child effects before parent effects, so by the time
98
+ * this effect runs the role-based default focus has already moved.
99
+ * We then apply the legacy `Popup` contract:
100
+ *
101
+ * 1. If the consumer wired `setInitialFocusRef`, focus that element
102
+ * (escape hatch wins).
103
+ * 2. Otherwise, if `autoFocus={false}`, return focus to the trigger.
104
+ * The `autoFocus` prop on `Popup` opts out of any initial-focus
105
+ * movement, even for roles like `dialog`.
106
+ *
107
+ * When neither applies the role-based default from the underlying
108
+ * `Popover` stays in place.
109
+ */
110
+ useEffect(function () {
111
+ if (!isOpen) {
112
+ /**
113
+ * Clear the consumer-supplied focus target on close so a
114
+ * subsequent open does not silently inherit a stale ref from
115
+ * the previous open cycle. The next open will re-collect a
116
+ * target only if the content render prop re-wires
117
+ * `setInitialFocusRef`.
118
+ */
119
+ consumerInitialFocusRef.current = null;
120
+ return;
121
+ }
122
+ var consumerTarget = consumerInitialFocusRef.current;
123
+ if (consumerTarget && consumerTarget.isConnected) {
124
+ consumerTarget.focus();
125
+ return;
126
+ }
127
+ if (!autoFocus) {
128
+ var _trigger = triggerRef.current;
129
+ if (_trigger && _trigger.isConnected) {
130
+ _trigger.focus();
131
+ }
132
+ }
133
+ }, [isOpen, autoFocus]);
77
134
  var popoverId = usePopoverId();
78
135
 
79
136
  // Placement conversion.
@@ -98,6 +155,33 @@ export var PopupTopLayer = /*#__PURE__*/memo(function PopupTopLayer(_ref) {
98
155
  isOpen: isOpen
99
156
  });
100
157
 
158
+ /**
159
+ * Register with the open-layer observer as a `popup` while open.
160
+ *
161
+ * The legacy `Popup` registered every open popup with the observer
162
+ * regardless of role, and product surfaces such as the side-nav
163
+ * panel splitter rely on this signal to detect open popups via
164
+ * `getCount({ type: 'popup' }) > 0`. The underlying top-layer
165
+ * `Popover` only self-registers when its role is one of
166
+ * `menu | listbox | dialog | alertdialog | tree | grid`, which would
167
+ * miss role-less popups and silently regress the splitter.
168
+ *
169
+ * Registering here unconditionally preserves the legacy contract.
170
+ * When the role does opt the `Popover` into observer registration
171
+ * the count is incremented twice, which is safe because every known
172
+ * consumer treats the count as a boolean (`> 0` / `=== 0`).
173
+ */
174
+ var handleObserverClose = useCallback(function () {
175
+ onClose === null || onClose === void 0 || onClose(createPopoverCloseEvent({
176
+ reason: 'programmatic'
177
+ }));
178
+ }, [onClose]);
179
+ useNotifyOpenLayerObserver({
180
+ type: 'popup',
181
+ isOpen: isOpen,
182
+ onClose: handleObserverClose
183
+ });
184
+
101
185
  // onClose bridge.
102
186
  // Translates top-layer's { reason: TPopoverCloseReason } into legacy
103
187
  // onClose(event, currentLevel?) by synthesizing DOM events.
@@ -126,7 +210,7 @@ export var PopupTopLayer = /*#__PURE__*/memo(function PopupTopLayer(_ref) {
126
210
  onClose: onClose,
127
211
  setInitialFocusRef: setInitialFocusRef
128
212
  };
129
- }, [isOpen, onClose]);
213
+ }, [isOpen, onClose, setInitialFocusRef]);
130
214
 
131
215
  // Role mapping.
132
216
  // Build the role/label props for Popover.
@@ -2,6 +2,13 @@ type TRestrictedDialogRole = 'dialog' | 'alertdialog' | 'menu';
2
2
  type TWcagSupplementalRole = 'tooltip' | 'listbox' | 'tree' | 'grid' | 'note' | 'status' | 'alert' | 'log';
3
3
  /**
4
4
  * Props shape passed from legacy `role` / `label` / `titleId` into `@atlaskit/top-layer` `Popup.Content`.
5
+ *
6
+ * The `{ role: undefined }` variant covers the legacy contract where a
7
+ * consumer omits `role` to opt out of any popup semantics (and the
8
+ * associated role-based initial-focus movement). Legacy `Popup` left the
9
+ * popup element role-less in that case; we preserve that behaviour here
10
+ * rather than defaulting to `'dialog'`, which would move focus into the
11
+ * popup on open.
5
12
  */
6
13
  export type TUseRolePropsResult = {
7
14
  role: TRestrictedDialogRole;
@@ -12,6 +19,9 @@ export type TUseRolePropsResult = {
12
19
  } | {
13
20
  role: TWcagSupplementalRole;
14
21
  label: string | undefined;
22
+ } | {
23
+ role: undefined;
24
+ label: string | undefined;
15
25
  };
16
26
  /**
17
27
  * Maps legacy popup role/label/titleId props to the discriminated union
@@ -20,6 +30,10 @@ export type TUseRolePropsResult = {
20
30
  * `Popup.Content` enforces at the type level that dialog/alertdialog/menu
21
31
  * roles must have `label` or `labelledBy`. This hook bridges the legacy
22
32
  * flat-prop API (`role`, `label`, `titleId`) to that shape.
33
+ *
34
+ * When `role` is omitted entirely, the result carries `role: undefined`
35
+ * (no implicit default). This matches the legacy `Popup` contract and
36
+ * keeps the underlying `Popover` from running role-based initial focus.
23
37
  */
24
38
  export declare function useRoleProps({ role, label, titleId, }: {
25
39
  role: string | undefined;
@@ -6,12 +6,5 @@
6
6
  "sideEffects": [
7
7
  "**/*.compiled.css"
8
8
  ],
9
- "types": "../dist/types/entry-points/experimental/compositional.d.ts",
10
- "typesVersions": {
11
- ">=4.5 <5.9": {
12
- "*": [
13
- "../dist/types-ts4.5/entry-points/experimental/compositional.d.ts"
14
- ]
15
- }
16
- }
9
+ "types": "../dist/types/entry-points/experimental/compositional.d.ts"
17
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/popup",
3
- "version": "4.25.0",
3
+ "version": "4.26.0",
4
4
  "description": "A popup displays brief content in an overlay.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/"
@@ -48,7 +48,7 @@
48
48
  "@atlaskit/portal": "^5.5.0",
49
49
  "@atlaskit/primitives": "^19.0.0",
50
50
  "@atlaskit/tokens": "^13.4.0",
51
- "@atlaskit/top-layer": "^0.17.0",
51
+ "@atlaskit/top-layer": "^0.18.0",
52
52
  "@babel/runtime": "^7.0.0",
53
53
  "@compiled/react": "^0.20.0",
54
54
  "bind-event-listener": "^3.0.0",