Part 14 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
The built-in SelectEntry in Form-JS's properties panel renders a native HTML <select> element. For a dropdown with five options, this is fine. For a dropdown with 200 ticket types, 150 API endpoints, or 300 hierarchical categories, a native select is unusable. The user has to scroll through an unsorted wall of text to find what they want. There's no search, no filtering, no keyboard navigation beyond the browser's native letter-jump.
I needed a searchable select. I built one.
Then I needed another one in a different properties provider. I copied the first.
Then a third. Another copy.
By the fifth copy, I had the same 150-line component in four different files, each with slightly different props and slightly different styling. When I fixed a keyboard navigation bug in the first copy, the fix was in one file. The bug remained in three others.
This article documents the component I should have extracted after the second copy — and the lesson I apparently needed to learn four times before I learned it.
The Problem
The properties panel runs in Preact. This is important because it means:
- You cannot use React's
useState,useEffect,useRef— you use Preact's versions frompreact/hooks - You cannot use React UI libraries like React Select, Headless UI, or Radix — they depend on React's fiber reconciler
- You write JSX using
preact/jsx-runtime'sjsxandjsxs, not React's
Every searchable select library I could have dropped in was built for React. I needed to build one from scratch in Preact.
The requirements:
- Text input that filters the visible options
- Keyboard navigation — ArrowUp, ArrowDown, Enter, Escape
- Click outside to close
- Loading state for async option lists
- Validation error display
- Accessible label association
- Consistent styling with the rest of the properties panel
None of these are technically difficult individually. Together, in Preact, without a UI library, they require careful attention to Preact-specific behaviors that differ from React.
What I Tried First
My first attempt used a native <select> with a text input above it as a filter:
// ❌ Attempt 1: Native select with filter input
function SearchableSelect(props) {
const [filter, setFilter] = useState('');
const filteredOptions = props.options.filter(o =>
o.label.toLowerCase().includes(filter.toLowerCase())
);
return jsxs("div", {
children: [
jsx("input", {
type: "text",
placeholder: "Filter...",
onInput: (e) => setFilter(e.target.value)
}),
jsx("select", {
size: Math.min(filteredOptions.length, 8),
onChange: (e) => props.setValue(e.target.value),
children: filteredOptions.map(opt =>
jsx("option", { value: opt.value, children: opt.label })
)
})
]
});
}
This worked but looked terrible — a text input above a multi-line native select is not what users expect from a searchable dropdown. It also had no keyboard navigation between the input and the select, no single-select UX (you'd have to click an option in the list), and no way to show a loading state.
My second attempt (the first that actually shipped to production) was a custom dropdown panel — the approach that became the pattern I eventually extracted. I'll document that correctly here.
The Solution: A Custom Dropdown Panel
The component has three pieces:
- A trigger button — shows the currently selected option label, opens the dropdown when clicked
- A dropdown panel — appears below the trigger when open, contains the search input and options list
-
State management —
isOpen,searchTerm,highlightedIndex, all coordinated through Preact hooks
State and Refs
import { useState, useEffect, useRef } from 'preact/hooks';
import { jsx, jsxs } from 'preact/jsx-runtime';
import classnames from 'classnames';
export function SearchableSelect(props) {
const {
element, // The field object (passed as "element" in properties panel convention)
id, // Entry ID — used for label association
description, // Help text below the entry
label, // Entry label
getValue, // () => currentValue
setValue, // (value) => void
getOptions, // () => [{label, value}]
disabled, // Disable the control
tooltip, // Hover tooltip on trigger
loading, // Show loading state
placeholder = 'Search...', // Search input placeholder
validate // Optional validation function
} = props;
// ✅ UI state
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(-1);
// ✅ Validation state
const [errors, setErrors] = useState([]);
// ✅ Container ref for click-outside detection
const dropdownRef = useRef(null);
// Current selected value and options
const value = getValue(element) || '';
const options = getOptions ? getOptions(element) : [];
// ✅ Filter options by search term — case-insensitive includes
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
);
// ✅ Find currently selected option to display its label in the trigger
const selectedOption = options.find(opt => opt.value === value);
const displayValue = selectedOption ? selectedOption.label : '';
Validation Integration
Run validation when the value changes:
// ✅ Run validation when value changes
useEffect(() => {
if (validate && typeof validate === 'function') {
const validationErrors = validate(value);
// Normalize: validation can return null, string, or string[]
if (!validationErrors) {
setErrors([]);
} else if (typeof validationErrors === 'string') {
setErrors([validationErrors]);
} else if (Array.isArray(validationErrors)) {
setErrors(validationErrors);
} else {
setErrors([]);
}
}
}, [value, validate]);
Click-Outside Detection
// ✅ Click-outside: only attach when open, always clean up
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
setSearchTerm('');
}
};
if (isOpen) {
// ✅ Attach only when open — no event listener when closed
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
// No cleanup needed when isOpen is false — listener was never added
}, [isOpen]);
Why mousedown instead of click? mousedown fires before click. When the user clicks an option in the dropdown, mousedown fires first (on the option element, inside dropdownRef), so the outside-click check fails correctly — the element is inside the ref. If you use click, there's a race condition where the panel might close before click fires on the option, causing the selection to be lost.
Why not attach the listener globally always? Performance. A global mousedown listener that fires on every click anywhere in the application — even when the dropdown is closed — is unnecessary overhead. The isOpen guard in the useEffect dependency array ensures the listener is only active when the dropdown is open.
Keyboard Navigation
const handleKeyDown = (e) => {
// If closed, certain keys open it
if (!isOpen) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
setIsOpen(true);
}
return;
}
// If open, navigate
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
// Don't go past the last option
setHighlightedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
// Don't go above the first option
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
break;
case 'Enter':
e.preventDefault();
// Select the highlighted option
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
handleSelect(filteredOptions[highlightedIndex].value);
}
break;
case 'Escape':
// Close without selecting
setIsOpen(false);
setSearchTerm('');
break;
}
};
e.preventDefault() on arrow keys is essential. Without it, ArrowDown/ArrowUp scroll the page while simultaneously trying to navigate the dropdown. The native browser behavior and your custom navigation behavior fight each other.
Selection and Toggle
const handleSelect = (selectedValue) => {
setValue(selectedValue);
setIsOpen(false);
setSearchTerm(''); // Reset search on selection
setHighlightedIndex(-1); // Reset highlight
};
const handleToggle = () => {
if (!disabled && !loading) {
setIsOpen(!isOpen);
if (!isOpen) {
// Opening: reset search and highlight
setSearchTerm('');
setHighlightedIndex(-1);
}
}
};
The Render
return jsxs("div", {
class: classnames(styles.entry, {
[styles.error]: errors.length > 0,
[styles.hasValidation]: !!validate
}),
"data-entry-id": id,
// ✅ Container ref — used by click-outside detection
ref: dropdownRef,
children: [
// Label
jsx("label", {
for: `bio-properties-panel-${id}`,
class: styles.label,
children: label
}),
jsxs("div", {
class: styles.searchableSelectContainer,
style: "position: relative;",
children: [
// ============================================================
// TRIGGER BUTTON
// ============================================================
jsx("div", {
class: classnames(styles.input, styles.select, {
[styles.error]: errors.length > 0
}),
style: [
"display: flex;",
"justify-content: space-between;",
"align-items: center;",
`cursor: ${disabled || loading ? 'not-allowed' : 'pointer'};`,
`opacity: ${disabled || loading ? '0.6' : '1'};`
].join(' '),
onClick: handleToggle,
onKeyDown: handleKeyDown,
tabIndex: disabled ? -1 : 0, // ✅ Keyboard focusable
title: tooltip,
"aria-haspopup": "listbox",
"aria-expanded": isOpen,
children: jsx("span", {
style: [
"flex: 1;",
"overflow: hidden;",
"text-overflow: ellipsis;",
"white-space: nowrap;",
`color: ${displayValue ? 'inherit' : '#999'};`
].join(' '),
children: loading ? 'Loading...' : (displayValue || '-- Select --')
})
}),
// ============================================================
// DROPDOWN PANEL (only rendered when open)
// ============================================================
isOpen && jsxs("div", {
class: styles.dropdownPanel,
style: [
"position: absolute;",
"top: 100%;",
"left: 0;",
"right: 0;",
"margin-top: 4px;",
"background: white;",
"border: 1px solid #ccc;",
"border-radius: 4px;",
"box-shadow: 0 4px 12px rgba(0,0,0,0.15);",
"max-height: 300px;",
"z-index: 1000;",
"display: flex;",
"flex-direction: column;"
].join(' '),
children: [
// Search input
jsx("input", {
type: "text",
class: styles.input,
style: [
"margin: 8px;",
"padding: 8px;",
"border: 1px solid #ccc;",
"border-radius: 4px;",
"width: calc(100% - 16px);"
].join(' '),
placeholder: placeholder,
value: searchTerm,
// ✅ onInput, not onChange — Preact-specific
onInput: (e) => {
setSearchTerm(e.target.value);
setHighlightedIndex(0); // ✅ Reset highlight when search changes
},
onKeyDown: handleKeyDown,
// ✅ autoFocus when dropdown opens
autoFocus: true
}),
// Options list
jsx("div", {
role: "listbox",
style: "overflow-y: auto; max-height: 250px;",
children: filteredOptions.length === 0
? jsx("div", {
style: "padding: 12px; text-align: center; color: #999;",
children: searchTerm ? 'No results found' : 'No options available'
})
: filteredOptions.map((option, index) =>
jsx("div", {
key: option.value,
role: "option",
"aria-selected": option.value === value,
class: styles.dropdownOption,
style: [
"padding: 10px 12px;",
"cursor: pointer;",
`background: ${
index === highlightedIndex ? '#e6f2ff'
: option.value === value ? '#f0f8ff'
: 'white'
};`,
"border-bottom: 1px solid #eee;",
"transition: background 0.15s;"
].join(' '),
onMouseEnter: () => setHighlightedIndex(index),
onClick: () => handleSelect(option.value),
children: option.label
})
)
})
]
})
]
}),
// Validation errors
errors.length > 0 && jsx("div", {
class: styles.error,
children: errors.map((error, index) =>
jsx("span", {
key: index,
class: styles.errorMessage,
children: error
})
)
}),
// Description
description && jsx("div", {
class: styles.description,
children: description
})
]
});
}
The Critical Preact Difference: onInput vs onChange
In React, onChange fires on every keystroke for text inputs — React overrides the native DOM behavior to make onChange behave like onInput. This is a React-specific quirk.
In Preact, onChange behaves like the native DOM change event — it fires when the input loses focus, not on every keystroke. If you write onChange in a Preact text input expecting React behavior, typing into the search field does nothing until you click away.
For a search input that should filter as you type, you must use onInput:
// ❌ React habit — wrong in Preact
jsx("input", {
onChange: (e) => setSearchTerm(e.target.value)
})
// ✅ Correct in Preact
jsx("input", {
onInput: (e) => setSearchTerm(e.target.value)
})
This is the single most common bug when writing Preact components after writing React components. It's silent — no error, just an input that doesn't update until blur. Every copy of SearchableSelect I wrote before extracting it had this bug fixed separately.
The autoFocus Behavior
When the dropdown panel opens, the search input needs focus immediately so the user can start typing without clicking on it first. The autoFocus attribute handles this:
jsx("input", {
type: "text",
autoFocus: true, // ✅ Focus when rendered (panel just opened)
// ...
})
autoFocus works because the search input is only rendered when isOpen is true. When isOpen becomes true, the input mounts for the first time, and autoFocus fires. When isOpen becomes false, the input unmounts. When isOpen becomes true again, the input mounts again with autoFocus again.
Without autoFocus, the user clicks the trigger to open the panel, then has to click the search input to focus it, then can start typing. That's two clicks to begin searching — one click too many.
In Preact, autoFocus works correctly (unlike some older React versions where it behaved inconsistently). No additional useEffect with inputRef.current.focus() is needed.
The highlightedIndex Reset on Search Change
When the user types in the search input, the filtered options list changes. The highlightedIndex should reset to 0 (the first visible option) so that pressing Enter immediately selects the top result:
onInput: (e) => {
setSearchTerm(e.target.value);
setHighlightedIndex(0); // ✅ Reset to top of filtered list
}
Without this reset, the highlighted index might point to an option that no longer exists in the filtered list. If highlightedIndex is 5 and filtering reduces the list to 3 options, filteredOptions[5] is undefined. Pressing Enter would select undefined.value, which throws.
The Loading State
When loading is true, the trigger shows "Loading..." and is non-interactive:
style: `cursor: ${disabled || loading ? 'not-allowed' : 'pointer'};`,
children: loading ? 'Loading...' : (displayValue || '-- Select --')
And in handleToggle:
const handleToggle = () => {
if (!disabled && !loading) { // ✅ Neither disabled nor loading
setIsOpen(!isOpen);
// ...
}
};
The loading state prevents the designer from opening the dropdown before options are available. Without this guard, the user could open the panel while options are loading, see an empty list, and think there are no options.
For the cascading configuration from Article 12, the loading state is especially important. When the designer selects a ticket type, the phase selector enters loading state while phases fetch. The designer can see "Loading..." in the phase selector and knows to wait.
The Five-Times Mistake and the Extracted Utility
Here is an honest accounting of where SearchableSelect appeared in the codebase before extraction:
Copy 1: DropdownPropertiesProvider.js — for selecting the hierarchical API list and predefined API
Copy 2: AutoFillPropertiesProvider.js — for selecting the auto-fill API
Copy 3: TicketAutoFillPropertiesProvider.js — for ticket type, ticket phase, and field name
Copy 4: GridFieldPropertiesProvider.js — was considered, not added because grid config doesn't have large option lists
Each copy had the same core logic with minor variations:
- Different default placeholder text
- Different styling class references
- The
onInputvsonChangebug fixed in copies 1, 2, and 3 but with different timing - Keyboard navigation in copies 1 and 3, missing in copy 2
When I fixed the keyboard navigation in copy 1 (user reported ArrowUp wrapping incorrectly — it went below index 0 to index -1 which rendered no highlight), I fixed it in one file. Three weeks later the same bug was reported for the API selector in the auto-fill panel. That's copy 2. I had forgotten to apply the fix there.
The correct fix was applied to five locations' worth of code in one PR. The extracted utility means it only needs to be applied once.
The Extracted Component
// src/formjs/shared/ui/SearchableSelect.js
import { useState, useEffect, useRef } from 'preact/hooks';
import { jsx, jsxs } from 'preact/jsx-runtime';
import classnames from 'classnames';
import styles from './SearchableSelect.module.scss';
/**
* Searchable select component for the bpmn-io properties panel.
*
* Use when a SelectEntry with native <select> is insufficient —
* typically when you have more than ~20 options or options loaded from an API.
*
* @example
* // In a properties panel entry component:
* return SearchableSelect({
* element: field,
* id: `my-entry-${field.id}`,
* label: 'API Selection',
* getValue: () => get(field, ['my_config', 'apiId']),
* setValue: (value) => editField(field, ['my_config', 'apiId'], value),
* getOptions: () => apiList,
* loading: isLoading,
* placeholder: 'Search APIs...'
* });
*/
export function SearchableSelect(props) {
const {
element,
id,
description,
label,
getValue,
setValue,
getOptions,
disabled = false,
tooltip,
loading = false,
placeholder = 'Search...',
validate
} = props;
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [errors, setErrors] = useState([]);
const dropdownRef = useRef(null);
const value = getValue(element) || '';
const options = getOptions ? getOptions(element) : [];
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectedOption = options.find(opt => opt.value === value);
const displayValue = selectedOption ? selectedOption.label : '';
// Validation
useEffect(() => {
if (!validate || typeof validate !== 'function') return;
const result = validate(value);
if (!result) setErrors([]);
else if (typeof result === 'string') setErrors([result]);
else if (Array.isArray(result)) setErrors(result);
else setErrors([]);
}, [value, validate]);
// Click-outside
useEffect(() => {
if (!isOpen) return;
const handler = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setIsOpen(false);
setSearchTerm('');
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isOpen]);
const handleSelect = (selectedValue) => {
setValue(selectedValue);
setIsOpen(false);
setSearchTerm('');
setHighlightedIndex(-1);
};
const handleToggle = () => {
if (disabled || loading) return;
setIsOpen(prev => {
if (!prev) {
setSearchTerm('');
setHighlightedIndex(-1);
}
return !prev;
});
};
const handleKeyDown = (e) => {
if (!isOpen) {
if (['Enter', ' ', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
setIsOpen(true);
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(i => Math.min(i + 1, filteredOptions.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
handleSelect(filteredOptions[highlightedIndex].value);
}
break;
case 'Escape':
setIsOpen(false);
setSearchTerm('');
break;
}
};
return jsxs("div", {
class: classnames(styles.entry, {
[styles.hasError]: errors.length > 0
}),
"data-entry-id": id,
ref: dropdownRef,
children: [
jsx("label", {
for: `bio-properties-panel-${id}`,
class: styles.label,
children: label
}),
jsxs("div", {
class: styles.container,
style: "position: relative;",
children: [
// Trigger
jsx("div", {
class: classnames(styles.trigger, {
[styles.hasError]: errors.length > 0,
[styles.disabled]: disabled || loading
}),
onClick: handleToggle,
onKeyDown: handleKeyDown,
tabIndex: disabled ? -1 : 0,
title: tooltip,
"aria-haspopup": "listbox",
"aria-expanded": isOpen,
"aria-label": label,
children: jsx("span", {
class: classnames(styles.triggerText, {
[styles.placeholder]: !displayValue && !loading
}),
children: loading ? 'Loading...' : (displayValue || '-- Select --')
})
}),
// Panel
isOpen && jsxs("div", {
class: styles.panel,
role: "dialog",
children: [
// ✅ onInput for Preact — not onChange
jsx("input", {
type: "text",
class: styles.searchInput,
placeholder,
value: searchTerm,
onInput: (e) => {
setSearchTerm(e.target.value);
setHighlightedIndex(0);
},
onKeyDown: handleKeyDown,
autoFocus: true // ✅ Focus on open
}),
jsx("div", {
class: styles.optionsList,
role: "listbox",
children: filteredOptions.length === 0
? jsx("div", {
class: styles.emptyState,
children: searchTerm ? 'No results found' : 'No options available'
})
: filteredOptions.map((option, index) =>
jsx("div", {
key: option.value,
role: "option",
"aria-selected": option.value === value,
class: classnames(styles.option, {
[styles.highlighted]: index === highlightedIndex,
[styles.selected]: option.value === value
}),
onMouseEnter: () => setHighlightedIndex(index),
onClick: () => handleSelect(option.value),
children: option.label
})
)
})
]
})
]
}),
// Errors
errors.length > 0 && jsx("div", {
class: styles.errors,
children: errors.map((err, i) =>
jsx("span", { key: i, class: styles.errorMessage, children: err })
)
}),
// Description
description && jsx("div", {
class: styles.description,
children: description
})
]
});
}
How Each Provider Uses It
After extraction, each provider imports the shared component:
// DropdownPropertiesProvider.js
import { SearchableSelect } from '@/formjs/shared/ui/SearchableSelect';
function HierarchicalApiListEntry(props) {
const { field, getValue, setValue, id, validate } = props;
const [categoryList, setCategoryList] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
formsClient.get('api/v1/hierarchical-data/categories?active_only=true')
.then(response => {
const options = response?.data?.data?.map(cat => ({
value: cat.id,
label: `${cat.display_name} (Level ${cat.level})`
}));
setCategoryList(options);
})
.catch(err => notify.error(`Failed: ${err.message}`))
.finally(() => setLoading(false));
}, []);
return SearchableSelect({
element: field,
getValue,
id,
label: 'API List',
description: 'Select hierarchical category for dropdown options',
getOptions: () => [
{ value: '', label: '-- Select API List --' },
...categoryList
],
setValue,
validate,
loading,
placeholder: 'Search API lists...',
tooltip: 'Select the hierarchical category that will provide dropdown options'
});
}
// AutoFillPropertiesProvider.js
import { SearchableSelect } from '@/formjs/shared/ui/SearchableSelect';
function AutoFillPredefinedApiEntry(props) {
const { field, getValue, id, editField } = props;
const [apiList, setApiList] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
formsClient.get('api/v1/api-connectivities/')
.then(data => {
const list = Array.isArray(data?.data) ? data.data : (data.results || []);
setApiList(list.map(api => ({
value: api.id.toString(),
label: api.name,
apiData: api
})));
})
.catch(err => notify.error(`Failed: ${err.message}`))
.finally(() => setLoading(false));
}, []);
const handleSetValue = (selectedId) => {
const selected = apiList.find(opt => opt.value === selectedId);
if (selected?.apiData) {
const currentConfig = get(field, ['autoFillLogic']) || {};
editField(field, 'autoFillLogic', {
...currentConfig,
predefined_api_id: selectedId,
predefined_api_details: selected.apiData,
mapResult: '',
filterKey: ''
});
}
};
return SearchableSelect({
element: field,
getValue,
id,
label: 'Select Predefined API',
description: 'Choose API to use for auto-fill',
getOptions: () => [
{ value: '', label: '-- Select API --' },
...apiList
],
setValue: handleSetValue,
loading,
placeholder: 'Search APIs...',
tooltip: 'Select the API schema to use for auto-filling this field'
});
}
The same import, the same component, different props. The keyboard navigation fix applies everywhere. The onInput behavior is correct everywhere. The loading state works everywhere.
The Meta-Lesson: When to Extract
I knew I should extract SearchableSelect after the second copy. I did not extract it until after the fifth.
The reason I gave myself at the time: "I'm not sure the API is stable yet." What this actually meant: extracting requires deciding on a component API, writing the documentation, creating the shared file and import path, and updating the existing copies. That's 45 minutes of work. Copying takes 5 minutes.
The cost of not extracting: 4 locations with the keyboard navigation bug. 2 locations with onChange instead of onInput. 3 separate PRs to fix bugs that should have been one PR. A sixth copy was started before I caught myself.
The rule I now follow: extract when you copy for the second time, not the fifth.
When you copy a second time:
- The API has already been validated by one real use case
- You haven't accumulated so many copies that updating them all is painful
- The extraction PR is small — one new file, two import changes
- You still remember why every line of the original component exists
When you wait until the fifth time:
- You have four copies with divergent bugs
- The extraction PR requires updating four files
- You've forgotten why some decisions were made in the original
- The copies have diverged enough that harmonizing them requires reviewing each one
The rule costs 45 minutes. Not following it cost four bug reports, three PRs, and a day of review time spread across six months.
The Tradeoffs
Custom dropdowns are not accessible by default. A native <select> is fully accessible for free — screen readers announce it, keyboard users navigate it, the browser handles focus management. A custom dropdown requires explicit ARIA attributes: role="listbox", role="option", aria-selected, aria-haspopup, aria-expanded. I've added the essential ones, but a fully accessible custom select is significantly more work than a native one. For an internal tool used by form designers — a technical audience — this tradeoff is acceptable. For a public-facing product, it wouldn't be.
The z-index: 1000 panel will conflict with modals. The dropdown panel needs a high z-index to appear above other properties panel content. If the properties panel itself is inside a modal with a z-index, you need to manage stacking contexts carefully. In practice, the Camunda editor's properties panel has a predictable stacking context where 1000 works.
No virtualization for large lists. For 200 options, rendering all option divs is fine. For 2000 options, it would be slow. If you have truly large option lists, you'd need to add virtualization — only rendering options within the visible scroll window. I didn't need this for my use cases (largest list was ~300 ticket types), but it's a known limitation.
The component is called as a function, not as JSX. In Preact properties panel components, you call SearchableSelect({...}) rather than rendering <SearchableSelect {...} />. This is the pattern used throughout the properties panel — entry components are factory functions called directly, not components mounted by a renderer. This means you don't get React/Preact's component identity tracking, which means state is reset on every render. For SearchableSelect, this is acceptable — the open/search state should reset when the properties panel re-renders anyway.
What Comes Next
The SearchableSelect component is used in the cascading configuration, the auto-fill configuration, and the dropdown properties panel. It's also the foundation for one more complex pattern: the conditional rules editor, where form designers build FEEL conditions and select which options to show for each condition.
Article 15 covers the conditional rules properties panel component — the ConditionalRulesGroup that brings together FeelEntry, SearchableSelect, drag-up/drag-down reordering, and the priority/merge mode toggle — and how a complex configuration UI is composed from the simpler pieces you've already built.
This is Part 14 of "Extending bpmn-io Form-JS Beyond Its Limits." The series covers the complete architecture for production-grade Form-JS extensions — the documentation that doesn't exist yet.
Tags: camunda bpmn formjs preact properties-panel accessible components javascript devex
Top comments (0)