Skip to content

Commit 321b9a3

Browse files
VanAndersondgreif
andauthored
Anchored overlay can take an external AnchorRef (#1266)
Co-authored-by: Dusty Greif <[email protected]>
1 parent 2793ef4 commit 321b9a3

File tree

6 files changed

+111
-26
lines changed

6 files changed

+111
-26
lines changed

.changeset/odd-chairs-attack.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/components": patch
3+
---
4+
5+
Anchored overlay can take an external anchorRef.

src/ActionMenu.tsx

+38-8
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,38 @@ import React, {useCallback, useMemo} from 'react'
66
import {AnchoredOverlay} from './AnchoredOverlay'
77
import {useProvidedStateOrCreate} from './hooks/useProvidedStateOrCreate'
88
import {OverlayProps} from './Overlay'
9-
export interface ActionMenuProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
9+
import {useProvidedRefOrCreate} from './hooks'
10+
11+
interface ActionMenuPropsWithAnchor {
1012
/**
1113
* A custom function component used to render the anchor element.
1214
* Will receive the `anchoredContent` prop as `children` prop.
1315
* Uses a `Button` by default.
1416
*/
15-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16-
renderAnchor?: (props: any) => JSX.Element
17+
renderAnchor?: <T extends React.HTMLAttributes<HTMLElement>>(props: T) => JSX.Element
18+
19+
/**
20+
* An override to the internal renderAnchor ref that will be spread on to the renderAnchor,
21+
* When renderAnchor is defined, this prop will be spread on to the rendAnchor
22+
* component that is passed in.
23+
*/
24+
anchorRef?: React.RefObject<HTMLElement>
25+
}
26+
27+
interface ActionMenuPropsWithoutAnchor {
28+
/**
29+
* A custom function component used to render the anchor element.
30+
* When renderAnchor is null, an anchorRef is required.
31+
*/
32+
renderAnchor: null
1733

34+
/**
35+
* An override to the internal renderAnchor ref. When renderAnchor is null this can be
36+
* used to make an anchor that is detached from ActionMenu.
37+
*/
38+
anchorRef: React.RefObject<HTMLElement>
39+
}
40+
interface ActionMenuBaseProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
1841
/**
1942
* Content that is passed into the renderAnchor component, which is a button by default.
2043
*/
@@ -41,13 +64,16 @@ export interface ActionMenuProps extends Partial<Omit<GroupedListProps, keyof Li
4164
overlayProps?: Partial<OverlayProps>
4265
}
4366

67+
export type ActionMenuProps = ActionMenuBaseProps & (ActionMenuPropsWithAnchor | ActionMenuPropsWithoutAnchor)
68+
4469
const ActionMenuItem = (props: ItemProps) => <Item role="menuitem" {...props} />
4570

4671
ActionMenuItem.displayName = 'ActionMenu.Item'
4772

