Instantly share code, notes, and snippets.
Created
November 15, 2019 14:37
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save Kikketer/b22d5eb9398a30c58cc71b817e018085 to your computer and use it in GitHub Desktop.
Allowing the ReachUI Menu Button to work properly in an iFrame and IE11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { Children, cloneElement, createContext, forwardRef, useContext, useEffect, useRef } from 'react' | |
import Portal from '@reach/portal' | |
import Rect, { useRect } from '@reach/rect' | |
import Component from '@reach/component-component' | |
import { node, func, object, string, number, oneOfType, any } from 'prop-types' | |
import { wrapEvent, checkStyles, assignRef, useForkedRef } from '@reach/utils' | |
import styled from 'styled-components' | |
const StyledMenuWrap = styled.div` | |
display: block; | |
position: absolute; | |
z-index: 99999; | |
` | |
const noop = () => {} | |
let id = 0 | |
const genId = () => `button-${++id}` | |
// TODO: add the mousedown/drag/mouseup to select of native menus, will | |
// also help w/ remove the menu button tooltip hide-flash. | |
// TODO: add type-to-highlight like native menus | |
const MenuContext = createContext() | |
const checkIfAppManagedFocus = ({ refs, state, prevState, document = window.document }) => { | |
if (!state.isOpen && prevState.isOpen) { | |
return !refs.menu.contains(document.activeElement) | |
} | |
return false | |
} | |
const manageFocusOnUpdate = ({ refs, state, prevState }, appManagedFocus) => { | |
if (state.isOpen && !prevState.isOpen) { | |
window.__REACH_DISABLE_TOOLTIPS = true | |
if (state.selectionIndex !== -1) { | |
// haven't measured the popover yet, give it a frame otherwise | |
// we'll scroll to the bottom of the page >.< | |
requestAnimationFrame(() => { | |
if (refs.items && refs.items[state.selectionIndex]) { | |
refs.items[state.selectionIndex].focus() | |
} | |
}) | |
} else { | |
refs.menu.focus() | |
} | |
} else if (!state.isOpen && prevState.isOpen) { | |
if (!appManagedFocus) { | |
refs.button && refs.button.focus() | |
} | |
// we want to ignore the immediate focus of a tooltip so it doesn't pop | |
// up again when the menu closes, only pops up when focus returns again | |
// to the tooltip (like native OS tooltips) | |
window.__REACH_DISABLE_TOOLTIPS = false | |
} else if (state.selectionIndex !== prevState.selectionIndex) { | |
if (state.selectionIndex === -1) { | |
// clear highlight when mousing over non-menu items, but focus the menu | |
// so the the keyboard will work after a mouseover | |
refs.menu && refs.menu.focus() | |
} else if (refs.items && refs.items[state.selectionIndex]) { | |
refs.items[state.selectionIndex].focus() | |
} | |
} | |
} | |
const openAtFirstItem = () => ({ isOpen: true, selectionIndex: 0 }) | |
const close = () => ({ | |
isOpen: false, | |
selectionIndex: -1, | |
closingWithClick: false | |
}) | |
const selectItemAtIndex = index => () => ({ | |
selectionIndex: index | |
}) | |
const getMenuRefs = () => ({ | |
button: null, | |
menu: null, | |
items: [] | |
}) | |
const getInitialMenuState = () => ({ | |
buttonId: null, | |
isOpen: false, | |
buttonRect: undefined, | |
selectionIndex: -1, | |
closingWithClick: false | |
}) | |
const checkIfStylesIncluded = () => checkStyles('menu-button') | |
//////////////////////////////////////////////////////////////////////////////// | |
export const Menu = ({ children, document }) => { | |
document = document || window.document | |
return ( | |
<Component | |
getRefs={getMenuRefs} | |
getInitialState={getInitialMenuState} | |
didMount={checkIfStylesIncluded} | |
didUpdate={manageFocusOnUpdate} | |
getSnapshotBeforeUpdate={checkIfAppManagedFocus} | |
> | |
{context => ( | |
<MenuContext.Provider value={{ ...context, document }}> | |
{typeof children === 'function' ? children({ isOpen: context.state.isOpen, document }) : children} | |
</MenuContext.Provider> | |
)} | |
</Component> | |
) | |
} | |
Menu.propTypes = { | |
children: oneOfType([func, node]), | |
document: any | |
} | |
Menu.displayName = 'Menu' | |
//////////////////////////////////////////////////////////////////////////////// | |
export const MenuButton = forwardRef(({ onClick, onKeyDown, onMouseDown, id, ...props }, forwardedRef) => { | |
const { refs, state, setState } = useContext(MenuContext) | |
const ownRef = useRef(null) | |
useRect(ownRef, state.isOpen, buttonRect => setState({ buttonRect })) | |
useEffect( | |
() => setState({ buttonId: id != null ? id : genId() }), | |
[] // eslint-disable-line react-hooks/exhaustive-deps | |
) | |
return ( | |
<button | |
id={state.buttonId} | |
aria-haspopup="menu" | |
aria-expanded={state.isOpen} | |
data-reach-menu-button | |
type="button" | |
ref={node => { | |
assignRef(forwardedRef, node) | |
assignRef(ownRef, node) | |
refs.button = node | |
}} | |
onMouseDown={wrapEvent(onMouseDown, () => { | |
if (state.isOpen) { | |
setState({ closingWithClick: true }) | |
} | |
})} | |
onClick={wrapEvent(onClick, () => { | |
if (state.isOpen) { | |
setState(close) | |
} else { | |
setState(openAtFirstItem) | |
} | |
})} | |
onKeyDown={wrapEvent(onKeyDown, event => { | |
if (event.key === 'ArrowDown') { | |
event.preventDefault() // prevent scroll | |
setState(openAtFirstItem) | |
} else if (event.key === 'ArrowUp') { | |
event.preventDefault() // prevent scroll | |
setState(openAtFirstItem) | |
} | |
})} | |
{...props} | |
/> | |
) | |
}) | |
MenuButton.propTypes = { | |
children: node, | |
id: string, | |
onClick: func, | |
onKeyDown: func, | |
onMouseDown: func | |
} | |
MenuButton.displayName = 'MenuButton' | |
//////////////////////////////////////////////////////////////////////////////// | |
export const MenuItem = forwardRef( | |
( | |
{ onClick, onKeyDown, onMouseLeave, onMouseMove, onSelect, role = 'menuitem', _index: index, _ref = null, ...rest }, | |
forwardedRef | |
) => { | |
const { state, setState } = useContext(MenuContext) | |
const ownRef = useRef(null) | |
const ref = useForkedRef(_ref, forwardedRef, ownRef) | |
const isSelected = index === state.selectionIndex | |
const select = () => { | |
onSelect() | |
setState(close) | |
} | |
return ( | |
<div | |
{...rest} | |
ref={ref} | |
data-reach-menu-item={role === 'menuitem' ? true : undefined} | |
role={role} | |
tabIndex={-1} | |
data-selected={role === 'menuitem' && isSelected ? true : undefined} | |
onClick={wrapEvent(onClick, () => { | |
select() | |
})} | |
onKeyDown={wrapEvent(onKeyDown, event => { | |
if (event.key === 'Enter' || event.key === ' ') { | |
// prevent the button from being "clicked" by | |
// this "Enter" keydown | |
event.preventDefault() | |
select() | |
} | |
})} | |
onMouseMove={wrapEvent(onMouseMove, () => { | |
if (!isSelected) { | |
setState(selectItemAtIndex(index)) | |
} | |
})} | |
onMouseLeave={wrapEvent(onMouseLeave, () => { | |
// clear out selection when mouse over a non-menu item child | |
setState({ selectionIndex: -1 }) | |
})} | |
/> | |
) | |
} | |
) | |
MenuItem.propTypes = { | |
_index: number, | |
_ref: func, | |
onClick: func, | |
onKeyDown: func, | |
onMouseLeave: func, | |
onMouseMove: func, | |
onSelect: func.isRequired, | |
role: string, | |
setState: func, | |
state: object | |
} | |
MenuItem.displayName = 'MenuItem' | |
//////////////////////////////////////////////////////////////////////////////// | |
export const MenuLink = forwardRef( | |
( | |
{ as: AsComp = 'a', component: Comp, onClick, onKeyDown, role, _index: index, _ref = null, ...props }, | |
forwardedRef | |
) => { | |
const { state, setState } = useContext(MenuContext) | |
const Link = Comp || AsComp | |
const ownRef = useRef(null) | |
const ref = useForkedRef(_ref, forwardedRef, ownRef) | |
if (Comp) { | |
console.warn('[@reach/menu-button]: Please use the `as` prop instead of `component`.') | |
} | |
return ( | |
<MenuItem role="none" onSelect={noop} _index={index} _ref={noop}> | |
<Link | |
role="menuitem" | |
data-reach-menu-item | |
tabIndex={-1} | |
data-selected={index === state.selectionIndex ? true : undefined} | |
onClick={wrapEvent(onClick, () => { | |
setState(close) | |
})} | |
onKeyDown={wrapEvent(onKeyDown, event => { | |
if (event.key === 'Enter') { | |
// prevent MenuItem's preventDefault from firing, | |
// allowing this link to work w/ the keyboard | |
event.stopPropagation() | |
} | |
})} | |
ref={ref} | |
{...props} | |
/> | |
</MenuItem> | |
) | |
} | |
) | |
MenuLink.propTypes = { | |
_index: number, | |
_ref: func, | |
as: any, | |
component: any, | |
onClick: func, | |
onKeyDown: func, | |
role: string | |
} | |
MenuLink.displayName = 'MenuLink' | |
//////////////////////////////////////////////////////////////////////////////// | |
export const MenuPopover = forwardRef(({ children, style, ...props }, forwardedRef) => { | |
const { state } = useContext(MenuContext) | |
return ( | |
state.isOpen && ( | |
<Portal> | |
<Rect> | |
{({ rect: menuRect, ref }) => ( | |
<StyledMenuWrap | |
data-reach-menu-popover | |
data-reach-menu // deprecate for naming consistency? | |
ref={node => { | |
assignRef(ref, node) | |
assignRef(forwardedRef, node) | |
}} | |
{...props} | |
style={{ | |
...style, | |
...getStyles(state.buttonRect, menuRect) | |
}} | |
> | |
{children} | |
</StyledMenuWrap> | |
)} | |
</Rect> | |
</Portal> | |
) | |
) | |
}) | |
MenuPopover.propTypes = { | |
children: node, | |
style: object | |
} | |
MenuPopover.displayName = 'MenuPopover' | |
//////////////////////////////////////////////////////////////////////////////// | |
export const MenuList = forwardRef((props, forwardedRef) => { | |
const ownRef = useRef(null) | |
const ref = useForkedRef(ownRef, forwardedRef) | |
return ( | |
<MenuPopover> | |
<MenuItems {...props} data-reach-menu-list="" ref={ref} /> | |
</MenuPopover> | |
) | |
}) | |
MenuList.propTypes = { | |
children: node.isRequired | |
} | |
MenuList.displayName = 'MenuList' | |
//////////////////////////////////////////////////////////////////////////////// | |
const focusableChildrenTypes = [MenuItem, MenuLink] | |
const isFocusableChildType = child => focusableChildrenTypes.includes(child.type) | |
const getFocusableMenuChildren = childrenArray => { | |
const focusable = childrenArray.filter(child => isFocusableChildType(child)) | |
return focusable | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
export const MenuItems = forwardRef(({ children, onKeyDown, onBlur, ...rest }, ref) => { | |
const { state, setState, refs, document } = useContext(MenuContext) | |
const clones = Children.toArray(children).filter(Boolean) | |
const focusableChildren = getFocusableMenuChildren(clones) | |
return ( | |
<div | |
data-reach-menu-items | |
{...rest} | |
role="menu" | |
aria-labelledby={state.buttonId} | |
tabIndex={-1} | |
ref={node => { | |
refs.menu = node | |
assignRef(ref, node) | |
}} | |
onBlur={event => { | |
if (!state.closingWithClick && !refs.menu.contains(event.relatedTarget || document.activeElement)) { | |
setState(close) | |
} | |
}} | |
onKeyDown={wrapEvent(onKeyDown, event => { | |
if (event.key === 'Escape') { | |
setState(close) | |
} else if (event.key === 'ArrowDown') { | |
event.preventDefault() // prevent window scroll | |
const nextIndex = state.selectionIndex + 1 | |
if (nextIndex !== focusableChildren.length) { | |
setState({ selectionIndex: nextIndex }) | |
} | |
} else if (event.key === 'ArrowUp') { | |
event.preventDefault() // prevent window scroll | |
const nextIndex = state.selectionIndex - 1 | |
if (nextIndex !== -1) { | |
setState({ selectionIndex: nextIndex }) | |
} | |
} else if (event.key === 'Tab') { | |
event.preventDefault() // prevent leaving | |
} | |
})} | |
> | |
{clones.map(child => { | |
if (isFocusableChildType(child)) { | |
const focusIndex = focusableChildren.indexOf(child) | |
return cloneElement(child, { | |
_index: focusIndex, | |
_ref: node => (refs.items[focusIndex] = node) | |
}) | |
} | |
return child | |
})} | |
</div> | |
) | |
}) | |
MenuItems.propTypes = { | |
children: node, | |
onBlur: func, | |
onKeyDown: func, | |
refs: object, | |
setState: func, | |
state: object | |
} | |
MenuItems.displayName = 'MenuItems' | |
//////////////////////////////////////////////////////////////////////////////// | |
const getStyles = (buttonRect, menuRect) => { | |
const haventMeasuredButtonYet = !buttonRect | |
if (haventMeasuredButtonYet) { | |
return { opacity: 0 } | |
} | |
const haventMeasuredMenuYet = !menuRect | |
const styles = { | |
left: `${buttonRect.left + window.pageXOffset}px`, | |
top: `${buttonRect.top + buttonRect.height + window.pageYOffset}px` | |
} | |
if (haventMeasuredMenuYet) { | |
return { | |
...styles, | |
opacity: 0 | |
} | |
} | |
if (buttonRect.width < 500) { | |
styles.minWidth = buttonRect.width | |
} | |
const collisions = { | |
top: buttonRect.top - menuRect.height < 0, | |
right: window.innerWidth < buttonRect.left + menuRect.width, | |
bottom: window.innerHeight < buttonRect.top + menuRect.height, | |
left: buttonRect.left + buttonRect.width - menuRect.width < 0 | |
} | |
const directionRight = collisions.right && !collisions.left | |
const directionUp = collisions.bottom && !collisions.top | |
return { | |
...styles, | |
left: directionRight | |
? `${buttonRect.right - menuRect.width + window.pageXOffset}px` | |
: `${buttonRect.left + window.pageXOffset}px`, | |
top: directionUp | |
? `${buttonRect.top - menuRect.height + window.pageYOffset}px` | |
: `${buttonRect.top + buttonRect.height + window.pageYOffset}px` | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment