Skip to content

Instantly share code, notes, and snippets.

@laurenashpole
Last active July 10, 2025 07:55
Show Gist options
  • Save laurenashpole/5dae1460ebcbd58dff7a08a1ae6bb1ca to your computer and use it in GitHub Desktop.
Save laurenashpole/5dae1460ebcbd58dff7a08a1ae6bb1ca to your computer and use it in GitHub Desktop.
MultiSelect Component for Sanity Studio
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
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