@react-spectrum/menu 3.16.1-nightly.4321 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/ar-AE.main.js +8 -0
  2. package/dist/ar-AE.main.js.map +1 -0
  3. package/dist/ar-AE.mjs +10 -0
  4. package/dist/ar-AE.module.js +10 -0
  5. package/dist/ar-AE.module.js.map +1 -0
  6. package/dist/bg-BG.main.js +8 -0
  7. package/dist/bg-BG.main.js.map +1 -0
  8. package/dist/bg-BG.mjs +10 -0
  9. package/dist/bg-BG.module.js +10 -0
  10. package/dist/bg-BG.module.js.map +1 -0
  11. package/dist/cs-CZ.main.js +8 -0
  12. package/dist/cs-CZ.main.js.map +1 -0
  13. package/dist/cs-CZ.mjs +10 -0
  14. package/dist/cs-CZ.module.js +10 -0
  15. package/dist/cs-CZ.module.js.map +1 -0
  16. package/dist/da-DK.main.js +8 -0
  17. package/dist/da-DK.main.js.map +1 -0
  18. package/dist/da-DK.mjs +10 -0
  19. package/dist/da-DK.module.js +10 -0
  20. package/dist/da-DK.module.js.map +1 -0
  21. package/dist/de-DE.main.js +8 -0
  22. package/dist/de-DE.main.js.map +1 -0
  23. package/dist/de-DE.mjs +10 -0
  24. package/dist/de-DE.module.js +10 -0
  25. package/dist/de-DE.module.js.map +1 -0
  26. package/dist/el-GR.main.js +8 -0
  27. package/dist/el-GR.main.js.map +1 -0
  28. package/dist/el-GR.mjs +10 -0
  29. package/dist/el-GR.module.js +10 -0
  30. package/dist/el-GR.module.js.map +1 -0
  31. package/dist/en-US.main.js +8 -0
  32. package/dist/en-US.main.js.map +1 -0
  33. package/dist/en-US.mjs +10 -0
  34. package/dist/en-US.module.js +10 -0
  35. package/dist/en-US.module.js.map +1 -0
  36. package/dist/es-ES.main.js +8 -0
  37. package/dist/es-ES.main.js.map +1 -0
  38. package/dist/es-ES.mjs +10 -0
  39. package/dist/es-ES.module.js +10 -0
  40. package/dist/es-ES.module.js.map +1 -0
  41. package/dist/et-EE.main.js +8 -0
  42. package/dist/et-EE.main.js.map +1 -0
  43. package/dist/et-EE.mjs +10 -0
  44. package/dist/et-EE.module.js +10 -0
  45. package/dist/et-EE.module.js.map +1 -0
  46. package/dist/fi-FI.main.js +8 -0
  47. package/dist/fi-FI.main.js.map +1 -0
  48. package/dist/fi-FI.mjs +10 -0
  49. package/dist/fi-FI.module.js +10 -0
  50. package/dist/fi-FI.module.js.map +1 -0
  51. package/dist/fr-FR.main.js +8 -0
  52. package/dist/fr-FR.main.js.map +1 -0
  53. package/dist/fr-FR.mjs +10 -0
  54. package/dist/fr-FR.module.js +10 -0
  55. package/dist/fr-FR.module.js.map +1 -0
  56. package/dist/he-IL.main.js +8 -0
  57. package/dist/he-IL.main.js.map +1 -0
  58. package/dist/he-IL.mjs +10 -0
  59. package/dist/he-IL.module.js +10 -0
  60. package/dist/he-IL.module.js.map +1 -0
  61. package/dist/hr-HR.main.js +8 -0
  62. package/dist/hr-HR.main.js.map +1 -0
  63. package/dist/hr-HR.mjs +10 -0
  64. package/dist/hr-HR.module.js +10 -0
  65. package/dist/hr-HR.module.js.map +1 -0
  66. package/dist/hu-HU.main.js +8 -0
  67. package/dist/hu-HU.main.js.map +1 -0
  68. package/dist/hu-HU.mjs +10 -0
  69. package/dist/hu-HU.module.js +10 -0
  70. package/dist/hu-HU.module.js.map +1 -0
  71. package/dist/import.mjs +458 -332
  72. package/dist/it-IT.main.js +8 -0
  73. package/dist/it-IT.main.js.map +1 -0
  74. package/dist/it-IT.mjs +10 -0
  75. package/dist/it-IT.module.js +10 -0
  76. package/dist/it-IT.module.js.map +1 -0
  77. package/dist/ja-JP.main.js +8 -0
  78. package/dist/ja-JP.main.js.map +1 -0
  79. package/dist/ja-JP.mjs +10 -0
  80. package/dist/ja-JP.module.js +10 -0
  81. package/dist/ja-JP.module.js.map +1 -0
  82. package/dist/ko-KR.main.js +8 -0
  83. package/dist/ko-KR.main.js.map +1 -0
  84. package/dist/ko-KR.mjs +10 -0
  85. package/dist/ko-KR.module.js +10 -0
  86. package/dist/ko-KR.module.js.map +1 -0
  87. package/dist/lt-LT.main.js +8 -0
  88. package/dist/lt-LT.main.js.map +1 -0
  89. package/dist/lt-LT.mjs +10 -0
  90. package/dist/lt-LT.module.js +10 -0
  91. package/dist/lt-LT.module.js.map +1 -0
  92. package/dist/lv-LV.main.js +8 -0
  93. package/dist/lv-LV.main.js.map +1 -0
  94. package/dist/lv-LV.mjs +10 -0
  95. package/dist/lv-LV.module.js +10 -0
  96. package/dist/lv-LV.module.js.map +1 -0
  97. package/dist/main.css +545 -1
  98. package/dist/main.css.map +1 -0
  99. package/dist/main.js +452 -325
  100. package/dist/main.js.map +1 -1
  101. package/dist/module.js +458 -332
  102. package/dist/module.js.map +1 -1
  103. package/dist/nb-NO.main.js +8 -0
  104. package/dist/nb-NO.main.js.map +1 -0
  105. package/dist/nb-NO.mjs +10 -0
  106. package/dist/nb-NO.module.js +10 -0
  107. package/dist/nb-NO.module.js.map +1 -0
  108. package/dist/nl-NL.main.js +8 -0
  109. package/dist/nl-NL.main.js.map +1 -0
  110. package/dist/nl-NL.mjs +10 -0
  111. package/dist/nl-NL.module.js +10 -0
  112. package/dist/nl-NL.module.js.map +1 -0
  113. package/dist/pl-PL.main.js +8 -0
  114. package/dist/pl-PL.main.js.map +1 -0
  115. package/dist/pl-PL.mjs +10 -0
  116. package/dist/pl-PL.module.js +10 -0
  117. package/dist/pl-PL.module.js.map +1 -0
  118. package/dist/pt-BR.main.js +8 -0
  119. package/dist/pt-BR.main.js.map +1 -0
  120. package/dist/pt-BR.mjs +10 -0
  121. package/dist/pt-BR.module.js +10 -0
  122. package/dist/pt-BR.module.js.map +1 -0
  123. package/dist/pt-PT.main.js +8 -0
  124. package/dist/pt-PT.main.js.map +1 -0
  125. package/dist/pt-PT.mjs +10 -0
  126. package/dist/pt-PT.module.js +10 -0
  127. package/dist/pt-PT.module.js.map +1 -0
  128. package/dist/ro-RO.main.js +8 -0
  129. package/dist/ro-RO.main.js.map +1 -0
  130. package/dist/ro-RO.mjs +10 -0
  131. package/dist/ro-RO.module.js +10 -0
  132. package/dist/ro-RO.module.js.map +1 -0
  133. package/dist/ru-RU.main.js +8 -0
  134. package/dist/ru-RU.main.js.map +1 -0
  135. package/dist/ru-RU.mjs +10 -0
  136. package/dist/ru-RU.module.js +10 -0
  137. package/dist/ru-RU.module.js.map +1 -0
  138. package/dist/sk-SK.main.js +8 -0
  139. package/dist/sk-SK.main.js.map +1 -0
  140. package/dist/sk-SK.mjs +10 -0
  141. package/dist/sk-SK.module.js +10 -0
  142. package/dist/sk-SK.module.js.map +1 -0
  143. package/dist/sl-SI.main.js +8 -0
  144. package/dist/sl-SI.main.js.map +1 -0
  145. package/dist/sl-SI.mjs +10 -0
  146. package/dist/sl-SI.module.js +10 -0
  147. package/dist/sl-SI.module.js.map +1 -0
  148. package/dist/sr-SP.main.js +8 -0
  149. package/dist/sr-SP.main.js.map +1 -0
  150. package/dist/sr-SP.mjs +10 -0
  151. package/dist/sr-SP.module.js +10 -0
  152. package/dist/sr-SP.module.js.map +1 -0
  153. package/dist/sv-SE.main.js +8 -0
  154. package/dist/sv-SE.main.js.map +1 -0
  155. package/dist/sv-SE.mjs +10 -0
  156. package/dist/sv-SE.module.js +10 -0
  157. package/dist/sv-SE.module.js.map +1 -0
  158. package/dist/tr-TR.main.js +8 -0
  159. package/dist/tr-TR.main.js.map +1 -0
  160. package/dist/tr-TR.mjs +10 -0
  161. package/dist/tr-TR.module.js +10 -0
  162. package/dist/tr-TR.module.js.map +1 -0
  163. package/dist/types.d.ts +13 -3
  164. package/dist/types.d.ts.map +1 -1
  165. package/dist/uk-UA.main.js +8 -0
  166. package/dist/uk-UA.main.js.map +1 -0
  167. package/dist/uk-UA.mjs +10 -0
  168. package/dist/uk-UA.module.js +10 -0
  169. package/dist/uk-UA.module.js.map +1 -0
  170. package/dist/zh-CN.main.js +8 -0
  171. package/dist/zh-CN.main.js.map +1 -0
  172. package/dist/zh-CN.mjs +10 -0
  173. package/dist/zh-CN.module.js +10 -0
  174. package/dist/zh-CN.module.js.map +1 -0
  175. package/dist/zh-TW.main.js +8 -0
  176. package/dist/zh-TW.main.js.map +1 -0
  177. package/dist/zh-TW.mjs +10 -0
  178. package/dist/zh-TW.module.js +10 -0
  179. package/dist/zh-TW.module.js.map +1 -0
  180. package/package.json +24 -24
  181. package/src/ActionMenu.tsx +1 -1
  182. package/src/ContextualHelpTrigger.tsx +127 -57
  183. package/src/Menu.tsx +87 -12
  184. package/src/MenuItem.tsx +27 -22
  185. package/src/MenuTrigger.tsx +3 -3
  186. package/src/SubmenuTrigger.tsx +167 -0
  187. package/src/context.ts +21 -13
  188. package/src/index.ts +2 -0
@@ -10,15 +10,20 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {classNames, SlotProvider, useIsMobileDevice} from '@react-spectrum/utils';
14
- import {DismissButton} from '@react-aria/overlays';
13
+ import {classNames, SlotProvider, unwrapDOMRef, useIsMobileDevice} from '@react-spectrum/utils';
14
+ import {FocusScope} from '@react-aria/focus';
15
+ import {getInteractionModality} from '@react-aria/interactions';
15
16
  import helpStyles from '@adobe/spectrum-css-temp/components/contextualhelp/vars.css';
16
17
  import {ItemProps, Key} from '@react-types/shared';
17
- import {MenuDialogContext, useMenuStateContext} from './context';
18
- import {Modal, Popover} from '@react-spectrum/overlays';
19
- import React, {ReactElement, useRef} from 'react';
18
+ import {Popover} from '@react-spectrum/overlays';
19
+ import React, {JSX, ReactElement, useRef, useState} from 'react';
20
+ import ReactDOM from 'react-dom';
20
21
  import styles from '@adobe/spectrum-css-temp/components/menu/vars.css';
21
- import {useOverlayTriggerState} from '@react-stately/overlays';
22
+ import {SubmenuTriggerContext, useMenuStateContext} from './context';
23
+ import {TrayHeaderWrapper} from './Menu';
24
+ import {UNSTABLE_useSubmenuTrigger} from '@react-aria/menu';
25
+ import {UNSTABLE_useSubmenuTriggerState} from '@react-stately/menu';
26
+ import {useLayoutEffect} from '@react-aria/utils';
22
27
 
