Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c5da3b0
Menu unavailable item
snowystinger Mar 11, 2023
70eafb3
Add keyboard/touch support, remove onAction
snowystinger Mar 15, 2023
1018385
possibly a solution to restore focus for hovered items in collections
snowystinger Mar 15, 2023
38f0d4b
fix menutrigger test
snowystinger Mar 15, 2023
33c9508
Merge branch 'main' into menu-subdialog
snowystinger Mar 31, 2023
300a043
fix lint
snowystinger Mar 31, 2023
abcab4a
remove unused code
snowystinger Mar 31, 2023
5dfc8d8
Merge branch 'main' into menu-subdialog
snowystinger Mar 31, 2023
d3c6baa
fix lint
snowystinger Mar 31, 2023
3e41dff
Review comments
snowystinger Apr 12, 2023
c4de43b
use modal on mobile
snowystinger Apr 12, 2023
583eb2c
fix lint
snowystinger Apr 12, 2023
2880744
fix rest of lint
snowystinger Apr 12, 2023
e2f87b1
review changes
snowystinger Apr 12, 2023
12f3791
persist hovered style when open
snowystinger Apr 12, 2023
d01c113
Fix focus behavior
snowystinger Apr 13, 2023
bcde85a
expose aria-haspopup on useMenuItem
snowystinger Apr 13, 2023
7f203bd
make aria hooks less spectrum specific
snowystinger Apr 13, 2023
922766a
Merge branch 'main' into menu-subdialog
snowystinger Apr 13, 2023
c423997
remove unused pkg
snowystinger Apr 13, 2023
b34eb69
remove unnecessary wrapper function
snowystinger Apr 13, 2023
85c1d31
move to useEffectEvent and add test against multiple open
snowystinger Apr 13, 2023
e0b2c54
fix lint
snowystinger Apr 13, 2023
f4bc106
Focus scope contain find next focusable even in portal-ed child scope
snowystinger Apr 14, 2023
3b2cf1f
Support RTL
snowystinger Apr 14, 2023
b810c96
Merge branch 'main' into menu-subdialog
snowystinger Apr 14, 2023
339e453
restoreNode and nextElement should focus there not before
snowystinger Apr 14, 2023
e055964
Merge branch 'main' into menu-subdialog
snowystinger Apr 26, 2023
d174b31
Merge branch 'main' into menu-subdialog
snowystinger Apr 27, 2023
2c7bcce
Merge branch 'main' into menu-subdialog
snowystinger Apr 27, 2023
41caf3c
Merge branch 'main' into menu-subdialog
snowystinger Apr 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
block-size: var(--spectrum-contextualhelp-icon-size);
inline-size: var(--spectrum-contextualhelp-icon-size);
}

}

.react-spectrum-ContextualHelp-dialog.react-spectrum-ContextualHelp-dialog {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

@import './index.css';
@import './skin.css';
4 changes: 2 additions & 2 deletions packages/@adobe/spectrum-css-temp/components/menu/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,12 @@ governing permissions and limitations under the License.
}
}

