Explorar o código

Replace item dropdown on mappings page with a filtering search field

The select had grown past 100 options and was no longer practical to use.
It is now a text input that filters the item list as you type, matching
anywhere in the name (case- and diacritic-insensitive), so "seil" finds
"Seil (50m)", "Einfachseil" and "Seilklemme". Item categories stay as
headers and are hidden when none of their items match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sebastian hai 3 semanas
pai
achega
3454183bf9
Modificáronse 1 ficheiros con 155 adicións e 18 borrados
  1. 155 18
      admin/mappings.php

+ 155 - 18
admin/mappings.php

@@ -80,6 +80,19 @@ show_alerts();
 ?>
 <h1>Activity ↔ Item Mappings</h1>
 
+<style>
+/* Searchable item picker (replaces the unwieldy 100+ entry select) */
+.item-picker{position:relative;}
+.item-list{position:absolute;z-index:20;top:calc(100% + 4px);left:0;right:0;max-height:280px;overflow-y:auto;
+           background:var(--surface);border:1.5px solid var(--border);border-radius:var(--radius);
+           box-shadow:0 8px 24px rgba(0,0,0,.45);padding:.25rem 0;}
+.item-group-label{padding:.4rem .8rem .2rem;font-size:.7rem;font-weight:600;letter-spacing:.08em;
+                  text-transform:uppercase;color:var(--accent2);}
+.item-option{padding:.35rem .8rem .35rem 1.3rem;font-size:.88rem;cursor:pointer;}
+.item-option:hover,.item-option.active{background:rgba(232,197,71,.12);color:var(--accent);}
+.item-empty{padding:.5rem .8rem;font-size:.85rem;color:var(--muted);}
+</style>
+
 <div style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:flex-start;">
 
   <!-- Activity selector -->
@@ -112,25 +125,34 @@ show_alerts();
         <input type="hidden" name="action" value="add">
         <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
         <div style="flex:1;min-width:200px;">
-          <label>Add Item</label>
-          <select name="itemID" required style="margin-bottom:0;">
-            <option value="">– choose item –</option>
-            <?php
-            // Group items in optgroups
-            $optGroups = [];
-            foreach ($allItems as $it) {
-                if (in_array($it['itemID'], $mappedItemIds)) continue; // already mapped
-                $optGroups[$it['groupName']][] = $it;
-            }
-            foreach ($optGroups as $grpName => $grpItems):
-            ?>
-            <optgroup label="<?= htmlspecialchars($grpName) ?>">
-              <?php foreach ($grpItems as $it): ?>
-              <option value="<?= $it['itemID'] ?>"><?= htmlspecialchars($it['itemName']) ?></option>
+          <label for="item-search">Add Item</label>
+          <div class="item-picker" id="item-picker">
+            <input type="hidden" name="itemID" id="item-id">
+            <input type="text" id="item-search" autocomplete="off" role="combobox"
+                   aria-autocomplete="list" aria-expanded="false" aria-controls="item-list"
+                   placeholder="Type to search items…" style="margin-bottom:0;">
+            <div class="item-list" id="item-list" role="listbox" hidden>
+              <?php
+              // Group the still-unmapped items; the list is filtered client-side.
+              $optGroups = [];
+              foreach ($allItems as $it) {
+                  if (in_array($it['itemID'], $mappedItemIds)) continue; // already mapped
+                  $optGroups[$it['groupName']][] = $it;
+              }
+              foreach ($optGroups as $grpName => $grpItems):
+              ?>
+              <div class="item-group">
+                <div class="item-group-label"><?= htmlspecialchars($grpName) ?></div>
+                <?php foreach ($grpItems as $it): ?>
+                <div class="item-option" role="option" tabindex="-1"
+                     data-id="<?= $it['itemID'] ?>"
+                     data-name="<?= htmlspecialchars($it['itemName']) ?>"><?= htmlspecialchars($it['itemName']) ?></div>
+                <?php endforeach; ?>
+              </div>
               <?php endforeach; ?>
