select-search: drop bookmarklet and build step, .user.js is the single source

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Glenn Goossens
2026-10-26 13:51:05 +01:00
co-authored by Claude Sonnet 5.5
parent 5320b17945
commit 3e610366fe
7 changed files with 4 additions and 926 deletions
+2 -19
View File
@@ -95,33 +95,16 @@ stil: is de opslag vol of geblokkeerd, dan verdwijnt het recent-blok en werkt de
| 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, voor wie geen extensies mag installeren. Werkt overal, maar je klikt
hem zelf aan per pagina.
1. Kopieer de inhoud van [`bookmarklet.txt`](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.
`select-search.user.js` bevat de volledige logica, zonder dependencies. Test met de fixture:
```sh
./build.sh # genereert select-search.user.js en bookmarklet.txt
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.
Tijdens het ontwikkelen laad je het script via een dev-stub in plaats van een geplakte kopie,
zie [Ontwikkelen](../../AGENTS.md#dev-stub-vs-gebruik). Draai eerst `./build.sh`, want de stub wijst
naar `select-search.user.js`.
zie [Ontwikkelen](../../AGENTS.md#dev-stub-vs-gebruik). Hoog bij elke wijziging `@version` op.
## Licentie
File diff suppressed because one or more lines are too long
-64
View File
@@ -1,64 +0,0 @@
#!/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"
out="$here"
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
# 1. Userscript = metadatablok + ongewijzigde core.
cat "$hdr" "$src" > "$out/select-search.user.js"
# 2. Bookmarklet = core, URL-encoded achter javascript:.
python3 - "$src" "$out/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 < "$out/bookmarklet.txt"
echo "bookmarklet gekopieerd naar het klembord"
fi
echo "geschreven: $out/select-search.user.js"
echo "geschreven: $out/bookmarklet.txt"
-814
View File
@@ -1,814 +0,0 @@
/*
* 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;
// Streefbreedte van de lijst. Het veld zelf is meestal smaller dan zijn labels; onder deze
// breedte wordt de lijst een kolom van afgebroken zinnen in plaats van een leesbare lijst.
var LIJST_BREED = 420;
var MARGE = 8;
var uid = 0;
// Recent gebruikte waarden per veld. Vijf, want select-search filtert het blok mee tijdens het
// zoeken: wat buiten de top vijf valt typ je sneller dan je het herkent. Geen vervaltermijn,
// een lijst van vijf verdringt zichzelf al.
var RECENT_KEY = 'selectSearchRecent';
var RECENT_MAX = 5;
var RECENT_FIELDS = 40;
/* ---------------------------------------------------------------- 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);
};
}
/* --------------------------------------------------------------- recent */
// Eén localStorage-sleutel voor alle velden samen:
// { v: 1, fields: { "<sleutel>": { ts: <ms>, items: [[value, label], ...] } } }
// ts dient enkel om het oudste veld te laten vallen zodra er meer dan RECENT_FIELDS in staan,
// zodat de sleutel niet onbeperkt groeit. Lezen en schrijven falen stil: recent is comfort,
// geen data, en een volle quota mag de dropdown niet stukmaken.
function readStore() {
var raw;
try { raw = localStorage.getItem(RECENT_KEY); } catch (e) { return null; }
if (!raw) return null;
var data;
try { data = JSON.parse(raw); } catch (e) { return null; }
if (!data || data.v !== 1 || !data.fields || typeof data.fields !== 'object') return null;
return data;
}
function writeStore(data) {
try { localStorage.setItem(RECENT_KEY, JSON.stringify(data)); } catch (e) { /* quota, stil */ }
}
function readRecent(sleutel) {
var data = readStore();
if (!data || !Object.prototype.hasOwnProperty.call(data.fields, sleutel)) return [];
var veld = data.fields[sleutel];
return veld && veld.items && veld.items.length ? veld.items : [];
}
function writeRecent(sleutel, lijst) {
var data = readStore() || { v: 1, fields: {} };
data.fields[sleutel] = { ts: Date.now(), items: lijst.slice(0, RECENT_MAX) };
var namen = Object.keys(data.fields);
if (namen.length > RECENT_FIELDS) {
namen.sort(function (a, b) { return (data.fields[a].ts || 0) - (data.fields[b].ts || 0); });
for (var i = 0; i < namen.length - RECENT_FIELDS; i++) delete data.fields[namen[i]];
}
writeStore(data);
}
function pushRecent(sleutel, value, label) {
// Zonder label wordt het een onleesbare regel bovenaan de lijst, en die kan de gebruiker
// niet herkennen en dus ook niet gericht wegdoen.
if (!sleutel || !value || !label) return;
var lijst = readRecent(sleutel).filter(function (e) { return e[0] !== value; });
lijst.unshift([value, label]);
writeRecent(sleutel, lijst);
}
// De lijst kon alleen groeien. Dit is de weg terug: de waarde verdwijnt uit recent en staat
// daarna weer gewoon tussen alle opties. Levert false op als er niets te wissen viel.
function dropRecent(sleutel, value) {
var lijst = readRecent(sleutel);
var uit = lijst.filter(function (e) { return e[0] !== value; });
if (uit.length === lijst.length) return false;
writeRecent(sleutel, uit);
return true;
}
// BIPP rendert ids als ctl00_..._resultTable_ctl03_interfaceAccountList. Rij 3 en rij 4 zijn
// hetzelfde veld en horen dus één lijst te delen: de rij- en volgnummers gaan eruit. Levert dat
// niets op, dan krijgt die select geen recent-blok en blijft hij gewoon doorzoekbaar.
// Uit op de loginpagina: daar kiest iedereen een participant uit een lange lijst en schuift een
// recent-blok enkel gewoonte-keuzes bovenaan. Zoeken blijft werken. `data-ss-recent="off"` doet
// hetzelfde voor één veld.
function recentKey(select) {
if (select.getAttribute && select.getAttribute('data-ss-recent') === 'off') return '';
if (/\/login\.aspx$/i.test((select.ownerDocument.defaultView || window).location.pathname)) return '';
var expliciet = select.getAttribute && select.getAttribute('data-ss-recent-key');
if (expliciet) return expliciet;
var ruw = select.name || select.id || '';
if (!ruw) return '';
return ruw.split(/[$_]/).filter(function (deel) {
return deel && !/^ctl\d+$/i.test(deel) && !/^\d+$/.test(deel);
}).join('_');
}
/* ------------------------------------------------------------ highlight */
// Alle treffers over het volledige label, gesorteerd en samengevoegd. Los van het tekenen,
// want een label wordt in twee stukken getekend (code en omschrijving) en een treffer mag
// over die grens heen lopen.
function matchRanges(item, tokens) {
if (!tokens.length) return [];
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 ranges;
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());
}
return merged;
}
// Tekent label.slice(from, to). Een treffer die de grens kruist wordt afgeknipt, zodat beide
// helften hun eigen <mark> krijgen en er geen tag over de spans heen loopt.
function highlight(item, ranges, from, to) {
var html = '', pos = from;
for (var m = 0; m < ranges.length; m++) {
var a = Math.max(ranges[m][0], from), b = Math.min(ranges[m][1], to);
if (b <= a) continue;
html += escapeHtml(item.label.slice(pos, a));
html += '<mark>' + escapeHtml(item.label.slice(a, b)) + '</mark>';
pos = b;
}
return html + escapeHtml(item.label.slice(pos, to));
}
// Waar splitst "10-AD010000-CFALWERK - ADRIVA (ALGEMEEN)" in code en omschrijving? Enkel bij de
// eerste " - " en enkel als daar echt een code voor staat: één woord zonder spatie. Zo blijft
// een gewone titel met een gedachtestreepje ("Emotie en verbeelding - 2025") ongesplitst.
// De teruggegeven index staat ná de spatie: label.slice(0, punt - 1) is de code met zijn
// streepje, label.slice(punt) de omschrijving, en de spatie ertussen valt buiten beide.
function splitPunt(label) {
var sep = label.indexOf(' - ');
if (sep <= 0) return -1;
if (label.lastIndexOf(' ', sep - 1) !== -1) return -1;
return sep + 3;
}
/* ----------------------------------------------------------------- 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}',
// #767676 haalt net de AA-drempel op wit; het #777 van li.kop zit er net onder.
'input::placeholder{color:#767676;opacity:1}',
'.caret{position:absolute;right:6px;top:50%;transform:translateY(-50%);pointer-events:none;',
'border:4px solid transparent;border-top-color:#444;margin-top:2px}',
// Het kruisje neemt de plek van het pijltje in en krijgt dus geen eigen ruimte. Zo blijft de
// volle breedte voor de tekst, staat er nooit een leeg gat, en verschuift er evenmin iets:
// enkel de zichtbaarheid van de twee wisselt. Zo doet Ant Design het ook.
'.wis{position:absolute;right:2px;top:50%;transform:translateY(-50%);width:17px;height:17px;',
'display:flex;align-items:center;justify-content:center;color:#767676;cursor:pointer;',
'visibility:hidden}',
'.box.wisbaar.gevuld:hover .wis,.box.wisbaar.gevuld:focus-within .wis{visibility:visible}',
'.box.wisbaar.gevuld:hover .caret,.box.wisbaar.gevuld:focus-within .caret{visibility:hidden}',
'.wis:hover{color:#000}',
'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}',
// Een label dat niet past loopt terug over een volgende regel met een kleine hangende
// inspring. Niet uitlijnen onder de omschrijving: bij een lange code schuift die inspring mee
// tot halverwege de rij en blijft er nauwelijks tekstbreedte over. 2.5ch is de breedte van
// twee cijfers plus een streepje, dus precies het "10-" waarmee elke budgetcode begint: daar
// valt de vervolgregel netjes samen met de rest van de code. Elders is het gewoon een kleine
// inspring, genoeg om te zien dat een regel bij de vorige hoort. ch en niet px, want de maat
// hoort bij de tekstgrootte en niet bij het scherm.
'li{padding:4px 8px;cursor:pointer;line-height:1.3;',
'padding-left:calc(8px + 2.5ch);text-indent:-2.5ch;overflow-wrap:break-word}',
// De code zelf mag nooit afbreken, ook niet op zijn eigen streepjes: white-space:pre houdt
// "10-AD113010 -" in één stuk. De spatie erna staat buiten de span, zodat het eerste woord
// van de omschrijving wel naar de volgende regel kan.
'li .c{white-space:pre}',
// Meerregelige rijen lopen anders in elkaar over: het gat binnen een rij is even groot als
// dat tussen twee rijen. Een haarlijn tussen twee opties zegt waar de ene stopt.
'li[data-i]+li[data-i]{border-top:1px solid #eee}',
'li.kop{padding:6px 8px 2px;padding-left:8px;text-indent:0;color:#777;font-size:11px;',
'font-weight:600;cursor:default}',
'li.active{background:#0b57d0;color:#fff;border-top-color:#0b57d0}',
'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}'
].join('');
/* ------------------------------------------------------------- enhance */
function enhance(select) {
var doc = select.ownerDocument;
var win = doc.defaultView || window;
var id = 'ss' + (++uid);
var recentSleutel = recentKey(select);
// Manuele stand: het recent-blok en Delete werken, maar er wordt niets vanzelf onthouden.
// Voor wie pas na een bevestiging van de server mag opslaan; die roept remember() zelf aan.
var recentManueel = select.getAttribute('data-ss-recent') === 'manual';
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';
// Wissen gebeurt met een kruisje in het veld, zoals in react-select, MUI en Ant Design, en
// niet met een "geen waarde"-rij bovenaan de lijst. Ant Design toont het kruisje pas bij
// hover, dat doen wij ook, met focus erbij zodat het toetsenbord niet in de kou staat.
var wis = doc.createElement('span');
wis.className = 'wis';
wis.setAttribute('role', 'button');
wis.setAttribute('aria-label', 'Wissen');
wis.innerHTML = '<svg width="11" height="11" viewBox="0 0 12 12" fill="none" '
+ 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true">'
+ '<path d="M2.5 2.5l7 7M9.5 2.5l-7 7"/></svg>';
var list = doc.createElement('ul');
list.id = id + '-list';
list.setAttribute('role', 'listbox');
box.appendChild(input);
box.appendChild(wis);
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;
// De zoekterm apart bijhouden. input.value is niet hetzelfde: bij een open lijst zonder
// dat er getypt is staat daar het gekozen label in (geselecteerd, klaar om over te typen),
// terwijl de lijst volledig is. Wie input.value als zoekterm leest, denkt dus dat er
// gefilterd wordt op iets wat de gebruiker nooit intypte.
var zoek = '';
// Index van de optie met een lege waarde, de "niets gekozen"-stand. Die optie is geen keuze:
// hij komt niet in de lijst, maar levert de placeholder en is het doel van het kruisje.
var leegIndex = -1, leegLabel = '';
function readOptions() {
items = [];
leegIndex = -1;
leegLabel = '';
// Twee soorten "niets gekozen". Een optie zonder waarde is de echte, een optie met een
// waarde maar zonder leesbare tekst (OAF schrijft graag <option value="0">&nbsp;</option>)
// is de terugval. Beide horen buiten de lijst, want een regel die je niet kunt lezen is
// geen keuze, maar het kruisje mikt liefst op de eerste: die maakt het veld echt leeg.
var zonderWaarde = -1, zonderWaardeLabel = '', blanco = -1;
// De volgorde van de optgroups bepaalt de volgorde van de blokken in de lijst, ook na
// filteren. gi 0 is het recent-blok, gi 1 zijn de opties zonder groep, echte groepen
// beginnen bij 2.
var groepen = [], gi;
// Recent verwijst naar opties die al in de select staan; wat er niet meer in zit valt
// vanzelf weg. De positie in de bewaarde lijst is de positie in het blok.
var wil = {}, recentLijst = recentSleutel ? readRecent(recentSleutel) : [];
for (var r = 0; r < recentLijst.length; r++) wil['v:' + recentLijst[r][0]] = r;
var recentItems = [], gewoon = [];
for (var i = 0; i < select.options.length; i++) {
var o = select.options[i];
var label = (o.textContent || '').replace(/\s+/g, ' ').trim();
if (!o.value || !label) {
if (!o.value) {
if (zonderWaarde === -1) { zonderWaarde = i; zonderWaardeLabel = label; }
} else if (blanco === -1) blanco = i;
continue;
}
var plek = wil['v:' + o.value];
if (plek !== undefined && recentItems[plek] === undefined) {
recentItems[plek] = {
index: i,
label: label,
group: 'recent',
gi: 0,
disabled: o.disabled,
removable: true,
mru: true,
norm: normMap(label)
};
// Een optie die in recent staat wordt in haar eigen groep overgeslagen: twee keer
// dezelfde regel in de resultaten is verwarrend, ook met een kopregel erbij.
continue;
}
var groep = o.parentNode && o.parentNode.tagName === 'OPTGROUP' ? o.parentNode : null;
var grp = groep ? (groep.label || '') : '';
if (!grp) gi = 1;
else {
gi = groepen.indexOf(grp);
if (gi === -1) { groepen.push(grp); gi = groepen.length + 1; } else gi += 2;
}
gewoon.push({
index: i,
label: label,
group: grp,
gi: gi,
disabled: o.disabled,
// Wie de opties levert bepaalt of een regel weg mag, met data-removable op de optie of
// op haar optgroup. Het script weet niet wat verwijderen betekent en doet het dus ook
// niet zelf: het meldt enkel dat de gebruiker erom vraagt.
removable: !!(o.dataset.removable || (groep && groep.dataset.removable)),
// Enkel het label is doorzoekbaar: de groepsnaam staat als kopregel op het scherm en
// hoort niet stilzwijgend mee te zoeken. highlight() mapt de treffers bovendien terug
// op label, dus een treffer in de groepsnaam zou buiten het label vallen.
norm: normMap(label)
});
}
if (zonderWaarde !== -1) { leegIndex = zonderWaarde; leegLabel = zonderWaardeLabel; }
else if (blanco !== -1) leegIndex = blanco;
for (var k = 0; k < recentItems.length; k++) {
if (recentItems[k]) items.push(recentItems[k]);
}
// Zonder kopregel loopt het recent-blok naadloos over in de losse opties eronder, en dan
// zie je niet meer waar het ophoudt. Alleen nodig als er iets boven staat.
if (items.length) {
for (var g = 0; g < gewoon.length; g++) {
if (!gewoon[g].group) gewoon[g].group = 'alle';
}
}
items = items.concat(gewoon);
}
function currentLabel() {
var o = select.options[select.selectedIndex];
// Staat de lege optie aan, dan is het veld leeg en neemt de placeholder het over. Anders
// zou "budgetcode" er als gewone zwarte waarde staan, alsof er wel iets gekozen is.
if (!o || !o.value) return '';
return (o.textContent || '').replace(/\s+/g, ' ').trim();
}
// Het kruisje hoort alleen te bestaan waar wissen ook kan, en alleen zichtbaar te zijn als er
// iets te wissen valt. De hover- en focusvoorwaarde staat in de CSS.
function updateWis() {
box.classList.toggle('wisbaar', leegIndex !== -1);
box.classList.toggle('gevuld', !!currentLabel());
}
function syncFromSelect() {
readOptions();
input.placeholder = select.getAttribute('data-placeholder') || leegLabel;
updateWis();
if (!open) input.value = currentLabel();
}
function clear() {
if (leegIndex === -1) return;
select.selectedIndex = leegIndex;
input.value = '';
updateWis();
closeList(false);
fire(select, 'input');
fire(select, 'change');
}
function position() {
var r = input.getBoundingClientRect();
var below = win.innerHeight - r.bottom - 6;
var above = r.top - 6;
var down = below >= 160 || below >= above;
// De lijst hing vast aan de linkerrand van het veld en kon dus alleen naar rechts groeien.
// Bij een veld rechts in het formulier bleef er niets over en viel elk label terug op
// afbreken. Daarom eerst de breedte laten kiezen met de volle vensterbreedte beschikbaar,
// die meten, en de lijst dan zo ver naar links schuiven als nodig om ze te laten passen.
var wens = Math.min(Math.max(r.width, LIJST_BREED), win.innerWidth - MARGE * 2);
list.style.left = MARGE + 'px';
list.style.minWidth = r.width + 'px';
list.style.maxWidth = wens + 'px';
list.style.maxHeight = Math.max(120, (down ? below : above)) + 'px';
var breed = list.offsetWidth;
list.style.left = Math.max(MARGE, Math.min(r.left, win.innerWidth - MARGE - breed)) + '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();
// Per groep twee emmers: eerst wat met de zoekterm begint, dan de rest. De emmers worden
// in groepsvolgorde aan elkaar geplakt, zodat elke groep aaneengesloten blijft en de
// kopregel zich niet herhaalt.
var emmers = [];
for (var i = 0; i < items.length; i++) {
var it = items[i], n = it.norm.n, ok = true;
for (var t = 0; t < tokens.length; t++) {
if (n.indexOf(tokens[t]) === -1) { ok = false; break; }
}
if (!ok) continue;
if (!emmers[it.gi]) emmers[it.gi] = { starts: [], rest: [] };
(n.indexOf(tokens[0]) === 0 ? emmers[it.gi].starts : emmers[it.gi].rest).push(it);
}
var out = [];
for (var g = 0; g < emmers.length; g++) {
if (emmers[g]) out = out.concat(emmers[g].starts, emmers[g].rest);
}
return out;
}
// behoud: houd de blauwe balk waar hij stond. Enkel voor een herbouw van de optielijst onder
// een open lijst, want dan is de gebruiker midden in iets en zou terugspringen naar de eerste
// regel hem onderbreken. Bij openen en typen hoort de balk juist wel bovenaan te beginnen.
function render(q, behoud) {
var vorigeActieve = active;
zoek = 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), vorige = null;
for (var i = 0; i < n; i++) {
var it = shown[i];
if (it.group && it.group !== vorige) {
html += '<li class="kop">' + escapeHtml(it.group) + '</li>';
}
vorige = it.group;
var ranges = matchRanges(it, tokens);
var punt = splitPunt(it.label);
var binnen = punt === -1
? highlight(it, ranges, 0, it.label.length)
: '<span class="c">' + highlight(it, ranges, 0, punt - 1) + '</span> '
+ highlight(it, ranges, punt, it.label.length);
html += '<li role="option" id="' + id + '-o' + i + '" data-i="' + i + '"'
+ (it.removable ? ' data-rm="1"' : '')
+ (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>'
+ binnen
+ '</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);
var doel = 0;
if (behoud && vorigeActieve > 0) doel = Math.min(vorigeActieve, shown.length - 1);
setActive(shown.length ? doel : -1, false);
// De lijst is zo breed als haar inhoud, dus elke filterslag verandert die breedte en
// daarmee ook hoever ze naar links geschoven moet staan. Zonder deze herberekening blijft
// ze hangen op de plek die bij de volledige lijst hoorde: na het intypen van een zoekterm
// staan er dan drie korte regels ver links van het veld waar ze bij horen. openList roept
// position() zelf aan, en daar staat open nog op false, dus dit verdubbelt niets.
if (open) position();
}
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;
updateWis();
closeList(false);
if (recentSleutel && !recentManueel) {
pushRecent(recentSleutel, select.options[it.index].value, it.label);
// Herbouwen, anders klopt het blok pas bij de volgende keer dat de opties veranderen.
readOptions();
}
fire(select, 'input');
fire(select, 'change');
}
// Het script haalt zelf niets weg. Het meldt enkel dat de gebruiker deze regel kwijt wil, en
// wie de opties leverde beslist wat dat betekent en past de <select> aan. Luistert er niemand,
// dan gebeurt er niets, en dat is geen fout.
function fireRemove(it) {
var ev;
var detail = { value: select.options[it.index].value, label: it.label, index: it.index };
try {
ev = new CustomEvent('select-search:remove', { bubbles: true, cancelable: true, detail: detail });
} catch (e) {
ev = doc.createEvent('CustomEvent');
ev.initCustomEvent('select-search:remove', true, true, detail);
}
select.dispatchEvent(ev);
}
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;
delete select.__selectSearchSync;
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(); }
});
// mousedown in plaats van click, met preventDefault: zo houdt het invoerveld zijn focus en
// blijft het kruisje zichtbaar terwijl je klikt. stopPropagation houdt de lijst dicht.
wis.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
clear();
});
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;
// Backspace op een leeg invoerveld wist de keuze, zoals in react-select. Staat er nog
// tekst, dan doet Backspace gewoon wat je verwacht en blijft de keuze staan.
case 'Backspace':
if (!input.value && leegIndex !== -1 && currentLabel()) { e.preventDefault(); clear(); }
break;
// Delete haalt de actieve regel uit haar lijst, zoals de adresbalk van een browser dat met
// een suggestie doet. Alleen zolang er niets getypt is: filtert de gebruiker, dan blijft
// Delete de gewone teksttoets en gebeurt er niets met de lijst.
case 'Delete':
if (!open || zoek || active < 0) break;
var doelwit = shown[active];
if (!doelwit || !doelwit.removable) break;
e.preventDefault();
if (doelwit.mru && recentSleutel
&& dropRecent(recentSleutel, select.options[doelwit.index].value)) {
readOptions();
render(zoek, true);
break;
}
fireRemove(doelwit);
break;
}
});
// De muis verzet dezelfde balk als de pijltjes: één begrip van "waar sta ik", zodat Enter en
// Delete altijd op de regel slaan die oplicht. mousemove en niet mouseover, want een muis die
// toevallig boven de lijst ligt mag de pijltjes niet blijven overrulen.
list.addEventListener('mousemove', function (e) {
var li = e.target;
while (li && li.tagName !== 'LI') li = li.parentNode;
if (!li || !li.hasAttribute('data-i')) return;
var i = parseInt(li.getAttribute('data-i'), 10);
// Geen scroll: de regel ligt per definitie al onder de cursor en dus in beeld.
if (i !== active) setActive(i, false);
});
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() {
updateWis();
if (!open) input.value = currentLabel();
}
select.addEventListener('change', onSelectChange);
// De app vervangt de optielijst (bv. "Inclusief niet-raamcontractartikelen").
var resync = debounce(function () {
syncFromSelect();
if (open) render(zoek, true);
}, 50);
var obs = new MutationObserver(resync);
obs.observe(select, { childList: true, subtree: true });
wrap.__selectSearchDetach = detach;
wrap.__selectSearchSelect = select;
// Haak voor remember(): de opties veranderen niet, alleen het recent-blok, dus de
// MutationObserver merkt er niets van.
select.__selectSearchSync = function () {
syncFromSelect();
if (open) render(zoek, true);
};
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;
// Voor wie pas mag onthouden nadat de server bevestigd heeft, zie data-ss-recent="manual".
// Eerste argument mag de select zelf zijn of rechtstreeks de veldsleutel.
window.selectSearch = {
remember: function (selectOrKey, value, label) {
var sleutel = typeof selectOrKey === 'string' ? selectOrKey : recentKey(selectOrKey);
if (!sleutel || !value) return;
pushRecent(sleutel, value, label == null ? String(value) : String(label));
if (selectOrKey && typeof selectOrKey.__selectSearchSync === 'function') {
selectOrKey.__selectSearchSync();
}
},
forget: function (selectOrKey, value) {
var sleutel = typeof selectOrKey === 'string' ? selectOrKey : recentKey(selectOrKey);
if (!sleutel || !value) return;
dropRecent(sleutel, value);
if (selectOrKey && typeof selectOrKey.__selectSearchSync === 'function') {
selectOrKey.__selectSearchSync();
}
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { attach(document); });
} else {
attach(document);
}
})();
@@ -1,16 +0,0 @@
// ==UserScript==
// @name BIPP: doorzoekbare dropdowns
// @namespace https://github.com/glgoose/ua-userscripts
// @version 1.8.5
// @description Geeft elke <select> met veel opties een zoekveld met substring-zoeken.
// @author glgoose
// @license GPL-3.0-or-later
// @homepageURL https://gitea.flw.uantwerpen.be/glenn/ua-userscripts/src/branch/main/scripts/select-search
// @supportURL https://gitea.flw.uantwerpen.be/glenn/ua-userscripts/issues
// @downloadURL https://gitea.flw.uantwerpen.be/glenn/ua-userscripts/raw/branch/main/scripts/select-search/select-search.user.js
// @updateURL https://gitea.flw.uantwerpen.be/glenn/ua-userscripts/raw/branch/main/scripts/select-search/select-search.user.js
// @match https://bipp.biotechpartner.be/web/*
// @run-at document-idle
// @grant none
// @noframes false
// ==/UserScript==