@atlaskit/popup 4.25.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @atlaskit/popup
2
2
 
3
+ ## 4.26.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`032135d12186b`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/032135d12186b) -
8
+ Top-layer: fix initial focus regressions on the top-layer adapters for `@atlaskit/avatar-group`
9
+ and `@atlaskit/popup`. Behind the `platform-dst-top-layer` feature gate.
10
+
3
11
  ## 4.25.1
4
12
 
5
13
  ### Patch Changes
@@ -0,0 +1,274 @@
1
+ import { expect, test } from '@af/integration-testing';
2
+
3
+ /**
4
+ * Popup: focus contract on the top-layer code path.
5
+ *
6
+ * `Popup` is a generic positioned overlay whose initial-focus behaviour
7
+ * depends on the `role` prop and the `autoFocus` prop. The tests in this
8
+ * spec exercise each branch of `top-layer/useInitialFocus` reachable
9
+ * through the public `Popup` API:
10
+ *
11
+ * - `role="dialog"`: focus moves to the first focusable inside the popup
12
+ * (default case), or to an element carrying the native HTML `autofocus`
13
+ * attribute when present.
14
+ * - no `role`: focus stays on the trigger; no focus movement is performed.
15
+ * - `role="listbox"`: focus prefers `[aria-selected="true"]` over the
16
+ * first option, except when the popup is owned by a combobox.
17
+ * - combobox carve-outs: focus stays on the combobox input when the popup
18
+ * is its owned listbox, whether the combobox is the trigger or rendered
19
+ * outside the popup element (react-select pattern).
20
+ * - `autoFocus={false}`: focus stays on the trigger regardless of role.
21
+ *
22
+ * Escape-to-restore and Tab cycling are also exercised for the default
23
+ * dialog case as representative top-layer focus-trap behaviour.
24
+ *
25
+ * See: `platform/packages/design-system/top-layer/notes/architecture/focus.md`.
26
+ */
27
+
28
+ const featureFlag = 'platform-dst-top-layer';
29
+
30
+ test.describe('Popup: top-layer focus contract', () => {
31
+ test('initial focus: focus moves to first focusable on open', async ({ page }) => {
32
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
33
+ 'design-system',
34
+ 'popup',
35
+ 'testing-initial-focus-matrix',
36
+ { featureFlag },
37
+ );
38
+
39
+ await page.getByTestId('dialog-popup-trigger').click();
40
+ await expect(page.getByTestId('dialog-popup-content')).toBeVisible();
41
+
42
+ await expect(page.getByTestId('dialog-popup-first-button')).toBeFocused();
43
+ });
44
+
45
+ test('focus restoration: Escape restores focus to the trigger', async ({ page }) => {
46
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
47
+ 'design-system',
48
+ 'popup',
49
+ 'testing-initial-focus-matrix',
50
+ { featureFlag },
51
+ );
52
+
53
+ const trigger = page.getByTestId('dialog-popup-trigger');
54
+ await trigger.click();
55
+ await expect(page.getByTestId('dialog-popup-content')).toBeVisible();
56
+
57
+ await page.keyboard.press('Escape');
58
+ await expect(page.getByTestId('dialog-popup-content')).toBeHidden();
59
+ await expect(trigger).toBeFocused();
60
+ });
61
+
62
+ test('focus movement: Tab moves focus between focusable elements within the popup', async ({
63
+ page,
64
+ }) => {
65
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
66
+ 'design-system',
67
+ 'popup',
68
+ 'testing-initial-focus-matrix',
69
+ { featureFlag },
70
+ );
71
+
72
+ await page.getByTestId('dialog-popup-trigger').click();
73
+ await expect(page.getByTestId('dialog-popup-content')).toBeVisible();
74
+
75
+ const first = page.getByTestId('dialog-popup-first-button');
76
+ const second = page.getByTestId('dialog-popup-second-button');
77
+
78
+ await expect(first).toBeFocused();
79
+ await page.keyboard.press('Tab');
80
+ await expect(second).toBeFocused();
81
+ });
82
+
83
+ // WCAG 2.4.3 Focus Order + WAI-ARIA APG Dialog pattern
84
+ // (https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/).
85
+ // For `role="dialog"`, when a descendant carries the native HTML
86
+ // `autofocus` attribute, focus must land on that element instead of
87
+ // the first focusable in source order.
88
+ test('initial focus: native [autofocus] element wins for role="dialog"', async ({ page }) => {
89
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
90
+ 'design-system',
91
+ 'popup',
92
+ 'testing-initial-focus-matrix',
93
+ { featureFlag },
94
+ );
95
+
96
+ await page.getByTestId('autofocus-dialog-popup-trigger').click();
97
+ await expect(page.getByTestId('autofocus-dialog-popup-content')).toBeVisible();
98
+
99
+ // The popup's first focusable is a button, but a sibling
100
+ // `<input>` carries `autofocus`, so focus must land on the
101
+ // input.
102
+ await expect(page.getByTestId('autofocus-dialog-popup-input')).toBeFocused();
103
+ });
104
+
105
+ // WCAG 2.4.3 Focus Order (no opinionated movement when no role is
106
+ // declared). The Popover API does not move focus on open; ARIA roles
107
+ // such as `dialog` / `menu` / `listbox` are what trigger the
108
+ // initial-focus contract. A popover without a role has no APG
109
+ // pattern, so focus must stay on the trigger.
110
+ test('initial focus: no `role` prop keeps focus on the trigger', async ({ page }) => {
111
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
112
+ 'design-system',
113
+ 'popup',
114
+ 'testing-initial-focus-matrix',
115
+ { featureFlag },
116
+ );
117
+
118
+ const trigger = page.getByTestId('no-role-popup-trigger');
119
+ // Explicitly focus the trigger before clicking so the assertion
120
+ // reflects the contract under test (focus is preserved across
121
+ // open). `button.click()` does not synchronously focus the
122
+ // element on every browser engine (notably WebKit), so we cannot
123
+ // rely on the click as the focus trigger here.
124
+ await trigger.focus();
125
+ await trigger.click();
126
+ await expect(page.getByTestId('no-role-popup-content')).toBeVisible();
127
+
128
+ // Without a role, the popup is `tabIndex={-1}` and is not a
129
+ // useful focus target, so focus remains on the trigger.
130
+ await expect(trigger).toBeFocused();
131
+ });
132
+
133
+ // WCAG 2.4.3 Focus Order + WAI-ARIA APG Listbox pattern
134
+ // (https://www.w3.org/WAI/ARIA/apg/patterns/listbox/).
135
+ // "If an option is selected, focus is set on the selected option."
136
+ // Focus must land on `[aria-selected="true"]` rather than the first
137
+ // `[role="option"]`.
138
+ test('initial focus: listbox prefers `[aria-selected="true"]` over the first option', async ({
139
+ page,
140
+ }) => {
141
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
142
+ 'design-system',
143
+ 'popup',
144
+ 'testing-initial-focus-matrix',
145
+ { featureFlag },
146
+ );
147
+
148
+ await page.getByTestId('selected-option-listbox-popup-trigger').click();
149
+ await expect(page.getByTestId('selected-option-listbox-popup-content')).toBeVisible();
150
+
151
+ await expect(page.getByTestId('selected-option-listbox-option-2')).toBeFocused();
152
+ });
153
+
154
+ // WCAG 2.4.3 Focus Order + WAI-ARIA APG Combobox pattern
155
+ // (https://www.w3.org/WAI/ARIA/apg/patterns/combobox/).
156
+ // "DOM focus remains on the combobox... the listbox does not
157
+ // receive focus." When the combobox's `aria-controls` references the
158
+ // popup id, focus must remain on the combobox input so the user can
159
+ // keep typing; the active option is surfaced via
160
+ // `aria-activedescendant`.
161
+ test('initial focus: combobox-controlled listbox keeps focus on the combobox input', async ({
162
+ page,
163
+ }) => {
164
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
165
+ 'design-system',
166
+ 'popup',
167
+ 'testing-initial-focus-matrix',
168
+ { featureFlag },
169
+ );
170
+
171
+ const combobox = page.getByTestId('combobox-controls-input');
172
+ await combobox.focus();
173
+ await expect(page.getByTestId('combobox-controls-popup-content')).toBeVisible();
174
+
175
+ await expect(combobox).toBeFocused();
176
+ });
177
+
178
+ // WCAG 2.4.3 Focus Order. Carve-out scoping: the APG Combobox
179
+ // exception only applies when the focused combobox's `aria-controls`
180
+ // references this popup. An unrelated combobox on the page must not
181
+ // block the standard listbox initial-focus behaviour.
182
+ test('initial focus: an unrelated combobox does not block focus movement into the listbox', async ({
183
+ page,
184
+ }) => {
185
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
186
+ 'design-system',
187
+ 'popup',
188
+ 'testing-initial-focus-matrix',
189
+ { featureFlag },
190
+ );
191
+
192
+ // Focus an unrelated combobox first so it is the active element
193
+ // at the moment the popup opens. Its `aria-controls` points
194
+ // elsewhere, so the carve-out must not apply.
195
+ await page.getByTestId('unrelated-combobox-input').focus();
196
+ await page.getByTestId('unrelated-combobox-popup-trigger').click();
197
+ await expect(page.getByTestId('unrelated-combobox-popup-content')).toBeVisible();
198
+
199
+ await expect(page.getByTestId('unrelated-combobox-option-1')).toBeFocused();
200
+ });
201
+
202
+ // WCAG 2.4.3 Focus Order + WAI-ARIA APG Combobox pattern
203
+ // (https://www.w3.org/WAI/ARIA/apg/patterns/combobox/).
204
+ // The combobox-carve-out is scoped by the `aria-controls`
205
+ // relationship, not by DOM containment. The `react-select` pattern
206
+ // portals the listbox separately from the textbox, so focus must
207
+ // still stay on the external input.
208
+ test('initial focus: external combobox (react-select pattern) keeps focus on the input', async ({
209
+ page,
210
+ }) => {
211
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
212
+ 'design-system',
213
+ 'popup',
214
+ 'testing-initial-focus-matrix',
215
+ { featureFlag },
216
+ );
217
+
218
+ const combobox = page.getByTestId('external-combobox-input');
219
+ await combobox.focus();
220
+ await expect(page.getByTestId('external-combobox-popup-content')).toBeVisible();
221
+
222
+ await expect(combobox).toBeFocused();
223
+ });
224
+
225
+ // Consumer escape hatch. `autoFocus={false}` disables Popup's
226
+ // initial-focus behaviour entirely. Note: pairing `autoFocus={false}`
227
+ // with `role="dialog"` deviates from the WAI-ARIA APG Dialog
228
+ // pattern, which expects focus to move into the dialog on open.
229
+ // The escape hatch is intended for patterns where the popup is
230
+ // visual-only (e.g. a hover-card / tooltip-like surface) and must
231
+ // not be combined with a focus-receiving role in production code.
232
+ test('initial focus: `autoFocus={false}` keeps focus on the trigger', async ({ page }) => {
233
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
234
+ 'design-system',
235
+ 'popup',
236
+ 'testing-initial-focus-matrix',
237
+ { featureFlag },
238
+ );
239
+
240
+ const trigger = page.getByTestId('auto-focus-disabled-popup-trigger');
241
+ // Explicitly focus the trigger before clicking so the assertion
242
+ // reflects the contract under test (focus is preserved across
243
+ // open). `button.click()` does not synchronously focus the
244
+ // element on every browser engine (notably WebKit).
245
+ await trigger.focus();
246
+ await trigger.click();
247
+ await expect(page.getByTestId('auto-focus-disabled-popup-content')).toBeVisible();
248
+
249
+ await expect(trigger).toBeFocused();
250
+ });
251
+
252
+ // WCAG 2.4.3 Focus Order. `Popup`'s `content` render prop exposes
253
+ // `setInitialFocusRef` so consumers can override the default
254
+ // `getInitialFocusTarget` resolution and pick a specific element to
255
+ // focus on open. The consumer-supplied ref must win over the role-
256
+ // based default (first focusable for `role="dialog"`).
257
+ test('initial focus: consumer-supplied `setInitialFocusRef` overrides the role-based default', async ({
258
+ page,
259
+ }) => {
260
+ await page.visitExample<typeof import('../../examples/97-testing-initial-focus-matrix.tsx')>(
261
+ 'design-system',
262
+ 'popup',
263
+ 'testing-initial-focus-matrix',
264
+ { featureFlag },
265
+ );
266
+
267
+ await page.getByTestId('set-initial-focus-ref-popup-trigger').click();
268
+ await expect(page.getByTestId('set-initial-focus-ref-popup-content')).toBeVisible();
269
+
270
+ // The first focusable in the popup is the first button, but the
271
+ // consumer wired `setInitialFocusRef` to the second button.
272
+ await expect(page.getByTestId('set-initial-focus-ref-popup-second-button')).toBeFocused();
273
+ });
274
+ });
@@ -11,9 +11,9 @@ require("./popup-content-top-layer.compiled.css");
11
11
  var _react = _interopRequireWildcard(require("react"));