/* Added .spectrum-Menu so paddings from component styles are overriden */
/* Added .spectrum-Menu so paddings from component styles are overridden */
.spectrum-Menu .spectrum-Menu-end {
grid-area: end;
justify-self: end;
align-self: flex-start;
padding-inline-start: var(--spectrum-global-dimension-size-125);
padding-inline-start: var(--spectrum-global-dimension-size-250);
}
.spectrum-Menu-icon {
grid-area: icon;
Expand Down
65 changes: 53 additions & 12 deletions packages/@react-aria/focus/src/FocusScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,16 +314,33 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {

let focusedElement = document.activeElement;
let scope = scopeRef.current;
if (!isElementInScope(focusedElement, scope)) {
let childScopeRef = getChildScopeElementIsIn(focusedElement, scopeRef);
if (childScopeRef == null) {
return;
}

let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, scope);
walker.currentNode = focusedElement;
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
if (!nextElement) {
walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling : scope[0].previousElementSibling;
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
let focusNextInChildScope = (childScopeRef: ScopeRef): FocusableElement | null => {
if (childScopeRef !== scopeRef) {
let walker = getFocusableTreeWalker(getScopeRoot(childScopeRef.current), {tabbable: true}, scope, scopeRef);
walker.currentNode = focusedElement;
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
if (nextElement) {
return nextElement;
} else {
return focusNextInChildScope(focusScopeTree.getTreeNode(childScopeRef).parent.scopeRef);
}
}
return null;
};
let nextElement = focusNextInChildScope(childScopeRef);

if (!nextElement && isElementInScope(focusedElement, scope)) {
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, scope);
walker.currentNode = focusedElement;
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
if (!nextElement) {
walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling : scope[0].previousElementSibling;
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
}
}

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

function isElementInAnyScope(element: Element) {
return isElementInChildScope(element);
return !!isElementInChildScope(element);
}

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

function getChildScopeElementIsIn(element: Element, scope: ScopeRef = null): ScopeRef | null {
// If the element is within a top layer element (e.g. toasts), always allow moving focus there.
if (element instanceof Element && element.closest('[data-react-aria-top-layer]')) {
return null;
}

// node.contains in isElementInScope covers child scopes that are also DOM children,
// but does not cover child scopes in portals.
for (let {scopeRef: s} of focusScopeTree.traverse(focusScopeTree.getTreeNode(scope))) {
if (isElementInScope(element, s.current)) {
return s;
}
}

return null;
}

/** @private */
export function isElementInChildOfActiveScope(element: Element) {
return isElementInChildScope(element, activeScope);
return !!isElementInChildScope(element, activeScope);
}

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

if (nodeToRestore && nextElement && nodeToRestore === nextElement) {
e.preventDefault();
e.stopPropagation();
focusElement(nextElement, true);
return;
}

// If there is no next element, or it is outside the current scope, move focus to the
// next element after the node to restore to instead.
if ((!nextElement || !isElementInScope(nextElement, scopeRef.current)) && nodeToRestore) {
Expand Down Expand Up @@ -666,7 +707,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
* Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker}
* that matches all focusable/tabbable elements.
*/
export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) {
export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[], scopeRef?: ScopeRef) {
let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;
let walker = document.createTreeWalker(
root,
Expand All @@ -680,7 +721,7 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions

if ((node as Element).matches(selector)
&& isElementVisible(node as Element)
&& (!scope || isElementInScope(node as Element, scope))
&& ((!scope || isElementInScope(node as Element, scope)) || (scopeRef && isElementInChildScope(node as Element, scopeRef)))
&& (!opts?.accept || opts.accept(node as Element))
) {
return NodeFilter.FILTER_ACCEPT;
Expand Down
1 change: 1 addition & 0 deletions packages/@react-aria/menu/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"url": "https://github.com/adobe/react-spectrum"
},
"dependencies": {
"@react-aria/focus": "^3.12.0",
"@react-aria/i18n": "^3.7.1",
"@react-aria/interactions": "^3.15.0",
"@react-aria/overlays": "^3.14.0",
Expand Down
138 changes: 109 additions & 29 deletions packages/@react-aria/menu/src/useMenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
*/

import {DOMAttributes, FocusableElement, PressEvent} from '@react-types/shared';
import {focusSafely} from '@react-aria/focus';
import {getItemCount} from '@react-stately/collections';
import {isFocusVisible, useHover, usePress} from '@react-aria/interactions';
import {Key, RefObject} from 'react';
import {isFocusVisible, useHover, useKeyboard, usePress} from '@react-aria/interactions';
import {Key, RefObject, useCallback, useRef} from 'react';
import {menuData} from './useMenu';
import {mergeProps, useSlotId} from '@react-aria/utils';
import {mergeProps, useEffectEvent, useLayoutEffect, useSlotId} from '@react-aria/utils';
import {TreeState} from '@react-stately/tree';
import {useLocale} from '@react-aria/i18n';
import {useSelectableItem} from '@react-aria/selection';

export interface MenuItemAria {
Expand Down Expand Up @@ -80,7 +82,10 @@ export interface AriaMenuItemProps {
* Handler that is called when the user activates the item.
* @deprecated - pass to the menu instead.
*/
onAction?: (key: Key) => void
onAction?: (key: Key) => void,

/** What kind of popup the item opens. */
'aria-haspopup'?: 'menu' | 'dialog'
}

/**
Expand All @@ -93,15 +98,44 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
let {
key,
closeOnSelect,
isVirtualized
isVirtualized,
'aria-haspopup': hasPopup
} = props;
let {direction} = useLocale();

let isMenuDialogTrigger = state.collection.getItem(key).hasChildNodes;
let isOpen = state.expandedKeys.has(key);

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

let openTimeout = useRef<ReturnType<typeof setTimeout> | undefined>();
let cancelOpenTimeout = useCallback(() => {
if (openTimeout.current) {
clearTimeout(openTimeout.current);
openTimeout.current = undefined;
}
}, [openTimeout]);

let onSubmenuOpen = useEffectEvent(() => {
cancelOpenTimeout();
if (!state.expandedKeys.has(key)) {
state.toggleKey(key);
}
});

useLayoutEffect(() => {
return () => cancelOpenTimeout();
}, [cancelOpenTimeout]);

let data = menuData.get(state);
let onClose = props.onClose || data.onClose;
let onAction = props.onAction || data.onAction;
let onActionMenuDialogTrigger = useCallback(() => {
onSubmenuOpen();
// will need to disable this lint rule when using useEffectEvent https://react.dev/learn/separating-events-from-effects#logic-inside-effects-is-reactive
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
let onAction = isMenuDialogTrigger ? onActionMenuDialogTrigger : props.onAction || data.onAction;

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

let onKeyDown = (e: KeyboardEvent) => {
// Ignore repeating events, which may have started on the menu trigger before moving
// focus to the menu item. We want to wait for a second complete key press sequence.
if (e.repeat) {
return;
}

switch (e.key) {
case ' ':
if (!isDisabled && state.selectionManager.selectionMode === 'none' && closeOnSelect !== false && onClose) {
onClose();
}
break;
case 'Enter':
// The Enter key should always close on select, except if overridden.
if (!isDisabled && closeOnSelect !== false && onClose) {
onClose();
}
break;
}
};
if (hasPopup != null) {
ariaProps['aria-haspopup'] = hasPopup;
ariaProps['aria-expanded'] = isOpen ? 'true' : 'false';
}

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

// Pressing a menu item should close by default in single selection mode but not multiple
// selection mode, except if overridden by the closeOnSelect prop.
if (onClose && (closeOnSelect ?? state.selectionManager.selectionMode !== 'multiple')) {
if (!isMenuDialogTrigger && onClose && (closeOnSelect ?? state.selectionManager.selectionMode !== 'multiple')) {
onClose();
}
}
Expand All @@ -188,14 +205,77 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
if (!isFocusVisible()) {
state.selectionManager.setFocused(true);
state.selectionManager.setFocusedKey(key);
// focus immediately so that a focus scope opened on hover has the correct restore node
let isFocused = key === state.selectionManager.focusedKey;
if (isFocused && state.selectionManager.isFocused && document.activeElement !== ref.current) {
if (state.expandedKeys.size > 0 && !state.expandedKeys.has(key)) {
for (let expandedKey of state.expandedKeys) {
state.toggleKey(expandedKey);
}
}
focusSafely(ref.current);
}
}
},
onHoverChange: isHovered => {
if (isHovered && isMenuDialogTrigger) {
if (!openTimeout.current) {
openTimeout.current = setTimeout(() => {
onSubmenuOpen();
}, 200);
}
} else if (!isHovered) {
cancelOpenTimeout();
}
}
});

let {keyboardProps} = useKeyboard({
onKeyDown: (e) => {
// Ignore repeating events, which may have started on the menu trigger before moving
// focus to the menu item. We want to wait for a second complete key press sequence.
if (e.repeat) {
e.continuePropagation();
return;
}

switch (e.key) {
case ' ':
if (!isDisabled && state.selectionManager.selectionMode === 'none' && !isMenuDialogTrigger && closeOnSelect !== false && onClose) {
onClose();
}
break;
case 'Enter':
// The Enter key should always close on select, except if overridden.
if (!isDisabled && closeOnSelect !== false && !isMenuDialogTrigger && onClose) {
onClose();
}
break;
case 'ArrowRight':
if (isMenuDialogTrigger && direction === 'ltr') {
onSubmenuOpen();
} else {
e.continuePropagation();
}
break;
case 'ArrowLeft':
if (isMenuDialogTrigger && direction === 'rtl') {
onSubmenuOpen();
} else {
e.continuePropagation();
}
break;
default:
e.continuePropagation();
break;
}
}
});

return {
menuItemProps: {
...ariaProps,
...mergeProps(itemProps, pressProps, hoverProps, {onKeyDown})
...mergeProps(itemProps, pressProps, hoverProps, keyboardProps)
},
labelProps: {
id: labelId
Expand Down
6 changes: 4 additions & 2 deletions packages/@react-aria/overlays/src/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export interface OverlayProps {
*/
portalContainer?: Element,
/** The overlay to render in the portal. */
children: ReactNode
children: ReactNode,
/** Whether to contain focus within the overlay. This is an override, by default Dialogs contain and nothing else does. */
shouldContainFocus?: boolean
}

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

let contents = (
<OverlayContext.Provider value={contextValue}>
<FocusScope restoreFocus contain={contain}>
<FocusScope restoreFocus contain={props.shouldContainFocus ?? contain}>
{props.children}
</FocusScope>
</OverlayContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {classNames, ClearSlots, SlotProvider} from '@react-spectrum/utils';
import {Dialog, DialogTrigger} from '@react-spectrum/dialog';
import {FocusableRef} from '@react-types/shared';
import HelpOutline from '@spectrum-icons/workflow/HelpOutline';
import helpStyles from './contextualhelp.css';
import helpStyles from '@adobe/spectrum-css-temp/components/contextualhelp/vars.css';
import InfoOutline from '@spectrum-icons/workflow/InfoOutline';
// @ts-ignore
import intlMessages from '../intl/*.json';
Expand Down
Loading