@atlaskit/dropdown-menu 16.8.9 → 16.8.10

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 (33) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/cjs/dropdown-menu-top-layer.compiled.css +10 -0
  3. package/dist/cjs/dropdown-menu-top-layer.js +347 -0
  4. package/dist/cjs/dropdown-menu.js +62 -8
  5. package/dist/cjs/internal/components/group-title.js +6 -1
  6. package/dist/cjs/internal/use-arrow-navigation/index.js +25 -0
  7. package/dist/cjs/internal/use-arrow-navigation/use-arrow-navigation.js +18 -0
  8. package/dist/es2019/dropdown-menu-top-layer.compiled.css +10 -0
  9. package/dist/es2019/dropdown-menu-top-layer.js +323 -0
  10. package/dist/es2019/dropdown-menu.js +59 -9
  11. package/dist/es2019/internal/components/group-title.js +6 -1
  12. package/dist/es2019/internal/use-arrow-navigation/index.js +2 -0
  13. package/dist/es2019/internal/use-arrow-navigation/use-arrow-navigation.js +1 -0
  14. package/dist/esm/dropdown-menu-top-layer.compiled.css +10 -0
  15. package/dist/esm/dropdown-menu-top-layer.js +338 -0
  16. package/dist/esm/dropdown-menu.js +62 -8
  17. package/dist/esm/internal/components/group-title.js +6 -1
  18. package/dist/esm/internal/use-arrow-navigation/index.js +2 -0
  19. package/dist/esm/internal/use-arrow-navigation/use-arrow-navigation.js +1 -0
  20. package/dist/types/dropdown-menu-top-layer.d.ts +18 -0
  21. package/dist/types/dropdown-menu.d.ts +1 -1
  22. package/dist/types/internal/components/group-title.d.ts +6 -1
  23. package/dist/types/internal/use-arrow-navigation/index.d.ts +2 -0
  24. package/dist/types/internal/use-arrow-navigation/use-arrow-navigation.d.ts +1 -0
  25. package/dist/types/types.d.ts +20 -0
  26. package/dist/types-ts4.5/dropdown-menu-top-layer.d.ts +18 -0
  27. package/dist/types-ts4.5/dropdown-menu.d.ts +1 -1
  28. package/dist/types-ts4.5/internal/components/group-title.d.ts +6 -1
  29. package/dist/types-ts4.5/internal/use-arrow-navigation/index.d.ts +2 -0
  30. package/dist/types-ts4.5/internal/use-arrow-navigation/use-arrow-navigation.d.ts +1 -0
  31. package/dist/types-ts4.5/types.d.ts +20 -0
  32. package/package.json +21 -16
  33. package/offerings.json +0 -36