12
12
  var React = _react;
13
13
  var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends"));
14
- var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
15
14
  var _runtime = require("@compiled/react/runtime");
16
15
  var _noop = _interopRequireDefault(require("@atlaskit/ds-lib/noop"));
16
+ var _openLayerObserver = require("@atlaskit/layering/experimental/open-layer-observer");
17
17
  var _animations = require("@atlaskit/top-layer/animations");
18
18
  var _placementMap = require("@atlaskit/top-layer/placement-map");
19
19
  var _popover = require("@atlaskit/top-layer/popover");
@@ -66,11 +66,26 @@ function PopupContentTopLayer(_ref) {
66
66
  _shouldFlip = _ref.shouldFlip,
67
67
  _appearance = _ref.appearance,
68
68
  _shouldDisableGpuAcceleration = _ref.shouldDisableGpuAcceleration;
69
- var _useState = (0, _react.useState)(null),
70
- _useState2 = (0, _slicedToArray2.default)(_useState, 2),
71
- setInitialFocusRef = _useState2[1];
69
+ /**
70
+ * Escape hatch for consumer-chosen initial focus target.
71
+ *
72
+ * The legacy compositional `PopupContent` surfaced
73
+ * `setInitialFocusRef` on the content children render prop with a
74
+ * `Dispatch<SetStateAction<HTMLElement | null>>` signature so it
75
+ * could be passed as a React `ref` callback. We keep the same shape
76
+ * for API compatibility, but store the value in a ref rather than
77
+ * component state. Flipping state on every open caused a needless
78
+ * re-render and, more importantly, raced the initial-focus effect
79
+ * below. The ref is read in the open-side effect to override the
80
+ * role-based default focus chosen by `useInitialFocus` inside the
81
+ * underlying `Popover`.
82
+ */
83
+ var consumerInitialFocusRef = (0, _react.useRef)(null);
84
+ var setInitialFocusRef = (0, _react.useCallback)(function (value) {
85
+ consumerInitialFocusRef.current = typeof value === 'function' ? value(consumerInitialFocusRef.current) : value;
86
+ }, []);
72
87
 
73
- // ── Placement conversion ──
88
+ // Placement conversion
74
89
  // Legacy `offset` is the popper `[along, away]` tuple; `fromLegacyPlacement`
75
90
  // folds it into the new `placement.offset` shape.
76
91
  var topLayerPlacement = (0, _react.useMemo)(function () {
@@ -99,7 +114,7 @@ function PopupContentTopLayer(_ref) {
99
114
  onClose: onClose,
100
115
  setInitialFocusRef: setInitialFocusRef
101
116
  };
102
- }, [isOpen, onClose]);
117
+ }, [isOpen, onClose, setInitialFocusRef]);
103
118
 
