DEV Community

Cover image for Building a Searchable Select Component for the bpmn-io Properties Panel
Sam Abaasi
Sam Abaasi

Posted on

Building a Searchable Select Component for the bpmn-io Properties Panel

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 from preact/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's jsx and jsxs, 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:

  1. Text input that filters the visible options
  2. Keyboard navigation — ArrowUp, ArrowDown, Enter, Escape
  3. Click outside to close
  4. Loading state for async option lists
  5. Validation error display
  6. Accessible label association
  7. 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 })
        )
      })
    ]
  });
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. A trigger button — shows the currently selected option label, opens the dropdown when clicked
  2. A dropdown panel — appears below the trigger when open, contains the search input and options list
  3. State managementisOpen, 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 : '';
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
    }
  };
Enter fullscreen mode Exit fullscreen mode

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);
      }
    }
  };
Enter fullscreen mode Exit fullscreen mode

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
      })
    ]
  });
}
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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)
  // ...
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'};`,
Enter fullscreen mode Exit fullscreen mode
children: loading ? 'Loading...' : (displayValue || '-- Select --')
Enter fullscreen mode Exit fullscreen mode

And in handleToggle:

const handleToggle = () => {
  if (!disabled && !loading) {  // ✅ Neither disabled nor loading
    setIsOpen(!isOpen);
    // ...
  }
};
Enter fullscreen mode Exit fullscreen mode

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 onInput vs onChange bug 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
      })
    ]
  });
}
Enter fullscreen mode Exit fullscreen mode

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'
  });
}
Enter fullscreen mode Exit fullscreen mode
// 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'
  });
}
Enter fullscreen mode Exit fullscreen mode

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)