Skip to content

Commit

Permalink
fix: make bindHover/bindFocus work correctly together
Browse files Browse the repository at this point in the history
fix #102
  • Loading branch information
jedwards1211 committed May 2, 2022
1 parent c4251fd commit f7172be
Show file tree
Hide file tree
Showing 6 changed files with 328 additions and 18 deletions.
13 changes: 13 additions & 0 deletions demo/Root.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import HoverMenu from './examples/HoverMenu'
import HoverMenuCode from '!!raw-loader!./examples/HoverMenu'
import HoverMenuHooks from './examples/HoverMenu.hooks'
import HoverMenuHooksCode from '!!raw-loader!./examples/HoverMenu.hooks'
import HoverFocusMenu from './examples/HoverFocusMenu'
import HoverFocusMenuCode from '!!raw-loader!./examples/HoverFocusMenu'
import HoverFocusMenuHooks from './examples/HoverFocusMenu.hooks'
import HoverFocusMenuHooksCode from '!!raw-loader!./examples/HoverFocusMenu.hooks'
import CustomAnchorHooks from './examples/CustomAnchor.hooks'
import CustomAnchorHooksCode from '!!raw-loader!./examples/CustomAnchor.hooks'
import CascadingHoverMenus from './examples/CascadingHoverMenus'
Expand Down Expand Up @@ -106,6 +110,15 @@ const Root = ({ classes }) => (
hooksExample={<HoverMenuHooks />}
hooksCode={HoverMenuHooksCode}
/>
<Demo
title="Hover/Focus Menu"
headerId="HoverFocus-menu"
example={<HoverFocusMenu />}
code={HoverFocusMenuCode}
hooksExample={<HoverFocusMenuHooks />}
hooksCode={HoverFocusMenuHooksCode}
/>

<Demo
title="Custom Anchor"
headerId="custom-anchor"
Expand Down
38 changes: 38 additions & 0 deletions demo/examples/HoverFocusMenu.hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as React from 'react'
import HoverMenu from 'material-ui-popup-state/HoverMenu'
import MenuItem from '@mui/material/MenuItem'
import Button from '@mui/material/Button'
import {
usePopupState,
bindFocus,
bindHover,
bindMenu,
} from 'material-ui-popup-state/hooks'

const HoverFocusMenu = () => {
const popupState = usePopupState({
variant: 'popover',
popupId: 'demoMenu',
})
return (
<React.Fragment>
<Button
variant="contained"
{...bindHover(popupState)}
{...bindFocus(popupState)}
>
Hover or focus to open Menu
</Button>
<HoverMenu
{...bindMenu(popupState)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<MenuItem onClick={popupState.close}>Cake</MenuItem>
<MenuItem onClick={popupState.close}>Death</MenuItem>
</HoverMenu>
</React.Fragment>
)
}

export default HoverFocusMenu
35 changes: 35 additions & 0 deletions demo/examples/HoverFocusMenu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as React from 'react'
import HoverMenu from 'material-ui-popup-state/HoverMenu'
import MenuItem from '@mui/material/MenuItem'
import Button from '@mui/material/Button'
import PopupState, {
bindHover,
bindFocus,
bindMenu,
} from 'material-ui-popup-state'

const HoverFocusMenu = () => (
<PopupState variant="popover" popupId="demoMenu">
{(popupState) => (
<React.Fragment>
<Button
variant="contained"
{...bindHover(popupState)}
{...bindFocus(popupState)}
>
Hover or focus to open Menu
</Button>
<HoverMenu
{...bindMenu(popupState)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<MenuItem onClick={popupState.close}>Cake</MenuItem>
<MenuItem onClick={popupState.close}>Death</MenuItem>
</HoverMenu>
</React.Fragment>
)}
</PopupState>
)

export default HoverFocusMenu
30 changes: 22 additions & 8 deletions src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type PopupState = {|
open: (eventOrAnchorEl?: SyntheticEvent<any> | HTMLElement) => void,
close: () => void,
toggle: (eventOrAnchorEl?: SyntheticEvent<any> | HTMLElement) => void,
onBlur: (event: SyntheticEvent<any>) => void,
onMouseLeave: (event: SyntheticEvent<any>) => void,
setOpen: (
open: boolean,
Expand Down Expand Up @@ -124,8 +125,8 @@ export function createPopupState({

const newState: $Shape<CoreState> = {
isOpen: true,
hovered: eventType === 'mouseover',
focused: eventType === 'focus',
hovered: eventType === 'mouseover' || hovered,
focused: eventType === 'focus' || focused,
}

if (currentTarget) {
Expand All @@ -152,9 +153,6 @@ export function createPopupState({
case 'touchstart':
setState({ _deferNextClose: true })
return
case 'blur':
if (isElementInPopup((arg: any)?.relatedTarget, popupState)) return
break
}
const doClose = () => {
if (_childPopupState) _childPopupState.close()
Expand All @@ -181,7 +179,22 @@ export function createPopupState({
const onMouseLeave = (event: SyntheticEvent<any>) => {
const relatedTarget: any = (event: any).relatedTarget
if (hovered && !isElementInPopup(relatedTarget, popupState)) {
close(event)
if (focused) {
setState({ hovered: false })
} else {
close(event)
}
}
}

const onBlur = (event: SyntheticEvent<any>) => {
const relatedTarget: any = (event: any).relatedTarget
if (focused && !isElementInPopup(relatedTarget, popupState)) {
if (hovered) {
setState({ focused: false })
} else {
close(event)
}
}
}

Expand All @@ -199,6 +212,7 @@ export function createPopupState({
close,
toggle,
setOpen,
onBlur,
onMouseLeave,
disableAutoFocus: disableAutoFocus ?? Boolean(hovered || focused),
_childPopupState,
Expand Down Expand Up @@ -339,7 +353,7 @@ export function bindHover({
export function bindFocus({
isOpen,
open,
close,
onBlur,
popupId,
variant,
}: PopupState): {|
Expand All @@ -356,7 +370,7 @@ export function bindFocus({
: null,
'aria-haspopup': variant === 'popover' ? true : undefined,
onFocus: open,
onBlur: close,
onBlur,
}
}

Expand Down
116 changes: 111 additions & 5 deletions test/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
bindTrigger,
bindToggle,
bindFocus,
bindHover,
type PopupState,
bindContextMenu,
} from '../src/hooks'
Expand Down Expand Up @@ -248,23 +249,23 @@ describe('usePopupState', () => {
assert.strictEqual(input.prop('aria-controls'), null)
assert.strictEqual(input.prop('aria-haspopup'), true)
assert.strictEqual(input.prop('onFocus'), popupStates[0].open)
assert.strictEqual(input.prop('onBlur'), popupStates[0].close)
assert.strictEqual(input.prop('onBlur'), popupStates[0].onBlur)
assert.strictEqual(popover.prop('id'), 'info')
assert.strictEqual(popover.prop('open'), false)
assert.strictEqual(popover.prop('disableAutoFocus'), true)
assert.strictEqual(popover.prop('disableEnforceFocus'), true)
assert.strictEqual(popover.prop('disableRestoreFocus'), true)
assert.strictEqual(popover.prop('onClose'), popupStates[0].close)

input.prop('onFocus')({ currentTarget: inputRef })
input.prop('onFocus')({ type: 'focus', currentTarget: inputRef })
wrapper.update()
input = wrapper.find(Input)
popover = wrapper.find(Popover)
assert.strictEqual(popupStates[1].isOpen, true)
assert.strictEqual(input.prop('aria-controls'), 'info')
assert.strictEqual(input.prop('aria-haspopup'), true)
assert.strictEqual(input.prop('onFocus'), popupStates[1].open)
assert.strictEqual(input.prop('onBlur'), popupStates[1].close)
assert.strictEqual(input.prop('onBlur'), popupStates[1].onBlur)
assert.strictEqual(popover.prop('id'), 'info')
assert.strictEqual(popover.prop('anchorEl'), inputRef)
assert.strictEqual(popover.prop('open'), true)
Expand All @@ -273,20 +274,125 @@ describe('usePopupState', () => {
assert.strictEqual(popover.prop('disableRestoreFocus'), true)
assert.strictEqual(popover.prop('onClose'), popupStates[1].close)

input.prop('onBlur')({ currentTarget: inputRef })
input.prop('onBlur')({ type: 'blur', currentTarget: inputRef })
wrapper.update()
input = wrapper.find(Input)
popover = wrapper.find(Popover)
assert.strictEqual(popupStates[2].isOpen, false)
assert.strictEqual(input.prop('aria-controls'), null)
assert.strictEqual(input.prop('aria-haspopup'), true)
assert.strictEqual(input.prop('onFocus'), popupStates[2].open)
assert.strictEqual(input.prop('onBlur'), popupStates[2].close)
assert.strictEqual(input.prop('onBlur'), popupStates[2].onBlur)
assert.strictEqual(popover.prop('id'), 'info')
assert.strictEqual(popover.prop('open'), false)
assert.strictEqual(popover.prop('onClose'), popupStates[2].close)
})
})
describe('bindPopover/bindFocus/bindHover', () => {
let inputRef
let input
let popover

const popupStates = []

beforeEach(() => (popupStates.length = 0))

const MenuTest = (): React.Node => {
const popupState = usePopupState({
popupId: 'info',
variant: 'popover',
disableAutoFocus: true,
})
popupStates.push(popupState)
return (
<React.Fragment>
<Input
{...bindFocus(popupState)}
{...bindHover(popupState)}
inputRef={(c) => (inputRef = c)}
/>
<Popover {...bindPopover(popupState)}>Info</Popover>
</React.Fragment>
)
}

for (const events of [
['focus', 'mouseover', 'blur', 'mouseleave'],
['focus', 'blur', 'mouseover', 'mouseleave'],
['mouseover', 'focus', 'mouseleave', 'blur'],
['mouseover', 'focus', 'blur', 'mouseleave'],
['focus', 'mouseover', 'mouseleave', 'blur'],
]) {
it(`works for ${events.join(', ')}`, async function () {
let i = 0

const wrapper = mount(<MenuTest />)
input = wrapper.find(Input)
popover = wrapper.find(Popover)
assert.strictEqual(popupStates[i].isOpen, false)
assert.strictEqual(input.prop('aria-controls'), null)
assert.strictEqual(input.prop('aria-haspopup'), true)
assert.strictEqual(input.prop('onFocus'), popupStates[i].open)
assert.strictEqual(input.prop('onBlur'), popupStates[i].onBlur)
assert.strictEqual(popover.prop('id'), 'info')
assert.strictEqual(popover.prop('open'), false)
assert.strictEqual(popover.prop('disableAutoFocus'), true)
assert.strictEqual(popover.prop('disableEnforceFocus'), true)
assert.strictEqual(popover.prop('disableRestoreFocus'), true)
assert.strictEqual(popover.prop('onClose'), popupStates[i].close)

let hovered = false
let focused = false

for (const type of events) {
let handler = ''
switch (type) {
case 'focus':
focused = true
handler = 'onFocus'
break
case 'blur':
focused = false
handler = 'onBlur'
break
case 'mouseover':
hovered = true
handler = 'onMouseOver'
break
case 'mouseleave':
hovered = false
handler = 'onMouseLeave'
break
}
const open = hovered || focused
input.prop(handler)({ type, currentTarget: inputRef })
wrapper.update()
i++
input = wrapper.find(Input)
popover = wrapper.find(Popover)
assert.strictEqual(popupStates[i].isOpen, open)
assert.strictEqual(input.prop('aria-controls'), open ? 'info' : null)
assert.strictEqual(input.prop('aria-haspopup'), true)
assert.strictEqual(input.prop('onFocus'), popupStates[i].open)
assert.strictEqual(input.prop('onBlur'), popupStates[i].onBlur)
assert.strictEqual(input.prop('onMouseOver'), popupStates[i].open)
assert.strictEqual(
input.prop('onMouseLeave'),
popupStates[i].onMouseLeave
)
if (open) {
assert.strictEqual(popover.prop('id'), 'info')
assert.strictEqual(popover.prop('anchorEl'), open ? inputRef : null)
assert.strictEqual(popover.prop('open'), open)
assert.strictEqual(popover.prop('disableAutoFocus'), true)
assert.strictEqual(popover.prop('disableEnforceFocus'), true)
assert.strictEqual(popover.prop('disableRestoreFocus'), true)
assert.strictEqual(popover.prop('onClose'), popupStates[i].close)
}
}
})
}
})
describe('bindMenu/bindTrigger with anchorRef', () => {
let button
let menu
Expand Down
Loading

0 comments on commit f7172be

Please sign in to comment.