Skip to content

Commit 5f65eb4

Browse files
committed
feat: add anchorRef API for complex use cases
1 parent 635a2c2 commit 5f65eb4

8 files changed

Lines changed: 413 additions & 58 deletions

File tree

README.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ export default withStyles(styles)(PopperPopupState)
195195
`material-ui-popup-state/hooks` exports several helper functions you can use to
196196
connect components easily:
197197

198+
- `anchorRef`: creates a `ref` function to pass to the `anchorEl`
199+
(by default, the `currentTarget` of the mouse event that triggered the popup
200+
is used; only use `anchorRef` if you want a different element to be the anchor).
198201
- `bindMenu`: creates props to control a `Menu` component.
199202
- `bindPopover`: creates props to control a `Popover` component.
200203
- `bindPopper`: creates props to control a `Popper` component.
@@ -258,12 +261,14 @@ the trigger component may declare the same id in an ARIA prop.
258261

259262
An object with the following properties:
260263

261-
- `open(eventOrAnchorEl)`: opens the popup
264+
- `open([eventOrAnchorEl])`: opens the popup
262265
- `close()`: closes the popup
263-
- `toggle(eventOrAnchorEl)`: opens the popup if it is closed, or closes the popup if it is open.
264-
- `setOpen(open, [eventOrAnchorEl])`: sets whether the popup is open. `eventOrAnchorEl` is required if `open` is truthy.
266+
- `toggle([eventOrAnchorEl])`: opens the popup if it is closed, or closes the popup if it is open.
267+
- `setOpen(open, [eventOrAnchorEl])`: sets whether the popup is open.
265268
- `isOpen`: `true`/`false` if the popup is open/closed
266-
- `anchorEl`: the current anchor element (`null` when the popup is closed)
269+
- `anchorEl`: the current anchor element
270+
- `setAnchorEl`: sets the anchor element (the `currentTarget` of the triggering
271+
mouse event is used by default unless you have called `setAnchorEl`)
267272
- `popupId`: the `popupId` prop you passed to `PopupState`
268273
- `variant`: the `variant` prop you passed to `PopupState`
269274

@@ -461,6 +466,9 @@ export default withStyles(styles)(PopperPopupState)
461466
`material-ui-popup-state` exports several helper functions you can use to
462467
connect components easily:
463468

469+
- `anchorRef`: creates a `ref` function to pass to the `anchorEl`
470+
(by default, the `currentTarget` of the mouse event that triggered the popup
471+
is used; only use `anchorRef` if you want a different element to be the anchor).
464472
- `bindMenu`: creates props to control a `Menu` component.
465473
- `bindPopover`: creates props to control a `Popover` component.
466474
- `bindPopper`: creates props to control a `Popper` component.
@@ -518,11 +526,13 @@ the trigger component may declare the same id in an ARIA prop.
518526
The render function. It will be called with an object containing the following
519527
props (exported as the `InjectedProps` type):
520528

521-
- `open(eventOrAnchorEl)`: opens the popup
529+
- `open([eventOrAnchorEl])`: opens the popup
522530
- `close()`: closes the popup
523-
- `toggle(eventOrAnchorEl)`: opens the popup if it is closed, or closes the popup if it is open.
524-
- `setOpen(open, [eventOrAnchorEl])`: sets whether the popup is open. `eventOrAnchorEl` is required if `open` is truthy.
531+
- `toggle([eventOrAnchorEl])`: opens the popup if it is closed, or closes the popup if it is open.
532+
- `setOpen(open, [eventOrAnchorEl])`: sets whether the popup is open.
525533
- `isOpen`: `true`/`false` if the popup is open/closed
526-
- `anchorEl`: the current anchor element (`null` when the popup is closed)
534+
- `anchorEl`: the current anchor element
535+
- `setAnchorEl`: sets the anchor element (the `currentTarget` of the triggering
536+
mouse event is used by default unless you have called `setAnchorEl`)
527537
- `popupId`: the `popupId` prop you passed to `PopupState`
528538
- `variant`: the `variant` prop you passed to `PopupState`

