@atlaskit/dropdown-menu 16.5.2 → 16.6.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,16 @@
1
1
  # @atlaskit/dropdown-menu
2
2
 
3
+ ## 16.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`0b9ac729e9180`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/0b9ac729e9180) -
8
+ [ux] Nested dropdown menus can now navigate throguh left and right arrow keys.
9
+
10
+ ### Patch Changes
11
+
12
+ - Updated dependencies
13
+
3
14
  ## 16.5.2
4
15
 
5
16
  ### Patch Changes
@@ -158,13 +158,13 @@ var DropdownMenu = function DropdownMenu(_ref) {
158
158
  }, [isLocalOpen, setLocalIsOpen, onOpenChange, itemRef]);
159
159
  var handleOnClose = (0, _react.useCallback)(function (event, currentLevel) {
160
160
  var _event$target$closest, _event$target;
161
- var isTabOrEscapeKey = isKeyboardEvent(event) && (event.key === 'Tab' || event.key === 'Escape');
161
+ var isTabLeftOrEscapeKey = isKeyboardEvent(event) && (event.key === 'Tab' || event.key === 'Escape' || event.key === _keycodes.KEY_LEFT);
162
162
 
163
- // Stop propagation on ESCAPE key if shouldPreventEscapePropagation is true
164
- if (shouldPreventEscapePropagation && isKeyboardEvent(event) && event.key === 'Escape') {
163
+ // Stop propagation on ESCAPE or Left arrow if shouldPreventEscapePropagation is true
164
+ if (shouldPreventEscapePropagation && isKeyboardEvent(event) && (event.key === 'Escape' || event.key === _keycodes.KEY_LEFT)) {
165
165
  event.stopPropagation();
166
166
  }
167
- if (event !== null && !isTabOrEscapeKey && event.target instanceof HTMLElement && (_event$target$closest = (_event$target = event.target).closest) !== null && _event$target$closest !== void 0 && _event$target$closest.call(_event$target, "[id^=".concat(_useGeneratedId.PREFIX, "] [aria-haspopup]"))) {
167
+ if (event !== null && !isTabLeftOrEscapeKey && event.target instanceof HTMLElement && (_event$target$closest = (_event$target = event.target).closest) !== null && _event$target$closest !== void 0 && _event$target$closest.call(_event$target, "[id^=".concat(_useGeneratedId.PREFIX, "] [aria-haspopup]"))) {
168
168
  var _itemRef$current2;
169
169
  // Check if it is within dropdown and it is a trigger button
170
170
  // if it is a nested dropdown, clicking trigger won't close the dropdown
@@ -188,7 +188,7 @@ var DropdownMenu = function DropdownMenu(_ref) {
188
188
  var _returnFocusRef$curre;
189
189
  (_returnFocusRef$curre = returnFocusRef.current) === null || _returnFocusRef$curre === void 0 || _returnFocusRef$curre.focus();
190
190
  });
191
- } else if (isKeyboardEvent(event) && (event.key === 'Tab' && event.shiftKey || event.key === 'Escape')) {
191
+ } else if (isKeyboardEvent(event) && (event.key === 'Tab' && event.shiftKey || event.key === 'Escape' || event.key === _keycodes.KEY_LEFT)) {
192
192
  requestAnimationFrame(function () {
193
193
  var _itemRef$current3;
194
194
  (_itemRef$current3 = itemRef.current) === null || _itemRef$current3 === void 0 || _itemRef$current3.focus();
@@ -64,7 +64,10 @@ var FocusManager = function FocusManager(_ref) {
64
64
  // Updating register ref on force reload will cause `useRegisterItemWithFocusManager` to re-register
65
65
  [refresh]);
66
66
  var _useLayering = (0, _layering.useLayering)(),
67
- isLayerDisabled = _useLayering.isLayerDisabled;
67
+ isLayerDisabled = _useLayering.isLayerDisabled,
68
+ currentLevel = _useLayering.currentLevel;
69
+ // Root menu (from a button) is at level 1; first submenu is at level 2. ARIA: Left only closes submenus.
70
+ var isNestedMenu = currentLevel > 1;
68
71
  // Intentionally rebinding on each render
69
72
  (0, _react.useEffect)(function () {
70
73
  if (registerMode.current === 'ordered') {
@@ -75,9 +78,9 @@ var FocusManager = function FocusManager(_ref) {
75
78
  (0, _react.useEffect)(function () {
76
79
  return (0, _bindEventListener.bind)(window, {
77
80
  type: 'keydown',
78
- listener: (0, _handleFocus.default)(menuItemRefs, isLayerDisabled, onClose)
81
+ listener: (0, _handleFocus.default)(menuItemRefs, isLayerDisabled, onClose, isNestedMenu)
79
82
  });
80
- }, [isLayerDisabled, onClose]);
83
+ }, [isLayerDisabled, onClose, isNestedMenu]);
81
84
  var contextValue = {
82
85
  menuItemRefs: menuItemRefs.current,
83
86
  registerRef: registerRef
@@ -55,6 +55,7 @@ var getPrevFocusableElement = function getPrevFocusableElement(refs, currentFocu
55
55
  return;
56
56
  };
57
57
  function handleFocus(refs, isLayerDisabled, onClose) {
58
+ var isNestedMenu = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
58
59
  return function (e) {
59
60
  var _refs$current;
60
61
  var currentRefs = (_refs$current = refs.current) !== null && _refs$current !== void 0 ? _refs$current : [];
@@ -87,6 +88,24 @@ function handleFocus(refs, isLayerDisabled, onClose) {
87
88
  return;
88
89
  }
89
90
  }
91
+ // Left arrow: only in a submenu — close and return focus to parent. No navigation within menu.
92
+ if (e.key === _keycodes.KEY_LEFT) {
93
+ e.preventDefault();
94
+ if (isNestedMenu) {
95
+ onClose(e);
96
+ }
97
+ return;
98
+ }
99
+
100
+ // Right arrow: only when item has submenu — open it. No navigation within menu.
101
+ if (e.key === _keycodes.KEY_RIGHT) {
102
+ var activeElement = document.activeElement;
103
+ if (activeElement instanceof HTMLElement && (activeElement.getAttribute('aria-haspopup') === 'true' || activeElement.getAttribute('aria-haspopup') === 'menu')) {
104
+ e.preventDefault();
105
+ activeElement.click();
106
+ }
107
+ return;
108
+ }
90
109
  var action = actionMap[e.key];
91
110
  switch (action) {
92
111
  case 'next':
@@ -2,7 +2,7 @@ import _extends from "@babel/runtime/helpers/extends";
2
2
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { bind } from 'bind-event-listener';
4
4
  import Button from '@atlaskit/button/new';
5
- import { KEY_DOWN, KEY_ENTER, KEY_SPACE, KEY_TAB } from '@atlaskit/ds-lib/keycodes';
5
+ import { KEY_DOWN, KEY_ENTER, KEY_LEFT, KEY_SPACE, KEY_TAB } from '@atlaskit/ds-lib/keycodes';
6
6
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
7
7
  import noop from '@atlaskit/ds-lib/noop';
8
8
  import useControlledState from '@atlaskit/ds-lib/use-controlled';
@@ -128,13 +128,13 @@ const DropdownMenu = ({
128
128
  }, [isLocalOpen, setLocalIsOpen, onOpenChange, itemRef]);
129
129
  const handleOnClose = useCallback((event, currentLevel) => {
130
130
  var _event$target$closest, _event$target;
131
- const isTabOrEscapeKey = isKeyboardEvent(event) && (event.key === 'Tab' || event.key === 'Escape');
131
+ const isTabLeftOrEscapeKey = isKeyboardEvent(event) && (event.key === 'Tab' || event.key === 'Escape' || event.key === KEY_LEFT);
132
132
 
133
- // Stop propagation on ESCAPE key if shouldPreventEscapePropagation is true
134
- if (shouldPreventEscapePropagation && isKeyboardEvent(event) && event.key === 'Escape') {
133
+ // Stop propagation on ESCAPE or Left arrow if shouldPreventEscapePropagation is true
134
+ if (shouldPreventEscapePropagation && isKeyboardEvent(event) && (event.key === 'Escape' || event.key === KEY_LEFT)) {
135
135
  event.stopPropagation();
136
136
  }
137
- if (event !== null && !isTabOrEscapeKey && event.target instanceof HTMLElement && (_event$target$closest = (_event$target = event.target).closest) !== null && _event$target$closest !== void 0 && _event$target$closest.call(_event$target, `[id^=${PREFIX}] [aria-haspopup]`)) {
137
+ if (event !== null && !isTabLeftOrEscapeKey && event.target instanceof HTMLElement && (_event$target$closest = (_event$target = event.target).closest) !== null && _event$target$closest !== void 0 && _event$target$closest.call(_event$target, `[id^=${PREFIX}] [aria-haspopup]`)) {
138
138
  var _itemRef$current2;
139
139
  // Check if it is within dropdown and it is a trigger button
140
140
  // if it is a nested dropdown, clicking trigger won't close the dropdown
@@ -158,7 +158,7 @@ const DropdownMenu = ({
158
158
  var _returnFocusRef$curre;
159
159
  (_returnFocusRef$curre = returnFocusRef.current) === null || _returnFocusRef$curre === void 0 ? void 0 : _returnFocusRef$curre.focus();
160
160
  });
161
- } else if (isKeyboardEvent(event) && (event.key === 'Tab' && event.shiftKey || event.key === 'Escape')) {
161
+ } else if (isKeyboardEvent(event) && (event.key === 'Tab' && event.shiftKey || event.key === 'Escape' || event.key === KEY_LEFT)) {
162
162
  requestAnimationFrame(() => {
163
163
  var _itemRef$current3;
164
164
  (_itemRef$current3 = itemRef.current) === null || _itemRef$current3 === void 0 ? void 0 : _itemRef$current3.focus();
@@ -53,8 +53,11 @@ const FocusManager = ({
53
53
  // Updating register ref on force reload will cause `useRegisterItemWithFocusManager` to re-register
54
54
  [refresh]);
55
55
  const {
56
- isLayerDisabled
56
+ isLayerDisabled,
57
+ currentLevel
57
58
  } = useLayering();
59
+ // Root menu (from a button) is at level 1; first submenu is at level 2. ARIA: Left only closes submenus.
60
+ const isNestedMenu = currentLevel > 1;
58
61
  // Intentionally rebinding on each render
59
62
  useEffect(() => {
60
63
  if (registerMode.current === 'ordered') {
@@ -64,8 +67,8 @@ const FocusManager = ({
64
67
  });
65
68
  useEffect(() => bind(window, {
66
69
  type: 'keydown',
67
- listener: handleFocus(menuItemRefs, isLayerDisabled, onClose)
68
- }), [isLayerDisabled, onClose]);
70
+ listener: handleFocus(menuItemRefs, isLayerDisabled, onClose, isNestedMenu)
71
+ }), [isLayerDisabled, onClose, isNestedMenu]);
69
72
  const contextValue = {
70
73
  menuItemRefs: menuItemRefs.current,
71
74
  registerRef
@@ -1,4 +1,4 @@
1
- import { KEY_DOWN, KEY_END, KEY_HOME, KEY_TAB, KEY_UP } from '@atlaskit/ds-lib/keycodes';
1
+ import { KEY_DOWN, KEY_END, KEY_HOME, KEY_LEFT, KEY_RIGHT, KEY_TAB, KEY_UP } from '@atlaskit/ds-lib/keycodes';
2
2
  import { fg } from '@atlaskit/platform-feature-flags';
3
3
  import { PREFIX } from './use-generated-id';
4
4
  const actionMap = {
@@ -55,7 +55,7 @@ const getPrevFocusableElement = (refs, currentFocusedIdx) => {
55
55
  }
56
56
  return;
57
57
  };
58
- export default function handleFocus(refs, isLayerDisabled, onClose) {
58
+ export default function handleFocus(refs, isLayerDisabled, onClose, isNestedMenu = false) {
59
59
  return e => {
60
60
  var _refs$current;
61
61
  const currentRefs = (_refs$current = refs.current) !== null && _refs$current !== void 0 ? _refs$current : [];
@@ -89,6 +89,24 @@ export default function handleFocus(refs, isLayerDisabled, onClose) {
89
89
  return;
90
90
  }
91
91
  }
92
+ // Left arrow: only in a submenu — close and return focus to parent. No navigation within menu.
93
+ if (e.key === KEY_LEFT) {
94
+ e.preventDefault();
95
+ if (isNestedMenu) {
96
+ onClose(e);
97
+ }
98
+ return;
99
+ }
100
+
101
+ // Right arrow: only when item has submenu — open it. No navigation within menu.
102
+ if (e.key === KEY_RIGHT) {
103
+ const activeElement = document.activeElement;
104
+ if (activeElement instanceof HTMLElement && (activeElement.getAttribute('aria-haspopup') === 'true' || activeElement.getAttribute('aria-haspopup') === 'menu')) {
105
+ e.preventDefault();
106
+ activeElement.click();
107
+ }
108
+ return;
109
+ }
92
110
  const action = actionMap[e.key];
93
111
  switch (action) {
94
112
  case 'next':
@@ -8,7 +8,7 @@ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t =
8
8
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
9
  import { bind } from 'bind-event-listener';
10
10
  import Button from '@atlaskit/button/new';
11
- import { KEY_DOWN, KEY_ENTER, KEY_SPACE, KEY_TAB } from '@atlaskit/ds-lib/keycodes';
11
+ import { KEY_DOWN, KEY_ENTER, KEY_LEFT, KEY_SPACE, KEY_TAB } from '@atlaskit/ds-lib/keycodes';
12
12
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
13
13
  import noop from '@atlaskit/ds-lib/noop';
14
14
  import useControlledState from '@atlaskit/ds-lib/use-controlled';
@@ -150,13 +150,13 @@ var DropdownMenu = function DropdownMenu(_ref) {
150
150
  }, [isLocalOpen, setLocalIsOpen, onOpenChange, itemRef]);
151
151
  var handleOnClose = useCallback(function (event, currentLevel) {
152
152
  var _event$target$closest, _event$target;
153
- var isTabOrEscapeKey = isKeyboardEvent(event) && (event.key === 'Tab' || event.key === 'Escape');
153
+ var isTabLeftOrEscapeKey = isKeyboardEvent(event) && (event.key === 'Tab' || event.key === 'Escape' || event.key === KEY_LEFT);
154
154
 
155
- // Stop propagation on ESCAPE key if shouldPreventEscapePropagation is true
156
- if (shouldPreventEscapePropagation && isKeyboardEvent(event) && event.key === 'Escape') {
155
+ // Stop propagation on ESCAPE or Left arrow if shouldPreventEscapePropagation is true
156
+ if (shouldPreventEscapePropagation && isKeyboardEvent(event) && (event.key === 'Escape' || event.key === KEY_LEFT)) {
157
157
  event.stopPropagation();
158
158
  }
159
- if (event !== null && !isTabOrEscapeKey && event.target instanceof HTMLElement && (_event$target$closest = (_event$target = event.target).closest) !== null && _event$target$closest !== void 0 && _event$target$closest.call(_event$target, "[id^=".concat(PREFIX, "] [aria-haspopup]"))) {
159
+ if (event !== null && !isTabLeftOrEscapeKey && event.target instanceof HTMLElement && (_event$target$closest = (_event$target = event.target).closest) !== null && _event$target$closest !== void 0 && _event$target$closest.call(_event$target, "[id^=".concat(PREFIX, "] [aria-haspopup]"))) {
160
160
  var _itemRef$current2;
161
161
  // Check if it is within dropdown and it is a trigger button
162
162
  // if it is a nested dropdown, clicking trigger won't close the dropdown
@@ -180,7 +180,7 @@ var DropdownMenu = function DropdownMenu(_ref) {
180
180
  var _returnFocusRef$curre;
181
181
  (_returnFocusRef$curre = returnFocusRef.current) === null || _returnFocusRef$curre === void 0 || _returnFocusRef$curre.focus();
182
182
  });
183
- } else if (isKeyboardEvent(event) && (event.key === 'Tab' && event.shiftKey || event.key === 'Escape')) {
183
+ } else if (isKeyboardEvent(event) && (event.key === 'Tab' && event.shiftKey || event.key === 'Escape' || event.key === KEY_LEFT)) {
184
184
  requestAnimationFrame(function () {
185
185
  var _itemRef$current3;
186
186
  (_itemRef$current3 = itemRef.current) === null || _itemRef$current3 === void 0 || _itemRef$current3.focus();
@@ -56,7 +56,10 @@ var FocusManager = function FocusManager(_ref) {
56
56
  // Updating register ref on force reload will cause `useRegisterItemWithFocusManager` to re-register
57
57
  [refresh]);
58
58
  var _useLayering = useLayering(),
59
- isLayerDisabled = _useLayering.isLayerDisabled;
59
+ isLayerDisabled = _useLayering.isLayerDisabled,
60
+ currentLevel = _useLayering.currentLevel;
61
+ // Root menu (from a button) is at level 1; first submenu is at level 2. ARIA: Left only closes submenus.
62
+ var isNestedMenu = currentLevel > 1;
60
63
  // Intentionally rebinding on each render
61
64
  useEffect(function () {
62
65
  if (registerMode.current === 'ordered') {
@@ -67,9 +70,9 @@ var FocusManager = function FocusManager(_ref) {
67
70
  useEffect(function () {
68
71
  return bind(window, {
69
72
  type: 'keydown',
70
- listener: handleFocus(menuItemRefs, isLayerDisabled, onClose)
73
+ listener: handleFocus(menuItemRefs, isLayerDisabled, onClose, isNestedMenu)
71
74
  });
72
- }, [isLayerDisabled, onClose]);
75
+ }, [isLayerDisabled, onClose, isNestedMenu]);
73
76
  var contextValue = {
74
77
  menuItemRefs: menuItemRefs.current,
75
78
  registerRef: registerRef
@@ -1,5 +1,5 @@
1
1
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
- import { KEY_DOWN, KEY_END, KEY_HOME, KEY_TAB, KEY_UP } from '@atlaskit/ds-lib/keycodes';
2
+ import { KEY_DOWN, KEY_END, KEY_HOME, KEY_LEFT, KEY_RIGHT, KEY_TAB, KEY_UP } from '@atlaskit/ds-lib/keycodes';
3
3
  import { fg } from '@atlaskit/platform-feature-flags';
4
4
  import { PREFIX } from './use-generated-id';
5
5
  var actionMap = _defineProperty(_defineProperty(_defineProperty(_defineProperty({}, KEY_DOWN, 'next'), KEY_UP, 'prev'), KEY_HOME, 'first'), KEY_END, 'last');
@@ -48,6 +48,7 @@ var getPrevFocusableElement = function getPrevFocusableElement(refs, currentFocu
48
48
  return;
49
49
  };
50
50
  export default function handleFocus(refs, isLayerDisabled, onClose) {
51
+ var isNestedMenu = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
51
52
  return function (e) {
52
53
  var _refs$current;
53
54
  var currentRefs = (_refs$current = refs.current) !== null && _refs$current !== void 0 ? _refs$current : [];
@@ -80,6 +81,24 @@ export default function handleFocus(refs, isLayerDisabled, onClose) {
80
81
  return;
81
82
  }
82
83
  }
84
+ // Left arrow: only in a submenu — close and return focus to parent. No navigation within menu.
85
+ if (e.key === KEY_LEFT) {
86
+ e.preventDefault();
87
+ if (isNestedMenu) {
88
+ onClose(e);
89
+ }
90
+ return;
91
+ }
92
+
93
+ // Right arrow: only when item has submenu — open it. No navigation within menu.
94
+ if (e.key === KEY_RIGHT) {
95
+ var activeElement = document.activeElement;
96
+ if (activeElement instanceof HTMLElement && (activeElement.getAttribute('aria-haspopup') === 'true' || activeElement.getAttribute('aria-haspopup') === 'menu')) {
97
+ e.preventDefault();
98
+ activeElement.click();
99
+ }
100
+ return;
101
+ }
83
102
  var action = actionMap[e.key];
84
103
  switch (action) {
85
104
  case 'next':
@@ -1,3 +1,3 @@
1
1
  import { type RefObject } from 'react';
2
2
  import { type FocusableElementRef } from '../../types';
3
- export default function handleFocus(refs: RefObject<FocusableElementRef[]>, isLayerDisabled: () => boolean, onClose: (e: KeyboardEvent) => void): (e: KeyboardEvent) => void;
3
+ export default function handleFocus(refs: RefObject<FocusableElementRef[]>, isLayerDisabled: () => boolean, onClose: (e: KeyboardEvent) => void, isNestedMenu?: boolean): (e: KeyboardEvent) => void;
@@ -1,3 +1,3 @@
1
1
  import { type RefObject } from 'react';
2
2
  import { type FocusableElementRef } from '../../types';
3
- export default function handleFocus(refs: RefObject<FocusableElementRef[]>, isLayerDisabled: () => boolean, onClose: (e: KeyboardEvent) => void): (e: KeyboardEvent) => void;
3
+ export default function handleFocus(refs: RefObject<FocusableElementRef[]>, isLayerDisabled: () => boolean, onClose: (e: KeyboardEvent) => void, isNestedMenu?: boolean): (e: KeyboardEvent) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/dropdown-menu",
3
- "version": "16.5.2",
3
+ "version": "16.6.0",
4
4
  "description": "A dropdown menu displays a list of actions or options to a user.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/"
@@ -26,7 +26,7 @@
26
26
  "dependencies": {
27
27
  "@atlaskit/button": "^23.10.0",
28
28
  "@atlaskit/css": "^0.19.0",
29
- "@atlaskit/ds-lib": "^5.3.0",
29
+ "@atlaskit/ds-lib": "^5.4.0",
30
30
  "@atlaskit/icon": "^32.0.0",
31
31
  "@atlaskit/layering": "^3.6.0",
32
32
  "@atlaskit/menu": "^8.4.0",
@@ -35,7 +35,7 @@
35
35
  "@atlaskit/primitives": "^18.0.0",
36
36
  "@atlaskit/spinner": "^19.0.0",
37
37
  "@atlaskit/theme": "^22.0.0",
38
- "@atlaskit/tokens": "^11.0.0",
38
+ "@atlaskit/tokens": "^11.1.0",
39
39
  "@atlaskit/visually-hidden": "^3.0.0",
40
40
  "@babel/runtime": "^7.0.0",
41
41
  "bind-event-listener": "^3.0.0"
@@ -56,7 +56,7 @@
56
56
  "@atlaskit/form": "^15.4.0",
57
57
  "@atlaskit/heading": "^5.3.0",
58
58
  "@atlaskit/link": "^3.3.0",
59
- "@atlaskit/lozenge": "^13.4.0",
59
+ "@atlaskit/lozenge": "^13.5.0",
60
60
  "@atlaskit/modal-dialog": "^14.11.0",
61
61
  "@atlaskit/section-message": "^8.12.0",
62
62
  "@atlaskit/textfield": "^8.2.0",