Skip to content

Commit

Permalink
Add SelectPanel alpha component (#1224)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgreif committed May 18, 2021
1 parent d2e341c commit 909ada5
Show file tree
Hide file tree
Showing 9 changed files with 508 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/khaki-meals-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/components": patch
---

Add `SelectPanel` alpha component
10 changes: 10 additions & 0 deletions docs/content/SelectPanel.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: SelectPanel
status: Alpha
---

A `SelectPanel` provides an anchor that will open an overlay with a list of selectable items, and a text input to filter the selectable items

## Example

## Component props
55 changes: 55 additions & 0 deletions src/FilteredActionList/FilteredActionList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, {useCallback} from 'react'
import {GroupedListProps, ListPropsBase} from '../ActionList/List'
import TextInput, {TextInputProps} from '../TextInput'
import Box from '../Box'
import {ActionList} from '../ActionList'
import Spinner from '../Spinner'

export interface FilteredActionListProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
loading?: boolean
placeholderText: string
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement>) => void
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
}

export function FilteredActionList({
loading = false,
placeholderText,
onFilterChange,
items,
textInputProps,
...listProps
}: FilteredActionListProps): JSX.Element {
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
onFilterChange(value, e)
},
[onFilterChange]
)

return (
<>
<TextInput
block
width="auto"
color="text.primary"
onChange={onInputChange}
placeholder={placeholderText}
aria-label={placeholderText}
{...textInputProps}
/>
<Box flexGrow={1} overflow="auto">
{loading ? (
<Box width="100%" display="flex" flexDirection="row" justifyContent="center" pt={6} pb={7}>
<Spinner />
</Box>
) : (
<ActionList items={items} {...listProps} role="listbox" />
)}
</Box>
</>
)
}

FilteredActionList.displayName = 'FilteredActionList'
2 changes: 2 additions & 0 deletions src/FilteredActionList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {FilteredActionList} from './FilteredActionList'
export type {FilteredActionListProps} from './FilteredActionList'
148 changes: 148 additions & 0 deletions src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import React, {useCallback, useMemo} from 'react'
import {FilteredActionList, FilteredActionListProps} from '../FilteredActionList'
import {OverlayProps} from '../Overlay'
import {ItemInput} from '../ActionList/List'
import {FocusZoneSettings} from '../behaviors/focusZone'
import {DropdownButton} from '../DropdownMenu'
import {ItemProps} from '../ActionList'
import {AnchoredOverlay, AnchoredOverlayProps} from '../AnchoredOverlay'
import Flex from '../Flex'
import {TextInputProps} from '../TextInput'

interface SelectPanelSingleSelection {
selected: ItemInput | undefined
onSelectedChange: (selected: ItemInput | undefined) => void
}

interface SelectPanelMultiSelection {
selected: ItemInput[]
onSelectedChange: (selected: ItemInput[]) => void
}

interface SelectPanelBaseProps {
renderAnchor?: AnchoredOverlayProps['renderAnchor']
onOpenChange: (
open: boolean,
gesture: 'anchor-click' | 'anchor-key-press' | 'click-outside' | 'escape' | 'selection'
) => void
placeholder?: string
onFilterChange: (value: string, e?: React.ChangeEvent<HTMLInputElement>) => void
overlayProps?: Partial<OverlayProps>
}

export type SelectPanelProps = SelectPanelBaseProps &
Omit<FilteredActionListProps, 'onFilterChange' | 'selectionVariant'> &
Pick<AnchoredOverlayProps, 'open'> &
(SelectPanelSingleSelection | SelectPanelMultiSelection)

function isMultiSelectVariant(
selected: SelectPanelSingleSelection['selected'] | SelectPanelMultiSelection['selected']
): selected is SelectPanelMultiSelection['selected'] {
return Array.isArray(selected)
}

const focusZoneSettings: Partial<FocusZoneSettings> = {
focusOutBehavior: 'wrap',
focusableElementFilter: element => {
return !(element instanceof HTMLInputElement) || element.type !== 'checkbox'
}
}

const textInputProps: Partial<TextInputProps> = {
mx: 2,
my: 2,
contrast: true
}

