Skip to content

Commit b9f0d1e

Browse files
authored
fix(Popup): placement (#5782)
* fix: popup placement * fix: add test * fix: typing * fix: typing
1 parent 39e89c0 commit b9f0d1e

File tree

10 files changed

+205
-43
lines changed

10 files changed

+205
-43
lines changed

.changeset/ten-sloths-play.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@ultraviolet/ui": patch
3+
---
4+
5+
All `Popup` components (`Popover`, `Tooltip`, `Menu`): 4 new positions `auto-` to have auto-placement but give priority to a direction. For instance, `auto-bottom` will try to place the popup beneath the disclosure first, if there is not enough place it will try top, then left, then right.
6+
The priorities are :
7+
- `auto-bottom` : bottom > top > left > right
8+
- `auto-left` : left > right > top > bottom
9+
- `auto-right` : right > left > top > bottom
10+
- `auto` and `auto-top` : top > bottom > left > right
11+
12+
**BREAKING CHANGE**
13+
`Menu`: prop `noShrink` renamed `shrink` with opposite behavior

packages/ui/src/components/Menu/MenuContent.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const Menu = forwardRef(
4646
children,
4747
disclosure,
4848
hasArrow = false,
49-
placement = 'bottom',
49+
placement = 'auto-bottom',
5050
className,
5151
'data-testid': dataTestId,
5252
maxHeight,
@@ -56,7 +56,7 @@ export const Menu = forwardRef(
5656
align,
5757
searchable = false,
5858
footer,
59-
noShrink = false,
59+
shrink,
6060
style,
6161
}: MenuProps,
6262
ref: Ref<HTMLButtonElement | null>,
@@ -198,7 +198,7 @@ export const Menu = forwardRef(
198198
if (indexOfCurrent > 0) {
199199
listItem[indexOfCurrent - 1].focus()
200200
} else {
201-
listItem[listItem.length - 1].focus()
201+
listItem.at(-1)?.focus()
202202
}
203203
} else if (event.key === 'ArrowLeft' && triggerMethod === 'hover') {
204204
disclosureRef.current?.focus()
@@ -210,15 +210,15 @@ export const Menu = forwardRef(
210210
}
211211

212212
useEffect(() => {
213-
if (disclosureRef.current && placement === 'bottom' && !noShrink) {
213+
if (disclosureRef.current && placement === 'bottom' && shrink) {
214214
const disclosureRect = disclosureRef.current.getBoundingClientRect()
215215
const disclosureBottom = disclosureRect.bottom
216216
const targetSize = portalTarget.getBoundingClientRect().bottom
217217
const availableSpace =
218218
targetSize - disclosureBottom - SPACE_DISCLOSURE_POPUP
219219
setPopupMaxHeight(`${availableSpace}px`)
220220
}
221-
}, [isVisible, portalTarget, disclosureRef, placement, noShrink])
221+
}, [isVisible, portalTarget, disclosureRef, placement, shrink])
222222

223223
return (
224224
<Popup
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { StoryFn } from '@storybook/react-vite'
2+
import { DotsHorizontalIcon } from '@ultraviolet/icons'
3+
import { Button } from '../../index'
4+
import { Stack } from '../../Stack'
5+
import { Menu } from '..'
6+
7+
export const DefaultDisclosure = (
8+
<Button sentiment="neutral" size="small" variant="ghost">
9+
<DotsHorizontalIcon />
10+
</Button>
11+
)
12+
13+
export const Placement: StoryFn<typeof Menu> = ({
14+
disclosure = DefaultDisclosure,
15+
...props
16+
}) => (
17+
<>
18+
<Stack alignItems="end" justifyContent="left" width="100%">
19+
<>
20+
Placement = &quot;auto-right&quot;: not enough room on the right, so
21+
second priority (left)
22+
<Menu disclosure={disclosure} placement="auto-right">
23+
<Menu.Item borderless key="borderless">
24+
Information with a very long name. Lorem ipsum dolor sit amet,
25+
consectetur adipiscing elit.
26+
</Menu.Item>
27+
<Menu.Item borderless key="power on">
28+
Power on
29+
</Menu.Item>
30+
</Menu>
31+
</>
32+
<>
33+
Placement = &quot;right&quot;: not enough room on the right but force
34+
placement on the right
35+
<Menu disclosure={disclosure} placement="right">
36+
<Menu.Item borderless key="borderless">
37+
Information with a very long name. Lorem ipsum dolor sit amet,
38+
consectetur adipiscing elit.
39+
</Menu.Item>
40+
<Menu.Item borderless key="power on">
41+
Power on
42+
</Menu.Item>
43+
</Menu>
44+
</>
45+
</Stack>
46+
<Stack
47+
alignItems="center"
48+
justifyContent="center"
49+
style={{
50+
marginTop: 100,
51+
}}
52+
>
53+
You can play with the placement here using storybook controls
54+
<Menu disclosure={disclosure} {...props}>
55+
<Menu.Item borderless key="borderless">
56+
Information with a very long name. Lorem ipsum dolor sit amet,
57+
consectetur adipiscing elit.
58+
</Menu.Item>
59+
<Menu.Item borderless key="power on">
60+
Power on
61+
</Menu.Item>
62+
</Menu>
63+
</Stack>
64+
</>
65+
)
66+
67+
Placement.parameters = {
68+
docs: {
69+
description: {
70+
story: `You can choose to place automatically the menu or manually. There are for manual placements: "top", "bottom", "left" and "right".
71+
There are five modes of auto-placement: "auto", "auto-left", "auto-right", "auto-top", and "auto-bottom". Those "auto-" allow to give a prioriry to the direction. For instance, auto-bottom will try to place the popup beneath the disclosure first, if there is not enough place it will try top, then left, then right.
72+
The priorities are :
73+
<ul>
74+
<li> auto-bottom : bottom > top > left > right</li>
75+
<li> auto-left : left > right > top > bottom</li>
76+
<li> auto-right : right > left > top > bottom</li>
77+
<li> auto and auto-top : top > bottom > left > right</li>
78+
</ul>
79+
`,
80+
},
81+
},
82+
}

packages/ui/src/components/Menu/__stories__/Shrink.stories.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,24 @@ export const Shrink: StoryFn<typeof Menu> = () => (
1414
<Menu.Item>item</Menu.Item>
1515
</Menu>
1616

17-
<Menu disclosure={<Button>noShrink=true</Button>} noShrink>
17+
<Menu
18+
disclosure={<Button>default with placement bottom</Button>}
19+
placement="bottom"
20+
>
21+
<Menu.Item>item</Menu.Item>
22+
<Menu.Item>item</Menu.Item>
23+
<Menu.Item>item</Menu.Item>
24+
<Menu.Item>item</Menu.Item>
25+
<Menu.Item>item</Menu.Item>
26+
27+
<Menu.Item>item</Menu.Item>
28+
</Menu>
29+
30+
<Menu
31+
disclosure={<Button>shrink=true and placement bottom</Button>}
32+
placement="bottom"
33+
shrink
34+
>
1835
<Menu.Item>item</Menu.Item>
1936
<Menu.Item>item</Menu.Item>
2037
<Menu.Item>item</Menu.Item>
@@ -30,7 +47,7 @@ Shrink.parameters = {
3047
docs: {
3148
description: {
3249
story:
33-
'When the menu is at the bottom of a page (not possible to scroll down further), with `placement = "bottom"`, it will shrink so that it does not cause overflow. It is possible to remove this feature using prop `noShrink`',
50+
'When the menu is at the bottom of a page (not possible to scroll down further), with `placement = "bottom"`, it can shrink so that it does not cause overflow. Activate this feature using prop `shrink`',
3451
},
3552
},
3653
}

packages/ui/src/components/Menu/__stories__/index.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export { Borderless } from './Borderless.stories'
3131
export { Group } from './Group.stories'
3232
export { Active } from './Active.stories'
3333
export { Searchable } from './Searchable.stories'
34+
export { Placement } from './Placement.stories'
3435
export { LongMenu } from './LongMenu.stories'
3536
export { TriggerMethod } from './TriggerMethod.stories'
3637
export { WithModal } from './WithModal.stories'

packages/ui/src/components/Menu/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ export type MenuProps = {
5353
footer?: ReactNode
5454
placement?: Exclude<ComponentProps<typeof Popup>['placement'], 'nested-menu'>
5555
/**
56-
* When set to true, the menu does not shrink (height) to avoid overflow on the page
56+
* When set to true, the menu shrinks (height) to avoid overflow on the page
5757
*/
58-
noShrink?: boolean
58+
shrink?: boolean
5959
} & Pick<
6060
ComponentProps<typeof Popup>,
6161
'dynamicDomRendering' | 'align' | 'style'

packages/ui/src/components/Popup/__tests__/index.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,17 @@ describe('popup', () => {
141141
})
142142

143143
describe(`defined placement`, () => {
144-
;['top', 'left', 'right', 'bottom'].forEach(placement => {
144+
;[
145+
'top',
146+
'left',
147+
'right',
148+
'bottom',
149+
'auto',
150+
'auto-top',
151+
'auto-bottom',
152+
'auto-left',
153+
'auto-right',
154+
].forEach(placement => {
145155
test(`should renders Popup with placement ${placement}`, async () => {
146156
renderWithTheme(
147157
<Popup

packages/ui/src/components/Popup/helpers.ts

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import type { RefObject } from 'react'
22

3-
export type PopupPlacement =
4-
| 'top'
5-
| 'right'
6-
| 'bottom'
7-
| 'left'
3+
const PLACEMENTS = ['top', 'right', 'bottom', 'left', 'nested-menu'] as const
4+
5+
type Placements = (typeof PLACEMENTS)[number]
6+
7+
type AddPrefixToUnion<T extends string, P extends string> = T extends string
8+
? `${P}${T}`
9+
: never
10+
11+
type AutoPlacements =
12+
| AddPrefixToUnion<Exclude<Placements, 'nested-menu'>, 'auto-'>
813
| 'auto'
9-
| 'nested-menu'
14+
15+
export type PopupPlacement = AutoPlacements | Placements
1016
export type PopupAlign = 'start' | 'center'
17+
18+
const isGenericPlacement = (
19+
placement: PopupPlacement,
20+
): placement is Placements => PLACEMENTS.includes(placement as Placements)
21+
1122
export const DEFAULT_ARROW_WIDTH = 8 // in px
1223
const SPACE = 4 // in px
1324
const TOTAL_USED_SPACE = 0 // in px
@@ -28,6 +39,23 @@ type ComputePlacementTypes = {
2839
offsetParentRect: DOMRect
2940
offsetParent: Element
3041
isNestedMenu?: boolean
42+
autoPlacement?: AutoPlacements
43+
}
44+
// Depending on the auto-placement preferences, change the placements hierarchy
45+
46+
const getOrderOfPlacement = (autoPlacement: AutoPlacements) => {
47+
if (autoPlacement === 'auto-bottom') {
48+
return ['bottom', 'top', 'left', 'right'] as const
49+
}
50+
if (autoPlacement === 'auto-left') {
51+
return ['left', 'right', 'top', 'bottom'] as const
52+
}
53+
54+
if (autoPlacement === 'auto-right') {
55+
return ['right', 'left', 'top', 'bottom'] as const
56+
}
57+
58+
return ['top', 'bottom', 'left', 'right'] as const
3159
}
3260

3361
/**
@@ -40,6 +68,7 @@ const computePlacement = ({
4068
offsetParent,
4169
popupPortalTarget,
4270
isNestedMenu,
71+
autoPlacement,
4372
}: ComputePlacementTypes) => {
4473
const {
4574
top: childrenTop,
@@ -48,6 +77,8 @@ const computePlacement = ({
4877
width: childrenWidth,
4978
} = childrenStructuredRef
5079

80+
const orderOfPlacement = getOrderOfPlacement(autoPlacement ?? 'auto')
81+
5182
const { top: parentTop, left: parentLeft } = offsetParentRect
5283

5384
const isPopupPortalTargetBody =
@@ -76,24 +107,31 @@ const computePlacement = ({
76107
return 'right'
77108
}
78109

79-
if (overloadedChildrenTop - popupHeight - TOTAL_USED_SPACE < 0) {
80-
return 'bottom'
110+
const conditionsOfPlacement = {
111+
bottom:
112+
window.innerHeight - overloadedChildrenTop - TOTAL_USED_SPACE >=
113+
popupHeight,
114+
left: overloadedChildrenLeft - TOTAL_USED_SPACE >= popupWidth,
115+
right:
116+
window.innerWidth - overloadedChildrenLeft - TOTAL_USED_SPACE >=
117+
popupWidth,
118+
top: overloadedChildrenTop - popupHeight - TOTAL_USED_SPACE >= 0,
81119
}
82120

83-
if (overloadedChildrenLeft - popupWidth - TOTAL_USED_SPACE < 0) {
84-
return 'right'
121+
if (conditionsOfPlacement[orderOfPlacement[0]]) {
122+
return orderOfPlacement[0]
85123
}
86124

87-
if (
88-
overloadedChildrenRight + popupWidth + TOTAL_USED_SPACE >
89-
window.innerWidth
90-
) {
91-
return 'left'
125+
if (conditionsOfPlacement[orderOfPlacement[1]]) {
126+
return orderOfPlacement[1]
92127
}
93128

94-
return 'top'
95-
}
129+
if (conditionsOfPlacement[orderOfPlacement[2]]) {
130+
return orderOfPlacement[2]
131+
}
96132

133+
return orderOfPlacement[3]
134+
}
97135
/**
98136
* This function will check if the offset parent is usable for popup positioning
99137
* If not it will loop and search for a compatible parent until document.body is reached
@@ -135,7 +173,7 @@ const findOffsetParent = (element: RefObject<HTMLDivElement>) => {
135173
* @param popupStructuredRef the rect of the popup, the popup itself
136174
*/
137175
const getPopupOverflowFromParent = (
138-
position: 'top' | 'right' | 'bottom' | 'left' | 'nested-menu',
176+
position: Placements,
139177
offsetParentRect: { top: number; left: number; right: number },
140178
childrenRect: DOMRect,
141179
popupStructuredRef: DOMRect,
@@ -244,16 +282,16 @@ export const computePositions = ({
244282
popupRef.current as HTMLDivElement
245283
).getBoundingClientRect()
246284

247-
const placementBasedOnWindowSize =
248-
placement === 'auto'
249-
? computePlacement({
250-
childrenStructuredRef: childrenRect,
251-
offsetParent,
252-
offsetParentRect,
253-
popupPortalTarget,
254-
popupStructuredRef,
255-
})
256-
: placement
285+
const placementBasedOnWindowSize = isGenericPlacement(placement)
286+
? placement
287+
: computePlacement({
288+
autoPlacement: placement,
289+
childrenStructuredRef: childrenRect,
290+
offsetParent,
291+
offsetParentRect,
292+
popupPortalTarget,
293+
popupStructuredRef,
294+
})
257295

258296
const {
259297
top: childrenTop,

packages/ui/src/components/Tabs/TabMenu.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ export const TabMenu = forwardRef(
5050
</button>
5151
}
5252
id={id}
53-
portalTarget={document.body}
54-
ref={ref} // We need to attach it to the body to avoid overflow issues
53+
placement="bottom"
54+
portalTarget={document.body} // We need to attach it to the body to avoid overflow issues
55+
ref={ref}
5556
visible={visible}
5657
>
5758
{children}

0 commit comments

Comments
 (0)