Skip to content

ActionMenu: Add position callback as prop #5910

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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
5 changes: 5 additions & 0 deletions .changeset/orange-eels-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

ActionMenu: Adds new prop `onPositionChange` that is called when the position of the overlay is changed
8 changes: 8 additions & 0 deletions packages/react/.storybook/storybook.css
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,11 @@
.testCustomClassnameMono {
font-family: var(--fontStack-monospace) !important;
}

.testCustomPositionMiddle {
position: absolute !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
width: 200px;
}
6 changes: 6 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@
"defaultValue": "'outside-bottom'",
"description": "Controls which side of the anchor the menu will appear"
},
{
"name": "onPositionChange",
"type": "(position: AnchorPosition) => void",
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
"type": "(position: AnchorPosition) => void",
"type": "({ position }: { position: AnchorPosition }) => void",

"defaultValue": "",
"description": "Callback that is called when the position of the overlay changes"
},
{
"name": "data-test-id",
"type": "unknown",
Expand Down
48 changes: 48 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
RocketIcon,
WorkflowIcon,
} from '@primer/octicons-react'
import type {AnchorPosition, AnchorSide} from '@primer/behaviors'

export default {
title: 'Components/ActionMenu/Examples',
Expand Down Expand Up @@ -577,3 +578,50 @@ export const OnlyInactiveItems = () => (
</ActionMenu.Overlay>
</ActionMenu>
)

export const DynamicAnchorSides = () => {
const [currentSide, setCurrentSide] = React.useState<AnchorSide>('outside-bottom')
const [updatedSide, setUpdatedSide] = React.useState<AnchorPosition>()

return (
<>
<div className="testCustomPositionMiddle">
<ActionMenu>
<ActionMenu.Button>Open menu</ActionMenu.Button>
<ActionMenu.Overlay
width="auto"
maxHeight="large"
side={currentSide}
onPositionChange={position => {
setUpdatedSide(position)
}}
>
<ActionList>
<ActionList.Group>
<ActionList.GroupHeading>
Inside {updatedSide?.anchorSide.includes('inside') ? '(current)' : null}
</ActionList.GroupHeading>
<ActionList.Item onSelect={() => setCurrentSide('inside-top')}>Inside-top</ActionList.Item>
<ActionList.Item onSelect={() => setCurrentSide('inside-bottom')}>Inside-bottom</ActionList.Item>
<ActionList.Item onSelect={() => setCurrentSide('inside-left')}>Inside-left</ActionList.Item>
<ActionList.Item onSelect={() => setCurrentSide('inside-right')}>Inside-right</ActionList.Item>
<ActionList.Item onSelect={() => setCurrentSide('inside-center')}>Inside-center</ActionList.Item>
</ActionList.Group>
<ActionList.Group>
<ActionList.GroupHeading>
Outside {updatedSide?.anchorSide.includes('outside') ? '(current)' : null}
</ActionList.GroupHeading>
<ActionList.Item onSelect={() => setCurrentSide('outside-top')}>Outside-top</ActionList.Item>
<ActionList.Item onSelect={() => setCurrentSide('outside-bottom')}>Outside-bottom</ActionList.Item>
<ActionList.Item onSelect={() => setCurrentSide('outside-left')}>Outside-left</ActionList.Item>
<ActionList.Item onSelect={() => setCurrentSide('outside-right')}>Outside-right</ActionList.Item>
</ActionList.Group>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>

<span>Current Overlay Side: {currentSide}</span>
</div>
</>
)
}
4 changes: 4 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigat
import {Divider} from '../ActionList/Divider'
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'
import type {ButtonProps} from '../Button'
import type {AnchorPosition} from '@primer/behaviors'
import {Button} from '../Button'
import {useId} from '../hooks/useId'
import type {MandateProps} from '../utils/types'
Expand Down Expand Up @@ -233,11 +234,13 @@ type MenuOverlayProps = Partial<OverlayProps> &
* Recommended: `ActionList`
*/
children: React.ReactNode
onPositionChange?: (side: AnchorPosition) => void
}
const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
children,
align = 'start',
side,
onPositionChange,
'aria-labelledby': ariaLabelledby,
...overlayProps
}) => {
Expand Down Expand Up @@ -281,6 +284,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
side={side ?? (isSubmenu ? 'outside-right' : 'outside-bottom')}
overlayProps={overlayProps}
focusZoneSettings={{focusOutBehavior: 'wrap'}}
onPositionChange={onPositionChange}
>
<div ref={containerRef}>
<ActionListContainerContext.Provider
Expand Down
14 changes: 13 additions & 1 deletion packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {FocusZoneHookSettings} from '../hooks/useFocusZone'
import {useFocusZone} from '../hooks/useFocusZone'
import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks'
import {useId} from '../hooks/useId'
import type {PositionSettings} from '@primer/behaviors'
import type {AnchorPosition, PositionSettings} from '@primer/behaviors'
import {useResponsiveValue, type ResponsiveValue} from '../hooks/useResponsiveValue'

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

export type AnchoredOverlayProps = AnchoredOverlayBaseProps &
Expand Down Expand Up @@ -129,6 +133,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
pinPosition,
variant = {regular: 'anchored', narrow: 'anchored'},
preventOverflow = true,
onPositionChange,
}) => {
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
Expand Down Expand Up @@ -162,6 +167,12 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
[open, onOpen, onClose],
)

