diff --git a/.changeset/khaki-meals-pay.md b/.changeset/khaki-meals-pay.md new file mode 100644 index 00000000000..044ed5528d9 --- /dev/null +++ b/.changeset/khaki-meals-pay.md @@ -0,0 +1,5 @@ +--- +"@primer/components": patch +--- + +Add `SelectPanel` alpha component diff --git a/docs/content/SelectPanel.mdx b/docs/content/SelectPanel.mdx new file mode 100644 index 00000000000..549a762d7cf --- /dev/null +++ b/docs/content/SelectPanel.mdx @@ -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 diff --git a/src/FilteredActionList/FilteredActionList.tsx b/src/FilteredActionList/FilteredActionList.tsx new file mode 100644 index 00000000000..369d43e43fc --- /dev/null +++ b/src/FilteredActionList/FilteredActionList.tsx @@ -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>, ListPropsBase { + loading?: boolean + placeholderText: string + onFilterChange: (value: string, e: React.ChangeEvent) => void + textInputProps?: Partial> +} + +export function FilteredActionList({ + loading = false, + placeholderText, + onFilterChange, + items, + textInputProps, + ...listProps +}: FilteredActionListProps): JSX.Element { + const onInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value + onFilterChange(value, e) + }, + [onFilterChange] + ) + + return ( + <> + + + {loading ? ( + + + + ) : ( + + )} + + + ) +} + +FilteredActionList.displayName = 'FilteredActionList' diff --git a/src/FilteredActionList/index.ts b/src/FilteredActionList/index.ts new file mode 100644 index 00000000000..3f8176fe71c --- /dev/null +++ b/src/FilteredActionList/index.ts @@ -0,0 +1,2 @@ +export {FilteredActionList} from './FilteredActionList' +export type {FilteredActionListProps} from './FilteredActionList' diff --git a/src/SelectPanel/SelectPanel.tsx b/src/SelectPanel/SelectPanel.tsx new file mode 100644 index 00000000000..01f85ab58b3 --- /dev/null +++ b/src/SelectPanel/SelectPanel.tsx @@ -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) => void + overlayProps?: Partial +} + +export type SelectPanelProps = SelectPanelBaseProps & + Omit & + Pick & + (SelectPanelSingleSelection | SelectPanelMultiSelection) + +function isMultiSelectVariant( + selected: SelectPanelSingleSelection['selected'] | SelectPanelMultiSelection['selected'] +): selected is SelectPanelMultiSelection['selected'] { + return Array.isArray(selected) +} + +const focusZoneSettings: Partial = { + focusOutBehavior: 'wrap', + focusableElementFilter: element => { + return !(element instanceof HTMLInputElement) || element.type !== 'checkbox' + } +} + +const textInputProps: Partial = { + mx: 2, + my: 2, + contrast: true +} + +export function SelectPanel({ + open, + onOpenChange, + renderAnchor = 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 ( + + + + + + ) +} + +SelectPanel.displayName = 'SelectPanel' diff --git a/src/SelectPanel/index.ts b/src/SelectPanel/index.ts new file mode 100644 index 00000000000..d07aecfa89b --- /dev/null +++ b/src/SelectPanel/index.ts @@ -0,0 +1,2 @@ +export {SelectPanel} from './SelectPanel' +export type {SelectPanelProps} from './SelectPanel' diff --git a/src/__tests__/SelectPanel.tsx b/src/__tests__/SelectPanel.tsx new file mode 100644 index 00000000000..aeef32e1ab3 --- /dev/null +++ b/src/__tests__/SelectPanel.tsx @@ -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([]) + const [, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + + return ( + + + +
+
+
+ ) +} + +describe('SelectPanel', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + behavesAsComponent({ + Component: SelectPanel, + systemPropArray: [COMMON], + options: {skipAs: true, skipSx: true}, + toRender: () => + }) + + checkExports('SelectPanel', { + default: undefined, + SelectPanel + }) + + it('should have no axe violations', async () => { + const {container} = HTMLRender() + const results = await axe(container) + expect(results).toHaveNoViolations() + cleanup() + }) +}) diff --git a/src/__tests__/__snapshots__/SelectPanel.tsx.snap b/src/__tests__/__snapshots__/SelectPanel.tsx.snap new file mode 100644 index 00000000000..cf5a9210656 --- /dev/null +++ b/src/__tests__/__snapshots__/SelectPanel.tsx.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectPanel renders consistently 1`] = ` +.c0 { + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + line-height: 1.5; + color: #24292e; +} + +.c1 { + position: relative; + display: inline-block; + padding: 6px 16px; + font-family: inherit; + font-weight: 600; + line-height: 20px; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + border-radius: 6px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + -webkit-text-decoration: none; + text-decoration: none; + text-align: center; + font-size: 14px; + color: #24292e; + background-color: #fafbfc; + border: 1px solid rgba(27,31,35,0.15); + box-shadow: 0 1px 0 rgba(27,31,35,0.04),inset 0 1px 0 rgba(255,255,255,0.25); +} + +.c1:hover { + -webkit-text-decoration: none; + text-decoration: none; +} + +.c1:focus { + outline: none; +} + +.c1:disabled { + cursor: default; +} + +.c1:disabled svg { + opacity: 0.6; +} + +.c1:hover { + background-color: #f3f4f6; + border-color: rgba(27,31,35,0.15); +} + +.c1:focus { + border-color: rgba(27,31,35,0.15); + box-shadow: 0 0 0 3px rgba(3,102,214,0.3); +} + +.c1:active { + background-color: #edeff2; + box-shadow: inset 0 0.15em 0.3em rgba(27,31,35,0.15); +} + +.c1:disabled { + color: #959da5; + background-color: #fafbfc; + border-color: rgba(27,31,35,0.15); +} + +.c2 { + margin-left: 4px; +} + +
+ +
+
+`; diff --git a/src/stories/SelectPanel.stories.tsx b/src/stories/SelectPanel.stories.tsx new file mode 100644 index 00000000000..d9ff546a30d --- /dev/null +++ b/src/stories/SelectPanel.stories.tsx @@ -0,0 +1,101 @@ +import {Meta} from '@storybook/react' +import React, {useState} from 'react' +import {theme, ThemeProvider} from '..' +import {ItemInput} from '../ActionList/List' +import BaseStyles from '../BaseStyles' +import {DropdownButton} from '../DropdownMenu' +import {SelectPanel} from '../SelectPanel' +import BorderBox from '../BorderBox' + +const meta: Meta = { + title: 'Composite components/SelectPanel', + component: SelectPanel, + decorators: [ + (Story: React.ComponentType): JSX.Element => { + return ( + + + + + + ) + } + ], + parameters: { + controls: { + disable: true + } + } +} +export default meta + +function getColorCircle(color: string) { + return function () { + return + } +} + +const items = [ + {leadingVisual: getColorCircle('#a2eeef'), text: 'enhancement', id: 1}, + {leadingVisual: getColorCircle('#d73a4a'), text: 'bug', id: 2}, + {leadingVisual: getColorCircle('#0cf478'), text: 'good first issue', id: 3}, + {leadingVisual: getColorCircle('#8dc6fc'), text: 'design', id: 4} +] + +export function MultiSelectStory(): JSX.Element { + const [selected, setSelected] = React.useState([items[0], items[1]]) + const [filter, setFilter] = React.useState('') + const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + const [open, setOpen] = useState(false) + + return ( + <> +

Multi Select Panel

+
Please select labels that describe your issue:
+ ( + + {children ?? 'Select Labels'} + + )} + placeholderText="Filter Labels" + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + /> + + ) +} +MultiSelectStory.storyName = 'Multi Select' + +export function SingleSelectStory(): JSX.Element { + const [selected, setSelected] = React.useState(items[0]) + const [filter, setFilter] = React.useState('') + const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + const [open, setOpen] = useState(false) + + return ( + <> +

Single Select Panel

+
Please select a label that describe your issue:
+ ( + + {children ?? 'Select Labels'} + + )} + placeholderText="Filter Labels" + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + /> + + ) +} +SingleSelectStory.storyName = 'Single Select'