-            </optgroup>
-            <?php endforeach; ?>
-          </select>
+              <div class="item-empty" hidden>No matching items.</div>
+            </div>
+          </div>
         </div>
         <div style="width:90px;">
           <label>Qty</label>
@@ -178,4 +200,119 @@ show_alerts();
   </div>
 
 </div>
+
+<script>
+(function () {
+  var picker = document.getElementById('item-picker');
+  if (!picker) return;
+  var search  = document.getElementById('item-search');
+  var hidden  = document.getElementById('item-id');
+  var list    = document.getElementById('item-list');
+  var empty   = list.querySelector('.item-empty');
+  var groups  = Array.prototype.slice.call(list.querySelectorAll('.item-group'));
+  var options = Array.prototype.slice.call(list.querySelectorAll('.item-option'));
+  var active  = null;
+
+  // Lower-cased and stripped of diacritics, so "muhe" also finds "Mühe".
+  function norm(s) {
+    return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
+  }
+  options.forEach(function (o) { o.dataset.search = norm(o.dataset.name); });
+
+  function setActive(el) {
+    if (active) active.classList.remove('active');
+    active = el;
+    if (active) {
+      active.classList.add('active');
+      active.scrollIntoView({block: 'nearest'});
+    }
+  }
+
+  function visibleOptions() {
+    return options.filter(function (o) { return !o.hidden; });
+  }
+
+  function filter(showAll) {
+    var q = showAll ? '' : norm(search.value.trim());
+    options.forEach(function (o) {
+      o.hidden = q !== '' && o.dataset.search.indexOf(q) === -1;
+    });
+    // Hide a group entirely when none of its items matched.
+    groups.forEach(function (g) {
+      var items = Array.prototype.slice.call(g.querySelectorAll('.item-option'));
+      g.hidden = !items.some(function (o) { return !o.hidden; });
+    });
+    empty.hidden = visibleOptions().length > 0;
+    setActive(visibleOptions()[0] || null);
+  }
+
+  function open() {
+    list.hidden = false;
+    search.setAttribute('aria-expanded', 'true');
+  }
+
+  function close() {
+    list.hidden = true;
+    search.setAttribute('aria-expanded', 'false');
+    setActive(null);
+  }
+
+  function choose(option) {
+    if (!option) return;
+    hidden.value = option.dataset.id;
+    search.value = option.dataset.name;
+    filter();
+    close();
+  }
+
+  search.addEventListener('input', function () {
+    hidden.value = ''; // typing invalidates a previous pick
+    filter();
+    open();
+  });
+  search.addEventListener('focus', function () {
+    // After a pick the field holds the full item name; offer the whole list again.
+    var picked = !!hidden.value;
+    if (picked) search.select();
+    filter(picked);
+    open();
+  });
+  search.addEventListener('keydown', function (e) {
+    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+      e.preventDefault();
+      if (list.hidden) { filter(!!hidden.value); open(); return; }
+      var vis = visibleOptions();
+      if (!vis.length) return;
+      var i = vis.indexOf(active);
+      i = e.key === 'ArrowDown' ? (i + 1) % vis.length : (i <= 0 ? vis.length : i) - 1;
+      setActive(vis[i]);
+    } else if (e.key === 'Enter') {
+      if (!list.hidden && active) { e.preventDefault(); choose(active); }
+    } else if (e.key === 'Escape') {
+      close();
+    }
+  });
+
+  list.addEventListener('mousedown', function (e) {
+    var option = e.target.closest('.item-option');
+    if (option) { e.preventDefault(); choose(option); }
+  });
+
+  document.addEventListener('click', function (e) {
+    if (!picker.contains(e.target)) close();
+  });
+
+  search.form.addEventListener('submit', function (e) {
+    if (!hidden.value) {
+      e.preventDefault();
+      search.focus();
+      filter();
+      open();
+    }
+  });
+
+  filter();
+})();
+</script>
+
 <?php admin_foot(); ?>