← back to NationalPaperHangers

public/js/tag-input.js

200 lines

/**
 * tag-input.js — pill/chip tag input component (vanilla JS, no framework)
 *
 * Usage: add data-tag-input="fieldName" to a wrapper element.
 * The script finds the existing hidden <input name="fieldName"> (which stores
 * the comma-joined value the POST handler already reads), renders a pill UI
 * on top, and keeps the hidden input in sync.
 *
 * Allowed values: pass JSON array via data-allowed="[...]" (optional).
 * If omitted, free-text tags are accepted (brands_handled, accreditations).
 */
(function () {
  'use strict';

  function initTagInput(wrapper) {
    var fieldName = wrapper.dataset.tagInput;
    var hidden = wrapper.querySelector('input[type="hidden"][name="' + fieldName + '"]');
    if (!hidden) return;

    var allowedRaw = wrapper.dataset.allowed;
    var allowed = allowedRaw ? JSON.parse(allowedRaw) : null;

    // Read current comma-split values from the hidden input
    var tags = hidden.value
      ? hidden.value.split(',').map(function (v) { return v.trim(); }).filter(Boolean)
      : [];

    // Build the pill container + text input
    var pillBox = document.createElement('div');
    pillBox.className = 'tag-pill-box';
    pillBox.setAttribute('role', 'group');
    pillBox.setAttribute('aria-label', fieldName.replace(/_/g, ' '));

    var textInput = document.createElement('input');
    textInput.type = 'text';
    textInput.className = 'tag-text-input';
    textInput.placeholder = allowed
      ? 'Type to choose or press Enter'
      : 'Type and press Enter to add';
    textInput.setAttribute('autocomplete', 'off');
    textInput.setAttribute('aria-label', 'Add ' + fieldName.replace(/_/g, ' '));

    // Dropdown for enumerated values
    var dropdown = null;
    if (allowed) {
      dropdown = document.createElement('ul');
      dropdown.className = 'tag-dropdown';
      dropdown.setAttribute('role', 'listbox');
      dropdown.hidden = true;
      pillBox.appendChild(dropdown);
    }

    pillBox.appendChild(textInput);
    wrapper.appendChild(pillBox);

    function sync() {
      hidden.value = tags.join(', ');
    }

    function renderPills() {
      // Remove existing pill elements (not the textInput or dropdown)
      Array.from(pillBox.querySelectorAll('.tag-pill')).forEach(function (el) {
        el.remove();
      });
      tags.forEach(function (tag, idx) {
        var pill = document.createElement('span');
        pill.className = 'tag-pill';
        pill.textContent = tag.replace(/_/g, ' ');
        pill.setAttribute('role', 'button');
        pill.setAttribute('tabindex', '0');
        pill.setAttribute('aria-label', 'Remove ' + tag.replace(/_/g, ' '));

        var removeBtn = document.createElement('button');
        removeBtn.type = 'button';
        removeBtn.className = 'tag-pill-remove';
        removeBtn.innerHTML = '&times;';
        removeBtn.setAttribute('aria-label', 'Remove ' + tag.replace(/_/g, ' '));
        removeBtn.addEventListener('click', function () {
          tags.splice(idx, 1);
          renderPills();
          sync();
        });
        pill.appendChild(removeBtn);

        // Support keyboard removal via Enter/Space on the pill itself
        pill.addEventListener('keydown', function (e) {
          if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            tags.splice(idx, 1);
            renderPills();
            sync();
            textInput.focus();
          }
        });

        // Insert before the textInput
        pillBox.insertBefore(pill, textInput);
      });
    }

    function addTag(raw) {
      var val = raw.trim().toLowerCase().replace(/\s+/g, '_');
      if (!val) return;
      if (allowed && !allowed.includes(val)) return; // reject invalid enum
      if (tags.includes(val)) {
        textInput.value = '';
        return; // dedupe
      }
      tags.push(val);
      renderPills();
      sync();
      textInput.value = '';
      if (dropdown) closeDropdown();
    }

    function openDropdown(filter) {
      if (!dropdown) return;
      var fl = filter.toLowerCase();
      var matches = allowed.filter(function (v) {
        return !tags.includes(v) && v.replace(/_/g, ' ').includes(fl);
      });
      dropdown.innerHTML = '';
      if (!matches.length) { dropdown.hidden = true; return; }
      matches.forEach(function (v) {
        var li = document.createElement('li');
        li.className = 'tag-dropdown-item';
        li.setAttribute('role', 'option');
        li.textContent = v.replace(/_/g, ' ');
        li.addEventListener('mousedown', function (e) {
          e.preventDefault(); // prevent blur before click
          addTag(v);
        });
        dropdown.appendChild(li);
      });
      dropdown.hidden = false;
    }

    function closeDropdown() {
      if (dropdown) dropdown.hidden = true;
    }

    textInput.addEventListener('input', function () {
      if (allowed) openDropdown(textInput.value);
    });

    textInput.addEventListener('focus', function () {
      if (allowed) openDropdown(textInput.value);
    });

    textInput.addEventListener('blur', function () {
      setTimeout(closeDropdown, 150); // allow mousedown on item to fire first
    });

    textInput.addEventListener('keydown', function (e) {
      if (e.key === 'Enter' || e.key === ',') {
        e.preventDefault();
        addTag(textInput.value);
      }
      // Backspace on empty input removes last tag
      if (e.key === 'Backspace' && !textInput.value && tags.length) {
        tags.pop();
        renderPills();
        sync();
      }
      // Arrow keys navigate dropdown
      if (dropdown && !dropdown.hidden) {
        var items = Array.from(dropdown.querySelectorAll('.tag-dropdown-item'));
        var focused = dropdown.querySelector('.tag-dropdown-item.is-active');
        var idx = items.indexOf(focused);
        if (e.key === 'ArrowDown') {
          e.preventDefault();
          if (focused) focused.classList.remove('is-active');
          var next = items[(idx + 1) % items.length];
          if (next) { next.classList.add('is-active'); next.scrollIntoView({ block: 'nearest' }); }
        }
        if (e.key === 'ArrowUp') {
          e.preventDefault();
          if (focused) focused.classList.remove('is-active');
          var prev = items[(idx - 1 + items.length) % items.length];
          if (prev) { prev.classList.add('is-active'); prev.scrollIntoView({ block: 'nearest' }); }
        }
        if (e.key === 'Enter' && focused) {
          e.preventDefault();
          addTag(focused.textContent);
        }
        if (e.key === 'Escape') closeDropdown();
      }
    });

    // Click on the pill box focuses text input (UX feel)
    pillBox.addEventListener('click', function (e) {
      if (e.target === pillBox) textInput.focus();
    });

    renderPills();
  }

  document.querySelectorAll('[data-tag-input]').forEach(initTagInput);
})();