Skip to content

Commit 424432c

Browse files
authored
Menu unavailable item (#4192)
* Menu unavailable item
1 parent e40d636 commit 424432c

File tree

24 files changed

+638
-113
lines changed

24 files changed

+638
-113
lines changed

packages/@react-spectrum/contextualhelp/src/contextualhelp.css renamed to packages/@adobe/spectrum-css-temp/components/contextualhelp/index.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
block-size: var(--spectrum-contextualhelp-icon-size);
2828
inline-size: var(--spectrum-contextualhelp-icon-size);
2929
}
30-
3130
}
3231

3332
.react-spectrum-ContextualHelp-dialog.react-spectrum-ContextualHelp-dialog {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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+
*/
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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 './index.css';
14+
@import './skin.css';

packages/@adobe/spectrum-css-temp/components/menu/index.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,12 @@ governing permissions and limitations under the License.
177177
}
178178
}
179179

180-
/* Added .spectrum-Menu so paddings from component styles are overriden */
180+
/* Added .spectrum-Menu so paddings from component styles are overridden */
181181
.spectrum-Menu .spectrum-Menu-end {
182182
grid-area: end;
183183
justify-self: end;
184184
align-self: flex-start;
185-
padding-inline-start: var(--spectrum-global-dimension-size-125);
185+
padding-inline-start: var(--spectrum-global-dimension-size-250);
186186
}
187187
.spectrum-Menu-icon {
188188
grid-area: icon;

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -314,16 +314,33 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
314314

315315
let focusedElement = document.activeElement;
316316
let scope = scopeRef.current;
317-
if (!isElementInScope(focusedElement, scope)) {
317+
let childScopeRef = getChildScopeElementIsIn(focusedElement, scopeRef);
318+
if (childScopeRef == null) {
318319
return;
319320
}
320-
321-
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, scope);
322-
walker.currentNode = focusedElement;
323-
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
324-
if (!nextElement) {
325-
walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling : scope[0].previousElementSibling;
326-
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
321+
let focusNextInChildScope = (childScopeRef: ScopeRef): FocusableElement | null => {
322+
if (childScopeRef !== scopeRef) {
323+
let walker = getFocusableTreeWalker(getScopeRoot(childScopeRef.current), {tabbable: true}, scope, scopeRef);
324+
walker.currentNode = focusedElement;
325+
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
326+
if (nextElement) {
327+
return nextElement;
328+
} else {
329+
return focusNextInChildScope(focusScopeTree.getTreeNode(childScopeRef).parent.scopeRef);
330+
}
331+
}
332+
return null;
333+
};
334+
let nextElement = focusNextInChildScope(childScopeRef);
335+
336+
if (!nextElement && isElementInScope(focusedElement, scope)) {
337+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, scope);
338+
walker.currentNode = focusedElement;
339+
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
340+
if (!nextElement) {
341+
walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling : scope[0].previousElementSibling;
342+
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
343+
}
327344
}
328345

329346
e.preventDefault();
@@ -393,7 +410,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
393410
}
394411

395412
function isElementInAnyScope(element: Element) {
396-
return isElementInChildScope(element);
413+
return !!isElementInChildScope(element);
397414
}
398415

