assets/js/autocomplete-helper.js

/**
 * Reusable autocomplete widget HTML generator.
 *
 * Produces markup matching the structure in templates/element/autoCompleteControl.php,
 * for use in JavaScript-rendered UI (e.g. workflow config panels).
 * All generated HTML is compatible with the "ac" Stimulus controller.
 *
 * @param {object} options
 * @param {string} options.url - AJAX endpoint URL for search
 * @param {string} [options.name] - name attribute for the hidden value input
 * @param {string} [options.value] - initial value for the hidden input
 * @param {string} [options.placeholder] - placeholder text for the text input
 * @param {number} [options.minLength=1] - minimum characters before search triggers
 * @param {boolean} [options.allowOther=false] - allow custom values not in results
 * @param {object} [options.initSelection] - {value, text} to pre-populate the widget
 * @param {string} [options.hiddenAttrs] - extra attribute string for the hidden value input
 * @param {string} [options.size] - Bootstrap size suffix (e.g. 'sm')
 * @param {string} [options.cssClass] - extra CSS classes on the wrapper div
 * @param {string} [options.inputId] - id for the visible text input
 * @param {string} [options.resultsId] - id for the results list
 * @param {string} [options.statusId] - id for the live status region
 * @returns {string} HTML string
 */
export function renderAutoComplete(options) {
    const url = options.url || '';
    const name = options.name ? ` name="${options.name}"` : '';
    const value = options.value || '';
    const placeholder = options.placeholder ? ` placeholder="${options.placeholder}"` : '';
    const minLength = options.minLength != null ? options.minLength : 1;
    const allowOther = options.allowOther ? 'true' : 'false';
    const hiddenAttrs = options.hiddenAttrs ? ' ' + options.hiddenAttrs : '';
    const size = options.size || '';
    const cssClass = options.cssClass ? ' ' + options.cssClass : '';

    const inputGroupClass = size ? `input-group input-group-${size}` : 'input-group';
    const inputClass = size ? `form-control form-control-${size}` : 'form-control';
    const safeName = (options.name || 'autocomplete').replace(/[^a-zA-Z0-9_-]/g, '-');
    const inputId = options.inputId || `${safeName}-disp`;
    const resultsId = options.resultsId || `${safeName}-results`;
    const statusId = options.statusId || `${safeName}-status`;

    let initAttr = '';
    if (options.initSelection) {
        const json = JSON.stringify(options.initSelection).replace(/'/g, ''');
        initAttr = `\n     data-ac-init-selection-value='${json}'`;
    }

    return `<div data-controller="ac"
     data-ac-url-value="${url}"
     data-ac-min-length-value="${minLength}"
     data-ac-allow-other-value="${allowOther}"${initAttr}
     class="position-relative kmp_autoComplete${cssClass}">
  <input type="hidden"${name} value="${value}"
         data-ac-target="hidden"${hiddenAttrs}>
  <input type="hidden" data-ac-target="hiddenText">
  <div class="${inputGroupClass}">
    <input type="text" class="${inputClass}" id="${inputId}"
           role="combobox"
           aria-autocomplete="list"
           aria-expanded="false"
           aria-controls="${resultsId}"
           aria-describedby="${statusId}"
           data-ac-target="input"${placeholder}>
    <button type="button" class="btn btn-outline-secondary" data-ac-target="clearBtn" data-action="ac#clear" disabled>Clear</button>
  </div>
  <ul data-ac-target="results"
      id="${resultsId}"
      role="listbox"
      class="list-group z-3 col-12 position-absolute auto-complete-list"
      hidden="hidden"></ul>
  <div id="${statusId}" class="visually-hidden" role="status" aria-live="polite" aria-atomic="true"
       data-ac-target="status"></div>
</div>`;
}