diff --git a/packages/@react-spectrum/contextualhelp/src/contextualhelp.css b/packages/@adobe/spectrum-css-temp/components/contextualhelp/index.css similarity index 99% rename from packages/@react-spectrum/contextualhelp/src/contextualhelp.css rename to packages/@adobe/spectrum-css-temp/components/contextualhelp/index.css index 1078cd7ef62..04db902504a 100644 --- a/packages/@react-spectrum/contextualhelp/src/contextualhelp.css +++ b/packages/@adobe/spectrum-css-temp/components/contextualhelp/index.css @@ -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 { diff --git a/packages/@adobe/spectrum-css-temp/components/contextualhelp/skin.css b/packages/@adobe/spectrum-css-temp/components/contextualhelp/skin.css new file mode 100644 index 00000000000..ff0df4a4045 --- /dev/null +++ b/packages/@adobe/spectrum-css-temp/components/contextualhelp/skin.css @@ -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. + */ diff --git a/packages/@adobe/spectrum-css-temp/components/contextualhelp/vars.css b/packages/@adobe/spectrum-css-temp/components/contextualhelp/vars.css new file mode 100644 index 00000000000..ca1e08522d8 --- /dev/null +++ b/packages/@adobe/spectrum-css-temp/components/contextualhelp/vars.css @@ -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'; diff --git a/packages/@adobe/spectrum-css-temp/components/menu/index.css b/packages/@adobe/spectrum-css-temp/components/menu/index.css index 5d472344595..f141510d2bf 100644 --- a/packages/@adobe/spectrum-css-temp/components/menu/index.css +++ b/packages/@adobe/spectrum-css-temp/components/menu/index.css @@ -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; diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 05b87181747..dd68e889307 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -314,16 +314,33 @@ function useFocusContainment(scopeRef: RefObject, 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(); @@ -393,7 +410,7 @@ function useFocusContainment(scopeRef: RefObject, contain: boolean) { } function isElementInAnyScope(element: Element) { - return isElementInChildScope(element); + return !!isElementInChildScope(element); } function isElementInScope(element: Element, scope: Element[]) { @@ -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) { @@ -583,6 +617,13 @@ function useRestoreFocus(scopeRef: RefObject, 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) { @@ -666,7 +707,7 @@ function useRestoreFocus(scopeRef: RefObject, 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, @@ -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; diff --git a/packages/@react-aria/menu/package.json b/packages/@react-aria/menu/package.json index a7b545ff7cb..4edd8ef69eb 100644 --- a/packages/@react-aria/menu/package.json +++ b/packages/@react-aria/menu/package.json @@ -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", diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index ea3073c77b6..1ba0133196b 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -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 { @@ -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' } /** @@ -93,15 +98,44 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, 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 | 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') { @@ -131,27 +165,10 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, 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) { @@ -167,7 +184,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, 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(); } } @@ -188,6 +205,69 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, 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; } } }); @@ -195,7 +275,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re return { menuItemProps: { ...ariaProps, - ...mergeProps(itemProps, pressProps, hoverProps, {onKeyDown}) + ...mergeProps(itemProps, pressProps, hoverProps, keyboardProps) }, labelProps: { id: labelId diff --git a/packages/@react-aria/overlays/src/Overlay.tsx b/packages/@react-aria/overlays/src/Overlay.tsx index 0951aeb4d46..d1b43218b36 100644 --- a/packages/@react-aria/overlays/src/Overlay.tsx +++ b/packages/@react-aria/overlays/src/Overlay.tsx @@ -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); @@ -44,7 +46,7 @@ export function Overlay(props: OverlayProps) { let contents = ( - + {props.children} diff --git a/packages/@react-spectrum/contextualhelp/src/ContextualHelp.tsx b/packages/@react-spectrum/contextualhelp/src/ContextualHelp.tsx index 1ce6d35b191..d51f34ee6df 100644 --- a/packages/@react-spectrum/contextualhelp/src/ContextualHelp.tsx +++ b/packages/@react-spectrum/contextualhelp/src/ContextualHelp.tsx @@ -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'; diff --git a/packages/@react-spectrum/dialog/src/Dialog.tsx b/packages/@react-spectrum/dialog/src/Dialog.tsx index c5e7f177a55..ff42526475a 100644 --- a/packages/@react-spectrum/dialog/src/Dialog.tsx +++ b/packages/@react-spectrum/dialog/src/Dialog.tsx @@ -11,7 +11,15 @@ */ import {ActionButton} from '@react-spectrum/button'; -import {classNames, SlotProvider, unwrapDOMRef, useDOMRef, useHasChild, useStyleProps} from '@react-spectrum/utils'; +import { + classNames, + SlotProvider, + unwrapDOMRef, + useDOMRef, + useHasChild, + useSlotProps, + useStyleProps +} from '@react-spectrum/utils'; import CrossLarge from '@spectrum-icons/ui/CrossLarge'; import {DialogContext, DialogContextValue} from './context'; import {DOMRef} from '@react-types/shared'; @@ -34,6 +42,7 @@ let sizeMap = { }; function Dialog(props: SpectrumDialogProps, ref: DOMRef) { + props = useSlotProps(props, 'dialog'); let { type = 'modal', ...contextProps diff --git a/packages/@react-spectrum/menu/intl/ar-AE.json b/packages/@react-spectrum/menu/intl/ar-AE.json index daaacec9545..f2445d375e3 100644 --- a/packages/@react-spectrum/menu/intl/ar-AE.json +++ b/packages/@react-spectrum/menu/intl/ar-AE.json @@ -1,3 +1,4 @@ { - "moreActions": "المزيد من الإجراءات" + "moreActions": "المزيد من الإجراءات", + "unavailable": "Unavailable" } diff --git a/packages/@react-spectrum/menu/intl/en-US.json b/packages/@react-spectrum/menu/intl/en-US.json index 13ccdcbe06b..0a385794230 100644 --- a/packages/@react-spectrum/menu/intl/en-US.json +++ b/packages/@react-spectrum/menu/intl/en-US.json @@ -1,3 +1,4 @@ { - "moreActions": "More actions" -} \ No newline at end of file + "moreActions": "More actions", + "unavailable": "Unavailable" +} diff --git a/packages/@react-spectrum/menu/package.json b/packages/@react-spectrum/menu/package.json index c05ba4e8179..64c11aaae39 100644 --- a/packages/@react-spectrum/menu/package.json +++ b/packages/@react-spectrum/menu/package.json @@ -54,6 +54,7 @@ "@react-spectrum/utils": "^3.9.1", "@react-stately/collections": "^3.7.0", "@react-stately/menu": "^3.5.1", + "@react-stately/overlays": "^3.5.1", "@react-stately/tree": "^3.6.0", "@react-stately/utils": "^3.6.0", "@react-types/menu": "^3.9.0", diff --git a/packages/@react-spectrum/menu/src/Menu.tsx b/packages/@react-spectrum/menu/src/Menu.tsx index 443abf57d70..1e762e69fc4 100644 --- a/packages/@react-spectrum/menu/src/Menu.tsx +++ b/packages/@react-spectrum/menu/src/Menu.tsx @@ -12,7 +12,8 @@ import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import {DOMRef} from '@react-types/shared'; -import {MenuContext} from './context'; +import {FocusScope} from '@react-aria/focus'; +import {MenuContext, MenuStateContext} from './context'; import {MenuItem} from './MenuItem'; import {MenuSection} from './MenuSection'; import {mergeProps, useSyncRef} from '@react-aria/utils'; @@ -35,43 +36,47 @@ function Menu(props: SpectrumMenuProps, ref: DOMRef - {[...state.collection].map(item => { - if (item.type === 'section') { - return ( - - ); - } + + 0}> +
    + {[...state.collection].map(item => { + if (item.type === 'section') { + return ( + + ); + } - let menuItem = ( - - ); + let menuItem = ( + + ); - if (item.wrapper) { - menuItem = item.wrapper(menuItem); - } + if (item.wrapper) { + menuItem = item.wrapper(menuItem); + } - return menuItem; - })} -
+ return menuItem; + })} + +
+
); } diff --git a/packages/@react-spectrum/menu/src/MenuDialogTrigger.tsx b/packages/@react-spectrum/menu/src/MenuDialogTrigger.tsx new file mode 100644 index 00000000000..60aea9c3dcf --- /dev/null +++ b/packages/@react-spectrum/menu/src/MenuDialogTrigger.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2023 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 {classNames, SlotProvider, useIsMobileDevice} from '@react-spectrum/utils'; +import helpStyles from '@adobe/spectrum-css-temp/components/contextualhelp/vars.css'; +import {ItemProps} from '@react-types/shared'; +import {MenuDialogContext, useMenuStateContext} from './context'; +import {Modal, Popover} from '@react-spectrum/overlays'; +import React, {Key, ReactElement, useRef} from 'react'; +import {useOverlayTriggerState} from '@react-stately/overlays'; + +function MenuDialogTrigger(props: ItemProps & {isUnavailable?: boolean, targetKey: Key}): ReactElement { + let {isUnavailable} = props; + + let {state: menuState} = useMenuStateContext(); + let state = useOverlayTriggerState({isOpen: menuState.expandedKeys.has(props.targetKey), onOpenChange: (val) => { + if (!val) { + if (menuState.expandedKeys.has(props.targetKey)) { + menuState.toggleKey(props.targetKey); + } + } + }}); + let slots = {}; + if (isUnavailable) { + slots = { + dialog: {UNSAFE_className: classNames(helpStyles, 'react-spectrum-ContextualHelp-dialog')}, + content: {UNSAFE_className: helpStyles['react-spectrum-ContextualHelp-content']}, + footer: {UNSAFE_className: helpStyles['react-spectrum-ContextualHelp-footer']} + }; + } + let [trigger] = React.Children.toArray(props.children); + let [, content] = props.children as [ReactElement, ReactElement]; + + let isMobile = useIsMobileDevice(); + let triggerRef = useRef(null); + return ( + <> + {trigger} + + { + isMobile ? ( + + {content} + + ) : ( + {content} + ) + } + + + ); +} + +MenuDialogTrigger.getCollectionNode = function* getCollectionNode(props: ItemProps) { + let [trigger] = React.Children.toArray(props.children) as ReactElement[]; + let [, content] = props.children as [ReactElement, ReactElement]; + + yield { + element: React.cloneElement(trigger, {...trigger.props, hasChildItems: true}), + wrapper: (element) => ( + + {element} + {content} + + ) + }; +}; + +let _Item = MenuDialogTrigger as (props: ItemProps & {isUnavailable?: boolean}) => JSX.Element; +export {_Item as MenuDialogTrigger}; diff --git a/packages/@react-spectrum/menu/src/MenuItem.tsx b/packages/@react-spectrum/menu/src/MenuItem.tsx index d938264a8d3..543aa053d82 100644 --- a/packages/@react-spectrum/menu/src/MenuItem.tsx +++ b/packages/@react-spectrum/menu/src/MenuItem.tsx @@ -12,16 +12,19 @@ import CheckmarkMedium from '@spectrum-icons/ui/CheckmarkMedium'; import {classNames, ClearSlots, SlotProvider} from '@react-spectrum/utils'; +import {DOMAttributes, Node} from '@react-types/shared'; import {FocusRing} from '@react-aria/focus'; import {Grid} from '@react-spectrum/layout'; -import {mergeProps} from '@react-aria/utils'; -import {Node} from '@react-types/shared'; +import InfoOutline from '@spectrum-icons/workflow/InfoOutline'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {mergeProps, useSlotId} from '@react-aria/utils'; import React, {Key, useRef} from 'react'; import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {Text} from '@react-spectrum/text'; import {TreeState} from '@react-stately/tree'; -import {useHover} from '@react-aria/interactions'; -import {useMenuContext} from './context'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useMenuContext, useMenuDialogContext} from './context'; import {useMenuItem} from '@react-aria/menu'; interface MenuItemProps { @@ -39,6 +42,15 @@ export function MenuItem(props: MenuItemProps) { isVirtualized, onAction } = props; + let stringFormatter = useLocalizedStringFormatter(intlMessages); + let menuDialogContext = useMenuDialogContext(); + let {triggerRef} = menuDialogContext || {}; + let isMenuDialogTrigger = !!menuDialogContext; + let isUnavailable = false; + + if (isMenuDialogTrigger) { + isUnavailable = menuDialogContext.isUnavailable; + } let { onClose, @@ -53,8 +65,17 @@ export function MenuItem(props: MenuItemProps) { let isSelected = state.selectionManager.isSelected(key); let isDisabled = state.disabledKeys.has(key); - let ref = useRef(); - let {menuItemProps, labelProps, descriptionProps, keyboardShortcutProps} = useMenuItem( + let ref = useRef(null); + if (triggerRef) { + ref = triggerRef; + } + + let { + menuItemProps, + labelProps, + descriptionProps, + keyboardShortcutProps + } = useMenuItem( { isSelected, isDisabled, @@ -63,12 +84,18 @@ export function MenuItem(props: MenuItemProps) { onClose, closeOnSelect, isVirtualized, - onAction + onAction, + 'aria-haspopup': isMenuDialogTrigger ? 'dialog' : undefined }, state, ref ); - let {hoverProps, isHovered} = useHover({isDisabled}); + let endId = useSlotId(); + let endProps: DOMAttributes = {}; + if (endId) { + endProps.id = endId; + menuItemProps['aria-describedby'] = menuItemProps['aria-describedby'] + ' ' + endId; + } let contents = typeof rendered === 'string' ? {rendered} @@ -77,7 +104,7 @@ export function MenuItem(props: MenuItemProps) { return (
  • (props: MenuItemProps) { 'is-disabled': isDisabled, 'is-selected': isSelected, 'is-selectable': state.selectionManager.selectionMode !== 'none', - 'is-hovered': isHovered + 'is-open': state.expandedKeys.has(key) } )}> (props: MenuItemProps) { (props: MenuItemProps) { - } + classNames( + styles, + 'spectrum-Menu-checkmark' + ) + } /> + } + { + isUnavailable && + } diff --git a/packages/@react-spectrum/menu/src/MenuTrigger.tsx b/packages/@react-spectrum/menu/src/MenuTrigger.tsx index 3435ad8cdc1..46aafccdf13 100644 --- a/packages/@react-spectrum/menu/src/MenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/MenuTrigger.tsx @@ -58,6 +58,7 @@ function MenuTrigger(props: SpectrumMenuTriggerProps, ref: DOMRef) let isMobile = useIsMobileDevice(); let menuContext = { ...menuProps, + state, ref: menuRef, onClose: state.close, closeOnSelect, diff --git a/packages/@react-spectrum/menu/src/context.ts b/packages/@react-spectrum/menu/src/context.ts index a5b30a7c2f6..6d923f1ddf2 100644 --- a/packages/@react-spectrum/menu/src/context.ts +++ b/packages/@react-spectrum/menu/src/context.ts @@ -11,14 +11,17 @@ */ import {FocusStrategy} from '@react-types/shared'; +import {MenuTriggerState} from '@react-stately/menu'; import React, {HTMLAttributes, MutableRefObject, useContext} from 'react'; +import {TreeState} from '@react-stately/tree'; export interface MenuContextValue extends HTMLAttributes { onClose?: () => void, closeOnSelect?: boolean, shouldFocusWrap?: boolean, autoFocus?: boolean | FocusStrategy, - ref?: MutableRefObject + ref?: MutableRefObject, + state?: MenuTriggerState } export const MenuContext = React.createContext({}); @@ -26,3 +29,25 @@ export const MenuContext = React.createContext({}); export function useMenuContext(): MenuContextValue { return useContext(MenuContext); } + +export interface MenuDialogContextValue { + isUnavailable?: boolean, + triggerRef?: MutableRefObject +} + +export const MenuDialogContext = React.createContext(undefined); + +export function useMenuDialogContext(): MenuDialogContextValue { + return useContext(MenuDialogContext); +} + +export interface MenuStateContextValue { + state?: TreeState +} + +export const MenuStateContext = React.createContext>({}); + +export function useMenuStateContext(): MenuStateContextValue { + return useContext(MenuStateContext); +} + diff --git a/packages/@react-spectrum/menu/src/index.ts b/packages/@react-spectrum/menu/src/index.ts index 3dc3952a58c..9ff37075da9 100644 --- a/packages/@react-spectrum/menu/src/index.ts +++ b/packages/@react-spectrum/menu/src/index.ts @@ -15,6 +15,7 @@ export {MenuTrigger} from './MenuTrigger'; export {Menu} from './Menu'; export {ActionMenu} from './ActionMenu'; +export {MenuDialogTrigger} from './MenuDialogTrigger'; export {Item, Section} from '@react-stately/collections'; export type {SpectrumMenuTriggerProps} from '@react-types/menu'; export type {SpectrumActionMenuProps, SpectrumMenuProps} from '@react-types/menu'; diff --git a/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx b/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx index 9e7f4eb7c28..812385d3c16 100644 --- a/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx +++ b/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx @@ -17,10 +17,13 @@ import AlignLeft from '@spectrum-icons/workflow/AlignLeft'; import AlignRight from '@spectrum-icons/workflow/AlignRight'; import Blower from '@spectrum-icons/workflow/Blower'; import Book from '@spectrum-icons/workflow/Book'; +import {Content, Footer} from '@react-spectrum/view'; import Copy from '@spectrum-icons/workflow/Copy'; import Cut from '@spectrum-icons/workflow/Cut'; -import {Item, Menu, MenuTrigger, Section} from '../'; -import {Keyboard, Text} from '@react-spectrum/text'; +import {Dialog} from '@react-spectrum/dialog'; +import {Heading, Keyboard, Text} from '@react-spectrum/text'; +import {Item, Menu, MenuDialogTrigger, MenuTrigger, Section} from '../'; +import {Link} from '@react-spectrum/link'; import Paste from '@spectrum-icons/workflow/Paste'; import React, {useState} from 'react'; @@ -712,3 +715,31 @@ function ControlledOpeningMenuTrigger() { ); } + +export let MenuItemUnavailable = { + render: () => render( + + One + + Two + + hello + Is it me you're looking for? + + + Three + + + Four + Shut the door + + + hello + Is it me you're looking for? +
    Learn more
    +
    +
    + Five +
    + ) +}; diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index 76f21849c3d..0f83d08b1a9 100644 --- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js +++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js @@ -10,12 +10,19 @@ * governing permissions and limitations under the License. */ -import {act, DEFAULT_LONG_PRESS_TIME, fireEvent, installPointerEvent, render, triggerLongPress, triggerPress, triggerTouch, within} from '@react-spectrum/test-utils'; -import {Button} from '@react-spectrum/button'; -import {Item, Menu, MenuTrigger, Section} from '../'; +import {act, fireEvent, render, screen, within} from '@testing-library/react'; +import {action} from '@storybook/addon-actions'; +import {ActionButton, Button} from '@react-spectrum/button'; +import {Content, Footer} from '@react-spectrum/view'; +import {DEFAULT_LONG_PRESS_TIME, installPointerEvent, triggerLongPress, triggerPress, triggerTouch} from '@react-spectrum/test-utils'; +import {Dialog} from '@react-spectrum/dialog'; +import {Heading, Text} from '@react-spectrum/text'; +import {Item, Menu, MenuDialogTrigger, MenuTrigger, Section} from '../'; +import {Link} from '@react-spectrum/link'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; +import userEvent from '@testing-library/user-event'; let triggerText = 'Menu Button'; @@ -962,4 +969,188 @@ describe('MenuTrigger', function () { }); }); + describe('sub dialogs', function () { + let tree; + afterEach(() => { + if (tree) { + tree.unmount(); + } + tree = null; + }); + + describe('unavailable item', function () { + let renderTree = (options = {}) => { + let {providerProps = {}} = options; + let {locale = 'en-US'} = providerProps; + tree = render( + + + Menu + + One + + + Hello + Is it me you're looking for? + + + Lionel Richie says: + I can see it in your eyes + + + Three + Five + + Choose a College Major + + Choosing a College Major + What factors should I consider when choosing a college major? +
    Visit this link before choosing this action. Learn more
    +
    +
    +
    +
    +
    + ); + }; + let openMenu = () => { + let triggerButton = tree.getByRole('button'); + triggerPress(triggerButton); + act(() => {jest.runAllTimers();}); + + return tree.getByRole('menu'); + }; + + it('adds the expected spectrum icon', function () { + renderTree(); + let menu = openMenu(); + let unavailableItem = within(menu).getAllByRole('menuitem')[1]; + expect(unavailableItem).toBeVisible(); + + let icon = within(unavailableItem).getByRole('img', {hidden: true}); + expect(icon).not.toHaveAttribute('aria-hidden'); + }); + + it('can open a sub dialog with hover', function () { + renderTree(); + let menu = openMenu(); + let menuItems = within(menu).getAllByRole('menuitem'); + let unavailableItem = menuItems[1]; + expect(unavailableItem).toBeVisible(); + expect(unavailableItem).toHaveAttribute('aria-haspopup', 'dialog'); + + fireEvent.mouseEnter(unavailableItem); + act(() => {jest.runAllTimers();}); + let dialog = tree.getByRole('dialog'); + expect(dialog).toBeVisible(); + + fireEvent.mouseLeave(unavailableItem); + fireEvent.mouseEnter(menuItems[2]); + act(() => {jest.runAllTimers();}); + expect(menu).toBeVisible(); + expect(dialog).not.toBeInTheDocument(); + expect(document.activeElement).toBe(menuItems[2]); + }); + + it('can open a sub dialog with keyboard', function () { + renderTree(); + let menu = openMenu(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + let unavailableItem = within(menu).getAllByRole('menuitem')[1]; + expect(document.activeElement).toBe(unavailableItem); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + + let dialog = tree.getByRole('dialog'); + expect(dialog).toBeVisible(); + }); + + it('will close sub dialogs as you hover other items even if you click open it', function () { + renderTree(); + let menu = openMenu(); + let menuItems = within(menu).getAllByRole('menuitem'); + let unavailableItem = menuItems[1]; + expect(unavailableItem).toBeVisible(); + expect(unavailableItem).toHaveAttribute('aria-haspopup', 'dialog'); + + fireEvent.mouseEnter(unavailableItem); + fireEvent.mouseDown(unavailableItem); + fireEvent.mouseUp(unavailableItem); + act(() => {jest.runAllTimers();}); + let dialog = tree.getByRole('dialog'); + expect(dialog).toBeVisible(); + + fireEvent.mouseLeave(unavailableItem); + fireEvent.mouseEnter(menuItems[2]); + act(() => {jest.runAllTimers();}); + expect(dialog).not.toBeVisible(); + + fireEvent.mouseLeave(menuItems[2]); + fireEvent.mouseEnter(menuItems[3]); + act(() => {jest.runAllTimers();}); + fireEvent.mouseLeave(menuItems[3]); + fireEvent.mouseEnter(menuItems[4]); + act(() => {jest.runAllTimers();}); + + expect(menu).toBeVisible(); + dialog = tree.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(document.activeElement).toBe(dialog); + + userEvent.tab(); + act(() => {jest.runAllTimers();}); + let link = screen.getByRole('link'); + expect(document.activeElement).toBe(link); + + userEvent.tab(); + act(() => {jest.runAllTimers();}); + expect(dialog).not.toBeInTheDocument(); + expect(document.activeElement).toBe(menuItems[4]); + }); + + it('will close everything if the user shift tabs out of the subdialog', function () { + renderTree(); + let menu = openMenu(); + let menuItems = within(menu).getAllByRole('menuitem'); + let unavailableItem = menuItems[4]; + expect(unavailableItem).toBeVisible(); + expect(unavailableItem).toHaveAttribute('aria-haspopup', 'dialog'); + + fireEvent.mouseEnter(unavailableItem); + act(() => {jest.runAllTimers();}); + let dialog = tree.getByRole('dialog'); + expect(dialog).toBeVisible(); + + expect(document.activeElement).toBe(dialog); + + userEvent.tab({shift: true}); + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + expect(dialog).not.toBeInTheDocument(); + + expect(document.activeElement).toBe(unavailableItem); + }); + + it('will close everything if the user shift tabs out of the subdialog', function () { + renderTree({providerProps: {locale: 'ar-AE'}}); + let menu = openMenu(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + let unavailableItem = within(menu).getAllByRole('menuitem')[1]; + expect(document.activeElement).toBe(unavailableItem); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + + let dialog = tree.getByRole('dialog'); + expect(dialog).toBeVisible(); + }); + }); + }); }); diff --git a/packages/@react-spectrum/overlays/src/Overlay.tsx b/packages/@react-spectrum/overlays/src/Overlay.tsx index 7f5f6db031b..8f6771ec720 100644 --- a/packages/@react-spectrum/overlays/src/Overlay.tsx +++ b/packages/@react-spectrum/overlays/src/Overlay.tsx @@ -18,7 +18,7 @@ import React, {useCallback, useState} from 'react'; import {Overlay as ReactAriaOverlay} from '@react-aria/overlays'; function Overlay(props: OverlayProps, ref: DOMRef) { - let {children, isOpen, container, onEnter, onEntering, onEntered, onExit, onExiting, onExited, nodeRef} = props; + let {children, isOpen, shouldContainFocus, container, onEnter, onEntering, onEntered, onExit, onExiting, onExited, nodeRef} = props; let [exited, setExited] = useState(!isOpen); let handleEntered = useCallback(() => { @@ -43,7 +43,7 @@ function Overlay(props: OverlayProps, ref: DOMRef) { } return ( - + , StyleProps { children: ReactNode, hideArrow?: boolean, - state: OverlayTriggerState + state: OverlayTriggerState, + shouldContainFocus?: boolean } interface PopoverWrapperProps extends PopoverProps { diff --git a/packages/@react-types/overlays/src/index.d.ts b/packages/@react-types/overlays/src/index.d.ts index 83e3969803d..6c0ff566ea4 100644 --- a/packages/@react-types/overlays/src/index.d.ts +++ b/packages/@react-types/overlays/src/index.d.ts @@ -72,7 +72,8 @@ export interface OverlayProps { onExit?: () => void, onExiting?: () => void, onExited?: () => void, - nodeRef: MutableRefObject + nodeRef: MutableRefObject, + shouldContainFocus?: boolean } export interface ModalProps extends StyleProps, Omit {