const positionChange = (position: AnchorPosition | undefined) => {
if (onPositionChange && position) {
onPositionChange(position)
}
}

const {position} = useAnchoredPosition(
{
anchorElementRef: anchorRef,
Expand All @@ -171,6 +182,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
align,
alignmentOffset,
anchorOffset,
onPositionChange: positionChange,
},
[overlayRef.current],
)
Expand Down
21 changes: 21 additions & 0 deletions packages/react/src/__tests__/AnchoredOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,22 @@ import theme from '../theme'
import BaseStyles from '../BaseStyles'
import {ThemeProvider} from '../ThemeProvider'
import {setupMatchMedia} from '../utils/test-helpers'
import type {AnchorPosition} from '@primer/behaviors'

setupMatchMedia()

type TestComponentSettings = {
initiallyOpen?: boolean
onOpenCallback?: (gesture: string) => void
onCloseCallback?: (gesture: string) => void
onPositionChange?: (position: AnchorPosition | undefined) => void
}

const AnchoredOverlayTestComponent = ({
initiallyOpen = false,
onOpenCallback,
onCloseCallback,
onPositionChange,
}: TestComponentSettings = {}) => {
const [open, setOpen] = useState(initiallyOpen)
const onOpen = useCallback(
Expand All @@ -45,6 +48,7 @@ const AnchoredOverlayTestComponent = ({
onOpen={onOpen}
onClose={onClose}
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
onPositionChange={onPositionChange}
>
<button type="button">Focusable Child</button>
</AnchoredOverlay>
Expand Down Expand Up @@ -144,4 +148,21 @@ describe('AnchoredOverlay', () => {
const {container} = HTMLRender(<AnchoredOverlayTestComponent initiallyOpen={true} />)
expect(container).toMatchSnapshot()
})

it('should call onPositionChange when provided', () => {
const mockPositionChangeCallback = jest.fn(side => side)
const anchoredOverlay = HTMLRender(
<AnchoredOverlayTestComponent initiallyOpen={true} onPositionChange={mockPositionChangeCallback} />,
)
const overlay = anchoredOverlay.baseElement.querySelector('[role="none"]')!
fireEvent.keyDown(overlay, {key: 'Escape'})

expect(mockPositionChangeCallback).toHaveBeenCalledTimes(1)
expect(mockPositionChangeCallback).toHaveBeenCalledWith({
anchorAlign: 'start',
anchorSide: 'outside-bottom',
left: 0,
top: 4,
})
})
})
13 changes: 13 additions & 0 deletions packages/react/src/hooks/useAnchoredPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface AnchoredPositionHookSettings extends Partial<PositionSettings>
floatingElementRef?: React.RefObject<Element>
anchorElementRef?: React.RefObject<Element>
pinPosition?: boolean
onPositionChange?: (position: AnchorPosition | undefined) => void
}

/**
Expand All @@ -30,6 +31,7 @@ export function useAnchoredPosition(
} {
const floatingElementRef = useProvidedRefOrCreate(settings?.floatingElementRef)
const anchorElementRef = useProvidedRefOrCreate(settings?.anchorElementRef)
const savedOnPositionChange = React.useRef(settings?.onPositionChange)
const [position, setPosition] = React.useState<AnchorPosition | undefined>(undefined)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, setPrevHeight] = React.useState<number | undefined>(undefined)
Expand Down Expand Up @@ -71,17 +73,28 @@ export function useAnchoredPosition(
return prev
}
}

if (prev && prev.anchorSide === newPosition.anchorSide) {
// if the position hasn't changed, don't update
savedOnPositionChange.current?.(newPosition)
}

return newPosition
})
} else {
setPosition(undefined)
savedOnPositionChange.current?.(undefined)
}
setPrevHeight(floatingElementRef.current?.clientHeight)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[floatingElementRef, anchorElementRef, ...dependencies],
)

useLayoutEffect(() => {
savedOnPositionChange.current = settings?.onPositionChange
}, [settings?.onPositionChange])

useLayoutEffect(updatePosition, [updatePosition])

useResizeObserver(updatePosition) // watches for changes in window size
Expand Down
Loading