feat: add searchable property to choice fields (#1061)

Adds an opt-in `searchable` option on `choice` fields which replaces the
dropdown toggle with a filterable input, similar to the taxonomy field.
Filtering matches on both the choice title and ID.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Elio StruyfandClaude Opus 5 committed 2026-09-20 21:51:59 +02:00
1 parent 639cc2d622
commit f27093a638
8 files changed
+84 -12

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Change Log
## [10.13.0] - 2026-xx-xx
### 🎨 Enhancements
- [#1061](https://github.com/estruyf/vscode-front-matter/issues/1061): Added the `searchable` property on `choice` fields to show a searchable input instead of a dropdown, useful when you have many choices
## [10.12.0] - 2026-08-21 - [Release notes](https://beta.frontmatter.codes/updates/v10.12.0)
### 🎨 Enhancements
+1
View File
@@ -429,6 +429,7 @@
"panel.fields.choiceField.select": "Select {0}",
"panel.fields.choiceField.clear": "Clear value",
"panel.fields.choiceField.search": "Search {0}",
"panel.fields.contentTypeRelationshipField.loading": "Fetching possible values...",
+5
View File
@@ -1488,6 +1488,11 @@
"default": false,
"description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.multiple.description%"
},
"searchable": {
"type": "boolean",
"default": false,
"description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.searchable.description%"
},
"isPreviewImage": {
"type": "boolean",
"default": false,
+1
View File
@@ -220,6 +220,7 @@
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.single.description": "Is a single line field",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.wysiwyg.description": "Is a WYSIWYG field. You can set it to markdown or HTML.",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.multiple.description": "Do you allow to select multiple values?",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.searchable.description": "Show a searchable input for the choice field instead of a dropdown. Useful when you have many choices.",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.isPreviewImage.description": "Specify if the image field can be used as preview. Be aware, you can only have one preview image per content type.",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.hidden.description": "Do you want to hide the field from the metadata section?",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.taxonomyId.description": "The ID of your taxonomy field. It cannot contain the \"tags\" or \"categories\" value.",
+4
View File
@@ -1424,6 +1424,10 @@ export enum LocalizationKey {
* Clear value
*/
panelFieldsChoiceFieldClear = 'panel.fields.choiceField.clear',
/**
* Search {0}
*/
panelFieldsChoiceFieldSearch = 'panel.fields.choiceField.search',
/**
* Fetching possible values...
*/
+1
View File
@@ -106,6 +106,7 @@ export interface Field {
single?: boolean;
wysiwyg?: boolean | string;
multiple?: boolean;
searchable?: boolean;
isPreviewImage?: boolean;
hidden?: boolean;
taxonomyId?: string;
@@ -14,6 +14,7 @@ import useDropdownStyle from '../../hooks/useDropdownStyle';
export interface IChoiceFieldProps extends BaseFieldProps<string | string[]> {
choices: string[] | Choice[];
multiSelect?: boolean;
searchable?: boolean;
onChange: (value: string | string[]) => void;
}
@@ -23,10 +24,12 @@ export const ChoiceField: React.FunctionComponent<IChoiceFieldProps> = ({
value,
choices,
multiSelect,
searchable,
onChange,
required
}: React.PropsWithChildren<IChoiceFieldProps>) => {
const [crntSelected, setCrntSelected] = React.useState<string | string[] | null>(value);
const [inputValue, setInputValue] = React.useState<string>('');
const dsRef = React.useRef<Downshift<string> | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const { getDropdownStyle } = useDropdownStyle(inputRef as any);
@@ -84,6 +87,18 @@ export const ChoiceField: React.FunctionComponent<IChoiceFieldProps> = ({
return true;
});
/**
* Filters the choices by the typed search value (matches on title and ID)
*/
const search = searchable ? (inputValue || '').toLowerCase() : '';
const filteredChoices = !search
? (availableChoices as Array<string | Choice>)
: (availableChoices as Array<string | Choice>).filter(
(choice) =>
getValue(choice, 'title').toLowerCase().includes(search) ||
getValue(choice, 'id').toLowerCase().includes(search)
);
const showRequiredState = useMemo(() => {
return (
required && ((crntSelected instanceof Array && crntSelected.length === 0) || !crntSelected)
@@ -102,24 +117,62 @@ export const ChoiceField: React.FunctionComponent<IChoiceFieldProps> = ({
<Downshift
ref={dsRef}
onSelect={(selected) => onValueChange(selected || '')}
onSelect={(selected) => {
onValueChange(selected || '');
setInputValue('');
}}
itemToString={(item) => (item ? item : '')}
inputValue={inputValue}
onStateChange={(changes) => {
// Only track what the user types, ignore the value Downshift sets on selection
if (changes.type === Downshift.stateChangeTypes.changeInput) {
setInputValue(changes.inputValue || '');
}
}}
>
{({ getToggleButtonProps, getItemProps, getMenuProps, isOpen, getRootProps }) => (
{({
getToggleButtonProps,
getInputProps,
getItemProps,
getMenuProps,
isOpen,
getRootProps,
openMenu,
closeMenu
}) => (
<div
{...getRootProps(undefined, { suppressRefError: true })}
ref={inputRef}
className={`metadata_field__choice`}
>
<button
{...getToggleButtonProps({
className: `metadata_field__choice__toggle`,
disabled: availableChoices.length === 0
})}
>
<span>{l10n.t(LocalizationKey.panelFieldsChoiceFieldSelect, label)}</span>
<ChevronDownIcon className="icon" />
</button>
{searchable ? (
<div
className={`article__tags__input metadata_field__choice__search ${showRequiredState ? 'required' : ''}`}
>
<input
{...getInputProps({
onFocus: openMenu as () => void,
onClick: openMenu as () => void,
onBlur: () => {
closeMenu();
setInputValue('');
},
disabled: availableChoices.length === 0
})}
placeholder={l10n.t(LocalizationKey.panelFieldsChoiceFieldSearch, label)}
/>
</div>
) : (
<button
{...getToggleButtonProps({
className: `metadata_field__choice__toggle`,
disabled: availableChoices.length === 0
})}
>
<span>{l10n.t(LocalizationKey.panelFieldsChoiceFieldSelect, label)}</span>
<ChevronDownIcon className="icon" />
</button>
)}
<ul
className={`field_dropdown metadata_field__choice_list ${isOpen ? 'open' : 'closed'}`}
@@ -129,7 +182,7 @@ export const ChoiceField: React.FunctionComponent<IChoiceFieldProps> = ({
{...getMenuProps()}
>
{
availableChoices.map((choice, index) => (
filteredChoices.map((choice, index) => (
<li
{...getItemProps({
key: getValue(choice, 'id'),
@@ -326,6 +326,7 @@ export const WrapperField: React.FunctionComponent<IWrapperFieldProps> = ({
required={!!field.required}
choices={choices}
multiSelect={field.multiple}
searchable={!!field.searchable}
onChange={onFieldChange}
/>
</FieldBoundary>