← 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 = '×';
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);
})();