select-search: muis verzet de selectie, en Delete haalt een regel uit haar groep

De blauwe balk werd alleen door de pijltjes verzet: er was een li.active-stijl maar
geen enkele hover- of mousemove-handler, dus wie met de muis over de lijst ging stond
nergens op. Nu verzet mousemove dezelfde active-regel als het toetsenbord. Bewust geen
mouseover, want een stilliggende muis boven de lijst zou dan de pijltjes blijven
tegenwerken.

Daarbovenop een generiek haakje om suggesties op te ruimen, zoals de adresbalk van een
browser dat doet. Een optie of optgroup met data-removable is met Delete te wissen: het
script haalt zelf niets weg maar stuurt een select-search:remove-event met value, label
en index, en wie de lijst bezit past de opties aan. Geen kruisje per regel: dat neemt
plek in en wie met de muis op een regel staat wil die kiezen, niet weggooien.

Delete vuurt alleen zolang er niets getypt is, anders blijft het een gewone teksttoets.
Die vraag werd eerst met input.value beantwoord, en dat is niet de zoekterm: bij een
open lijst zonder typwerk staat daar het gekozen label in, geselecteerd en klaar om over
te typen. Delete deed dus niets in precies de gewone situatie. De zoekterm zit nu in een
eigen variabele, en die lost hetzelfde stille probleem op in resync, die de open lijst
zou dichtfilteren op een label dat de gebruiker nooit intypte.

Na een herbouw van de opties blijft de balk staan waar hij stond, geklemd op de laatste
regel, zodat je meerdere regels achter elkaar kan wissen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Glenn Goossens
2026-09-01 16:59:22 +02:00
co-authored by Claude Opus 5
parent 6b473c6261
commit 05a37f31ce
6 changed files with 396 additions and 43 deletions
+27 -1
View File
@@ -15,8 +15,30 @@ Klik een dropdown aan en typ.
- Zoekt op **substring**, negeert accenten: `genee` vindt `Genée`. - Zoekt op **substring**, negeert accenten: `genee` vindt `Genée`.
- Meerdere woorden in willekeurige volgorde: `poule rest` vindt `Restaurant Poule & Poulette B.V.` - Meerdere woorden in willekeurige volgorde: `poule rest` vindt `Restaurant Poule & Poulette B.V.`
- Pijltjes navigeren, Enter kiest, Escape sluit, Tab kiest en gaat verder. - Pijltjes navigeren, Enter kiest, Escape sluit, Tab kiest en gaat verder. Ga je met de muis
over de lijst, dan verzet die dezelfde selectie als de pijltjes, zodat Enter altijd de regel
neemt die oplicht.
- Dropdowns met minder dan 10 opties blijven onaangeroerd. - Dropdowns met minder dan 10 opties blijven onaangeroerd.
- Zit de dropdown in groepen (`<optgroup>`), dan komt elke groepsnaam als grijze kopregel boven
zijn blok te staan. Die regel is geen resultaat: pijltjes slaan hem over en Enter kiest hem
nooit. Opties zonder groep blijven los bovenaan.
- Ook bij zoeken blijven de groepen aaneengesloten en in de volgorde van de dropdown zelf, zodat
een groep als *recent* bovenaan blijft en geen enkele kopregel zich herhaalt. Binnen een groep
komt wat met de zoekterm begint eerst.
- Er wordt enkel op het label gezocht, niet op de groepsnaam.
- Een optie met een lege waarde geldt als "niets gekozen". Ze staat niet in de lijst, maar haar
label wordt de grijze placeholder van het veld. Met `data-placeholder` op de `<select>` zet je
een eigen tekst, die wint van dat label.
- Staat er iets gekozen, dan neemt een kruisje de plek van het pijltje in zodra je er met de muis
over gaat of erin staat. Dat zet de keuze terug op leeg. Het kruisje krijgt dus geen eigen
ruimte: de tekst houdt de volle breedte en er verschuift niets. Backspace in een leeg veld doet
hetzelfde. Zonder lege optie is er niets te wissen en blijft het kruisje weg.
- Staat er `data-removable` op een `<option>` of op een `<optgroup>`, dan haalt `Delete` de
regel die oplicht uit de lijst, zolang je niets getypt hebt. Ben je aan het zoeken, dan blijft
Delete een gewone teksttoets. Het script gooit zelf niets weg: het stuurt een `select-search:remove`
event op de `<select>`, met `detail` `{value, label, index}`. Wie de opties leverde beslist
wat verwijderen betekent en past de `<select>` aan, waarna de lijst zichzelf hertekent met
behoud van de zoekterm en de plek van de selectie.
- Boven 200 treffers toont het enkel het aantal. Verfijn dan je zoekterm. - Boven 200 treffers toont het enkel het aantal. Verfijn dan je zoekterm.
## Problemen ## Problemen
@@ -52,6 +74,10 @@ 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 Hoog bij elke wijziging `@version` op in `src/userscript-header.txt`, anders krijgt niemand de
update binnen. update binnen.
Tijdens het ontwikkelen laad je het script via een dev-stub in plaats van een geplakte kopie,
zie [Ontwikkelen](../../README.md#ontwikkelen). Draai eerst `./build.sh`, want de stub wijst
naar `select-search.user.js`.
## Licentie ## Licentie
[GPL-3.0-or-later](../../LICENSE). [GPL-3.0-or-later](../../LICENSE).
File diff suppressed because one or more lines are too long
+163 -20
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name Doorzoekbare dropdowns (select-search) // @name BIPP: doorzoekbare dropdowns
// @namespace https://github.com/glgoose/ua-userscripts // @namespace https://github.com/glgoose/ua-userscripts
// @version 1.2.0 // @version 1.5.0
// @description Geeft elke <select> met veel opties een zoekveld met substring-zoeken. // @description Geeft elke <select> met veel opties een zoekveld met substring-zoeken.
// @author glgoose // @author glgoose
// @license GPL-3.0-or-later // @license GPL-3.0-or-later
@@ -105,22 +105,32 @@
'background:#fff;color:#000;font:13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;', 'background:#fff;color:#000;font:13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;',
'height:100%;min-height:22px}', 'height:100%;min-height:22px}',
'input:focus{outline:none;border-color:#0b57d0;box-shadow:0 0 0 1px #0b57d0}', '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;', '.caret{position:absolute;right:6px;top:50%;transform:translateY(-50%);pointer-events:none;',
'border:4px solid transparent;border-top-color:#444;margin-top:2px}', '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;', '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;', 'background:#fff;color:#000;border:1px solid #b0b0b0;border-radius:3px;',
'box-shadow:0 4px 14px rgba(0,0,0,.22);', 'box-shadow:0 4px 14px rgba(0,0,0,.22);',
'font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;display:none}', 'font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;display:none}',
'ul.open{display:block}', 'ul.open{display:block}',
'li{padding:3px 8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', 'li{padding:3px 8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'li.kop{padding:6px 8px 2px;color:#777;font-size:11px;font-weight:600;cursor:default}',
'li.active{background:#0b57d0;color:#fff}', 'li.active{background:#0b57d0;color:#fff}',
'li.active mark{background:#ffe08a;color:#000}', 'li.active mark{background:#ffe08a;color:#000}',
'li.dis{color:#999;cursor:default}', 'li.dis{color:#999;cursor:default}',
'li.note{color:#666;cursor:default;font-style:italic;padding-top:5px}', 'li.note{color:#666;cursor:default;font-style:italic;padding-top:5px}',
'li.empty{color:#666;cursor:default}', 'li.empty{color:#666;cursor:default}',
'mark{background:#ffe08a;color:inherit;padding:0}', 'mark{background:#ffe08a;color:inherit;padding:0}'
'.grp{color:#777;font-size:11px;margin-left:6px}',
'li.active .grp{color:#dbe6fb}'
].join(''); ].join('');
/* ------------------------------------------------------------- enhance */ /* ------------------------------------------------------------- enhance */
@@ -153,10 +163,21 @@
input.title = 'Typ om te zoeken. Alt+klik zet het originele keuzemenu terug.'; input.title = 'Typ om te zoeken. Alt+klik zet het originele keuzemenu terug.';
var caret = doc.createElement('span'); var caret = doc.createElement('span');
caret.className = 'caret'; 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'); var list = doc.createElement('ul');
list.id = id + '-list'; list.id = id + '-list';
list.setAttribute('role', 'listbox'); list.setAttribute('role', 'listbox');
box.appendChild(input); box.appendChild(input);
box.appendChild(wis);
box.appendChild(caret); box.appendChild(caret);
root.appendChild(style); root.appendChild(style);
root.appendChild(box); root.appendChild(box);
@@ -172,34 +193,86 @@
select.dataset.ssEnhanced = '1'; select.dataset.ssEnhanced = '1';
var items = [], shown = [], active = -1, open = false; 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() { function readOptions() {
items = []; items = [];
leegIndex = -1;
leegLabel = '';
// De volgorde van de optgroups bepaalt de volgorde van de blokken in de lijst, ook na
// filteren. Opties zonder groep houden gi 0 en blijven dus vooraan.
var groepen = [], gi;
for (var i = 0; i < select.options.length; i++) { for (var i = 0; i < select.options.length; i++) {
var o = select.options[i]; var o = select.options[i];
var label = (o.textContent || '').replace(/\s+/g, ' ').trim(); var label = (o.textContent || '').replace(/\s+/g, ' ').trim();
var grp = o.parentNode && o.parentNode.tagName === 'OPTGROUP' if (!o.value) {
? (o.parentNode.label || '') : ''; if (leegIndex === -1) { leegIndex = i; leegLabel = label; }
continue;
}
var groep = o.parentNode && o.parentNode.tagName === 'OPTGROUP' ? o.parentNode : null;
var grp = groep ? (groep.label || '') : '';
if (!grp) gi = 0;
else {
gi = groepen.indexOf(grp);
if (gi === -1) { groepen.push(grp); gi = groepen.length; } else gi += 1;
}
items.push({ items.push({
index: i, index: i,
label: label, label: label,
group: grp, group: grp,
gi: gi,
disabled: o.disabled, disabled: o.disabled,
norm: normMap(grp ? label + ' ' + grp : label) // 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)
}); });
} }
} }
function currentLabel() { function currentLabel() {
var o = select.options[select.selectedIndex]; var o = select.options[select.selectedIndex];
return o ? (o.textContent || '').replace(/\s+/g, ' ').trim() : ''; // 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() { function syncFromSelect() {
readOptions(); readOptions();
input.placeholder = select.getAttribute('data-placeholder') || leegLabel;
updateWis();
if (!open) input.value = currentLabel(); 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() { function position() {
var r = input.getBoundingClientRect(); var r = input.getBoundingClientRect();
var below = win.innerHeight - r.bottom - 6; var below = win.innerHeight - r.bottom - 6;
@@ -221,32 +294,49 @@
function filter(q) { function filter(q) {
var tokens = tokenize(q); var tokens = tokenize(q);
if (!tokens.length) return items.slice(); if (!tokens.length) return items.slice();
var starts = [], rest = []; // 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++) { for (var i = 0; i < items.length; i++) {
var n = items[i].norm.n, ok = true; var it = items[i], n = it.norm.n, ok = true;
for (var t = 0; t < tokens.length; t++) { for (var t = 0; t < tokens.length; t++) {
if (n.indexOf(tokens[t]) === -1) { ok = false; break; } if (n.indexOf(tokens[t]) === -1) { ok = false; break; }
} }
if (!ok) continue; if (!ok) continue;
(n.indexOf(tokens[0]) === 0 ? starts : rest).push(items[i]); if (!emmers[it.gi]) emmers[it.gi] = { starts: [], rest: [] };
(n.indexOf(tokens[0]) === 0 ? emmers[it.gi].starts : emmers[it.gi].rest).push(it);
} }
return starts.concat(rest); var out = [];
for (var g = 0; g < emmers.length; g++) {
if (emmers[g]) out = out.concat(emmers[g].starts, emmers[g].rest);
}
return out;
} }
function render(q) { // 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); var tokens = tokenize(q);
shown = filter(q); shown = filter(q);
var html = ''; var html = '';
if (!shown.length) { if (!shown.length) {
html = '<li class="empty">Geen resultaten</li>'; html = '<li class="empty">Geen resultaten</li>';
} else { } else {
var n = Math.min(shown.length, MAX_RENDER); var n = Math.min(shown.length, MAX_RENDER), vorige = null;
for (var i = 0; i < n; i++) { for (var i = 0; i < n; i++) {
var it = shown[i]; var it = shown[i];
if (it.group && it.group !== vorige) {
html += '<li class="kop">' + escapeHtml(it.group) + '</li>';
}
vorige = it.group;
html += '<li role="option" id="' + id + '-o' + i + '" data-i="' + i + '"' html += '<li role="option" id="' + id + '-o' + i + '" data-i="' + i + '"'
+ (it.removable ? ' data-rm="1"' : '')
+ (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>' + (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>'
+ highlight(it, tokens) + highlight(it, tokens)
+ (it.group ? '<span class="grp">' + escapeHtml(it.group) + '</span>' : '')
+ '</li>'; + '</li>';
} }
if (shown.length > n) { if (shown.length > n) {
@@ -255,7 +345,9 @@
} }
list.innerHTML = html; list.innerHTML = html;
shown = shown.slice(0, MAX_RENDER); shown = shown.slice(0, MAX_RENDER);
setActive(shown.length ? 0 : -1, false); var doel = 0;
if (behoud && vorigeActieve > 0) doel = Math.min(vorigeActieve, shown.length - 1);
setActive(shown.length ? doel : -1, false);
} }
function setActive(i, scroll) { function setActive(i, scroll) {
@@ -301,11 +393,27 @@
if (!it || it.disabled) return; if (!it || it.disabled) return;
select.selectedIndex = it.index; select.selectedIndex = it.index;
input.value = it.label; input.value = it.label;
updateWis();
closeList(false); closeList(false);
fire(select, 'input'); fire(select, 'input');
fire(select, 'change'); 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) { function fire(el, type) {
var ev; var ev;
try { try {
@@ -347,6 +455,13 @@
} }
if (!open) { e.preventDefault(); input.focus(); openList(''); input.select(); } 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('focus', function () { if (!open) openList(''); });
input.addEventListener('input', function () { if (open) render(input.value); else openList(input.value); }); input.addEventListener('input', function () { if (open) render(input.value); else openList(input.value); });
input.addEventListener('blur', function () { input.addEventListener('blur', function () {
@@ -378,8 +493,34 @@
case 'Escape': case 'Escape':
if (open) { e.preventDefault(); e.stopPropagation(); closeList(true); } if (open) { e.preventDefault(); e.stopPropagation(); closeList(true); }
break; 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();
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('mousedown', function (e) { e.preventDefault(); });
list.addEventListener('click', function (e) { list.addEventListener('click', function (e) {
var li = e.target; var li = e.target;
@@ -387,14 +528,16 @@
if (li && li.hasAttribute('data-i')) choose(parseInt(li.getAttribute('data-i'), 10)); if (li && li.hasAttribute('data-i')) choose(parseInt(li.getAttribute('data-i'), 10));
}); });
function onSelectChange() { if (!open) input.value = currentLabel(); } function onSelectChange() {
updateWis();
if (!open) input.value = currentLabel();
}
select.addEventListener('change', onSelectChange); select.addEventListener('change', onSelectChange);
// De app vervangt de optielijst (bv. "Inclusief niet-raamcontractartikelen"). // De app vervangt de optielijst (bv. "Inclusief niet-raamcontractartikelen").
var resync = debounce(function () { var resync = debounce(function () {
var q = open ? input.value : null;
syncFromSelect(); syncFromSelect();
if (open) render(q); if (open) render(zoek, true);
}, 50); }, 50);
var obs = new MutationObserver(resync); var obs = new MutationObserver(resync);
obs.observe(select, { childList: true, subtree: true }); obs.observe(select, { childList: true, subtree: true });
+161 -18
View File
@@ -89,22 +89,32 @@
'background:#fff;color:#000;font:13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;', 'background:#fff;color:#000;font:13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;',
'height:100%;min-height:22px}', 'height:100%;min-height:22px}',
'input:focus{outline:none;border-color:#0b57d0;box-shadow:0 0 0 1px #0b57d0}', '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;', '.caret{position:absolute;right:6px;top:50%;transform:translateY(-50%);pointer-events:none;',
'border:4px solid transparent;border-top-color:#444;margin-top:2px}', '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;', '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;', 'background:#fff;color:#000;border:1px solid #b0b0b0;border-radius:3px;',
'box-shadow:0 4px 14px rgba(0,0,0,.22);', 'box-shadow:0 4px 14px rgba(0,0,0,.22);',
'font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;display:none}', 'font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;display:none}',
'ul.open{display:block}', 'ul.open{display:block}',
'li{padding:3px 8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', 'li{padding:3px 8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'li.kop{padding:6px 8px 2px;color:#777;font-size:11px;font-weight:600;cursor:default}',
'li.active{background:#0b57d0;color:#fff}', 'li.active{background:#0b57d0;color:#fff}',
'li.active mark{background:#ffe08a;color:#000}', 'li.active mark{background:#ffe08a;color:#000}',
'li.dis{color:#999;cursor:default}', 'li.dis{color:#999;cursor:default}',
'li.note{color:#666;cursor:default;font-style:italic;padding-top:5px}', 'li.note{color:#666;cursor:default;font-style:italic;padding-top:5px}',
'li.empty{color:#666;cursor:default}', 'li.empty{color:#666;cursor:default}',
'mark{background:#ffe08a;color:inherit;padding:0}', 'mark{background:#ffe08a;color:inherit;padding:0}'
'.grp{color:#777;font-size:11px;margin-left:6px}',
'li.active .grp{color:#dbe6fb}'
].join(''); ].join('');
/* ------------------------------------------------------------- enhance */ /* ------------------------------------------------------------- enhance */
@@ -137,10 +147,21 @@
input.title = 'Typ om te zoeken. Alt+klik zet het originele keuzemenu terug.'; input.title = 'Typ om te zoeken. Alt+klik zet het originele keuzemenu terug.';
var caret = doc.createElement('span'); var caret = doc.createElement('span');
caret.className = 'caret'; 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'); var list = doc.createElement('ul');
list.id = id + '-list'; list.id = id + '-list';
list.setAttribute('role', 'listbox'); list.setAttribute('role', 'listbox');
box.appendChild(input); box.appendChild(input);
box.appendChild(wis);
box.appendChild(caret); box.appendChild(caret);
root.appendChild(style); root.appendChild(style);
root.appendChild(box); root.appendChild(box);
@@ -156,34 +177,86 @@
select.dataset.ssEnhanced = '1'; select.dataset.ssEnhanced = '1';
var items = [], shown = [], active = -1, open = false; 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() { function readOptions() {
items = []; items = [];
leegIndex = -1;
leegLabel = '';
// De volgorde van de optgroups bepaalt de volgorde van de blokken in de lijst, ook na
// filteren. Opties zonder groep houden gi 0 en blijven dus vooraan.
var groepen = [], gi;
for (var i = 0; i < select.options.length; i++) { for (var i = 0; i < select.options.length; i++) {
var o = select.options[i]; var o = select.options[i];
var label = (o.textContent || '').replace(/\s+/g, ' ').trim(); var label = (o.textContent || '').replace(/\s+/g, ' ').trim();
var grp = o.parentNode && o.parentNode.tagName === 'OPTGROUP' if (!o.value) {
? (o.parentNode.label || '') : ''; if (leegIndex === -1) { leegIndex = i; leegLabel = label; }
continue;
}
var groep = o.parentNode && o.parentNode.tagName === 'OPTGROUP' ? o.parentNode : null;
var grp = groep ? (groep.label || '') : '';
if (!grp) gi = 0;
else {
gi = groepen.indexOf(grp);
if (gi === -1) { groepen.push(grp); gi = groepen.length; } else gi += 1;
}
items.push({ items.push({
index: i, index: i,
label: label, label: label,
group: grp, group: grp,
gi: gi,
disabled: o.disabled, disabled: o.disabled,
norm: normMap(grp ? label + ' ' + grp : label) // 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)
}); });
} }
} }
function currentLabel() { function currentLabel() {
var o = select.options[select.selectedIndex]; var o = select.options[select.selectedIndex];
return o ? (o.textContent || '').replace(/\s+/g, ' ').trim() : ''; // 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() { function syncFromSelect() {
readOptions(); readOptions();
input.placeholder = select.getAttribute('data-placeholder') || leegLabel;
updateWis();
if (!open) input.value = currentLabel(); 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() { function position() {
var r = input.getBoundingClientRect(); var r = input.getBoundingClientRect();
var below = win.innerHeight - r.bottom - 6; var below = win.innerHeight - r.bottom - 6;
@@ -205,32 +278,49 @@
function filter(q) { function filter(q) {
var tokens = tokenize(q); var tokens = tokenize(q);
if (!tokens.length) return items.slice(); if (!tokens.length) return items.slice();
var starts = [], rest = []; // 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++) { for (var i = 0; i < items.length; i++) {
var n = items[i].norm.n, ok = true; var it = items[i], n = it.norm.n, ok = true;
for (var t = 0; t < tokens.length; t++) { for (var t = 0; t < tokens.length; t++) {
if (n.indexOf(tokens[t]) === -1) { ok = false; break; } if (n.indexOf(tokens[t]) === -1) { ok = false; break; }
} }
if (!ok) continue; if (!ok) continue;
(n.indexOf(tokens[0]) === 0 ? starts : rest).push(items[i]); if (!emmers[it.gi]) emmers[it.gi] = { starts: [], rest: [] };
(n.indexOf(tokens[0]) === 0 ? emmers[it.gi].starts : emmers[it.gi].rest).push(it);
} }
return starts.concat(rest); var out = [];
for (var g = 0; g < emmers.length; g++) {
if (emmers[g]) out = out.concat(emmers[g].starts, emmers[g].rest);
}
return out;
} }
function render(q) { // 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); var tokens = tokenize(q);
shown = filter(q); shown = filter(q);
var html = ''; var html = '';
if (!shown.length) { if (!shown.length) {
html = '<li class="empty">Geen resultaten</li>'; html = '<li class="empty">Geen resultaten</li>';
} else { } else {
var n = Math.min(shown.length, MAX_RENDER); var n = Math.min(shown.length, MAX_RENDER), vorige = null;
for (var i = 0; i < n; i++) { for (var i = 0; i < n; i++) {
var it = shown[i]; var it = shown[i];
if (it.group && it.group !== vorige) {
html += '<li class="kop">' + escapeHtml(it.group) + '</li>';
}
vorige = it.group;
html += '<li role="option" id="' + id + '-o' + i + '" data-i="' + i + '"' html += '<li role="option" id="' + id + '-o' + i + '" data-i="' + i + '"'
+ (it.removable ? ' data-rm="1"' : '')
+ (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>' + (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>'
+ highlight(it, tokens) + highlight(it, tokens)
+ (it.group ? '<span class="grp">' + escapeHtml(it.group) + '</span>' : '')
+ '</li>'; + '</li>';
} }
if (shown.length > n) { if (shown.length > n) {
@@ -239,7 +329,9 @@
} }
list.innerHTML = html; list.innerHTML = html;
shown = shown.slice(0, MAX_RENDER); shown = shown.slice(0, MAX_RENDER);
setActive(shown.length ? 0 : -1, false); var doel = 0;
if (behoud && vorigeActieve > 0) doel = Math.min(vorigeActieve, shown.length - 1);
setActive(shown.length ? doel : -1, false);
} }
function setActive(i, scroll) { function setActive(i, scroll) {
@@ -285,11 +377,27 @@
if (!it || it.disabled) return; if (!it || it.disabled) return;
select.selectedIndex = it.index; select.selectedIndex = it.index;
input.value = it.label; input.value = it.label;
updateWis();
closeList(false); closeList(false);
fire(select, 'input'); fire(select, 'input');
fire(select, 'change'); 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) { function fire(el, type) {
var ev; var ev;
try { try {
@@ -331,6 +439,13 @@
} }
if (!open) { e.preventDefault(); input.focus(); openList(''); input.select(); } 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('focus', function () { if (!open) openList(''); });
input.addEventListener('input', function () { if (open) render(input.value); else openList(input.value); }); input.addEventListener('input', function () { if (open) render(input.value); else openList(input.value); });
input.addEventListener('blur', function () { input.addEventListener('blur', function () {
@@ -362,8 +477,34 @@
case 'Escape': case 'Escape':
if (open) { e.preventDefault(); e.stopPropagation(); closeList(true); } if (open) { e.preventDefault(); e.stopPropagation(); closeList(true); }
break; 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();
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('mousedown', function (e) { e.preventDefault(); });
list.addEventListener('click', function (e) { list.addEventListener('click', function (e) {
var li = e.target; var li = e.target;
@@ -371,14 +512,16 @@
if (li && li.hasAttribute('data-i')) choose(parseInt(li.getAttribute('data-i'), 10)); if (li && li.hasAttribute('data-i')) choose(parseInt(li.getAttribute('data-i'), 10));
}); });
function onSelectChange() { if (!open) input.value = currentLabel(); } function onSelectChange() {
updateWis();
if (!open) input.value = currentLabel();
}
select.addEventListener('change', onSelectChange); select.addEventListener('change', onSelectChange);
// De app vervangt de optielijst (bv. "Inclusief niet-raamcontractartikelen"). // De app vervangt de optielijst (bv. "Inclusief niet-raamcontractartikelen").
var resync = debounce(function () { var resync = debounce(function () {
var q = open ? input.value : null;
syncFromSelect(); syncFromSelect();
if (open) render(q); if (open) render(zoek, true);
}, 50); }, 50);
var obs = new MutationObserver(resync); var obs = new MutationObserver(resync);
obs.observe(select, { childList: true, subtree: true }); obs.observe(select, { childList: true, subtree: true });
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name Doorzoekbare dropdowns (select-search) // @name BIPP: doorzoekbare dropdowns
// @namespace https://github.com/glgoose/ua-userscripts // @namespace https://github.com/glgoose/ua-userscripts
// @version 1.2.0 // @version 1.5.0
// @description Geeft elke <select> met veel opties een zoekveld met substring-zoeken. // @description Geeft elke <select> met veel opties een zoekveld met substring-zoeken.
// @author glgoose // @author glgoose
// @license GPL-3.0-or-later // @license GPL-3.0-or-later
+42 -1
View File
@@ -20,7 +20,9 @@
<tr><td><label for="lev">Leverancier</label></td> <tr><td><label for="lev">Leverancier</label></td>
<td><select id="lev" name="leverancier"></select></td></tr> <td><select id="lev" name="leverancier"></select></td></tr>
<tr><td><label for="fab">Fabricaat</label></td> <tr><td><label for="fab">Fabricaat</label></td>
<td><select id="fab" name="fabricaat"></select></td></tr> <td><select id="fab" name="fabricaat" data-placeholder="fabricaat"></select></td></tr>
<tr><td><label for="groep">Met groepen</label></td>
<td><select id="groep" name="groep"></select></td></tr>
<tr><td><label for="klein">Klein menu</label></td> <tr><td><label for="klein">Klein menu</label></td>
<td><select id="klein" name="klein"> <td><select id="klein" name="klein">
<option value="">--</option><option>Ja</option><option>Nee</option> <option value="">--</option><option>Ja</option><option>Nee</option>
@@ -64,6 +66,44 @@ function vul(sel, n, offset){
sel.value = huidig; sel.value = huidig;
} }
// Select met optgroups: recent bovenaan, daarna de rest, plus een optie zonder groep die
// bovenaan los blijft staan. Labels lang genoeg om af te kappen.
function vulGroepen(sel){
sel.innerHTML = '';
var leeg = document.createElement('option');
leeg.value = ''; leeg.textContent = 'leverancier';
sel.appendChild(leeg);
var blokken = [
['recent', maak(3, 300)],
['alle leveranciers', maak(60, 700)]
];
blokken.forEach(function(blok){
var g = document.createElement('optgroup');
g.label = blok[0];
// Alleen recent mag met Delete uitgedund worden. Zo is de vlag hier ook echt getest.
if (blok[0] === 'recent') g.dataset.removable = '1';
blok[1].forEach(function(o){
var e = document.createElement('option');
e.value = o.v; e.textContent = o.t;
g.appendChild(e);
});
sel.appendChild(g);
});
}
vulGroepen(document.getElementById('groep'));
// De host beslist wat verwijderen betekent. Hier: de optie gaat uit recent en verhuist naar de
// tweede groep, net als het echte script met zijn recente budgetcodes doet.
document.getElementById('groep').addEventListener('select-search:remove', function(e){
var sel = e.currentTarget;
var weg = sel.options[e.detail.index];
if (!weg) return;
var doel = sel.querySelectorAll('optgroup')[1];
var gekozen = sel.value;
doel.insertBefore(weg, doel.firstChild);
sel.value = gekozen;
});
var lev = document.getElementById('lev'), fab = document.getElementById('fab'); var lev = document.getElementById('lev'), fab = document.getElementById('fab');
function herlaad(){ function herlaad(){
var inclusief = document.getElementById('incl').checked; var inclusief = document.getElementById('incl').checked;
@@ -83,6 +123,7 @@ document.getElementById('f').addEventListener('submit', function(e){
document.getElementById('uit').textContent = document.getElementById('uit').textContent =
'verzonden:\n leverancier = ' + d.get('leverancier') + 'verzonden:\n leverancier = ' + d.get('leverancier') +
'\n fabricaat = ' + d.get('fabricaat') + '\n fabricaat = ' + d.get('fabricaat') +
'\n groep = ' + d.get('groep') +
'\n klein = ' + d.get('klein') + '\n klein = ' + d.get('klein') +
'\n (label leverancier: ' + (lev.options[lev.selectedIndex] || {textContent:''}).textContent + ')'; '\n (label leverancier: ' + (lev.options[lev.selectedIndex] || {textContent:''}).textContent + ')';
}); });