From f27093a6381dca5115871f6d37aa4e6b9aaeed99 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Sun, 20 Sep 2026 21:51:59 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 6 ++ l10n/bundle.l10n.json | 1 + package.json | 5 ++ package.nls.json | 1 + src/localization/localization.enum.ts | 4 + src/models/PanelSettings.ts | 1 + .../components/Fields/ChoiceField.tsx | 77 ++++++++++++++++--- .../components/Fields/WrapperField.tsx | 1 + 8 files changed, 84 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eda1b8f..e034a582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 73b47765..1f5d44d2 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -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...", diff --git a/package.json b/package.json index c8d9b9d1..9015e4b9 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/package.nls.json b/package.nls.json index 61e04304..f7aa2bbf 100644 --- a/package.nls.json +++ b/package.nls.json @@ -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.", diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index 3dcdb5df..7f5f62f8 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -1424,6 +1424,10 @@ export enum LocalizationKey { * Clear value */ panelFieldsChoiceFieldClear = 'panel.fields.choiceField.clear', + /** + * Search {0} + */ + panelFieldsChoiceFieldSearch = 'panel.fields.choiceField.search', /** * Fetching possible values... */ diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index 8b4c3d7c..b7174328 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -106,6 +106,7 @@ export interface Field { single?: boolean; wysiwyg?: boolean | string; multiple?: boolean; + searchable?: boolean; isPreviewImage?: boolean; hidden?: boolean; taxonomyId?: string; diff --git a/src/panelWebView/components/Fields/ChoiceField.tsx b/src/panelWebView/components/Fields/ChoiceField.tsx index da28b073..1f80a765 100644 --- a/src/panelWebView/components/Fields/ChoiceField.tsx +++ b/src/panelWebView/components/Fields/ChoiceField.tsx @@ -14,6 +14,7 @@ import useDropdownStyle from '../../hooks/useDropdownStyle'; export interface IChoiceFieldProps extends BaseFieldProps { choices: string[] | Choice[]; multiSelect?: boolean; + searchable?: boolean; onChange: (value: string | string[]) => void; } @@ -23,10 +24,12 @@ export const ChoiceField: React.FunctionComponent = ({ value, choices, multiSelect, + searchable, onChange, required }: React.PropsWithChildren) => { const [crntSelected, setCrntSelected] = React.useState(value); + const [inputValue, setInputValue] = React.useState(''); const dsRef = React.useRef | null>(null); const inputRef = React.useRef(null); const { getDropdownStyle } = useDropdownStyle(inputRef as any); @@ -84,6 +87,18 @@ export const ChoiceField: React.FunctionComponent = ({ 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) + : (availableChoices as Array).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 = ({ 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 + }) => (
- + {searchable ? ( +
+ void, + onClick: openMenu as () => void, + onBlur: () => { + closeMenu(); + setInputValue(''); + }, + disabled: availableChoices.length === 0 + })} + placeholder={l10n.t(LocalizationKey.panelFieldsChoiceFieldSearch, label)} + /> +
+ ) : ( + + )}
    = ({ {...getMenuProps()} > { - availableChoices.map((choice, index) => ( + filteredChoices.map((choice, index) => (
  • = ({ required={!!field.required} choices={choices} multiSelect={field.multiple} + searchable={!!field.searchable} onChange={onFieldChange} />