Add 'scripts/select-search/' from commit '98095f3fc6f9a86e3b64b7c7ba14d299259fc7e2'

git-subtree-dir: scripts/select-search
git-subtree-mainline: e5bd11259f
git-subtree-split: 98095f3fc6
This commit is contained in:
Glenn Goossens
2026-08-29 13:34:58 +02:00
9 changed files with 1170 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.DS_Store
*.swp
*.swo
+21
View File
@@ -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.
+74
View File
@@ -0,0 +1,74 @@
# select-search
Geeft elke `<select>` met veel opties een zoekveld. Een native dropdown zoekt alleen op de eerste
letters, dus "Poule" vinden in "Restaurant Poule & Poulette B.V." lukt niet. Met dit script wel.
Het originele `<select>` blijft de bron van waarheid en wordt enkel verborgen. Het formulier
verstuurt dus exact hetzelfde als voorheen.
## Installeren
1. Installeer Tampermonkey voor je browser:
| Browser | Extensie | Extra stap |
|---|---|---|
| Firefox | [installeren](https://addons.mozilla.org/firefox/addon/tampermonkey/) | geen |
| Chrome | [installeren](https://chromewebstore.google.com/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo) | zie stap 2 |
| Edge | [installeren](https://microsoftedge.microsoft.com/addons/detail/tampermonkey/iikmkjmpaadaobahmlepeloendndfphd) | zie stap 2 |
2. Alleen Chrome en Edge: ga naar `chrome://extensions` of `edge://extensions`, klik bij
Tampermonkey op *Details* en zet **Allow user scripts** aan. Zonder dat draait geen enkel
userscript.
3. Open de
[installatielink](https://raw.githubusercontent.com/glgoose/select-search/main/dist/select-search.user.js)
en klik op installeren.
Beheert je werkgever je browser, dan zijn extensies mogelijk beperkt tot een goedgekeurde lijst.
Vraag het na bij IT. Kan het niet, gebruik dan de [bookmarklet](#bookmarklet).
## Gebruik
Klik een dropdown aan en typ.
- Zoekt op **substring**, negeert accenten: `genee` vindt `Genée`.
- Meerdere woorden in willekeurige volgorde: `poule rest` vindt `Restaurant Poule & Poulette B.V.`
- Pijltjes navigeren, Enter kiest, Escape sluit, Tab kiest en gaat verder.
- Dropdowns met minder dan 10 opties blijven onaangeroerd.
- Boven 200 treffers toont het enkel het aantal. Verfijn dan je zoekterm.
## Problemen
| Symptoom | Oplossing |
|---|---|
| Eén veld doet raar | **Alt+klik** op het zoekveld zet daar het originele menu terug |
| Veld moet altijd overgeslagen | Zet `data-nosearch` op de `<select>` |
| Niets gebeurt op de pagina | Check in het Tampermonkey-dashboard of de URL onder `@match` valt |
| Formulier zit in een iframe van een ander domein | Niet op te lossen, browserbeperking |
## Bookmarklet
Alternatief zonder extensie. Werkt overal, maar je klikt hem zelf aan per pagina.
1. Kopieer de inhoud van [`dist/bookmarklet.txt`](dist/bookmarklet.txt).
2. Maak een bladwijzer met naam `Zoek in dropdown` en die tekst als URL.
3. Klik hem aan op de pagina met de dropdown. Nog eens klikken doet een herscan.
Sites met een strikte Content-Security-Policy blokkeren bookmarklets. Dan is de extensie de enige
route.
## Ontwikkelen
`src/select-search.js` bevat de volledige logica, zonder dependencies.
```sh
./build.sh # genereert dist/ (userscript + bookmarklet)
python3 -m http.server 8731 # open http://localhost:8731/test/fixture.html
```
Hoog bij elke wijziging `@version` op in `src/userscript-header.txt`, anders krijgt niemand de
update binnen.
## Licentie
MIT
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Bouwt het userscript en de bookmarklet uit src/select-search.js.
# Gebruik: ./build.sh (de bookmarklet belandt ook meteen in het klembord)
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
src="$here/src/select-search.js"
hdr="$here/src/userscript-header.txt"
dist="$here/dist"
for f in "$src" "$hdr"; do
if [ ! -f "$f" ]; then
echo "build.sh: ontbrekend bestand: $f" >&2
exit 1
fi
done
if ! command -v python3 >/dev/null 2>&1; then
echo "build.sh: python3 is nodig om de bookmarklet te encoderen" >&2
exit 1
fi
if grep -q 'MATCH_PLACEHOLDER' "$hdr"; then
echo "build.sh: vul eerst de echte @match-URL in $hdr in" >&2
exit 1
fi
mkdir -p "$dist"
# 1. Userscript = metadatablok + ongewijzigde core.
cat "$hdr" "$src" > "$dist/select-search.user.js"
# 2. Bookmarklet = core, URL-encoded achter javascript:.
python3 - "$src" "$dist/bookmarklet.txt" <<'PY'
import io, sys, urllib.parse
src, out = sys.argv[1], sys.argv[2]
code = io.open(src, encoding='utf-8').read()
# Regelcommentaar sneuvelt zodra alles op een regel staat; strip het weg.
lines = []
for line in code.split('\n'):
stripped = line.strip()
if stripped.startswith('//'):
continue
lines.append(line)
code = '\n'.join(lines)
# Een bladwijzerbeheerder mag de nieuwe regels weggooien zonder dat de
# bookmarklet stukgaat: dus geen enkel regelcommentaar meer overhouden.
for n, line in enumerate(code.split('\n'), 1):
if '//' in line:
sys.exit('build.sh: regelcommentaar op regel %d breekt de bookmarklet: %s'
% (n, line.strip()))
url = 'javascript:' + urllib.parse.quote(code, safe="!#$&'()*+,-./:;=?@_~")
io.open(out, 'w', encoding='utf-8').write(url)
print('bookmarklet: %d tekens' % len(url))
PY
if command -v pbcopy >/dev/null 2>&1; then
pbcopy < "$dist/bookmarklet.txt"
echo "bookmarklet gekopieerd naar het klembord"
fi
echo "geschreven: $dist/select-search.user.js"
echo "geschreven: $dist/bookmarklet.txt"
File diff suppressed because one or more lines are too long
+457
View File
@@ -0,0 +1,457 @@
// ==UserScript==
// @name Doorzoekbare dropdowns (select-search)
// @namespace https://github.com/glgoose/select-search
// @version 1.0.0
// @description Geeft elke <select> 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 <select> met veel opties doorzoekbaar.
*
* Het originele <select> 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 += '<mark>' + escapeHtml(item.label.slice(merged[m][0], merged[m][1])) + '</mark>';
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 = '<li class="empty">Geen resultaten</li>';
} else {
var n = Math.min(shown.length, MAX_RENDER);
for (var i = 0; i < n; i++) {
var it = shown[i];
html += '<li role="option" id="' + id + '-o' + i + '" data-i="' + i + '"'
+ (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>'
+ highlight(it, tokens)
+ (it.group ? '<span class="grp">' + escapeHtml(it.group) + '</span>' : '')
+ '</li>';
}
if (shown.length > n) {
html += '<li class="note">nog ' + (shown.length - n) + ' resultaten, verfijn je zoekterm</li>';
}
}
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);
}
})();
+441
View File
@@ -0,0 +1,441 @@
/*
* select-search - maakt elke <select> met veel opties doorzoekbaar.
*
* Het originele <select> 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 += '<mark>' + escapeHtml(item.label.slice(merged[m][0], merged[m][1])) + '</mark>';
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 = '<li class="empty">Geen resultaten</li>';
} else {
var n = Math.min(shown.length, MAX_RENDER);
for (var i = 0; i < n; i++) {
var it = shown[i];
html += '<li role="option" id="' + id + '-o' + i + '" data-i="' + i + '"'
+ (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>'
+ highlight(it, tokens)
+ (it.group ? '<span class="grp">' + escapeHtml(it.group) + '</span>' : '')
+ '</li>';
}
if (shown.length > n) {
html += '<li class="note">nog ' + (shown.length - n) + ' resultaten, verfijn je zoekterm</li>';
}
}
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);
}
})();
@@ -0,0 +1,16 @@
// ==UserScript==
// @name Doorzoekbare dropdowns (select-search)
// @namespace https://github.com/glgoose/select-search
// @version 1.0.0
// @description Geeft elke <select> 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==
+91
View File
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<title>select-search fixture</title>
<style>
body{font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;margin:24px;color:#111}
table{border-spacing:0 6px}
td:first-child{padding-right:24px}
select{width:400px}
#uit{margin-top:20px;padding:8px;background:#f2f5fb;border:1px solid #cbd8ee;white-space:pre-wrap}
.bar{background:#eaf1fb;padding:8px;margin:-24px -24px 20px}
</style>
</head>
<body>
<div class="bar">Testpagina, bootst de werk-app na</div>
<form id="f">
<label><input type="checkbox" id="incl"> Inclusief niet-raamcontractartikelen</label>
<table>
<tr><td><label for="lev">Leverancier</label></td>
<td><select id="lev" name="leverancier"></select></td></tr>
<tr><td><label for="fab">Fabricaat</label></td>
<td><select id="fab" name="fabricaat"></select></td></tr>
<tr><td><label for="klein">Klein menu</label></td>
<td><select id="klein" name="klein">
<option value="">--</option><option>Ja</option><option>Nee</option>
</select></td></tr>
</table>
<button type="submit">Verzenden</button>
</form>
<div id="uit">nog niets verzonden</div>
<script>
var VOOR = ['Restaurant','Bakkerij','Groothandel','Technische Dienst','Installatiebedrijf',
'Drukkerij','Transport','Bouwbedrijf','Schoonmaak','Catering','Labo','Apotheek',
'Garage','Kwekerij','Slagerij','Boekhandel','Meubelmakerij','Elektro'];
var NAAM = ['Poule & Poulette','Van den Berghe','De Smet','Janssens','Peeters','Maertens',
'Vermeulen','Claeys','De Vos','Willems','Lemmens','Coppens','Declercq','Genée',
'Vanhoutte','Segers','Wouters','Dhondt','Vandenbroucke','Storms'];
var VORM = ['B.V.','N.V.','BVBA','VZW','Comm.V.','& Zonen'];
function maak(n, offset){
var out = [];
for (var i = 0; i < n; i++) {
var v = VOOR[(i + offset) % VOOR.length];
var m = NAAM[(i * 7 + offset) % NAAM.length];
var s = VORM[(i * 3 + offset) % VORM.length];
out.push({v: 'ID' + (1000 + i + offset), t: v + ' ' + m + ' ' + s + ' (' + (1000 + i + offset) + ')'});
}
return out;
}
function vul(sel, n, offset){
var huidig = sel.value;
sel.innerHTML = '';
var leeg = document.createElement('option');
leeg.value = ''; leeg.textContent = '';
sel.appendChild(leeg);
maak(n, offset).forEach(function(o){
var e = document.createElement('option');
e.value = o.v; e.textContent = o.t;
sel.appendChild(e);
});
sel.value = huidig;
}
var lev = document.getElementById('lev'), fab = document.getElementById('fab');
function herlaad(){
var inclusief = document.getElementById('incl').checked;
vul(lev, inclusief ? 850 : 120, 0);
vul(fab, inclusief ? 400 : 60, 500);
}
document.getElementById('incl').addEventListener('change', herlaad);
herlaad();
lev.addEventListener('change', function(){
console.log('[fixture] change op leverancier:', lev.value, '/', lev.options[lev.selectedIndex].textContent);
});
document.getElementById('f').addEventListener('submit', function(e){
e.preventDefault();
var d = new FormData(e.target);
document.getElementById('uit').textContent =
'verzonden:\n leverancier = ' + d.get('leverancier') +
'\n fabricaat = ' + d.get('fabricaat') +
'\n klein = ' + d.get('klein') +
'\n (label leverancier: ' + (lev.options[lev.selectedIndex] || {textContent:''}).textContent + ')';
});
</script>
</body>
</html>