104
119
  // ── Role mapping ──
105
120
  var roleProps = (0, _topLayerBridge.useRoleProps)({
@@ -110,6 +125,49 @@ function PopupContentTopLayer(_ref) {
110
125
  var popoverRef = (0, _react.useRef)(null);
111
126
  var anchorRef = (0, _react.useContext)(_triggerRefObjectContext.TriggerRefObjectContext);
112
127
 
128
+ /**
129
+ * Override the role-based initial focus chosen by the underlying
130
+ * `Popover` (its internal `useInitialFocus`).
131
+ *
132
+ * React flushes child effects before parent effects, so by the time
133
+ * this effect runs the role-based default focus has already moved.
134
+ * We then apply the legacy `Popup` contract:
135
+ *
136
+ * 1. If the consumer wired `setInitialFocusRef`, focus that element
137
+ * (escape hatch wins).
138
+ * 2. Otherwise, if `autoFocus={false}`, return focus to the trigger.
139
+ * The `autoFocus` prop on `Popup` opts out of any initial-focus
140
+ * movement, even for roles like `dialog`.
141
+ *
142
+ * When neither applies the role-based default from the underlying
143
+ * `Popover` stays in place.
144
+ */
145
+ (0, _react.useEffect)(function () {
146
+ if (!isOpen) {
147
+ /**
148
+ * Clear the consumer-supplied focus target on close so a
149
+ * subsequent open does not silently inherit a stale ref from
150
+ * the previous open cycle. The next open will re-collect a
151
+ * target only if the content render prop re-wires
152
+ * `setInitialFocusRef`.
153
+ */
154
+ consumerInitialFocusRef.current = null;
155
+ return;
156
+ }
157
+ var consumerTarget = consumerInitialFocusRef.current;
158
+ if (consumerTarget && consumerTarget.isConnected) {
159
+ consumerTarget.focus();
160
+ return;
161
+ }
162
+ if (!autoFocus) {
163
+ var _anchorRef$current;
164
+ var trigger = (_anchorRef$current = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.current) !== null && _anchorRef$current !== void 0 ? _anchorRef$current : null;
165
+ if (trigger && trigger.isConnected) {
166
+ trigger.focus();
167
+ }
168
+ }
169
+ }, [isOpen, autoFocus, anchorRef]);
170
+
113
171
  // `isOpen` is included so the anchor positioning effect re-runs when
114
172
  // the Popover host element is unmounted/remounted across open cycles.
115
173
  (0, _useAnchorPosition.useAnchorPosition)({
@@ -125,6 +183,33 @@ function PopupContentTopLayer(_ref) {
125
183
  isOpen: isOpen
126
184
  });
127
185
 
186
+ /**
187
+ * Register with the open-layer observer as a `popup` while open.
188
+ *
189
+ * The legacy compositional `PopupContent` registered every open
190
+ * popup with the observer regardless of role, and product surfaces
191
+ * such as the side-nav panel splitter rely on this signal to detect
192
+ * open popups via `getCount({ type: 'popup' }) > 0`. The underlying
193
+ * top-layer `Popover` only self-registers when its role is one of
194
+ * `menu | listbox | dialog | alertdialog | tree | grid`, which would
195
+ * miss role-less popups and silently regress the splitter.
196
+ *
197
+ * Registering here unconditionally preserves the legacy contract.
198
+ * When the role does opt the `Popover` into observer registration
199
+ * the count is incremented twice, which is safe because every known
200
+ * consumer treats the count as a boolean (`> 0` / `=== 0`).
201
+ */
202
+ var handleObserverClose = (0, _react.useCallback)(function () {
203
+ onClose === null || onClose === void 0 || onClose((0, _popover.createPopoverCloseEvent)({
204
+ reason: 'programmatic'
205
+ }));
206
+ }, [onClose]);
207
+ (0, _openLayerObserver.useNotifyOpenLayerObserver)({
208
+ type: 'popup',
209
+ isOpen: isOpen,
210
+ onClose: handleObserverClose
211
+ });
212
+
128
213
  // Narrow to ForwardRefExoticComponent so JSX accepts the ref prop.
129
214
  // All popupComponent implementations use forwardRef per the PopupComponentProps contract.
130
215
  var Container = PopupContainer;
@@ -7,6 +7,13 @@ exports.useRoleProps = useRoleProps;
7
7
  var _react = require("react");
8
8
  /**
9
9
  * Props shape passed from legacy `role` / `label` / `titleId` into `@atlaskit/top-layer` `Popup.Content`.
10
+ *
11
+ * The `{ role: undefined }` variant covers the legacy contract where a
12
+ * consumer omits `role` to opt out of any popup semantics (and the
13
+ * associated role-based initial-focus movement). Legacy `Popup` left the
14
+ * popup element role-less in that case; we preserve that behaviour here
15
+ * rather than defaulting to `'dialog'`, which would move focus into the
16
+ * popup on open.
10
17
  */
11
18
 
12
19
  /**
@@ -16,6 +23,10 @@ var _react = require("react");
16
23
  * `Popup.Content` enforces at the type level that dialog/alertdialog/menu
17
24
  * roles must have `label` or `labelledBy`. This hook bridges the legacy
18
25
  * flat-prop API (`role`, `label`, `titleId`) to that shape.
26
+ *
27
+ * When `role` is omitted entirely, the result carries `role: undefined`
28
+ * (no implicit default). This matches the legacy `Popup` contract and
29
+ * keeps the underlying `Popover` from running role-based initial focus.
19
30
  */
20
31
  function useRoleProps(_ref) {
21
32
  var role = _ref.role,
@@ -40,16 +51,10 @@ function useRoleProps(_ref) {
40
51
  label: label
41
52
  };
42
53
  }
43
- // Default: dialog role with label or labelledBy
44
- if (titleId) {
45
- return {
46
- role: 'dialog',
47
- labelledBy: titleId
48
- };
49
- }
54
+ // No role supplied: preserve the legacy "no popup semantics" contract.
50
55
  return {
51
- role: 'dialog',
52
- label: label !== null && label !== void 0 ? label : 'Popup'
56
+ role: undefined,
57
+ label: label
53
58
  };
54
59
  }, [role, label, titleId]);
55
60
  }
@@ -12,8 +12,8 @@ var _react = _interopRequireWildcard(require("react"));
12
12
  var React = _react;
13
13
  var _runtime = require("@compiled/react/runtime");
14
14
  var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends"));
15
- var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
16
15
  var _noop = _interopRequireDefault(require("@atlaskit/ds-lib/noop"));
16
+ var _openLayerObserver = require("@atlaskit/layering/experimental/open-layer-observer");
17
17
  var _animations = require("@atlaskit/top-layer/animations");
18
18
  var _getAriaForTrigger = require("@atlaskit/top-layer/get-aria-for-trigger");
19
19
  var _placementMap = require("@atlaskit/top-layer/placement-map");
@@ -80,9 +80,66 @@ var PopupTopLayer = exports.PopupTopLayer = /*#__PURE__*/(0, _react.memo)(functi
80
80
  var triggerRef = (0, _react.useRef)(null);
81
81
  var popoverRef = (0, _react.useRef)(null);
82
82
  var popupContainerRef = (0, _react.useRef)(null);
83
- var _useState = (0, _react.useState)(null),
84
- _useState2 = (0, _slicedToArray2.default)(_useState, 2),
85
- setInitialFocusRef = _useState2[1];
83
+
84
+ /**
85
+ * Escape hatch for consumer-chosen initial focus target.
86
+ *
87
+ * The legacy `Popup` surfaced `setInitialFocusRef` on the content
88
+ * render prop with a `Dispatch<SetStateAction<HTMLElement | null>>`
89
+ * signature so it could be passed as a React `ref` callback. We
90
+ * keep the same shape for API compatibility, but store the value in
91
+ * a ref rather than component state. Flipping state on every open
92
+ * caused a needless re-render and, more importantly, raced the
93
+ * initial-focus effect below. The ref is read in the open-side
94
+ * effect to override the role-based default focus chosen by
95
+ * `useInitialFocus` inside the underlying `Popover`.
96
+ */
97
+ var consumerInitialFocusRef = (0, _react.useRef)(null);
98
+ var setInitialFocusRef = (0, _react.useCallback)(function (value) {
99
+ consumerInitialFocusRef.current = typeof value === 'function' ? value(consumerInitialFocusRef.current) : value;
100
+ }, []);
101
+
102
+ /**
103
+ * Override the role-based initial focus chosen by the underlying
104
+ * `Popover` (its internal `useInitialFocus`).
105
+ *
106
+ * React flushes child effects before parent effects, so by the time
107
+ * this effect runs the role-based default focus has already moved.
108
+ * We then apply the legacy `Popup` contract:
109
+ *
110
+ * 1. If the consumer wired `setInitialFocusRef`, focus that element
111
+ * (escape hatch wins).
112
+ * 2. Otherwise, if `autoFocus={false}`, return focus to the trigger.
113
+ * The `autoFocus` prop on `Popup` opts out of any initial-focus
114
+ * movement, even for roles like `dialog`.
115
+ *
116
+ * When neither applies the role-based default from the underlying
117
+ * `Popover` stays in place.
118
+ */
119
+ (0, _react.useEffect)(function () {
120
+ if (!isOpen) {
121
+ /**
122
+ * Clear the consumer-supplied focus target on close so a
123
+ * subsequent open does not silently inherit a stale ref from
124
+ * the previous open cycle. The next open will re-collect a
125
+ * target only if the content render prop re-wires
126
+ * `setInitialFocusRef`.
127
+ */
128
+ consumerInitialFocusRef.current = null;
129
+ return;
130
+ }
131
+ var consumerTarget = consumerInitialFocusRef.current;
132
+ if (consumerTarget && consumerTarget.isConnected) {
133
+ consumerTarget.focus();
134
+ return;
135
+ }
136
+ if (!autoFocus) {
137
+ var _trigger = triggerRef.current;
138
+ if (_trigger && _trigger.isConnected) {
139
+ _trigger.focus();
140
+ }
141
+ }
142
+ }, [isOpen, autoFocus]);
86
143
  var popoverId = (0, _usePopoverId.usePopoverId)();
87
144
 
88
145
  // Placement conversion.
@@ -107,6 +164,33 @@ var PopupTopLayer = exports.PopupTopLayer = /*#__PURE__*/(0, _react.memo)(functi
107
164
  isOpen: isOpen
108
165
  });
109
166
 
167
+ /**
168
+ * Register with the open-layer observer as a `popup` while open.
169
+ *
170
+ * The legacy `Popup` registered every open popup with the observer
171
+ * regardless of role, and product surfaces such as the side-nav
172
+ * panel splitter rely on this signal to detect open popups via
173
+ * `getCount({ type: 'popup' }) > 0`. The underlying top-layer
174
+ * `Popover` only self-registers when its role is one of
175
+ * `menu | listbox | dialog | alertdialog | tree | grid`, which would
176
+ * miss role-less popups and silently regress the splitter.
177
+ *
178
+ * Registering here unconditionally preserves the legacy contract.
179
+ * When the role does opt the `Popover` into observer registration
180
+ * the count is incremented twice, which is safe because every known
181
+ * consumer treats the count as a boolean (`> 0` / `=== 0`).
182
+ */
183
+ var handleObserverClose = (0, _react.useCallback)(function () {
184
+ onClose === null || onClose === void 0 || onClose((0, _popover.createPopoverCloseEvent)({
185
+ reason: 'programmatic'
186
+ }));
187
+ }, [onClose]);
188
+ (0, _openLayerObserver.useNotifyOpenLayerObserver)({
189
+ type: 'popup',
190
+ isOpen: isOpen,
191
+ onClose: handleObserverClose
192
+ });
193
+
110
194
  // onClose bridge.
111
195
  // Translates top-layer's { reason: TPopoverCloseReason } into legacy
112
196
  // onClose(event, currentLevel?) by synthesizing DOM events.
@@ -135,7 +219,7 @@ var PopupTopLayer = exports.PopupTopLayer = /*#__PURE__*/(0, _react.memo)(functi
135
219
  onClose: onClose,
136
220
  setInitialFocusRef: setInitialFocusRef
137
221
  };
138
- }, [isOpen, onClose]);
222
+ }, [isOpen, onClose, setInitialFocusRef]);
139
223
 
140
224
  // Role mapping.
141
225
  // Build the role/label props for Popover.
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/popup",
3
- "version": "4.25.1",
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/"