-
Notifications
You must be signed in to change notification settings - Fork 598
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
Adds open, onOpenChange, and anchorRef props to DropdownMenu #1372
Changes from 2 commits
e264181
e1cfaf9
cd9431d
113a7a2
8f67fe8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@primer/components': patch | ||
--- | ||
|
||
Extends DropdownMenu to allow anchorRef, open, and onOpenChange props. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,18 +1,14 @@ | ||
import React, {useCallback, useMemo, useState} from 'react' | ||
import React, {useCallback, useMemo} from 'react' | ||
import {List, GroupedListProps, ListPropsBase, ItemInput} from '../ActionList/List' | ||
import {DropdownButton, DropdownButtonProps} from './DropdownButton' | ||
import {ItemProps} from '../ActionList/Item' | ||
import {AnchoredOverlay} from '../AnchoredOverlay' | ||
import {OverlayProps} from '../Overlay' | ||
import {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay' | ||
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate' | ||
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' | ||
|
||
export interface DropdownMenuProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase { | ||
/** | ||
* A custom function component used to render the anchor element. | ||
* Will receive the selected text as `children` prop when an item is activated. | ||
* Uses a `DropdownButton` by default. | ||
*/ | ||
renderAnchor?: <T extends React.HTMLAttributes<HTMLElement>>(props: T) => JSX.Element | ||
|
||
interface DropdownMenuBaseProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase { | ||
/** | ||
* A placeholder value to display on the trigger button when no selection has been made. | ||
*/ | ||
|
@@ -33,35 +29,53 @@ export interface DropdownMenuProps extends Partial<Omit<GroupedListProps, keyof | |
* Props to be spread on the internal `Overlay` component. | ||
*/ | ||
overlayProps?: Partial<OverlayProps> | ||
|
||
/** | ||
* If defined, will control the open/closed state of the overlay. Must be used in conjuction with `onOpenChange`. | ||
*/ | ||
open?: boolean | ||
|
||
/** | ||
* If defined, will control the open/closed state of the overlay. Must be used in conjuction with `open`. | ||
*/ | ||
onOpenChange?: (s: boolean) => void | ||
jfuchs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
export type DropdownMenuProps = DropdownMenuBaseProps & AnchoredOverlayWrapperAnchorProps | ||
|
||
/** | ||
* A `DropdownMenu` provides an anchor (button by default) that will open a floating menu of selectable items. The menu can be | ||
* opened and navigated using keyboard or mouse. When an item is selected, the menu will close and the `onChange` callback will be called. | ||
* If the default anchor button is used, the anchor contents will be updated with the selection. | ||
*/ | ||
export function DropdownMenu({ | ||
renderAnchor = <T extends DropdownButtonProps>(props: T) => <DropdownButton {...props} />, | ||
anchorRef: externalAnchorRef, | ||
placeholder, | ||
selectedItem, | ||
onChange, | ||
overlayProps, | ||
items, | ||
open, | ||
onOpenChange, | ||
...listProps | ||
}: DropdownMenuProps): JSX.Element { | ||
const [open, setOpen] = useState(false) | ||
const onOpen = useCallback(() => setOpen(true), []) | ||
const onClose = useCallback(() => setOpen(false), []) | ||
const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false) | ||
const onOpen = useCallback(() => setCombinedOpenState(true), [setCombinedOpenState]) | ||
const onClose = useCallback(() => setCombinedOpenState(false), [setCombinedOpenState]) | ||
|
||
const anchorRef = useProvidedRefOrCreate(externalAnchorRef) | ||
|
||
const renderMenuAnchor = useCallback( | ||
<T extends React.HTMLAttributes<HTMLElement>>(props: T) => { | ||
return renderAnchor({ | ||
const renderMenuAnchor = useMemo(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just curious, what happens if both There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If they're both passed, the I spoke with @dgreif about this yesterday, and it sounds like the prop types in |
||
if (renderAnchor === null) { | ||
return null | ||
} | ||
return <T extends React.HTMLAttributes<HTMLElement>>(props: T) => | ||
renderAnchor({ | ||
...props, | ||
children: selectedItem?.text ?? placeholder | ||
}) | ||
}, | ||
[placeholder, renderAnchor, selectedItem?.text] | ||
) | ||
}, [placeholder, renderAnchor, selectedItem?.text]) | ||
|
||
const itemsToRender = useMemo(() => { | ||
return items.map(item => { | ||
|
@@ -86,7 +100,8 @@ export function DropdownMenu({ | |
return ( | ||
<AnchoredOverlay | ||
renderAnchor={renderMenuAnchor} | ||
open={open} | ||
anchorRef={anchorRef} | ||
open={combinedOpenState} | ||
onOpen={onOpen} | ||
onClose={onClose} | ||
overlayProps={overlayProps} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ import React from 'react' | |
import {theme, ThemeProvider} from '..' | ||
import {ItemInput} from '../ActionList/List' | ||
import BaseStyles from '../BaseStyles' | ||
import Box from '../Box' | ||
import {DropdownMenu, DropdownButton} from '../DropdownMenu' | ||
import TextInput from '../TextInput' | ||
|
||
|
@@ -52,3 +53,32 @@ export function FavoriteColorStory(): JSX.Element { | |
) | ||
} | ||
FavoriteColorStory.storyName = 'Favorite Color' | ||
|
||
export function ExternalAnchorStory(): JSX.Element { | ||
const items = React.useMemo(() => [{text: '🔵 Cyan'}, {text: '🔴 Magenta'}, {text: '🟡 Yellow'}], []) | ||
const [selectedItem, setSelectedItem] = React.useState<ItemInput | undefined>() | ||
const anchorRef = React.useRef<HTMLDivElement>(null) | ||
const [open, setOpen] = React.useState(false) | ||
|
||
return ( | ||
<Box display="flex" flexDirection="column" alignItems="flex-start"> | ||
<Box display="flex" flexDirection="row"> | ||
<DropdownButton onClick={() => setOpen(true)}>Click me to open the dropdown</DropdownButton> | ||
<Box ref={anchorRef} bg="papayawhip" p={4} ml={40} borderRadius={2} display="inline-block"> | ||
Anchored on me! | ||
</Box> | ||
</Box> | ||
<DropdownMenu | ||
renderAnchor={null} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is explicitly setting There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep. I tried to mirror
It's a little wonky, but my inclination is to try to clean all of these up in a future PR. I'd like to hear your thoughts though! |
||
anchorRef={anchorRef} | ||
open={open} | ||
onOpenChange={setOpen} | ||
placeholder="🎨" | ||
items={items} | ||
selectedItem={selectedItem} | ||
onChange={setSelectedItem} | ||
/> | ||
</Box> | ||
) | ||
} | ||
ExternalAnchorStory.storyName = 'DropdownMenu with External Anchor' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we be explicit here about how the
open
state is managed ifopen
is not defined? (i.e. it becomes an uncontrolled component)