Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/ten-sloths-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@ultraviolet/ui": patch
---

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.
The priorities are :
- `auto-bottom` : bottom > top > left > right
- `auto-left` : left > right > top > bottom
- `auto-right` : right > left > top > bottom
- `auto` and `auto-top` : top > bottom > left > right

**BREAKING CHANGE**
`Menu`: prop `noShrink` renamed `shrink` with opposite behavior
10 changes: 5 additions & 5 deletions packages/ui/src/components/Menu/MenuContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const Menu = forwardRef(
children,
disclosure,
hasArrow = false,
placement = 'bottom',
placement = 'auto-bottom',
className,
'data-testid': dataTestId,
maxHeight,
Expand All @@ -56,7 +56,7 @@ export const Menu = forwardRef(
align,
searchable = false,
footer,
noShrink = false,
shrink,
style,
}: MenuProps,
ref: Ref<HTMLButtonElement | null>,
Expand Down Expand Up @@ -198,7 +198,7 @@ export const Menu = forwardRef(
if (indexOfCurrent > 0) {
listItem[indexOfCurrent - 1].focus()
} else {
listItem[listItem.length - 1].focus()
listItem.at(-1)?.focus()
}
} else if (event.key === 'ArrowLeft' && triggerMethod === 'hover') {
disclosureRef.current?.focus()
Expand All @@ -210,15 +210,15 @@ export const Menu = forwardRef(
}

useEffect(() => {
if (disclosureRef.current && placement === 'bottom' && !noShrink) {
if (disclosureRef.current && placement === 'bottom' && shrink) {
const disclosureRect = disclosureRef.current.getBoundingClientRect()
const disclosureBottom = disclosureRect.bottom
const targetSize = portalTarget.getBoundingClientRect().bottom
const availableSpace =
targetSize - disclosureBottom - SPACE_DISCLOSURE_POPUP
setPopupMaxHeight(`${availableSpace}px`)
}
}, [isVisible, portalTarget, disclosureRef, placement, noShrink])
}, [isVisible, portalTarget, disclosureRef, placement, shrink])

return (
<Popup
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { StoryFn } from '@storybook/react-vite'
import { DotsHorizontalIcon } from '@ultraviolet/icons'
import { Button } from '../../index'
import { Stack } from '../../Stack'
import { Menu } from '..'

export const DefaultDisclosure = (
<Button sentiment="neutral" size="small" variant="ghost">
<DotsHorizontalIcon />
</Button>
)

export const Placement: StoryFn<typeof Menu> = ({
disclosure = DefaultDisclosure,
...props
}) => (
<>
<Stack alignItems="end" justifyContent="left" width="100%">
<>
Placement = &quot;auto-right&quot;: not enough room on the right, so
second priority (left)
<Menu disclosure={disclosure} placement="auto-right">
<Menu.Item borderless key="borderless">
Information with a very long name. Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
</Menu.Item>
<Menu.Item borderless key="power on">
Power on
</Menu.Item>
</Menu>
</>
<>
Placement = &quot;right&quot;: not enough room on the right but force
placement on the right
<Menu disclosure={disclosure} placement="right">
<Menu.Item borderless key="borderless">
Information with a very long name. Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
</Menu.Item>
<Menu.Item borderless key="power on">
Power on
</Menu.Item>
</Menu>
</>
</Stack>
<Stack
alignItems="center"
justifyContent="center"
style={{
marginTop: 100,
}}
>
You can play with the placement here using storybook controls
<Menu disclosure={disclosure} {...props}>
<Menu.Item borderless key="borderless">
Information with a very long name. Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
</Menu.Item>
<Menu.Item borderless key="power on">
Power on
</Menu.Item>
</Menu>
</Stack>
</>
)

Placement.parameters = {
docs: {
description: {
story: `You can choose to place automatically the menu or manually. There are for manual placements: "top", "bottom", "left" and "right".
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.
The priorities are :
<ul>
<li> auto-bottom : bottom > top > left > right</li>
<li> auto-left : left > right > top > bottom</li>
<li> auto-right : right > left > top > bottom</li>
<li> auto and auto-top : top > bottom > left > right</li>
</ul>
`,
},
},
}
21 changes: 19 additions & 2 deletions packages/ui/src/components/Menu/__stories__/Shrink.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,24 @@ export const Shrink: StoryFn<typeof Menu> = () => (
<Menu.Item>item</Menu.Item>
</Menu>

<Menu disclosure={<Button>noShrink=true</Button>} noShrink>
<Menu
disclosure={<Button>default with placement bottom</Button>}
placement="bottom"
>
<Menu.Item>item</Menu.Item>
<Menu.Item>item</Menu.Item>
<Menu.Item>item</Menu.Item>
<Menu.Item>item</Menu.Item>
<Menu.Item>item</Menu.Item>

<Menu.Item>item</Menu.Item>
</Menu>

<Menu
disclosure={<Button>shrink=true and placement bottom</Button>}
placement="bottom"
shrink
>
<Menu.Item>item</Menu.Item>
<Menu.Item>item</Menu.Item>
<Menu.Item>item</Menu.Item>
Expand All @@ -30,7 +47,7 @@ Shrink.parameters = {
docs: {
description: {
story:
'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`',
'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`',
},
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export { Borderless } from './Borderless.stories'
export { Group } from './Group.stories'
export { Active } from './Active.stories'
export { Searchable } from './Searchable.stories'
export { Placement } from './Placement.stories'
export { LongMenu } from './LongMenu.stories'
export { TriggerMethod } from './TriggerMethod.stories'
export { WithModal } from './WithModal.stories'
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/Menu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ export type MenuProps = {
footer?: ReactNode
placement?: Exclude<ComponentProps<typeof Popup>['placement'], 'nested-menu'>
/**
* When set to true, the menu does not shrink (height) to avoid overflow on the page
* When set to true, the menu shrinks (height) to avoid overflow on the page
*/
noShrink?: boolean
shrink?: boolean
} & Pick<
ComponentProps<typeof Popup>,
'dynamicDomRendering' | 'align' | 'style'
Expand Down
12 changes: 11 additions & 1 deletion packages/ui/src/components/Popup/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,17 @@ describe('popup', () => {
})

describe(`defined placement`, () => {
;['top', 'left', 'right', 'bottom'].forEach(placement => {
;[
'top',
'left',
'right',
'bottom',
'auto',
'auto-top',
'auto-bottom',
'auto-left',
'auto-right',
].forEach(placement => {
test(`should renders Popup with placement ${placement}`, async () => {
renderWithTheme(
<Popup
Expand Down
89 changes: 60 additions & 29 deletions packages/ui/src/components/Popup/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type { RefObject } from 'react'

export type PopupPlacement =
| 'top'
| 'right'
| 'bottom'
| 'left'
type AutoPlacements =
| 'auto'
| 'nested-menu'
| 'auto-bottom'
| 'auto-left'
| 'auto-right'
| 'auto-top'

type NonAutoPlacements = 'top' | 'right' | 'bottom' | 'left' | 'nested-menu'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Placements


export type PopupPlacement = AutoPlacements | NonAutoPlacements

export type PopupAlign = 'start' | 'center'
export const DEFAULT_ARROW_WIDTH = 8 // in px
const SPACE = 4 // in px
Expand All @@ -28,6 +32,23 @@ type ComputePlacementTypes = {
offsetParentRect: DOMRect
offsetParent: Element
isNestedMenu?: boolean
autoPlacement?: AutoPlacements
}
// Depending on the auto-placement preferences, change the placements hierarchy

const getOrderOfPlacement = (autoPlacement: AutoPlacements) => {
if (autoPlacement === 'auto-bottom') {
return ['bottom', 'top', 'left', 'right'] as const
}
if (autoPlacement === 'auto-left') {
return ['left', 'right', 'top', 'bottom'] as const
}

if (autoPlacement === 'auto-right') {
return ['right', 'left', 'top', 'bottom'] as const
}

return ['top', 'bottom', 'left', 'right'] as const
}

/**
Expand All @@ -40,6 +61,7 @@ const computePlacement = ({
offsetParent,
popupPortalTarget,
isNestedMenu,
autoPlacement,
}: ComputePlacementTypes) => {
const {
top: childrenTop,
Expand All @@ -48,6 +70,8 @@ const computePlacement = ({
width: childrenWidth,
} = childrenStructuredRef

const orderOfPlacement = getOrderOfPlacement(autoPlacement ?? 'auto')

const { top: parentTop, left: parentLeft } = offsetParentRect

const isPopupPortalTargetBody =
Expand Down Expand Up @@ -76,24 +100,31 @@ const computePlacement = ({
return 'right'
}

if (overloadedChildrenTop - popupHeight - TOTAL_USED_SPACE < 0) {
return 'bottom'
const conditionsOfPlacement = {
bottom:
window.innerHeight - overloadedChildrenTop - TOTAL_USED_SPACE >=
popupHeight,
left: overloadedChildrenLeft - TOTAL_USED_SPACE >= popupWidth,
right:
window.innerWidth - overloadedChildrenLeft - TOTAL_USED_SPACE >=
popupWidth,
top: overloadedChildrenTop - popupHeight - TOTAL_USED_SPACE >= 0,
}

if (overloadedChildrenLeft - popupWidth - TOTAL_USED_SPACE < 0) {
return 'right'
if (conditionsOfPlacement[orderOfPlacement[0]]) {
return orderOfPlacement[0]
}

if (
overloadedChildrenRight + popupWidth + TOTAL_USED_SPACE >
window.innerWidth
) {
return 'left'
if (conditionsOfPlacement[orderOfPlacement[1]]) {
return orderOfPlacement[1]
}

return 'top'
}
if (conditionsOfPlacement[orderOfPlacement[2]]) {
return orderOfPlacement[2]
}

return orderOfPlacement[3]
}
/**
* This function will check if the offset parent is usable for popup positioning
* If not it will loop and search for a compatible parent until document.body is reached
Expand Down Expand Up @@ -135,7 +166,7 @@ const findOffsetParent = (element: RefObject<HTMLDivElement>) => {
* @param popupStructuredRef the rect of the popup, the popup itself
*/
const getPopupOverflowFromParent = (
position: 'top' | 'right' | 'bottom' | 'left' | 'nested-menu',
position: NonAutoPlacements,
offsetParentRect: { top: number; left: number; right: number },
childrenRect: DOMRect,
popupStructuredRef: DOMRect,
Expand Down Expand Up @@ -244,16 +275,16 @@ export const computePositions = ({
popupRef.current as HTMLDivElement
).getBoundingClientRect()

const placementBasedOnWindowSize =
placement === 'auto'
? computePlacement({
childrenStructuredRef: childrenRect,
offsetParent,
offsetParentRect,
popupPortalTarget,
popupStructuredRef,
})
: placement
const placementBasedOnWindowSize = placement.startsWith('auto')
? computePlacement({
autoPlacement: placement as AutoPlacements,
childrenStructuredRef: childrenRect,
offsetParent,
offsetParentRect,
popupPortalTarget,
popupStructuredRef,
})
: placement

const {
top: childrenTop,
Expand Down Expand Up @@ -288,7 +319,7 @@ export const computePositions = ({
: childrenLeft - parentLeft + childrenWidth

const popupOverflow = getPopupOverflowFromParent(
placementBasedOnWindowSize,
placementBasedOnWindowSize as NonAutoPlacements,
offsetParentRect,
childrenRect,
popupStructuredRef,
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/Tabs/TabMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ export const TabMenu = forwardRef(
</button>
}
id={id}
portalTarget={document.body}
ref={ref} // We need to attach it to the body to avoid overflow issues
placement="bottom"
portalTarget={document.body} // We need to attach it to the body to avoid overflow issues
ref={ref}
visible={visible}
>
{children}
Expand Down
Loading
Loading