demo/Root.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import HoverMenu from './examples/HoverMenu'
1616
import HoverMenuCode from '!!raw-loader!./examples/HoverMenu'
1717
import HoverMenuHooks from './examples/HoverMenu.hooks'
1818
import HoverMenuHooksCode from '!!raw-loader!./examples/HoverMenu.hooks'
19+
import CustomAnchorHooks from './examples/CustomAnchor.hooks'
20+
import CustomAnchorHooksCode from '!!raw-loader!./examples/CustomAnchor.hooks'
1921
import CascadingHoverMenus from './examples/CascadingHoverMenus'
2022
import CascadingHoverMenusCode from '!!raw-loader!./examples/CascadingHoverMenus'
2123
import CascadingHoverMenusHooks from './examples/CascadingHoverMenus.hooks'
@@ -66,6 +68,12 @@ const Root = ({ classes }) => (
6668
hooksExample={<HoverMenuHooks />}
6769
hooksCode={HoverMenuHooksCode}
6870
/>
71+
<Demo
72+
title="Custom Anchor"
73+
headerId="custom-anchor"
74+
hooksExample={<CustomAnchorHooks />}
75+
hooksCode={CustomAnchorHooksCode}
76+
/>
6977
<Demo
7078
title="Cascading Hover Menus"
7179
headerId="cascading-hover-menus"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as React from 'react'
2+
import IconButton from '@material-ui/core/IconButton'
3+
import Menu from '@material-ui/core/Menu'
4+
import MenuItem from '@material-ui/core/MenuItem'
5+
import Paper from '@material-ui/core/Paper'
6+
import Typography from '@material-ui/core/Typography'
7+
import List from '@material-ui/core/List'
8+
import ListItem from '@material-ui/core/ListItem'
9+
import ListItemText from '@material-ui/core/ListItemText'
10+
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
11+
import MoreVertIcon from '@material-ui/icons/MoreVert'
12+
import PopupState, {
13+
anchorRef,
14+
bindTrigger,
15+
bindMenu,
16+
} from 'material-ui-popup-state'
17+
18+
const CustomAnchor = () => (
19+
<PopupState variant="popover" popupId="demoMenu">
20+
{popupState => (
21+
<div>
22+
<Typography variant="h6">
23+
In this example the menu gets anchored to the <code>ListItem</code>{' '}
24+
instead of the <code>IconButton</code> that triggers it. This is
25+
accomplished by passing the following to the <code>ListItem</code>:
26+
<pre>{'ContainerProps={{ ref: anchorRef(popupState) }}'}</pre>
27+
</Typography>
28+
29+
<Paper>
30+
<List>
31+
<ListItem button ContainerProps={{ ref: anchorRef(popupState) }}>
32+
<ListItemText
33+
primary="Stuff"
34+
secondary="Last Modified Apr 9, 2019"
35+
/>
36+
<ListItemSecondaryAction>
37+
<IconButton {...bindTrigger(popupState)}>
38+
<MoreVertIcon />
39+
</IconButton>
40+
</ListItemSecondaryAction>
41+
</ListItem>
42+
</List>
43+
</Paper>
44+
<Menu
45+
{...bindMenu(popupState)}
46+
getContentAnchorEl={null}
47+
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
48+
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
49+
>
50+
<MenuItem onClick={popupState.close}>Move</MenuItem>
51+
<MenuItem onClick={popupState.close}>Rename</MenuItem>
52+
<MenuItem onClick={popupState.close}>Delete</MenuItem>
53+
</Menu>
54+
</div>
55+
)}
56+
</PopupState>
57+
)
58+
59+
export default CustomAnchor

src/core.js

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ import * as React from 'react'
66
export type Variant = 'popover' | 'popper'
77

88
export type PopupState = {
9-
open: (eventOrAnchorEl: SyntheticEvent<any> | HTMLElement) => void,
9+
open: (eventOrAnchorEl?: SyntheticEvent<any> | HTMLElement) => void,
1010
close: () => void,
11-
toggle: (eventOrAnchorEl: SyntheticEvent<any> | HTMLElement) => void,
11+
toggle: (eventOrAnchorEl?: SyntheticEvent<any> | HTMLElement) => void,
1212
onMouseLeave: (event: SyntheticEvent<any>) => void,
1313
setOpen: (
1414
open: boolean,
1515
eventOrAnchorEl?: SyntheticEvent<any> | HTMLElement
1616
) => void,
1717
isOpen: boolean,
1818
anchorEl: ?HTMLElement,
19+
setAnchorEl: (?HTMLElement) => any,
20+
setAnchorElUsed: boolean,
1921
popupId: ?string,
2022
variant: Variant,
2123
_childPopupState: ?PopupState,
@@ -25,20 +27,24 @@ export type PopupState = {
2527
let eventOrAnchorElWarned: boolean = false
2628

2729
export type CoreState = {
30+
isOpen: boolean,
31+
setAnchorElUsed: boolean,
2832
anchorEl: ?HTMLElement,
2933
hovered: boolean,
3034
_childPopupState: ?PopupState,
3135
}
3236

3337
export const initCoreState: CoreState = {
38+
isOpen: false,
39+
setAnchorElUsed: false,
3440
anchorEl: null,
3541
hovered: false,
3642
_childPopupState: null,
3743
}
3844

3945
export function createPopupState({
40-
state: { anchorEl, hovered, _childPopupState },
41-
setState,
46+
state,
47+
setState: _setState,
4248
parentPopupState,
4349
popupId,
4450
variant,
@@ -49,15 +55,34 @@ export function createPopupState({
4955
variant: Variant,
5056
parentPopupState?: ?PopupState,
5157
}): PopupState {
52-
const toggle = (eventOrAnchorEl: SyntheticEvent<any> | HTMLElement) => {
53-
if (anchorEl) close()
58+
const { isOpen, setAnchorElUsed, anchorEl, hovered, _childPopupState } = state
59+
60+
// use lastState to workaround cases where setState is called multiple times
61+
// in a single render (e.g. because of refs being called multiple times)
62+
let lastState = state
63+
const setState = (nextState: $Shape<CoreState>) => {
64+
if (hasChanges(lastState, nextState)) {
65+
_setState((lastState = { ...lastState, ...nextState }))
66+
}
67+
}
68+
69+
const setAnchorEl = (_anchorEl: ?HTMLElement) => {
70+
setState({ setAnchorElUsed: true, anchorEl: _anchorEl })
71+
}
72+
73+
const toggle = (eventOrAnchorEl?: SyntheticEvent<any> | HTMLElement) => {
74+
if (isOpen) close()
5475
else open(eventOrAnchorEl)
5576
}
5677

57-
const open = (eventOrAnchorEl: SyntheticEvent<any> | HTMLElement) => {
58-
if (!eventOrAnchorElWarned && !eventOrAnchorEl) {
78+
const open = (eventOrAnchorEl?: SyntheticEvent<any> | HTMLElement) => {
79+
if (!eventOrAnchorElWarned && !eventOrAnchorEl && !setAnchorElUsed) {
5980
eventOrAnchorElWarned = true
60-
console.error('eventOrAnchorEl should be defined') // eslint-disable-line no-console
81+
/* eslint-disable no-console */
82+
console.error(
83+
'eventOrAnchorEl should be defined if setAnchorEl is not used'
84+
)
85+
/* eslint-enable no-console */
6186
}
6287
if (parentPopupState) {
6388
if (!parentPopupState.isOpen) return
@@ -66,29 +91,34 @@ export function createPopupState({
6691
if (typeof document === 'object' && document.activeElement) {
6792
document.activeElement.blur()
6893
}
69-
setState({
70-
anchorEl:
71-
eventOrAnchorEl && eventOrAnchorEl.currentTarget
72-
? (eventOrAnchorEl.currentTarget: any)
73-
: (eventOrAnchorEl: any),
74-
hovered: (eventOrAnchorEl: any).type === 'mouseenter',
75-
})
94+
95+
const newState: $Shape<CoreState> = {
96+
isOpen: true,
97+
hovered: eventOrAnchorEl && (eventOrAnchorEl: any).type === 'mouseenter',
98+
}
99+
100+
if (eventOrAnchorEl && eventOrAnchorEl.currentTarget) {
101+
if (!setAnchorElUsed) {
102+
newState.anchorEl = (eventOrAnchorEl.currentTarget: any)
103+
}
104+
} else if (eventOrAnchorEl) {
105+
newState.anchorEl = (eventOrAnchorEl: any)
106+
}
107+
108+
setState(newState)
76109
}
77110

78111
const close = () => {
79112
if (_childPopupState) _childPopupState.close()
80113
if (parentPopupState) parentPopupState._setChildPopupState(null)
81-
setState({ anchorEl: null, hovered: false })
114+
setState({ isOpen: false, hovered: false })
82115
}
83116

84117
const setOpen = (
85118
nextOpen: boolean,
86119
eventOrAnchorEl?: SyntheticEvent<any> | HTMLElement
87120
) => {
88121
if (nextOpen) {
89-
if (!eventOrAnchorEl) {
90-
throw new Error('eventOrAnchorEl must be defined when opening')
91-
}
92122
open(eventOrAnchorEl)
93123
} else close()
94124
}
@@ -104,9 +134,11 @@ export function createPopupState({
104134

105135
const popupState = {
106136
anchorEl,
137+
setAnchorEl,
138+
setAnchorElUsed,
107139
popupId,
108140
variant,
109-
isOpen: anchorEl != null,
141+
isOpen,
110142
open,
111143
close,
112144
toggle,
@@ -119,6 +151,18 @@ export function createPopupState({
119151
return popupState
120152
}
121153

154+
/**
155+
* Creates a ref that sets the anchorEl for the popup.
156+
*
157+
* @param {object} popupState the argument passed to the child function of
158+
* `PopupState`
159+
*/
160+
export function anchorRef({ setAnchorEl }: PopupState): (?HTMLElement) => any {
161+
return (el: ?HTMLElement) => {
162+
if (el) setAnchorEl(el)
163+
}
164+
}
165+
122166
/**
123167
* Creates props for a component that opens the popup when clicked.
124168
*
@@ -133,14 +177,14 @@ export function bindTrigger({
133177
}: PopupState): {
134178
'aria-owns'?: ?string,
135179
'aria-describedby'?: ?string,
136-
'aria-haspopup': true,
180+
'aria-haspopup': ?true,
137181
onClick: (event: SyntheticEvent<any>) => void,
138182
} {
139183
return {
140184
[variant === 'popover' ? 'aria-owns' : 'aria-describedby']: isOpen
141185
? popupId
142186
: null,
143-
'aria-haspopup': true,
187+
'aria-haspopup': variant === 'popover' ? true : undefined,
144188
onClick: open,
145189
}
146190
}
@@ -159,14 +203,14 @@ export function bindToggle({
159203
}: PopupState): {
160204
'aria-owns'?: ?string,
161205
'aria-describedby'?: ?string,
162-
'aria-haspopup': true,
206+
'aria-haspopup': ?true,
163207
onClick: (event: SyntheticEvent<any>) => void,
164208
} {
165209
return {
166210
[variant === 'popover' ? 'aria-owns' : 'aria-describedby']: isOpen
167211
? popupId
168212
: null,
169-
'aria-haspopup': true,
213+
'aria-haspopup': variant === 'popover' ? true : undefined,
170214
onClick: toggle,
171215
}
172216
}
@@ -186,15 +230,15 @@ export function bindHover({
186230
}: PopupState): {
187231
'aria-owns'?: ?string,
188232
'aria-describedby'?: ?string,
189-
'aria-haspopup': true,
233+
'aria-haspopup': ?true,
190234
onMouseEnter: (event: SyntheticEvent<any>) => any,
191235
onMouseLeave: (event: SyntheticEvent<any>) => any,
192236
} {
193237
return {
194238
[variant === 'popover' ? 'aria-owns' : 'aria-describedby']: isOpen
195239
? popupId
196240
: null,
197-
'aria-haspopup': true,
241+
'aria-haspopup': variant === 'popover' ? true : undefined,
198242
onMouseEnter: open,
199243
onMouseLeave,
200244
}
@@ -284,3 +328,12 @@ function isAncestor(parent: ?Element, child: ?Element): boolean {
284328
}
285329
return false
286330
}
331+
332+
function hasChanges(state: CoreState, nextState: $Shape<CoreState>): boolean {
333+
for (let key in nextState) {
334+
if (state.hasOwnProperty(key) && state[key] !== nextState[key]) {
335+
return true
336+
}
337+
}
338+
return false
339+
}

0 commit comments

Comments
 (0)