Created
June 18, 2025 22:41
-
-
Save jjj201200/15cdfc0fd5b8729d761cb79356a50b33 to your computer and use it in GitHub Desktop.
mantine/multiSelect support renderValue
This file contains hidden or 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
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