-
Notifications
You must be signed in to change notification settings - Fork 526
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add SelectPanel alpha component (#1224)
- Loading branch information
Showing
9 changed files
with
508 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@primer/components": patch | ||
--- | ||
|
||
Add `SelectPanel` alpha component |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export {FilteredActionList} from './FilteredActionList' | ||
export type {FilteredActionListProps} from './FilteredActionList' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export {SelectPanel} from './SelectPanel' | ||
export type {SelectPanelProps} from './SelectPanel' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
Oops, something went wrong.
909ada5
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.
Successfully deployed to the following URLs: