ui_elements_dropdown.mjs

/**
## /ui/elements/dropdown

The dropdown elements module exports the dropdown method to create a dropdown element group from a params argument.

@requires /ui/elements/pills

@module /ui/elements/dropdown
*/

/**
@function dropdown

@description
The dropdown method returns a dropdown element created from the params argument.

@param {Object} params Parameter for the creation of the dropdown element.
@property {string} [params.placeholder=''] The placeholder for the list of options.
@property {boolean} [params.multi] Allow multiple choice if true.
@property {boolean} [params.dropdown_search] Specify wether a searchbox is supplied in a dropdown filter.
@property {boolean} [params.keepPlaceholder] set this flag to `true` in order to keep the original placeholder after an option is selected.
@property {Object} [params.entries] Array of option elements. Expected format: [{title: 'Title for Option 1', option: 'option1'}, ...]. Add property `selected: true` for entry selected by default. `title` will appear as the description, `option` is a value passed as selected.
@property {function} [params.headerOnClick] Callback to execute when a header is clicked. Overrides default function.
@property {Number} [params.maxHeight] Optional max height property on results unordered list <ul> in pixels. Defaults to 500px in dropdown stylesheet.

@returns {HTMLElement} HTML dropdown element
*/
export default function dropdown(params) {
  params.selectedTitles = new Set();
  params.selectedOptions = new Set();

  params.placeholder ??= params.span || '';

  params.entries = params.entries?.filter?.((entry) => entry.option !== '');

  pillsElement(params);

  //Assign the search element if specified
  searchInput(params);

  params.onChange ??= selectOnChange;

  params.options = optionElements(params);

  // Create a string of title in set.
  const selectedTitles = params.selectedTitles.size
    ? Array.from(params.selectedTitles).join(', ')
    : params.placeholder;

  const placeholderText = params.keepPlaceholder
    ? params.placeholder
    : selectedTitles;

  params.placeHolderOption = mapp.utils.html.node`
    <option style="display: none;" value="" disabled selected>${placeholderText}`;

  params.options.unshift(params.placeHolderOption);

  params.data_id ??= 'dropdown';

  params.select = mapp.utils.html.node`<select
    class="select-dropdown"
    data-id=${params.data_id}
    onfocus=${selectReset}
    onblur=${selectReset}
    onchange=${(e) => selectOnChange(e, params)}>
    ${params.options}`;

  params.node = mapp.utils.html.node`
    ${params.pills?.container}
    ${params.search || params.select}`;

  return params.node;
}

function selectReset(e) {
  e.target.selectedIndex = 0;
}

function selectOnChange(e, params) {
  const selectedIndex = e.target.selectedIndex;

  // reset selectedIndex on target to the placeholder option.
  e.target.selectedIndex = 0;

  const entry = params.entries[selectedIndex - 1];

  if (params.multi) {
    const toggle = params.options[selectedIndex].classList.toggle('selected');

    if (toggle) {
      params.selectedTitles.add(entry.title);
      params.selectedOptions.add(entry.option);

      params.pills?.add(entry.title);
    } else {
      params.selectedTitles.delete(entry.title);
      params.selectedOptions.delete(entry.option);

      params.pills?.remove(entry.title);
    }

    if (!params.pills) {
      params.placeHolderOption.textContent =
        // join selected titles if available.
        (params.selectedTitles.size > 0 &&
          Array.from(params.selectedTitles).join(', ')) ||
        params.placeholder;
    }

    params.callback?.(e, [...params.selectedOptions]);

    // return if params.multi
    return;
  }

  if (!params.keepPlaceholder) {
    params.placeHolderOption.textContent = entry.title;
  }

  params.callback?.(e, entry);
}

function optionElements(params) {
  const options = params.entries.map((entry) => {
    const option = mapp.utils.html.node`<option
        value=${entry.option}>
        ${entry.title || entry.label || entry.field}`;

    // The entry is already selected during creation of dropdown.
    if (entry.selected) {
      option.classList.add('selected');
      params.selectedTitles.add(entry.title);
      params.selectedOptions.add(entry.option);

      // create pill
      params.pills?.add(entry.title);
    }

    return option;
  });

  return options;
}

/**
@function pillsElement

@description
Assign the params.pills property to the be a pills element.

@param {Object} params The dropdown element object.
@property {boolean} [params.pills] The pills element will be assigned to the flag property.
@property {set} params.selectedTitles A set of titles from currently selected dropdown items.
@property {set} params.selectedOptions A set of options from currently selected dropdown items.
*/
function pillsElement(params) {
  if (!params.pills) return;

  params.pills = mapp.ui.elements.pills({
    addCallback: (val, pills) => {
      params.callback?.(null, [...pills]);
    },
    pills: [...params.selectedTitles],
    removeCallback: (val, pills) => {
      const entry = params.entries.findIndex((entry) => entry.option === val);

      // Add one to the index to account for the placeholder option.
      const index =
        params.entries.findIndex((entry) => entry.option === val) + 1;

      // Remove the selected class from the option element and entry
      params.entries.find((entry) => entry.option === val).selected = false;
      params.options[index].classList.remove('selected');

      params.selectedTitles.delete(entry.title);
      params.selectedOptions.delete(entry.option);

      params.callback?.(null, [...pills]);
    },
  });
}

/**
@function search

@description
Assign the params.search property to the be a search element.

@param {Object} params The dropdown element object.
@property {set} params.selectedTitles A set of titles from currently selected dropdown items.
@property {set} params.selectedOptions A set of options from currently selected dropdown items.
@property {Array} params.entries The options avialble to the dropdown.
@property {String} params.placeholder THe options avialble to the dropdown.
@property {boolean} [params.search] The search element will be assigned to the flag property.
@property {boolean} [params.pills] Pill element for adding the options to when selected.

@returns {HTMElement} The search element and its datalist.
*/
function searchInput(params) {
  if (!params.search) return;

  const listId = `${params.field}-search-options`;

  const placeholder = params.placeholder || 'Enter search term...';

  const searchInput = mapp.utils.html.node`<input 
    placeholder=${placeholder}
    type="search" list=${listId} 
    onInput=${(e) => onSearchInput(e, params)}
    onfocus="this.placeholder=''"
    onblur=${(e) => (e.target.placeholder = placeholder)}>`;

  params.searchOptions = [];
  for (const entry of params.entries) {
    const option = mapp.utils.html.node`
      <option value=${entry.option} data-title=${entry.title}>${entry.title}`;
    params.searchOptions.push(option);
  }
  const datalist = mapp.utils.html
    .node`<datalist id=${listId}>${params.searchOptions}`;

  params.search = mapp.utils.html.node`${searchInput}${datalist}`;

  return params.search;
}

function onSearchInput(e, params) {
  params.entries.forEach((entry) => {
    if (entry.title === e.target.value) {
      entry.selected = !entry.selected;

      if (entry.selected) {
        params.selectedTitles.add(entry.title);
        params.selectedOptions.add(entry.option);
        params.pills?.add(entry.title);
      } else {
        params.selectedTitles.delete(entry.title);
        params.selectedOptions.delete(entry.option);
        params.pills?.remove(entry.title);
      }

      e.target.value = '';
      e.target.dispatchEvent(new Event('blur'));

      params.callback?.(e, [...params.selectedOptions]);
    }
  });
}