-
Couldn't load subscription status.
- Fork 1.3k
Close non-containing overlays when a new overlay is opened #1510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e6fb3c
e26bc52
f41edf2
3dd87b7
4931572
3dec421
a53225a
743ba6d
020f942
17915c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| /* | ||
| * 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 {DismissButton, useOverlay} from '@react-aria/overlays'; | ||
| import {Flex} from '@react-spectrum/layout'; | ||
| import {FocusScope} from '@react-aria/focus'; | ||
| import {Item} from '@react-stately/collections'; | ||
| import {mergeProps} from '@react-aria/utils'; | ||
| import React from 'react'; | ||
| import {storiesOf} from '@storybook/react'; | ||
| import {useButton} from '@react-aria/button'; | ||
| import {useFocus} from '@react-aria/interactions'; | ||
| import {useMenu, useMenuItem, useMenuTrigger} from '@react-aria/menu'; | ||
| import {useMenuTriggerState} from '@react-stately/menu'; | ||
| import {useTreeState} from '@react-stately/tree'; | ||
|
|
||
|
|
||
| storiesOf('useMenuTrigger', module) | ||
| .add('2 menus', () => ( | ||
| <Flex> | ||
| <MenuButton label="Actions"> | ||
| <Item key="copy">Copy</Item> | ||
| <Item key="cut">Cut</Item> | ||
| <Item key="paste">Paste</Item> | ||
| </MenuButton> | ||
| <MenuButton label="Actions2"> | ||
| <Item key="copy">Copy</Item> | ||
| <Item key="cut">Cut</Item> | ||
| <Item key="paste">Paste</Item> | ||
| </MenuButton> | ||
| </Flex> | ||
| )) | ||
| .add('2 menus with disabled', () => ( | ||
| <Flex> | ||
| <MenuButton isDisabled label="Actions"> | ||
| <Item key="copy">Copy</Item> | ||
| <Item key="cut">Cut</Item> | ||
| <Item key="paste">Paste</Item> | ||
| </MenuButton> | ||
| <MenuButton label="Actions2"> | ||
| <Item key="copy">Copy</Item> | ||
| <Item key="cut">Cut</Item> | ||
| <Item key="paste">Paste</Item> | ||
| </MenuButton> | ||
| </Flex> | ||
| )); | ||
|
|
||
| function MenuButton(props) { | ||
| // Create state based on the incoming props | ||
| let state = useMenuTriggerState(props); | ||
|
|
||
| // Get props for the menu trigger and menu elements | ||
| let ref = React.useRef(null); | ||
|
|
||
| let shouldCloseOnInteractOutside = (element) => !ref?.current?.contains(element) ?? false; | ||
| let {menuTriggerProps, menuProps} = useMenuTrigger({}, state, ref); | ||
|
|
||
| // Get props for the button based on the trigger props from useMenuTrigger | ||
| let {buttonProps} = useButton({...menuTriggerProps, isDisabled: props.isDisabled}, ref); | ||
|
|
||
| return ( | ||
| <div style={{position: 'relative', display: 'inline-block'}}> | ||
| <button {...buttonProps} disabled={props.isDisabled} ref={ref} style={{height: 30, fontSize: 14}}> | ||
| {props.label} | ||
| <span aria-hidden="true" style={{paddingLeft: 5}}> | ||
| ▼ | ||
| </span> | ||
| </button> | ||
| {state.isOpen && ( | ||
| <MenuPopup | ||
| {...props} | ||
| shouldCloseOnInteractOutside={shouldCloseOnInteractOutside} | ||
| domProps={menuProps} | ||
| autoFocus={state.focusStrategy} | ||
| onClose={() => state.close()} /> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function MenuPopup(props) { | ||
| // Create menu state based on the incoming props | ||
| let state = useTreeState({...props, selectionMode: 'none'}); | ||
|
|
||
| // Get props for the menu element | ||
| let ref = React.useRef(); | ||
| let {menuProps} = useMenu(props, state, ref); | ||
|
|
||
| // Handle events that should cause the menu to close, | ||
| // e.g. blur, clicking outside, or pressing the escape key. | ||
| let overlayRef = React.useRef(); | ||
| let {overlayProps} = useOverlay( | ||
| { | ||
| shouldCloseOnInteractOutside: props.shouldCloseOnInteractOutside, | ||
| onClose: props.onClose, | ||
| shouldCloseOnBlur: true, | ||
| isOpen: true, | ||
| isDismissable: true | ||
| }, | ||
| overlayRef | ||
| ); | ||
|
|
||
| // Wrap in <FocusScope> so that focus is restored back to the | ||
| // trigger when the menu is closed. In addition, add hidden | ||
| // <DismissButton> components at the start and end of the list | ||
| // to allow screen reader users to dismiss the popup easily. | ||
| return ( | ||
| <FocusScope restoreFocus> | ||
| <div {...overlayProps} ref={overlayRef}> | ||
| <DismissButton onDismiss={props.onClose} /> | ||
| <ul | ||
| {...mergeProps(menuProps, props.domProps)} | ||
| ref={ref} | ||
| style={{ | ||
| position: 'absolute', | ||
| width: '100%', | ||
| margin: '4px 0 0 0', | ||
| padding: 0, | ||
| listStyle: 'none', | ||
| border: '1px solid gray', | ||
| background: 'lightgray' | ||
| }}> | ||
| {[...state.collection].map((item) => ( | ||
| <MenuItem | ||
| key={item.key} | ||
| item={item} | ||
| state={state} | ||
| onAction={props.onAction} | ||
| onClose={props.onClose} /> | ||
| ))} | ||
| </ul> | ||
| <DismissButton onDismiss={props.onClose} /> | ||
| </div> | ||
| </FocusScope> | ||
| ); | ||
| } | ||
|
|
||
| function MenuItem({item, state, onAction, onClose}) { | ||
| // Get props for the menu item element | ||
| let ref = React.useRef(); | ||
| let {menuItemProps} = useMenuItem( | ||
| { | ||
| key: item.key, | ||
| isDisabled: item.isDisabled, | ||
| onAction, | ||
| onClose | ||
| }, | ||
| state, | ||
| ref | ||
| ); | ||
|
|
||
| // Handle focus events so we can apply highlighted | ||
| // style to the focused menu item | ||
| let [isFocused, setFocused] = React.useState(false); | ||
| let {focusProps} = useFocus({onFocusChange: setFocused}); | ||
|
|
||
| return ( | ||
| <li | ||
| {...mergeProps(menuItemProps, focusProps)} | ||
| ref={ref} | ||
| style={{ | ||
| background: isFocused ? 'gray' : 'transparent', | ||
| color: isFocused ? 'white' : 'black', | ||
| padding: '2px 5px', | ||
| outline: 'none', | ||
| cursor: 'pointer' | ||
| }}> | ||
| {item.rendered} | ||
| </li> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,7 +49,12 @@ interface OverlayAria { | |
| overlayProps: HTMLAttributes<HTMLElement> | ||
| } | ||
|
|
||
| const visibleOverlays: RefObject<HTMLElement>[] = []; | ||
| interface OpenOverlay { | ||
| ref: RefObject<HTMLElement>, | ||
| onClose: () => void | ||
| } | ||
|
|
||
| export const visibleOverlays: OpenOverlay[] = []; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should make sure that this isn't exported from the package (e.g. |
||
|
|
||
| /** | ||
| * Provides the behavior for overlays such as dialogs, popovers, and menus. | ||
|
|
@@ -62,11 +67,11 @@ export function useOverlay(props: OverlayProps, ref: RefObject<HTMLElement>): Ov | |
| // Add the overlay ref to the stack of visible overlays on mount, and remove on unmount. | ||
| useEffect(() => { | ||
| if (isOpen) { | ||
| visibleOverlays.push(ref); | ||
| visibleOverlays.push({ref, onClose}); | ||
| } | ||
|
|
||
| return () => { | ||
| let index = visibleOverlays.indexOf(ref); | ||
| let index = visibleOverlays.findIndex(({ref: openRef}) => ref === openRef); | ||
| if (index >= 0) { | ||
| visibleOverlays.splice(index, 1); | ||
| } | ||
|
|
@@ -75,7 +80,7 @@ export function useOverlay(props: OverlayProps, ref: RefObject<HTMLElement>): Ov | |
|
|
||
| // Only hide the overlay when it is the topmost visible overlay in the stack. | ||
| let onHide = () => { | ||
| if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) { | ||
| if (visibleOverlays[visibleOverlays.length - 1].ref === ref && onClose) { | ||
| onClose(); | ||
| } | ||
| }; | ||
|
|
@@ -95,7 +100,7 @@ export function useOverlay(props: OverlayProps, ref: RefObject<HTMLElement>): Ov | |
| }; | ||
|
|
||
| // Handle clicking outside the overlay to close it | ||
| useInteractOutside({ref, onInteractOutside: isDismissable ? onInteractOutside : null}); | ||
| useInteractOutside({ref, onInteractOutside: isDismissable && isOpen ? onInteractOutside : undefined}); | ||
|
|
||
| let {focusWithinProps} = useFocusWithin({ | ||
| isDisabled: !shouldCloseOnBlur, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ import {HTMLAttributes, RefObject, useEffect} from 'react'; | |
| import {onCloseMap} from './useCloseOnScroll'; | ||
| import {OverlayTriggerState} from '@react-stately/overlays'; | ||
| import {useId} from '@react-aria/utils'; | ||
| import {visibleOverlays} from './useOverlay'; | ||
|
|
||
| interface OverlayTriggerProps { | ||
| /** Type of overlay that is opened by the trigger. */ | ||
|
|
@@ -45,6 +46,26 @@ export function useOverlayTrigger(props: OverlayTriggerProps, state: OverlayTrig | |
| } | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| if (isOpen === true && visibleOverlays.length > 1) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @devongovett yep, I think I had it working for one, then changed for the other and forgot to retest. I can reproduce it and I can fix it for one or the other. I'm having trouble solving for both though. It's this line here without knowing the overlay this trigger is opening, it's hard to determine if it's in the visibleOverlays or not yet Some thoughts on it so far: We can't check the id of the last visible overlay, the id isn't on the ref, it's somewhere further down in the tree. We can't pass the id into useOverlay for storage in the array of visibleOverlays because it's on the dialog or menu context, so we'd need to change an API to get it to the useOverlay hook. I thought about waiting to remove previous overlays dependent on when the new overlay mounts, but if there's a delay bigger than 0 to show it, then that might get a little funny looking? I think it'd be messy to implement as it'd be a new piece of state i think. |
||
| // The last overlay is the one just opened. | ||
| // If we have two overlays open, then we need to determine if we're nested or not. | ||
| // Start from top of the stack (minus the one we just opened) and close it if it doesn't | ||
| // contain the trigger that opened the most recent overlay. | ||
| // Do this until we find one that does contain it or close everything. | ||
| let i = visibleOverlays.length - 2; | ||
| do { | ||
| let {ref: overlayRef, onClose} = visibleOverlays[i]; | ||
| if (!overlayRef.current.contains(ref.current)) { | ||
| onClose(); | ||
| } else { | ||
| break; | ||
| } | ||
| i--; | ||
| } while (i >= 0); | ||
| } | ||
| }, [isOpen]); | ||
|
|
||
| // Aria 1.1 supports multiple values for aria-haspopup other than just menus. | ||
| // https://www.w3.org/TR/wai-aria-1.1/#aria-haspopup | ||
| // However, we only add it for menus for now because screen readers often | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this prop needed? Would be nice to avoid making everyone writing an overlay pass it in...