diff --git a/public/packets.js b/public/packets.js
index 19200572..b2756653 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -1678,7 +1678,12 @@
-
+
@@ -1789,8 +1794,23 @@
// --- Observer multi-select ---
const obsMenu = document.getElementById('observerMenu');
+ const obsList = document.getElementById('observerList');
+ const obsSearchInput = document.getElementById('observerSearchInput');
const obsTrigger = document.getElementById('observerTrigger');
const selectedObservers = new Set(filters.observer ? filters.observer.split(',') : []);
+ function applyObserverSearchFilter() {
+ const raw = (obsSearchInput.value || '').trim().toLowerCase();
+ // #1884 — default to substring matching so "brussels" finds "ON4XYZ
+ // Brussels"; a leading ^ opts into prefix-only matching for narrowing
+ // down a shared prefix like "BE".
+ const anchored = raw.startsWith('^');
+ const term = anchored ? raw.slice(1) : raw;
+ obsList.querySelectorAll('.multi-select-item[data-obs-name]').forEach((item) => {
+ const name = item.dataset.obsName;
+ const matches = !term || (anchored ? name.startsWith(term) : name.includes(term));
+ item.style.display = matches ? '' : 'none';
+ });
+ }
function buildObserverMenu() {
const allChecked = selectedObservers.size === 0;
let html = `
`;
@@ -1802,11 +1822,15 @@
} else {
for (const o of observers) {
const checked = selectedObservers.has(String(o.id)) ? 'checked' : '';
- html += `
`;
+ const name = o.name || String(o.id);
+ html += `
`;
}
}
- obsMenu.innerHTML = html;
+ obsList.innerHTML = html;
+ applyObserverSearchFilter();
}
+ obsSearchInput.addEventListener('click', (e) => e.stopPropagation());
+ obsSearchInput.addEventListener('input', applyObserverSearchFilter);
// #1693 — expose for loadObservers() to refresh on resolve.
_rebuildObserverMenu = () => { buildObserverMenu(); updateObsTrigger(); };
function updateObsTrigger() {
@@ -1822,9 +1846,22 @@
}
buildObserverMenu();
updateObsTrigger();
- obsTrigger.addEventListener('click', (e) => { e.stopPropagation(); obsMenu.classList.toggle('open'); typeMenu.classList.remove('open'); });
+ obsTrigger.addEventListener('click', (e) => {
+ e.stopPropagation();
+ obsMenu.classList.toggle('open');
+ typeMenu.classList.remove('open');
+ // #1884 — don't autofocus on touch devices; it raises the on-screen
+ // keyboard over the list the user is about to tap.
+ const isTouch = window.matchMedia('(pointer: coarse)').matches;
+ if (obsMenu.classList.contains('open') && !isTouch) obsSearchInput.focus();
+ });
obsMenu.addEventListener('change', (e) => {
const id = e.target.dataset.obsId;
+ // #1884 — obsSearchInput lives inside obsMenu, so its own change
+ // events (blur/Enter) bubble here too; without this guard they run
+ // the else branch below and rebuild the list mid-click, dropping
+ // whatever checkbox the user just pressed.
+ if (!id) return;
if (id === '__all__') {
selectedObservers.clear();
} else {
@@ -2000,6 +2037,8 @@
var obMenu = document.getElementById('observerMenu');
if (obMenu) obMenu.querySelectorAll('input[type=checkbox]').forEach(function(cb) { cb.checked = false; });
document.getElementById('observerTrigger').textContent = 'All Observers ▾';
+ obsSearchInput.value = '';
+ applyObserverSearchFilter();
// Reset type multi-select
var typeMenu = document.getElementById('typeMenu');
diff --git a/public/style.css b/public/style.css
index d3e49aec..b541f3f7 100644
--- a/public/style.css
+++ b/public/style.css
@@ -3552,6 +3552,16 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); }
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 4px 0; display: none;
}
.multi-select-menu.open { display: block; }
+.multi-select-search-wrap {
+ position: sticky; top: 0; z-index: 1; background: var(--card-bg, #fff);
+ padding: 4px 8px 6px; border-bottom: 1px solid var(--border);
+}
+.multi-select-search {
+ width: 100%; box-sizing: border-box; padding: 5px 8px; border-radius: 5px;
+ border: 1px solid var(--border); background: var(--input-bg); color: var(--text);
+ font-size: 13px;
+}
+.multi-select-search:focus { border-color: var(--accent); outline: none; }
.multi-select-item {
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
font-size: 13px; cursor: pointer; color: var(--text); white-space: nowrap;
diff --git a/test-all.sh b/test-all.sh
index 1563a183..9e15b728 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -146,7 +146,9 @@ node test-node-reach-coverage.js
node test-nodes-export-wiring.js
node test-nodes-export.js
node test-observer-iata-1188.js
+node test-observer-menu-interactions.js
node test-observer-naive-clock-1478.js
+node test-observer-search-filter.js
node test-observers-headings.js
node test-packet-filter-time.js
node test-packet-filter-ux.js
diff --git a/test-clear-filters.js b/test-clear-filters.js
index b8c9184d..2340bf9f 100644
--- a/test-clear-filters.js
+++ b/test-clear-filters.js
@@ -124,7 +124,10 @@ function extractClearHandler() {
else if (src[i] === '}') { depth--; if (depth === 0) { fnEnd = i; break; } }
}
assert(fnEnd > fnStart, 'could not find end of clear handler');
- return src.substring(fnStart + 1, fnEnd);
+ // The handler also resets the observer search box (#1884). These cases do
+ // not test it (test-observer-menu-interactions.js does), so give it stand-ins.
+ const searchStubs = "const obsSearchInput = { value: '' }; function applyObserverSearchFilter() {}\n";
+ return searchStubs + src.substring(fnStart + 1, fnEnd);
}
/**
diff --git a/test-observer-menu-interactions.js b/test-observer-menu-interactions.js
new file mode 100644
index 00000000..32d98144
--- /dev/null
+++ b/test-observer-menu-interactions.js
@@ -0,0 +1,148 @@
+/* test-observer-menu-interactions.js — behavioral tests for the observer
+ * dropdown's event handlers (#1884 follow-up review). Exercises the actual
+ * handler bodies extracted from packets.js, not source-grep tautology.
+ *
+ * Covers three interaction bugs found after the matcher itself (tested in
+ * test-observer-search-filter.js) was fixed:
+ * 1. obsSearchInput's own change/blur events bubble into obsMenu's change
+ * handler and must not be mistaken for a checkbox toggle.
+ * 2. Clear Filters must reset the search box and re-apply the filter.
+ * 3. The dropdown must not autofocus the search box on touch devices.
+ */
+'use strict';
+
+const fs = require('fs');
+const assert = require('assert');
+
+console.log('--- test-observer-menu-interactions.js ---');
+
+let passed = 0, failed = 0;
+function test(name, fn) {
+ try { fn(); passed++; console.log(` ✅ ${name}`); }
+ catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
+}
+
+const SRC = fs.readFileSync(__dirname + '/public/packets.js', 'utf-8');
+
+/**
+ * Extract the source between a marker ending in "{" and its matching "}".
+ */
+function extractBlock(marker) {
+ const markerIdx = SRC.indexOf(marker);
+ assert(markerIdx !== -1, `marker not found: ${marker}`);
+ const fnStart = markerIdx + marker.length - 1;
+ assert(SRC[fnStart] === '{', `marker must end just before "{": ${marker}`);
+ let depth = 0, fnEnd = -1;
+ for (let i = fnStart; i < SRC.length; i++) {
+ if (SRC[i] === '{') depth++;
+ else if (SRC[i] === '}') { depth--; if (depth === 0) { fnEnd = i; break; } }
+ }
+ assert(fnEnd > fnStart, `could not find end of block: ${marker}`);
+ return SRC.substring(fnStart + 1, fnEnd);
+}
+
+function stubEl(overrides) {
+ return Object.assign({
+ value: '',
+ textContent: '',
+ style: {},
+ classList: { add() {}, remove() {}, contains() { return false; }, toggle() {} },
+ querySelectorAll: () => [],
+ }, overrides);
+}
+
+// --- 1. obsMenu change handler must ignore events with no data-obs-id ---
+// (the search input's own change/blur events bubble up to this listener)
+{
+ const body = extractBlock("obsMenu.addEventListener('change', (e) => {");
+
+ function run(target, { calls, selectedObservers, filters }) {
+ const localStorage = { setItem: () => calls.push('setItem'), removeItem: () => calls.push('removeItem') };
+ const buildObserverMenu = () => calls.push('buildObserverMenu');
+ const updateObsTrigger = () => calls.push('updateObsTrigger');
+ const updatePacketsUrl = () => calls.push('updatePacketsUrl');
+ const renderTableRows = () => calls.push('renderTableRows');
+ const fn = new Function(
+ 'e', 'selectedObservers', 'filters', 'localStorage',
+ 'buildObserverMenu', 'updateObsTrigger', 'updatePacketsUrl', 'renderTableRows',
+ body
+ );
+ fn(target, selectedObservers, filters, localStorage, buildObserverMenu, updateObsTrigger, updatePacketsUrl, renderTableRows);
+ }
+
+ test('change event with no data-obs-id (bubbled from search input) is a no-op', () => {
+ const calls = [];
+ const selectedObservers = new Set(['3']);
+ run({ target: { dataset: {}, checked: false } }, { calls, selectedObservers, filters: {} });
+ assert.deepStrictEqual(calls, [], 'no side effects should run for an id-less change event');
+ assert.deepStrictEqual([...selectedObservers], ['3'], 'selection must be untouched');
+ });
+
+ test('change event on an actual checkbox still updates the selection', () => {
+ const calls = [];
+ const selectedObservers = new Set();
+ const filters = {};
+ run({ target: { dataset: { obsId: '7' }, checked: true } }, { calls, selectedObservers, filters });
+ assert.deepStrictEqual([...selectedObservers], ['7'], 'checking observer 7 should select it');
+ assert(calls.includes('buildObserverMenu'), 'buildObserverMenu should run for a real toggle');
+ assert(calls.includes('updatePacketsUrl'), 'updatePacketsUrl should run for a real toggle');
+ });
+}
+
+// --- 2. Clear Filters must reset and re-apply the observer search box ---
+{
+ const body = extractBlock("if (clearBtn) clearBtn.addEventListener('click', function() {");
+
+ test('Clear Filters resets the search box and re-applies the filter', () => {
+ const applyCalls = [];
+ const obsSearchInput = stubEl({ value: 'brussels' });
+ const filters = {};
+ const localStorage = { removeItem() {}, setItem() {} };
+ const RegionFilter = { setSelected() {} };
+ const loadPackets = () => {};
+ const updatePacketsUrl = () => {};
+ const applyObserverSearchFilter = () => applyCalls.push('applied');
+ const documentStub = { getElementById: () => stubEl({}) };
+
+ const fn = new Function(
+ 'filters', 'localStorage', 'document', 'obsSearchInput', 'applyObserverSearchFilter',
+ 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', 'RegionFilter', 'updatePacketsUrl', 'loadPackets',
+ '_observerFilterSet',
+ body
+ );
+ fn(filters, localStorage, documentStub, obsSearchInput, applyObserverSearchFilter,
+ 15, 15, RegionFilter, updatePacketsUrl, loadPackets, null);
+
+ assert.strictEqual(obsSearchInput.value, '', 'search box should be cleared');
+ assert.deepStrictEqual(applyCalls, ['applied'], 'the filter should be re-applied after clearing');
+ });
+}
+
+// --- 3. Autofocus must be gated on touch (pointer: coarse) devices ---
+{
+ const body = extractBlock("obsTrigger.addEventListener('click', (e) => {");
+
+ function run(isTouch) {
+ const focusCalls = [];
+ const obsSearchInput = stubEl({ focus: () => focusCalls.push('focus') });
+ const obsMenu = { classList: { toggle() {}, contains: () => true } };
+ const typeMenu = { classList: { remove() {} } };
+ const window_ = { matchMedia: () => ({ matches: isTouch }) };
+ const fn = new Function('e', 'obsMenu', 'typeMenu', 'obsSearchInput', 'window', body);
+ fn({ stopPropagation() {} }, obsMenu, typeMenu, obsSearchInput, window_);
+ return focusCalls;
+ }
+
+ test('touch devices (pointer: coarse) do not get the search box autofocused', () => {
+ assert.deepStrictEqual(run(true), [], 'focus() should not be called on touch devices');
+ });
+
+ test('non-touch devices still get the search box autofocused', () => {
+ assert.deepStrictEqual(run(false), ['focus'], 'focus() should still be called on desktop/mouse');
+ });
+}
+
+// Summary
+console.log(`\n${passed} passed, ${failed} failed`);
+if (failed > 0) process.exit(1);
+console.log('All tests passed ✅');
diff --git a/test-observer-search-filter.js b/test-observer-search-filter.js
new file mode 100644
index 00000000..ddcdc27d
--- /dev/null
+++ b/test-observer-search-filter.js
@@ -0,0 +1,119 @@
+/* test-observer-search-filter.js — behavioral tests for the observer dropdown
+ * search box (#1884). Exercises the actual applyObserverSearchFilter logic,
+ * not source-grep tautology.
+ */
+'use strict';
+
+const vm = require('vm');
+const fs = require('fs');
+const assert = require('assert');
+
+console.log('--- test-observer-search-filter.js ---');
+
+let passed = 0, failed = 0;
+function test(name, fn) {
+ try { fn(); passed++; console.log(` ✅ ${name}`); }
+ catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
+}
+
+/**
+ * Extract the applyObserverSearchFilter function body from packets.js.
+ */
+function extractApplyObserverSearchFilter() {
+ const src = fs.readFileSync(__dirname + '/public/packets.js', 'utf-8');
+ const marker = 'function applyObserverSearchFilter()';
+ const idx = src.indexOf(marker);
+ assert(idx !== -1, 'applyObserverSearchFilter not found in packets.js');
+ const fnStart = src.indexOf('{', idx);
+ let depth = 0, fnEnd = -1;
+ for (let i = fnStart; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') { depth--; if (depth === 0) { fnEnd = i; break; } }
+ }
+ assert(fnEnd > fnStart, 'could not find end of applyObserverSearchFilter');
+ return src.substring(fnStart + 1, fnEnd);
+}
+
+/**
+ * Build a minimal sandbox: an obsSearchInput with a settable value, and an
+ * obsList whose querySelectorAll returns items with a data-obs-name dataset
+ * and a style.display we can assert on.
+ */
+function makeItem(name) {
+ return { dataset: { obsName: name }, style: { display: '' } };
+}
+
+function runFilter(term, names) {
+ const items = names.map(makeItem);
+ const obsSearchInput = { value: term };
+ const obsList = {
+ querySelectorAll: (sel) => {
+ assert.strictEqual(sel, '.multi-select-item[data-obs-name]');
+ return items;
+ },
+ };
+ const body = extractApplyObserverSearchFilter();
+ const fn = new Function('obsSearchInput', 'obsList', body);
+ fn(obsSearchInput, obsList);
+ return items;
+}
+
+const fnBody = extractApplyObserverSearchFilter();
+
+test('both includes and startsWith are used (default substring, ^-anchored prefix)', () => {
+ assert(fnBody.includes('.includes('), 'expected substring includes() in filter body');
+ assert(fnBody.includes('startsWith'), 'expected startsWith in filter body for ^-anchored matching');
+});
+
+test('empty search term shows every item', () => {
+ const items = runFilter('', ['on4xyz brussels', 'be1abc', 'be2def']);
+ for (const it of items) assert.strictEqual(it.style.display, '', `expected visible: ${it.dataset.obsName}`);
+});
+
+test('default matching is substring, not prefix-only', () => {
+ const items = runFilter('brussels', ['on4xyz brussels', 'be1abc']);
+ assert.strictEqual(items[0].style.display, '', 'mid-string "brussels" should match by default (includes)');
+ assert.strictEqual(items[1].style.display, 'none', 'be1abc should not match "brussels"');
+});
+
+test('default substring match still finds prefix matches too', () => {
+ const items = runFilter('be1', ['be1abc', 'be2def', 'on4xyz brussels']);
+ assert.strictEqual(items[0].style.display, '', 'be1abc should match "be1"');
+ assert.strictEqual(items[1].style.display, 'none', 'be2def should not match "be1"');
+ assert.strictEqual(items[2].style.display, 'none', 'on4xyz brussels should not match "be1"');
+});
+
+test('^-anchored term uses prefix-only matching', () => {
+ const items = runFilter('^be', ['be1abc', 'on4xyz brussels']);
+ assert.strictEqual(items[0].style.display, '', 'be1abc should match anchored prefix "^be"');
+ assert.strictEqual(items[1].style.display, 'none', 'brussels contains "be" but not as a prefix, should not match "^be"');
+});
+
+test('bare ^ with no remaining term shows every item', () => {
+ const items = runFilter('^', ['be1abc', 'on4xyz brussels']);
+ for (const it of items) assert.strictEqual(it.style.display, '', `expected visible: ${it.dataset.obsName}`);
+});
+
+test('search term is trimmed before matching', () => {
+ const items = runFilter(' be1 ', ['be1abc', 'be2def']);
+ assert.strictEqual(items[0].style.display, '', 'be1abc should match trimmed term "be1"');
+ assert.strictEqual(items[1].style.display, 'none', 'be2def should not match trimmed term "be1"');
+});
+
+test('matching is case-insensitive relative to the stored lowercase name', () => {
+ // buildObserverMenu() stores data-obs-name already lowercased; the search
+ // input itself is lowercased by applyObserverSearchFilter before matching.
+ const items = runFilter('BE1', ['be1abc']);
+ assert.strictEqual(items[0].style.display, '', 'uppercase search term should still match lowercase stored name');
+});
+
+test('^-anchored search is also case-insensitive', () => {
+ const items = runFilter('^BE', ['be1abc', 'on4xyz brussels']);
+ assert.strictEqual(items[0].style.display, '', 'uppercase anchored term should still match lowercase stored name');
+ assert.strictEqual(items[1].style.display, 'none', 'brussels should not match anchored "^BE"');
+});
+
+// Summary
+console.log(`\n${passed} passed, ${failed} failed`);
+if (failed > 0) process.exit(1);
+console.log('All tests passed ✅');