399416
function isElementInScope(element: Element, scope: Element[]) {
@@ -417,9 +434,26 @@ function isElementInChildScope(element: Element, scope: ScopeRef = null) {
417434
return false;
418435
}
419436

437+
function getChildScopeElementIsIn(element: Element, scope: ScopeRef = null): ScopeRef | null {
438+
// If the element is within a top layer element (e.g. toasts), always allow moving focus there.
439+
if (element instanceof Element && element.closest('[data-react-aria-top-layer]')) {
440+
return null;
441+
}
442+
443+
// node.contains in isElementInScope covers child scopes that are also DOM children,
444+
// but does not cover child scopes in portals.
445+
for (let {scopeRef: s} of focusScopeTree.traverse(focusScopeTree.getTreeNode(scope))) {
446+
if (isElementInScope(element, s.current)) {
447+
return s;
448+
}
449+
}
450+
451+
return null;
452+
}
453+
420454
/** @private */
421455
export function isElementInChildOfActiveScope(element: Element) {
422-
return isElementInChildScope(element, activeScope);
456+
return !!isElementInChildScope(element, activeScope);
423457
}
424458

425459
function isAncestorScope(ancestor: ScopeRef, scope: ScopeRef) {
@@ -583,6 +617,13 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
583617
focusScopeTree.getTreeNode(scopeRef).nodeToRestore = null;
584618
}
585619

620+
if (nodeToRestore && nextElement && nodeToRestore === nextElement) {
621+
e.preventDefault();
622+
e.stopPropagation();
623+
focusElement(nextElement, true);
624+
return;
625+
}
626+
586627
// If there is no next element, or it is outside the current scope, move focus to the
587628
// next element after the node to restore to instead.
588629
if ((!nextElement || !isElementInScope(nextElement, scopeRef.current)) && nodeToRestore) {
@@ -666,7 +707,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
666707
* Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker}
667708
* that matches all focusable/tabbable elements.
668709
*/
669-
export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) {
710+
export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[], scopeRef?: ScopeRef) {
670711
let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;
671712
let walker = document.createTreeWalker(
672713
root,
@@ -680,7 +721,7 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions
680721

681722
if ((node as Element).matches(selector)
682723
&& isElementVisible(node as Element)
683-
&& (!scope || isElementInScope(node as Element, scope))
724+
&& ((!scope || isElementInScope(node as Element, scope)) || (scopeRef && isElementInChildScope(node as Element, scopeRef)))
684725
&& (!opts?.accept || opts.accept(node as Element))
685726
) {
686727
return NodeFilter.FILTER_ACCEPT;

packages/@react-aria/menu/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"url": "https://github.com/adobe/react-spectrum"
2323
},
2424
"dependencies": {
25+
"@react-aria/focus": "^3.12.0",
2526
"@react-aria/i18n": "^3.7.1",
2627
"@react-aria/interactions": "^3.15.0",
2728
"@react-aria/overlays": "^3.14.0",

packages/@react-aria/menu/src/useMenuItem.ts

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
*/
1212

1313
import {DOMAttributes, FocusableElement, PressEvent} from '@react-types/shared';
14+
import {focusSafely} from '@react-aria/focus';
1415
import {getItemCount} from '@react-stately/collections';
15-
import {isFocusVisible, useHover, usePress} from '@react-aria/interactions';
16-
import {Key, RefObject} from 'react';
16+
import {isFocusVisible, useHover, useKeyboard, usePress} from '@react-aria/interactions';
17+
import {Key, RefObject, useCallback, useRef} from 'react';
1718
import {menuData} from './useMenu';
18-
import {mergeProps, useSlotId} from '@react-aria/utils';
19+
import {mergeProps, useEffectEvent, useLayoutEffect, useSlotId} from '@react-aria/utils';
1920
import {TreeState} from '@react-stately/tree';
21+
import {useLocale} from '@react-aria/i18n';
2022
import {useSelectableItem} from '@react-aria/selection';
2123

2224
export interface MenuItemAria {
@@ -80,7 +82,10 @@ export interface AriaMenuItemProps {
8082
* Handler that is called when the user activates the item.
8183
* @deprecated - pass to the menu instead.
8284
*/
83-
onAction?: (key: Key) => void
85+
onAction?: (key: Key) => void,
86+
87+
/** What kind of popup the item opens. */
88+
'aria-haspopup'?: 'menu' | 'dialog'
8489
}
8590

8691
/**
@@ -93,15 +98,44 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
9398
let {
9499
key,
95100
closeOnSelect,
96-
isVirtualized
101+
isVirtualized,
102+
'aria-haspopup': hasPopup
97103
} = props;
104+
let {direction} = useLocale();
105+
106+
let isMenuDialogTrigger = state.collection.getItem(key).hasChildNodes;
107+
let isOpen = state.expandedKeys.has(key);
98108

99109
let isDisabled = props.isDisabled ?? state.disabledKeys.has(key);
100110
let isSelected = props.isSelected ?? state.selectionManager.isSelected(key);
101111

112+
let openTimeout = useRef<ReturnType<typeof setTimeout> | undefined>();
113+
let cancelOpenTimeout = useCallback(() => {
114+
if (openTimeout.current) {
115+
clearTimeout(openTimeout.current);
116+
openTimeout.current = undefined;
117+
}
118+
}, [openTimeout]);
119+
120+
let onSubmenuOpen = useEffectEvent(() => {
121+
cancelOpenTimeout();
122+
if (!state.expandedKeys.has(key)) {
123+
state.toggleKey(key);
124+
}
125+
});
126+
127+
useLayoutEffect(() => {
128+
return () => cancelOpenTimeout();
129+
}, [cancelOpenTimeout]);
130+
102131
let data = menuData.get(state);
103132
let onClose = props.onClose || data.onClose;
104-
let onAction = props.onAction || data.onAction;
133+
let onActionMenuDialogTrigger = useCallback(() => {
134+
onSubmenuOpen();
135+
// will need to disable this lint rule when using useEffectEvent https://react.dev/learn/separating-events-from-effects#logic-inside-effects-is-reactive
136+
// eslint-disable-next-line react-hooks/exhaustive-deps
137+
}, []);
138+
let onAction = isMenuDialogTrigger ? onActionMenuDialogTrigger : props.onAction || data.onAction;
105139

106140
let role = 'menuitem';
107141
if (state.selectionManager.selectionMode === 'single') {
@@ -131,27 +165,10 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
131165
ariaProps['aria-setsize'] = getItemCount(state.collection);
132166
}
133167

134-
let onKeyDown = (e: KeyboardEvent) => {
135-
// Ignore repeating events, which may have started on the menu trigger before moving
136-
// focus to the menu item. We want to wait for a second complete key press sequence.
137-
if (e.repeat) {
138-
return;
139-
}
140-
141-
switch (e.key) {
142-
case ' ':
143-
if (!isDisabled && state.selectionManager.selectionMode === 'none' && closeOnSelect !== false && onClose) {
144-
onClose();
145-
}
146-
break;
147-
case 'Enter':
148-
// The Enter key should always close on select, except if overridden.
149-
if (!isDisabled && closeOnSelect !== false && onClose) {
150-
onClose();
151-
}
152-
break;
153-
}
154-
};
168+
if (hasPopup != null) {
169+
ariaProps['aria-haspopup'] = hasPopup;
170+
ariaProps['aria-expanded'] = isOpen ? 'true' : 'false';
171+
}
155172

156173
let onPressStart = (e: PressEvent) => {
157174
if (e.pointerType === 'keyboard' && onAction) {
@@ -167,7 +184,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
167184

168185
// Pressing a menu item should close by default in single selection mode but not multiple
169186
// selection mode, except if overridden by the closeOnSelect prop.
170-
if (onClose && (closeOnSelect ?? state.selectionManager.selectionMode !== 'multiple')) {
187+
if (!isMenuDialogTrigger && onClose && (closeOnSelect ?? state.selectionManager.selectionMode !== 'multiple')) {
171188
onClose();
172189
}
173190
}
@@ -188,14 +205,77 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
188205
if (!isFocusVisible()) {
189206
state.selectionManager.setFocused(true);
190207
state.selectionManager.setFocusedKey(key);
208+
// focus immediately so that a focus scope opened on hover has the correct restore node
209+
let isFocused = key === state.selectionManager.focusedKey;
210+
if (isFocused && state.selectionManager.isFocused && document.activeElement !== ref.current) {
211+
if (state.expandedKeys.size > 0 && !state.expandedKeys.has(key)) {
212+
for (let expandedKey of state.expandedKeys) {
213+
state.toggleKey(expandedKey);
214+
}
215+
}
216+
focusSafely(ref.current);
217+
}
218+
}
219+
},
220+
onHoverChange: isHovered => {
221+
if (isHovered && isMenuDialogTrigger) {
222+
if (!openTimeout.current) {
223+
openTimeout.current = setTimeout(() => {
224+
onSubmenuOpen();
225+
}, 200);
226+
}
227+
} else if (!isHovered) {
228+
cancelOpenTimeout();
229+
}
230+
}
231+
});
232+
233+
let {keyboardProps} = useKeyboard({
234+
onKeyDown: (e) => {
235+
// Ignore repeating events, which may have started on the menu trigger before moving
236+
// focus to the menu item. We want to wait for a second complete key press sequence.
237+
if (e.repeat) {
238+
e.continuePropagation();
239+
return;
240+
}
241+
242+
switch (e.key) {
243+
case ' ':
244+
if (!isDisabled && state.selectionManager.selectionMode === 'none' && !isMenuDialogTrigger && closeOnSelect !== false && onClose) {
245+
onClose();
246+
}
247+
break;
248+
case 'Enter':
249+
// The Enter key should always close on select, except if overridden.
250+
if (!isDisabled && closeOnSelect !== false && !isMenuDialogTrigger && onClose) {
251+
onClose();
252+
}
253+
break;
254+
case 'ArrowRight':
255+
if (isMenuDialogTrigger && direction === 'ltr') {
256+
onSubmenuOpen();
257+
} else {
258+
e.continuePropagation();
259+
}
260+
break;
261+
case 'ArrowLeft':
262+
if (isMenuDialogTrigger && direction === 'rtl') {
263+
onSubmenuOpen();
264+
} else {
265+
e.continuePropagation();
266+
}
267+
break;
268+
default:
269+
e.continuePropagation();
270+
break;
191271
}
192272
}
193273
});
194274

195275
return {
196276
menuItemProps: {
197277
...ariaProps,
198-
...mergeProps(itemProps, pressProps, hoverProps, {onKeyDown})
278+
...mergeProps(itemProps, pressProps, hoverProps, keyboardProps)
199279
},
200280
labelProps: {
201281
id: labelId

packages/@react-aria/overlays/src/Overlay.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export interface OverlayProps {
2323
*/
2424
portalContainer?: Element,
2525
/** The overlay to render in the portal. */
26-
children: ReactNode
26+
children: ReactNode,
27+
/** Whether to contain focus within the overlay. This is an override, by default Dialogs contain and nothing else does. */
28+
shouldContainFocus?: boolean
2729
}
2830

2931
export const OverlayContext = React.createContext(null);
@@ -44,7 +46,7 @@ export function Overlay(props: OverlayProps) {
4446

4547
let contents = (
4648
<OverlayContext.Provider value={contextValue}>
47-
<FocusScope restoreFocus contain={contain}>
49+
<FocusScope restoreFocus contain={props.shouldContainFocus ?? contain}>
4850
{props.children}
4951
</FocusScope>
5052
</OverlayContext.Provider>

packages/@react-spectrum/contextualhelp/src/ContextualHelp.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {classNames, ClearSlots, SlotProvider} from '@react-spectrum/utils';
1515
import {Dialog, DialogTrigger} from '@react-spectrum/dialog';
1616
import {FocusableRef} from '@react-types/shared';
1717
import HelpOutline from '@spectrum-icons/workflow/HelpOutline';
18-
import helpStyles from './contextualhelp.css';
18+
import helpStyles from '@adobe/spectrum-css-temp/components/contextualhelp/vars.css';
1919
import InfoOutline from '@spectrum-icons/workflow/InfoOutline';
2020
// @ts-ignore
2121
import intlMessages from '../intl/*.json';

0 commit comments

Comments
 (0)