select-search: kleine hangende inspring in plaats van afkappen

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. Ze kiest nu
eerst haar breedte met het volle venster beschikbaar, en schuift daarna zo ver naar links als
nodig om te passen.

Een label dat dan nog niet op één regel past loopt terug met een hangende inspring van 2.5ch, de
breedte van twee cijfers plus een streepje, dus precies het "10-" waarmee elke budgetcode begint.
Uitlijnen onder de omschrijving lag meer voor de hand, maar die inspring is zo breed als de code
en bij een lange code bleef er nauwelijks tekstbreedte over. De code zelf breekt nooit af, ook
niet op zijn eigen streepjes: daarvoor staat ze in een eigen span met white-space:pre, met de
spatie erbuiten zodat het eerste woord van de omschrijving wel kan terugvallen.

Tussen twee opties staat een haarlijn, want een meerregelige rij loopt anders over in de
volgende. highlight() tekent nu een stuk van het label in plaats van het geheel, zodat een
zoektreffer over de grens tussen code en omschrijving heen blijft kloppen. Het title-attribuut
kan weg: met terugloop staat het volledige label altijd op het scherm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Glenn Goossens
2026-09-14 10:11:22 +00:00
co-authored by Claude Opus 5
parent 6e6d6d8fc6
commit 5d4df257b0
5 changed files with 152 additions and 33 deletions
+7
View File
@@ -46,6 +46,13 @@ Klik een dropdown aan en typ.
`{value, label, index}`. Wie de opties leverde beslist wat verwijderen betekent en past de `{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 `<select>` aan, waarna de lijst zichzelf hertekent met behoud van de zoekterm en de plek van
de selectie. de selectie.
- De lijst is breder dan het veld, tot 420px, en schuift daarvoor zo nodig naar links op. Een
veld dat rechts in het formulier staat krijgt dus dezelfde leesbare breedte als een veld links.
- De code (alles voor de eerste ` - `, als dat één woord is) breekt nooit af, ook niet op zijn
eigen streepjes. Past een label niet op één regel, dan loopt het terug met een hangende
inspring van `2.5ch`: net de breedte van het `10-` waarmee elke budgetcode begint, en elders
genoeg om te zien dat een regel bij de vorige hoort. Tussen twee opties staat een haarlijn,
want een meerregelige rij loopt anders over in de volgende.
- Boven 200 treffers toont het enkel het aantal. Verfijn dan je zoekterm. - Boven 200 treffers toont het enkel het aantal. Verfijn dan je zoekterm.
## Recent ## Recent
File diff suppressed because one or more lines are too long
+72 -16
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name BIPP: doorzoekbare dropdowns // @name BIPP: doorzoekbare dropdowns
// @namespace https://github.com/glgoose/ua-userscripts // @namespace https://github.com/glgoose/ua-userscripts
// @version 1.6.0 // @version 1.8.2
// @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
@@ -33,6 +33,10 @@
// kleinere dropdowns laten we met rust; hoogstens MAX_RENDER treffers tekenen // kleinere dropdowns laten we met rust; hoogstens MAX_RENDER treffers tekenen
var MIN_OPTIONS = 10; var MIN_OPTIONS = 10;
var MAX_RENDER = 200; 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; var uid = 0;
// Recent gebruikte waarden per veld. Vijf, want select-search filtert het blok mee tijdens het // 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, // zoeken: wat buiten de top vijf valt typ je sneller dan je het herkent. Geen vervaltermijn,
@@ -144,8 +148,11 @@
/* ------------------------------------------------------------ highlight */ /* ------------------------------------------------------------ highlight */
function highlight(item, tokens) { // Alle treffers over het volledige label, gesorteerd en samengevoegd. Los van het tekenen,
if (!tokens.length) return escapeHtml(item.label); // 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 = []; var ranges = [];
for (var t = 0; t < tokens.length; t++) { for (var t = 0; t < tokens.length; t++) {
var tok = tokens[t], from = 0, idx; var tok = tokens[t], from = 0, idx;
@@ -156,7 +163,7 @@
from = idx + tok.length; from = idx + tok.length;
} }
} }
if (!ranges.length) return escapeHtml(item.label); if (!ranges.length) return ranges;
ranges.sort(function (a, b) { return a[0] - b[0]; }); ranges.sort(function (a, b) { return a[0] - b[0]; });
var merged = [ranges[0].slice()]; var merged = [ranges[0].slice()];
for (var r = 1; r < ranges.length; r++) { for (var r = 1; r < ranges.length; r++) {
@@ -164,13 +171,33 @@
if (ranges[r][0] <= last[1]) last[1] = Math.max(last[1], ranges[r][1]); if (ranges[r][0] <= last[1]) last[1] = Math.max(last[1], ranges[r][1]);
else merged.push(ranges[r].slice()); else merged.push(ranges[r].slice());
} }
var html = '', pos = 0; return merged;
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>'; // Tekent label.slice(from, to). Een treffer die de grens kruist wordt afgeknipt, zodat beide
pos = merged[m][1]; // 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)); 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 */ /* ----------------------------------------------------------------- css */
@@ -201,9 +228,25 @@
'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}', // Een label dat niet past loopt terug over een volgende regel met een kleine hangende
'li.kop{padding:6px 8px 2px;color:#777;font-size:11px;font-weight:600;cursor:default}', // inspring. Niet uitlijnen onder de omschrijving: bij een lange code schuift die inspring mee
'li.active{background:#0b57d0;color:#fff}', // 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.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}',
@@ -402,10 +445,17 @@
var below = win.innerHeight - r.bottom - 6; var below = win.innerHeight - r.bottom - 6;
var above = r.top - 6; var above = r.top - 6;
var down = below >= 160 || below >= above; var down = below >= 160 || below >= above;
list.style.left = r.left + 'px'; // 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.minWidth = r.width + 'px';
list.style.maxWidth = Math.max(r.width, Math.min(560, win.innerWidth - r.left - 8)) + 'px'; list.style.maxWidth = wens + 'px';
list.style.maxHeight = Math.max(120, (down ? below : above)) + '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) { if (down) {
list.style.top = r.bottom + 2 + 'px'; list.style.top = r.bottom + 2 + 'px';
list.style.bottom = 'auto'; list.style.bottom = 'auto';
@@ -457,10 +507,16 @@
html += '<li class="kop">' + escapeHtml(it.group) + '</li>'; html += '<li class="kop">' + escapeHtml(it.group) + '</li>';
} }
vorige = it.group; 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 + '"' html += '<li role="option" id="' + id + '-o' + i + '" data-i="' + i + '"'
+ (it.removable ? ' data-rm="1"' : '') + (it.removable ? ' data-rm="1"' : '')
+ (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>' + (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>'
+ highlight(it, tokens) + binnen
+ '</li>'; + '</li>';
} }
if (shown.length > n) { if (shown.length > n) {
+71 -15
View File
@@ -17,6 +17,10 @@
// kleinere dropdowns laten we met rust; hoogstens MAX_RENDER treffers tekenen // kleinere dropdowns laten we met rust; hoogstens MAX_RENDER treffers tekenen
var MIN_OPTIONS = 10; var MIN_OPTIONS = 10;
var MAX_RENDER = 200; 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; var uid = 0;
// Recent gebruikte waarden per veld. Vijf, want select-search filtert het blok mee tijdens het // 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, // zoeken: wat buiten de top vijf valt typ je sneller dan je het herkent. Geen vervaltermijn,
@@ -128,8 +132,11 @@
/* ------------------------------------------------------------ highlight */ /* ------------------------------------------------------------ highlight */
function highlight(item, tokens) { // Alle treffers over het volledige label, gesorteerd en samengevoegd. Los van het tekenen,
if (!tokens.length) return escapeHtml(item.label); // 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 = []; var ranges = [];
for (var t = 0; t < tokens.length; t++) { for (var t = 0; t < tokens.length; t++) {
var tok = tokens[t], from = 0, idx; var tok = tokens[t], from = 0, idx;
@@ -140,7 +147,7 @@
from = idx + tok.length; from = idx + tok.length;
} }
} }
if (!ranges.length) return escapeHtml(item.label); if (!ranges.length) return ranges;
ranges.sort(function (a, b) { return a[0] - b[0]; }); ranges.sort(function (a, b) { return a[0] - b[0]; });
var merged = [ranges[0].slice()]; var merged = [ranges[0].slice()];
for (var r = 1; r < ranges.length; r++) { for (var r = 1; r < ranges.length; r++) {
@@ -148,13 +155,33 @@
if (ranges[r][0] <= last[1]) last[1] = Math.max(last[1], ranges[r][1]); if (ranges[r][0] <= last[1]) last[1] = Math.max(last[1], ranges[r][1]);
else merged.push(ranges[r].slice()); else merged.push(ranges[r].slice());
} }
var html = '', pos = 0; return merged;
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>'; // Tekent label.slice(from, to). Een treffer die de grens kruist wordt afgeknipt, zodat beide
pos = merged[m][1]; // 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)); 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 */ /* ----------------------------------------------------------------- css */
@@ -185,9 +212,25 @@
'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}', // Een label dat niet past loopt terug over een volgende regel met een kleine hangende
'li.kop{padding:6px 8px 2px;color:#777;font-size:11px;font-weight:600;cursor:default}', // inspring. Niet uitlijnen onder de omschrijving: bij een lange code schuift die inspring mee
'li.active{background:#0b57d0;color:#fff}', // 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.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}',
@@ -386,10 +429,17 @@
var below = win.innerHeight - r.bottom - 6; var below = win.innerHeight - r.bottom - 6;
var above = r.top - 6; var above = r.top - 6;
var down = below >= 160 || below >= above; var down = below >= 160 || below >= above;
list.style.left = r.left + 'px'; // 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.minWidth = r.width + 'px';
list.style.maxWidth = Math.max(r.width, Math.min(560, win.innerWidth - r.left - 8)) + 'px'; list.style.maxWidth = wens + 'px';
list.style.maxHeight = Math.max(120, (down ? below : above)) + '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) { if (down) {
list.style.top = r.bottom + 2 + 'px'; list.style.top = r.bottom + 2 + 'px';
list.style.bottom = 'auto'; list.style.bottom = 'auto';
@@ -441,10 +491,16 @@
html += '<li class="kop">' + escapeHtml(it.group) + '</li>'; html += '<li class="kop">' + escapeHtml(it.group) + '</li>';
} }
vorige = it.group; 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 + '"' html += '<li role="option" id="' + id + '-o' + i + '" data-i="' + i + '"'
+ (it.removable ? ' data-rm="1"' : '') + (it.removable ? ' data-rm="1"' : '')
+ (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>' + (it.disabled ? ' class="dis" aria-disabled="true"' : '') + '>'
+ highlight(it, tokens) + binnen
+ '</li>'; + '</li>';
} }
if (shown.length > n) { if (shown.length > n) {
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name BIPP: doorzoekbare dropdowns // @name BIPP: doorzoekbare dropdowns
// @namespace https://github.com/glgoose/ua-userscripts // @namespace https://github.com/glgoose/ua-userscripts
// @version 1.6.0 // @version 1.8.2
// @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