23
28
  interface MenuDialogTriggerProps {
24
29
  /** Whether the menu item is currently unavailable. */
@@ -34,23 +39,40 @@ interface InternalMenuDialogTriggerProps extends MenuDialogTriggerProps {
34
39
  export interface SpectrumMenuDialogTriggerProps extends MenuDialogTriggerProps {}
35
40
 
36
41
  function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElement {
37
- let {isUnavailable} = props;
42
+ let {isUnavailable = false, targetKey} = props;
38
43
 
39
44
  let triggerRef = useRef<HTMLLIElement>(null);
40
45
  let popoverRef = useRef(null);
41
- let {state: menuState, container, menu} = useMenuStateContext();
42
- let state = useOverlayTriggerState({isOpen: menuState.expandedKeys.has(props.targetKey), onOpenChange: (val) => {
43
- if (!val) {
44
- if (menuState.expandedKeys.has(props.targetKey)) {
45
- menuState.toggleKey(props.targetKey);
46
- }
47
- }
48
- }});
46
+ let {popoverContainerRef, trayContainerRef, rootMenuTriggerState, menu: parentMenuRef, state} = useMenuStateContext();
47
+ let triggerNode = state.collection.getItem(targetKey);
48
+ let submenuTriggerState = UNSTABLE_useSubmenuTriggerState({triggerKey: targetKey}, {...rootMenuTriggerState, ...state});
49
+ let submenuRef = unwrapDOMRef(popoverRef);
50
+ let {submenuTriggerProps, popoverProps} = UNSTABLE_useSubmenuTrigger({
51
+ node: triggerNode,
52
+ parentMenuRef,
53
+ submenuRef,
54
+ type: 'dialog',
55
+ isDisabled: !isUnavailable
56
+ }, submenuTriggerState, triggerRef);
49
57
  let isMobile = useIsMobileDevice();
50
58
  let slots = {};
51
59
  if (isUnavailable) {
52
60
  slots = {
53
- dialog: {UNSAFE_className: classNames(helpStyles, 'react-spectrum-ContextualHelp-dialog', classNames(styles, !isMobile ? 'spectrum-Menu-subdialog' : ''))},
61
+ dialog: {
62
+ UNSAFE_className: classNames(
63
+ helpStyles,
64
+ 'react-spectrum-ContextualHelp-dialog',
65
+ {
66
+ 'react-spectrum-ContextualHelp-dialog--isMobile': isMobile
67
+ },
68
+ classNames(
69
+ styles,
70
+ {
71
+ 'spectrum-Menu-subdialog': !isMobile
72
+ }
73
+ )
74
+ )
75
+ },
54
76
  content: {UNSAFE_className: helpStyles['react-spectrum-ContextualHelp-content']},
55
77
  footer: {UNSAFE_className: helpStyles['react-spectrum-ContextualHelp-footer']}
56
78
  };
@@ -58,62 +80,110 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem
58
80
  let [trigger] = React.Children.toArray(props.children);
59
81
  let [, content] = props.children as [ReactElement, ReactElement];
60
82
 
61
- let onExit = () => {
62
- // if focus was already moved back to a menu item, don't need to do anything
63
- if (!menu.current.contains(document.activeElement)) {
64
- // need to return focus to the trigger because hitting Esc causes focus to go to the subdialog, which is then unmounted
65
- // this leads to blur never being fired nor focus on the body
66
- triggerRef.current.focus();
67
- }
68
- };
69
83
  let onBlurWithin = (e) => {
70
- if (e.relatedTarget && popoverRef.current && !popoverRef.current?.UNSAFE_getDOMNode().contains(e.relatedTarget)) {
71
- if (menuState.expandedKeys.has(props.targetKey)) {
72
- menuState.toggleKey(props.targetKey);
84
+ if (e.relatedTarget && popoverRef.current && (!popoverRef?.current?.UNSAFE_getDOMNode()?.contains(e.relatedTarget) && !(e.relatedTarget === triggerRef.current && getInteractionModality() === 'pointer'))) {
85
+ if (submenuTriggerState.isOpen) {
86
+ submenuTriggerState.close();
73
87
  }
74
88
  }
75
89
  };
90
+
91
+ let overlay;
92
+ let tray;
93
+ let onBackButtonPress = () => {
94
+ submenuTriggerState.close();
95
+ if (parentMenuRef.current && !parentMenuRef.current.contains(document.activeElement)) {
96
+ parentMenuRef.current.focus();
97
+ }
98
+ };
99
+ let [offset, setOffset] = useState(0);
100
+ useLayoutEffect(() => {
101
+ if (parentMenuRef.current) {
102
+ let offset = window?.getComputedStyle(parentMenuRef?.current)?.getPropertyValue('--spectrum-submenu-offset-distance');
103
+ if (offset !== '') {
104
+ setOffset(-1 * parseInt(offset, 10));
105
+ }
106
+ }
107
+ }, [parentMenuRef]);
108
+
109
+ if (isMobile) {
110
+ delete submenuTriggerProps.onBlur;
111
+ delete submenuTriggerProps.onHoverChange;
112
+ if (trayContainerRef.current && submenuTriggerState.isOpen) {
113
+ let subDialogKeyDown = (e: KeyboardEvent) => {
114
+ switch (e.key) {
115
+ case 'Escape':
116
+ e.stopPropagation();
117
+ onBackButtonPress();
118
+ break;
119
+ }
120
+ };
121
+
122
+ tray = (
123
+ <TrayHeaderWrapper
124
+ isSubmenu
125
+ parentMenuTreeState={state}
126
+ rootMenuTriggerState={rootMenuTriggerState}
127
+ wrapperKeyDown={subDialogKeyDown}
128
+ onBackButtonPress={onBackButtonPress}>
129
+ {content}
130
+ </TrayHeaderWrapper>
131
+ );
132
+
133
+ overlay = ReactDOM.createPortal(tray, trayContainerRef.current);
134
+ }
135
+ } else {
136
+ let onDismissButtonPress = () => {
137
+ submenuTriggerState.close();
138
+ parentMenuRef.current?.focus();
139
+ };
140
+
141
+ overlay = (
142
+ <Popover
143
+ {...popoverProps}
144
+ UNSAFE_style={{clipPath: 'unset', overflow: 'visible', filter: 'unset', borderWidth: '0px'}}
145
+ UNSAFE_className={classNames(styles, 'spectrum-Submenu-popover')}
146
+ onDismissButtonPress={onDismissButtonPress}
147
+ onBlurWithin={onBlurWithin}
148
+ container={popoverContainerRef.current}
149
+ state={submenuTriggerState}
150
+ ref={popoverRef}
151
+ triggerRef={triggerRef}
152
+ placement="end top"
153
+ containerPadding={0}
154
+ crossOffset={offset}
155
+ offset={offset}
156
+ hideArrow
157
+ enableBothDismissButtons>
158
+ <FocusScope restoreFocus>
159
+ {content}
160
+ </FocusScope>
161
+ </Popover>
162
+ );
163
+ }
164
+
76
165
  return (
77
166
  <>
78
- <MenuDialogContext.Provider value={{isUnavailable, triggerRef}}>{trigger}</MenuDialogContext.Provider>
167
+ <SubmenuTriggerContext.Provider value={{isUnavailable, triggerRef, ...submenuTriggerProps}}>{trigger}</SubmenuTriggerContext.Provider>
79
168
  <SlotProvider slots={slots}>
80
- {
81
- isMobile ? (
82
- <Modal state={state} isDismissable>
83
- <DismissButton onDismiss={state.close} />
84
- {content}
85
- <DismissButton onDismiss={state.close} />
86
- </Modal>
87
- ) : (
88
- <Popover
89
- UNSAFE_style={{clipPath: 'unset', overflow: 'visible', filter: 'unset', borderWidth: '0px'}}
90
- onExit={onExit}
91
- onBlurWithin={onBlurWithin}
92
- container={container.current}
93
- state={state}
94
- ref={popoverRef}
95
- triggerRef={triggerRef}
96
- placement="end top"
97
- offset={-10}
98
- hideArrow
99
- isNonModal
100
- enableBothDismissButtons
101
- disableFocusManagement>
102
- {content}
103
- </Popover>
104
- )
105
- }
169
+ {submenuTriggerState.isOpen && overlay}
106
170
  </SlotProvider>
107
171
  </>
108
172
  );
109
173
  }
110
174
 
111
175
  ContextualHelpTrigger.getCollectionNode = function* getCollectionNode<T>(props: ItemProps<T>) {
112
- let [trigger] = React.Children.toArray(props.children) as ReactElement[];
176
+ let childArray: ReactElement[] = [];
177
+ React.Children.forEach(props.children, child => {
178
+ if (React.isValidElement(child)) {
179
+ childArray.push(child);
180
+ }
181
+ });
182
+ let [trigger] = childArray;
113
183
  let [, content] = props.children as [ReactElement, ReactElement];
114
184
 
115
185
  yield {
116
- element: React.cloneElement(trigger, {...trigger.props, hasChildItems: true}),
186
+ element: React.cloneElement(trigger, {...trigger.props, hasChildItems: true, isTrigger: true}),
117
187
  wrapper: (element) => (
118
188
  <ContextualHelpTrigger key={element.key} targetKey={element.key} {...props}>
119
189
  {element}
@@ -123,5 +193,5 @@ ContextualHelpTrigger.getCollectionNode = function* getCollectionNode<T>(props:
123
193
  };
124
194
  };
125
195
 
126
- let _Item = ContextualHelpTrigger as (props: SpectrumMenuDialogTriggerProps) => React.JSX.Element;
196
+ let _Item = ContextualHelpTrigger as (props: SpectrumMenuDialogTriggerProps) => JSX.Element;
127
197
  export {_Item as ContextualHelpTrigger};
package/src/Menu.tsx CHANGED
@@ -10,40 +10,67 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import {ActionButton} from '@react-spectrum/button';
14
+ import ArrowDownSmall from '@spectrum-icons/ui/ArrowDownSmall';
13
15
  import {classNames, useDOMRef, useIsMobileDevice, useStyleProps} from '@react-spectrum/utils';
14
16
  import {DOMRef} from '@react-types/shared';
15
17
  import {FocusScope} from '@react-aria/focus';
16
- import {MenuContext, MenuStateContext} from './context';
18
+ // @ts-ignore
19
+ import intlMessages from '../intl/*.json';
20
+ import {MenuContext, MenuStateContext, useMenuStateContext} from './context';
17
21
  import {MenuItem} from './MenuItem';
18
22
  import {MenuSection} from './MenuSection';
19
- import {mergeProps, useSyncRef} from '@react-aria/utils';
20
- import React, {ReactElement, useContext, useRef} from 'react';
23
+ import {mergeProps, useSlotId, useSyncRef} from '@react-aria/utils';
24
+ import React, {ReactElement, useContext, useEffect, useRef, useState} from 'react';
21
25
  import {SpectrumMenuProps} from '@react-types/menu';
22
26
  import styles from '@adobe/spectrum-css-temp/components/menu/vars.css';
27
+ import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
23
28
  import {useMenu} from '@react-aria/menu';
24
29
  import {useTreeState} from '@react-stately/tree';
25
30
 
26
31
  function Menu<T extends object>(props: SpectrumMenuProps<T>, ref: DOMRef<HTMLDivElement>) {
32
+ let isSubmenu = true;
27
33
  let contextProps = useContext(MenuContext);
34
+ let parentMenuContext = useMenuStateContext();
35
+ let {rootMenuTriggerState, state: parentMenuTreeState} = parentMenuContext || {rootMenuTriggerState: contextProps.state};
36
+ if (!parentMenuContext) {
37
+ isSubmenu = false;
38
+ }
28
39
  let completeProps = {
29
40
  ...mergeProps(contextProps, props)
30
41
  };
31
-
32
42
  let domRef = useDOMRef(ref);
33
- let scopedRef = useRef(null);
43
+ let popoverContainerRef = useRef(null);
44
+ let trayContainerRef = useRef(null);
34
45
  let state = useTreeState(completeProps);
46
+ let submenuRef = useRef<HTMLDivElement>(null);
35
47
  let {menuProps} = useMenu(completeProps, state, domRef);
36
48
  let {styleProps} = useStyleProps(completeProps);
37
49
  useSyncRef(contextProps, domRef);
38
- let isMobile = useIsMobileDevice();
50
+ let [leftOffset, setLeftOffset] = useState({left: 0});
51
+ useEffect(() => {
52
+ if (popoverContainerRef.current) {
53
+ let {left} = popoverContainerRef.current.getBoundingClientRect();
54
+ setLeftOffset({left: -1 * left});
55
+ }
56
+ }, []);
39
57
 
58
+ let menuLevel = contextProps.submenuLevel ?? -1;
59
+ let hasOpenSubmenu = state.collection.getItem(rootMenuTriggerState?.UNSTABLE_expandedKeysStack[menuLevel + 1]) != null;
60
+ // TODO: add slide transition
40
61
  return (
41
- <MenuStateContext.Provider value={{state, container: scopedRef, menu: domRef}}>
42
- <FocusScope contain={state.expandedKeys.size > 0}>
43
- <div className={classNames(styles, !isMobile ? 'spectrum-Menu-wrapper' : '')}>
62
+ <MenuStateContext.Provider value={{popoverContainerRef, trayContainerRef, menu: domRef, submenu: submenuRef, rootMenuTriggerState, state}}>
63
+ <div style={{height: hasOpenSubmenu ? '100%' : undefined}} ref={trayContainerRef} />
64
+ <FocusScope>
65
+ <TrayHeaderWrapper
66
+ onBackButtonPress={contextProps.onBackButtonPress}
67
+ hasOpenSubmenu={hasOpenSubmenu}
68
+ isSubmenu={isSubmenu}
69
+ parentMenuTreeState={parentMenuTreeState}
70
+ rootMenuTriggerState={rootMenuTriggerState}>
44
71
  <div
45
72
  {...menuProps}
46
- {...styleProps}
73
+ style={mergeProps(styleProps.style, menuProps.style)}
47
74
  ref={domRef}
48
75
  className={
49
76
  classNames(
@@ -78,13 +105,61 @@ function Menu<T extends object>(props: SpectrumMenuProps<T>, ref: DOMRef<HTMLDiv
78
105
  return menuItem;
79
106
  })}
80
107
  </div>
81
- </div>
82
- <div ref={scopedRef} />
108
+ </TrayHeaderWrapper>
109
+ {rootMenuTriggerState?.isOpen && <div ref={popoverContainerRef} style={{width: '100vw', position: 'absolute', top: -5, ...leftOffset}} /> }
83
110
  </FocusScope>
84
111
  </MenuStateContext.Provider>
85
112
  );
86
113
  }
87
114
 
115
+ export function TrayHeaderWrapper(props) {
116
+ let {children, isSubmenu, hasOpenSubmenu, parentMenuTreeState, rootMenuTriggerState, onBackButtonPress, wrapperKeyDown} = props;
117
+ let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/menu');
118
+ let backButtonText = parentMenuTreeState?.collection.getItem(rootMenuTriggerState?.UNSTABLE_expandedKeysStack.slice(-1)[0])?.textValue;
119
+ let backButtonLabel = stringFormatter.format('backButton', {
120
+ prevMenuButton: backButtonText
121
+ });
122
+ let headingId = useSlotId();
123
+ let isMobile = useIsMobileDevice();
124
+ let {direction} = useLocale();
125
+
126
+ return (
127
+ <>
128
+ <div
129
+ role={headingId ? 'dialog' : undefined}
130
+ aria-labelledby={headingId}
131
+ aria-hidden={isMobile && hasOpenSubmenu}
132
+ data-testid="menu-wrapper"
133
+ className={
134
+ classNames(
135
+ styles,
136
+ 'spectrum-Menu-wrapper',
137
+ {
138
+ 'spectrum-Menu-wrapper--isMobile': isMobile,
139
+ 'is-expanded': hasOpenSubmenu
140
+ }
141
+ )
142
+ }>
143
+ <div role="presentation" className={classNames(styles, 'spectrum-Submenu-wrapper')} onKeyDown={wrapperKeyDown}>
144
+ {isMobile && isSubmenu && !hasOpenSubmenu && (
145
+ <div className={classNames(styles, 'spectrum-Submenu-headingWrapper')}>
146
+ <ActionButton
147
+ aria-label={backButtonLabel}
148
+ isQuiet
149
+ onPress={onBackButtonPress}>
150
+ {/* We don't have a ArrowLeftSmall so make due with ArrowDownSmall and transforms */}
151
+ {direction === 'rtl' ? <ArrowDownSmall UNSAFE_style={{rotate: '270deg'}} /> : <ArrowDownSmall UNSAFE_style={{rotate: '90deg'}} />}
152
+ </ActionButton>
153
+ <h1 id={headingId} className={classNames(styles, 'spectrum-Submenu-heading')}>{backButtonText}</h1>
154
+ </div>
155
+ )}
156
+ {children}
157
+ </div>
158
+ </div>
159
+ </>
160
+ );
161
+ }
162
+
88
163
  /**
89
164
  * Menus display a list of actions or options that a user can choose.
90
165
  */
package/src/MenuItem.tsx CHANGED
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import CheckmarkMedium from '@spectrum-icons/ui/CheckmarkMedium';
14
+ import ChevronLeft from '@spectrum-icons/workflow/ChevronLeft';
15
+ import ChevronRight from '@spectrum-icons/workflow/ChevronRight';
14
16
  import {classNames, ClearSlots, SlotProvider} from '@react-spectrum/utils';
15
17
  import {DOMAttributes, Key, Node} from '@react-types/shared';
16
18
  import {FocusRing} from '@react-aria/focus';
@@ -23,8 +25,8 @@ import React, {useMemo, useRef} from 'react';
23
25
  import styles from '@adobe/spectrum-css-temp/components/menu/vars.css';
24
26
  import {Text} from '@react-spectrum/text';
25
27
  import {TreeState} from '@react-stately/tree';
26
- import {useLocalizedStringFormatter} from '@react-aria/i18n';
27
- import {useMenuContext, useMenuDialogContext} from './context';
28
+ import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
29
+ import {useMenuContext, useSubmenuTriggerContext} from './context';
28
30
  import {useMenuItem} from '@react-aria/menu';
29
31
 
30
32
  interface MenuItemProps<T> {
@@ -42,33 +44,32 @@ export function MenuItem<T>(props: MenuItemProps<T>) {
42
44
  isVirtualized,
43
45
  onAction
44
46
  } = props;
45
- let stringFormatter = useLocalizedStringFormatter(intlMessages);
46
- let menuDialogContext = useMenuDialogContext();
47
- let {triggerRef} = menuDialogContext || {};
48
- let isMenuDialogTrigger = !!menuDialogContext;
49
- let isUnavailable = false;
50
-
51
- if (isMenuDialogTrigger) {
52
- isUnavailable = menuDialogContext.isUnavailable;
53
- }
54
-
55
- let ElementType: React.ElementType = item.props.href ? 'a' : 'div';
56
-
57
47
  let {
58
48
  closeOnSelect
59
49
  } = useMenuContext();
60
-
61
50
  let {
62
51
  rendered,
63
52
  key
64
53
  } = item;
65
54
 
66
- let isSelected = state.selectionManager.isSelected(key);
67
- let isDisabled = state.disabledKeys.has(key);
55
+ let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/menu');
56
+ let {direction} = useLocale();
68
57
 
58
+ let submenuTriggerContext = useSubmenuTriggerContext();
59
+ let {triggerRef, ...submenuTriggerProps} = submenuTriggerContext || {};
60
+ let isSubmenuTrigger = !!submenuTriggerContext;
61
+ let isUnavailable;
62
+ let ElementType: React.ElementType = item.props.href ? 'a' : 'div';
63
+
64
+ if (isSubmenuTrigger) {
65
+ isUnavailable = submenuTriggerContext.isUnavailable;
66
+ }
67
+
68
+ let isDisabled = state.disabledKeys.has(key);
69
+ let isSelectable = !isSubmenuTrigger && state.selectionManager.selectionMode !== 'none';
70
+ let isSelected = isSelectable && state.selectionManager.isSelected(key);
69
71
  let itemref = useRef<any>(null);
70
72
  let ref = useObjectRef(useMemo(() => mergeRefs(itemref, triggerRef), [itemref, triggerRef]));
71
-
72
73
  let {
73
74
  menuItemProps,
74
75
  labelProps,
@@ -83,7 +84,7 @@ export function MenuItem<T>(props: MenuItemProps<T>) {
83
84
  closeOnSelect,
84
85
  isVirtualized,
85
86
  onAction,
86
- 'aria-haspopup': isMenuDialogTrigger && isUnavailable ? 'dialog' : undefined
87
+ ...submenuTriggerProps
87
88
  },
88
89
  state,
89
90
  ref
@@ -110,8 +111,8 @@ export function MenuItem<T>(props: MenuItemProps<T>) {
110
111
  {
111
112
  'is-disabled': isDisabled,
112
113
  'is-selected': isSelected,
113
- 'is-selectable': state.selectionManager.selectionMode !== 'none',
114
- 'is-open': state.expandedKeys.has(key)
114
+ 'is-selectable': isSelectable,
115
+ 'is-open': submenuTriggerProps.isOpen
115
116
  }
116
117
  )}>
117
118
  <Grid
@@ -128,7 +129,8 @@ export function MenuItem<T>(props: MenuItemProps<T>) {
128
129
  end: {UNSAFE_className: styles['spectrum-Menu-end'], ...endProps},
129
130
  icon: {UNSAFE_className: styles['spectrum-Menu-icon'], size: 'S'},
130
131
  description: {UNSAFE_className: styles['spectrum-Menu-description'], ...descriptionProps},
131
- keyboard: {UNSAFE_className: styles['spectrum-Menu-keyboard'], ...keyboardShortcutProps}
132
+ keyboard: {UNSAFE_className: styles['spectrum-Menu-keyboard'], ...keyboardShortcutProps},
133
+ chevron: {UNSAFE_className: styles['spectrum-Menu-chevron'], size: 'S'}
132
134
  }}>
133
135
  {contents}
134
136
  {isSelected &&
@@ -144,6 +146,9 @@ export function MenuItem<T>(props: MenuItemProps<T>) {
144
146
  {
145
147
  isUnavailable && <InfoOutline slot="end" size="XS" alignSelf="center" aria-label={stringFormatter.format('unavailable')} />
146
148
  }
149
+ {
150
+ isUnavailable == null && isSubmenuTrigger && (direction === 'rtl' ? <ChevronLeft slot="chevron" /> : <ChevronRight slot="chevron" />)
151
+ }
147
152
  </SlotProvider>
148
153
  </ClearSlots>
149
154
  </Grid>
@@ -58,7 +58,6 @@ function MenuTrigger(props: SpectrumMenuTriggerProps, ref: DOMRef<HTMLElement>)
58
58
  let isMobile = useIsMobileDevice();
59
59
  let menuContext = {
60
60
  ...menuProps,
61
- state,
62
61
  ref: menuRef,
63
62
  onClose: state.close,
64
63
  closeOnSelect,
@@ -67,14 +66,15 @@ function MenuTrigger(props: SpectrumMenuTriggerProps, ref: DOMRef<HTMLElement>)
67
66
  width: '100%',
68
67
  maxHeight: 'inherit'
69
68
  } : undefined,
70
- UNSAFE_className: classNames(styles, {'spectrum-Menu-popover': !isMobile})
69
+ UNSAFE_className: classNames(styles, {'spectrum-Menu-popover': !isMobile}),
70
+ state
71
71
  };
72
72
 
73
73
  // On small screen devices, the menu is rendered in a tray, otherwise a popover.
74
74
  let overlay;
75
75
  if (isMobile) {
76
76
  overlay = (
77
- <Tray state={state}>
77
+ <Tray state={state} isFixedHeight>
78
78
  {menu}
79
79
  </Tray>
80
80
  );
@@ -0,0 +1,167 @@
1
+ /*
2
+ * Copyright 2020 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import {classNames, useIsMobileDevice} from '@react-spectrum/utils';
14
+ import {Key} from '@react-types/shared';
15
+ import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context';
16
+ import {mergeProps, useLayoutEffect} from '@react-aria/utils';
17
+ import {Popover} from '@react-spectrum/overlays';
18
+ import React, {ReactElement, useRef, useState} from 'react';
19
+ import ReactDOM from 'react-dom';
20
+ import styles from '@adobe/spectrum-css-temp/components/menu/vars.css';
21
+ import {UNSTABLE_useSubmenuTrigger} from '@react-aria/menu';
22
+ import {UNSTABLE_useSubmenuTriggerState} from '@react-stately/menu';
23
+ import {useLocale} from '@react-aria/i18n';
24
+
25
+ interface SubmenuTriggerProps {
26
+ /**
27
+ * The contents of the SubmenuTrigger - a Item and a Menu.
28
+ */
29
+ children: ReactElement[],
30
+ targetKey: Key
31
+ }
32
+
33
+ export interface SpectrumSubmenuTriggerProps extends Omit<SubmenuTriggerProps, 'targetKey'> {}
34
+
35
+ function SubmenuTrigger(props: SubmenuTriggerProps) {
36
+ let triggerRef = useRef<HTMLDivElement>();
37
+ let {
38
+ children,
39
+ targetKey
40
+ } = props;
41
+
42
+ let [menuTrigger, menu] = React.Children.toArray(children);
43
+ let {popoverContainerRef, trayContainerRef, menu: parentMenuRef, submenu: menuRef, rootMenuTriggerState, state} = useMenuStateContext();
44
+ let triggerNode = state.collection.getItem(targetKey);
45
+ let submenuTriggerState = UNSTABLE_useSubmenuTriggerState({triggerKey: targetKey}, rootMenuTriggerState);
46
+ let {submenuTriggerProps, submenuProps, popoverProps} = UNSTABLE_useSubmenuTrigger({
47
+ node: triggerNode,
48
+ parentMenuRef,
49
+ submenuRef: menuRef
50
+ }, submenuTriggerState, triggerRef);
51
+ let isMobile = useIsMobileDevice();
52
+ let onBackButtonPress = () => {
53
+ submenuTriggerState.close();
54
+ if (parentMenuRef.current && !parentMenuRef.current.contains(document.activeElement)) {
55
+ parentMenuRef.current.focus();
56
+ }
57
+ };
58
+
59
+ let {direction} = useLocale();
60
+ let mobileSubmenuKeyDown = (e: KeyboardEvent) => {
61
+ switch (e.key) {
62
+ case 'ArrowLeft':
63
+ if (direction === 'ltr') {
64
+ triggerRef.current.focus();
65
+ }
66
+ break;
67
+ case 'ArrowRight':
68
+ if (direction === 'rtl') {
69
+ triggerRef.current.focus();
70
+ }
71
+ break;
72
+ }
73
+ };
74
+
75
+ let overlay;
76
+ let [offset, setOffset] = useState(0);
77
+ useLayoutEffect(() => {
78
+ if (parentMenuRef.current) {
79
+ let offset = window?.getComputedStyle(parentMenuRef?.current)?.getPropertyValue('--spectrum-submenu-offset-distance');
80
+ if (offset !== '') {
81
+ setOffset(-1 * parseInt(offset, 10));
82
+ }
83
+ }
84
+ }, [parentMenuRef]);
85
+
86
+ if (isMobile) {
87
+ delete submenuTriggerProps.onBlur;
88
+ delete submenuTriggerProps.onHoverChange;
89
+ submenuProps.autoFocus ??= true;
90
+ if (trayContainerRef.current && submenuTriggerState.isOpen) {
91
+ overlay = ReactDOM.createPortal(menu, trayContainerRef.current);
92
+ }
93
+ } else {
94
+ let onDismissButtonPress = () => {
95
+ submenuTriggerState.close();
96
+ parentMenuRef.current?.focus();
97
+ };
98
+
99
+ overlay = (
100
+ <Popover
101
+ {...popoverProps}
102
+ onDismissButtonPress={onDismissButtonPress}
103
+ UNSAFE_className={classNames(styles, 'spectrum-Submenu-popover')}
104
+ container={popoverContainerRef.current}
105
+ containerPadding={0}
106
+ crossOffset={offset}
107
+ offset={offset}
108
+ enableBothDismissButtons
109
+ UNSAFE_style={{clipPath: 'unset', overflow: 'visible', borderWidth: '0px'}}
110
+ state={submenuTriggerState}
111
+ triggerRef={triggerRef}
112
+ scrollRef={menuRef}
113
+ placement="end top"
114
+ hideArrow>
115
+ {menu}
116
+ </Popover>
117
+ );
118
+ }
119
+
120
+ let menuContext = {
121
+ ...mergeProps(submenuProps, {
122
+ ref: menuRef,
123
+ UNSAFE_style: isMobile ? {
124
+ width: '100%',
125
+ maxHeight: 'inherit'
126
+ } : undefined,
127
+ UNSAFE_className: classNames(styles, {'spectrum-Menu-popover': !isMobile}),
128
+ ...(isMobile && {
129
+ onBackButtonPress,
130
+ onKeyDown: mobileSubmenuKeyDown
131
+ })
132
+ })
133
+ };
134
+
135
+ return (
136
+ <>
137
+ <SubmenuTriggerContext.Provider value={{triggerRef, ...submenuTriggerProps}}>{menuTrigger}</SubmenuTriggerContext.Provider>
138
+ <MenuContext.Provider value={menuContext}>
139
+ {overlay}
140
+ </MenuContext.Provider>
141
+ </>
142
+ );
143
+ }
144
+
145
+ SubmenuTrigger.getCollectionNode = function* (props: SpectrumSubmenuTriggerProps) {
146
+ let childArray: ReactElement[] = [];
147
+ React.Children.forEach(props.children, child => {
148
+ if (React.isValidElement(child)) {
149
+ childArray.push(child);
150
+ }
151
+ });
152
+ let [trigger] = childArray;
153
+ let [, content] = props.children as [ReactElement, ReactElement];
154
+
155
+ yield {
156
+ element: React.cloneElement(trigger, {...trigger.props, hasChildItems: true, isTrigger: true}),
157
+ wrapper: (element) => (
158
+ <SubmenuTrigger key={element.key} targetKey={element.key} {...props}>
159
+ {element}
160
+ {content}
161
+ </SubmenuTrigger>
162
+ )
163
+ };
164
+ };
165
+
166
+ let _SubmenuTrigger = SubmenuTrigger as (props: SpectrumSubmenuTriggerProps) => JSX.Element;
167
+ export {_SubmenuTrigger as SubmenuTrigger};