export function SelectPanel({
open,
onOpenChange,
renderAnchor = props => <DropdownButton {...props} />,
placeholder,
selected,
onSelectedChange,
onFilterChange,
items,
overlayProps,
...listProps
}: SelectPanelProps): JSX.Element {
const onOpen: AnchoredOverlayProps['onOpen'] = useCallback(gesture => onOpenChange(true, gesture), [onOpenChange])
const onClose = useCallback(
(gesture: 'click-outside' | 'escape' | 'selection') => {
onOpenChange(false, gesture)
// ensure consuming component clears filter since the input will be blank on next open
onFilterChange('')
},
[onFilterChange, onOpenChange]
)

const renderMenuAnchor: AnchoredOverlayProps['renderAnchor'] = useCallback(
props => {
const selectedItems = Array.isArray(selected) ? selected : [...(selected ? [selected] : [])]

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

const itemsToRender = useMemo(() => {
return items.map(item => {
const isItemSelected = isMultiSelectVariant(selected) ? selected.includes(item) : selected === item

return {
...item,
role: 'option',
selected: 'selected' in item && item.selected === undefined ? undefined : isItemSelected,
onAction: (itemFromAction, event) => {
item.onAction?.(itemFromAction, event)

if (event.defaultPrevented) {
return
}

if (isMultiSelectVariant(selected)) {
// multi select
const otherSelectedItems = selected.filter(selectedItem => selectedItem !== item)
const newSelectedItems = selected.includes(item) ? otherSelectedItems : [...otherSelectedItems, item]

const multiSelectOnChange = onSelectedChange as SelectPanelMultiSelection['onSelectedChange']
multiSelectOnChange(newSelectedItems)
return
}

// single select
const singleSelectOnChange = onSelectedChange as SelectPanelSingleSelection['onSelectedChange']
singleSelectOnChange(item === selected ? undefined : item)
onClose('selection')
}
} as ItemProps
})
}, [onClose, onSelectedChange, items, selected])

return (
<AnchoredOverlay
renderAnchor={renderMenuAnchor}
open={open}
onOpen={onOpen}
onClose={onClose}
overlayProps={overlayProps}
focusZoneSettings={focusZoneSettings}
>
<Flex flexDirection="column" width="100%" height="100%">
<FilteredActionList
onFilterChange={onFilterChange}
{...listProps}
role="listbox"
items={itemsToRender}
selectionVariant={isMultiSelectVariant(selected) ? 'multiple' : 'single'}
textInputProps={textInputProps}
/>
</Flex>
</AnchoredOverlay>
)
}

SelectPanel.displayName = 'SelectPanel'
2 changes: 2 additions & 0 deletions src/SelectPanel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {SelectPanel} from './SelectPanel'
export type {SelectPanelProps} from './SelectPanel'
63 changes: 63 additions & 0 deletions src/__tests__/SelectPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {cleanup, render as HTMLRender} from '@testing-library/react'
import 'babel-polyfill'
import {axe, toHaveNoViolations} from 'jest-axe'
import React from 'react'
import theme from '../theme'
import {SelectPanel} from '../SelectPanel'
import {COMMON} from '../constants'
import {behavesAsComponent, checkExports} from '../utils/testing'
import {BaseStyles, ThemeProvider} from '..'
import {ItemInput} from '../ActionList/List'

expect.extend(toHaveNoViolations)

const items = [{text: 'Foo'}, {text: 'Bar'}, {text: 'Baz'}, {text: 'Bon'}] as ItemInput[]

function SimpleSelectPanel(): JSX.Element {
const [selected, setSelected] = React.useState<ItemInput[]>([])
const [, setFilter] = React.useState('')
const [open, setOpen] = React.useState(false)

return (
<ThemeProvider theme={theme}>
<BaseStyles>
<SelectPanel
items={items}
placeholder="Select Items"
placeholderText="Filter Items"
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
open={open}
onOpenChange={setOpen}
/>
<div id="portal-root"></div>
</BaseStyles>
</ThemeProvider>
)
}

describe('SelectPanel', () => {
afterEach(() => {
jest.clearAllMocks()
})

behavesAsComponent({
Component: SelectPanel,
systemPropArray: [COMMON],
options: {skipAs: true, skipSx: true},
toRender: () => <SimpleSelectPanel />
})

checkExports('SelectPanel', {
default: undefined,
SelectPanel
})

it('should have no axe violations', async () => {
const {container} = HTMLRender(<SimpleSelectPanel />)
const results = await axe(container)
expect(results).toHaveNoViolations()
cleanup()
})
})
Loading

1 comment on commit 909ada5

@vercel
Copy link

@vercel vercel bot commented on 909ada5 May 18, 2021

Choose a reason for hiding this comment

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

Please sign in to comment.