commit 8084cbc9f0a127e512f74101bd02e6be1deb644b Author: Glenn Goossens <48956933+glgoose@users.noreply.github.com> Date: Mon Aug 24 11:26:54 2026 +0200 Add select-search userscript and bookmarklet Makes any stays in the DOM and remains the source of truth: choosing an option sets selectedIndex and fires native input/change events, so the host application and its form submission are unaffected. Handles option lists that the app repopulates at runtime via a per-select MutationObserver, and new selects appearing after AJAX via a document-level one. Filtering is substring-based, accent-insensitive and multi-token. Built into both a Tampermonkey userscript and a bookmarklet from one source. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..482e34b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.swp +*.swo diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..57e13a6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 glgoose + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d5b04c --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# select-search + +Geeft elke `` blijft bestaan en blijft de bron van waarheid: het wordt enkel visueel +verborgen. Een keuze zet `selectedIndex` en vuurt native `input`- en `change`-events, dus de +applicatie merkt geen verschil met een gewone selectie en het formulier verstuurt hetzelfde als +voorheen. + +## Installeren + +### Chrome + +1. Installeer [Tampermonkey](https://chromewebstore.google.com/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo) + uit de Chrome Web Store. +2. Open de [installatielink](https://raw.githubusercontent.com/glgoose/select-search/main/dist/select-search.user.js). + Tampermonkey herkent het bestand en toont zijn installatiescherm in plaats van de broncode. +3. Klik op installeren. + +### Edge + +Dezelfde stappen, maar haal Tampermonkey uit de **Edge Add-ons** store, niet uit de Chrome Web +Store. [Violentmonkey](https://microsoftedge.microsoft.com/addons/detail/violentmonkey/) is een open +source alternatief dat dezelfde scripts draait. + +Staat je browser onder beheer van je werkgever, dan kan het zijn dat extensies alleen van een +goedgekeurde lijst mogen komen. Vraag dat na bij je IT-dienst voor je het probeert. + +### Zonder extensie: de bookmarklet + +Mag je geen extensies installeren, gebruik dan de bookmarklet. Die vraagt niets van je browser en +werkt overal, maar je moet hem per pagina zelf aanklikken. + +1. Neem de inhoud van [`dist/bookmarklet.txt`](dist/bookmarklet.txt) over. +2. Toon de bladwijzerbalk, rechtsklik erop en kies *Bladwijzer toevoegen*. +3. Naam: `Zoek in dropdown`. URL: de gekopieerde tekst. +4. Klik de bladwijzer aan op de pagina met de dropdown. Nog eens klikken doet een herscan. + +Websites met een strikte Content-Security-Policy blokkeren bookmarklets. Werkt hij niet, dan is de +extensie de enige route. + +## Gebruik + +- Typen filtert op **substring**, niet alleen op het begin, en negeert accenten. `genee` vindt + `Genée`. +- Meerdere woorden mogen in willekeurige volgorde: `poule rest` vindt + `Restaurant Poule & Poulette B.V.` +- Pijltjes navigeren, Enter kiest, Escape sluit zonder te wijzigen, Tab kiest en gaat verder. +- Boven de 200 treffers stopt het met tekenen en toont het hoeveel er nog zijn. Verfijn dan je + zoekterm. + +Dropdowns met minder dan 10 opties blijven onaangeroerd. + +## Als er iets misgaat + +**Alt+klik** op een zoekveld zet voor dat ene veld het originele keuzemenu terug, voor als een +specifiek veld zich vreemd gedraagt. Zet `data-nosearch` op een ` met veel opties een zoekveld met substring-zoeken. +// @author glgoose +// @license MIT +// @homepageURL https://github.com/glgoose/select-search +// @supportURL https://github.com/glgoose/select-search/issues +// @downloadURL https://raw.githubusercontent.com/glgoose/select-search/main/dist/select-search.user.js +// @updateURL https://raw.githubusercontent.com/glgoose/select-search/main/dist/select-search.user.js +// @match https://bipp.biotechpartner.be/web/* +// @run-at document-idle +// @grant none +// @noframes false +// ==/UserScript== +/* + * select-search - maakt elke blijft in de DOM en blijft de bron van waarheid: + * het wordt enkel visueel verborgen. Selecteren gebeurt via selectedIndex + + * native input/change events, zodat de applicatie niets merkt van de ingreep. + */ +(function () { + 'use strict'; + + if (window.__selectSearchLoaded) { + if (typeof window.__selectSearchScan === 'function') window.__selectSearchScan(); + return; + } + window.__selectSearchLoaded = true; + + // kleinere dropdowns laten we met rust; hoogstens MAX_RENDER treffers tekenen + var MIN_OPTIONS = 10; + var MAX_RENDER = 200; + var uid = 0; + + /* ---------------------------------------------------------------- utils */ + + function normMap(s) { + // Genormaliseerde (accentloze, lowercase) string + index-mapping terug + // naar de originele string, zodat we treffers kunnen highlighten. + var out = '', map = []; + for (var i = 0; i < s.length; i++) { + var c = s.charAt(i).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + for (var j = 0; j < c.length; j++) { out += c.charAt(j); map.push(i); } + } + return { n: out, map: map }; + } + + function escapeHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>'); + } + + function tokenize(q) { + return normMap(q).n.split(/\s+/).filter(function (t) { return t.length > 0; }); + } + + function debounce(fn, ms) { + var t = null; + return function () { + if (t) clearTimeout(t); + t = setTimeout(function () { t = null; fn(); }, ms); + }; + } + + /* ------------------------------------------------------------ highlight */ + + function highlight(item, tokens) { + if (!tokens.length) return escapeHtml(item.label); + var ranges = []; + for (var t = 0; t < tokens.length; t++) { + var tok = tokens[t], from = 0, idx; + while ((idx = item.norm.n.indexOf(tok, from)) !== -1) { + var start = item.norm.map[idx]; + var end = item.norm.map[idx + tok.length - 1] + 1; + ranges.push([start, end]); + from = idx + tok.length; + } + } + if (!ranges.length) return escapeHtml(item.label); + ranges.sort(function (a, b) { return a[0] - b[0]; }); + var merged = [ranges[0].slice()]; + for (var r = 1; r < ranges.length; r++) { + var last = merged[merged.length - 1]; + if (ranges[r][0] <= last[1]) last[1] = Math.max(last[1], ranges[r][1]); + else merged.push(ranges[r].slice()); + } + var html = '', pos = 0; + for (var m = 0; m < merged.length; m++) { + html += escapeHtml(item.label.slice(pos, merged[m][0])); + html += '' + escapeHtml(item.label.slice(merged[m][0], merged[m][1])) + ''; + pos = merged[m][1]; + } + return html + escapeHtml(item.label.slice(pos)); + } + + /* ----------------------------------------------------------------- css */ + + var CSS = [ + ':host{all:initial;display:inline-block;vertical-align:middle;max-width:100%}', + '*{box-sizing:border-box;font:inherit}', + '.box{position:relative;display:block;width:100%}', + 'input{width:100%;padding:2px 22px 2px 4px;border:1px solid #767676;border-radius:2px;', + 'background:#fff;color:#000;font:13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;', + 'height:100%;min-height:22px}', + 'input:focus{outline:none;border-color:#0b57d0;box-shadow:0 0 0 1px #0b57d0}', + '.caret{position:absolute;right:6px;top:50%;transform:translateY(-50%);pointer-events:none;', + 'border:4px solid transparent;border-top-color:#444;margin-top:2px}', + 'ul{position:fixed;z-index:2147483647;margin:0;padding:2px 0;list-style:none;overflow-y:auto;', + 'background:#fff;color:#000;border:1px solid #b0b0b0;border-radius:3px;', + 'box-shadow:0 4px 14px rgba(0,0,0,.22);', + 'font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;display:none}', + 'ul.open{display:block}', + 'li{padding:3px 8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', + 'li.active{background:#0b57d0;color:#fff}', + 'li.active mark{background:#ffe08a;color:#000}', + 'li.dis{color:#999;cursor:default}', + 'li.note{color:#666;cursor:default;font-style:italic;padding-top:5px}', + 'li.empty{color:#666;cursor:default}', + 'mark{background:#ffe08a;color:inherit;padding:0}', + '.grp{color:#777;font-size:11px;margin-left:6px}', + 'li.active .grp{color:#dbe6fb}' + ].join(''); + + /* ------------------------------------------------------------- enhance */ + + function enhance(select) { + var doc = select.ownerDocument; + var win = doc.defaultView || window; + var id = 'ss' + (++uid); + + var rect = select.getBoundingClientRect(); + var wrap = doc.createElement('span'); + wrap.className = 'select-search-wrap'; + if (rect.width) wrap.style.width = rect.width + 'px'; + if (rect.height) wrap.style.height = rect.height + 'px'; + select.parentNode.insertBefore(wrap, select.nextSibling); + + var root = wrap.attachShadow({ mode: 'open' }); + var style = doc.createElement('style'); + style.textContent = CSS; + var box = doc.createElement('span'); + box.className = 'box'; + var input = doc.createElement('input'); + input.type = 'text'; + input.setAttribute('role', 'combobox'); + input.setAttribute('aria-expanded', 'false'); + input.setAttribute('aria-autocomplete', 'list'); + input.setAttribute('aria-controls', id + '-list'); + input.setAttribute('autocomplete', 'off'); + input.setAttribute('spellcheck', 'false'); + input.title = 'Typ om te zoeken. Alt+klik zet het originele keuzemenu terug.'; + var caret = doc.createElement('span'); + caret.className = 'caret'; + var list = doc.createElement('ul'); + list.id = id + '-list'; + list.setAttribute('role', 'listbox'); + box.appendChild(input); + box.appendChild(caret); + root.appendChild(style); + root.appendChild(box); + root.appendChild(list); + + // Origineel blijft bestaan (form submit, app-code), maar onzichtbaar. + var prevStyle = select.getAttribute('style'); + select.style.position = 'absolute'; + select.style.opacity = '0'; + select.style.pointerEvents = 'none'; + select.style.width = (rect.width || 1) + 'px'; + select.style.height = (rect.height || 1) + 'px'; + select.dataset.ssEnhanced = '1'; + + var items = [], shown = [], active = -1, open = false; + + function readOptions() { + items = []; + for (var i = 0; i < select.options.length; i++) { + var o = select.options[i]; + var label = (o.textContent || '').replace(/\s+/g, ' ').trim(); + var grp = o.parentNode && o.parentNode.tagName === 'OPTGROUP' + ? (o.parentNode.label || '') : ''; + items.push({ + index: i, + label: label, + group: grp, + disabled: o.disabled, + norm: normMap(grp ? label + ' ' + grp : label) + }); + } + } + + function currentLabel() { + var o = select.options[select.selectedIndex]; + return o ? (o.textContent || '').replace(/\s+/g, ' ').trim() : ''; + } + + function syncFromSelect() { + readOptions(); + if (!open) input.value = currentLabel(); + } + + function position() { + var r = input.getBoundingClientRect(); + var below = win.innerHeight - r.bottom - 6; + var above = r.top - 6; + var down = below >= 160 || below >= above; + list.style.left = r.left + 'px'; + list.style.minWidth = r.width + 'px'; + list.style.maxWidth = Math.max(r.width, Math.min(560, win.innerWidth - r.left - 8)) + 'px'; + list.style.maxHeight = Math.max(120, (down ? below : above)) + 'px'; + if (down) { + list.style.top = r.bottom + 2 + 'px'; + list.style.bottom = 'auto'; + } else { + list.style.top = 'auto'; + list.style.bottom = (win.innerHeight - r.top + 2) + 'px'; + } + } + + function filter(q) { + var tokens = tokenize(q); + if (!tokens.length) return items.slice(); + var starts = [], rest = []; + for (var i = 0; i < items.length; i++) { + var n = items[i].norm.n, ok = true; + for (var t = 0; t < tokens.length; t++) { + if (n.indexOf(tokens[t]) === -1) { ok = false; break; } + } + if (!ok) continue; + (n.indexOf(tokens[0]) === 0 ? starts : rest).push(items[i]); + } + return starts.concat(rest); + } + + function render(q) { + var tokens = tokenize(q); + shown = filter(q); + var html = ''; + if (!shown.length) { + html = '
  • Geen resultaten
  • '; + } else { + var n = Math.min(shown.length, MAX_RENDER); + for (var i = 0; i < n; i++) { + var it = shown[i]; + html += '
  • ' + + highlight(it, tokens) + + (it.group ? '' + escapeHtml(it.group) + '' : '') + + '
  • '; + } + if (shown.length > n) { + html += '
  • nog ' + (shown.length - n) + ' resultaten, verfijn je zoekterm
  • '; + } + } + list.innerHTML = html; + shown = shown.slice(0, MAX_RENDER); + setActive(shown.length ? 0 : -1, false); + } + + function setActive(i, scroll) { + var nodes = list.querySelectorAll('li[data-i]'); + for (var k = 0; k < nodes.length; k++) nodes[k].classList.remove('active'); + active = i; + if (i < 0 || i >= nodes.length) { + input.removeAttribute('aria-activedescendant'); + return; + } + nodes[i].classList.add('active'); + input.setAttribute('aria-activedescendant', nodes[i].id); + if (scroll !== false) { + var li = nodes[i], top = li.offsetTop, bot = top + li.offsetHeight; + if (top < list.scrollTop) list.scrollTop = top; + else if (bot > list.scrollTop + list.clientHeight) list.scrollTop = bot - list.clientHeight; + } + } + + function openList(q) { + render(q === undefined ? '' : q); + list.classList.add('open'); + input.setAttribute('aria-expanded', 'true'); + open = true; + position(); + win.addEventListener('scroll', position, true); + win.addEventListener('resize', position); + } + + function closeList(restore) { + list.classList.remove('open'); + input.setAttribute('aria-expanded', 'false'); + input.removeAttribute('aria-activedescendant'); + open = false; + active = -1; + win.removeEventListener('scroll', position, true); + win.removeEventListener('resize', position); + if (restore !== false) input.value = currentLabel(); + } + + function choose(i) { + var it = shown[i]; + if (!it || it.disabled) return; + select.selectedIndex = it.index; + input.value = it.label; + closeList(false); + fire(select, 'input'); + fire(select, 'change'); + } + + function fire(el, type) { + var ev; + try { + ev = new Event(type, { bubbles: true, cancelable: false }); + } catch (e) { + ev = doc.createEvent('HTMLEvents'); + ev.initEvent(type, true, false); + } + el.dispatchEvent(ev); + // Legacy apps binden vaak via jQuery; native events bereiken die handlers + // wel, maar een expliciete trigger dekt ook .change()-only bindings. + var jq = win.jQuery || win.$; + if (jq && jq.fn && typeof jq === 'function') { + try { jq(el).trigger(type); } catch (e2) { /* stil */ } + } + } + + function detach() { + obs.disconnect(); + select.removeEventListener('change', onSelectChange); + win.removeEventListener('scroll', position, true); + win.removeEventListener('resize', position); + if (prevStyle === null) select.removeAttribute('style'); + else select.setAttribute('style', prevStyle); + delete select.dataset.ssEnhanced; + select.dataset.nosearch = '1'; + if (wrap.parentNode) wrap.parentNode.removeChild(wrap); + } + + /* -------------------------------------------------------- listeners */ + + input.addEventListener('mousedown', function (e) { + // ontsnappingsluik: native select terug + if (e.altKey) { + e.preventDefault(); + detach(); + select.focus(); + return; + } + if (!open) { e.preventDefault(); input.focus(); openList(''); input.select(); } + }); + input.addEventListener('focus', function () { if (!open) openList(''); }); + input.addEventListener('input', function () { if (open) render(input.value); else openList(input.value); }); + input.addEventListener('blur', function () { + setTimeout(function () { if (open) closeList(true); }, 120); + }); + input.addEventListener('keydown', function (e) { + var n = list.querySelectorAll('li[data-i]').length; + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (!open) { openList(''); break; } + if (n) setActive((active + 1) % n); + break; + case 'ArrowUp': + e.preventDefault(); + if (!open) { openList(''); break; } + if (n) setActive((active - 1 + n) % n); + break; + case 'Home': if (open && n) { e.preventDefault(); setActive(0); } break; + case 'End': if (open && n) { e.preventDefault(); setActive(n - 1); } break; + case 'PageDown': if (open && n) { e.preventDefault(); setActive(Math.min(n - 1, active + 10)); } break; + case 'PageUp': if (open && n) { e.preventDefault(); setActive(Math.max(0, active - 10)); } break; + case 'Enter': + if (open) { e.preventDefault(); if (active >= 0) choose(active); else closeList(true); } + break; + case 'Tab': + if (open && active >= 0) choose(active); else if (open) closeList(true); + break; + case 'Escape': + if (open) { e.preventDefault(); e.stopPropagation(); closeList(true); } + break; + } + }); + list.addEventListener('mousedown', function (e) { e.preventDefault(); }); + list.addEventListener('click', function (e) { + var li = e.target; + while (li && li.tagName !== 'LI') li = li.parentNode; + if (li && li.hasAttribute('data-i')) choose(parseInt(li.getAttribute('data-i'), 10)); + }); + + function onSelectChange() { if (!open) input.value = currentLabel(); } + select.addEventListener('change', onSelectChange); + + // De app vervangt de optielijst (bv. "Inclusief niet-raamcontractartikelen"). + var resync = debounce(function () { + var q = open ? input.value : null; + syncFromSelect(); + if (open) render(q); + }, 50); + var obs = new MutationObserver(resync); + obs.observe(select, { childList: true, subtree: true }); + + wrap.__selectSearchDetach = detach; + wrap.__selectSearchSelect = select; + syncFromSelect(); + } + + /* ---------------------------------------------------------------- scan */ + + function eligible(s) { + return !s.multiple && s.size <= 1 && !s.disabled + && !s.dataset.ssEnhanced && !s.hasAttribute('data-nosearch') + && s.options.length >= MIN_OPTIONS; + } + + function scanDoc(doc) { + var selects = doc.querySelectorAll('select'); + for (var i = 0; i < selects.length; i++) { + if (eligible(selects[i])) { + try { enhance(selects[i]); } catch (e) { /* laat de pagina met rust */ } + } + } + // Wrappers waarvan de app het originele select heeft weggegooid opruimen. + var wraps = doc.querySelectorAll('.select-search-wrap'); + for (var w = 0; w < wraps.length; w++) { + var sel = wraps[w].__selectSearchSelect; + if (sel && !doc.contains(sel) && wraps[w].parentNode) { + wraps[w].parentNode.removeChild(wraps[w]); + } + } + var frames = doc.querySelectorAll('iframe,frame'); + for (var f = 0; f < frames.length; f++) { + try { + var fd = frames[f].contentDocument; + if (fd && fd.body) { attach(fd); } + } catch (e) { /* cross-origin */ } + } + } + + var attached = []; + function attach(doc) { + if (attached.indexOf(doc) === -1) { + attached.push(doc); + new MutationObserver(debounce(function () { scanDoc(doc); }, 120)) + .observe(doc.documentElement, { childList: true, subtree: true }); + } + scanDoc(doc); + } + + function scan() { for (var i = 0; i < attached.length; i++) scanDoc(attached[i]); attach(document); } + window.__selectSearchScan = scan; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { attach(document); }); + } else { + attach(document); + } +})(); diff --git a/src/select-search.js b/src/select-search.js new file mode 100644 index 0000000..1661704 --- /dev/null +++ b/src/select-search.js @@ -0,0 +1,441 @@ +/* + * select-search - maakt elke blijft in de DOM en blijft de bron van waarheid: + * het wordt enkel visueel verborgen. Selecteren gebeurt via selectedIndex + + * native input/change events, zodat de applicatie niets merkt van de ingreep. + */ +(function () { + 'use strict'; + + if (window.__selectSearchLoaded) { + if (typeof window.__selectSearchScan === 'function') window.__selectSearchScan(); + return; + } + window.__selectSearchLoaded = true; + + // kleinere dropdowns laten we met rust; hoogstens MAX_RENDER treffers tekenen + var MIN_OPTIONS = 10; + var MAX_RENDER = 200; + var uid = 0; + + /* ---------------------------------------------------------------- utils */ + + function normMap(s) { + // Genormaliseerde (accentloze, lowercase) string + index-mapping terug + // naar de originele string, zodat we treffers kunnen highlighten. + var out = '', map = []; + for (var i = 0; i < s.length; i++) { + var c = s.charAt(i).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + for (var j = 0; j < c.length; j++) { out += c.charAt(j); map.push(i); } + } + return { n: out, map: map }; + } + + function escapeHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>'); + } + + function tokenize(q) { + return normMap(q).n.split(/\s+/).filter(function (t) { return t.length > 0; }); + } + + function debounce(fn, ms) { + var t = null; + return function () { + if (t) clearTimeout(t); + t = setTimeout(function () { t = null; fn(); }, ms); + }; + } + + /* ------------------------------------------------------------ highlight */ + + function highlight(item, tokens) { + if (!tokens.length) return escapeHtml(item.label); + var ranges = []; + for (var t = 0; t < tokens.length; t++) { + var tok = tokens[t], from = 0, idx; + while ((idx = item.norm.n.indexOf(tok, from)) !== -1) { + var start = item.norm.map[idx]; + var end = item.norm.map[idx + tok.length - 1] + 1; + ranges.push([start, end]); + from = idx + tok.length; + } + } + if (!ranges.length) return escapeHtml(item.label); + ranges.sort(function (a, b) { return a[0] - b[0]; }); + var merged = [ranges[0].slice()]; + for (var r = 1; r < ranges.length; r++) { + var last = merged[merged.length - 1]; + if (ranges[r][0] <= last[1]) last[1] = Math.max(last[1], ranges[r][1]); + else merged.push(ranges[r].slice()); + } + var html = '', pos = 0; + for (var m = 0; m < merged.length; m++) { + html += escapeHtml(item.label.slice(pos, merged[m][0])); + html += '' + escapeHtml(item.label.slice(merged[m][0], merged[m][1])) + ''; + pos = merged[m][1]; + } + return html + escapeHtml(item.label.slice(pos)); + } + + /* ----------------------------------------------------------------- css */ + + var CSS = [ + ':host{all:initial;display:inline-block;vertical-align:middle;max-width:100%}', + '*{box-sizing:border-box;font:inherit}', + '.box{position:relative;display:block;width:100%}', + 'input{width:100%;padding:2px 22px 2px 4px;border:1px solid #767676;border-radius:2px;', + 'background:#fff;color:#000;font:13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;', + 'height:100%;min-height:22px}', + 'input:focus{outline:none;border-color:#0b57d0;box-shadow:0 0 0 1px #0b57d0}', + '.caret{position:absolute;right:6px;top:50%;transform:translateY(-50%);pointer-events:none;', + 'border:4px solid transparent;border-top-color:#444;margin-top:2px}', + 'ul{position:fixed;z-index:2147483647;margin:0;padding:2px 0;list-style:none;overflow-y:auto;', + 'background:#fff;color:#000;border:1px solid #b0b0b0;border-radius:3px;', + 'box-shadow:0 4px 14px rgba(0,0,0,.22);', + 'font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;display:none}', + 'ul.open{display:block}', + 'li{padding:3px 8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', + 'li.active{background:#0b57d0;color:#fff}', + 'li.active mark{background:#ffe08a;color:#000}', + 'li.dis{color:#999;cursor:default}', + 'li.note{color:#666;cursor:default;font-style:italic;padding-top:5px}', + 'li.empty{color:#666;cursor:default}', + 'mark{background:#ffe08a;color:inherit;padding:0}', + '.grp{color:#777;font-size:11px;margin-left:6px}', + 'li.active .grp{color:#dbe6fb}' + ].join(''); + + /* ------------------------------------------------------------- enhance */ + + function enhance(select) { + var doc = select.ownerDocument; + var win = doc.defaultView || window; + var id = 'ss' + (++uid); + + var rect = select.getBoundingClientRect(); + var wrap = doc.createElement('span'); + wrap.className = 'select-search-wrap'; + if (rect.width) wrap.style.width = rect.width + 'px'; + if (rect.height) wrap.style.height = rect.height + 'px'; + select.parentNode.insertBefore(wrap, select.nextSibling); + + var root = wrap.attachShadow({ mode: 'open' }); + var style = doc.createElement('style'); + style.textContent = CSS; + var box = doc.createElement('span'); + box.className = 'box'; + var input = doc.createElement('input'); + input.type = 'text'; + input.setAttribute('role', 'combobox'); + input.setAttribute('aria-expanded', 'false'); + input.setAttribute('aria-autocomplete', 'list'); + input.setAttribute('aria-controls', id + '-list'); + input.setAttribute('autocomplete', 'off'); + input.setAttribute('spellcheck', 'false'); + input.title = 'Typ om te zoeken. Alt+klik zet het originele keuzemenu terug.'; + var caret = doc.createElement('span'); + caret.className = 'caret'; + var list = doc.createElement('ul'); + list.id = id + '-list'; + list.setAttribute('role', 'listbox'); + box.appendChild(input); + box.appendChild(caret); + root.appendChild(style); + root.appendChild(box); + root.appendChild(list); + + // Origineel blijft bestaan (form submit, app-code), maar onzichtbaar. + var prevStyle = select.getAttribute('style'); + select.style.position = 'absolute'; + select.style.opacity = '0'; + select.style.pointerEvents = 'none'; + select.style.width = (rect.width || 1) + 'px'; + select.style.height = (rect.height || 1) + 'px'; + select.dataset.ssEnhanced = '1'; + + var items = [], shown = [], active = -1, open = false; + + function readOptions() { + items = []; + for (var i = 0; i < select.options.length; i++) { + var o = select.options[i]; + var label = (o.textContent || '').replace(/\s+/g, ' ').trim(); + var grp = o.parentNode && o.parentNode.tagName === 'OPTGROUP' + ? (o.parentNode.label || '') : ''; + items.push({ + index: i, + label: label, + group: grp, + disabled: o.disabled, + norm: normMap(grp ? label + ' ' + grp : label) + }); + } + } + + function currentLabel() { + var o = select.options[select.selectedIndex]; + return o ? (o.textContent || '').replace(/\s+/g, ' ').trim() : ''; + } + + function syncFromSelect() { + readOptions(); + if (!open) input.value = currentLabel(); + } + + function position() { + var r = input.getBoundingClientRect(); + var below = win.innerHeight - r.bottom - 6; + var above = r.top - 6; + var down = below >= 160 || below >= above; + list.style.left = r.left + 'px'; + list.style.minWidth = r.width + 'px'; + list.style.maxWidth = Math.max(r.width, Math.min(560, win.innerWidth - r.left - 8)) + 'px'; + list.style.maxHeight = Math.max(120, (down ? below : above)) + 'px'; + if (down) { + list.style.top = r.bottom + 2 + 'px'; + list.style.bottom = 'auto'; + } else { + list.style.top = 'auto'; + list.style.bottom = (win.innerHeight - r.top + 2) + 'px'; + } + } + + function filter(q) { + var tokens = tokenize(q); + if (!tokens.length) return items.slice(); + var starts = [], rest = []; + for (var i = 0; i < items.length; i++) { + var n = items[i].norm.n, ok = true; + for (var t = 0; t < tokens.length; t++) { + if (n.indexOf(tokens[t]) === -1) { ok = false; break; } + } + if (!ok) continue; + (n.indexOf(tokens[0]) === 0 ? starts : rest).push(items[i]); + } + return starts.concat(rest); + } + + function render(q) { + var tokens = tokenize(q); + shown = filter(q); + var html = ''; + if (!shown.length) { + html = '
  • Geen resultaten
  • '; + } else { + var n = Math.min(shown.length, MAX_RENDER); + for (var i = 0; i < n; i++) { + var it = shown[i]; + html += '
  • ' + + highlight(it, tokens) + + (it.group ? '' + escapeHtml(it.group) + '' : '') + + '
  • '; + } + if (shown.length > n) { + html += '
  • nog ' + (shown.length - n) + ' resultaten, verfijn je zoekterm
  • '; + } + } + list.innerHTML = html; + shown = shown.slice(0, MAX_RENDER); + setActive(shown.length ? 0 : -1, false); + } + + function setActive(i, scroll) { + var nodes = list.querySelectorAll('li[data-i]'); + for (var k = 0; k < nodes.length; k++) nodes[k].classList.remove('active'); + active = i; + if (i < 0 || i >= nodes.length) { + input.removeAttribute('aria-activedescendant'); + return; + } + nodes[i].classList.add('active'); + input.setAttribute('aria-activedescendant', nodes[i].id); + if (scroll !== false) { + var li = nodes[i], top = li.offsetTop, bot = top + li.offsetHeight; + if (top < list.scrollTop) list.scrollTop = top; + else if (bot > list.scrollTop + list.clientHeight) list.scrollTop = bot - list.clientHeight; + } + } + + function openList(q) { + render(q === undefined ? '' : q); + list.classList.add('open'); + input.setAttribute('aria-expanded', 'true'); + open = true; + position(); + win.addEventListener('scroll', position, true); + win.addEventListener('resize', position); + } + + function closeList(restore) { + list.classList.remove('open'); + input.setAttribute('aria-expanded', 'false'); + input.removeAttribute('aria-activedescendant'); + open = false; + active = -1; + win.removeEventListener('scroll', position, true); + win.removeEventListener('resize', position); + if (restore !== false) input.value = currentLabel(); + } + + function choose(i) { + var it = shown[i]; + if (!it || it.disabled) return; + select.selectedIndex = it.index; + input.value = it.label; + closeList(false); + fire(select, 'input'); + fire(select, 'change'); + } + + function fire(el, type) { + var ev; + try { + ev = new Event(type, { bubbles: true, cancelable: false }); + } catch (e) { + ev = doc.createEvent('HTMLEvents'); + ev.initEvent(type, true, false); + } + el.dispatchEvent(ev); + // Legacy apps binden vaak via jQuery; native events bereiken die handlers + // wel, maar een expliciete trigger dekt ook .change()-only bindings. + var jq = win.jQuery || win.$; + if (jq && jq.fn && typeof jq === 'function') { + try { jq(el).trigger(type); } catch (e2) { /* stil */ } + } + } + + function detach() { + obs.disconnect(); + select.removeEventListener('change', onSelectChange); + win.removeEventListener('scroll', position, true); + win.removeEventListener('resize', position); + if (prevStyle === null) select.removeAttribute('style'); + else select.setAttribute('style', prevStyle); + delete select.dataset.ssEnhanced; + select.dataset.nosearch = '1'; + if (wrap.parentNode) wrap.parentNode.removeChild(wrap); + } + + /* -------------------------------------------------------- listeners */ + + input.addEventListener('mousedown', function (e) { + // ontsnappingsluik: native select terug + if (e.altKey) { + e.preventDefault(); + detach(); + select.focus(); + return; + } + if (!open) { e.preventDefault(); input.focus(); openList(''); input.select(); } + }); + input.addEventListener('focus', function () { if (!open) openList(''); }); + input.addEventListener('input', function () { if (open) render(input.value); else openList(input.value); }); + input.addEventListener('blur', function () { + setTimeout(function () { if (open) closeList(true); }, 120); + }); + input.addEventListener('keydown', function (e) { + var n = list.querySelectorAll('li[data-i]').length; + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (!open) { openList(''); break; } + if (n) setActive((active + 1) % n); + break; + case 'ArrowUp': + e.preventDefault(); + if (!open) { openList(''); break; } + if (n) setActive((active - 1 + n) % n); + break; + case 'Home': if (open && n) { e.preventDefault(); setActive(0); } break; + case 'End': if (open && n) { e.preventDefault(); setActive(n - 1); } break; + case 'PageDown': if (open && n) { e.preventDefault(); setActive(Math.min(n - 1, active + 10)); } break; + case 'PageUp': if (open && n) { e.preventDefault(); setActive(Math.max(0, active - 10)); } break; + case 'Enter': + if (open) { e.preventDefault(); if (active >= 0) choose(active); else closeList(true); } + break; + case 'Tab': + if (open && active >= 0) choose(active); else if (open) closeList(true); + break; + case 'Escape': + if (open) { e.preventDefault(); e.stopPropagation(); closeList(true); } + break; + } + }); + list.addEventListener('mousedown', function (e) { e.preventDefault(); }); + list.addEventListener('click', function (e) { + var li = e.target; + while (li && li.tagName !== 'LI') li = li.parentNode; + if (li && li.hasAttribute('data-i')) choose(parseInt(li.getAttribute('data-i'), 10)); + }); + + function onSelectChange() { if (!open) input.value = currentLabel(); } + select.addEventListener('change', onSelectChange); + + // De app vervangt de optielijst (bv. "Inclusief niet-raamcontractartikelen"). + var resync = debounce(function () { + var q = open ? input.value : null; + syncFromSelect(); + if (open) render(q); + }, 50); + var obs = new MutationObserver(resync); + obs.observe(select, { childList: true, subtree: true }); + + wrap.__selectSearchDetach = detach; + wrap.__selectSearchSelect = select; + syncFromSelect(); + } + + /* ---------------------------------------------------------------- scan */ + + function eligible(s) { + return !s.multiple && s.size <= 1 && !s.disabled + && !s.dataset.ssEnhanced && !s.hasAttribute('data-nosearch') + && s.options.length >= MIN_OPTIONS; + } + + function scanDoc(doc) { + var selects = doc.querySelectorAll('select'); + for (var i = 0; i < selects.length; i++) { + if (eligible(selects[i])) { + try { enhance(selects[i]); } catch (e) { /* laat de pagina met rust */ } + } + } + // Wrappers waarvan de app het originele select heeft weggegooid opruimen. + var wraps = doc.querySelectorAll('.select-search-wrap'); + for (var w = 0; w < wraps.length; w++) { + var sel = wraps[w].__selectSearchSelect; + if (sel && !doc.contains(sel) && wraps[w].parentNode) { + wraps[w].parentNode.removeChild(wraps[w]); + } + } + var frames = doc.querySelectorAll('iframe,frame'); + for (var f = 0; f < frames.length; f++) { + try { + var fd = frames[f].contentDocument; + if (fd && fd.body) { attach(fd); } + } catch (e) { /* cross-origin */ } + } + } + + var attached = []; + function attach(doc) { + if (attached.indexOf(doc) === -1) { + attached.push(doc); + new MutationObserver(debounce(function () { scanDoc(doc); }, 120)) + .observe(doc.documentElement, { childList: true, subtree: true }); + } + scanDoc(doc); + } + + function scan() { for (var i = 0; i < attached.length; i++) scanDoc(attached[i]); attach(document); } + window.__selectSearchScan = scan; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { attach(document); }); + } else { + attach(document); + } +})(); diff --git a/src/userscript-header.txt b/src/userscript-header.txt new file mode 100644 index 0000000..2b0b076 --- /dev/null +++ b/src/userscript-header.txt @@ -0,0 +1,16 @@ +// ==UserScript== +// @name Doorzoekbare dropdowns (select-search) +// @namespace https://github.com/glgoose/select-search +// @version 1.0.0 +// @description Geeft elke Inclusief niet-raamcontractartikelen + + + + + + + +
    + + +
    nog niets verzonden
    + + + +