Compare commits
3
Commits
f140a32199
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee1dca93ea | ||
|
|
3e610366fe | ||
|
|
5320b17945 |
@@ -1,84 +1,51 @@
|
|||||||
# ua-userscripts
|
# ua-userscripts
|
||||||
|
|
||||||
Dev-conventies voor dit script-repo: dev-stub workflow en naamgeving.
|
Repo: https://gitea.flw.uantwerpen.be/glenn/ua-userscripts. Scripts in `scripts/<map>/`, elk met een eigen README.
|
||||||
|
|
||||||
## Dev-stub vs. gebruik
|
## Dev-stub vs. gebruik
|
||||||
|
|
||||||
De file in `scripts/` is de bron van waarheid voor de code. Wat in Tampermonkey staat is dus
|
`scripts/` is de bron van waarheid. In Tampermonkey staat een van twee sporen, nooit een met de hand geplakte kopie:
|
||||||
óf een loader die naar die file wijst, óf een kopie die van de raw-URL komt. Nooit een met de
|
|
||||||
hand geplakte kopie, want dan zijn er twee waarheden die stil uit elkaar lopen.
|
|
||||||
|
|
||||||
| spoor | wat er in Tampermonkey staat | grootte | `@version` |
|
| spoor | in Tampermonkey | `@version` |
|
||||||
| ----------- | --------------------------------------- | ----------------- | ----------- |
|
| ----------- | ------------------------------- | ----------- |
|
||||||
| **dev** | stub met `@require file:///...` | een paar honderd byte | `0.0.0-dev` |
|
| **dev** | stub met `@require file:///...` | `0.0.0-dev` |
|
||||||
| **gebruik** | volledige kopie via de raw-URL | de echte grootte | `1.0.0` |
|
| **gebruik** | volledige kopie via de raw-URL | `1.0.0` |
|
||||||
|
|
||||||
De grootte in het dashboard zegt dus in welk spoor een rij zit.
|
### Dev-stub maken en installeren
|
||||||
|
|
||||||
### Een dev-stub maken
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bin/dev-stub bipp-budgetcode
|
bin/dev-stub bipp-budgetcode # --list toont de mappen, --no-copy slaat het klembord over
|
||||||
```
|
```
|
||||||
|
|
||||||
Claude installeert de stub zelf, Glenn plakt niets. Serveer de stub (`--no-copy`) als
|
Claude installeert de stub zelf:
|
||||||
`<map>.user.js` vanuit de scratchpad op een vrije poort, open die URL in de Claude-in-Chrome-tab
|
|
||||||
en Tampermonkey opent zijn installatiepagina. Die pagina (`chrome-extension://`) is voor de
|
|
||||||
browsertools onbereikbaar, dus de enige stap voor Glenn is daar op **Install** klikken. Stop de
|
|
||||||
server daarna.
|
|
||||||
|
|
||||||
- **Juiste Chrome-profiel.** BIPP en de Tampermonkey-scripts zitten in het profiel **UA**
|
1. Serveer de stub (`--no-copy`) als `<map>.user.js` vanuit de scratchpad op een vrije poort.
|
||||||
(Profile 1). Met de Claude-extensie in twee profielen verbindt `list_connected_browsers` met
|
2. Open die URL in de Claude-in-Chrome-tab, in het Chrome-profiel **UA** (Profile 1, kies met `select_browser`; controleer via de `History` van dat profiel).
|
||||||
het profiel dat eerst antwoordt; kies UA met `select_browser`. Of je goed zit, zie je aan de
|
3. De gebruiker klikt op **Install** (de `chrome-extension://`-pagina is onbereikbaar voor de tools).
|
||||||
`History` van het profiel na een bezoek vanuit de tool.
|
4. Stop de server.
|
||||||
- Een bestaande rij met dezelfde `@namespace` plus `@name` wordt bijgewerkt. Een andere `@name`
|
|
||||||
geeft een tweede rij die dezelfde file nog eens laadt, dus elke sneltoets dubbel: verwijder
|
|
||||||
dan eerst de oude rij.
|
|
||||||
|
|
||||||
`bin/dev-stub --list` toont de scriptmappen, `--no-copy` slaat het klembord over.
|
Zelfde `@namespace` plus `@name` werkt een bestaande rij bij. Een andere `@name` geeft een tweede rij die dezelfde file nog eens laadt (elke sneltoets dubbel): verwijder dan eerst de oude.
|
||||||
|
|
||||||
Drie dingen die aan die stub vasthangen:
|
Eigenschappen van de stub:
|
||||||
|
|
||||||
- **De metadata komt uit de stub, niet uit de required file.** `@match`, `@grant` en `@run-at`
|
- **Metadata komt uit de stub**, niet uit de required file. `@match`, `@grant`, `@run-at` en `@require` moeten erin staan; `dev-stub` neemt ze over. Opnieuw genereren en installeren is alleen nodig als die wijzigen.
|
||||||
moeten er dus in staan; het `==UserScript==`-blok van de file zelf wordt genegeerd. `dev-stub`
|
- **Geen `@downloadURL` en `@updateURL`.** `@updateURL none` geeft een eslint-waarschuwing in de editor. Een rij die ooit van de raw-URL kwam, biedt alsnog updates aan die de stub overschrijven: verwijder die rij en maak de stub opnieuw via **Create a new script**.
|
||||||
neemt die velden over uit het echte script, zodat de twee niet uit elkaar lopen.
|
- **Vaste `0.0.0-dev`**, geen bump nodig: de code komt bij elke paginaload van schijf.
|
||||||
- **`@downloadURL` en `@updateURL` staan er allebei niet in.** De code komt uit een lokaal
|
- **`@require` wijst naar het script zelf.** Opslaan en de pagina herladen volstaat.
|
||||||
bestand, dus er is niets te downloaden of bij te werken. `@updateURL none` is geen
|
- **Vereist** dat **Allow access to file URLs** aanstaat (`chrome://extensions`, Details).
|
||||||
oplossing: de editor van Tampermonkey toont dan een eslint-waarschuwing
|
|
||||||
(`use-download-and-update-url`), en zonder `@downloadURL` heeft `@updateURL` ook geen
|
|
||||||
functie. Een rij die ooit van de raw-URL kwam, onthoudt die URL apart van het meta-blok en
|
|
||||||
biedt dan alsnog een update aan die de stub overschrijft: verwijder zo'n rij en maak de stub
|
|
||||||
opnieuw aan via **Create a new script**.
|
|
||||||
- **De stub hoeft niet mee met een `@version`-bump.** Ze staat op een vaste `0.0.0-dev`. Een
|
|
||||||
echt nummer zou daar toch niets zeggen: de code komt bij elke pagina-load van schijf, dus wat
|
|
||||||
er draait is wat er nú in de repo staat. De kolom hoeft enkel `-dev` te tonen. Opnieuw
|
|
||||||
genereren en plakken doe je alleen als de metadata zelf wijzigt, dus bij een andere `@match`,
|
|
||||||
`@grant`, `@run-at` of `@require`.
|
|
||||||
- **Heeft de map een `src/`, dan wijst `@require` daarnaar,** niet naar het gebouwde script.
|
|
||||||
Opslaan en de pagina herladen volstaat; `build.sh` draai je pas als je publiceert, voor de
|
|
||||||
raw-URL en de bookmarklet.
|
|
||||||
|
|
||||||
Lokale bestanden lezen mag Tampermonkey alleen als **Allow access to file URLs** aanstaat, bij
|
## Publiceren
|
||||||
`chrome://extensions` onder Details.
|
|
||||||
|
|
||||||
### Naamgeving
|
- Hoog `@version` op bij elke wijziging, anders krijgt niemand de update.
|
||||||
|
- `@namespace` blijft `https://github.com/glgoose/ua-userscripts`. Het is alleen een identificatie, en wijzigen geeft bij iedereen een dubbele rij.
|
||||||
|
|
||||||
`<Toepassing>: <wat het doet>`, met de toepassing zoals je ze zelf noemt (`BIPP`, `iExpense`,
|
## Naamgeving
|
||||||
`PeopleSoft`), daarna de dubbele punt en een kleine-letter naamwoordgroep zonder punt. Het
|
|
||||||
dashboard sorteert op naam, dus zo staan de scripts van dezelfde toepassing bij elkaar.
|
|
||||||
|
|
||||||
- Geen `[DEV]` in de naam. Dat groepeert op ontwikkelstatus in plaats van op toepassing, en de
|
`<Toepassing>: <wat het doet>`: toepassing zoals je ze zelf noemt (`BIPP`, `iExpense`, `PeopleSoft`, of `Algemeen` bij meerdere sites), dubbele punt, kleine-letter naamwoordgroep zonder punt. Het dashboard sorteert op naam, zo staan scripts van dezelfde toepassing bij elkaar.
|
||||||
Version-kolom zegt met `-dev` al hetzelfde.
|
|
||||||
- Geen technische slug in de naam. Die staat in het repo-pad en in `@homepageURL`.
|
- Geen `[DEV]` of technische slug in de naam.
|
||||||
- Draait een script op meerdere sites, dan `Algemeen: ...`.
|
|
||||||
- Mapnaam en bestandsnaam in kebab-case, zelfde volgorde: `<toepassing>-<functie>`.
|
- Mapnaam en bestandsnaam in kebab-case, zelfde volgorde: `<toepassing>-<functie>`.
|
||||||
|
|
||||||
Hoog `@version` op bij elke wijziging, anders krijgt niemand de update binnen.
|
|
||||||
|
|
||||||
## Testen op BIPP
|
## Testen op BIPP
|
||||||
|
|
||||||
BIPP-pagina's zoals `OrderComposer.aspx` en `OrderRequestDetail.aspx` werken alleen via het
|
Pagina's zoals `OrderComposer.aspx` en `OrderRequestDetail.aspx` werken alleen via het menu (een `__doPostBack`, bijvoorbeeld `javascript:__doPostBack('orderRequestMenu$reviewRequestBtn','')`). Rechtstreeks via de URL geeft de server "Object reference not set to an instance of an object": navigeer eerst naar een andere BIPP-pagina en klik dan in het menu.
|
||||||
menu (een `__doPostBack`, bijvoorbeeld `javascript:__doPostBack('orderRequestMenu$reviewRequestBtn','')`).
|
|
||||||
Open je ze rechtstreeks via de URL, dan geeft de server "Object reference not set to an instance
|
|
||||||
of an object". Dat ligt niet aan je script: navigeer eerst naar een andere BIPP-pagina en klik
|
|
||||||
dan in het menu.
|
|
||||||
|
|||||||
+1
-11
@@ -67,19 +67,9 @@ for f in "$dir"/*.user.js; do
|
|||||||
done
|
done
|
||||||
if [ -z "$src" ]; then
|
if [ -z "$src" ]; then
|
||||||
echo "dev-stub: geen *.user.js in $dir" >&2
|
echo "dev-stub: geen *.user.js in $dir" >&2
|
||||||
echo "dev-stub: bij een script met een build: draai eerst ./build.sh" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Heeft de map een build, dan wijst @require naar de bron in plaats van naar het
|
|
||||||
# gebouwde bestand: zo volstaat opslaan en de pagina herladen, zonder build.sh
|
|
||||||
# ertussen. De metadata blijft wel uit het gebouwde script komen, want de bron
|
|
||||||
# heeft geen ==UserScript==-blok.
|
|
||||||
code="$src"
|
|
||||||
if [ -f "$dir/src/$(basename "$dir").js" ]; then
|
|
||||||
code="$dir/src/$(basename "$dir").js"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Eén metadataveld uit de kop van het echte script halen.
|
# Eén metadataveld uit de kop van het echte script halen.
|
||||||
meta() {
|
meta() {
|
||||||
sed -n 's|^// @'"$1"' *\(.*[^ ]\) *$|\1|p' "$src"
|
sed -n 's|^// @'"$1"' *\(.*[^ ]\) *$|\1|p' "$src"
|
||||||
@@ -123,7 +113,7 @@ fi
|
|||||||
meta resource | while IFS= read -r value; do
|
meta resource | while IFS= read -r value; do
|
||||||
[ -n "$value" ] && printf '// @%-12s %s\n' resource "$value"
|
[ -n "$value" ] && printf '// @%-12s %s\n' resource "$value"
|
||||||
done
|
done
|
||||||
printf '// @%-12s file://%s\n' require "$code"
|
printf '// @%-12s file://%s\n' require "$src"
|
||||||
for key in grant connect run-at noframes; do
|
for key in grant connect run-at noframes; do
|
||||||
meta "$key" | while IFS= read -r value; do
|
meta "$key" | while IFS= read -r value; do
|
||||||
[ -n "$value" ] && printf '// @%-12s %s\n' "$key" "$value"
|
[ -n "$value" ] && printf '// @%-12s %s\n' "$key" "$value"
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
# bipp-sneltoetsen
|
# bipp-sneltoetsen
|
||||||
|
|
||||||
Ctrl+letter drukt in BIPP op een knop. De letter is onderstreept, de tooltip toont de sneltoets, en zolang je Ctrl ingedrukt houdt verschijnt de sneltoets onder elke knop die er een heeft.
|
Sneltoetsen in BIPP, per rol van de knop. Ze werken in de Nederlandse en de Engelse interface, want de toets hangt aan de rol en niet aan een letter uit het label. De tooltip toont de sneltoets, en zolang je Ctrl, Cmd of Alt ingedrukt houdt verschijnt de sneltoets onder elke knop die er een heeft.
|
||||||
|
|
||||||
| toets | knop | waar |
|
| toets | rol | NL | EN |
|
||||||
| ------ | ---------- | ------------------------------------------------- |
|
| ------------------------------ | ---------- | --------------------------- | ---------------------- |
|
||||||
| Ctrl+B | Bevestigen | Aanvraag evalueren, bestelbon detail |
|
| Ctrl+Enter (Mac ook Cmd+Enter) | hoofdactie | Bevestigen of Goedkeuren, anders Indienen | Authorization or Approve, anders Submit |
|
||||||
| Ctrl+Z | Zoeken | Artikelen, Favorietenlijsten, Aanvragen, Bestelbonnen, Ontvangstregistratie |
|
| Ctrl+S | opslaan | Opslaan | Save |
|
||||||
| Ctrl+R | Reset | idem |
|
| Ctrl+[ | terug | Terug | Back |
|
||||||
| Ctrl+N | Nieuw | Artikelen, Favorietenlijsten |
|
| Alt+S | zoeken | Zoeken | Search |
|
||||||
| Ctrl+S | Opslaan | Winkelmandje, bestelbon detail |
|
| Alt+R | reset | Reset | Reset |
|
||||||
| Ctrl+I | Indienen | Winkelmandje |
|
| Alt+N | nieuw | Nieuw | New |
|
||||||
| Ctrl+T | Terug | detailpagina's |
|
| Alt+C | annuleren | Annuleren | Cancel |
|
||||||
|
| Alt+H | wachtstand | In wachtstand | On hold |
|
||||||
|
|
||||||
Verwijderen, Annuleren en In wachtstand krijgen bewust geen sneltoets: die zijn niet terug te draaien of komen zelden voor.
|
Ctrl voor de standaardtoetsen, Alt voor de zijacties. Op een Mac is Alt de Option-toets.
|
||||||
|
|
||||||
[**Installeren**](https://gitea.flw.uantwerpen.be/glenn/ua-userscripts/raw/branch/main/scripts/bipp-sneltoetsen/bipp-sneltoetsen.user.js)
|
[**Installeren**](https://gitea.flw.uantwerpen.be/glenn/ua-userscripts/raw/branch/main/scripts/bipp-sneltoetsen/bipp-sneltoetsen.user.js)
|
||||||
· gebeurt er niets, zie de [installatie-uitleg](../../README.md).
|
· gebeurt er niets, zie de [installatie-uitleg](../../README.md).
|
||||||
|
|
||||||
## Hoe het werkt
|
## Hoe het werkt
|
||||||
|
|
||||||
- Het script zoekt op elke pagina onder `/web/` per sneltoets de eerste zichtbare, actieve knop met dat label. Is er geen, dan doet de toets niets en gaat hij gewoon door naar de browser.
|
- Het script zoekt op elke pagina onder `/web/` per sneltoets de eerste zichtbare, actieve knop met een van de labels van die rol. Is er geen, dan doet de toets niets en gaat hij gewoon naar de browser.
|
||||||
- Op macOS gebruikt Chrome Cmd voor zijn eigen sneltoetsen, dus Ctrl+letter is vrij. In tekstvelden zijn sommige Ctrl-letters Emacs-bindings (Ctrl+B één teken terug, Ctrl+N volgende regel, Ctrl+T letters wisselen): die heb je in BIPP niet meer.
|
- Staan Bevestigen (of Goedkeuren) en Indienen allebei op de pagina, dan krijgt alleen Bevestigen Ctrl+Enter.
|
||||||
|
- Ctrl+S overschrijft de opslaantoets van de browser, maar alleen op pagina's met een Opslaan-knop. Cmd (macOS) blijft bij de browser, behalve voor Enter.
|
||||||
|
- De zijacties staan op Alt: Chrome geeft Ctrl+N (nieuw venster) niet aan een pagina, Ctrl+F en Ctrl+R zijn zoeken en herladen, en Ctrl+Alt is AltGr op Windows. Op een Mac typt Option+letter normaal een teken, dat het script onderdrukt op pagina's met zo'n knop.
|
||||||
|
- De hints onder de knoppen staan op één regel. Raken ze elkaar, dan schuift de volgende hint naar rechts.
|
||||||
|
- Annuleren en In wachtstand klikken de knop zoals een muisklik, dus een bevestigingsdialoog van BIPP verschijnt gewoon.
|
||||||
- Zet BIPP zelf al een tooltip op de knop (Bevestigen, Indienen), dan komt de sneltoets op een eigen regel eronder.
|
- Zet BIPP zelf al een tooltip op de knop (Bevestigen, Indienen), dan komt de sneltoets op een eigen regel eronder.
|
||||||
- De onderstreping en de hints verschuiven niets: ze staan `position:absolute` buiten de flow. Bij een `<input>` kan de letter niet onderstreept worden met HTML, dus komt er een lijntje onder, berekend uit de lettermaat.
|
|
||||||
- Pagina's die hun knoppen later opbouwen krijgen tot 15 seconden de tijd.
|
- Pagina's die hun knoppen later opbouwen krijgen tot 15 seconden de tijd.
|
||||||
|
|
||||||
## Licentie
|
## Licentie
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name BIPP: sneltoetsen voor knoppen
|
// @name BIPP: sneltoetsen voor knoppen
|
||||||
// @namespace https://github.com/glgoose/ua-userscripts
|
// @namespace https://github.com/glgoose/ua-userscripts
|
||||||
// @version 1.1.2
|
// @version 1.3.0
|
||||||
// @description Ctrl+letter drukt op Bevestigen (B), Zoeken (Z), Reset (R), Nieuw (N), Opslaan (S), Indienen (I) en Terug (T). De letter is onderstreept en bij ingedrukte Ctrl verschijnt de sneltoets onder de knop.
|
// @description Sneltoetsen per rol van de knop, in het Nederlands en het Engels: Ctrl+Enter (Bevestigen/Authorization/Approve, anders Indienen/Submit), Ctrl+S (Opslaan/Save), Ctrl+[ (Terug/Back), Alt+S (Zoeken/Search), Alt+R (Reset), Alt+N (Nieuw/New), Alt+C (Annuleren/Cancel), Alt+H (In wachtstand/On hold). Bij ingedrukte Ctrl, Cmd of Alt verschijnt de sneltoets onder de knop.
|
||||||
// @author glgoose
|
// @author glgoose
|
||||||
// @license GPL-3.0-or-later
|
// @license GPL-3.0-or-later
|
||||||
// @homepageURL https://gitea.flw.uantwerpen.be/glenn/ua-userscripts/src/branch/main/scripts/bipp-sneltoetsen
|
// @homepageURL https://gitea.flw.uantwerpen.be/glenn/ua-userscripts/src/branch/main/scripts/bipp-sneltoetsen
|
||||||
@@ -16,76 +16,55 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Knoplabel (kleine letters), de letter die onderstreept wordt, en de toets.
|
// Een sneltoets hangt aan de rol van de knop, niet aan een letter uit het label, dus NL en EN delen dezelfde toets.
|
||||||
|
// labels: kleine letters, in volgorde van voorrang (de eerste die op de pagina staat wint).
|
||||||
|
// match: welke toetscombinatie het is. hint: wat de tooltip en de hint onder de knop tonen.
|
||||||
var KEYS = [
|
var KEYS = [
|
||||||
{ label: 'bevestigen', letter: 'b', code: 'KeyB' },
|
{ labels: ['bevestigen', 'authorization', 'approve', 'goedkeuren', 'confirm', 'indienen', 'submit'],
|
||||||
{ label: 'zoeken', letter: 'z', code: 'KeyZ' },
|
// Op een Mac werkt Cmd+Enter ook, maar de hint blijft overal Ctrl+Enter.
|
||||||
{ label: 'reset', letter: 'r', code: 'KeyR' },
|
hint: 'Ctrl+Enter',
|
||||||
{ label: 'nieuw', letter: 'n', code: 'KeyN' },
|
match: function (e) { return (e.code === 'Enter' || e.code === 'NumpadEnter') && (e.ctrlKey || e.metaKey) && !(e.ctrlKey && e.metaKey) && !e.altKey && !e.shiftKey; } },
|
||||||
{ label: 'opslaan', letter: 's', code: 'KeyS' },
|
{ labels: ['zoeken', 'search'], hint: 'Alt+S', match: alt('KeyS') },
|
||||||
{ label: 'indienen', letter: 'i', code: 'KeyI' },
|
{ labels: ['opslaan', 'save'], hint: 'Ctrl+S', match: ctrl('KeyS') },
|
||||||
{ label: 'terug', letter: 't', code: 'KeyT' }
|
{ labels: ['terug', 'back'], hint: 'Ctrl+[', match: ctrl('BracketLeft') },
|
||||||
|
{ labels: ['reset'], hint: 'Alt+R', match: alt('KeyR') },
|
||||||
|
{ labels: ['nieuw', 'new'], hint: 'Alt+N', match: alt('KeyN') },
|
||||||
|
{ labels: ['annuleren', 'cancel'], hint: 'Alt+C', match: alt('KeyC') },
|
||||||
|
{ labels: ['in wachtstand', 'on hold'], hint: 'Alt+H', match: alt('KeyH') }
|
||||||
];
|
];
|
||||||
var MARK = 'data-bipp-sneltoets';
|
var MARK = 'data-bipp-sneltoets';
|
||||||
|
|
||||||
function hint(k) { return 'Ctrl+' + k.letter.toUpperCase(); }
|
// Alt voor de zijacties: Ctrl+N, Ctrl+F en Ctrl+R zijn browsertoetsen, en Ctrl+Alt is AltGr op Windows.
|
||||||
|
function alt(code) {
|
||||||
|
return function (e) { return e.code === code && e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey; };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alleen Ctrl: Cmd blijft bij de browser (Cmd+F, Cmd+S, Cmd+R zijn zoeken, opslaan en herladen).
|
||||||
|
function ctrl(code) {
|
||||||
|
return function (e) { return e.code === code && e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; };
|
||||||
|
}
|
||||||
|
|
||||||
function norm(s) { return (s || '').replace(/\s+/g, ' ').trim().toLowerCase(); }
|
function norm(s) { return (s || '').replace(/\s+/g, ' ').trim().toLowerCase(); }
|
||||||
|
|
||||||
function visible(el) { return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); }
|
function visible(el) { return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); }
|
||||||
|
|
||||||
// Eerste zichtbare, actieve knop met dit label. Een postback-pagina kan er verborgen kopieën van hebben.
|
// Per label (op volgorde van voorrang) de eerste zichtbare, actieve knop. Een postback-pagina kan er verborgen kopieën van hebben.
|
||||||
function findButton(k) {
|
function findButton(k) {
|
||||||
var btns = document.querySelectorAll('input[type="submit"], input[type="button"], button');
|
var btns = document.querySelectorAll('input[type="submit"], input[type="button"], button');
|
||||||
for (var i = 0; i < btns.length; i++) {
|
for (var l = 0; l < k.labels.length; l++) {
|
||||||
var b = btns[i];
|
for (var i = 0; i < btns.length; i++) {
|
||||||
if (norm(b.value || b.textContent) === k.label && !b.disabled && visible(b)) return b;
|
var b = btns[i];
|
||||||
|
if (norm(b.value || b.textContent) === k.labels[l] && !b.disabled && visible(b)) return b;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// <button>: de letter in een <u>. <input> kan geen HTML bevatten, dus daar komt een lijntje onder de letter.
|
function mark(btn, k) {
|
||||||
function underline(btn, k) {
|
|
||||||
if (btn.getAttribute(MARK)) return;
|
if (btn.getAttribute(MARK)) return;
|
||||||
btn.setAttribute(MARK, k.letter);
|
btn.setAttribute(MARK, k.hint);
|
||||||
// BIPP zet soms zelf een title (Bevestigen): dan komt de sneltoets op een eigen regel eronder.
|
// BIPP zet soms zelf een title (Bevestigen): dan komt de sneltoets op een eigen regel eronder.
|
||||||
btn.title = btn.title ? btn.title + '\n' + hint(k) : hint(k);
|
btn.title = btn.title ? btn.title + '\n' + k.hint : k.hint;
|
||||||
if (btn.tagName === 'BUTTON' && btn.children.length === 0) {
|
|
||||||
var t = btn.textContent;
|
|
||||||
var i = t.toLowerCase().indexOf(k.letter);
|
|
||||||
btn.textContent = '';
|
|
||||||
btn.appendChild(document.createTextNode(t.slice(0, i)));
|
|
||||||
var u = document.createElement('u');
|
|
||||||
u.textContent = t.charAt(i);
|
|
||||||
btn.appendChild(u);
|
|
||||||
btn.appendChild(document.createTextNode(t.slice(i + 1)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
drawLine(btn, k);
|
|
||||||
window.addEventListener('resize', function () { drawLine(btn, k); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawLine(btn, k) {
|
|
||||||
var old = btn.nextSibling && btn.nextSibling.getAttribute && btn.nextSibling.getAttribute(MARK + '-line');
|
|
||||||
if (old) btn.parentNode.removeChild(btn.nextSibling);
|
|
||||||
var cs = getComputedStyle(btn);
|
|
||||||
var ctx = document.createElement('canvas').getContext('2d');
|
|
||||||
ctx.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
|
|
||||||
var text = btn.value || btn.textContent;
|
|
||||||
var i = text.toLowerCase().indexOf(k.letter);
|
|
||||||
var before = ctx.measureText(text.slice(0, i)).width;
|
|
||||||
var letter = ctx.measureText(text.charAt(i)).width;
|
|
||||||
var total = ctx.measureText(text).width;
|
|
||||||
var inner = btn.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
|
||||||
var start = parseFloat(cs.paddingLeft) + btn.clientLeft + Math.max(0, (inner - total) / 2);
|
|
||||||
var line = document.createElement('span');
|
|
||||||
line.setAttribute(MARK + '-line', '1');
|
|
||||||
var fs = parseFloat(cs.fontSize);
|
|
||||||
// position:absolute houdt de lijn buiten de flow, dus er verschuift niets.
|
|
||||||
line.style.cssText = 'position:absolute;pointer-events:none;height:1px;background:currentColor;color:' + cs.color +
|
|
||||||
';left:' + (btn.offsetLeft + start + before) + 'px;width:' + letter + 'px;top:' +
|
|
||||||
(btn.offsetTop + btn.offsetHeight / 2 + fs * 0.55) + 'px';
|
|
||||||
btn.parentNode.insertBefore(line, btn.nextSibling);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var tips = [];
|
var tips = [];
|
||||||
@@ -96,13 +75,26 @@
|
|||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
var r = btn.getBoundingClientRect();
|
var r = btn.getBoundingClientRect();
|
||||||
var tip = document.createElement('span');
|
var tip = document.createElement('span');
|
||||||
tip.textContent = hint(k);
|
tip.textContent = k.hint;
|
||||||
tip.style.cssText = 'position:absolute;z-index:99999;pointer-events:none;white-space:nowrap;' +
|
tip.style.cssText = 'position:absolute;z-index:99999;pointer-events:none;white-space:nowrap;' +
|
||||||
'font:11px ui-monospace,Menlo,Consolas,monospace;background:#1f2937;color:#fff;border-radius:4px;padding:1px 6px;' +
|
'font:11px ui-monospace,Menlo,Consolas,monospace;background:#1f2937;color:#fff;border-radius:4px;padding:1px 6px;' +
|
||||||
'left:' + (r.left + window.scrollX + r.width / 2) + 'px;top:' + (r.bottom + window.scrollY + 4) + 'px;transform:translateX(-50%)';
|
'left:' + (r.left + window.scrollX + r.width / 2) + 'px;top:' + (r.bottom + window.scrollY + 4) + 'px;transform:translateX(-50%)';
|
||||||
document.body.appendChild(tip);
|
document.body.appendChild(tip);
|
||||||
tips.push(tip);
|
tips.push(tip);
|
||||||
});
|
});
|
||||||
|
spread();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smalle knoppen naast elkaar geven hints die elkaar raken: schuif de volgende hint naar rechts, met een kleine tussenruimte.
|
||||||
|
function spread() {
|
||||||
|
var prevRight = -Infinity;
|
||||||
|
tips.slice().sort(function (a, b) { return a.getBoundingClientRect().left - b.getBoundingClientRect().left; })
|
||||||
|
.forEach(function (tip) {
|
||||||
|
var r = tip.getBoundingClientRect();
|
||||||
|
var shift = Math.max(0, prevRight + 4 - r.left);
|
||||||
|
if (shift) tip.style.left = (parseFloat(tip.style.left) + shift) + 'px';
|
||||||
|
prevRight = r.right + shift;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function hideTips() {
|
function hideTips() {
|
||||||
tips.forEach(function (t) { if (t.parentNode) t.parentNode.removeChild(t); });
|
tips.forEach(function (t) { if (t.parentNode) t.parentNode.removeChild(t); });
|
||||||
@@ -110,10 +102,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Control') { if (!e.repeat) showTips(); return; }
|
if (e.key === 'Control' || e.key === 'Meta' || e.key === 'Alt') { if (!e.repeat) showTips(); return; }
|
||||||
if (!e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
|
|
||||||
for (var i = 0; i < KEYS.length; i++) {
|
for (var i = 0; i < KEYS.length; i++) {
|
||||||
if (e.code !== KEYS[i].code) continue;
|
if (!KEYS[i].match(e)) continue;
|
||||||
var btn = findButton(KEYS[i]);
|
var btn = findButton(KEYS[i]);
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -122,7 +113,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
document.addEventListener('keyup', function (e) { if (e.key === 'Control') hideTips(); }, true);
|
document.addEventListener('keyup', function (e) { if (e.key === 'Control' || e.key === 'Meta' || e.key === 'Alt') hideTips(); }, true);
|
||||||
window.addEventListener('blur', hideTips);
|
window.addEventListener('blur', hideTips);
|
||||||
|
|
||||||
// De pagina bouwt de knoppen soms pas na een postback op: probeer het een paar keer.
|
// De pagina bouwt de knoppen soms pas na een postback op: probeer het een paar keer.
|
||||||
@@ -131,7 +122,7 @@
|
|||||||
var left = 0;
|
var left = 0;
|
||||||
KEYS.forEach(function (k) {
|
KEYS.forEach(function (k) {
|
||||||
var btn = findButton(k);
|
var btn = findButton(k);
|
||||||
if (btn) underline(btn, k); else left++;
|
if (btn) mark(btn, k); else left++;
|
||||||
});
|
});
|
||||||
if (left && ++tries < 30) setTimeout(init, 500);
|
if (left && ++tries < 30) setTimeout(init, 500);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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
|
## 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
|
```sh
|
||||||
./build.sh # genereert select-search.user.js en bookmarklet.txt
|
|
||||||
python3 -m http.server 8731 # open http://localhost:8731/test/fixture.html
|
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,
|
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
|
zie [Ontwikkelen](../../AGENTS.md#dev-stub-vs-gebruik). Hoog bij elke wijziging `@version` op.
|
||||||
naar `select-search.user.js`.
|
|
||||||
|
|
||||||
## Licentie
|
## Licentie
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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"
|
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenize(q) {
|
|
||||||
return normMap(q).n.split(/\s+/).filter(function (t) { return t.length > 0; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function debounce(fn, ms) {
|
|
||||||
var t = null;
|
|
||||||
return function () {
|
|
||||||
if (t) clearTimeout(t);
|
|
||||||
t = setTimeout(function () { t = null; fn(); }, ms);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------- 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"> </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==
|
|
||||||
Reference in New Issue
Block a user