Skip to content

Commit

Permalink
feat(SelectPanel): allow external anchor ref (#1368)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgreif committed Aug 5, 2021
1 parent d3a463f commit 36f156a
Show file tree
Hide file tree
Showing 5 changed files with 58 additions and 38 deletions.
5 changes: 5 additions & 0 deletions .changeset/swift-books-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/components": patch
---

Allow `anchorRef` to be passed into `SelectPanel` if you want to use an external anchor
32 changes: 2 additions & 30 deletions src/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,8 @@ import {AnchoredOverlay} from './AnchoredOverlay'
import {useProvidedStateOrCreate} from './hooks/useProvidedStateOrCreate'
import {OverlayProps} from './Overlay'
import {useProvidedRefOrCreate} from './hooks'
import {AnchoredOverlayWrapperAnchorProps} from './AnchoredOverlay/AnchoredOverlay'

interface ActionMenuPropsWithAnchor {
/**
* A custom function component used to render the anchor element.
* Will receive the `anchoredContent` prop as `children` prop.
* Uses a `Button` by default.
*/
renderAnchor?: <T extends React.HTMLAttributes<HTMLElement>>(props: T) => JSX.Element

/**
* An override to the internal renderAnchor ref that will be spread on to the renderAnchor,
* When renderAnchor is defined, this prop will be spread on to the rendAnchor
* component that is passed in.
*/
anchorRef?: React.RefObject<HTMLElement>
}

interface ActionMenuPropsWithoutAnchor {
/**
* A custom function component used to render the anchor element.
* When renderAnchor is null, an anchorRef is required.
*/
renderAnchor: null

/**
* An override to the internal renderAnchor ref. When renderAnchor is null this can be
* used to make an anchor that is detached from ActionMenu.
*/
anchorRef: React.RefObject<HTMLElement>
}
interface ActionMenuBaseProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
/**
* Content that is passed into the renderAnchor component, which is a button by default.
Expand Down Expand Up @@ -64,7 +36,7 @@ interface ActionMenuBaseProps extends Partial<Omit<GroupedListProps, keyof ListP
overlayProps?: Partial<OverlayProps>
}

export type ActionMenuProps = ActionMenuBaseProps & (ActionMenuPropsWithAnchor | ActionMenuPropsWithoutAnchor)
export type ActionMenuProps = ActionMenuBaseProps & AnchoredOverlayWrapperAnchorProps

const ActionMenuItem = (props: ItemProps) => <Item role="menuitem" {...props} />

Expand Down
4 changes: 4 additions & 0 deletions src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ interface AnchoredOverlayPropsWithoutAnchor {
anchorRef: React.RefObject<HTMLElement>
}

export type AnchoredOverlayWrapperAnchorProps =
| Partial<AnchoredOverlayPropsWithAnchor>
| AnchoredOverlayPropsWithoutAnchor

interface AnchoredOverlayBaseProps extends Pick<OverlayProps, 'height' | 'width'> {
/**
* Determines whether the overlay portion of the component should be shown or not
Expand Down
22 changes: 15 additions & 7 deletions src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {AnchoredOverlay, AnchoredOverlayProps} from '../AnchoredOverlay'
import Box from '../Box'
import {TextInputProps} from '../TextInput'
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
import {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay'
import {useProvidedRefOrCreate} from '../hooks'

interface SelectPanelSingleSelection {
selected: ItemInput | undefined
Expand All @@ -21,7 +23,6 @@ interface SelectPanelMultiSelection {
}

interface SelectPanelBaseProps {
renderAnchor?: <T extends React.HTMLAttributes<HTMLElement>>(props: T) => JSX.Element
onOpenChange: (
open: boolean,
gesture: 'anchor-click' | 'anchor-key-press' | 'click-outside' | 'escape' | 'selection'
Expand All @@ -33,6 +34,7 @@ interface SelectPanelBaseProps {
export type SelectPanelProps = SelectPanelBaseProps &
Omit<FilteredActionListProps, 'selectionVariant'> &
Pick<AnchoredOverlayProps, 'open'> &
AnchoredOverlayWrapperAnchorProps &
(SelectPanelSingleSelection | SelectPanelMultiSelection)

function isMultiSelectVariant(
Expand All @@ -50,6 +52,7 @@ export function SelectPanel({
open,
onOpenChange,
renderAnchor = props => <DropdownButton {...props} />,
anchorRef: externalAnchorRef,
placeholder,
selected,
onSelectedChange,
Expand All @@ -69,6 +72,7 @@ export function SelectPanel({
[externalOnFilterChange, setInternalFilterValue]
)

const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
const onOpen: AnchoredOverlayProps['onOpen'] = useCallback(gesture => onOpenChange(true, gesture), [onOpenChange])
const onClose = useCallback(
(gesture: Parameters<Exclude<AnchoredOverlayProps['onClose'], undefined>>[0] | 'selection') => {
Expand All @@ -77,17 +81,20 @@ export function SelectPanel({
[onOpenChange]
)

const renderMenuAnchor = useCallback(
<T extends React.HTMLAttributes<HTMLElement>>(props: T) => {
const selectedItems = Array.isArray(selected) ? selected : [...(selected ? [selected] : [])]
const renderMenuAnchor = useMemo(() => {
if (renderAnchor === null) {
return null
}

const selectedItems = Array.isArray(selected) ? selected : [...(selected ? [selected] : [])]

return <T extends React.HTMLAttributes<HTMLElement>>(props: T) => {
return renderAnchor({
...props,
children: selectedItems.length ? selectedItems.map(item => item.text).join(', ') : placeholder
})
},
[placeholder, renderAnchor, selected]
)
}
}, [placeholder, renderAnchor, selected])

const itemsToRender = useMemo(() => {
return items.map(item => {
Expand Down Expand Up @@ -139,6 +146,7 @@ export function SelectPanel({
return (
<AnchoredOverlay
renderAnchor={renderMenuAnchor}
anchorRef={anchorRef}
open={open}
onOpen={onOpen}
onClose={onClose}
Expand Down
33 changes: 32 additions & 1 deletion src/stories/SelectPanel.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {OverlayProps} from '../Overlay'
import {Meta} from '@storybook/react'
import React, {useState} from 'react'
import React, {useRef, useState} from 'react'
import {theme, ThemeProvider} from '..'
import {ItemInput} from '../ActionList/List'
import BaseStyles from '../BaseStyles'
Expand Down Expand Up @@ -119,6 +119,37 @@ export function SingleSelectStory(): JSX.Element {
}
SingleSelectStory.storyName = 'Single Select'

export function ExternalAnchorStory(): JSX.Element {
const [selected, setSelected] = React.useState<ItemInput | undefined>(items[0])
const [filter, setFilter] = React.useState('')
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
const [open, setOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)

return (
<>
<h1>Select Panel With External Anchor</h1>
<DropdownButton ref={buttonRef} onClick={() => setOpen(!open)}>
Custom: {selected?.text || 'Click Me'}
</DropdownButton>
<SelectPanel
renderAnchor={null}
anchorRef={buttonRef}
placeholderText="Filter Labels"
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
showItemDividers={true}
overlayProps={{width: 'small', height: 'xsmall'}}
/>
</>
)
}
ExternalAnchorStory.storyName = 'With External Anchor'

export function SelectPanelHeightInitialWithOverflowingItemsStory(): JSX.Element {
const [selected, setSelected] = React.useState<ItemInput | undefined>(items[0])
const [filter, setFilter] = React.useState('')
Expand Down

0 comments on commit 36f156a

Please sign in to comment.