Skip to content

Commit cdd4253

Browse files
authored
ActionMenu: Add position callback as prop (#5910)
1 parent 9e006b3 commit cdd4253

File tree

8 files changed

+120
-2
lines changed

8 files changed

+120
-2
lines changed

.changeset/orange-eels-rule.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
ActionMenu: Adds new prop `onPositionChange` that is called when the position of the overlay is changed

packages/react/.storybook/storybook.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,11 @@
8181
.testCustomClassnameMono {
8282
font-family: var(--fontStack-monospace) !important;
8383
}
84+
85+
.testCustomPositionMiddle {
86+
position: absolute !important;
87+
top: 50% !important;
88+
left: 50% !important;
89+
transform: translate(-50%, -50%) !important;
90+
width: 200px;
91+
}

packages/react/src/ActionMenu/ActionMenu.docs.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@
111111
"defaultValue": "'outside-bottom'",
112112
"description": "Controls which side of the anchor the menu will appear"
113113
},
114+
{
115+
"name": "onPositionChange",
116+
"type": "({ position }: { position: AnchorPosition }) => void",
117+
"defaultValue": "",
118+
"description": "Callback that is called when the position of the overlay changes"
119+
},
114120
{
115121
"name": "data-test-id",
116122
"type": "unknown",

packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
RocketIcon,
2020
WorkflowIcon,
2121
} from '@primer/octicons-react'
22+
import type {AnchorPosition, AnchorSide} from '@primer/behaviors'
2223