@@ -0,0 +1,323 @@
1
+ /* dropdown-menu-top-layer.tsx generated by @compiled/babel-plugin v0.39.1 */
2
+ import _extends from "@babel/runtime/helpers/extends";
3
+ import "./dropdown-menu-top-layer.compiled.css";
4
+ import * as React from 'react';
5
+ import { ax, ix } from "@compiled/react/runtime";
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
+ import { bind } from 'bind-event-listener';
8
+ import Button from '@atlaskit/button/new';
9
+ import { KEY_DOWN, KEY_ENTER, KEY_SPACE } from '@atlaskit/ds-lib/keycodes';
10
+ import mergeRefs from '@atlaskit/ds-lib/merge-refs';
11
+ import noop from '@atlaskit/ds-lib/noop';
12
+ import useControlledState from '@atlaskit/ds-lib/use-controlled';
13
+ import useFocus from '@atlaskit/ds-lib/use-focus-event';
14
+ import ExpandIcon from '@atlaskit/icon/core/chevron-down';
15
+ import MenuGroup from '@atlaskit/menu/menu-group';
16
+ import Spinner from '@atlaskit/spinner';
17
+ import { slideAndFade } from '@atlaskit/top-layer/animations';
18
+ import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
19
+ import { Popup } from '@atlaskit/top-layer/popup';
20
+ import SelectionStore from './internal/context/selection-store';
21
+ import { getFirstFocusable, isAtCurrentMenuLevel, useArrowNavigation } from './internal/use-arrow-navigation';
22
+ const MAX_HEIGHT = `calc(100vh - 16px)`;
23
+ const styles = {
24
+ spinnerContainer: "_1e0c1txw _1ul91lit _1bah1h6o _1q51v47k _y4tiv47k _85i5v47k _bozgv47k",
25
+ menuContent: "_2rkofajl _bfhk1bhr _16qs130s"
26
+ };
27
+ const animation = slideAndFade();
28
+
29
+ /**
30
+ * Event types produced by trigger interactions.
31
+ *
32
+ * - `React.MouseEvent<Element>` — from the trigger's `onClick` handler
33
+ * - `React.KeyboardEvent<Element>` — from the trigger's `onClick` when activated via keyboard
34
+ * - `KeyboardEvent` — native event from the ArrowDown `bind(window, ...)` listener
35
+ */
36
+
37
+ /**
38
+ * Determines whether a trigger interaction was keyboard-initiated.
39
+ *
40
+ * Keyboard signals:
41
+ * - `type === 'keydown'` (native KeyboardEvent from ArrowDown handler)
42
+ * - `clientX/clientY === 0` (assistive technology click)
43
+ * - `detail === 0` (keyboard-activated click via Enter/Space)
44
+ */
45
+ function isKeyboardTriggered(event) {
46
+ if (event.type === 'keydown') {
47
+ return true;
48
+ }
49
+ if ('clientX' in event && (event.clientX === 0 || event.clientY === 0)) {
50
+ return true;
51
+ }
52
+ if (event.detail === 0) {
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Loading indicator for the dropdown menu.
60
+ */
61
+ function LoadingIndicator({
62
+ statusLabel = 'Loading',
63
+ testId
64
+ }) {
65
+ return /*#__PURE__*/React.createElement("div", {
66
+ role: "menuitem",
67
+ className: ax([styles.spinnerContainer])
68
+ }, /*#__PURE__*/React.createElement(Spinner, {
69
+ size: "small",
70
+ label: statusLabel,
71
+ testId: testId
72
+ }));
73
+ }
74
+
75
+ /**
76
+ * Top-layer implementation of DropdownMenu.
77
+ *
78
+ * Replaces the legacy `@atlaskit/popup` + `@atlaskit/portal` + `@atlaskit/layering` pipeline
79
+ * with native Popover API via `@atlaskit/top-layer`.
80
+ *
81
+ * What's no longer needed:
82
+ * - Portal: top layer handles stacking natively
83
+ * - FocusLock / react-focus-lock: popover=auto provides light dismiss
84
+ * - z-index: top layer is always above everything
85
+ * - FocusManager (ref registration): replaced by DOM-query-based `useArrowNavigation`
86
+ * - handle-focus.tsx: replaced by `useArrowNavigation`
87
+ * - Layering context: top layer nesting is handled by the browser
88
+ * - Fallback placements / Popper: CSS Anchor Positioning handles positioning
89
+ */
90
+ function DropdownMenuTopLayer({
91
+ autoFocus = false,
92
+ children,
93
+ defaultOpen = false,
94
+ isLoading = false,
95
+ isOpen: isOpenProp,
96
+ onOpenChange = noop,
97
+ placement = 'bottom-start',
98
+ shouldFitContainer = false,
99
+ returnFocusRef,
100
+ spacing,
101
+ statusLabel,
102
+ testId,
103
+ trigger,
104
+ label,
105
+ interactionName,
106
+ menuLabel
107
+ }) {
108
+ var _ref;
109
+ const [isLocalOpen, setLocalIsOpen] = useControlledState(isOpenProp, () => defaultOpen);
110
+ const triggerRef = useRef(null);
111
+ const menuRef = useRef(null);
112
+ const [isTriggeredUsingKeyboard, setTriggeredUsingKeyboard] = useState(false);
113
+ const topLayerPlacement = useMemo(() => fromLegacyPlacement({
114
+ legacy: placement
115
+ }), [placement]);
116
+
117
+ // ── Close handling ──
118
+ // Focus restoration is handled natively by the Popover API:
119
+ // - Escape → browser restores focus to the trigger automatically
120
+ // - Click-outside → browser does NOT restore (correct behavior)
121
+ //
122
+ // The only custom focus handling needed is `returnFocusRef`: when provided,
123
+ // we redirect focus to a different element than the trigger. We do this
124
+ // in the onClose callback via rAF, which runs after the browser's native
125
+ // restoration — effectively overriding it.
126
+ const handleOnClose = useCallback(({
127
+ reason: _reason
128
+ }) => {
129
+ if (returnFocusRef) {
130
+ requestAnimationFrame(() => {
131
+ var _returnFocusRef$curre;
132
+ (_returnFocusRef$curre = returnFocusRef.current) === null || _returnFocusRef$curre === void 0 ? void 0 : _returnFocusRef$curre.focus();
133
+ });
134
+ }
135
+ setLocalIsOpen(false);
136
+ onOpenChange({
137
+ isOpen: false,
138
+ event: null
139
+ });
140
+ }, [onOpenChange, returnFocusRef, setLocalIsOpen]);
141
+
142
+ // ── Trigger click handling ──
143
+ const handleTriggerClicked = useCallback(event => {
144
+ const newValue = !isLocalOpen;
145
+ setTriggeredUsingKeyboard(isKeyboardTriggered(event));
146
+ setLocalIsOpen(newValue);
147
+
148
+ // Extract the native DOM event for onOpenChange
149
+ const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
150
+ onOpenChange({
151
+ isOpen: newValue,
152
+ event: nativeEvent
153
+ });
154
+ }, [isLocalOpen, setLocalIsOpen, onOpenChange]);
155
+ const {
156
+ isFocused,
157
+ bindFocus
158
+ } = useFocus();
159
+
160
+ // When trigger is focused, open dropdown on ArrowDown (top-level only).
161
+ // Per WAI-ARIA, ArrowDown opens a menu from a menubar/button trigger,
162
+ // but inside a vertical submenu, ArrowDown navigates between siblings
163
+ // and ArrowRight opens nested menus instead.
164
+ useEffect(() => {
165
+ var _triggerRef$current;
166
+ if (!isFocused || isLocalOpen) {
167
+ return noop;
168
+ }
169
+
170
+ // Don't open on ArrowDown if this trigger is inside a parent menu.
171
+ // Nested menus should only be opened via ArrowRight or Enter.
172
+ const isNestedTrigger = ((_triggerRef$current = triggerRef.current) === null || _triggerRef$current === void 0 ? void 0 : _triggerRef$current.closest('[role="menu"]')) != null;
173
+ return bind(window, {
174
+ type: 'keydown',
175
+ listener: function openOnKeyDown(e) {
176
+ if (e.key === KEY_DOWN && !isNestedTrigger) {
177
+ e.preventDefault();
178
+ handleTriggerClicked(e);
179
+ } else if ((e.code === KEY_SPACE || e.key === KEY_ENTER) && e.detail === 0) {
180
+ setTriggeredUsingKeyboard(true);
181
+ }
182
+ }
183
+ });
184
+ }, [isFocused, isLocalOpen, handleTriggerClicked]);
185
+
186
+ // ── Arrow navigation ──
187
+ // useArrowNavigation handles ArrowUp/Down, Home/End, and Tab-to-close
188
+ // by querying focusable elements in the menu DOM container.
189
+ const handleArrowClose = useCallback(() => {
190
+ handleOnClose({
191
+ reason: 'escape'
192
+ });
193
+ }, [handleOnClose]);
194
+ const handleNestedOpen = useCallback(({
195
+ trigger
196
+ }) => {
197
+ trigger.click();
198
+ }, []);
199
+ const handleNestedClose = useCallback(() => {
200
+ handleOnClose({
201
+ reason: 'escape'
202
+ });
203
+ }, [handleOnClose]);
204
+ useArrowNavigation({
205
+ containerRef: menuRef,
206
+ onClose: handleArrowClose,
207
+ onNestedOpen: handleNestedOpen,
208
+ onNestedClose: handleNestedClose,
209
+ isEnabled: isLocalOpen,
210
+ filter: isAtCurrentMenuLevel
211
+ });
212
+
213
+ // ── Auto-focus first item on open ──
214
+ useEffect(() => {
215
+ if (!isLocalOpen || !isTriggeredUsingKeyboard && !autoFocus) {
216
+ return;
217
+ }
218
+ requestAnimationFrame(() => {
219
+ const menu = menuRef.current;
220
+ if (!menu) {
221
+ return;
222
+ }
223
+ const firstItem = getFirstFocusable({
224
+ container: menu
225
+ });
226
+ firstItem === null || firstItem === void 0 ? void 0 : firstItem.focus();
227
+ });
228
+ }, [isLocalOpen, isTriggeredUsingKeyboard, autoFocus]);
229
+
230
+ // shouldFitContainer is handled by the width prop on Popup.Content below.
231
+ const popupContentWidth = shouldFitContainer ? 'min-trigger' : 'content';
232
+
233
+ // ── Close on menu item click ──
234
+ // Close when a regular menuitem is clicked, but not checkboxes/radios
235
+ // and not nested triggers (items with aria-haspopup).
236
+ const handleMenuClick = useCallback(e => {
237
+ var _target$closest;
238
+ const target = e.target;
239
+ const menuItem = (_target$closest = target.closest) === null || _target$closest === void 0 ? void 0 : _target$closest.call(target, '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]');
240
+ if (!menuItem) {
241
+ return;
242
+ }
243
+ const isCheckboxOrRadio = menuItem.getAttribute('role') === 'menuitemcheckbox' || menuItem.getAttribute('role') === 'menuitemradio';
244
+ // Don't close the menu when clicking a nested trigger (aria-haspopup).
245
+ // The nested dropdown will handle its own open/close.
246
+ const isNestedTrigger = menuItem.hasAttribute('aria-haspopup');
247
+ if (!isCheckboxOrRadio && !isNestedTrigger) {
248
+ setLocalIsOpen(false);
249
+ onOpenChange({
250
+ isOpen: false,
251
+ event: e.nativeEvent
252
+ });
253
+ }
254
+ }, [setLocalIsOpen, onOpenChange]);
255
+ return /*#__PURE__*/React.createElement(SelectionStore, null, /*#__PURE__*/React.createElement(Popup, {
256
+ placement: topLayerPlacement,
257
+ onClose: handleOnClose
258
+ }, /*#__PURE__*/React.createElement(Popup.TriggerFunction, null, ({
259
+ ref,
260
+ toggle: _toggle,
261
+ ariaAttributes
262
+ }) => {
263
+ const combinedRef = mergeRefs([ref, triggerRef]);
264
+
265
+ // FUDGE(top-layer-api): cast `ariaAttributes` to the narrow shape that adopter
266
+ // public types expect. `@atlaskit/top-layer` types `aria-haspopup` as the wider
267
+ // WAI-ARIA union (boolean | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid'),
268
+ // but the public `CustomTriggerProps` (extending `@atlaskit/popup` `TriggerProps`)
269
+ // is intentionally kept narrow (boolean | 'dialog') because the top-layer API
270
+ // surface is not yet settled. The runtime value is unchanged; only the
271
+ // TypeScript-visible type is narrowed at this boundary.
272
+ // REMOVE WHEN: the top-layer public API is committed (see
273
+ // packages/design-system/top-layer/notes/decisions/migration-roadmap.md "Open API
274
+ // decisions deferred to a follow-up PR") and a follow-up `minor` PR widens
275
+ // `TriggerProps['aria-haspopup']` on `@atlaskit/popup` to match.
276
+ const narrowAriaAttributes = ariaAttributes;
277
+ if (typeof trigger === 'function') {
278
+ return trigger({
279
+ ...narrowAriaAttributes,
280
+ ...bindFocus,
281
+ triggerRef: combinedRef,
282
+ isSelected: isLocalOpen,
283
+ onClick: handleTriggerClicked,
284
+ testId: testId && `${testId}--trigger`
285
+ });
286
+ }
287
+ return /*#__PURE__*/React.createElement(Button, _extends({}, bindFocus, {
288
+ ref: combinedRef
289
+ }, narrowAriaAttributes, {
290
+ isSelected: isLocalOpen,
291
+ iconAfter: iconProps => /*#__PURE__*/React.createElement(ExpandIcon, _extends({}, iconProps, {
292
+ size: "small"
293
+ })),
294
+ onClick: handleTriggerClicked,
295
+ testId: testId && `${testId}--trigger`,
296
+ "aria-label": label,
297
+ interactionName: interactionName
298
+ }), trigger);
299
+ }), /*#__PURE__*/React.createElement(Popup.Content, {
300
+ role: "menu",
301
+ label: (_ref = menuLabel !== null && menuLabel !== void 0 ? menuLabel : label) !== null && _ref !== void 0 ? _ref : typeof trigger === 'string' ? trigger : 'Menu',
302
+ isOpen: isLocalOpen,
303
+ animate: animation,
304
+ width: popupContentWidth,
305
+ testId: testId && `${testId}--content`
306
+ }, /*#__PURE__*/React.createElement("div", {
307
+ ref: menuRef,
308
+ className: ax([styles.menuContent])
309
+ }, /*#__PURE__*/React.createElement(MenuGroup, {
310
+ isLoading: isLoading,
311
+ maxHeight: MAX_HEIGHT,
312
+ maxWidth: shouldFitContainer ? undefined : 800,
313
+ onClick: handleMenuClick,
314
+ role: "menu",
315
+ spacing: spacing,
316
+ testId: testId && `${testId}--menu-wrapper--menu-group`,
317
+ menuLabel: menuLabel
318
+ }, isLoading ? /*#__PURE__*/React.createElement(LoadingIndicator, {
319
+ statusLabel: statusLabel,
320
+ testId: testId && `${testId}--menu-wrapper--loading-indicator`
321
+ }) : children)))));
322
+ }
323
+ export default DropdownMenuTopLayer;
@@ -9,9 +9,11 @@ import useControlledState from '@atlaskit/ds-lib/use-controlled';
9
9
  import useFocus from '@atlaskit/ds-lib/use-focus-event';
10
10
  import ExpandIcon from '@atlaskit/icon/core/chevron-down';
11
11
  import { useLayering } from '@atlaskit/layering';
12
+ import { fg } from '@atlaskit/platform-feature-flags';
12
13
  import Popup from '@atlaskit/popup';
13
14
  // eslint-disable-next-line @atlaskit/design-system/no-deprecated-imports
14
15
  import { layers } from '@atlaskit/theme/constants';
16
+ import DropdownMenuTopLayer from './dropdown-menu-top-layer';
15
17
  import FocusManager from './internal/components/focus-manager';
16
18
  import MenuWrapper from './internal/components/menu-wrapper';
17
19
  import SelectionStore from './internal/context/selection-store';
@@ -50,15 +52,9 @@ function isKeyboardEvent(event) {
50
52
  }
51
53
 
52
54
  /**
53
- * __Dropdown menu__
54
- *
55
- * A dropdown menu displays a list of actions or options to a user.
56
- *
57
- * - [Examples](https://atlassian.design/components/dropdown-menu/examples)
58
- * - [Code](https://atlassian.design/components/dropdown-menu/code)
59
- * - [Usage](https://atlassian.design/components/dropdown-menu/usage)
55
+ * Legacy Popper/Popup implementation (hooks run unconditionally when this component mounts).
60
56
  */
61
- const DropdownMenu = ({
57
+ function DropdownMenuLegacy({
62
58
  autoFocus = false,
63
59
  children,
64
60
  defaultOpen = false,
@@ -80,7 +76,7 @@ const DropdownMenu = ({
80
76
  strategy,
81
77
  menuLabel,
82
78
  shouldPreventEscapePropagation = false
83
- }) => {
79
+ }) {
84
80
  const [isLocalOpen, setLocalIsOpen] = useControlledState(isOpen, () => defaultOpen);
85
81
  const triggerRef = useRef(null);
86
82
  const [isTriggeredUsingKeyboard, setTriggeredUsingKeyboard] = useState(false);
@@ -315,5 +311,59 @@ const DropdownMenu = ({
315
311
  menuLabel: menuLabel
316
312
  }, children))
317
313
  })));
314
+ }
315
+
316
+ /**
317
+ * __Dropdown menu__
318
+ *
319
+ * A dropdown menu displays a list of actions or options to a user.
320
+ *
321
+ * - [Examples](https://atlassian.design/components/dropdown-menu/examples)
322
+ * - [Code](https://atlassian.design/components/dropdown-menu/code)
323
+ * - [Usage](https://atlassian.design/components/dropdown-menu/usage)
324
+ */
325
+ const DropdownMenu = props => {
326
+ const {
327
+ autoFocus = false,
328
+ children,
329
+ defaultOpen = false,
330
+ isLoading = false,
331
+ isOpen,
332
+ onOpenChange = noop,
333
+ placement = 'bottom-start',
334
+ shouldFitContainer = false,
335
+ returnFocusRef,
336
+ spacing,
337
+ statusLabel,
338
+ testId,
339
+ trigger,
340
+ label,
341
+ interactionName,
342
+ menuLabel
343
+ } = props;
344
+ if (fg('platform-dst-top-layer')) {
345
+ return /*#__PURE__*/React.createElement(DropdownMenuTopLayer, {
346
+ autoFocus: autoFocus,
347
+ children: children,
348
+ defaultOpen: defaultOpen,
349
+ isLoading: isLoading,
350
+ isOpen: isOpen,
351
+ onOpenChange: onOpenChange,
352
+ placement: placement,
353
+ shouldFitContainer: shouldFitContainer,
354
+ returnFocusRef: returnFocusRef,
355
+ spacing: spacing,
356
+ statusLabel: statusLabel,
357
+ testId: testId,
358
+ trigger: trigger,
359
+ label: label,
360
+ interactionName: interactionName,
361
+ menuLabel: menuLabel
362
+ });
363
+ }
364
+
365
+ // Forward full public props to the legacy Popper/Popup implementation unchanged.
366
+ // eslint-disable-next-line @repo/internal/react/no-unsafe-spread-props -- wrapper delegates entire DropdownMenuProps API
367
+ return /*#__PURE__*/React.createElement(DropdownMenuLegacy, props);
318
368
  };
