Skip to content
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

Add SelectPanel alpha component #1224

Merged
merged 9 commits into from
May 18, 2021
Merged
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/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
Copy link
Member

@smockle smockle May 14, 2021

Choose a reason for hiding this comment

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

  1. Would you mind opening an issue tracking either a. “Promote SelectPanel to “beta” status” or b. “Provide examples in the SelectPanel docs”?
  2. What do you think about incorporating some of the prop documentation from SelectPanel #1071 and DropdownMenu #1070?

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 '..'
dgreif marked this conversation as resolved.
Show resolved Hide resolved
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