Skip to content

Commit aa974ca

Browse files
committed
Use popper to position the root menu
1 parent d982040 commit aa974ca

File tree

4 files changed

+95
-87
lines changed

4 files changed

+95
-87
lines changed

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@
131131
"typescript": "^4.0.2"
132132
},
133133
"dependencies": {
134+
"@popperjs/core": "^2.5.3",
134135
"immer": "^7.0.9",
135-
"react": "^16.13.1"
136+
"react": "^16.13.1",
137+
"react-popper": "^2.2.3"
136138
}
137139
}

src/react-headless-nested-menu.tsx

+54-67
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import React from 'react'
1+
import React, { useState } from 'react'
22
import produce, { Draft } from 'immer'
3-
import getEventPath, { handleRefs, getDirection } from './utils'
3+
import { usePopper } from 'react-popper'
4+
import { Placement } from '@popperjs/core'
5+
import { Options } from '@popperjs/core/lib/modifiers/offset'
46

7+
import getEventPath, { handleRefs, getDirection } from './utils'
58
export interface MenuItem {
69
id: string
710
label: string
@@ -35,8 +38,6 @@ interface ClosePathAction {
3538

3639
type Action = ToggleAction | OpenPathAction | ClosePathAction
3740

38-
type Placement = 'top' | 'bottom' | 'start' | 'end'
39-
4041
/**
4142
* @ignore
4243
*/
@@ -45,7 +46,7 @@ interface NestedMenuState {
4546
isOpen: boolean
4647
currentPath: string[]
4748
currentPathItems: MenuItem[]
48-
placement: Placement
49+
placement?: Placement
4950
}
5051

5152
/**
@@ -83,6 +84,7 @@ interface NestedMenuProps {
8384
isOpen?: boolean
8485
defaultOpenPath?: string[]
8586
placement?: Placement
87+
offset?: Options['offset']
8688
}
8789

8890
// interface HitAreaProps {
@@ -98,7 +100,8 @@ export const useNestedMenu = ({
98100
items = [],
99101
isOpen = false,
100102
defaultOpenPath = [],
101-
placement = 'end',
103+
placement,
104+
offset,
102105
}: NestedMenuProps) => {
103106
const [state, dispatch] = React.useReducer(reducer, {
104107
items,
@@ -171,15 +174,30 @@ export const useNestedMenu = ({
171174
})
172175

173176
const menuRefs = React.useRef<{ [key: string]: HTMLElement }>({})
174-
175-
const getMenuProps = (item?: MenuItem) => ({
176-
key: item?.id || 'root',
177-
ref: handleRefs((itemNode) => {
178-
if (itemNode) {
179-
menuRefs.current[item?.id || 'root'] = itemNode
177+
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null)
178+
179+
const getMenuProps = (item?: MenuItem) => {
180+
if (item) {
181+
return {
182+
key: item.id,
183+
ref: handleRefs((itemNode) => {
184+
if (itemNode) {
185+
menuRefs.current[item.id] = itemNode
186+
}
187+
}),
188+
style: getMenuOffsetStyles(item),
180189
}
181-
}),
182-
})
190+
} else {
191+
return {
192+
key: 'root',
193+
ref: handleRefs((itemNode) => {
194+
setPopperElement(itemNode)
195+
}),
196+
style: styles.popper,
197+
...attributes.popper,
198+
}
199+
}
200+
}
183201

184202
const itemRefs = React.useRef<{ [key: string]: HTMLElement }>({})
185203

@@ -192,63 +210,16 @@ export const useNestedMenu = ({
192210
}),
193211
})
194212

195-
const getMenuOffsetStyles = (currentItem?: MenuItem) => {
196-
const item = currentItem ? itemRefs.current[currentItem.id] : null
197-
const button = toggleButtonRef.current as HTMLElement
213+
const getMenuOffsetStyles = (currentItem?: MenuItem): React.CSSProperties => {
214+
if (!currentItem) return {}
215+
const item = itemRefs.current[currentItem.id]
198216

199217
const dir = getDirection()
200-
const rootXEnd =
201-
dir === 'ltr'
202-
? button.getBoundingClientRect().right
203-
: window.innerWidth - button.getBoundingClientRect().left
204-
205-
let vertical: string = 'top'
206-
let horizontal: string = dir === 'ltr' ? 'left' : 'right'
207-
let verticalValue = item ? 0 : button.getBoundingClientRect().top
208-
let horizontalValue = item ? item.getBoundingClientRect().width : rootXEnd
209-
210-
if (dir === 'ltr') {
211-
if (placement === 'top') {
212-
vertical = item ? 'top' : 'bottom'
213-
verticalValue = item ? 0 : window.innerHeight - button.getBoundingClientRect().top
214-
horizontalValue = item
215-
? item.getBoundingClientRect().width
216-
: button.getBoundingClientRect().left
217-
} else if (placement === 'bottom') {
218-
verticalValue = item ? 0 : button.getBoundingClientRect().bottom
219-
horizontalValue = item
220-
? item.getBoundingClientRect().width
221-
: button.getBoundingClientRect().left
222-
} else if (placement === 'start') {
223-
horizontal = item ? 'left' : 'right'
224-
horizontalValue = item
225-
? item.getBoundingClientRect().width
226-
: window.innerWidth - button.getBoundingClientRect().left
227-
}
228-
} else {
229-
if (placement === 'top') {
230-
vertical = item ? 'top' : 'bottom'
231-
horizontal = 'right'
232-
verticalValue = item ? 0 : window.innerHeight - button.getBoundingClientRect().top
233-
horizontalValue = item
234-
? item.getBoundingClientRect().width
235-
: window.innerWidth - button.getBoundingClientRect().right
236-
} else if (placement === 'bottom') {
237-
verticalValue = item ? 0 : button.getBoundingClientRect().bottom
238-
horizontalValue = item
239-
? item.getBoundingClientRect().width
240-
: window.innerWidth - button.getBoundingClientRect().right
241-
} else if (placement === 'start') {
242-
horizontal = item ? 'right' : 'left'
243-
horizontalValue = item
244-
? item.getBoundingClientRect().width
245-
: button.getBoundingClientRect().right
246-
}
247-
}
248218

249219
return {
250-
[vertical]: verticalValue,
251-
[horizontal]: horizontalValue,
220+
position: 'absolute',
221+
top: 0,
222+
[dir === 'ltr' ? 'left' : 'right']: item.clientWidth,
252223
}
253224
}
254225

@@ -294,6 +265,22 @@ export const useNestedMenu = ({
294265
const anchorRef = React.useRef<HTMLElement>()
295266
const menuRef = React.useRef<HTMLElement>()
296267

268+
const { styles, attributes } = usePopper(toggleButtonRef.current, popperElement, {
269+
placement: 'right-start',
270+
modifiers: [
271+
{
272+
name: 'offset',
273+
options: {
274+
offset: offset,
275+
},
276+
},
277+
{
278+
name: 'flip',
279+
enabled: false,
280+
},
281+
],
282+
})
283+
297284
return {
298285
getToggleButtonProps,
299286
getMenuProps,

test/react-headless-nested-menu.test.tsx

+13-19
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const simpleList = [
1515
id: 'ckeri9fsh00023g65zjdr0wdx',
1616
label: 'Email',
1717
},
18-
];
18+
]
1919

2020
const Basic: React.FC = () => {
2121
const {
@@ -27,7 +27,7 @@ const Basic: React.FC = () => {
2727
getOpenTriggerProps,
2828
toggleMenu,
2929
isSubMenuOpen,
30-
isOpen
30+
isOpen,
3131
} = useNestedMenu({
3232
items: simpleList,
3333
})
@@ -41,9 +41,7 @@ const Basic: React.FC = () => {
4141
toggleMenu()
4242
}}
4343
>
44-
<div
45-
className={isSubMenuOpen(item) ? 'sub-open': 'sub-closed'}
46-
>
44+
<div className={isSubMenuOpen(item) ? 'sub-open' : 'sub-closed'}>
4745
{item.label}
4846
{item.subMenu && <span className="chevron" />}
4947
</div>
@@ -55,10 +53,6 @@ const Basic: React.FC = () => {
5553
const renderMenu = (items: Items, parentItem?: Items[0]) => (
5654
<div
5755
{...getMenuProps(parentItem)}
58-
style={{
59-
position: 'absolute',
60-
...getMenuOffsetStyles(parentItem),
61-
}}
6256
className={typeof parentItem === 'undefined' ? 'root' : 'sub'}
6357
{...getCloseTriggerProps('onPointerLeave', parentItem)}
6458
>
@@ -91,15 +85,15 @@ const Basic: React.FC = () => {
9185

9286
describe('Hook', () => {
9387
it('renders basic menu', () => {
94-
const { container, queryByText } = render(<Basic />);
95-
const btn = queryByText('Toggle');
96-
expect(btn).toBeDefined();
97-
expect(queryByText('Name')).toBe(null);
98-
expect(queryByText('Photo')).toBe(null);
99-
expect(queryByText('Email')).toBe(null);
100-
fireEvent.click(btn!);
101-
expect(queryByText('Name')).toBeDefined();
102-
expect(queryByText('Photo')).toBeDefined();
103-
expect(queryByText('Email')).toBeDefined();
88+
const { container, queryByText } = render(<Basic />)
89+
const btn = queryByText('Toggle')
90+
expect(btn).toBeDefined()
91+
expect(queryByText('Name')).toBe(null)
92+
expect(queryByText('Photo')).toBe(null)
93+
expect(queryByText('Email')).toBe(null)
94+
fireEvent.click(btn!)
95+
expect(queryByText('Name')).toBeDefined()
96+
expect(queryByText('Photo')).toBeDefined()
97+
expect(queryByText('Email')).toBeDefined()
10498
})
10599
})

yarn.lock

+25
Original file line numberDiff line numberDiff line change
@@ -1349,6 +1349,11 @@
13491349
dependencies:
13501350
"@types/node" ">= 8"
13511351

1352+
"@popperjs/core@^2.5.3":
1353+
version "2.5.3"
1354+
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.5.3.tgz#4982b0b66b7a4cf949b86f5d25a8cf757d3cfd9d"
1355+
integrity sha512-RFwCobxsvZ6j7twS7dHIZQZituMIDJJNHS/qY6iuthVebxS3zhRY+jaC2roEKiAYaVuTcGmX6Luc6YBcf6zJVg==
1356+
13521357
"@rollup/pluginutils@^3.0.9", "@rollup/pluginutils@^3.1.0":
13531358
version "3.1.0"
13541359
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
@@ -7289,11 +7294,24 @@ react-dom@^16.13.1:
72897294
prop-types "^15.6.2"
72907295
scheduler "^0.19.1"
72917296

7297+
react-fast-compare@^3.0.1:
7298+
version "3.2.0"
7299+
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
7300+
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
7301+
72927302
react-is@^16.12.0, react-is@^16.8.1:
72937303
version "16.13.1"
72947304
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
72957305
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
72967306

7307+
react-popper@^2.2.3:
7308+
version "2.2.3"
7309+
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.3.tgz#33d425fa6975d4bd54d9acd64897a89d904b9d97"
7310+
integrity sha512-mOEiMNT1249js0jJvkrOjyHsGvqcJd3aGW/agkiMoZk3bZ1fXN1wQszIQSjHIai48fE67+zwF8Cs+C4fWqlfjw==
7311+
dependencies:
7312+
react-fast-compare "^3.0.1"
7313+
warning "^4.0.2"
7314+
72977315
react@^16.13.1:
72987316
version "16.13.1"
72997317
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
@@ -9153,6 +9171,13 @@ walker@^1.0.7, walker@~1.0.5:
91539171
dependencies:
91549172
makeerror "1.0.x"
91559173

9174+
warning@^4.0.2:
9175+
version "4.0.3"
9176+
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
9177+
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
9178+
dependencies:
9179+
loose-envify "^1.0.0"
9180+
91569181
wcwidth@^1.0.0:
91579182
version "1.0.1"
91589183
resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8"

0 commit comments

Comments
 (0)