319
369
  export default DropdownMenu;
@@ -10,7 +10,12 @@ const styles = {
10
10
  /**
11
11
  * __Group title__
12
12
  *
13
- * Used to visually represent the title for DropdownMenu groups
13
+ * Used to visually represent the title for DropdownMenu groups.
14
+ *
15
+ * Pre-existing a11y note: uses `role="menuitem"` with `aria-hidden="true"`.
16
+ * WAI-ARIA APG recommends `role="presentation"` for non-interactive group
17
+ * headings, with the group linked via `aria-labelledby`. Out of scope for
18
+ * the top-layer migration — this is legacy behavior.
14
19
  *
15
20
  * @internal
16
21
  */
@@ -0,0 +1,2 @@
1
+ export { useArrowNavigation, isAtCurrentMenuLevel } from './use-arrow-navigation';
2
+ export { getFirstFocusable } from '@atlaskit/top-layer/focus';
@@ -0,0 +1 @@
1
+ export { useArrowNavigation, isAtCurrentMenuLevel } from '@atlaskit/top-layer/use-arrow-navigation';
@@ -0,0 +1,10 @@
1
+
2
+ ._2rkofajl{border-radius:var(--ds-radius-small,3px)}._16qs130s{box-shadow:var(--ds-shadow-overlay,0 8px 9pt #1e1f2126,0 0 1px #1e1f214f)}
3
+ ._1bah1h6o{justify-content:center}
4
+ ._1e0c1txw{display:flex}
5
+ ._1q51v47k{padding-block-start:var(--ds-space-250,20px)}
6
+ ._1ul91lit{min-width:10pc}
7
+ ._85i5v47k{padding-block-end:var(--ds-space-250,20px)}
8
+ ._bfhk1bhr{background-color:var(--ds-surface-overlay,#fff)}
9
+ ._bozgv47k{padding-inline-start:var(--ds-space-250,20px)}
10
+ ._y4tiv47k{padding-inline-end:var(--ds-space-250,20px)}