2324
export default {
2425
title: 'Components/ActionMenu/Examples',
@@ -577,3 +578,50 @@ export const OnlyInactiveItems = () => (
577578
</ActionMenu.Overlay>
578579
</ActionMenu>
579580
)
581+
582+
export const DynamicAnchorSides = () => {
583+
const [currentSide, setCurrentSide] = React.useState<AnchorSide>('outside-bottom')
584+
const [updatedSide, setUpdatedSide] = React.useState<AnchorPosition>()
585+
586+
return (
587+
<>
588+
<div className="testCustomPositionMiddle">
589+
<ActionMenu>
590+
<ActionMenu.Button>Open menu</ActionMenu.Button>
591+
<ActionMenu.Overlay
592+
width="auto"
593+
maxHeight="large"
594+
side={currentSide}
595+
onPositionChange={({position}) => {
596+
setUpdatedSide(position)
597+
}}
598+
>
599+
<ActionList>
600+
<ActionList.Group>
601+
<ActionList.GroupHeading>
602+
Inside {updatedSide?.anchorSide.includes('inside') ? '(current)' : null}
603+
</ActionList.GroupHeading>
604+
<ActionList.Item onSelect={() => setCurrentSide('inside-top')}>Inside-top</ActionList.Item>
605+
<ActionList.Item onSelect={() => setCurrentSide('inside-bottom')}>Inside-bottom</ActionList.Item>
606+
<ActionList.Item onSelect={() => setCurrentSide('inside-left')}>Inside-left</ActionList.Item>
607+
<ActionList.Item onSelect={() => setCurrentSide('inside-right')}>Inside-right</ActionList.Item>
608+
<ActionList.Item onSelect={() => setCurrentSide('inside-center')}>Inside-center</ActionList.Item>
609+
</ActionList.Group>
610+
<ActionList.Group>
611+
<ActionList.GroupHeading>
612+
Outside {updatedSide?.anchorSide.includes('outside') ? '(current)' : null}
613+
</ActionList.GroupHeading>
614+
<ActionList.Item onSelect={() => setCurrentSide('outside-top')}>Outside-top</ActionList.Item>
615+
<ActionList.Item onSelect={() => setCurrentSide('outside-bottom')}>Outside-bottom</ActionList.Item>
616+
<ActionList.Item onSelect={() => setCurrentSide('outside-left')}>Outside-left</ActionList.Item>
617+
<ActionList.Item onSelect={() => setCurrentSide('outside-right')}>Outside-right</ActionList.Item>
618+
</ActionList.Group>
619+
</ActionList>
620+
</ActionMenu.Overlay>
621+
</ActionMenu>
622+
623+
<span>Current Overlay Side: {currentSide}</span>
624+
</div>
625+
</>
626+
)
627+
}

packages/react/src/ActionMenu/ActionMenu.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigat
77
import {Divider} from '../ActionList/Divider'
88
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'
99
import type {ButtonProps} from '../Button'
10+
import type {AnchorPosition} from '@primer/behaviors'
1011
import {Button} from '../Button'
1112
import {useId} from '../hooks/useId'
1213
import type {MandateProps} from '../utils/types'
@@ -233,11 +234,13 @@ type MenuOverlayProps = Partial<OverlayProps> &
233234
* Recommended: `ActionList`
234235
*/
235236
children: React.ReactNode
237+
onPositionChange?: ({position}: {position: AnchorPosition}) => void
236238
}
237239
const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
238240
children,
239241
align = 'start',
240242
side,
243+
onPositionChange,
241244
'aria-labelledby': ariaLabelledby,
242245
...overlayProps
243246
}) => {
@@ -281,6 +284,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
281284
side={side ?? (isSubmenu ? 'outside-right' : 'outside-bottom')}
282285
overlayProps={overlayProps}
283286
focusZoneSettings={{focusOutBehavior: 'wrap'}}
287+
onPositionChange={onPositionChange}
284288
>
285289
<div ref={containerRef}>
286290
<ActionListContainerContext.Provider

packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@ import {Button} from '../Button'
66
import theme from '../theme'
77
import BaseStyles from '../BaseStyles'
88
import {ThemeProvider} from '../ThemeProvider'
9-
9+
import type {AnchorPosition} from '@primer/behaviors'
1010
type TestComponentSettings = {
1111
initiallyOpen?: boolean
1212
onOpenCallback?: (gesture: string) => void
1313
onCloseCallback?: (gesture: string) => void
14+
onPositionChange?: ({position}: {position: AnchorPosition}) => void
1415
}
1516

1617
const AnchoredOverlayTestComponent = ({
1718
initiallyOpen = false,
1819
onOpenCallback,
1920
onCloseCallback,
21+
onPositionChange,
2022
}: TestComponentSettings = {}) => {
2123
const [open, setOpen] = useState(initiallyOpen)
2224
const onOpen = useCallback(
@@ -41,6 +43,7 @@ const AnchoredOverlayTestComponent = ({
4143
onOpen={onOpen}
4244
onClose={onClose}
4345
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
46+
onPositionChange={onPositionChange}
4447
>
4548
<button type="button">Focusable Child</button>
4649
</AnchoredOverlay>
@@ -117,4 +120,23 @@ describe('AnchoredOverlay', () => {
117120
const {container} = render(<AnchoredOverlayTestComponent initiallyOpen={true} />)
118121
expect(container).toMatchSnapshot()
119122
})
123+
124+
it('should call onPositionChange when provided', () => {
125+
const mockPositionChangeCallback = vi.fn(({position}: {position: AnchorPosition}) => position)
126+
const anchoredOverlay = render(
127+
<AnchoredOverlayTestComponent initiallyOpen={true} onPositionChange={mockPositionChangeCallback} />,
128+
)
129+
const overlay = anchoredOverlay.baseElement.querySelector('[role="none"]')!
130+
fireEvent.keyDown(overlay, {key: 'Escape'})
131+
132+
expect(mockPositionChangeCallback).toHaveBeenCalledTimes(1)
133+
expect(mockPositionChangeCallback).toHaveBeenCalledWith({
134+
position: {
135+
anchorAlign: 'start',
136+
anchorSide: 'outside-bottom',
137+
left: 0,
138+
top: 26.84375,
139+
},
140+
})
141+
})
120142
})

packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {FocusZoneHookSettings} from '../hooks/useFocusZone'
77
import {useFocusZone} from '../hooks/useFocusZone'
88
import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks'
99
import {useId} from '../hooks/useId'
10-
import type {PositionSettings} from '@primer/behaviors'
10+
import type {AnchorPosition, PositionSettings} from '@primer/behaviors'
1111
import {useResponsiveValue, type ResponsiveValue} from '../hooks/useResponsiveValue'
1212

1313
interface AnchoredOverlayPropsWithAnchor {
@@ -98,6 +98,10 @@ interface AnchoredOverlayBaseProps extends Pick<OverlayProps, 'height' | 'width'
9898
* Optional prop to set variant for narrow screen sizes
9999
*/
100100
variant?: ResponsiveValue<'anchored', 'anchored' | 'fullscreen'>
101+
/**
102+
* An override to the internal position that will be used to position the overlay.
103+
*/
104+
onPositionChange?: ({position}: {position: AnchorPosition}) => void
101105
}
102106

103107
export type AnchoredOverlayProps = AnchoredOverlayBaseProps &
@@ -129,6 +133,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
129133
pinPosition,
130134
variant = {regular: 'anchored', narrow: 'anchored'},
131135
preventOverflow = true,
136+
onPositionChange,
132137
}) => {
133138
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
134139
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
@@ -162,6 +167,12 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
162167
[open, onOpen, onClose],
163168
)
164169

170+
const positionChange = (position: AnchorPosition | undefined) => {
171+
if (onPositionChange && position) {
172+
onPositionChange({position})
173+
}
174+
}
175+
165176
const {position} = useAnchoredPosition(
166177
{
167178
anchorElementRef: anchorRef,
@@ -171,6 +182,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
171182
align,
172183
alignmentOffset,
173184
anchorOffset,
185+
onPositionChange: positionChange,
174186
},
175187
[overlayRef.current],
176188
)

packages/react/src/hooks/useAnchoredPosition.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface AnchoredPositionHookSettings extends Partial<PositionSettings>
99
floatingElementRef?: React.RefObject<Element>
1010
anchorElementRef?: React.RefObject<Element>
1111
pinPosition?: boolean
12+
onPositionChange?: (position: AnchorPosition | undefined) => void
1213
}
1314