4873
const ActionMenuBase = ({
4974
anchorContent,
5075
renderAnchor = <T extends ButtonProps>(props: T) => <Button {...props} />,
76+
anchorRef: externalAnchorRef,
5177
onAction,
5278
open,
5379
setOpen,
@@ -56,19 +82,22 @@ const ActionMenuBase = ({
5682
...listProps
5783
}: ActionMenuProps): JSX.Element => {
5884
const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, setOpen, false)
85+
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
5986
const onOpen = useCallback(() => setCombinedOpenState(true), [setCombinedOpenState])
6087
const onClose = useCallback(() => setCombinedOpenState(false), [setCombinedOpenState])
6188

62-
const renderMenuAnchor = useCallback(
63-
<T extends React.HTMLAttributes<HTMLElement>>(props: T) => {
89+
const renderMenuAnchor = useMemo(() => {
90+
if (renderAnchor === null) {
91+
return null
92+
}
93+
return <T extends React.HTMLAttributes<HTMLElement>>(props: T) => {
6494
return renderAnchor({
6595
'aria-label': 'menu',
6696
children: anchorContent,
6797
...props
6898
})
69-
},
70-
[anchorContent, renderAnchor]
71-
)
99+
}
100+
}, [anchorContent, renderAnchor])
72101

73102
const itemsToRender = useMemo(() => {
74103
return items.map(item => {
@@ -89,6 +118,7 @@ const ActionMenuBase = ({
89118
return (
90119
<AnchoredOverlay
91120
renderAnchor={renderMenuAnchor}
121+
anchorRef={anchorRef}
92122
open={combinedOpenState}
93123
onOpen={onOpen}
94124
onClose={onClose}

src/AnchoredOverlay/AnchoredOverlay.tsx

+39-13
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,38 @@
1-
import React, {useCallback, useMemo, useRef} from 'react'
1+
import React, {useCallback, useMemo} from 'react'
22
import Overlay, {OverlayProps} from '../Overlay'
33
import {FocusTrapHookSettings, useFocusTrap} from '../hooks/useFocusTrap'
44
import {FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
5-
import {useAnchoredPosition, useRenderForcingRef} from '../hooks'
5+
import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks'
66
import {uniqueId} from '../utils/uniqueId'
77

8-
export interface AnchoredOverlayProps extends Pick<OverlayProps, 'height' | 'width'> {
8+
interface AnchoredOverlayPropsWithAnchor {
99
/**
1010
* A custom function component used to render the anchor element.
1111
* Will receive the selected text as `children` prop when an item is activated.
1212
*/
1313
renderAnchor: <T extends React.HTMLAttributes<HTMLElement>>(props: T) => JSX.Element
1414

15+
/**
16+
* An override to the internal ref that will be spread on to the renderAnchor
17+
*/
18+
anchorRef?: React.RefObject<HTMLElement>
19+
}
20+
21+
interface AnchoredOverlayPropsWithoutAnchor {
22+
/**
23+
* A custom function component used to render the anchor element.
24+
* When renderAnchor is null, an anchorRef is required.
25+
*/
26+
renderAnchor: null
27+
28+
/**
29+
* An override to the internal renderAnchor ref that will be used to position the overlay.
30+
* When renderAnchor is null this can be used to make an anchor that is detached from ActionMenu.
31+
*/
32+
anchorRef: React.RefObject<HTMLElement>
33+
}
34+
35+
interface AnchoredOverlayBaseProps extends Pick<OverlayProps, 'height' | 'width'> {
1536
/**
1637
* Determines whether the overlay portion of the component should be shown or not
1738
*/
@@ -43,12 +64,16 @@ export interface AnchoredOverlayProps extends Pick<OverlayProps, 'height' | 'wid
4364
focusZoneSettings?: Partial<FocusZoneHookSettings>
4465
}
4566

67+
export type AnchoredOverlayProps = AnchoredOverlayBaseProps &
68+
(AnchoredOverlayPropsWithAnchor | AnchoredOverlayPropsWithoutAnchor)
69+
4670
/**
4771
* An `AnchoredOverlay` provides an anchor that will open a floating overlay positioned relative to the anchor.
4872
* The overlay can be opened and navigated using keyboard or mouse.
4973
*/
5074
export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
5175
renderAnchor,
76+
anchorRef: externalAnchorRef,
5277
children,
5378
open,
5479
onOpen,
@@ -59,7 +84,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
5984
focusTrapSettings,
6085
focusZoneSettings
6186
}) => {
62-
const anchorRef = useRef<HTMLElement>(null)
87+
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
6388
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
6489
const anchorId = useMemo(uniqueId, [])
6590

@@ -106,15 +131,16 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
106131

107132
return (
108133
<>
109-
{renderAnchor({
110-
ref: anchorRef,
111-
id: anchorId,
112-
'aria-labelledby': anchorId,
113-
'aria-haspopup': 'listbox',
114-
tabIndex: 0,
115-
onClick: onAnchorClick,
116-
onKeyDown: onAnchorKeyDown
117-
})}
134+
{renderAnchor &&
135+
renderAnchor({
136+
ref: anchorRef,
137+
id: anchorId,
138+
'aria-labelledby': anchorId,
139+
'aria-haspopup': 'listbox',
140+
tabIndex: 0,
141+
onClick: onAnchorClick,
142+
onKeyDown: onAnchorKeyDown
143+
})}
118144
{open ? (
119145
<Overlay
120146
returnFocusRef={anchorRef}

src/SelectPanel/SelectPanel.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ interface SelectPanelMultiSelection {
2121
}
2222

2323
interface SelectPanelBaseProps {
24-
renderAnchor?: AnchoredOverlayProps['renderAnchor']
24+
renderAnchor?: <T extends React.HTMLAttributes<HTMLElement>>(props: T) => JSX.Element
2525
onOpenChange: (
2626
open: boolean,
2727
gesture: 'anchor-click' | 'anchor-key-press' | 'click-outside' | 'escape' | 'selection'
@@ -82,8 +82,8 @@ export function SelectPanel({
8282
[onOpenChange]
8383
)
8484

85-
const renderMenuAnchor: AnchoredOverlayProps['renderAnchor'] = useCallback(
86-
props => {
85+
const renderMenuAnchor = useCallback(
86+
<T extends React.HTMLAttributes<HTMLElement>>(props: T) => {
8787
const selectedItems = Array.isArray(selected) ? selected : [...(selected ? [selected] : [])]
8888

8989
return renderAnchor({

src/stories/ActionMenu.stories.tsx

+26-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
ArrowRightIcon
1212
} from '@primer/octicons-react'
1313
import {Meta} from '@storybook/react'
14-
import React, {useCallback, useState} from 'react'
14+
import React, {useCallback, useState, useRef} from 'react'
1515
import styled from 'styled-components'
1616
import {ThemeProvider} from '..'
1717
import {ActionMenu} from '../ActionMenu'
@@ -247,3 +247,28 @@ export function CustomTrigger(): JSX.Element {
247247
)
248248
}
249249
CustomTrigger.storyName = 'Custom Trigger'
250+
251+
export function ActionMenuWithExternalAnchor(): JSX.Element {
252+
const [isOpen, setIsOpen] = useState(false)
253+
const buttonRef = useRef<HTMLButtonElement>(null)
254+
return (
255+
<>
256+
<Button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
257+
Open Menu
258+
</Button>
259+
<ActionMenu
260+
renderAnchor={null}
261+
anchorRef={buttonRef}
262+
open={isOpen}
263+
setOpen={setIsOpen}
264+
items={[
265+
{text: 'New file'},
266+
ActionList.Divider,
267+
{text: 'Copy link'},
268+
{text: 'Edit file'},
269+
{text: 'Delete file', variant: 'danger'}
270+
]}
271+
/>
272+
</>
273+
)
274+
}

src/stories/Overlay.stories.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React, {useState, useRef} from 'react'
22
import {Meta} from '@storybook/react'
33
import styled from 'styled-components'
4-
54
import {BaseStyles, Overlay, Button, Text, ButtonDanger, ThemeProvider, Position, Flex} from '..'
65

76
export default {

0 commit comments

Comments
 (0)