Skip to content

Commit

Permalink
Do not validate options by default in OptionsDropdown
Browse files Browse the repository at this point in the history
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
ustun-ed committed Nov 27, 2024
1 parent c83f5b0 commit aab430a
Showing 1 changed file with 161 additions and 143 deletions.
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>
);
}

0 comments on commit aab430a

Please sign in to comment.