-
Notifications
You must be signed in to change notification settings - Fork 529
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
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
c9eca6e
Add SelectPanel alpha component
dgreif 53cf010
refactor: simplify api based on pr feedback
dgreif 68da6ef
Simplify renderAnchor typing
dgreif 968d4bd
refactor: `useValueX` -> `onValueXChange`
dgreif 9fe5591
fix: pr feedback
dgreif b2e5b57
Merge branch 'main' into select-panel
dgreif 6b1ef0c
fix: use new `Spinner` instead of `LoadingAnimation`
dgreif 9f0af6e
chore: fix lint
dgreif 3d13326
Create khaki-meals-pay.md
dgreif File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 '..' | ||
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() | ||
}) | ||
}) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
SelectPanel
to “beta” status” or b. “Provide examples in theSelectPanel
docs”?