Skip to content

Instantly share code, notes, and snippets.

@jjj201200
Created June 18, 2025 22:41
Show Gist options
  • Save jjj201200/15cdfc0fd5b8729d761cb79356a50b33 to your computer and use it in GitHub Desktop.
Save jjj201200/15cdfc0fd5b8729d761cb79356a50b33 to your computer and use it in GitHub Desktop.
mantine/multiSelect support renderValue
import React, {useState, useCallback, useMemo, forwardRef} from 'react';
import {
Combobox,
PillsInput,
Pill,
Input,
CheckIcon,
Group,
ScrollArea,
CloseButton,
Box,
useCombobox,
type MantineSize,
type MantineRadius,
type FloatingPosition,
} from '@mantine/core';
// Option data type
export interface StrongSelectorOption {
value: string;
label: string;
hidden?: boolean;
disabled?: boolean;
[key: string]: any; // Allow additional custom fields
}
// Custom render function type
export interface StrongSelectorRenderProps {
option: StrongSelectorOption;
checked?: boolean;
hovered?: boolean;
}
// Component props interface
export interface StrongSelectorProps {
// Basic props
data: StrongSelectorOption[];
value?: string | string[] | null;
onChange?: (value: string | string[] | null) => void;
// Selector configuration
multiple?: boolean;
placeholder?: string;
label?: string;
description?: string;
withAsterisk?: boolean;
clearable?: boolean;
searchable?: boolean;
disabled?: boolean;
error?: string;
// Style configuration
size?: MantineSize;
radius?: MantineRadius;
maxDropdownHeight?: number;
w?: string | number;
mb?: string | number;
// Custom rendering
renderOption?: (props: StrongSelectorRenderProps) => React.ReactNode;
renderValue?: (option: StrongSelectorOption) => React.ReactNode;
// Multiple selection related
maxValues?: number;
hidePickedOptions?: boolean;
// Search related
filter?: (value: string, option: StrongSelectorOption) => boolean;
// Portal configuration
withinPortal?: boolean;
portalProps?: Record<string, any>;
position?: FloatingPosition;
// Other props
[key: string]: any;
}
// Default filter function
const defaultFilter = (value: string, option: StrongSelectorOption): boolean => {
return option.label.toLowerCase().includes(value.toLowerCase());
};
// Default option render function
const defaultRenderOption = ({option, checked}: StrongSelectorRenderProps): React.ReactNode => (
<Group flex="1" gap="sm">
{checked && <CheckIcon size={12} />}
<span>{option.label}</span>
</Group>
);
// Default value render function
const defaultRenderValue = (option: StrongSelectorOption): React.ReactNode => option.label;
export const StrongSelector = forwardRef<HTMLInputElement, StrongSelectorProps>(
(
{
data = [],
value,
onChange,
multiple = false,
placeholder = 'Please select...',
label,
description,
withAsterisk,
clearable = true,
searchable = false,
disabled = false,
error,
size = 'sm',
radius = 'sm',
maxDropdownHeight = 220,
w,
mb,
renderOption = defaultRenderOption,
renderValue = defaultRenderValue,
maxValues,
hidePickedOptions = false,
filter = defaultFilter,
withinPortal = true,
portalProps = {target: document.body},
position = 'bottom',
...otherProps
},
ref,
) => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex('active'),
});
const [search, setSearch] = useState('');
// Handle value normalization
const normalizedValue = useMemo(() => {
if (multiple) {
return Array.isArray(value) ? value : value ? [value] : [];
}
return Array.isArray(value) ? value[0] || null : value;
}, [value, multiple]);
// Get selected options
const selectedOptions = useMemo(() => {
const selectedValues = multiple ? (normalizedValue as string[]) : [normalizedValue as string].filter(Boolean);
return data.filter((option: StrongSelectorOption) => selectedValues.includes(option.value));
}, [data, normalizedValue, multiple]);
// Filter options
const filteredOptions = useMemo(() => {
let options = data;
// Search filtering
if (searchable && search) {
options = options.filter((option: StrongSelectorOption) => filter(search, option));
}
// Hide picked options (multiple mode only)
if (multiple && hidePickedOptions) {
const selectedValues = normalizedValue as string[];
options = options.filter((option: StrongSelectorOption) => !selectedValues.includes(option.value));
}
return options;
}, [data, search, searchable, filter, multiple, hidePickedOptions, normalizedValue]);
// Handle option click
const handleOptionSubmit = useCallback(
(optionValue: string) => {
if (!onChange) return;
if (multiple) {
const currentValues = normalizedValue as string[];
let newValues: string[];
if (currentValues.includes(optionValue)) {
// Deselect
newValues = currentValues.filter((val) => val !== optionValue);
} else {
// Add selection
newValues = [...currentValues, optionValue];
// Check max values limit
if (maxValues && newValues.length > maxValues) {
return;
}
}
onChange(newValues);
} else {
onChange(optionValue);
combobox.closeDropdown();
}
if (searchable) {
setSearch('');
}
},
[multiple, normalizedValue, onChange, maxValues, searchable, combobox],
);
// Handle Pill removal
const handleRemoveValue = useCallback(
(valueToRemove: string) => {
if (!onChange || !multiple) return;
const currentValues = normalizedValue as string[];
const newValues = currentValues.filter((val) => val !== valueToRemove);
onChange(newValues);
},
[multiple, normalizedValue, onChange],
);
// Handle clear
const handleClear = useCallback(() => {
if (!onChange) return;
onChange(multiple ? [] : null);
if (searchable) {
setSearch('');
}
}, [onChange, multiple, searchable]);
// Check if option is selected
const isOptionSelected = useCallback(
(optionValue: string): boolean => {
if (multiple) {
return (normalizedValue as string[]).includes(optionValue);
}
return normalizedValue === optionValue;
},
[multiple, normalizedValue],
);
// Render option list
const options = filteredOptions
.filter((option: StrongSelectorOption) => !option.hidden)
.map((option: StrongSelectorOption) => {
const selected = isOptionSelected(option.value);
return (
<Combobox.Option value={option.value} key={option.value} active={selected} disabled={option.disabled}>
<Group gap="sm">{renderOption({option, checked: selected})}</Group>
</Combobox.Option>
);
});
// Render input area
const renderInput = () => {
if (multiple) {
return (
<PillsInput
size={size}
radius={radius}
disabled={disabled}
error={error}
onClick={() => combobox.openDropdown()}
rightSection={
<Group gap={4}>
{clearable && selectedOptions.length > 0 ? (
<CloseButton
size="sm"
onMouseDown={(event) => event.preventDefault()}
onClick={handleClear}
aria-label="Clear selection"
/>
) : (
<Combobox.Chevron />
)}
</Group>
}
rightSectionPointerEvents={clearable && selectedOptions.length > 0 ? 'all' : 'none'}
{...otherProps}
>
<Pill.Group>
{selectedOptions.map((option: StrongSelectorOption) => (
<Pill
key={option.value}
withRemoveButton
onRemove={() => handleRemoveValue(option.value)}
disabled={disabled}
>
{renderValue(option)}
</Pill>
))}
<Combobox.EventsTarget>
<PillsInput.Field
ref={ref}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
value={search}
placeholder={selectedOptions.length === 0 ? placeholder : undefined}
onChange={(event) => {
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === 'Backspace' && search.length === 0 && selectedOptions.length > 0) {
event.preventDefault();
handleRemoveValue(selectedOptions[selectedOptions.length - 1].value);
}
}}
disabled={disabled}
readOnly={!searchable}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
);
}
// Single selection mode
return (
<Input
component="button"
type="button"
pointer
size={size}
radius={radius}
disabled={disabled}
error={error}
rightSection={
<Group gap={4}>
{clearable && selectedOptions.length > 0 ? (
<CloseButton
size="sm"
onMouseDown={(event) => event.preventDefault()}
onClick={handleClear}
aria-label="Clear selection"
/>
) : (
<Combobox.Chevron />
)}
</Group>
}
rightSectionPointerEvents={clearable && selectedOptions.length > 0 ? 'all' : 'none'}
onClick={() => combobox.toggleDropdown()}
{...otherProps}
>
{selectedOptions.length > 0 ? (
<Box component="span" style={{textAlign: 'left'}}>
{renderValue(selectedOptions[0])}
</Box>
) : (
<Input.Placeholder>{placeholder}</Input.Placeholder>
)}
</Input>
);
};
return (
<Combobox
floatingStrategy="fixed"
store={combobox}
withinPortal={withinPortal}
portalProps={portalProps}
position={position}
onOptionSubmit={handleOptionSubmit}
size={size}
width="target"
middlewares={{flip: true, shift: true, inline: false}}
>
<Combobox.DropdownTarget>
<Box w={w} mb={mb}>
{label && (
<Input.Label required={withAsterisk} size={size}>
{label}
</Input.Label>
)}
{renderInput()}
{description && (
<Input.Description size={size} mt={5}>
{description}
</Input.Description>
)}
{error && (
<Input.Error size={size} mt={5}>
{error}
</Input.Error>
)}
</Box>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<Combobox.Options>
<ScrollArea.Autosize mah={maxDropdownHeight} type="scroll">
{options.length > 0 ? options : <Combobox.Empty>No options</Combobox.Empty>}
</ScrollArea.Autosize>
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
},
);
StrongSelector.displayName = 'StrongSelector';
export default StrongSelector;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment