OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

person-search.js

13 KB
/**
 * 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, '&amp;').replace(/</g, '&lt;')
            .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    }

    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 };
})();