-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Do not validate options by default in OptionsDropdown
Previously Mantine punished the common scenario of duplicate keys too heavily, resulting in component failures in duplicate options data. Now duplicate values are silently ignored as in React.
- Loading branch information
Showing
1 changed file
with
161 additions
and
143 deletions.
There are no files selected for viewing
304 changes: 161 additions & 143 deletions
304
packages/@mantine/core/src/components/Combobox/OptionsDropdown/OptionsDropdown.tsx
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 |
---|---|---|
@@ -1,173 +1,191 @@ | ||
import cx from 'clsx'; | ||
import { CheckIcon } from '../../Checkbox'; | ||
import { ScrollArea, ScrollAreaProps } from '../../ScrollArea/ScrollArea'; | ||
import { Combobox } from '../Combobox'; | ||
import { ComboboxItem, ComboboxLikeRenderOptionInput, ComboboxParsedItem } from '../Combobox.types'; | ||
import { defaultOptionsFilter, FilterOptionsInput } from './default-options-filter'; | ||
import { isEmptyComboboxData } from './is-empty-combobox-data'; | ||
import { isOptionsGroup } from './is-options-group'; | ||
import { validateOptions } from './validate-options'; | ||
import classes from '../Combobox.module.css'; | ||
import cx from "clsx"; | ||
import { CheckIcon } from "../../Checkbox"; | ||
import { ScrollArea, ScrollAreaProps } from "../../ScrollArea/ScrollArea"; | ||
import { Combobox } from "../Combobox"; | ||
import { | ||
ComboboxItem, | ||
ComboboxLikeRenderOptionInput, | ||
ComboboxParsedItem, | ||
} from "../Combobox.types"; | ||
import { | ||
defaultOptionsFilter, | ||
FilterOptionsInput, | ||
} from "./default-options-filter"; | ||
import { isEmptyComboboxData } from "./is-empty-combobox-data"; | ||
import { isOptionsGroup } from "./is-options-group"; | ||
import { validateOptions as validateOptionsFn } from "./validate-options"; | ||
import classes from "../Combobox.module.css"; | ||
|
||
export type OptionsFilter = (input: FilterOptionsInput) => ComboboxParsedItem[]; | ||
|
||
export interface OptionsGroup { | ||
group: string; | ||
items: ComboboxItem[]; | ||
group: string; | ||
items: ComboboxItem[]; | ||
} | ||
|
||
export type OptionsData = (ComboboxItem | OptionsGroup)[]; | ||
|
||
interface OptionProps { | ||
data: ComboboxItem | OptionsGroup; | ||
withCheckIcon?: boolean; | ||
value?: string | string[] | null; | ||
checkIconPosition?: 'left' | 'right'; | ||
unstyled: boolean | undefined; | ||
renderOption?: (input: ComboboxLikeRenderOptionInput<any>) => React.ReactNode; | ||
data: ComboboxItem | OptionsGroup; | ||
withCheckIcon?: boolean; | ||
value?: string | string[] | null; | ||
checkIconPosition?: "left" | "right"; | ||
unstyled: boolean | undefined; | ||
renderOption?: (input: ComboboxLikeRenderOptionInput<any>) => React.ReactNode; | ||
} | ||
|
||
function isValueChecked(value: string | string[] | undefined | null, optionValue: string) { | ||
return Array.isArray(value) ? value.includes(optionValue) : value === optionValue; | ||
function isValueChecked( | ||
value: string | string[] | undefined | null, | ||
optionValue: string, | ||
) { | ||
return Array.isArray(value) | ||
? value.includes(optionValue) | ||
: value === optionValue; | ||
} | ||
|
||
function Option({ | ||
data, | ||
withCheckIcon, | ||
value, | ||
checkIconPosition, | ||
unstyled, | ||
renderOption, | ||
data, | ||
withCheckIcon, | ||
value, | ||
checkIconPosition, | ||
unstyled, | ||
renderOption, | ||
}: OptionProps) { | ||
if (!isOptionsGroup(data)) { | ||
const checked = isValueChecked(value, data.value); | ||
const check = withCheckIcon && checked && ( | ||
<CheckIcon className={classes.optionsDropdownCheckIcon} /> | ||
); | ||
if (!isOptionsGroup(data)) { | ||
const checked = isValueChecked(value, data.value); | ||
const check = withCheckIcon && checked && ( | ||
<CheckIcon className={classes.optionsDropdownCheckIcon} /> | ||
); | ||
|
||
const defaultContent = ( | ||
<> | ||
{checkIconPosition === 'left' && check} | ||
<span>{data.label}</span> | ||
{checkIconPosition === 'right' && check} | ||
</> | ||
); | ||
const defaultContent = ( | ||
<> | ||
{checkIconPosition === "left" && check} | ||
<span>{data.label}</span> | ||
{checkIconPosition === "right" && check} | ||
</> | ||
); | ||
|
||
return ( | ||
<Combobox.Option | ||
value={data.value} | ||
disabled={data.disabled} | ||
className={cx({ [classes.optionsDropdownOption]: !unstyled })} | ||
data-reverse={checkIconPosition === 'right' || undefined} | ||
data-checked={checked || undefined} | ||
aria-selected={checked} | ||
active={checked} | ||
> | ||
{typeof renderOption === 'function' | ||
? renderOption({ option: data, checked }) | ||
: defaultContent} | ||
</Combobox.Option> | ||
); | ||
} | ||
return ( | ||
<Combobox.Option | ||
value={data.value} | ||
disabled={data.disabled} | ||
className={cx({ [classes.optionsDropdownOption]: !unstyled })} | ||
data-reverse={checkIconPosition === "right" || undefined} | ||
data-checked={checked || undefined} | ||
aria-selected={checked} | ||
active={checked} | ||
> | ||
{typeof renderOption === "function" | ||
? renderOption({ option: data, checked }) | ||
: defaultContent} | ||
</Combobox.Option> | ||
); | ||
} | ||
|
||
const options = data.items.map((item) => ( | ||
<Option | ||
data={item} | ||
value={value} | ||
key={item.value} | ||
unstyled={unstyled} | ||
withCheckIcon={withCheckIcon} | ||
checkIconPosition={checkIconPosition} | ||
renderOption={renderOption} | ||
/> | ||
)); | ||
const options = data.items.map((item) => ( | ||
<Option | ||
data={item} | ||
value={value} | ||
key={item.value} | ||
unstyled={unstyled} | ||
withCheckIcon={withCheckIcon} | ||
checkIconPosition={checkIconPosition} | ||
renderOption={renderOption} | ||
/> | ||
)); | ||
|
||
return <Combobox.Group label={data.group}>{options}</Combobox.Group>; | ||
return <Combobox.Group label={data.group}>{options}</Combobox.Group>; | ||
} | ||
|
||
export interface OptionsDropdownProps { | ||
data: OptionsData; | ||
filter: OptionsFilter | undefined; | ||
search: string | undefined; | ||
limit: number | undefined; | ||
withScrollArea: boolean | undefined; | ||
maxDropdownHeight: number | string | undefined; | ||
hidden?: boolean; | ||
hiddenWhenEmpty?: boolean; | ||
filterOptions?: boolean; | ||
withCheckIcon?: boolean; | ||
value?: string | string[] | null; | ||
checkIconPosition?: 'left' | 'right'; | ||
nothingFoundMessage?: React.ReactNode; | ||
unstyled: boolean | undefined; | ||
labelId: string | undefined; | ||
'aria-label': string | undefined; | ||
renderOption?: (input: ComboboxLikeRenderOptionInput<any>) => React.ReactNode; | ||
scrollAreaProps: ScrollAreaProps | undefined; | ||
data: OptionsData; | ||
filter: OptionsFilter | undefined; | ||
search: string | undefined; | ||
limit: number | undefined; | ||
withScrollArea: boolean | undefined; | ||
maxDropdownHeight: number | string | undefined; | ||
hidden?: boolean; | ||
hiddenWhenEmpty?: boolean; | ||
filterOptions?: boolean; | ||
validateOptions?: boolean; | ||
withCheckIcon?: boolean; | ||
value?: string | string[] | null; | ||
checkIconPosition?: "left" | "right"; | ||
nothingFoundMessage?: React.ReactNode; | ||
unstyled: boolean | undefined; | ||
labelId: string | undefined; | ||
"aria-label": string | undefined; | ||
renderOption?: (input: ComboboxLikeRenderOptionInput<any>) => React.ReactNode; | ||
scrollAreaProps: ScrollAreaProps | undefined; | ||
} | ||
|
||
export function OptionsDropdown({ | ||
data, | ||
hidden, | ||
hiddenWhenEmpty, | ||
filter, | ||
search, | ||
limit, | ||
maxDropdownHeight, | ||
withScrollArea = true, | ||
filterOptions = true, | ||
withCheckIcon = false, | ||
value, | ||
checkIconPosition, | ||
nothingFoundMessage, | ||
unstyled, | ||
labelId, | ||
renderOption, | ||
scrollAreaProps, | ||
'aria-label': ariaLabel, | ||
data, | ||
hidden, | ||
hiddenWhenEmpty, | ||
filter, | ||
search, | ||
limit, | ||
maxDropdownHeight, | ||
withScrollArea = true, | ||
filterOptions = true, | ||
validateOptions = false, | ||
withCheckIcon = false, | ||
value, | ||
checkIconPosition, | ||
nothingFoundMessage, | ||
unstyled, | ||
labelId, | ||
renderOption, | ||
scrollAreaProps, | ||
"aria-label": ariaLabel, | ||
}: OptionsDropdownProps) { | ||
validateOptions(data); | ||
if (validateOptions) { | ||
validateOptionsFn(data); | ||
} | ||
|
||
const shouldFilter = typeof search === 'string'; | ||
const filteredData = shouldFilter | ||
? (filter || defaultOptionsFilter)({ | ||
options: data, | ||
search: filterOptions ? search : '', | ||
limit: limit ?? Infinity, | ||
}) | ||
: data; | ||
const isEmpty = isEmptyComboboxData(filteredData); | ||
const shouldFilter = typeof search === "string"; | ||
const filteredData = shouldFilter | ||
? (filter || defaultOptionsFilter)({ | ||
options: data, | ||
search: filterOptions ? search : "", | ||
limit: limit ?? Infinity, | ||
}) | ||
: data; | ||
const isEmpty = isEmptyComboboxData(filteredData); | ||
|
||
const options = filteredData.map((item) => ( | ||
<Option | ||
data={item} | ||
key={isOptionsGroup(item) ? item.group : item.value} | ||
withCheckIcon={withCheckIcon} | ||
value={value} | ||
checkIconPosition={checkIconPosition} | ||
unstyled={unstyled} | ||
renderOption={renderOption} | ||
/> | ||
)); | ||
const options = filteredData.map((item) => ( | ||
<Option | ||
data={item} | ||
key={isOptionsGroup(item) ? item.group : item.value} | ||
withCheckIcon={withCheckIcon} | ||
value={value} | ||
checkIconPosition={checkIconPosition} | ||
unstyled={unstyled} | ||
renderOption={renderOption} | ||
/> | ||
)); | ||
|
||
return ( | ||
<Combobox.Dropdown hidden={hidden || (hiddenWhenEmpty && isEmpty)}> | ||
<Combobox.Options labelledBy={labelId} aria-label={ariaLabel}> | ||
{withScrollArea ? ( | ||
<ScrollArea.Autosize | ||
mah={maxDropdownHeight ?? 220} | ||
type="scroll" | ||
scrollbarSize="var(--combobox-padding)" | ||
offsetScrollbars="y" | ||
{...scrollAreaProps} | ||
> | ||
{options} | ||
</ScrollArea.Autosize> | ||
) : ( | ||
options | ||
)} | ||
{isEmpty && nothingFoundMessage && <Combobox.Empty>{nothingFoundMessage}</Combobox.Empty>} | ||
</Combobox.Options> | ||
</Combobox.Dropdown> | ||
); | ||
return ( | ||
<Combobox.Dropdown hidden={hidden || (hiddenWhenEmpty && isEmpty)}> | ||
<Combobox.Options labelledBy={labelId} aria-label={ariaLabel}> | ||
{withScrollArea ? ( | ||
<ScrollArea.Autosize | ||
mah={maxDropdownHeight ?? 220} | ||
type="scroll" | ||
scrollbarSize="var(--combobox-padding)" | ||
offsetScrollbars="y" | ||
{...scrollAreaProps} | ||
> | ||
{options} | ||
</ScrollArea.Autosize> | ||
) : ( | ||
options | ||
)} | ||
{isEmpty && nothingFoundMessage && ( | ||
<Combobox.Empty>{nothingFoundMessage}</Combobox.Empty> | ||
)} | ||
</Combobox.Options> | ||
</Combobox.Dropdown> | ||
); | ||
} |