1415
/**
@@ -30,6 +31,7 @@ export function useAnchoredPosition(
3031
} {
3132
const floatingElementRef = useProvidedRefOrCreate(settings?.floatingElementRef)
3233
const anchorElementRef = useProvidedRefOrCreate(settings?.anchorElementRef)
34+
const savedOnPositionChange = React.useRef(settings?.onPositionChange)
3335
const [position, setPosition] = React.useState<AnchorPosition | undefined>(undefined)
3436
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3537
const [_, setPrevHeight] = React.useState<number | undefined>(undefined)
@@ -71,17 +73,28 @@ export function useAnchoredPosition(
7173
return prev
7274
}
7375
}
76+
77+
if (prev && prev.anchorSide === newPosition.anchorSide) {
78+
// if the position hasn't changed, don't update
79+
savedOnPositionChange.current?.(newPosition)
80+
}
81+
7482
return newPosition
7583
})
7684
} else {
7785
setPosition(undefined)
86+
savedOnPositionChange.current?.(undefined)
7887
}
7988
setPrevHeight(floatingElementRef.current?.clientHeight)
8089
},
8190
// eslint-disable-next-line react-hooks/exhaustive-deps
8291
[floatingElementRef, anchorElementRef, ...dependencies],
8392
)
8493

94+
useLayoutEffect(() => {
95+
savedOnPositionChange.current = settings?.onPositionChange
96+
}, [settings?.onPositionChange])
97+
8598
useLayoutEffect(updatePosition, [updatePosition])
8699

87100
useResizeObserver(updatePosition) // watches for changes in window size

0 commit comments

Comments
 (0)