Quelltext des aktuellen Standes
/**
* Wiederverwendbare Verzeichnis-Personensuche (Autocomplete) mit Freitext.
*
* Aktiviert sich automatisch für jedes <input data-person-search="<endpoint>">.
* Liefert volle Tastatur-Bedienung (Pfeiltasten/Enter/Escape) und ARIA-
* Combobox-Semantik. Der Endpoint muss ein JSON-Array mit Objekten der Form
* { id, display_name, email, department?, job_title?, source? } liefern
* (z. B. /api/persons/search).
*
* Konfiguration per data-Attribut am Input:
* data-person-search Endpoint-URL (Pflicht; ?q=&limit= werden angehängt)
* data-ps-min Mindestzeichen vor der Suche (Default 2)
* data-ps-set Was die Auswahl in DIESES Feld schreibt:
* "name" (Default) = Anzeigename, "email" = E-Mail
* data-ps-require-email "1" = Personen ohne E-Mail werden deaktiviert dargestellt
* data-ps-fill-id Element-ID, in die die Personen-ID geschrieben wird
* data-ps-fill-name Element-ID, in die der Anzeigename geschrieben wird
* data-ps-fill-email Element-ID, in die die E-Mail geschrieben wird
* data-ps-badge Element-ID der Plakette „aus Verzeichnis" (.psx-selected).
* Sie wird bei einer Auswahl eingeblendet und wieder
* versteckt, sobald die Verknüpfung entfällt. Ein darin
* liegender .psx-selected-clear-Knopf hebt die Auswahl auf.
* data-ps-clear Verhalten dieses Knopfes: "link" (Default) löst nur die
* Verknüpfung und lässt den Text stehen – richtig, wenn das
* Feld selbst gespeichert wird; "input" leert zusätzlich das
* Suchfeld – richtig für reine Suchfelder.
*
* Zusätzlich werden auf dem Input zwei CustomEvents ausgelöst (bubbles):
* 'person-search:select' (detail = Person) bei Auswahl
* 'person-search:clear' wenn eine zuvor getroffene Auswahl durch manuelle
* Eingabe wieder aufgehoben wird (data-ps-fill-id wird
* dann automatisch geleert).
*/
(function () {
'use strict';
var ACTIVE = 'psx-item--active';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
function attach(input) {
if (!input || input.__psxAttached) { return; }
input.__psxAttached = true;
var endpoint = input.getAttribute('data-person-search') || '/api/persons/search';
var minChars = parseInt(input.getAttribute('data-ps-min') || '2', 10);
var setMode = input.getAttribute('data-ps-set') || 'name';
var requireEmail = input.getAttribute('data-ps-require-email') === '1';
var fillId = input.getAttribute('data-ps-fill-id');
var fillName = input.getAttribute('data-ps-fill-name');
var fillEmail = input.getAttribute('data-ps-fill-email');
var badgeId = input.getAttribute('data-ps-badge');
var badge = badgeId ? document.getElementById(badgeId) : null;
var clearMode = input.getAttribute('data-ps-clear') || 'link';
var wrap = input.closest('.psx-wrap');
if (!wrap) {
wrap = document.createElement('div');
wrap.className = 'psx-wrap';
input.parentNode.insertBefore(wrap, input);
wrap.appendChild(input);
}
var dropId = 'psx-drop-' + Math.random().toString(36).slice(2, 9);
var drop = document.createElement('div');
drop.className = 'psx-dropdown';
drop.id = dropId;
drop.setAttribute('role', 'listbox');
wrap.appendChild(drop);
input.setAttribute('role', 'combobox');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-controls', dropId);
var timer = null, seq = 0, items = [], persons = [], activeIdx = -1, open = false, hasSelection = false;
// Eine bereits vorausgefüllte Verknüpfung (z. B. gespeicherte Verzeichnisperson)
// gilt als Auswahl, damit manuelles Bearbeiten sie wie gewohnt aufhebt.
if (fillId) {
var preEl = document.getElementById(fillId);
if (preEl && preEl.value && preEl.value !== '0') { hasSelection = true; }
}
function showBadge(on) {
if (badge) { badge.style.display = on ? '' : 'none'; }
}
showBadge(hasSelection);
/**
* Hebt die Verknüpfung zum Verzeichnis auf. Die übernommenen Name-/E-Mail-Felder
* bleiben stehen – der Wert soll als Freitext weiterverwendbar sein. Ob dabei
* auch das Suchfeld geleert wird, entscheidet data-ps-clear: Trägt das Feld
* selbst den zu speichernden Wert ("link", Standard), bleibt sein Text erhalten;
* ist es nur ein Sucheingabefeld ("input"), wird er entfernt.
*/
function dropSelection() {
if (clearMode === 'input') { input.value = ''; }
if (fillId) {
var idEl = document.getElementById(fillId);
if (idEl) { idEl.value = ''; }
}
hasSelection = false;
showBadge(false);
input.dispatchEvent(new CustomEvent('person-search:clear', { bubbles: true }));
}
if (badge) {
var clearBtn = badge.querySelector('.psx-selected-clear');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
dropSelection();
close();
input.focus();
});
}
}
function close() {
open = false; activeIdx = -1; items = []; persons = [];
drop.classList.remove('psx-dropdown--open');
drop.innerHTML = '';
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
}
function isDisabled(el) {
return el && el.getAttribute('aria-disabled') === 'true';
}
function setActive(idx) {
if (!items.length) { return; }
if (idx < 0) { idx = items.length - 1; }
if (idx >= items.length) { idx = 0; }
var guard = 0;
while (isDisabled(items[idx]) && guard < items.length) {
idx = (idx + 1) % items.length; guard++;
}
if (isDisabled(items[idx])) { return; }
items.forEach(function (el) {
el.classList.remove(ACTIVE); el.setAttribute('aria-selected', 'false');
});
activeIdx = idx;
var el = items[idx];
el.classList.add(ACTIVE);
el.setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', el.id);
el.scrollIntoView({ block: 'nearest' });
}
function selectPerson(p) {
if (!p || (requireEmail && !p.email)) { return; }
if (setMode === 'email') {
input.value = p.email || '';
} else {
input.value = p.display_name || '';
}
if (fillId) { var e1 = document.getElementById(fillId); if (e1) { e1.value = p.id || ''; } }
if (fillName) { var e2 = document.getElementById(fillName); if (e2) { e2.value = p.display_name || ''; } }
if (fillEmail) { var e3 = document.getElementById(fillEmail); if (e3 && p.email) { e3.value = p.email; } }
hasSelection = true;
showBadge(true);
input.dispatchEvent(new CustomEvent('person-search:select', { detail: p, bubbles: true }));
close();
}
function render(list) {
persons = Array.isArray(list) ? list : [];
drop.innerHTML = '';
items = [];
if (!persons.length) {
var none = document.createElement('div');
none.className = 'psx-empty';
none.textContent = 'Keine Person gefunden';
drop.appendChild(none);
open = true;
drop.classList.add('psx-dropdown--open');
input.setAttribute('aria-expanded', 'true');
return;
}
persons.forEach(function (p, i) {
var noEmail = !p.email;
var disabled = requireEmail && noEmail;
var el = document.createElement('div');
el.className = 'psx-item' + (disabled ? ' psx-item--disabled' : '');
el.id = dropId + '-opt-' + i;
el.setAttribute('role', 'option');
el.setAttribute('aria-selected', 'false');
if (disabled) { el.setAttribute('aria-disabled', 'true'); }
var sub = [p.email || (noEmail ? '(keine E-Mail)' : ''), p.department, p.job_title]
.filter(Boolean).map(esc).join(' · ');
el.innerHTML =
'<div class="psx-item-name">' + esc(p.display_name) +
(p.source ? '<span class="psx-badge psx-badge--' + esc(p.source) + '">' + esc(p.source) + '</span>' : '') +
'</div>' + (sub ? '<div class="psx-item-sub">' + sub + '</div>' : '');
if (!disabled) {
el.addEventListener('mousedown', function (ev) { ev.preventDefault(); selectPerson(p); });
el.addEventListener('mouseenter', function () { setActive(i); });
}
drop.appendChild(el);
items.push(el);
});
open = true;
drop.classList.add('psx-dropdown--open');
input.setAttribute('aria-expanded', 'true');
setActive(0);
}
function query(q) {
var mine = ++seq;
var url = endpoint + (endpoint.indexOf('?') >= 0 ? '&' : '?') +
'q=' + encodeURIComponent(q) + '&limit=20';
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) { if (mine === seq) { render(list); } })
.catch(function () { if (mine === seq) { close(); } });
}
input.addEventListener('input', function () {
clearTimeout(timer);
// Manuelle Eingabe hebt eine zuvor getroffene Verzeichnis-Auswahl auf,
// damit keine veraltete Personen-ID mit freiem Text abgeschickt wird.
if (hasSelection) {
hasSelection = false;
showBadge(false);
// Nur die Personen-ID fällt weg; bereits übernommene Name-/E-Mail-Felder
// bleiben stehen, damit der Freitext weiterverwendbar ist.
if (fillId) { var fe = document.getElementById(fillId); if (fe) { fe.value = ''; } }
input.dispatchEvent(new CustomEvent('person-search:clear', { bubbles: true }));
}
var q = input.value.trim();
if (q.length < minChars) { close(); return; }
timer = setTimeout(function () { query(q); }, 220);
});
input.addEventListener('keydown', function (e) {
if (!open) {
if (e.key === 'ArrowDown' && input.value.trim().length >= minChars) {
e.preventDefault();
query(input.value.trim());
}
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(activeIdx + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(activeIdx - 1); }
else if (e.key === 'Enter') {
if (activeIdx >= 0 && persons[activeIdx]) { e.preventDefault(); selectPerson(persons[activeIdx]); }
} else if (e.key === 'Escape') {
// Nur das Dropdown schließen – nicht das umgebende Modal.
e.preventDefault(); e.stopPropagation(); close();
} else if (e.key === 'Tab') {
close();
}
});
document.addEventListener('click', function (e) {
if (!wrap.contains(e.target)) { close(); }
});
}
function init(root) {
var scope = root || document;
var nodes = scope.querySelectorAll('input[data-person-search]');
Array.prototype.forEach.call(nodes, attach);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { init(document); });
} else {
init(document);
}
window.PersonSearch = { attach: attach, init: init };
})();