Last active
July 10, 2025 07:55
-
-
Save laurenashpole/5dae1460ebcbd58dff7a08a1ae6bb1ca to your computer and use it in GitHub Desktop.
MultiSelect Component for Sanity Studio
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 {type FC, useCallback, useEffect, useState} from 'react' | |
import { | |
type ArrayOfObjectsInputProps, | |
insert, | |
PatchEvent, | |
setIfMissing, | |
unset, | |
useClient, | |
} from 'sanity' | |
import {SearchIcon, CloseIcon} from '@sanity/icons' | |
import { | |
type BaseAutocompleteOption, | |
Autocomplete, | |
Box, | |
Button, | |
Card, | |
Flex, | |
Inline, | |
Stack, | |
Text, | |
} from '@sanity/ui' | |
import {nanoid} from 'nanoid' | |
type MultiSelectOption = BaseAutocompleteOption & { | |
label: string | |
selected?: boolean | |
} | |
type MultiSelectProps = Omit<ArrayOfObjectsInputProps, 'value'> & { | |
value: { | |
_key: string | |
_ref: string | |
}[] | |
sanityQuery: string | |
} | |
const MultiSelect: FC<MultiSelectProps> = ({sanityQuery, ...props}) => { | |
const client = useClient({ | |
apiVersion: '2023-02-22', | |
}) | |
const [options, setOptions] = useState<MultiSelectOption[]>([]) | |
const [optionsToRender, setOptionsToRender] = useState<MultiSelectOption[]>([]) | |
useEffect(() => { | |
/* | |
* The autocomplete clear functionality isn't useful here and adds | |
* unnecessary steps. Unfortunately, since Sanity doesn't allow access | |
* to the button, this is the easiest way to hide it. | |
*/ | |
document.head.insertAdjacentHTML( | |
'beforeend', | |
`<style>#${props.id} ~ * button[aria-label="Clear"]{display:none}</style>` | |
) | |
}, [props.id]) | |
useEffect(() => { | |
const fetchOptions = async () => { | |
const response = await client.fetch(sanityQuery) | |
setOptions(response) | |
} | |
if (client && sanityQuery) { | |
fetchOptions() | |
} | |
}, [client, sanityQuery]) | |
useEffect(() => { | |
const valueRefs = props.value?.map((item) => item._ref) || [] | |
setOptionsToRender( | |
options.map((option) => ({ | |
...option, | |
selected: valueRefs.indexOf(option.value) > -1, | |
})) | |
) | |
}, [options, props.value]) | |
const handleSelect = useCallback( | |
(value: string) => { | |
props.onChange( | |
PatchEvent.from( | |
insert( | |
[ | |
{ | |
_key: nanoid(), | |
_ref: value, | |
_type: 'reference', | |
}, | |
], | |
'after', | |
[-1] | |
) | |
).prepend(setIfMissing([])) | |
) | |
}, | |
[props] | |
) | |
const handleRemove = (value: string) => { | |
const valueKey = props.value.find((item) => item._ref === value)?._key | |
if (valueKey) { | |
props.onChange(PatchEvent.from(unset([{_key: valueKey}]))) | |
} | |
} | |
return ( | |
<Stack space={3}> | |
<Autocomplete | |
radius={2} | |
id={props.id} | |
openButton | |
icon={SearchIcon} | |
placeholder="Type to search" | |
options={optionsToRender} | |
filterOption={(query, option: MultiSelectOption) => | |
option.label.toLowerCase().indexOf(query.toLowerCase()) > -1 | |
} | |
disabled={!options.filter((option) => !option.selected).length} | |
renderOption={(option) => ( | |
<> | |
<Card as="button" padding={2} disabled={option.selected}> | |
<Text size={2}>{option.label}</Text> | |
</Card> | |
</> | |
)} | |
renderValue={() => ''} | |
onSelect={handleSelect} | |
/> | |
<Inline space={2}> | |
{optionsToRender.map( | |
(option) => | |
option.selected && ( | |
<Card key={option.value} padding={1} paddingLeft={3} radius={6} shadow={1}> | |
<Flex align="center" justify="center"> | |
<Box marginRight={2}> | |
<Text size={1} weight="medium"> | |
{option.label} | |
</Text> | |
</Box> | |
<Button | |
mode="bleed" | |
fontSize={1} | |
padding={2} | |
radius={6} | |
iconRight={CloseIcon} | |
onClick={() => handleRemove(option.value)} | |
aria-label={`Remove ${option.label} tag`} | |
/> | |
</Flex> | |
</Card> | |
) | |
)} | |
</Inline> | |
</Stack> | |
) | |
} | |
export default MultiSelect |
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 from 'react' | |
import {defineField} from 'sanity' | |
import MultiSelect from '../plugins/MultiSelect' | |
export default { | |
name: 'post', | |
type: 'document', | |
title: 'Post', | |
fields: [ | |
... | |
defineField({ | |
name: 'tags', | |
title: 'Tags', | |
type: 'array', | |
of: [ | |
{ | |
type: 'reference', | |
to: [{type: 'tag'}], | |
}, | |
], | |
components: { | |
input: (props) => ( | |
<MultiSelect | |
sanityQuery={`*[_type == 'tag']{ 'value': _id, 'label': name }`} | |
{...props} | |
/> | |
), | |
}, | |
}), | |
], | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment