Batch Changes: Replace MultiSelect with wildcard MultiCombobox UI (#46450)

* Add first version of MultiCombobox UI

* Fix problem with value set in combobox input

* Replace multiselect with MultiCombobox UI on the batch changes list page

* Remove react-select package

* Add custom styles to batch changes list filter UI

* Bring Input and Select types back

* Fix no state option

* Fix by review comments

* Clear out the search input after you picked option
This commit is contained in:
Vova Kulikov 2023-01-17 22:07:16 -03:00 committed by GitHub
parent 03f776c4a8
commit da745613cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 187 additions and 764 deletions

View File

@ -1,7 +1,5 @@
import { Optional } from 'utility-types'
import { MultiSelectState } from '@sourcegraph/wildcard'
import { BatchChangeState } from '../../graphql-operations'
import { DiffMode } from './diffMode'
@ -9,6 +7,14 @@ import { RecentSearch } from './recentSearches'
import { SectionID, NoResultsSectionID } from './searchSidebar'
import { TourListState } from './tourState'
// Prior to this type we store in settings list of MultiSelectState
// we no longer use MultiSelect UI but for backward compatibility we still
// have to store and parse the old version of batch changes filters
export interface LegacyBatchChangesFilter {
label: string
value: BatchChangeState
}
/**
* Schema for temporary settings.
*/
@ -31,7 +37,7 @@ export interface TemporarySettingsSchema {
'user.themePreference': string
'signup.finishedWelcomeFlow': boolean
'homepage.userInvites.tab': number
'batches.defaultListFilters': MultiSelectState<BatchChangeState>
'batches.defaultListFilters': LegacyBatchChangesFilter[]
'batches.downloadSpecModalDismissed': boolean
'codeintel.badge.used': boolean
'codeintel.referencePanel.redesign.ctaDismissed': boolean

View File

@ -0,0 +1,8 @@
.root {
display: flex;
align-items: center;
gap: 0.5rem;
// Normalize font-weight within Label element
font-weight: normal;
}

View File

@ -1,37 +1,96 @@
import React from 'react'
import { FC, useCallback, useId, useState } from 'react'
import { H3, H4, MultiSelect, MultiSelectOption, MultiSelectProps } from '@sourcegraph/wildcard'
import classNames from 'classnames'
import { upperFirst } from 'lodash'
import {
H3,
H4,
Label,
MultiCombobox,
MultiComboboxInput,
MultiComboboxPopover,
MultiComboboxList,
MultiComboboxEmptyList,
MultiComboboxOption,
} from '@sourcegraph/wildcard'
import { BatchChangeState } from '../../../graphql-operations'
export const OPEN_STATUS: MultiSelectOption<BatchChangeState> = { label: 'Open', value: BatchChangeState.OPEN }
export const DRAFT_STATUS: MultiSelectOption<BatchChangeState> = { label: 'Draft', value: BatchChangeState.DRAFT }
export const CLOSED_STATUS: MultiSelectOption<BatchChangeState> = { label: 'Closed', value: BatchChangeState.CLOSED }
import styles from './BatchChangeListFilter.module.scss'
export const STATUS_OPTIONS: MultiSelectOption<BatchChangeState>[] = [OPEN_STATUS, DRAFT_STATUS, CLOSED_STATUS]
// Drafts are a new feature of severside execution that for now should not be shown if
// execution is not enabled.
const STATUS_OPTIONS_NO_DRAFTS: MultiSelectOption<BatchChangeState>[] = [OPEN_STATUS, CLOSED_STATUS]
/** Returns string with capitalized first letter */
const format = (filter: BatchChangeState): string => upperFirst(filter.toLowerCase())
interface BatchChangeListFiltersProps
extends Required<Pick<MultiSelectProps<MultiSelectOption<BatchChangeState>>, 'onChange' | 'value'>> {
interface BatchChangeListFiltersProps {
filters: BatchChangeState[]
selectedFilters: BatchChangeState[]
onFiltersChange: (filters: BatchChangeState[]) => void
className?: string
isExecutionEnabled: boolean
}
export const BatchChangeListFilters: React.FunctionComponent<React.PropsWithChildren<BatchChangeListFiltersProps>> = ({
isExecutionEnabled,
...props
}) => (
<>
{/* TODO: This should be a proper label. MultiSelect currently doesn't support that being inline though, so this is for later. */}
<H4 as={H3} className="mb-0 mr-2">
Status
</H4>
<MultiSelect
{...props}
options={isExecutionEnabled ? STATUS_OPTIONS : STATUS_OPTIONS_NO_DRAFTS}
aria-label="Select batch change status to filter."
/>
</>
)
export const BatchChangeListFilters: FC<BatchChangeListFiltersProps> = props => {
const { filters, selectedFilters, onFiltersChange, className } = props
const id = useId()
const [searchTerm, setSearchTerm] = useState('')
const handleFilterChange = useCallback(
(newFilters: BatchChangeState[]) => {
if (newFilters.length > selectedFilters.length) {
// Reset value when we add new filter
// see https://github.com/sourcegraph/sourcegraph/pull/46450#discussion_r1070840089
setSearchTerm('')
}
onFiltersChange(newFilters)
},
[selectedFilters, onFiltersChange]
)
// Render only non-selected filters and filters that match with search term value
const suggestions = filters.filter(
filter => !selectedFilters.includes(filter) && filter.toLowerCase().includes(searchTerm.toLowerCase())
)
return (
<Label htmlFor={id} className={classNames(className, styles.root)}>
<H4 as={H3} className="mb-0 mr-2">
Status
</H4>
<MultiCombobox
selectedItems={selectedFilters}
getItemName={format}
getItemKey={format}
onSelectedItemsChange={handleFilterChange}
aria-label="Select batch change status to filter."
>
<MultiComboboxInput
id={id}
value={searchTerm}
autoCorrect="false"
autoComplete="off"
placeholder="Select filter..."
onChange={event => setSearchTerm(event.target.value)}
/>
<MultiComboboxPopover>
<MultiComboboxList items={suggestions}>
{filters =>
filters.map((filter, index) => (
<MultiComboboxOption key={filter.toString()} value={format(filter)} index={index} />
))
}
</MultiComboboxList>
{suggestions.length === 0 && (
<MultiComboboxEmptyList>
{!searchTerm ? <>All filters are selected</> : <>No options</>}
</MultiComboboxEmptyList>
)}
</MultiComboboxPopover>
</MultiCombobox>
</Label>
)
}

View File

@ -83,7 +83,7 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
const isExecutionEnabled = isBatchChangesExecutionEnabled(settingsCascade)
const { selectedFilters, setSelectedFilters, selectedStates } = useBatchChangeListFilters()
const { selectedFilters, setSelectedFilters, availableFilters } = useBatchChangeListFilters({ isExecutionEnabled })
const [selectedTab, setSelectedTab] = useState<SelectedTab>(
openTab ?? (isSourcegraphDotCom ? 'gettingStarted' : 'batchChanges')
)
@ -118,7 +118,7 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
query: namespaceID ? BATCH_CHANGES_BY_NAMESPACE : BATCH_CHANGES,
variables: {
namespaceID,
states: selectedStates,
states: selectedFilters,
first: BATCH_CHANGES_PER_PAGE_COUNT,
after: null,
viewerCanAdminister: null,
@ -206,10 +206,10 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
)}
<BatchChangeListFilters
filters={availableFilters}
selectedFilters={selectedFilters}
onFiltersChange={setSelectedFilters}
className="m-0"
isExecutionEnabled={isExecutionEnabled}
value={selectedFilters}
onChange={setSelectedFilters}
/>
</div>
{error && <ConnectionError errors={[error.message]} />}

View File

@ -1,64 +1,85 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { lowerCase } from 'lodash'
import { useHistory } from 'react-router'
import { LegacyBatchChangesFilter } from '@sourcegraph/shared/src/settings/temporary/TemporarySettings'
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary/useTemporarySetting'
import { MultiSelectState } from '@sourcegraph/wildcard'
import { BatchChangeState } from '../../../graphql-operations'
import { STATUS_OPTIONS } from './BatchChangeListFilters'
const STATUS_OPTIONS = [BatchChangeState.OPEN, BatchChangeState.DRAFT, BatchChangeState.CLOSED]
const statesToFilters = (states: string[]): MultiSelectState<BatchChangeState> =>
STATUS_OPTIONS.filter(option => states.map(lowerCase).includes(option.value.toLowerCase()))
// Drafts are a new feature of serverside execution that for now should not be shown if
// execution is not enabled.
const STATUS_OPTIONS_NO_DRAFTS: BatchChangeState[] = [BatchChangeState.OPEN, BatchChangeState.CLOSED]
const filtersToStates = (filters: MultiSelectState<BatchChangeState>): BatchChangeState[] =>
filters.map(filter => filter.value)
const fromLegacyFilters = (legacyFilters: LegacyBatchChangesFilter[]): BatchChangeState[] =>
legacyFilters.map(legacyFilter => legacyFilter.value)
const toLegacyFilters = (filters: BatchChangeState[]): LegacyBatchChangesFilter[] =>
filters.map(filter => ({ label: filter.toString(), value: filter }))
interface UseBatchChangeListFiltersProps {
isExecutionEnabled: boolean
}
interface UseBatchChangeListFiltersResult {
/** State representing the different filters selected in the `MultiSelect` UI. */
selectedFilters: MultiSelectState<BatchChangeState>
selectedFilters: BatchChangeState[]
/** Method to set the filters selected in the `MultiSelect`. */
setSelectedFilters: (filters: MultiSelectState<BatchChangeState>) => void
setSelectedFilters: (filters: BatchChangeState[]) => void
/**
* Array of raw `BatchChangeState`s corresponding to `selectedFilters`, i.e. for
* passing in GraphQL connection query parameters.
* List of available batch changes filters, it may be different based on
* {@link isExecutionEnabled} prop
*/
selectedStates: BatchChangeState[]
availableFilters: BatchChangeState[]
}
/**
* Custom hook for managing, persisting, and transforming the state options selected from
* the `MultiSelect` UI to filter a list of batch changes.
* Custom hook for managing and persisting filter options selected from
* the MultiCombobox UI to filter a list of batch changes.
*/
export const useBatchChangeListFilters = (): UseBatchChangeListFiltersResult => {
export const useBatchChangeListFilters = (props: UseBatchChangeListFiltersProps): UseBatchChangeListFiltersResult => {
const { isExecutionEnabled } = props
const history = useHistory()
const availableFilters = isExecutionEnabled ? STATUS_OPTIONS : STATUS_OPTIONS_NO_DRAFTS
// NOTE: Fetching this setting is an async operation, so we can't use it as the
// initial value for `useState`. Instead, we will set the value of the filter state in
// a `useEffect` hook once we've loaded it.
const [defaultFilters, setDefaultFilters] = useTemporarySetting('batches.defaultListFilters', [])
const [selectedFilters, setSelectedFiltersRaw] = useState<MultiSelectState<BatchChangeState>>(() => {
const [hasModifiedFilters, setHasModifiedFilters] = useState(false)
const [selectedFilters, setSelectedFiltersRaw] = useState<BatchChangeState[]>(() => {
const searchParameters = new URLSearchParams(history.location.search).get('states')
if (searchParameters) {
return statesToFilters(searchParameters.split(','))
const loweredCaseFilters = new Set(availableFilters.map(filter => filter.toLowerCase()))
const urlFilters = searchParameters.split(',').map(option => option.toLowerCase())
return urlFilters
.filter(urlFilter => loweredCaseFilters.has(urlFilter))
.map(urlFilters => urlFilters.toUpperCase() as BatchChangeState)
}
return []
})
const [hasModifiedFilters, setHasModifiedFilters] = useState(false)
const setSelectedFilters = useCallback(
(filters: MultiSelectState<BatchChangeState>) => {
setHasModifiedFilters(true)
setSelectedFiltersRaw(filters)
setDefaultFilters(filters)
(filters: BatchChangeState[]) => {
const searchParameters = new URLSearchParams(history.location.search)
if (filters.length > 0) {
searchParameters.set('states', filtersToStates(filters).join(',').toLowerCase())
searchParameters.set(
'states',
filters
.map(filter => filter.toLowerCase())
.join(',')
.toLowerCase()
)
} else {
searchParameters.delete('states')
}
@ -66,6 +87,10 @@ export const useBatchChangeListFilters = (): UseBatchChangeListFiltersResult =>
if (history.location.search !== searchParameters.toString()) {
history.replace({ ...history.location, search: searchParameters.toString() })
}
setHasModifiedFilters(true)
setSelectedFiltersRaw(filters)
setDefaultFilters(toLegacyFilters(filters))
},
[setDefaultFilters, history]
)
@ -77,15 +102,13 @@ export const useBatchChangeListFilters = (): UseBatchChangeListFiltersResult =>
const searchParameters = new URLSearchParams(history.location.search).get('states')
if (defaultFilters && !hasModifiedFilters && !searchParameters) {
setSelectedFiltersRaw(defaultFilters)
setSelectedFiltersRaw(fromLegacyFilters(defaultFilters))
}
}, [defaultFilters, hasModifiedFilters, history.location.search])
const selectedStates = useMemo<BatchChangeState[]>(() => filtersToStates(selectedFilters), [selectedFilters])
return {
selectedFilters,
setSelectedFilters,
selectedStates,
availableFilters,
}
}

View File

@ -93,3 +93,10 @@
display: none;
}
}
.zero-state {
color: var(--text-muted);
font-size: 0.75rem;
padding: 0.5rem;
display: block;
}

View File

@ -172,6 +172,7 @@ export const MultiComboboxInput = forwardRef<HTMLInputElement, MultiComboboxInpu
selectOnClick={false}
autocomplete={false}
value={value.toString()}
byPassValue={value.toString()}
{...attributes}
/>
)
@ -179,12 +180,13 @@ export const MultiComboboxInput = forwardRef<HTMLInputElement, MultiComboboxInpu
interface MultiValueInputProps extends InputHTMLAttributes<HTMLInputElement> {
status?: InputStatus | `${InputStatus}`
byPassValue: string
}
// Forward ref doesn't support function components with generic,
// so we have to cast a proper FC types with generic props
const MultiValueInput = forwardRef((props: MultiValueInputProps, ref: Ref<HTMLInputElement>) => {
const { onKeyDown, onFocus, onBlur, value, ...attributes } = props
const { onKeyDown, onFocus, onBlur, byPassValue, value, ...attributes } = props
const {
setInputElement,
@ -201,7 +203,7 @@ const MultiValueInput = forwardRef((props: MultiValueInputProps, ref: Ref<HTMLIn
const listRef = useMergeRefs<HTMLUListElement>([setInputElement])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (value === '' && event.key === Key.Backspace) {
if (byPassValue === '' && event.key === Key.Backspace) {
onSelectedItemsChange(selectedItems.slice(0, -1))
// Prevent any single combobox UI state machine updates
@ -265,6 +267,7 @@ const MultiValueInput = forwardRef((props: MultiValueInputProps, ref: Ref<HTMLIn
))}
<Input
{...attributes}
value={byPassValue}
ref={inputRef}
className={styles.inputContainer}
inputClassName={styles.input}
@ -326,6 +329,14 @@ export function MultiComboboxList<T>(props: MultiComboboxListProps<T>): ReactEle
)
}
interface MultiComboboxEmptyListProps extends HTMLAttributes<HTMLSpanElement> {}
export function MultiComboboxEmptyList(props: MultiComboboxEmptyListProps): ReactElement {
const { className, ...attributes } = props
return <span {...attributes} className={classNames(className, styles.zeroState)} />
}
interface MultiComboboxOptionProps extends ComboboxOptionProps {
className?: string
}

View File

@ -13,6 +13,7 @@ export {
MultiComboboxInput,
MultiComboboxPopover,
MultiComboboxList,
MultiComboboxEmptyList,
MultiComboboxOptionGroup,
MultiComboboxOption,
MultiComboboxOptionText,

View File

@ -1,19 +0,0 @@
import { ReactElement } from 'react'
import { mdiClose } from '@mdi/js'
import { components, ClearIndicatorProps } from 'react-select'
import { Icon } from '../../Icon'
import { MultiSelectOption } from './types'
import styles from './MultiSelect.module.scss'
// Overwrite the clear indicator with `CloseIcon`
export const ClearIndicator = <OptionValue extends unknown = unknown>(
props: ClearIndicatorProps<MultiSelectOption<OptionValue>, true>
): ReactElement => (
<components.ClearIndicator {...props}>
<Icon className={styles.clearIcon} svgPath={mdiClose} inline={false} aria-hidden={true} />
</components.ClearIndicator>
)

View File

@ -1,24 +0,0 @@
import { ReactElement } from 'react'
import { mdiChevronDown } from '@mdi/js'
import { components, DropdownIndicatorProps } from 'react-select'
import { Icon } from '../../Icon'
import { MultiSelectOption } from './types'
import styles from './MultiSelect.module.scss'
// Overwrite the dropdown indicator with `ChevronDownIcon`
export const DropdownIndicator = <OptionValue extends unknown = unknown>(
props: DropdownIndicatorProps<MultiSelectOption<OptionValue>, true>
): ReactElement => (
<components.DropdownIndicator {...props}>
<Icon
className={props.isDisabled ? styles.dropdownIconDisabled : styles.dropdownIcon}
svgPath={mdiChevronDown}
inline={false}
aria-hidden={true}
/>
</components.DropdownIndicator>
)

View File

@ -1,62 +0,0 @@
.multi-select {
min-width: 6.5rem;
:global(.theme-light) & {
--react-select-neutral0: var(--white);
--react-select-neutral5: var(--gray-01);
--react-select-neutral10: var(--gray-01);
--react-select-neutral20: var(--gray-02);
--react-select-neutral30: var(--gray-03);
--react-select-neutral40: var(--gray-04);
--react-select-neutral50: var(--gray-05);
--react-select-neutral60: var(--gray-06);
--react-select-neutral70: var(--gray-07);
--react-select-neutral80: var(--gray-08);
--react-select-neutral90: var(--gray-09);
--select-button-border-color: var(--secondary-3);
}
:global(.theme-dark) & {
--react-select-neutral0: var(--black);
--react-select-neutral5: var(--gray-09);
--react-select-neutral10: var(--gray-08);
--react-select-neutral20: var(--gray-07);
--react-select-neutral30: var(--gray-06);
--react-select-neutral40: var(--gray-05);
--react-select-neutral50: var(--gray-04);
--react-select-neutral60: var(--gray-03);
--react-select-neutral70: var(--gray-02);
--react-select-neutral80: var(--gray-01);
--react-select-neutral90: var(--white);
--select-button-border-color: var(--secondary);
}
input:focus-within {
box-shadow: none;
}
}
.clear-icon {
color: var(--icon-color);
height: 1rem;
}
.dropdown-icon {
color: var(--icon-color);
height: 1rem;
}
.dropdown-icon-disabled {
color: var(--text-disabled);
height: 1rem;
}
.remove-icon {
width: 0.75rem;
height: 0.75rem;
}
.multi-value-label {
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -1,95 +0,0 @@
import { useState } from 'react'
import { Meta, Story } from '@storybook/react'
import { H1, H2 } from '../..'
import { BrandedStory } from '../../../stories/BrandedStory'
import { Grid } from '../../Grid/Grid'
import { MultiSelect, MultiSelectProps, MultiSelectState, MultiSelectOption } from '.'
const config: Meta = {
title: 'wildcard/MultiSelect',
decorators: [story => <BrandedStory>{() => <div className="container mt-3">{story()}</div>}</BrandedStory>],
}
export default config
type OptionValue = 'chocolate' | 'strawberry' | 'vanilla' | 'green tea' | 'rocky road' | 'really long'
const OPTIONS: MultiSelectOption<OptionValue>[] = [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' },
{ value: 'green tea', label: 'Green Tea' },
{ value: 'rocky road', label: 'Rocky Road' },
{ value: 'really long', label: 'A really really really REALLY long ice cream flavor' },
]
const BaseSelect = (props: Partial<Pick<MultiSelectProps, 'isValid' | 'isDisabled'>>) => {
const [selectedOptions, setSelectedOptions] = useState<MultiSelectState<OptionValue>>([])
return (
<MultiSelect
options={OPTIONS}
defaultValue={selectedOptions}
onChange={setSelectedOptions}
message="I am a message"
label="Select your favorite ice cream flavors."
aria-label="Select your favorite ice cream flavors."
{...props}
/>
)
}
const SelectWithValues = () => {
const [selectedOptions, setSelectedOptions] = useState<MultiSelectState<OptionValue>>([OPTIONS[5], OPTIONS[1]])
return (
<MultiSelect
options={OPTIONS}
defaultValue={selectedOptions}
onChange={setSelectedOptions}
message="I am a message"
label="Select your favorite ice cream flavors."
aria-label="Select your favorite ice cream flavors."
/>
)
}
export const MultiSelectExamples: Story = () => (
<>
<H1>Multi Select</H1>
<Grid columnCount={4}>
<div>
<H2>Standard</H2>
<BaseSelect />
</div>
<div>
<H2>Valid</H2>
<BaseSelect isValid={true} />
</div>
<div>
<H2>Invalid</H2>
<BaseSelect isValid={false} />
</div>
<div>
<H2>Disabled</H2>
<BaseSelect isDisabled={true} />
</div>
</Grid>
<H2>Pre-selected values (300px wide container)</H2>
<div style={{ width: '300px ' }}>
<SelectWithValues />
</div>
</>
)
MultiSelectExamples.parameters = {
chromatic: {
enableDarkMode: true,
disableSnapshot: false,
},
}

View File

@ -1,76 +0,0 @@
import { ReactElement } from 'react'
import classNames from 'classnames'
import Select, { Props as SelectProps, StylesConfig, GroupBase } from 'react-select'
import { AccessibleFieldProps } from '../internal/AccessibleFieldType'
import { FormFieldLabel } from '../internal/FormFieldLabel'
import { FormFieldMessage } from '../internal/FormFieldMessage'
import { getValidStyle } from '../internal/utils'
import { ClearIndicator } from './ClearIndicator'
import { DropdownIndicator } from './DropdownIndicator'
import { MultiValueContainer } from './MultiValueContainer'
import { MultiValueLabel } from './MultiValueLabel'
import { MultiValueRemove } from './MultiValueRemove'
import { STYLES } from './styles'
import { THEME } from './theme'
import { MultiSelectOption } from './types'
import selectStyles from '../Select/Select.module.scss'
import styles from './MultiSelect.module.scss'
export type MultiSelectProps<Option = unknown> = AccessibleFieldProps<
SelectProps<Option, true> & { options: SelectProps<Option, true>['options'] } & {
// Require options
/**
* Optional label position. Default is 'inline'
*/
labelVariant?: 'inline' | 'block'
}
>
/**
* A wrapper around `react-select`'s `Select` component for producing multiselect dropdown
* components.
*
* `MultiSelect` should be used to provide a user with a list of options from which they
* can select none to many, within a form.
*
* @param options An array of the `Option`s to be listed from the dropdown.
*/
export const MultiSelect = <OptionValue extends unknown = unknown>({
className,
labelVariant,
message,
options,
...props
}: MultiSelectProps<MultiSelectOption<OptionValue>>): ReactElement => (
<div className={classNames('form-group', className)}>
{'label' in props && (
<FormFieldLabel
htmlFor={props.id}
className={labelVariant === 'block' ? selectStyles.labelBlock : undefined}
>
{props.label}
</FormFieldLabel>
)}
<Select<MultiSelectOption<OptionValue>, true, GroupBase<MultiSelectOption<OptionValue>>>
isMulti={true}
className={classNames(styles.multiSelect, getValidStyle(props.isValid))}
options={options}
theme={THEME}
styles={STYLES as StylesConfig<MultiSelectOption<OptionValue>>}
hideSelectedOptions={false}
components={{
ClearIndicator,
DropdownIndicator,
MultiValueContainer,
MultiValueLabel,
MultiValueRemove,
}}
{...props}
/>
{message && <FormFieldMessage isValid={props.isValid}>{message}</FormFieldMessage>}
</div>
)

View File

@ -1,16 +0,0 @@
import { ReactElement } from 'react'
import { MultiValueGenericProps } from 'react-select'
import { Badge } from '../../Badge'
import { MultiSelectOption } from './types'
// Overwrite the multi value container with Wildcard `Badge`
export const MultiValueContainer = <OptionValue extends unknown = unknown>({
innerProps: _innerProps,
selectProps: _selectProps,
...props
}: MultiValueGenericProps<MultiSelectOption<OptionValue>, true>): ReactElement => (
<Badge variant="secondary" className={_innerProps.className} {...props} />
)

View File

@ -1,16 +0,0 @@
import { ReactElement } from 'react'
import { MultiValueGenericProps } from 'react-select'
import { MultiSelectOption } from './types'
import styles from './MultiSelect.module.scss'
// Remove extra wrappers around multi value label
export const MultiValueLabel = <OptionValue extends unknown = unknown>({
innerProps: _innerProps,
selectProps: _selectProps,
...props
}: MultiValueGenericProps<MultiSelectOption<OptionValue>, true>): ReactElement => (
<span className={styles.multiValueLabel} {...props} />
)

View File

@ -1,19 +0,0 @@
import { ReactElement } from 'react'
import { mdiClose } from '@mdi/js'
import { components, MultiValueRemoveProps } from 'react-select'
import { Icon } from '../../Icon'
import { MultiSelectOption } from './types'
import styles from './MultiSelect.module.scss'
// Overwrite the multi value remove indicator with `CloseIcon`
export const MultiValueRemove = <OptionValue extends unknown = unknown>(
props: MultiValueRemoveProps<MultiSelectOption<OptionValue>, true>
): ReactElement => (
<components.MultiValueRemove {...props}>
<Icon className={styles.removeIcon} svgPath={mdiClose} inline={false} aria-hidden={true} />
</components.MultiValueRemove>
)

View File

@ -1,2 +0,0 @@
export * from './MultiSelect'
export * from './types'

View File

@ -1,129 +0,0 @@
import { StylesConfig } from 'react-select'
export const STYLES: StylesConfig = {
clearIndicator: provided => ({
...provided,
padding: '0.125rem 0',
borderRadius: 'var(--border-radius)',
'&:hover': {
background: 'var(--secondary-3)',
},
}),
control: (provided, state) => ({
...provided,
// Styles here replicate the styles of `wildcard/Select`
backgroundColor: state.isDisabled ? 'var(--input-disabled-bg)' : 'var(--input-bg)',
borderColor: state.selectProps.isValid
? 'var(--success)'
: state.selectProps.isValid === false
? 'var(--danger)'
: state.isFocused
? state.theme.colors.primary
: 'var(--input-border-color)',
boxShadow: state.isFocused
? // These are stolen from `wildcard/Input` and `wildcard/Select`, which come from `client/wildcard/src/global-styles/forms.scss`
state.selectProps.isValid
? 'var(--input-focus-box-shadow-valid)'
: state.selectProps.isValid === false
? 'var(--input-focus-box-shadow-invalid)'
: 'var(--input-focus-box-shadow)'
: undefined,
cursor: 'pointer',
'&:hover': {
borderColor: undefined,
},
}),
dropdownIndicator: provided => ({
...provided,
padding: '0.125rem 0',
borderRadius: 'var(--border-radius)',
'&:hover': {
background: 'var(--secondary-3)',
},
}),
indicatorSeparator: (provided, state) => ({
...provided,
backgroundColor: state.hasValue ? 'var(--input-border-color)' : 'transparent',
}),
input: provided => ({
...provided,
color: 'var(--input-color)',
margin: '0 0.125rem',
padding: 0,
}),
menu: provided => ({
...provided,
background: 'var(--dropdown-bg)',
padding: 0,
margin: '0.125rem 0 0',
dropShadow: 'var(--dropdown-shadow)',
// This is to prevent item edges from sticking out of the rounded dropdown container
overflow: 'hidden',
}),
menuList: provided => ({
...provided,
padding: 0,
}),
multiValue: (provided, state) => ({
display: 'flex',
maxWidth: '100%',
alignItems: 'center',
padding: '0 0 0 0.5rem',
margin: '0.125rem',
background: state.isFocused ? 'var(--secondary-3)' : 'var(--secondary)',
borderStyle: 'solid',
borderWidth: '1px',
borderColor: state.isFocused ? 'var(--select-button-border-color)' : 'transparent',
'&:hover': {
background: 'var(--secondary-3)',
borderColor: 'var(--select-button-border-color)',
},
}),
multiValueRemove: (provided, state) => ({
...provided,
padding: '4px',
marginLeft: '0.25rem',
borderRadius: '0 var(--border-radius) var(--border-radius) 0',
background: state.isFocused ? 'var(--secondary-3)' : undefined,
':hover': {
...provided[':hover'],
background: 'var(--secondary-3)',
color: undefined,
},
}),
noOptionsMessage: provided => ({
...provided,
color: 'var(--input-placeholder-color)',
}),
option: (provided, state) => ({
...provided,
backgroundColor: state.isSelected
? state.isFocused
? 'var(--primary-3)'
: state.theme.colors.primary
: state.isFocused
? 'var(--dropdown-link-hover-bg)'
: undefined,
color: state.isSelected ? 'var(--light-text)' : undefined,
':hover': {
cursor: 'pointer',
},
':active': {
backgroundColor: state.isSelected
? state.isFocused
? 'var(--primary-3)'
: state.theme.colors.primary
: state.isFocused
? 'var(--dropdown-link-hover-bg)'
: undefined,
},
}),
placeholder: (provided, state) => ({
...provided,
color: state.isDisabled ? 'var(--gray-06)' : 'var(--input-placeholder-color)',
}),
valueContainer: provided => ({
...provided,
padding: '0.125rem 0.125rem 0.125rem 0.75rem',
}),
}

View File

@ -1,52 +0,0 @@
import { ThemeConfig } from 'react-select'
export const THEME: ThemeConfig = theme => ({
...theme,
borderRadius: 3,
// Each identifiable instance of `Select` components using a theme color has been
// overwritten by `STYLES` in order to switch to light mode/dark mode. These colors
// defined here only serve as a fallback in case of unexpected or missed color usage.
colors: {
primary: 'var(--primary)',
// Never used.
primary75: 'var(--primary)',
// Used for `option` background color, which is overwritten in `STYLES`.
primary50: 'var(--primary-2)',
// Used for `option` background color, which is overwritten in `STYLES`.
primary25: 'var(--primary-2)',
// Used for `multiValueRemove` color, which is replaced with a custom component.
danger: 'var(--danger)',
// Used for `multiValueRemove` background color, which is overwritten in `STYLES`.
dangerLight: 'var(--danger)',
// Used for `menu` and `control` background color as well as `option` color, all
// of which are overwritten in `STYLES`.
neutral0: 'var(--react-select-neutral0)',
// Used for `control` background color and `placeholder` color, both of which are
// overwritten in `STYLES`.
neutral5: 'var(--react-select-neutral5)',
// Used for `indicatorSeparator` background color and `control` border color, both
// of which are overwritten in `STYLES`, and `multiValue` background color, which
// is replaced with a custom component.
neutral10: 'var(--react-select-neutral10)',
// Used for `indicatorSeparator` background color, `control` border color, and
// `option` color, all of which are overwritten in `STYLES`.
neutral20: 'var(--react-select-neutral20)',
// Used for `control` border color, which is overwritten in `STYLES`.
neutral30: 'var(--react-select-neutral30)',
// Used by components that aren't used for `isMulti=true` or have been replaced
// with custom ones.
neutral40: 'var(--react-select-neutral40)',
// Used for `placeholder` color, which is overwritten in `STYLES`.
neutral50: 'var(--react-select-neutral50)',
// Used by components that have been replaced with custom ones.
neutral60: 'var(--react-select-neutral60)',
// Never used.
neutral70: 'var(--react-select-neutral70)',
// Used for `input` and `multiValue` color, which are both overwritten in
// `STYLES`, as well as components that aren't used for `isMulti=true` or have
// been replaced with custom ones.
neutral80: 'var(--react-select-neutral80)',
// Never used.
neutral90: 'var(--react-select-neutral90)',
},
})

View File

@ -1,31 +0,0 @@
import { GroupBase } from 'react-select'
import { AccessibleFieldProps } from '../internal/AccessibleFieldType'
/**
* Generic type for an option to be listed from the `MultiSelect` dropdown.
*
* @param OptionValue The type of the value of the option, i.e. a union set of all
* possible values.
*/
export interface MultiSelectOption<OptionValue = unknown> {
value: OptionValue
label: string
}
/**
* Generic type for the state a consumer of `MultiSelect` should expect to manage.
*
* @param OptionValue The type of the value of the option, i.e. a union set of all
* possible values.
*/
export type MultiSelectState<OptionValue = unknown> = readonly MultiSelectOption<OptionValue>[]
// We use module augmentation to make TS aware of custom props available from `Select`
// custom components, styles, theme, etc.
// See: https://react-select.com/typescript#custom-select-props
declare module 'react-select/dist/declarations/src/Select' {
export interface Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> {
isValid?: AccessibleFieldProps<{}>['isValid']
}
}

View File

@ -5,5 +5,4 @@ export * from './Input'
export * from './LoaderInput'
export * from './RadioButton'
export * from './Select'
export * from './MultiSelect'
export * from './TextArea'

View File

@ -24,7 +24,6 @@ export {
LoaderInput,
RadioButton,
Select,
MultiSelect,
TextArea,
InputStatus,
getInputStatus,
@ -76,6 +75,7 @@ export {
MultiComboboxInput,
MultiComboboxPopover,
MultiComboboxList,
MultiComboboxEmptyList,
MultiComboboxOptionGroup,
MultiComboboxOption,
MultiComboboxOptionText,
@ -87,9 +87,9 @@ export {
*/
export type { FeedbackPromptSubmitEventHandler } from './Feedback'
export type { AlertProps, AlertLinkProps } from './Alert'
export type { MultiSelectProps, MultiSelectOption, MultiSelectState, SelectProps, InputProps } from './Form'
export type { ButtonProps } from './Button'
export type { ButtonLinkProps } from './ButtonLink'
export type { SelectProps, InputProps } from './Form'
export type { Series, SeriesLikeChart, CategoricalLikeChart, LineChartProps, BarChartProps } from './Charts'
export type { LinkProps } from './Link'
export type { PopoverOpenEvent, Rectangle } from './Popover'

View File

@ -457,7 +457,6 @@
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "^6.3.0",
"react-select": "^5.2.2",
"react-spring": "^9.4.2",
"react-sticky-box": "1.0.2",
"react-visibility-sensor": "^5.1.1",

View File

@ -340,7 +340,6 @@ importers:
react-router: ^5.2.0
react-router-dom: ^5.2.0
react-router-dom-v5-compat: ^6.3.0
react-select: ^5.2.2
react-spring: ^9.4.2
react-sticky-box: 1.0.2
react-visibility-sensor: ^5.1.1
@ -520,7 +519,6 @@ importers:
react-router: 5.2.0_react@18.1.0
react-router-dom: 5.2.0_react@18.1.0
react-router-dom-v5-compat: 6.3.0_xjeikfjhclro5pp2abrahotxli
react-select: 5.2.2_ceda52rm5e5anx7ab4xo6b2wjy
react-spring: 9.4.2_ssv3vkwxg74iun7wj74vdfwzhu
react-sticky-box: 1.0.2_react@18.1.0
react-visibility-sensor: 5.1.1_ef5jwxihqo6n7gxfmzogljlgcm
@ -2739,95 +2737,6 @@ packages:
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
dev: true
/@emotion/babel-plugin/11.7.2_@babel+core@7.20.5:
resolution: {integrity: sha512-6mGSCWi9UzXut/ZAN6lGFu33wGR3SJisNl3c0tvlmb8XChH1b2SUvxvnOh7hvLpqyRdHHU9AiazV3Cwbk5SXKQ==}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
'@babel/core': 7.20.5
'@babel/helper-module-imports': 7.18.6
'@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.20.5
'@babel/runtime': 7.20.6
'@emotion/hash': 0.8.0
'@emotion/memoize': 0.7.5
'@emotion/serialize': 1.0.2
babel-plugin-macros: 2.8.0
convert-source-map: 1.7.0
escape-string-regexp: 4.0.0
find-root: 1.1.0
source-map: 0.5.7
stylis: 4.0.13
dev: false
/@emotion/cache/11.7.1:
resolution: {integrity: sha512-r65Zy4Iljb8oyjtLeCuBH8Qjiy107dOYC6SJq7g7GV5UCQWMObY4SJDPGFjiiVpPrOJ2hmJOoBiYTC7hwx9E2A==}
dependencies:
'@emotion/memoize': 0.7.5
'@emotion/sheet': 1.1.0
'@emotion/utils': 1.1.0
'@emotion/weak-memoize': 0.2.5
stylis: 4.0.13
dev: false
/@emotion/hash/0.8.0:
resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
dev: false
/@emotion/memoize/0.7.5:
resolution: {integrity: sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ==}
dev: false
/@emotion/react/11.8.1_kle6j4c75ehjy3nd72aprojeqy:
resolution: {integrity: sha512-XGaie4nRxmtP1BZYBXqC5JGqMYF2KRKKI7vjqNvQxyRpekVAZhb6QqrElmZCAYXH1L90lAelADSVZC4PFsrJ8Q==}
peerDependencies:
'@babel/core': ^7.0.0
'@types/react': '*'
react: '>=16.8.0'
peerDependenciesMeta:
'@babel/core':
optional: true
'@types/react':
optional: true
dependencies:
'@babel/core': 7.20.5
'@babel/runtime': 7.20.6
'@emotion/babel-plugin': 11.7.2_@babel+core@7.20.5
'@emotion/cache': 11.7.1
'@emotion/serialize': 1.0.2
'@emotion/sheet': 1.1.0
'@emotion/utils': 1.1.0
'@emotion/weak-memoize': 0.2.5
'@types/react': 18.0.8
hoist-non-react-statics: 3.3.2
react: 18.1.0
dev: false
/@emotion/serialize/1.0.2:
resolution: {integrity: sha512-95MgNJ9+/ajxU7QIAruiOAdYNjxZX7G2mhgrtDWswA21VviYIRP1R5QilZ/bDY42xiKsaktP4egJb3QdYQZi1A==}
dependencies:
'@emotion/hash': 0.8.0
'@emotion/memoize': 0.7.5
'@emotion/unitless': 0.7.5
'@emotion/utils': 1.1.0
csstype: 3.1.0
dev: false
/@emotion/sheet/1.1.0:
resolution: {integrity: sha512-u0AX4aSo25sMAygCuQTzS+HsImZFuS8llY8O7b9MDRzbJM0kVJlAz6KNDqcG7pOuQZJmj/8X/rAW+66kMnMW+g==}
dev: false
/@emotion/unitless/0.7.5:
resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==}
dev: false
/@emotion/utils/1.1.0:
resolution: {integrity: sha512-iRLa/Y4Rs5H/f2nimczYmS5kFJEbpiVvgN3XVfZ022IYhuNA1IRSHEizcof88LtCTXtl9S2Cxt32KgaXEu72JQ==}
dev: false
/@emotion/weak-memoize/0.2.5:
resolution: {integrity: sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==}
dev: false
/@esbuild/android-arm/0.16.10:
resolution: {integrity: sha512-RmJjQTRrO6VwUWDrzTBLmV4OJZTarYsiepLGlF2rYTVB701hSorPywPGvP6d8HCuuRibyXa5JX4s3jN2kHEtjQ==}
engines: {node: '>=12'}
@ -8595,12 +8504,6 @@ packages:
'@types/react': 18.0.8
dev: true
/@types/react-transition-group/4.4.4:
resolution: {integrity: sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==}
dependencies:
'@types/react': 18.0.8
dev: false
/@types/react/17.0.43:
resolution: {integrity: sha512-8Q+LNpdxf057brvPu1lMtC5Vn7J119xrP1aq4qiaefNioQUYANF/CYeK4NsKorSZyUGJ66g0IM+4bbjwx45o2A==}
dependencies:
@ -10253,14 +10156,6 @@ packages:
require-package-name: 2.0.1
dev: true
/babel-plugin-macros/2.8.0:
resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==}
dependencies:
'@babel/runtime': 7.20.6
cosmiconfig: 6.0.0
resolve: 1.22.0
dev: false
/babel-plugin-macros/3.0.1:
resolution: {integrity: sha512-CKt4+Oy9k2wiN+hT1uZzOw7d8zb1anbQpf7KLwaaXRCi/4pzKdFKHf7v5mvoPmjkmxshh7eKZQuRop06r5WP4w==}
engines: {node: '>=10', npm: '>=6'}
@ -11559,7 +11454,7 @@ packages:
color-name: 1.1.4
/color-name/1.1.3:
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
resolution: {integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=}
/color-name/1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
@ -13340,13 +13235,6 @@ packages:
'@babel/runtime': 7.20.6
dev: false
/dom-helpers/5.2.0:
resolution: {integrity: sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==}
dependencies:
'@babel/runtime': 7.20.6
csstype: 3.1.0
dev: false
/dom-serializer/0.2.2:
resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
dependencies:
@ -14882,10 +14770,6 @@ packages:
make-dir: 3.1.0
pkg-dir: 4.2.0
/find-root/1.1.0:
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
dev: false
/find-up/1.1.2:
resolution: {integrity: sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==}
engines: {node: '>=0.10.0'}
@ -22747,26 +22631,6 @@ packages:
react: 18.1.0
dev: false
/react-select/5.2.2_ceda52rm5e5anx7ab4xo6b2wjy:
resolution: {integrity: sha512-miGS2rT1XbFNjduMZT+V73xbJEeMzVkJOz727F6MeAr2hKE0uUSA8Ff7vD44H32x2PD3SRB6OXTY/L+fTV3z9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
dependencies:
'@babel/runtime': 7.20.6
'@emotion/cache': 11.7.1
'@emotion/react': 11.8.1_kle6j4c75ehjy3nd72aprojeqy
'@types/react-transition-group': 4.4.4
memoize-one: 5.0.4
prop-types: 15.8.1
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
react-transition-group: 4.4.1_ef5jwxihqo6n7gxfmzogljlgcm
transitivePeerDependencies:
- '@babel/core'
- '@types/react'
dev: false
/react-shallow-renderer/16.15.0_react@18.1.0:
resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==}
peerDependencies:
@ -22891,20 +22755,6 @@ packages:
react-lifecycles-compat: 3.0.4
dev: false
/react-transition-group/4.4.1_ef5jwxihqo6n7gxfmzogljlgcm:
resolution: {integrity: sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==}
peerDependencies:
react: '>=16.6.0'
react-dom: '>=16.6.0'
dependencies:
'@babel/runtime': 7.20.6
dom-helpers: 5.2.0
loose-envify: 1.4.0
prop-types: 15.8.1
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
dev: false
/react-use-measure/2.1.1_ef5jwxihqo6n7gxfmzogljlgcm:
resolution: {integrity: sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig==}
peerDependencies:
@ -24993,6 +24843,7 @@ packages:
/stylis/4.0.13:
resolution: {integrity: sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==}
dev: true
/sudo-prompt/9.2.1:
resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==}