fix(web): persistent live regions, scroll-aware diff TOC, ermite.js handlers
- live banners update children of a persistent role=status slot - an IntersectionObserver marks the diff file in view - live stream reloads once on 404/401 then stops reconnecting - server values reach evaluated attributes through data-* only - copy feedback updates aria-label and the hint region - header refs is a real link; JS-only controls hide without scripts - keyboard, filter and palette logic moves from hx-on blocks to ermite.js - one htmx:error listener surfaces handler errors
- commit
- 0e9351b3972ab03d7031cadebdb723e7604fbbf2
- author
- brice · · 2026-10-11 20:01 UTC
- parent
- 94354ea
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
61 files · +2013 -3079
M cmd/ermite/testdata/web/blob-binary.html +27 -73
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -229,7 +229,7 @@ | |||
| 229 | 229 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 230 | 230 | <div class="panel-head"> | |
| 231 | 231 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 232 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 232 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 233 | 233 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 234 | 234 | <kbd>esc</kbd> | |
| 235 | 235 | </button> | |
| @@ -264,13 +264,7 @@ | |||
| 264 | 264 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 265 | 265 | <div class="panel-head"> | |
| 266 | 266 | <label for="cmd-q" class="sr">go to</label> | |
| 267 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 268 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 269 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 270 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 271 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 272 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 273 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 267 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 274 | 268 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 275 | 269 | <kbd>esc</kbd> | |
| 276 | 270 | </button> | |
| @@ -341,67 +335,27 @@ | |||
| 341 | 335 | <kbd>esc</kbd> close</span> | |
| 342 | 336 | </p> | |
| 343 | 337 | </dialog> | |
| 344 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 345 | - | if (q('dialog[open]').count) return; | |
| 346 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 347 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 348 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 349 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 350 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 351 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 352 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 353 | - | })(1); | |
| 354 | - | k-k from:body -> (async (d) => { | |
| 355 | - | if (q('dialog[open]').count) return; | |
| 356 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 357 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 358 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 359 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 360 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 361 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 362 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 363 | - | })(-1); | |
| 364 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 365 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 366 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 367 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 368 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 369 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 370 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 371 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 372 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 373 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 374 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 375 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 376 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 377 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 378 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 379 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 380 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 381 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 382 | - | k-next from:body -> { | |
| 383 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 384 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 385 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 386 | - | }; | |
| 387 | - | k-split from:body -> { | |
| 388 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 389 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 390 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 391 | - | }; | |
| 392 | - | k-unified from:body -> { | |
| 393 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 394 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 395 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 396 | - | }; | |
| 397 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 398 | - | k-permalink from:body -> { | |
| 399 | - | let b = document.body.dataset; | |
| 400 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 401 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 402 | - | navigator.clipboard?.writeText(location.href); | |
| 403 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 404 | - | }"> | |
| 338 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 339 | + | k-k from:body -> ermite.move(-1); | |
| 340 | + | k-top from:body -> ermite.edge(false); | |
| 341 | + | k-bottom from:body -> ermite.edge(true); | |
| 342 | + | k-open from:body -> ermite.open(); | |
| 343 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 344 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 345 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 346 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 347 | + | k-filter from:body -> ermite.focusFilter(); | |
| 348 | + | k-gh from:body -> ermite.go('.brand'); | |
| 349 | + | k-s from:body -> ermite.tab('s'); | |
| 350 | + | k-t from:body -> ermite.tab('t'); | |
| 351 | + | k-c from:body -> ermite.tab('c'); | |
| 352 | + | k-r from:body -> ermite.tab('r'); | |
| 353 | + | k-m from:body -> ermite.tab('m'); | |
| 354 | + | k-next from:body -> ermite.nextNote(); | |
| 355 | + | k-split from:body -> ermite.layout('split'); | |
| 356 | + | k-unified from:body -> ermite.layout('unified'); | |
| 357 | + | k-collapse from:body -> ermite.collapse(); | |
| 358 | + | k-permalink from:body -> ermite.permalink()"> | |
| 405 | 359 | </div> | |
| 406 | 360 | </body> | |
| 407 | 361 | </html> | |
M cmd/ermite/testdata/web/blob-code.html +27 -73
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -299,7 +299,7 @@ | |||
| 299 | 299 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 300 | 300 | <div class="panel-head"> | |
| 301 | 301 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 302 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 302 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 303 | 303 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 304 | 304 | <kbd>esc</kbd> | |
| 305 | 305 | </button> | |
| @@ -334,13 +334,7 @@ | |||
| 334 | 334 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 335 | 335 | <div class="panel-head"> | |
| 336 | 336 | <label for="cmd-q" class="sr">go to</label> | |
| 337 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 338 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 339 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 340 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 341 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 342 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 343 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 337 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 344 | 338 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 345 | 339 | <kbd>esc</kbd> | |
| 346 | 340 | </button> | |
| @@ -411,67 +405,27 @@ | |||
| 411 | 405 | <kbd>esc</kbd> close</span> | |
| 412 | 406 | </p> | |
| 413 | 407 | </dialog> | |
| 414 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 415 | - | if (q('dialog[open]').count) return; | |
| 416 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 417 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 418 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 419 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 420 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 421 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 422 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 423 | - | })(1); | |
| 424 | - | k-k from:body -> (async (d) => { | |
| 425 | - | if (q('dialog[open]').count) return; | |
| 426 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 427 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 428 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 429 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 430 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 431 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 432 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 433 | - | })(-1); | |
| 434 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 435 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 436 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 437 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 438 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 439 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 440 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 441 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 442 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 443 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 444 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 445 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 446 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 447 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 448 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 449 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 450 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 451 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 452 | - | k-next from:body -> { | |
| 453 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 454 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 455 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 456 | - | }; | |
| 457 | - | k-split from:body -> { | |
| 458 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 459 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 460 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 461 | - | }; | |
| 462 | - | k-unified from:body -> { | |
| 463 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 464 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 465 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 466 | - | }; | |
| 467 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 468 | - | k-permalink from:body -> { | |
| 469 | - | let b = document.body.dataset; | |
| 470 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 471 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 472 | - | navigator.clipboard?.writeText(location.href); | |
| 473 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 474 | - | }"> | |
| 408 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 409 | + | k-k from:body -> ermite.move(-1); | |
| 410 | + | k-top from:body -> ermite.edge(false); | |
| 411 | + | k-bottom from:body -> ermite.edge(true); | |
| 412 | + | k-open from:body -> ermite.open(); | |
| 413 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 414 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 415 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 416 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 417 | + | k-filter from:body -> ermite.focusFilter(); | |
| 418 | + | k-gh from:body -> ermite.go('.brand'); | |
| 419 | + | k-s from:body -> ermite.tab('s'); | |
| 420 | + | k-t from:body -> ermite.tab('t'); | |
| 421 | + | k-c from:body -> ermite.tab('c'); | |
| 422 | + | k-r from:body -> ermite.tab('r'); | |
| 423 | + | k-m from:body -> ermite.tab('m'); | |
| 424 | + | k-next from:body -> ermite.nextNote(); | |
| 425 | + | k-split from:body -> ermite.layout('split'); | |
| 426 | + | k-unified from:body -> ermite.layout('unified'); | |
| 427 | + | k-collapse from:body -> ermite.collapse(); | |
| 428 | + | k-permalink from:body -> ermite.permalink()"> | |
| 475 | 429 | </div> | |
| 476 | 430 | </body> | |
| 477 | 431 | </html> | |
M cmd/ermite/testdata/web/blob-empty.html +27 -73
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -229,7 +229,7 @@ | |||
| 229 | 229 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 230 | 230 | <div class="panel-head"> | |
| 231 | 231 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 232 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 232 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 233 | 233 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 234 | 234 | <kbd>esc</kbd> | |
| 235 | 235 | </button> | |
| @@ -264,13 +264,7 @@ | |||
| 264 | 264 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 265 | 265 | <div class="panel-head"> | |
| 266 | 266 | <label for="cmd-q" class="sr">go to</label> | |
| 267 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 268 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 269 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 270 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 271 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 272 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 273 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 267 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 274 | 268 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 275 | 269 | <kbd>esc</kbd> | |
| 276 | 270 | </button> | |
| @@ -341,67 +335,27 @@ | |||
| 341 | 335 | <kbd>esc</kbd> close</span> | |
| 342 | 336 | </p> | |
| 343 | 337 | </dialog> | |
| 344 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 345 | - | if (q('dialog[open]').count) return; | |
| 346 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 347 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 348 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 349 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 350 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 351 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 352 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 353 | - | })(1); | |
| 354 | - | k-k from:body -> (async (d) => { | |
| 355 | - | if (q('dialog[open]').count) return; | |
| 356 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 357 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 358 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 359 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 360 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 361 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 362 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 363 | - | })(-1); | |
| 364 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 365 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 366 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 367 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 368 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 369 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 370 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 371 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 372 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 373 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 374 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 375 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 376 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 377 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 378 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 379 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 380 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 381 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 382 | - | k-next from:body -> { | |
| 383 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 384 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 385 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 386 | - | }; | |
| 387 | - | k-split from:body -> { | |
| 388 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 389 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 390 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 391 | - | }; | |
| 392 | - | k-unified from:body -> { | |
| 393 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 394 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 395 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 396 | - | }; | |
| 397 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 398 | - | k-permalink from:body -> { | |
| 399 | - | let b = document.body.dataset; | |
| 400 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 401 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 402 | - | navigator.clipboard?.writeText(location.href); | |
| 403 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 404 | - | }"> | |
| 338 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 339 | + | k-k from:body -> ermite.move(-1); | |
| 340 | + | k-top from:body -> ermite.edge(false); | |
| 341 | + | k-bottom from:body -> ermite.edge(true); | |
| 342 | + | k-open from:body -> ermite.open(); | |
| 343 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 344 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 345 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 346 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 347 | + | k-filter from:body -> ermite.focusFilter(); | |
| 348 | + | k-gh from:body -> ermite.go('.brand'); | |
| 349 | + | k-s from:body -> ermite.tab('s'); | |
| 350 | + | k-t from:body -> ermite.tab('t'); | |
| 351 | + | k-c from:body -> ermite.tab('c'); | |
| 352 | + | k-r from:body -> ermite.tab('r'); | |
| 353 | + | k-m from:body -> ermite.tab('m'); | |
| 354 | + | k-next from:body -> ermite.nextNote(); | |
| 355 | + | k-split from:body -> ermite.layout('split'); | |
| 356 | + | k-unified from:body -> ermite.layout('unified'); | |
| 357 | + | k-collapse from:body -> ermite.collapse(); | |
| 358 | + | k-permalink from:body -> ermite.permalink()"> | |
| 405 | 359 | </div> | |
| 406 | 360 | </body> | |
| 407 | 361 | </html> | |
M cmd/ermite/testdata/web/blob-image.html +27 -73
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -231,7 +231,7 @@ | |||
| 231 | 231 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 232 | 232 | <div class="panel-head"> | |
| 233 | 233 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 234 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 234 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 235 | 235 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 236 | 236 | <kbd>esc</kbd> | |
| 237 | 237 | </button> | |
| @@ -266,13 +266,7 @@ | |||
| 266 | 266 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 267 | 267 | <div class="panel-head"> | |
| 268 | 268 | <label for="cmd-q" class="sr">go to</label> | |
| 269 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 270 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 271 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 272 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 273 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 274 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 275 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 269 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 276 | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 277 | 271 | <kbd>esc</kbd> | |
| 278 | 272 | </button> | |
| @@ -343,67 +337,27 @@ | |||
| 343 | 337 | <kbd>esc</kbd> close</span> | |
| 344 | 338 | </p> | |
| 345 | 339 | </dialog> | |
| 346 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 347 | - | if (q('dialog[open]').count) return; | |
| 348 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 349 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 350 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 351 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 352 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 353 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 354 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 355 | - | })(1); | |
| 356 | - | k-k from:body -> (async (d) => { | |
| 357 | - | if (q('dialog[open]').count) return; | |
| 358 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 359 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 360 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 361 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 362 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 363 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 364 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 365 | - | })(-1); | |
| 366 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 367 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 368 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 369 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 370 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 371 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 372 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 373 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 374 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 375 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 376 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 377 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 378 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 379 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 380 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 381 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 382 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 383 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 384 | - | k-next from:body -> { | |
| 385 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 386 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 387 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 388 | - | }; | |
| 389 | - | k-split from:body -> { | |
| 390 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 391 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 392 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 393 | - | }; | |
| 394 | - | k-unified from:body -> { | |
| 395 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 396 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 397 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 398 | - | }; | |
| 399 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 400 | - | k-permalink from:body -> { | |
| 401 | - | let b = document.body.dataset; | |
| 402 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 403 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 404 | - | navigator.clipboard?.writeText(location.href); | |
| 405 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 406 | - | }"> | |
| 340 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 341 | + | k-k from:body -> ermite.move(-1); | |
| 342 | + | k-top from:body -> ermite.edge(false); | |
| 343 | + | k-bottom from:body -> ermite.edge(true); | |
| 344 | + | k-open from:body -> ermite.open(); | |
| 345 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 346 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 347 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 348 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 349 | + | k-filter from:body -> ermite.focusFilter(); | |
| 350 | + | k-gh from:body -> ermite.go('.brand'); | |
| 351 | + | k-s from:body -> ermite.tab('s'); | |
| 352 | + | k-t from:body -> ermite.tab('t'); | |
| 353 | + | k-c from:body -> ermite.tab('c'); | |
| 354 | + | k-r from:body -> ermite.tab('r'); | |
| 355 | + | k-m from:body -> ermite.tab('m'); | |
| 356 | + | k-next from:body -> ermite.nextNote(); | |
| 357 | + | k-split from:body -> ermite.layout('split'); | |
| 358 | + | k-unified from:body -> ermite.layout('unified'); | |
| 359 | + | k-collapse from:body -> ermite.collapse(); | |
| 360 | + | k-permalink from:body -> ermite.permalink()"> | |
| 407 | 361 | </div> | |
| 408 | 362 | </body> | |
| 409 | 363 | </html> | |
M cmd/ermite/testdata/web/blob-markdown.html +27 -73
| @@ -47,19 +47,19 @@ | |||
| 47 | 47 | </ol> | |
| 48 | 48 | </nav> | |
| 49 | 49 | <div class="hdr-acts"> | |
| 50 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 50 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 51 | 51 | <span class="ico i-branch" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="ref-name">main</span> | |
| 54 | 54 | <span class="sha">SHA7</span> | |
| 55 | - | </button> | |
| 56 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 55 | + | </a> | |
| 56 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 57 | 57 | <span class="ico i-search" aria-hidden="true"> | |
| 58 | 58 | </span> | |
| 59 | 59 | <span class="search-label">Go to…</span> | |
| 60 | 60 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 61 | 61 | </button> | |
| 62 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 62 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 63 | 63 | </div> | |
| 64 | 64 | </div> | |
| 65 | 65 | <nav class="tabs" aria-label="Sections"> | |
| @@ -296,7 +296,7 @@ | |||
| 296 | 296 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 297 | 297 | <div class="panel-head"> | |
| 298 | 298 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 299 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 299 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 300 | 300 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 301 | 301 | <kbd>esc</kbd> | |
| 302 | 302 | </button> | |
| @@ -331,13 +331,7 @@ | |||
| 331 | 331 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 332 | 332 | <div class="panel-head"> | |
| 333 | 333 | <label for="cmd-q" class="sr">go to</label> | |
| 334 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 335 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 336 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 337 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 338 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 339 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 340 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 334 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 341 | 335 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 342 | 336 | <kbd>esc</kbd> | |
| 343 | 337 | </button> | |
| @@ -408,67 +402,27 @@ | |||
| 408 | 402 | <kbd>esc</kbd> close</span> | |
| 409 | 403 | </p> | |
| 410 | 404 | </dialog> | |
| 411 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 412 | - | if (q('dialog[open]').count) return; | |
| 413 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 414 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 415 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 416 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 417 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 418 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 419 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 420 | - | })(1); | |
| 421 | - | k-k from:body -> (async (d) => { | |
| 422 | - | if (q('dialog[open]').count) return; | |
| 423 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 424 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 425 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 426 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 427 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 428 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 429 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 430 | - | })(-1); | |
| 431 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 432 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 433 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 434 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 435 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 436 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 437 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 438 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 439 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 440 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 441 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 442 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 443 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 444 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 445 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 446 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 447 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 448 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 449 | - | k-next from:body -> { | |
| 450 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 451 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 452 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 453 | - | }; | |
| 454 | - | k-split from:body -> { | |
| 455 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 456 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 457 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 458 | - | }; | |
| 459 | - | k-unified from:body -> { | |
| 460 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 461 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 462 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 463 | - | }; | |
| 464 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 465 | - | k-permalink from:body -> { | |
| 466 | - | let b = document.body.dataset; | |
| 467 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 468 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 469 | - | navigator.clipboard?.writeText(location.href); | |
| 470 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 471 | - | }"> | |
| 405 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 406 | + | k-k from:body -> ermite.move(-1); | |
| 407 | + | k-top from:body -> ermite.edge(false); | |
| 408 | + | k-bottom from:body -> ermite.edge(true); | |
| 409 | + | k-open from:body -> ermite.open(); | |
| 410 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 411 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 412 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 413 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 414 | + | k-filter from:body -> ermite.focusFilter(); | |
| 415 | + | k-gh from:body -> ermite.go('.brand'); | |
| 416 | + | k-s from:body -> ermite.tab('s'); | |
| 417 | + | k-t from:body -> ermite.tab('t'); | |
| 418 | + | k-c from:body -> ermite.tab('c'); | |
| 419 | + | k-r from:body -> ermite.tab('r'); | |
| 420 | + | k-m from:body -> ermite.tab('m'); | |
| 421 | + | k-next from:body -> ermite.nextNote(); | |
| 422 | + | k-split from:body -> ermite.layout('split'); | |
| 423 | + | k-unified from:body -> ermite.layout('unified'); | |
| 424 | + | k-collapse from:body -> ermite.collapse(); | |
| 425 | + | k-permalink from:body -> ermite.permalink()"> | |
| 472 | 426 | </div> | |
| 473 | 427 | </body> | |
| 474 | 428 | </html> | |
M cmd/ermite/testdata/web/blob-svg.html +27 -73
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -254,7 +254,7 @@ | |||
| 254 | 254 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 255 | 255 | <div class="panel-head"> | |
| 256 | 256 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 257 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 257 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 258 | 258 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 259 | 259 | <kbd>esc</kbd> | |
| 260 | 260 | </button> | |
| @@ -289,13 +289,7 @@ | |||
| 289 | 289 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 290 | 290 | <div class="panel-head"> | |
| 291 | 291 | <label for="cmd-q" class="sr">go to</label> | |
| 292 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 293 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 294 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 295 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 296 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 297 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 298 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 292 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 299 | 293 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 300 | 294 | <kbd>esc</kbd> | |
| 301 | 295 | </button> | |
| @@ -366,67 +360,27 @@ | |||
| 366 | 360 | <kbd>esc</kbd> close</span> | |
| 367 | 361 | </p> | |
| 368 | 362 | </dialog> | |
| 369 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 370 | - | if (q('dialog[open]').count) return; | |
| 371 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 372 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 373 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 374 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 375 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 376 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 377 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 378 | - | })(1); | |
| 379 | - | k-k from:body -> (async (d) => { | |
| 380 | - | if (q('dialog[open]').count) return; | |
| 381 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 382 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 383 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 384 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 385 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 386 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 387 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 388 | - | })(-1); | |
| 389 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 390 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 391 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 392 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 393 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 394 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 395 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 396 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 397 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 398 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 399 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 400 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 401 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 402 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 403 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 404 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 405 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 406 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 407 | - | k-next from:body -> { | |
| 408 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 409 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 410 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 411 | - | }; | |
| 412 | - | k-split from:body -> { | |
| 413 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 414 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 415 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 416 | - | }; | |
| 417 | - | k-unified from:body -> { | |
| 418 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 419 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 420 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 421 | - | }; | |
| 422 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 423 | - | k-permalink from:body -> { | |
| 424 | - | let b = document.body.dataset; | |
| 425 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 426 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 427 | - | navigator.clipboard?.writeText(location.href); | |
| 428 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 429 | - | }"> | |
| 363 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 364 | + | k-k from:body -> ermite.move(-1); | |
| 365 | + | k-top from:body -> ermite.edge(false); | |
| 366 | + | k-bottom from:body -> ermite.edge(true); | |
| 367 | + | k-open from:body -> ermite.open(); | |
| 368 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 369 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 370 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 371 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 372 | + | k-filter from:body -> ermite.focusFilter(); | |
| 373 | + | k-gh from:body -> ermite.go('.brand'); | |
| 374 | + | k-s from:body -> ermite.tab('s'); | |
| 375 | + | k-t from:body -> ermite.tab('t'); | |
| 376 | + | k-c from:body -> ermite.tab('c'); | |
| 377 | + | k-r from:body -> ermite.tab('r'); | |
| 378 | + | k-m from:body -> ermite.tab('m'); | |
| 379 | + | k-next from:body -> ermite.nextNote(); | |
| 380 | + | k-split from:body -> ermite.layout('split'); | |
| 381 | + | k-unified from:body -> ermite.layout('unified'); | |
| 382 | + | k-collapse from:body -> ermite.collapse(); | |
| 383 | + | k-permalink from:body -> ermite.permalink()"> | |
| 430 | 384 | </div> | |
| 431 | 385 | </body> | |
| 432 | 386 | </html> | |
M cmd/ermite/testdata/web/blob-text.html +27 -73
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -249,7 +249,7 @@ | |||
| 249 | 249 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 250 | 250 | <div class="panel-head"> | |
| 251 | 251 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 252 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 252 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 253 | 253 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 254 | 254 | <kbd>esc</kbd> | |
| 255 | 255 | </button> | |
| @@ -284,13 +284,7 @@ | |||
| 284 | 284 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 285 | 285 | <div class="panel-head"> | |
| 286 | 286 | <label for="cmd-q" class="sr">go to</label> | |
| 287 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 288 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 289 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 290 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 291 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 292 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 293 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 287 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 294 | 288 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 295 | 289 | <kbd>esc</kbd> | |
| 296 | 290 | </button> | |
| @@ -361,67 +355,27 @@ | |||
| 361 | 355 | <kbd>esc</kbd> close</span> | |
| 362 | 356 | </p> | |
| 363 | 357 | </dialog> | |
| 364 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 365 | - | if (q('dialog[open]').count) return; | |
| 366 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 367 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 368 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 369 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 370 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 371 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 372 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 373 | - | })(1); | |
| 374 | - | k-k from:body -> (async (d) => { | |
| 375 | - | if (q('dialog[open]').count) return; | |
| 376 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 377 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 378 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 379 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 380 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 381 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 382 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 383 | - | })(-1); | |
| 384 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 385 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 386 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 387 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 388 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 389 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 390 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 391 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 392 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 393 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 394 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 395 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 396 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 397 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 398 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 399 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 400 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 401 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 402 | - | k-next from:body -> { | |
| 403 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 404 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 405 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 406 | - | }; | |
| 407 | - | k-split from:body -> { | |
| 408 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 409 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 410 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 411 | - | }; | |
| 412 | - | k-unified from:body -> { | |
| 413 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 414 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 415 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 416 | - | }; | |
| 417 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 418 | - | k-permalink from:body -> { | |
| 419 | - | let b = document.body.dataset; | |
| 420 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 421 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 422 | - | navigator.clipboard?.writeText(location.href); | |
| 423 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 424 | - | }"> | |
| 358 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 359 | + | k-k from:body -> ermite.move(-1); | |
| 360 | + | k-top from:body -> ermite.edge(false); | |
| 361 | + | k-bottom from:body -> ermite.edge(true); | |
| 362 | + | k-open from:body -> ermite.open(); | |
| 363 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 364 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 365 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 366 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 367 | + | k-filter from:body -> ermite.focusFilter(); | |
| 368 | + | k-gh from:body -> ermite.go('.brand'); | |
| 369 | + | k-s from:body -> ermite.tab('s'); | |
| 370 | + | k-t from:body -> ermite.tab('t'); | |
| 371 | + | k-c from:body -> ermite.tab('c'); | |
| 372 | + | k-r from:body -> ermite.tab('r'); | |
| 373 | + | k-m from:body -> ermite.tab('m'); | |
| 374 | + | k-next from:body -> ermite.nextNote(); | |
| 375 | + | k-split from:body -> ermite.layout('split'); | |
| 376 | + | k-unified from:body -> ermite.layout('unified'); | |
| 377 | + | k-collapse from:body -> ermite.collapse(); | |
| 378 | + | k-permalink from:body -> ermite.permalink()"> | |
| 425 | 379 | </div> | |
| 426 | 380 | </body> | |
| 427 | 381 | </html> | |
M cmd/ermite/testdata/web/blob-too-large.html +27 -73
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -229,7 +229,7 @@ | |||
| 229 | 229 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 230 | 230 | <div class="panel-head"> | |
| 231 | 231 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 232 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 232 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 233 | 233 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 234 | 234 | <kbd>esc</kbd> | |
| 235 | 235 | </button> | |
| @@ -264,13 +264,7 @@ | |||
| 264 | 264 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 265 | 265 | <div class="panel-head"> | |
| 266 | 266 | <label for="cmd-q" class="sr">go to</label> | |
| 267 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 268 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 269 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 270 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 271 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 272 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 273 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 267 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 274 | 268 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 275 | 269 | <kbd>esc</kbd> | |
| 276 | 270 | </button> | |
| @@ -341,67 +335,27 @@ | |||
| 341 | 335 | <kbd>esc</kbd> close</span> | |
| 342 | 336 | </p> | |
| 343 | 337 | </dialog> | |
| 344 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 345 | - | if (q('dialog[open]').count) return; | |
| 346 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 347 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 348 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 349 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 350 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 351 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 352 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 353 | - | })(1); | |
| 354 | - | k-k from:body -> (async (d) => { | |
| 355 | - | if (q('dialog[open]').count) return; | |
| 356 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 357 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 358 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 359 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 360 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 361 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 362 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 363 | - | })(-1); | |
| 364 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 365 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 366 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 367 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 368 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 369 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 370 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 371 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 372 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 373 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 374 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 375 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 376 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 377 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 378 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 379 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 380 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 381 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 382 | - | k-next from:body -> { | |
| 383 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 384 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 385 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 386 | - | }; | |
| 387 | - | k-split from:body -> { | |
| 388 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 389 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 390 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 391 | - | }; | |
| 392 | - | k-unified from:body -> { | |
| 393 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 394 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 395 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 396 | - | }; | |
| 397 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 398 | - | k-permalink from:body -> { | |
| 399 | - | let b = document.body.dataset; | |
| 400 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 401 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 402 | - | navigator.clipboard?.writeText(location.href); | |
| 403 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 404 | - | }"> | |
| 338 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 339 | + | k-k from:body -> ermite.move(-1); | |
| 340 | + | k-top from:body -> ermite.edge(false); | |
| 341 | + | k-bottom from:body -> ermite.edge(true); | |
| 342 | + | k-open from:body -> ermite.open(); | |
| 343 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 344 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 345 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 346 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 347 | + | k-filter from:body -> ermite.focusFilter(); | |
| 348 | + | k-gh from:body -> ermite.go('.brand'); | |
| 349 | + | k-s from:body -> ermite.tab('s'); | |
| 350 | + | k-t from:body -> ermite.tab('t'); | |
| 351 | + | k-c from:body -> ermite.tab('c'); | |
| 352 | + | k-r from:body -> ermite.tab('r'); | |
| 353 | + | k-m from:body -> ermite.tab('m'); | |
| 354 | + | k-next from:body -> ermite.nextNote(); | |
| 355 | + | k-split from:body -> ermite.layout('split'); | |
| 356 | + | k-unified from:body -> ermite.layout('unified'); | |
| 357 | + | k-collapse from:body -> ermite.collapse(); | |
| 358 | + | k-permalink from:body -> ermite.permalink()"> | |
| 405 | 359 | </div> | |
| 406 | 360 | </body> | |
| 407 | 361 | </html> | |
M cmd/ermite/testdata/web/blob-trojan.html +27 -73
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -296,7 +296,7 @@ | |||
| 296 | 296 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 297 | 297 | <div class="panel-head"> | |
| 298 | 298 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 299 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 299 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 300 | 300 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 301 | 301 | <kbd>esc</kbd> | |
| 302 | 302 | </button> | |
| @@ -331,13 +331,7 @@ | |||
| 331 | 331 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 332 | 332 | <div class="panel-head"> | |
| 333 | 333 | <label for="cmd-q" class="sr">go to</label> | |
| 334 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 335 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 336 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 337 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 338 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 339 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 340 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 334 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 341 | 335 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 342 | 336 | <kbd>esc</kbd> | |
| 343 | 337 | </button> | |
| @@ -408,67 +402,27 @@ | |||
| 408 | 402 | <kbd>esc</kbd> close</span> | |
| 409 | 403 | </p> | |
| 410 | 404 | </dialog> | |
| 411 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 412 | - | if (q('dialog[open]').count) return; | |
| 413 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 414 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 415 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 416 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 417 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 418 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 419 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 420 | - | })(1); | |
| 421 | - | k-k from:body -> (async (d) => { | |
| 422 | - | if (q('dialog[open]').count) return; | |
| 423 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 424 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 425 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 426 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 427 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 428 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 429 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 430 | - | })(-1); | |
| 431 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 432 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 433 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 434 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 435 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 436 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 437 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 438 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 439 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 440 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 441 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 442 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 443 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 444 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 445 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 446 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 447 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 448 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 449 | - | k-next from:body -> { | |
| 450 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 451 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 452 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 453 | - | }; | |
| 454 | - | k-split from:body -> { | |
| 455 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 456 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 457 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 458 | - | }; | |
| 459 | - | k-unified from:body -> { | |
| 460 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 461 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 462 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 463 | - | }; | |
| 464 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 465 | - | k-permalink from:body -> { | |
| 466 | - | let b = document.body.dataset; | |
| 467 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 468 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 469 | - | navigator.clipboard?.writeText(location.href); | |
| 470 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 471 | - | }"> | |
| 405 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 406 | + | k-k from:body -> ermite.move(-1); | |
| 407 | + | k-top from:body -> ermite.edge(false); | |
| 408 | + | k-bottom from:body -> ermite.edge(true); | |
| 409 | + | k-open from:body -> ermite.open(); | |
| 410 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 411 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 412 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 413 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 414 | + | k-filter from:body -> ermite.focusFilter(); | |
| 415 | + | k-gh from:body -> ermite.go('.brand'); | |
| 416 | + | k-s from:body -> ermite.tab('s'); | |
| 417 | + | k-t from:body -> ermite.tab('t'); | |
| 418 | + | k-c from:body -> ermite.tab('c'); | |
| 419 | + | k-r from:body -> ermite.tab('r'); | |
| 420 | + | k-m from:body -> ermite.tab('m'); | |
| 421 | + | k-next from:body -> ermite.nextNote(); | |
| 422 | + | k-split from:body -> ermite.layout('split'); | |
| 423 | + | k-unified from:body -> ermite.layout('unified'); | |
| 424 | + | k-collapse from:body -> ermite.collapse(); | |
| 425 | + | k-permalink from:body -> ermite.permalink()"> | |
| 472 | 426 | </div> | |
| 473 | 427 | </body> | |
| 474 | 428 | </html> | |
M cmd/ermite/testdata/web/commit-big.html +31 -77
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">detached</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -106,11 +106,11 @@ | |||
| 106 | 106 | </span> | |
| 107 | 107 | <span class="grow"> | |
| 108 | 108 | </span> | |
| 109 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 109 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 110 | 110 | </a> | |
| 111 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 111 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 112 | 112 | </a> | |
| 113 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 113 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 114 | 114 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 115 | 115 | <kbd aria-hidden="true">x</kbd> | |
| 116 | 116 | </button> | |
| @@ -135,7 +135,7 @@ | |||
| 135 | 135 | </h2> | |
| 136 | 136 | <ol class="files" aria-label="Changed files"> | |
| 137 | 137 | <li> | |
| 138 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 138 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 139 | 139 | <span class="st" title="A">A</span> | |
| 140 | 140 | <span class="p" title="big.txt">big.txt</span> | |
| 141 | 141 | <span class="n"> | |
| @@ -266,7 +266,7 @@ | |||
| 266 | 266 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 267 | 267 | <div class="panel-head"> | |
| 268 | 268 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 269 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 269 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 270 | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 271 | 271 | <kbd>esc</kbd> | |
| 272 | 272 | </button> | |
| @@ -301,13 +301,7 @@ | |||
| 301 | 301 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 302 | 302 | <div class="panel-head"> | |
| 303 | 303 | <label for="cmd-q" class="sr">go to</label> | |
| 304 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 305 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 306 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 307 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 308 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 309 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 310 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 304 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 311 | 305 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 312 | 306 | <kbd>esc</kbd> | |
| 313 | 307 | </button> | |
| @@ -378,67 +372,27 @@ | |||
| 378 | 372 | <kbd>esc</kbd> close</span> | |
| 379 | 373 | </p> | |
| 380 | 374 | </dialog> | |
| 381 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 382 | - | if (q('dialog[open]').count) return; | |
| 383 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 384 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 385 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 386 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 387 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 388 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 389 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 390 | - | })(1); | |
| 391 | - | k-k from:body -> (async (d) => { | |
| 392 | - | if (q('dialog[open]').count) return; | |
| 393 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 394 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 395 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 396 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 397 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 398 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 399 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 400 | - | })(-1); | |
| 401 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 402 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 403 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 404 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 405 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 406 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 407 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 408 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 409 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 410 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 411 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 412 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 413 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 414 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 415 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 416 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 417 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 418 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 419 | - | k-next from:body -> { | |
| 420 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 421 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 422 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 423 | - | }; | |
| 424 | - | k-split from:body -> { | |
| 425 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 426 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 427 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 428 | - | }; | |
| 429 | - | k-unified from:body -> { | |
| 430 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 431 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 432 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 433 | - | }; | |
| 434 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 435 | - | k-permalink from:body -> { | |
| 436 | - | let b = document.body.dataset; | |
| 437 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 438 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 439 | - | navigator.clipboard?.writeText(location.href); | |
| 440 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 441 | - | }"> | |
| 375 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 376 | + | k-k from:body -> ermite.move(-1); | |
| 377 | + | k-top from:body -> ermite.edge(false); | |
| 378 | + | k-bottom from:body -> ermite.edge(true); | |
| 379 | + | k-open from:body -> ermite.open(); | |
| 380 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 381 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 382 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 383 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 384 | + | k-filter from:body -> ermite.focusFilter(); | |
| 385 | + | k-gh from:body -> ermite.go('.brand'); | |
| 386 | + | k-s from:body -> ermite.tab('s'); | |
| 387 | + | k-t from:body -> ermite.tab('t'); | |
| 388 | + | k-c from:body -> ermite.tab('c'); | |
| 389 | + | k-r from:body -> ermite.tab('r'); | |
| 390 | + | k-m from:body -> ermite.tab('m'); | |
| 391 | + | k-next from:body -> ermite.nextNote(); | |
| 392 | + | k-split from:body -> ermite.layout('split'); | |
| 393 | + | k-unified from:body -> ermite.layout('unified'); | |
| 394 | + | k-collapse from:body -> ermite.collapse(); | |
| 395 | + | k-permalink from:body -> ermite.permalink()"> | |
| 442 | 396 | </div> | |
| 443 | 397 | </body> | |
| 444 | 398 | </html> | |
M cmd/ermite/testdata/web/commit-binary.html +31 -77
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">detached</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -102,11 +102,11 @@ | |||
| 102 | 102 | </span> | |
| 103 | 103 | <span class="grow"> | |
| 104 | 104 | </span> | |
| 105 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 105 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 106 | 106 | </a> | |
| 107 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 107 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 108 | 108 | </a> | |
| 109 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 109 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 110 | 110 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 111 | 111 | <kbd aria-hidden="true">x</kbd> | |
| 112 | 112 | </button> | |
| @@ -130,7 +130,7 @@ | |||
| 130 | 130 | </h2> | |
| 131 | 131 | <ol class="files" aria-label="Changed files"> | |
| 132 | 132 | <li> | |
| 133 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 133 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 134 | 134 | <span class="st" title="M">M</span> | |
| 135 | 135 | <span class="p" title="logo.bin">logo.bin</span> | |
| 136 | 136 | <span class="n"> | |
| @@ -261,7 +261,7 @@ | |||
| 261 | 261 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 262 | 262 | <div class="panel-head"> | |
| 263 | 263 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 264 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 264 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 265 | 265 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 266 | 266 | <kbd>esc</kbd> | |
| 267 | 267 | </button> | |
| @@ -296,13 +296,7 @@ | |||
| 296 | 296 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 297 | 297 | <div class="panel-head"> | |
| 298 | 298 | <label for="cmd-q" class="sr">go to</label> | |
| 299 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 300 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 301 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 302 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 303 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 304 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 305 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 299 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 306 | 300 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 307 | 301 | <kbd>esc</kbd> | |
| 308 | 302 | </button> | |
| @@ -373,67 +367,27 @@ | |||
| 373 | 367 | <kbd>esc</kbd> close</span> | |
| 374 | 368 | </p> | |
| 375 | 369 | </dialog> | |
| 376 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 377 | - | if (q('dialog[open]').count) return; | |
| 378 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 379 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 380 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 381 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 382 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 383 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 384 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 385 | - | })(1); | |
| 386 | - | k-k from:body -> (async (d) => { | |
| 387 | - | if (q('dialog[open]').count) return; | |
| 388 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 389 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 390 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 391 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 392 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 393 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 394 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 395 | - | })(-1); | |
| 396 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 397 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 398 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 399 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 400 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 401 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 402 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 403 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 404 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 405 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 406 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 407 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 408 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 409 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 410 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 411 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 412 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 413 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 414 | - | k-next from:body -> { | |
| 415 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 416 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 417 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 418 | - | }; | |
| 419 | - | k-split from:body -> { | |
| 420 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 421 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 422 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 423 | - | }; | |
| 424 | - | k-unified from:body -> { | |
| 425 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 426 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 427 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 428 | - | }; | |
| 429 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 430 | - | k-permalink from:body -> { | |
| 431 | - | let b = document.body.dataset; | |
| 432 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 433 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 434 | - | navigator.clipboard?.writeText(location.href); | |
| 435 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 436 | - | }"> | |
| 370 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 371 | + | k-k from:body -> ermite.move(-1); | |
| 372 | + | k-top from:body -> ermite.edge(false); | |
| 373 | + | k-bottom from:body -> ermite.edge(true); | |
| 374 | + | k-open from:body -> ermite.open(); | |
| 375 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 376 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 377 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 378 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 379 | + | k-filter from:body -> ermite.focusFilter(); | |
| 380 | + | k-gh from:body -> ermite.go('.brand'); | |
| 381 | + | k-s from:body -> ermite.tab('s'); | |
| 382 | + | k-t from:body -> ermite.tab('t'); | |
| 383 | + | k-c from:body -> ermite.tab('c'); | |
| 384 | + | k-r from:body -> ermite.tab('r'); | |
| 385 | + | k-m from:body -> ermite.tab('m'); | |
| 386 | + | k-next from:body -> ermite.nextNote(); | |
| 387 | + | k-split from:body -> ermite.layout('split'); | |
| 388 | + | k-unified from:body -> ermite.layout('unified'); | |
| 389 | + | k-collapse from:body -> ermite.collapse(); | |
| 390 | + | k-permalink from:body -> ermite.permalink()"> | |
| 437 | 391 | </div> | |
| 438 | 392 | </body> | |
| 439 | 393 | </html> | |
M cmd/ermite/testdata/web/commit-merge.html +31 -77
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">detached</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -101,11 +101,11 @@ | |||
| 101 | 101 | </span> | |
| 102 | 102 | <span class="grow"> | |
| 103 | 103 | </span> | |
| 104 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 104 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 105 | 105 | </a> | |
| 106 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 106 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 107 | 107 | </a> | |
| 108 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 108 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 109 | 109 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 110 | 110 | <kbd aria-hidden="true">x</kbd> | |
| 111 | 111 | </button> | |
| @@ -169,7 +169,7 @@ | |||
| 169 | 169 | </h2> | |
| 170 | 170 | <ol class="files" aria-label="Changed files"> | |
| 171 | 171 | <li> | |
| 172 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 172 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 173 | 173 | <span class="st" title="A">A</span> | |
| 174 | 174 | <span class="p" title="side.txt">side.txt</span> | |
| 175 | 175 | <span class="n"> | |
| @@ -300,7 +300,7 @@ | |||
| 300 | 300 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 301 | 301 | <div class="panel-head"> | |
| 302 | 302 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 303 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 303 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 304 | 304 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 305 | 305 | <kbd>esc</kbd> | |
| 306 | 306 | </button> | |
| @@ -335,13 +335,7 @@ | |||
| 335 | 335 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 336 | 336 | <div class="panel-head"> | |
| 337 | 337 | <label for="cmd-q" class="sr">go to</label> | |
| 338 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 339 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 340 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 341 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 342 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 343 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 344 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 338 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 345 | 339 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 346 | 340 | <kbd>esc</kbd> | |
| 347 | 341 | </button> | |
| @@ -412,67 +406,27 @@ | |||
| 412 | 406 | <kbd>esc</kbd> close</span> | |
| 413 | 407 | </p> | |
| 414 | 408 | </dialog> | |
| 415 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 416 | - | if (q('dialog[open]').count) return; | |
| 417 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 418 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 419 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 420 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 421 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 422 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 423 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 424 | - | })(1); | |
| 425 | - | k-k from:body -> (async (d) => { | |
| 426 | - | if (q('dialog[open]').count) return; | |
| 427 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 428 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 429 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 430 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 431 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 432 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 433 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 434 | - | })(-1); | |
| 435 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 436 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 437 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 438 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 439 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 440 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 441 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 442 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 443 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 444 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 445 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 446 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 447 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 448 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 449 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 450 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 451 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 452 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 453 | - | k-next from:body -> { | |
| 454 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 455 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 456 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 457 | - | }; | |
| 458 | - | k-split from:body -> { | |
| 459 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 460 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 461 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 462 | - | }; | |
| 463 | - | k-unified from:body -> { | |
| 464 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 465 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 466 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 467 | - | }; | |
| 468 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 469 | - | k-permalink from:body -> { | |
| 470 | - | let b = document.body.dataset; | |
| 471 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 472 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 473 | - | navigator.clipboard?.writeText(location.href); | |
| 474 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 475 | - | }"> | |
| 409 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 410 | + | k-k from:body -> ermite.move(-1); | |
| 411 | + | k-top from:body -> ermite.edge(false); | |
| 412 | + | k-bottom from:body -> ermite.edge(true); | |
| 413 | + | k-open from:body -> ermite.open(); | |
| 414 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 415 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 416 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 417 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 418 | + | k-filter from:body -> ermite.focusFilter(); | |
| 419 | + | k-gh from:body -> ermite.go('.brand'); | |
| 420 | + | k-s from:body -> ermite.tab('s'); | |
| 421 | + | k-t from:body -> ermite.tab('t'); | |
| 422 | + | k-c from:body -> ermite.tab('c'); | |
| 423 | + | k-r from:body -> ermite.tab('r'); | |
| 424 | + | k-m from:body -> ermite.tab('m'); | |
| 425 | + | k-next from:body -> ermite.nextNote(); | |
| 426 | + | k-split from:body -> ermite.layout('split'); | |
| 427 | + | k-unified from:body -> ermite.layout('unified'); | |
| 428 | + | k-collapse from:body -> ermite.collapse(); | |
| 429 | + | k-permalink from:body -> ermite.permalink()"> | |
| 476 | 430 | </div> | |
| 477 | 431 | </body> | |
| 478 | 432 | </html> | |
M cmd/ermite/testdata/web/commit-modify-split.html +32 -78
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">detached</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -108,11 +108,11 @@ And <b>html</b> & "quotes".</pre> | |||
| 108 | 108 | </span> | |
| 109 | 109 | <span class="grow"> | |
| 110 | 110 | </span> | |
| 111 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 111 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 112 | 112 | </a> | |
| 113 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 113 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 114 | 114 | </a> | |
| 115 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 115 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 116 | 116 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 117 | 117 | <kbd aria-hidden="true">x</kbd> | |
| 118 | 118 | </button> | |
| @@ -276,7 +276,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 276 | 276 | </h2> | |
| 277 | 277 | <ol class="files" aria-label="Changed files"> | |
| 278 | 278 | <li> | |
| 279 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 279 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 280 | 280 | <span class="st" title="M">M</span> | |
| 281 | 281 | <span class="p" title="a.txt">a.txt</span> | |
| 282 | 282 | <span class="n"> | |
| @@ -290,7 +290,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 290 | 290 | </a> | |
| 291 | 291 | </li> | |
| 292 | 292 | <li> | |
| 293 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 293 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 294 | 294 | <span class="st" title="M">M</span> | |
| 295 | 295 | <span class="p" title="run.sh">run.sh</span> | |
| 296 | 296 | <span class="n"> | |
| @@ -421,7 +421,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 421 | 421 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 422 | 422 | <div class="panel-head"> | |
| 423 | 423 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 424 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 424 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 425 | 425 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 426 | 426 | <kbd>esc</kbd> | |
| 427 | 427 | </button> | |
| @@ -456,13 +456,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 456 | 456 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 457 | 457 | <div class="panel-head"> | |
| 458 | 458 | <label for="cmd-q" class="sr">go to</label> | |
| 459 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 460 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 461 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 462 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 463 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 464 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 465 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 459 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 466 | 460 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 467 | 461 | <kbd>esc</kbd> | |
| 468 | 462 | </button> | |
| @@ -533,67 +527,27 @@ And <b>html</b> & "quotes".</pre> | |||
| 533 | 527 | <kbd>esc</kbd> close</span> | |
| 534 | 528 | </p> | |
| 535 | 529 | </dialog> | |
| 536 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 537 | - | if (q('dialog[open]').count) return; | |
| 538 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 539 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 540 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 541 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 542 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 543 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 544 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 545 | - | })(1); | |
| 546 | - | k-k from:body -> (async (d) => { | |
| 547 | - | if (q('dialog[open]').count) return; | |
| 548 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 549 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 550 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 551 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 552 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 553 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 554 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 555 | - | })(-1); | |
| 556 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 557 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 558 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 559 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 560 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 561 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 562 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 563 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 564 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 565 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 566 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 567 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 568 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 569 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 570 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 571 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 572 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 573 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 574 | - | k-next from:body -> { | |
| 575 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 576 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 577 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 578 | - | }; | |
| 579 | - | k-split from:body -> { | |
| 580 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 581 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 582 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 583 | - | }; | |
| 584 | - | k-unified from:body -> { | |
| 585 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 586 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 587 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 588 | - | }; | |
| 589 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 590 | - | k-permalink from:body -> { | |
| 591 | - | let b = document.body.dataset; | |
| 592 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 593 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 594 | - | navigator.clipboard?.writeText(location.href); | |
| 595 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 596 | - | }"> | |
| 530 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 531 | + | k-k from:body -> ermite.move(-1); | |
| 532 | + | k-top from:body -> ermite.edge(false); | |
| 533 | + | k-bottom from:body -> ermite.edge(true); | |
| 534 | + | k-open from:body -> ermite.open(); | |
| 535 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 536 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 537 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 538 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 539 | + | k-filter from:body -> ermite.focusFilter(); | |
| 540 | + | k-gh from:body -> ermite.go('.brand'); | |
| 541 | + | k-s from:body -> ermite.tab('s'); | |
| 542 | + | k-t from:body -> ermite.tab('t'); | |
| 543 | + | k-c from:body -> ermite.tab('c'); | |
| 544 | + | k-r from:body -> ermite.tab('r'); | |
| 545 | + | k-m from:body -> ermite.tab('m'); | |
| 546 | + | k-next from:body -> ermite.nextNote(); | |
| 547 | + | k-split from:body -> ermite.layout('split'); | |
| 548 | + | k-unified from:body -> ermite.layout('unified'); | |
| 549 | + | k-collapse from:body -> ermite.collapse(); | |
| 550 | + | k-permalink from:body -> ermite.permalink()"> | |
| 597 | 551 | </div> | |
| 598 | 552 | </body> | |
| 599 | 553 | </html> | |
M cmd/ermite/testdata/web/commit-modify.html +32 -78
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">detached</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -108,11 +108,11 @@ And <b>html</b> & "quotes".</pre> | |||
| 108 | 108 | </span> | |
| 109 | 109 | <span class="grow"> | |
| 110 | 110 | </span> | |
| 111 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 111 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 112 | 112 | </a> | |
| 113 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 113 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 114 | 114 | </a> | |
| 115 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 115 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 116 | 116 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 117 | 117 | <kbd aria-hidden="true">x</kbd> | |
| 118 | 118 | </button> | |
| @@ -276,7 +276,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 276 | 276 | </h2> | |
| 277 | 277 | <ol class="files" aria-label="Changed files"> | |
| 278 | 278 | <li> | |
| 279 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 279 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 280 | 280 | <span class="st" title="M">M</span> | |
| 281 | 281 | <span class="p" title="a.txt">a.txt</span> | |
| 282 | 282 | <span class="n"> | |
| @@ -290,7 +290,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 290 | 290 | </a> | |
| 291 | 291 | </li> | |
| 292 | 292 | <li> | |
| 293 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 293 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 294 | 294 | <span class="st" title="M">M</span> | |
| 295 | 295 | <span class="p" title="run.sh">run.sh</span> | |
| 296 | 296 | <span class="n"> | |
| @@ -421,7 +421,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 421 | 421 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 422 | 422 | <div class="panel-head"> | |
| 423 | 423 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 424 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 424 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 425 | 425 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 426 | 426 | <kbd>esc</kbd> | |
| 427 | 427 | </button> | |
| @@ -456,13 +456,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 456 | 456 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 457 | 457 | <div class="panel-head"> | |
| 458 | 458 | <label for="cmd-q" class="sr">go to</label> | |
| 459 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 460 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 461 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 462 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 463 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 464 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 465 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 459 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 466 | 460 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 467 | 461 | <kbd>esc</kbd> | |
| 468 | 462 | </button> | |
| @@ -533,67 +527,27 @@ And <b>html</b> & "quotes".</pre> | |||
| 533 | 527 | <kbd>esc</kbd> close</span> | |
| 534 | 528 | </p> | |
| 535 | 529 | </dialog> | |
| 536 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 537 | - | if (q('dialog[open]').count) return; | |
| 538 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 539 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 540 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 541 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 542 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 543 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 544 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 545 | - | })(1); | |
| 546 | - | k-k from:body -> (async (d) => { | |
| 547 | - | if (q('dialog[open]').count) return; | |
| 548 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 549 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 550 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 551 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 552 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 553 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 554 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 555 | - | })(-1); | |
| 556 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 557 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 558 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 559 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 560 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 561 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 562 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 563 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 564 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 565 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 566 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 567 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 568 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 569 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 570 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 571 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 572 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 573 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 574 | - | k-next from:body -> { | |
| 575 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 576 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 577 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 578 | - | }; | |
| 579 | - | k-split from:body -> { | |
| 580 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 581 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 582 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 583 | - | }; | |
| 584 | - | k-unified from:body -> { | |
| 585 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 586 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 587 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 588 | - | }; | |
| 589 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 590 | - | k-permalink from:body -> { | |
| 591 | - | let b = document.body.dataset; | |
| 592 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 593 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 594 | - | navigator.clipboard?.writeText(location.href); | |
| 595 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 596 | - | }"> | |
| 530 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 531 | + | k-k from:body -> ermite.move(-1); | |
| 532 | + | k-top from:body -> ermite.edge(false); | |
| 533 | + | k-bottom from:body -> ermite.edge(true); | |
| 534 | + | k-open from:body -> ermite.open(); | |
| 535 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 536 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 537 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 538 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 539 | + | k-filter from:body -> ermite.focusFilter(); | |
| 540 | + | k-gh from:body -> ermite.go('.brand'); | |
| 541 | + | k-s from:body -> ermite.tab('s'); | |
| 542 | + | k-t from:body -> ermite.tab('t'); | |
| 543 | + | k-c from:body -> ermite.tab('c'); | |
| 544 | + | k-r from:body -> ermite.tab('r'); | |
| 545 | + | k-m from:body -> ermite.tab('m'); | |
| 546 | + | k-next from:body -> ermite.nextNote(); | |
| 547 | + | k-split from:body -> ermite.layout('split'); | |
| 548 | + | k-unified from:body -> ermite.layout('unified'); | |
| 549 | + | k-collapse from:body -> ermite.collapse(); | |
| 550 | + | k-permalink from:body -> ermite.permalink()"> | |
| 597 | 551 | </div> | |
| 598 | 552 | </body> | |
| 599 | 553 | </html> | |
M cmd/ermite/testdata/web/commit-rename.html +32 -78
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">detached</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -102,11 +102,11 @@ | |||
| 102 | 102 | </span> | |
| 103 | 103 | <span class="grow"> | |
| 104 | 104 | </span> | |
| 105 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 105 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 106 | 106 | </a> | |
| 107 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 107 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 108 | 108 | </a> | |
| 109 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 109 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 110 | 110 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 111 | 111 | <kbd aria-hidden="true">x</kbd> | |
| 112 | 112 | </button> | |
| @@ -180,7 +180,7 @@ | |||
| 180 | 180 | </h2> | |
| 181 | 181 | <ol class="files" aria-label="Changed files"> | |
| 182 | 182 | <li> | |
| 183 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 183 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 184 | 184 | <span class="st" title="R">R</span> | |
| 185 | 185 | <span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span> | |
| 186 | 186 | <span class="n"> | |
| @@ -195,7 +195,7 @@ | |||
| 195 | 195 | </a> | |
| 196 | 196 | </li> | |
| 197 | 197 | <li> | |
| 198 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 198 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 199 | 199 | <span class="st" title="D">D</span> | |
| 200 | 200 | <span class="p" title="gone.txt">gone.txt</span> | |
| 201 | 201 | <span class="n"> | |
| @@ -326,7 +326,7 @@ | |||
| 326 | 326 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 327 | 327 | <div class="panel-head"> | |
| 328 | 328 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 329 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 329 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 330 | 330 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 331 | 331 | <kbd>esc</kbd> | |
| 332 | 332 | </button> | |
| @@ -361,13 +361,7 @@ | |||
| 361 | 361 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 362 | 362 | <div class="panel-head"> | |
| 363 | 363 | <label for="cmd-q" class="sr">go to</label> | |
| 364 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 365 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 366 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 367 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 368 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 369 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 370 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 364 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 371 | 365 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 372 | 366 | <kbd>esc</kbd> | |
| 373 | 367 | </button> | |
| @@ -438,67 +432,27 @@ | |||
| 438 | 432 | <kbd>esc</kbd> close</span> | |
| 439 | 433 | </p> | |
| 440 | 434 | </dialog> | |
| 441 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 442 | - | if (q('dialog[open]').count) return; | |
| 443 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 444 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 445 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 446 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 447 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 448 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 449 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 450 | - | })(1); | |
| 451 | - | k-k from:body -> (async (d) => { | |
| 452 | - | if (q('dialog[open]').count) return; | |
| 453 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 454 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 455 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 456 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 457 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 458 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 459 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 460 | - | })(-1); | |
| 461 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 462 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 463 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 464 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 465 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 466 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 467 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 468 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 469 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 470 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 471 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 472 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 473 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 474 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 475 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 476 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 477 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 478 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 479 | - | k-next from:body -> { | |
| 480 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 481 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 482 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 483 | - | }; | |
| 484 | - | k-split from:body -> { | |
| 485 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 486 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 487 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 488 | - | }; | |
| 489 | - | k-unified from:body -> { | |
| 490 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 491 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 492 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 493 | - | }; | |
| 494 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 495 | - | k-permalink from:body -> { | |
| 496 | - | let b = document.body.dataset; | |
| 497 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 498 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 499 | - | navigator.clipboard?.writeText(location.href); | |
| 500 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 501 | - | }"> | |
| 435 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 436 | + | k-k from:body -> ermite.move(-1); | |
| 437 | + | k-top from:body -> ermite.edge(false); | |
| 438 | + | k-bottom from:body -> ermite.edge(true); | |
| 439 | + | k-open from:body -> ermite.open(); | |
| 440 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 441 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 442 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 443 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 444 | + | k-filter from:body -> ermite.focusFilter(); | |
| 445 | + | k-gh from:body -> ermite.go('.brand'); | |
| 446 | + | k-s from:body -> ermite.tab('s'); | |
| 447 | + | k-t from:body -> ermite.tab('t'); | |
| 448 | + | k-c from:body -> ermite.tab('c'); | |
| 449 | + | k-r from:body -> ermite.tab('r'); | |
| 450 | + | k-m from:body -> ermite.tab('m'); | |
| 451 | + | k-next from:body -> ermite.nextNote(); | |
| 452 | + | k-split from:body -> ermite.layout('split'); | |
| 453 | + | k-unified from:body -> ermite.layout('unified'); | |
| 454 | + | k-collapse from:body -> ermite.collapse(); | |
| 455 | + | k-permalink from:body -> ermite.permalink()"> | |
| 502 | 456 | </div> | |
| 503 | 457 | </body> | |
| 504 | 458 | </html> | |
M cmd/ermite/testdata/web/commit-root.html +35 -81
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">detached</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -100,11 +100,11 @@ | |||
| 100 | 100 | </span> | |
| 101 | 101 | <span class="grow"> | |
| 102 | 102 | </span> | |
| 103 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 103 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 104 | 104 | </a> | |
| 105 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 105 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 106 | 106 | </a> | |
| 107 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 107 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 108 | 108 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 109 | 109 | <kbd aria-hidden="true">x</kbd> | |
| 110 | 110 | </button> | |
| @@ -454,7 +454,7 @@ | |||
| 454 | 454 | </h2> | |
| 455 | 455 | <ol class="files" aria-label="Changed files"> | |
| 456 | 456 | <li> | |
| 457 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 457 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 458 | 458 | <span class="st" title="A">A</span> | |
| 459 | 459 | <span class="p" title="a.txt">a.txt</span> | |
| 460 | 460 | <span class="n"> | |
| @@ -469,7 +469,7 @@ | |||
| 469 | 469 | </a> | |
| 470 | 470 | </li> | |
| 471 | 471 | <li> | |
| 472 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 472 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 473 | 473 | <span class="st" title="A">A</span> | |
| 474 | 474 | <span class="p" title="gone.txt">gone.txt</span> | |
| 475 | 475 | <span class="n"> | |
| @@ -483,7 +483,7 @@ | |||
| 483 | 483 | </a> | |
| 484 | 484 | </li> | |
| 485 | 485 | <li> | |
| 486 | - | <a href="#f3" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f3' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 486 | + | <a href="#f3" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 487 | 487 | <span class="st" title="A">A</span> | |
| 488 | 488 | <span class="p" title="logo.bin">logo.bin</span> | |
| 489 | 489 | <span class="n"> | |
| @@ -498,7 +498,7 @@ | |||
| 498 | 498 | </a> | |
| 499 | 499 | </li> | |
| 500 | 500 | <li> | |
| 501 | - | <a href="#f4" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f4' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 501 | + | <a href="#f4" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 502 | 502 | <span class="st" title="A">A</span> | |
| 503 | 503 | <span class="p" title="old.txt">old.txt</span> | |
| 504 | 504 | <span class="n"> | |
| @@ -512,7 +512,7 @@ | |||
| 512 | 512 | </a> | |
| 513 | 513 | </li> | |
| 514 | 514 | <li> | |
| 515 | - | <a href="#f5" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f5' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 515 | + | <a href="#f5" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 516 | 516 | <span class="st" title="A">A</span> | |
| 517 | 517 | <span class="p" title="run.sh">run.sh</span> | |
| 518 | 518 | <span class="n"> | |
| @@ -642,7 +642,7 @@ | |||
| 642 | 642 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 643 | 643 | <div class="panel-head"> | |
| 644 | 644 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 645 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 645 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 646 | 646 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 647 | 647 | <kbd>esc</kbd> | |
| 648 | 648 | </button> | |
| @@ -677,13 +677,7 @@ | |||
| 677 | 677 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 678 | 678 | <div class="panel-head"> | |
| 679 | 679 | <label for="cmd-q" class="sr">go to</label> | |
| 680 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 681 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 682 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 683 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 684 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 685 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 686 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 680 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 687 | 681 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 688 | 682 | <kbd>esc</kbd> | |
| 689 | 683 | </button> | |
| @@ -754,67 +748,27 @@ | |||
| 754 | 748 | <kbd>esc</kbd> close</span> | |
| 755 | 749 | </p> | |
| 756 | 750 | </dialog> | |
| 757 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 758 | - | if (q('dialog[open]').count) return; | |
| 759 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 760 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 761 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 762 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 763 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 764 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 765 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 766 | - | })(1); | |
| 767 | - | k-k from:body -> (async (d) => { | |
| 768 | - | if (q('dialog[open]').count) return; | |
| 769 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 770 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 771 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 772 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 773 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 774 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 775 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 776 | - | })(-1); | |
| 777 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 778 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 779 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 780 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 781 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 782 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 783 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 784 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 785 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 786 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 787 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 788 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 789 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 790 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 791 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 792 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 793 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 794 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 795 | - | k-next from:body -> { | |
| 796 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 797 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 798 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 799 | - | }; | |
| 800 | - | k-split from:body -> { | |
| 801 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 802 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 803 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 804 | - | }; | |
| 805 | - | k-unified from:body -> { | |
| 806 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 807 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 808 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 809 | - | }; | |
| 810 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 811 | - | k-permalink from:body -> { | |
| 812 | - | let b = document.body.dataset; | |
| 813 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 814 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 815 | - | navigator.clipboard?.writeText(location.href); | |
| 816 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 817 | - | }"> | |
| 751 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 752 | + | k-k from:body -> ermite.move(-1); | |
| 753 | + | k-top from:body -> ermite.edge(false); | |
| 754 | + | k-bottom from:body -> ermite.edge(true); | |
| 755 | + | k-open from:body -> ermite.open(); | |
| 756 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 757 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 758 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 759 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 760 | + | k-filter from:body -> ermite.focusFilter(); | |
| 761 | + | k-gh from:body -> ermite.go('.brand'); | |
| 762 | + | k-s from:body -> ermite.tab('s'); | |
| 763 | + | k-t from:body -> ermite.tab('t'); | |
| 764 | + | k-c from:body -> ermite.tab('c'); | |
| 765 | + | k-r from:body -> ermite.tab('r'); | |
| 766 | + | k-m from:body -> ermite.tab('m'); | |
| 767 | + | k-next from:body -> ermite.nextNote(); | |
| 768 | + | k-split from:body -> ermite.layout('split'); | |
| 769 | + | k-unified from:body -> ermite.layout('unified'); | |
| 770 | + | k-collapse from:body -> ermite.collapse(); | |
| 771 | + | k-permalink from:body -> ermite.permalink()"> | |
| 818 | 772 | </div> | |
| 819 | 773 | </body> | |
| 820 | 774 | </html> | |
M cmd/ermite/testdata/web/commit-trojan.html +31 -77
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">detached</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -103,11 +103,11 @@ | |||
| 103 | 103 | </span> | |
| 104 | 104 | <span class="grow"> | |
| 105 | 105 | </span> | |
| 106 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 106 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 107 | 107 | </a> | |
| 108 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 108 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 109 | 109 | </a> | |
| 110 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 110 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 111 | 111 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 112 | 112 | <kbd aria-hidden="true">x</kbd> | |
| 113 | 113 | </button> | |
| @@ -304,7 +304,7 @@ | |||
| 304 | 304 | </h2> | |
| 305 | 305 | <ol class="files" aria-label="Changed files"> | |
| 306 | 306 | <li> | |
| 307 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 307 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 308 | 308 | <span class="st" title="A">A</span> | |
| 309 | 309 | <span class="p" title="evil.go">evil.go</span> | |
| 310 | 310 | <span class="n"> | |
| @@ -435,7 +435,7 @@ | |||
| 435 | 435 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 436 | 436 | <div class="panel-head"> | |
| 437 | 437 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 438 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 438 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 439 | 439 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 440 | 440 | <kbd>esc</kbd> | |
| 441 | 441 | </button> | |
| @@ -470,13 +470,7 @@ | |||
| 470 | 470 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 471 | 471 | <div class="panel-head"> | |
| 472 | 472 | <label for="cmd-q" class="sr">go to</label> | |
| 473 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 474 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 475 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 476 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 477 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 478 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 479 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 473 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 480 | 474 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 481 | 475 | <kbd>esc</kbd> | |
| 482 | 476 | </button> | |
| @@ -547,67 +541,27 @@ | |||
| 547 | 541 | <kbd>esc</kbd> close</span> | |
| 548 | 542 | </p> | |
| 549 | 543 | </dialog> | |
| 550 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 551 | - | if (q('dialog[open]').count) return; | |
| 552 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 553 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 554 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 555 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 556 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 557 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 558 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 559 | - | })(1); | |
| 560 | - | k-k from:body -> (async (d) => { | |
| 561 | - | if (q('dialog[open]').count) return; | |
| 562 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 563 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 564 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 565 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 566 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 567 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 568 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 569 | - | })(-1); | |
| 570 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 571 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 572 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 573 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 574 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 575 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 576 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 577 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 578 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 579 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 580 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 581 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 582 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 583 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 584 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 585 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 586 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 587 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 588 | - | k-next from:body -> { | |
| 589 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 590 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 591 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 592 | - | }; | |
| 593 | - | k-split from:body -> { | |
| 594 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 595 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 596 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 597 | - | }; | |
| 598 | - | k-unified from:body -> { | |
| 599 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 600 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 601 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 602 | - | }; | |
| 603 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 604 | - | k-permalink from:body -> { | |
| 605 | - | let b = document.body.dataset; | |
| 606 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 607 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 608 | - | navigator.clipboard?.writeText(location.href); | |
| 609 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 610 | - | }"> | |
| 544 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 545 | + | k-k from:body -> ermite.move(-1); | |
| 546 | + | k-top from:body -> ermite.edge(false); | |
| 547 | + | k-bottom from:body -> ermite.edge(true); | |
| 548 | + | k-open from:body -> ermite.open(); | |
| 549 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 550 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 551 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 552 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 553 | + | k-filter from:body -> ermite.focusFilter(); | |
| 554 | + | k-gh from:body -> ermite.go('.brand'); | |
| 555 | + | k-s from:body -> ermite.tab('s'); | |
| 556 | + | k-t from:body -> ermite.tab('t'); | |
| 557 | + | k-c from:body -> ermite.tab('c'); | |
| 558 | + | k-r from:body -> ermite.tab('r'); | |
| 559 | + | k-m from:body -> ermite.tab('m'); | |
| 560 | + | k-next from:body -> ermite.nextNote(); | |
| 561 | + | k-split from:body -> ermite.layout('split'); | |
| 562 | + | k-unified from:body -> ermite.layout('unified'); | |
| 563 | + | k-collapse from:body -> ermite.collapse(); | |
| 564 | + | k-permalink from:body -> ermite.permalink()"> | |
| 611 | 565 | </div> | |
| 612 | 566 | </body> | |
| 613 | 567 | </html> | |
M cmd/ermite/testdata/web/commits-page2.html +28 -76
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">main</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -85,9 +85,7 @@ | |||
| 85 | 85 | <main id="main" tabindex="-1"> | |
| 86 | 86 | <div class="filter"> | |
| 87 | 87 | <label for="commit-filter" class="sr">filter</label> | |
| 88 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 89 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 90 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 88 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 91 | 89 | </div> | |
| 92 | 90 | <div class="days"> | |
| 93 | 91 | <section aria-label="2026-03-01"> | |
| @@ -258,7 +256,7 @@ | |||
| 258 | 256 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 259 | 257 | <div class="panel-head"> | |
| 260 | 258 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 261 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 259 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 262 | 260 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 263 | 261 | <kbd>esc</kbd> | |
| 264 | 262 | </button> | |
| @@ -285,13 +283,7 @@ | |||
| 285 | 283 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 286 | 284 | <div class="panel-head"> | |
| 287 | 285 | <label for="cmd-q" class="sr">go to</label> | |
| 288 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 289 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 290 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 291 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 292 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 293 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 294 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 286 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 295 | 287 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 296 | 288 | <kbd>esc</kbd> | |
| 297 | 289 | </button> | |
| @@ -356,67 +348,27 @@ | |||
| 356 | 348 | <kbd>esc</kbd> close</span> | |
| 357 | 349 | </p> | |
| 358 | 350 | </dialog> | |
| 359 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 360 | - | if (q('dialog[open]').count) return; | |
| 361 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 362 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 363 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 364 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 365 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 366 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 367 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 368 | - | })(1); | |
| 369 | - | k-k from:body -> (async (d) => { | |
| 370 | - | if (q('dialog[open]').count) return; | |
| 371 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 372 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 373 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 374 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 375 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 376 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 377 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 378 | - | })(-1); | |
| 379 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 380 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 381 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 382 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 383 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 384 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 385 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 386 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 387 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 388 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 389 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 390 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 391 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 392 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 393 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 394 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 395 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 396 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 397 | - | k-next from:body -> { | |
| 398 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 399 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 400 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 401 | - | }; | |
| 402 | - | k-split from:body -> { | |
| 403 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 404 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 405 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 406 | - | }; | |
| 407 | - | k-unified from:body -> { | |
| 408 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 409 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 410 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 411 | - | }; | |
| 412 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 413 | - | k-permalink from:body -> { | |
| 414 | - | let b = document.body.dataset; | |
| 415 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 416 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 417 | - | navigator.clipboard?.writeText(location.href); | |
| 418 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 419 | - | }"> | |
| 351 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 352 | + | k-k from:body -> ermite.move(-1); | |
| 353 | + | k-top from:body -> ermite.edge(false); | |
| 354 | + | k-bottom from:body -> ermite.edge(true); | |
| 355 | + | k-open from:body -> ermite.open(); | |
| 356 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 357 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 358 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 359 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 360 | + | k-filter from:body -> ermite.focusFilter(); | |
| 361 | + | k-gh from:body -> ermite.go('.brand'); | |
| 362 | + | k-s from:body -> ermite.tab('s'); | |
| 363 | + | k-t from:body -> ermite.tab('t'); | |
| 364 | + | k-c from:body -> ermite.tab('c'); | |
| 365 | + | k-r from:body -> ermite.tab('r'); | |
| 366 | + | k-m from:body -> ermite.tab('m'); | |
| 367 | + | k-next from:body -> ermite.nextNote(); | |
| 368 | + | k-split from:body -> ermite.layout('split'); | |
| 369 | + | k-unified from:body -> ermite.layout('unified'); | |
| 370 | + | k-collapse from:body -> ermite.collapse(); | |
| 371 | + | k-permalink from:body -> ermite.permalink()"> | |
| 420 | 372 | </div> | |
| 421 | 373 | </body> | |
| 422 | 374 | </html> | |
M cmd/ermite/testdata/web/commits-path.html +28 -76
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -88,9 +88,7 @@ | |||
| 88 | 88 | <main id="main" tabindex="-1"> | |
| 89 | 89 | <div class="filter"> | |
| 90 | 90 | <label for="commit-filter" class="sr">filter</label> | |
| 91 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 92 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 93 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 91 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 94 | 92 | </div> | |
| 95 | 93 | <p class="lede dim">history of <code>a.txt</code> at main</p> | |
| 96 | 94 | <div class="days"> | |
| @@ -273,7 +271,7 @@ | |||
| 273 | 271 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 274 | 272 | <div class="panel-head"> | |
| 275 | 273 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 276 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 274 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 277 | 275 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 278 | 276 | <kbd>esc</kbd> | |
| 279 | 277 | </button> | |
| @@ -300,13 +298,7 @@ | |||
| 300 | 298 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 301 | 299 | <div class="panel-head"> | |
| 302 | 300 | <label for="cmd-q" class="sr">go to</label> | |
| 303 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 304 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 305 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 306 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 307 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 308 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 309 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 301 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 310 | 302 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 311 | 303 | <kbd>esc</kbd> | |
| 312 | 304 | </button> | |
| @@ -371,67 +363,27 @@ | |||
| 371 | 363 | <kbd>esc</kbd> close</span> | |
| 372 | 364 | </p> | |
| 373 | 365 | </dialog> | |
| 374 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 375 | - | if (q('dialog[open]').count) return; | |
| 376 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 377 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 378 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 379 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 380 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 381 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 382 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 383 | - | })(1); | |
| 384 | - | k-k from:body -> (async (d) => { | |
| 385 | - | if (q('dialog[open]').count) return; | |
| 386 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 387 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 388 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 389 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 390 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 391 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 392 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 393 | - | })(-1); | |
| 394 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 395 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 396 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 397 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 398 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 399 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 400 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 401 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 402 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 403 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 404 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 405 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 406 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 407 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 408 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 409 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 410 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 411 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 412 | - | k-next from:body -> { | |
| 413 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 414 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 415 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 416 | - | }; | |
| 417 | - | k-split from:body -> { | |
| 418 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 419 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 420 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 421 | - | }; | |
| 422 | - | k-unified from:body -> { | |
| 423 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 424 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 425 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 426 | - | }; | |
| 427 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 428 | - | k-permalink from:body -> { | |
| 429 | - | let b = document.body.dataset; | |
| 430 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 431 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 432 | - | navigator.clipboard?.writeText(location.href); | |
| 433 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 434 | - | }"> | |
| 366 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 367 | + | k-k from:body -> ermite.move(-1); | |
| 368 | + | k-top from:body -> ermite.edge(false); | |
| 369 | + | k-bottom from:body -> ermite.edge(true); | |
| 370 | + | k-open from:body -> ermite.open(); | |
| 371 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 372 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 373 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 374 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 375 | + | k-filter from:body -> ermite.focusFilter(); | |
| 376 | + | k-gh from:body -> ermite.go('.brand'); | |
| 377 | + | k-s from:body -> ermite.tab('s'); | |
| 378 | + | k-t from:body -> ermite.tab('t'); | |
| 379 | + | k-c from:body -> ermite.tab('c'); | |
| 380 | + | k-r from:body -> ermite.tab('r'); | |
| 381 | + | k-m from:body -> ermite.tab('m'); | |
| 382 | + | k-next from:body -> ermite.nextNote(); | |
| 383 | + | k-split from:body -> ermite.layout('split'); | |
| 384 | + | k-unified from:body -> ermite.layout('unified'); | |
| 385 | + | k-collapse from:body -> ermite.collapse(); | |
| 386 | + | k-permalink from:body -> ermite.permalink()"> | |
| 435 | 387 | </div> | |
| 436 | 388 | </body> | |
| 437 | 389 | </html> | |
M cmd/ermite/testdata/web/commits-root.html +31 -77
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">main</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -85,11 +85,9 @@ | |||
| 85 | 85 | <main id="main" tabindex="-1"> | |
| 86 | 86 | <div class="filter"> | |
| 87 | 87 | <label for="commit-filter" class="sr">filter</label> | |
| 88 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 89 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 90 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 88 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 91 | 89 | </div> | |
| 92 | - | <div id="commits-head"> | |
| 90 | + | <div id="commits-head" class="live-slot" role="status" data-slot> | |
| 93 | 91 | </div> | |
| 94 | 92 | <div class="days"> | |
| 95 | 93 | <section aria-label="2026-03-26"> | |
| @@ -957,6 +955,8 @@ | |||
| 957 | 955 | </span> | |
| 958 | 956 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Along&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 959 | 957 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 958 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 959 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 960 | 960 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 961 | 961 | gap -> ermiteLive.reload()"> | |
| 962 | 962 | </span> | |
| @@ -1060,7 +1060,7 @@ | |||
| 1060 | 1060 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 1061 | 1061 | <div class="panel-head"> | |
| 1062 | 1062 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 1063 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 1063 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 1064 | 1064 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 1065 | 1065 | <kbd>esc</kbd> | |
| 1066 | 1066 | </button> | |
| @@ -1087,13 +1087,7 @@ | |||
| 1087 | 1087 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 1088 | 1088 | <div class="panel-head"> | |
| 1089 | 1089 | <label for="cmd-q" class="sr">go to</label> | |
| 1090 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 1091 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 1092 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 1093 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 1094 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 1095 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 1096 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 1090 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 1097 | 1091 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 1098 | 1092 | <kbd>esc</kbd> | |
| 1099 | 1093 | </button> | |
| @@ -1158,67 +1152,27 @@ | |||
| 1158 | 1152 | <kbd>esc</kbd> close</span> | |
| 1159 | 1153 | </p> | |
| 1160 | 1154 | </dialog> | |
| 1161 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 1162 | - | if (q('dialog[open]').count) return; | |
| 1163 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 1164 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 1165 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 1166 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 1167 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 1168 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 1169 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 1170 | - | })(1); | |
| 1171 | - | k-k from:body -> (async (d) => { | |
| 1172 | - | if (q('dialog[open]').count) return; | |
| 1173 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 1174 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 1175 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 1176 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 1177 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 1178 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 1179 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 1180 | - | })(-1); | |
| 1181 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 1182 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 1183 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 1184 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 1185 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 1186 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 1187 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 1188 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 1189 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 1190 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 1191 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 1192 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 1193 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 1194 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 1195 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 1196 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 1197 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 1198 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 1199 | - | k-next from:body -> { | |
| 1200 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 1201 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 1202 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 1203 | - | }; | |
| 1204 | - | k-split from:body -> { | |
| 1205 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 1206 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 1207 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 1208 | - | }; | |
| 1209 | - | k-unified from:body -> { | |
| 1210 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 1211 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 1212 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 1213 | - | }; | |
| 1214 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 1215 | - | k-permalink from:body -> { | |
| 1216 | - | let b = document.body.dataset; | |
| 1217 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 1218 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 1219 | - | navigator.clipboard?.writeText(location.href); | |
| 1220 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 1221 | - | }"> | |
| 1155 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 1156 | + | k-k from:body -> ermite.move(-1); | |
| 1157 | + | k-top from:body -> ermite.edge(false); | |
| 1158 | + | k-bottom from:body -> ermite.edge(true); | |
| 1159 | + | k-open from:body -> ermite.open(); | |
| 1160 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 1161 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 1162 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 1163 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 1164 | + | k-filter from:body -> ermite.focusFilter(); | |
| 1165 | + | k-gh from:body -> ermite.go('.brand'); | |
| 1166 | + | k-s from:body -> ermite.tab('s'); | |
| 1167 | + | k-t from:body -> ermite.tab('t'); | |
| 1168 | + | k-c from:body -> ermite.tab('c'); | |
| 1169 | + | k-r from:body -> ermite.tab('r'); | |
| 1170 | + | k-m from:body -> ermite.tab('m'); | |
| 1171 | + | k-next from:body -> ermite.nextNote(); | |
| 1172 | + | k-split from:body -> ermite.layout('split'); | |
| 1173 | + | k-unified from:body -> ermite.layout('unified'); | |
| 1174 | + | k-collapse from:body -> ermite.collapse(); | |
| 1175 | + | k-permalink from:body -> ermite.permalink()"> | |
| 1222 | 1176 | </div> | |
| 1223 | 1177 | </body> | |
| 1224 | 1178 | </html> | |
M cmd/ermite/testdata/web/commits-small.html +31 -77
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">main</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -85,11 +85,9 @@ | |||
| 85 | 85 | <main id="main" tabindex="-1"> | |
| 86 | 86 | <div class="filter"> | |
| 87 | 87 | <label for="commit-filter" class="sr">filter</label> | |
| 88 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 89 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 90 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 88 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 91 | 89 | </div> | |
| 92 | - | <div id="commits-head"> | |
| 90 | + | <div id="commits-head" class="live-slot" role="status" data-slot> | |
| 93 | 91 | </div> | |
| 94 | 92 | <div class="days"> | |
| 95 | 93 | <section aria-label="2026-02-08"> | |
| @@ -302,6 +300,8 @@ | |||
| 302 | 300 | </span> | |
| 303 | 301 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Adiffs&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 304 | 302 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 303 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 304 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 305 | 305 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 306 | 306 | gap -> ermiteLive.reload()"> | |
| 307 | 307 | </span> | |
| @@ -405,7 +405,7 @@ | |||
| 405 | 405 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 406 | 406 | <div class="panel-head"> | |
| 407 | 407 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 408 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 408 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 409 | 409 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 410 | 410 | <kbd>esc</kbd> | |
| 411 | 411 | </button> | |
| @@ -440,13 +440,7 @@ | |||
| 440 | 440 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 441 | 441 | <div class="panel-head"> | |
| 442 | 442 | <label for="cmd-q" class="sr">go to</label> | |
| 443 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 444 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 445 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 446 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 447 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 448 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 449 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 443 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 450 | 444 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 451 | 445 | <kbd>esc</kbd> | |
| 452 | 446 | </button> | |
| @@ -523,67 +517,27 @@ | |||
| 523 | 517 | <kbd>esc</kbd> close</span> | |
| 524 | 518 | </p> | |
| 525 | 519 | </dialog> | |
| 526 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 527 | - | if (q('dialog[open]').count) return; | |
| 528 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 529 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 530 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 531 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 532 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 533 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 534 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 535 | - | })(1); | |
| 536 | - | k-k from:body -> (async (d) => { | |
| 537 | - | if (q('dialog[open]').count) return; | |
| 538 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 539 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 540 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 541 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 542 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 543 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 544 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 545 | - | })(-1); | |
| 546 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 547 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 548 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 549 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 550 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 551 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 552 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 553 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 554 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 555 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 556 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 557 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 558 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 559 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 560 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 561 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 562 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 563 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 564 | - | k-next from:body -> { | |
| 565 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 566 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 567 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 568 | - | }; | |
| 569 | - | k-split from:body -> { | |
| 570 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 571 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 572 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 573 | - | }; | |
| 574 | - | k-unified from:body -> { | |
| 575 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 576 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 577 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 578 | - | }; | |
| 579 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 580 | - | k-permalink from:body -> { | |
| 581 | - | let b = document.body.dataset; | |
| 582 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 583 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 584 | - | navigator.clipboard?.writeText(location.href); | |
| 585 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 586 | - | }"> | |
| 520 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 521 | + | k-k from:body -> ermite.move(-1); | |
| 522 | + | k-top from:body -> ermite.edge(false); | |
| 523 | + | k-bottom from:body -> ermite.edge(true); | |
| 524 | + | k-open from:body -> ermite.open(); | |
| 525 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 526 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 527 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 528 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 529 | + | k-filter from:body -> ermite.focusFilter(); | |
| 530 | + | k-gh from:body -> ermite.go('.brand'); | |
| 531 | + | k-s from:body -> ermite.tab('s'); | |
| 532 | + | k-t from:body -> ermite.tab('t'); | |
| 533 | + | k-c from:body -> ermite.tab('c'); | |
| 534 | + | k-r from:body -> ermite.tab('r'); | |
| 535 | + | k-m from:body -> ermite.tab('m'); | |
| 536 | + | k-next from:body -> ermite.nextNote(); | |
| 537 | + | k-split from:body -> ermite.layout('split'); | |
| 538 | + | k-unified from:body -> ermite.layout('unified'); | |
| 539 | + | k-collapse from:body -> ermite.collapse(); | |
| 540 | + | k-permalink from:body -> ermite.permalink()"> | |
| 587 | 541 | </div> | |
| 588 | 542 | </body> | |
| 589 | 543 | </html> | |
M cmd/ermite/testdata/web/empty.html +26 -72
| @@ -35,13 +35,13 @@ | |||
| 35 | 35 | </ol> | |
| 36 | 36 | </nav> | |
| 37 | 37 | <div class="hdr-acts"> | |
| 38 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 38 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 39 | 39 | <span class="ico i-search" aria-hidden="true"> | |
| 40 | 40 | </span> | |
| 41 | 41 | <span class="search-label">Go to…</span> | |
| 42 | 42 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 43 | 43 | </button> | |
| 44 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 44 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 45 | 45 | </div> | |
| 46 | 46 | </div> | |
| 47 | 47 | </header> | |
| @@ -55,12 +55,12 @@ | |||
| 55 | 55 | <div class="clone-line"> | |
| 56 | 56 | <span class="mute" aria-hidden="true">$ </span> | |
| 57 | 57 | <code>git remote add origin http://example.test/quiet.git</code> | |
| 58 | - | <button type="button" class="btn copy" data-label="Copy" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button> | |
| 58 | + | <button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="ermite.copy(this)">Copy</button> | |
| 59 | 59 | </div> | |
| 60 | 60 | <div class="clone-line"> | |
| 61 | 61 | <span class="mute" aria-hidden="true">$ </span> | |
| 62 | 62 | <code>git push -u origin main</code> | |
| 63 | - | <button type="button" class="btn copy" data-label="Copy" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button> | |
| 63 | + | <button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git push -u origin main" hx-on:click="ermite.copy(this)">Copy</button> | |
| 64 | 64 | </div> | |
| 65 | 65 | </div> | |
| 66 | 66 | <p class="state-back"> | |
| @@ -182,13 +182,7 @@ | |||
| 182 | 182 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 183 | 183 | <div class="panel-head"> | |
| 184 | 184 | <label for="cmd-q" class="sr">go to</label> | |
| 185 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 186 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 187 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 188 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 189 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 190 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 191 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 185 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 192 | 186 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 193 | 187 | <kbd>esc</kbd> | |
| 194 | 188 | </button> | |
| @@ -217,67 +211,27 @@ | |||
| 217 | 211 | <kbd>esc</kbd> close</span> | |
| 218 | 212 | </p> | |
| 219 | 213 | </dialog> | |
| 220 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 221 | - | if (q('dialog[open]').count) return; | |
| 222 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 223 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 224 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 225 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 226 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 227 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 228 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 229 | - | })(1); | |
| 230 | - | k-k from:body -> (async (d) => { | |
| 231 | - | if (q('dialog[open]').count) return; | |
| 232 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 233 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 234 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 235 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 236 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 237 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 238 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 239 | - | })(-1); | |
| 240 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 241 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 242 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 243 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 244 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 245 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 246 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 247 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 248 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 249 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 250 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 251 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 252 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 253 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 254 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 255 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 256 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 257 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 258 | - | k-next from:body -> { | |
| 259 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 260 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 261 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 262 | - | }; | |
| 263 | - | k-split from:body -> { | |
| 264 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 265 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 266 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 267 | - | }; | |
| 268 | - | k-unified from:body -> { | |
| 269 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 270 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 271 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 272 | - | }; | |
| 273 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 274 | - | k-permalink from:body -> { | |
| 275 | - | let b = document.body.dataset; | |
| 276 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 277 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 278 | - | navigator.clipboard?.writeText(location.href); | |
| 279 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 280 | - | }"> | |
| 214 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 215 | + | k-k from:body -> ermite.move(-1); | |
| 216 | + | k-top from:body -> ermite.edge(false); | |
| 217 | + | k-bottom from:body -> ermite.edge(true); | |
| 218 | + | k-open from:body -> ermite.open(); | |
| 219 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 220 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 221 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 222 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 223 | + | k-filter from:body -> ermite.focusFilter(); | |
| 224 | + | k-gh from:body -> ermite.go('.brand'); | |
| 225 | + | k-s from:body -> ermite.tab('s'); | |
| 226 | + | k-t from:body -> ermite.tab('t'); | |
| 227 | + | k-c from:body -> ermite.tab('c'); | |
| 228 | + | k-r from:body -> ermite.tab('r'); | |
| 229 | + | k-m from:body -> ermite.tab('m'); | |
| 230 | + | k-next from:body -> ermite.nextNote(); | |
| 231 | + | k-split from:body -> ermite.layout('split'); | |
| 232 | + | k-unified from:body -> ermite.layout('unified'); | |
| 233 | + | k-collapse from:body -> ermite.collapse(); | |
| 234 | + | k-permalink from:body -> ermite.permalink()"> | |
| 281 | 235 | </div> | |
| 282 | 236 | </body> | |
| 283 | 237 | </html> | |
M cmd/ermite/testdata/web/home.html +27 -71
| @@ -28,13 +28,13 @@ | |||
| 28 | 28 | <span class="brand-name">example.test</span> | |
| 29 | 29 | </a> | |
| 30 | 30 | <div class="hdr-acts"> | |
| 31 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 31 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 32 | 32 | <span class="ico i-search" aria-hidden="true"> | |
| 33 | 33 | </span> | |
| 34 | 34 | <span class="search-label">Go to…</span> | |
| 35 | 35 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 36 | 36 | </button> | |
| 37 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 37 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 38 | 38 | </div> | |
| 39 | 39 | </div> | |
| 40 | 40 | </header> | |
| @@ -42,7 +42,7 @@ | |||
| 42 | 42 | <main id="main" tabindex="-1"> | |
| 43 | 43 | <div class="filter"> | |
| 44 | 44 | <label for="home-filter" class="sr">filter</label> | |
| 45 | - | <input id="home-filter" type="search" data-filter placeholder="Filter repositories…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')"> | |
| 45 | + | <input id="home-filter" type="search" data-filter="#repo-rows > tr" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 46 | 46 | </div> | |
| 47 | 47 | <table class="list home" aria-label="Repositories"> | |
| 48 | 48 | <thead> | |
| @@ -109,6 +109,8 @@ | |||
| 109 | 109 | </span> | |
| 110 | 110 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 111 | 111 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 112 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 113 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 112 | 114 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 113 | 115 | gap -> ermiteLive.reload()"> | |
| 114 | 116 | </span> | |
| @@ -212,13 +214,7 @@ | |||
| 212 | 214 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 213 | 215 | <div class="panel-head"> | |
| 214 | 216 | <label for="cmd-q" class="sr">go to</label> | |
| 215 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 216 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 217 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 218 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 219 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 220 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 221 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 217 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 222 | 218 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 223 | 219 | <kbd>esc</kbd> | |
| 224 | 220 | </button> | |
| @@ -253,67 +249,27 @@ | |||
| 253 | 249 | <kbd>esc</kbd> close</span> | |
| 254 | 250 | </p> | |
| 255 | 251 | </dialog> | |
| 256 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 257 | - | if (q('dialog[open]').count) return; | |
| 258 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 259 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 260 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 261 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 262 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 263 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 264 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 265 | - | })(1); | |
| 266 | - | k-k from:body -> (async (d) => { | |
| 267 | - | if (q('dialog[open]').count) return; | |
| 268 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 269 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 270 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 271 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 272 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 273 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 274 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 275 | - | })(-1); | |
| 276 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 277 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 278 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 279 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 280 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 281 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 282 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 283 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 284 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 285 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 286 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 287 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 288 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 289 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 290 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 291 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 292 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 293 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 294 | - | k-next from:body -> { | |
| 295 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 296 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 297 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 298 | - | }; | |
| 299 | - | k-split from:body -> { | |
| 300 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 301 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 302 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 303 | - | }; | |
| 304 | - | k-unified from:body -> { | |
| 305 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 306 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 307 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 308 | - | }; | |
| 309 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 310 | - | k-permalink from:body -> { | |
| 311 | - | let b = document.body.dataset; | |
| 312 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 313 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 314 | - | navigator.clipboard?.writeText(location.href); | |
| 315 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 316 | - | }"> | |
| 252 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 253 | + | k-k from:body -> ermite.move(-1); | |
| 254 | + | k-top from:body -> ermite.edge(false); | |
| 255 | + | k-bottom from:body -> ermite.edge(true); | |
| 256 | + | k-open from:body -> ermite.open(); | |
| 257 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 258 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 259 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 260 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 261 | + | k-filter from:body -> ermite.focusFilter(); | |
| 262 | + | k-gh from:body -> ermite.go('.brand'); | |
| 263 | + | k-s from:body -> ermite.tab('s'); | |
| 264 | + | k-t from:body -> ermite.tab('t'); | |
| 265 | + | k-c from:body -> ermite.tab('c'); | |
| 266 | + | k-r from:body -> ermite.tab('r'); | |
| 267 | + | k-m from:body -> ermite.tab('m'); | |
| 268 | + | k-next from:body -> ermite.nextNote(); | |
| 269 | + | k-split from:body -> ermite.layout('split'); | |
| 270 | + | k-unified from:body -> ermite.layout('unified'); | |
| 271 | + | k-collapse from:body -> ermite.collapse(); | |
| 272 | + | k-permalink from:body -> ermite.permalink()"> | |
| 317 | 273 | </div> | |
| 318 | 274 | </body> | |
| 319 | 275 | </html> | |
M cmd/ermite/testdata/web/not-found-page.html +24 -70
| @@ -35,13 +35,13 @@ | |||
| 35 | 35 | </ol> | |
| 36 | 36 | </nav> | |
| 37 | 37 | <div class="hdr-acts"> | |
| 38 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 38 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 39 | 39 | <span class="ico i-search" aria-hidden="true"> | |
| 40 | 40 | </span> | |
| 41 | 41 | <span class="search-label">Go to…</span> | |
| 42 | 42 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 43 | 43 | </button> | |
| 44 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 44 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 45 | 45 | </div> | |
| 46 | 46 | </div> | |
| 47 | 47 | </header> | |
| @@ -171,13 +171,7 @@ | |||
| 171 | 171 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 172 | 172 | <div class="panel-head"> | |
| 173 | 173 | <label for="cmd-q" class="sr">go to</label> | |
| 174 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 175 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 176 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 177 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 178 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 179 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 180 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 174 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 181 | 175 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 182 | 176 | <kbd>esc</kbd> | |
| 183 | 177 | </button> | |
| @@ -200,67 +194,27 @@ | |||
| 200 | 194 | <kbd>esc</kbd> close</span> | |
| 201 | 195 | </p> | |
| 202 | 196 | </dialog> | |
| 203 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 204 | - | if (q('dialog[open]').count) return; | |
| 205 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 206 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 207 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 208 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 209 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 210 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 211 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 212 | - | })(1); | |
| 213 | - | k-k from:body -> (async (d) => { | |
| 214 | - | if (q('dialog[open]').count) return; | |
| 215 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 216 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 217 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 218 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 219 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 220 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 221 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 222 | - | })(-1); | |
| 223 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 224 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 225 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 226 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 227 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 228 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 229 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 230 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 231 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 232 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 233 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 234 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 235 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 236 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 237 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 238 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 239 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 240 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 241 | - | k-next from:body -> { | |
| 242 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 243 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 244 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 245 | - | }; | |
| 246 | - | k-split from:body -> { | |
| 247 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 248 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 249 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 250 | - | }; | |
| 251 | - | k-unified from:body -> { | |
| 252 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 253 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 254 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 255 | - | }; | |
| 256 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 257 | - | k-permalink from:body -> { | |
| 258 | - | let b = document.body.dataset; | |
| 259 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 260 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 261 | - | navigator.clipboard?.writeText(location.href); | |
| 262 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 263 | - | }"> | |
| 197 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 198 | + | k-k from:body -> ermite.move(-1); | |
| 199 | + | k-top from:body -> ermite.edge(false); | |
| 200 | + | k-bottom from:body -> ermite.edge(true); | |
| 201 | + | k-open from:body -> ermite.open(); | |
| 202 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 203 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 204 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 205 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 206 | + | k-filter from:body -> ermite.focusFilter(); | |
| 207 | + | k-gh from:body -> ermite.go('.brand'); | |
| 208 | + | k-s from:body -> ermite.tab('s'); | |
| 209 | + | k-t from:body -> ermite.tab('t'); | |
| 210 | + | k-c from:body -> ermite.tab('c'); | |
| 211 | + | k-r from:body -> ermite.tab('r'); | |
| 212 | + | k-m from:body -> ermite.tab('m'); | |
| 213 | + | k-next from:body -> ermite.nextNote(); | |
| 214 | + | k-split from:body -> ermite.layout('split'); | |
| 215 | + | k-unified from:body -> ermite.layout('unified'); | |
| 216 | + | k-collapse from:body -> ermite.collapse(); | |
| 217 | + | k-permalink from:body -> ermite.permalink()"> | |
| 264 | 218 | </div> | |
| 265 | 219 | </body> | |
| 266 | 220 | </html> | |
M cmd/ermite/testdata/web/not-found.html +24 -70
| @@ -35,13 +35,13 @@ | |||
| 35 | 35 | </ol> | |
| 36 | 36 | </nav> | |
| 37 | 37 | <div class="hdr-acts"> | |
| 38 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 38 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 39 | 39 | <span class="ico i-search" aria-hidden="true"> | |
| 40 | 40 | </span> | |
| 41 | 41 | <span class="search-label">Go to…</span> | |
| 42 | 42 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 43 | 43 | </button> | |
| 44 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 44 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 45 | 45 | </div> | |
| 46 | 46 | </div> | |
| 47 | 47 | </header> | |
| @@ -171,13 +171,7 @@ | |||
| 171 | 171 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 172 | 172 | <div class="panel-head"> | |
| 173 | 173 | <label for="cmd-q" class="sr">go to</label> | |
| 174 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 175 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 176 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 177 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 178 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 179 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 180 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 174 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 181 | 175 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 182 | 176 | <kbd>esc</kbd> | |
| 183 | 177 | </button> | |
| @@ -200,67 +194,27 @@ | |||
| 200 | 194 | <kbd>esc</kbd> close</span> | |
| 201 | 195 | </p> | |
| 202 | 196 | </dialog> | |
| 203 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 204 | - | if (q('dialog[open]').count) return; | |
| 205 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 206 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 207 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 208 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 209 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 210 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 211 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 212 | - | })(1); | |
| 213 | - | k-k from:body -> (async (d) => { | |
| 214 | - | if (q('dialog[open]').count) return; | |
| 215 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 216 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 217 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 218 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 219 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 220 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 221 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 222 | - | })(-1); | |
| 223 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 224 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 225 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 226 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 227 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 228 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 229 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 230 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 231 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 232 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 233 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 234 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 235 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 236 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 237 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 238 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 239 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 240 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 241 | - | k-next from:body -> { | |
| 242 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 243 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 244 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 245 | - | }; | |
| 246 | - | k-split from:body -> { | |
| 247 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 248 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 249 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 250 | - | }; | |
| 251 | - | k-unified from:body -> { | |
| 252 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 253 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 254 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 255 | - | }; | |
| 256 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 257 | - | k-permalink from:body -> { | |
| 258 | - | let b = document.body.dataset; | |
| 259 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 260 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 261 | - | navigator.clipboard?.writeText(location.href); | |
| 262 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 263 | - | }"> | |
| 197 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 198 | + | k-k from:body -> ermite.move(-1); | |
| 199 | + | k-top from:body -> ermite.edge(false); | |
| 200 | + | k-bottom from:body -> ermite.edge(true); | |
| 201 | + | k-open from:body -> ermite.open(); | |
| 202 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 203 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 204 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 205 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 206 | + | k-filter from:body -> ermite.focusFilter(); | |
| 207 | + | k-gh from:body -> ermite.go('.brand'); | |
| 208 | + | k-s from:body -> ermite.tab('s'); | |
| 209 | + | k-t from:body -> ermite.tab('t'); | |
| 210 | + | k-c from:body -> ermite.tab('c'); | |
| 211 | + | k-r from:body -> ermite.tab('r'); | |
| 212 | + | k-m from:body -> ermite.tab('m'); | |
| 213 | + | k-next from:body -> ermite.nextNote(); | |
| 214 | + | k-split from:body -> ermite.layout('split'); | |
| 215 | + | k-unified from:body -> ermite.layout('unified'); | |
| 216 | + | k-collapse from:body -> ermite.collapse(); | |
| 217 | + | k-permalink from:body -> ermite.permalink()"> | |
| 264 | 218 | </div> | |
| 265 | 219 | </body> | |
| 266 | 220 | </html> | |
M cmd/ermite/testdata/web/refs.html +30 -75
| @@ -38,19 +38,19 @@ | |||
| 38 | 38 | </ol> | |
| 39 | 39 | </nav> | |
| 40 | 40 | <div class="hdr-acts"> | |
| 41 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 41 | + | <a class="btn ref" href="/demo/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 42 | 42 | <span class="ico i-branch" aria-hidden="true"> | |
| 43 | 43 | </span> | |
| 44 | 44 | <span class="ref-name">main</span> | |
| 45 | 45 | <span class="sha">SHA7</span> | |
| 46 | - | </button> | |
| 47 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 46 | + | </a> | |
| 47 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 48 | 48 | <span class="ico i-search" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="search-label">Go to…</span> | |
| 51 | 51 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 52 | 52 | </button> | |
| 53 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 53 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 54 | 54 | </div> | |
| 55 | 55 | </div> | |
| 56 | 56 | <nav class="tabs" aria-label="Sections"> | |
| @@ -82,8 +82,7 @@ | |||
| 82 | 82 | <main id="main" tabindex="-1"> | |
| 83 | 83 | <div class="filter"> | |
| 84 | 84 | <label for="ref-filter" class="sr">filter</label> | |
| 85 | - | <input id="ref-filter" type="search" data-filter placeholder="Filter branches and tags…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 86 | - | let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"> | |
| 85 | + | <input id="ref-filter" type="search" data-filter="#ref-rows > tr" placeholder="Filter branches and tags…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 87 | 86 | </div> | |
| 88 | 87 | <table class="list refs" aria-label="Branches and tags"> | |
| 89 | 88 | <thead> | |
| @@ -193,6 +192,8 @@ | |||
| 193 | 192 | </span> | |
| 194 | 193 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&view=refs" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 195 | 194 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 195 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 196 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 196 | 197 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 197 | 198 | gap -> ermiteLive.reload()"> | |
| 198 | 199 | </span> | |
| @@ -296,7 +297,7 @@ | |||
| 296 | 297 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 297 | 298 | <div class="panel-head"> | |
| 298 | 299 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 299 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 300 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 300 | 301 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 301 | 302 | <kbd>esc</kbd> | |
| 302 | 303 | </button> | |
| @@ -335,13 +336,7 @@ | |||
| 335 | 336 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 336 | 337 | <div class="panel-head"> | |
| 337 | 338 | <label for="cmd-q" class="sr">go to</label> | |
| 338 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 339 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 340 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 341 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 342 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 343 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 344 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 339 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 345 | 340 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 346 | 341 | <kbd>esc</kbd> | |
| 347 | 342 | </button> | |
| @@ -424,67 +419,27 @@ | |||
| 424 | 419 | <kbd>esc</kbd> close</span> | |
| 425 | 420 | </p> | |
| 426 | 421 | </dialog> | |
| 427 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 428 | - | if (q('dialog[open]').count) return; | |
| 429 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 430 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 431 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 432 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 433 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 434 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 435 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 436 | - | })(1); | |
| 437 | - | k-k from:body -> (async (d) => { | |
| 438 | - | if (q('dialog[open]').count) return; | |
| 439 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 440 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 441 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 442 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 443 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 444 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 445 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 446 | - | })(-1); | |
| 447 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 448 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 449 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 450 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 451 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 452 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 453 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 454 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 455 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 456 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 457 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 458 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 459 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 460 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 461 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 462 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 463 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 464 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 465 | - | k-next from:body -> { | |
| 466 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 467 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 468 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 469 | - | }; | |
| 470 | - | k-split from:body -> { | |
| 471 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 472 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 473 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 474 | - | }; | |
| 475 | - | k-unified from:body -> { | |
| 476 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 477 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 478 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 479 | - | }; | |
| 480 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 481 | - | k-permalink from:body -> { | |
| 482 | - | let b = document.body.dataset; | |
| 483 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 484 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 485 | - | navigator.clipboard?.writeText(location.href); | |
| 486 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 487 | - | }"> | |
| 422 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 423 | + | k-k from:body -> ermite.move(-1); | |
| 424 | + | k-top from:body -> ermite.edge(false); | |
| 425 | + | k-bottom from:body -> ermite.edge(true); | |
| 426 | + | k-open from:body -> ermite.open(); | |
| 427 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 428 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 429 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 430 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 431 | + | k-filter from:body -> ermite.focusFilter(); | |
| 432 | + | k-gh from:body -> ermite.go('.brand'); | |
| 433 | + | k-s from:body -> ermite.tab('s'); | |
| 434 | + | k-t from:body -> ermite.tab('t'); | |
| 435 | + | k-c from:body -> ermite.tab('c'); | |
| 436 | + | k-r from:body -> ermite.tab('r'); | |
| 437 | + | k-m from:body -> ermite.tab('m'); | |
| 438 | + | k-next from:body -> ermite.nextNote(); | |
| 439 | + | k-split from:body -> ermite.layout('split'); | |
| 440 | + | k-unified from:body -> ermite.layout('unified'); | |
| 441 | + | k-collapse from:body -> ermite.collapse(); | |
| 442 | + | k-permalink from:body -> ermite.permalink()"> | |
| 488 | 443 | </div> | |
| 489 | 444 | </body> | |
| 490 | 445 | </html> | |
M cmd/ermite/testdata/web/summary.html +30 -74
| @@ -35,19 +35,19 @@ | |||
| 35 | 35 | </ol> | |
| 36 | 36 | </nav> | |
| 37 | 37 | <div class="hdr-acts"> | |
| 38 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 38 | + | <a class="btn ref" href="/demo/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 39 | 39 | <span class="ico i-branch" aria-hidden="true"> | |
| 40 | 40 | </span> | |
| 41 | 41 | <span class="ref-name">main</span> | |
| 42 | 42 | <span class="sha">SHA7</span> | |
| 43 | - | </button> | |
| 44 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 43 | + | </a> | |
| 44 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 45 | 45 | <span class="ico i-search" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="search-label">Go to…</span> | |
| 48 | 48 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 49 | 49 | </button> | |
| 50 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 50 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 51 | 51 | </div> | |
| 52 | 52 | </div> | |
| 53 | 53 | <nav class="tabs" aria-label="Sections"> | |
| @@ -117,7 +117,7 @@ | |||
| 117 | 117 | <div class="clone-line"> | |
| 118 | 118 | <span class="mute" aria-hidden="true">$ </span> | |
| 119 | 119 | <code>git clone http://example.test/demo.git</code> | |
| 120 | - | <button type="button" class="btn copy" data-label="Copy" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button> | |
| 120 | + | <button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="ermite.copy(this)">Copy</button> | |
| 121 | 121 | </div> | |
| 122 | 122 | </div> | |
| 123 | 123 | </section> | |
| @@ -211,6 +211,8 @@ | |||
| 211 | 211 | </span> | |
| 212 | 212 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&view=summary" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 213 | 213 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 214 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 215 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 214 | 216 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 215 | 217 | gap -> ermiteLive.reload()"> | |
| 216 | 218 | </span> | |
| @@ -314,7 +316,7 @@ | |||
| 314 | 316 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 315 | 317 | <div class="panel-head"> | |
| 316 | 318 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 317 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 319 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 318 | 320 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 319 | 321 | <kbd>esc</kbd> | |
| 320 | 322 | </button> | |
| @@ -353,13 +355,7 @@ | |||
| 353 | 355 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 354 | 356 | <div class="panel-head"> | |
| 355 | 357 | <label for="cmd-q" class="sr">go to</label> | |
| 356 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 357 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 358 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 359 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 360 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 361 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 362 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 358 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 363 | 359 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 364 | 360 | <kbd>esc</kbd> | |
| 365 | 361 | </button> | |
| @@ -442,67 +438,27 @@ | |||
| 442 | 438 | <kbd>esc</kbd> close</span> | |
| 443 | 439 | </p> | |
| 444 | 440 | </dialog> | |
| 445 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 446 | - | if (q('dialog[open]').count) return; | |
| 447 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 448 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 449 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 450 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 451 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 452 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 453 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 454 | - | })(1); | |
| 455 | - | k-k from:body -> (async (d) => { | |
| 456 | - | if (q('dialog[open]').count) return; | |
| 457 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 458 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 459 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 460 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 461 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 462 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 463 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 464 | - | })(-1); | |
| 465 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 466 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 467 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 468 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 469 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 470 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 471 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 472 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 473 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 474 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 475 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 476 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 477 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 478 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 479 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 480 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 481 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 482 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 483 | - | k-next from:body -> { | |
| 484 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 485 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 486 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 487 | - | }; | |
| 488 | - | k-split from:body -> { | |
| 489 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 490 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 491 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 492 | - | }; | |
| 493 | - | k-unified from:body -> { | |
| 494 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 495 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 496 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 497 | - | }; | |
| 498 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 499 | - | k-permalink from:body -> { | |
| 500 | - | let b = document.body.dataset; | |
| 501 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 502 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 503 | - | navigator.clipboard?.writeText(location.href); | |
| 504 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 505 | - | }"> | |
| 441 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 442 | + | k-k from:body -> ermite.move(-1); | |
| 443 | + | k-top from:body -> ermite.edge(false); | |
| 444 | + | k-bottom from:body -> ermite.edge(true); | |
| 445 | + | k-open from:body -> ermite.open(); | |
| 446 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 447 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 448 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 449 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 450 | + | k-filter from:body -> ermite.focusFilter(); | |
| 451 | + | k-gh from:body -> ermite.go('.brand'); | |
| 452 | + | k-s from:body -> ermite.tab('s'); | |
| 453 | + | k-t from:body -> ermite.tab('t'); | |
| 454 | + | k-c from:body -> ermite.tab('c'); | |
| 455 | + | k-r from:body -> ermite.tab('r'); | |
| 456 | + | k-m from:body -> ermite.tab('m'); | |
| 457 | + | k-next from:body -> ermite.nextNote(); | |
| 458 | + | k-split from:body -> ermite.layout('split'); | |
| 459 | + | k-unified from:body -> ermite.layout('unified'); | |
| 460 | + | k-collapse from:body -> ermite.collapse(); | |
| 461 | + | k-permalink from:body -> ermite.permalink()"> | |
| 506 | 462 | </div> | |
| 507 | 463 | </body> | |
| 508 | 464 | </html> | |
M cmd/ermite/testdata/web/tree-nested.html +28 -74
| @@ -47,19 +47,19 @@ | |||
| 47 | 47 | </ol> | |
| 48 | 48 | </nav> | |
| 49 | 49 | <div class="hdr-acts"> | |
| 50 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 50 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 51 | 51 | <span class="ico i-branch" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="ref-name">main</span> | |
| 54 | 54 | <span class="sha">SHA7</span> | |
| 55 | - | </button> | |
| 56 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 55 | + | </a> | |
| 56 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 57 | 57 | <span class="ico i-search" aria-hidden="true"> | |
| 58 | 58 | </span> | |
| 59 | 59 | <span class="search-label">Go to…</span> | |
| 60 | 60 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 61 | 61 | </button> | |
| 62 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 62 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 63 | 63 | </div> | |
| 64 | 64 | </div> | |
| 65 | 65 | <nav class="tabs" aria-label="Sections"> | |
| @@ -91,7 +91,7 @@ | |||
| 91 | 91 | <main id="main" tabindex="-1"> | |
| 92 | 92 | <div class="filter"> | |
| 93 | 93 | <label for="tree-filter" class="sr">filter</label> | |
| 94 | - | <input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |
| 94 | + | <input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 95 | 95 | </div> | |
| 96 | 96 | <table class="list tree" aria-label="Entries"> | |
| 97 | 97 | <thead> | |
| @@ -241,7 +241,7 @@ | |||
| 241 | 241 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 242 | 242 | <div class="panel-head"> | |
| 243 | 243 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 244 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 244 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 245 | 245 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 246 | 246 | <kbd>esc</kbd> | |
| 247 | 247 | </button> | |
| @@ -276,13 +276,7 @@ | |||
| 276 | 276 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 277 | 277 | <div class="panel-head"> | |
| 278 | 278 | <label for="cmd-q" class="sr">go to</label> | |
| 279 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 280 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 281 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 282 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 283 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 284 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 285 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 279 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 286 | 280 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 287 | 281 | <kbd>esc</kbd> | |
| 288 | 282 | </button> | |
| @@ -359,67 +353,27 @@ | |||
| 359 | 353 | <kbd>esc</kbd> close</span> | |
| 360 | 354 | </p> | |
| 361 | 355 | </dialog> | |
| 362 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 363 | - | if (q('dialog[open]').count) return; | |
| 364 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 365 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 366 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 367 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 368 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 369 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 370 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 371 | - | })(1); | |
| 372 | - | k-k from:body -> (async (d) => { | |
| 373 | - | if (q('dialog[open]').count) return; | |
| 374 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 375 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 376 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 377 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 378 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 379 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 380 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 381 | - | })(-1); | |
| 382 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 383 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 384 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 385 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 386 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 387 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 388 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 389 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 390 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 391 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 392 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 393 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 394 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 395 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 396 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 397 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 398 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 399 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 400 | - | k-next from:body -> { | |
| 401 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 402 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 403 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 404 | - | }; | |
| 405 | - | k-split from:body -> { | |
| 406 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 407 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 408 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 409 | - | }; | |
| 410 | - | k-unified from:body -> { | |
| 411 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 412 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 413 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 414 | - | }; | |
| 415 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 416 | - | k-permalink from:body -> { | |
| 417 | - | let b = document.body.dataset; | |
| 418 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 419 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 420 | - | navigator.clipboard?.writeText(location.href); | |
| 421 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 422 | - | }"> | |
| 356 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 357 | + | k-k from:body -> ermite.move(-1); | |
| 358 | + | k-top from:body -> ermite.edge(false); | |
| 359 | + | k-bottom from:body -> ermite.edge(true); | |
| 360 | + | k-open from:body -> ermite.open(); | |
| 361 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 362 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 363 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 364 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 365 | + | k-filter from:body -> ermite.focusFilter(); | |
| 366 | + | k-gh from:body -> ermite.go('.brand'); | |
| 367 | + | k-s from:body -> ermite.tab('s'); | |
| 368 | + | k-t from:body -> ermite.tab('t'); | |
| 369 | + | k-c from:body -> ermite.tab('c'); | |
| 370 | + | k-r from:body -> ermite.tab('r'); | |
| 371 | + | k-m from:body -> ermite.tab('m'); | |
| 372 | + | k-next from:body -> ermite.nextNote(); | |
| 373 | + | k-split from:body -> ermite.layout('split'); | |
| 374 | + | k-unified from:body -> ermite.layout('unified'); | |
| 375 | + | k-collapse from:body -> ermite.collapse(); | |
| 376 | + | k-permalink from:body -> ermite.permalink()"> | |
| 423 | 377 | </div> | |
| 424 | 378 | </body> | |
| 425 | 379 | </html> | |
M cmd/ermite/testdata/web/tree-root.html +28 -74
| @@ -41,19 +41,19 @@ | |||
| 41 | 41 | </ol> | |
| 42 | 42 | </nav> | |
| 43 | 43 | <div class="hdr-acts"> | |
| 44 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 44 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 45 | 45 | <span class="ico i-branch" aria-hidden="true"> | |
| 46 | 46 | </span> | |
| 47 | 47 | <span class="ref-name">main</span> | |
| 48 | 48 | <span class="sha">SHA7</span> | |
| 49 | - | </button> | |
| 50 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 49 | + | </a> | |
| 50 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 51 | 51 | <span class="ico i-search" aria-hidden="true"> | |
| 52 | 52 | </span> | |
| 53 | 53 | <span class="search-label">Go to…</span> | |
| 54 | 54 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 55 | 55 | </button> | |
| 56 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 56 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | <nav class="tabs" aria-label="Sections"> | |
| @@ -85,7 +85,7 @@ | |||
| 85 | 85 | <main id="main" tabindex="-1"> | |
| 86 | 86 | <div class="filter"> | |
| 87 | 87 | <label for="tree-filter" class="sr">filter</label> | |
| 88 | - | <input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |
| 88 | + | <input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 89 | 89 | </div> | |
| 90 | 90 | <table class="list tree" aria-label="Entries"> | |
| 91 | 91 | <thead> | |
| @@ -468,7 +468,7 @@ | |||
| 468 | 468 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 469 | 469 | <div class="panel-head"> | |
| 470 | 470 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 471 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 471 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 472 | 472 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 473 | 473 | <kbd>esc</kbd> | |
| 474 | 474 | </button> | |
| @@ -503,13 +503,7 @@ | |||
| 503 | 503 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 504 | 504 | <div class="panel-head"> | |
| 505 | 505 | <label for="cmd-q" class="sr">go to</label> | |
| 506 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 507 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 508 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 509 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 510 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 511 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 512 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 506 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 513 | 507 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 514 | 508 | <kbd>esc</kbd> | |
| 515 | 509 | </button> | |
| @@ -658,67 +652,27 @@ | |||
| 658 | 652 | <kbd>esc</kbd> close</span> | |
| 659 | 653 | </p> | |
| 660 | 654 | </dialog> | |
| 661 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 662 | - | if (q('dialog[open]').count) return; | |
| 663 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 664 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 665 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 666 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 667 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 668 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 669 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 670 | - | })(1); | |
| 671 | - | k-k from:body -> (async (d) => { | |
| 672 | - | if (q('dialog[open]').count) return; | |
| 673 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 674 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 675 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 676 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 677 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 678 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 679 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 680 | - | })(-1); | |
| 681 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 682 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 683 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 684 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 685 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 686 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 687 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 688 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 689 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 690 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 691 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 692 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 693 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 694 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 695 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 696 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 697 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 698 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 699 | - | k-next from:body -> { | |
| 700 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 701 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 702 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 703 | - | }; | |
| 704 | - | k-split from:body -> { | |
| 705 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 706 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 707 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 708 | - | }; | |
| 709 | - | k-unified from:body -> { | |
| 710 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 711 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 712 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 713 | - | }; | |
| 714 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 715 | - | k-permalink from:body -> { | |
| 716 | - | let b = document.body.dataset; | |
| 717 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 718 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 719 | - | navigator.clipboard?.writeText(location.href); | |
| 720 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 721 | - | }"> | |
| 655 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 656 | + | k-k from:body -> ermite.move(-1); | |
| 657 | + | k-top from:body -> ermite.edge(false); | |
| 658 | + | k-bottom from:body -> ermite.edge(true); | |
| 659 | + | k-open from:body -> ermite.open(); | |
| 660 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 661 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 662 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 663 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 664 | + | k-filter from:body -> ermite.focusFilter(); | |
| 665 | + | k-gh from:body -> ermite.go('.brand'); | |
| 666 | + | k-s from:body -> ermite.tab('s'); | |
| 667 | + | k-t from:body -> ermite.tab('t'); | |
| 668 | + | k-c from:body -> ermite.tab('c'); | |
| 669 | + | k-r from:body -> ermite.tab('r'); | |
| 670 | + | k-m from:body -> ermite.tab('m'); | |
| 671 | + | k-next from:body -> ermite.nextNote(); | |
| 672 | + | k-split from:body -> ermite.layout('split'); | |
| 673 | + | k-unified from:body -> ermite.layout('unified'); | |
| 674 | + | k-collapse from:body -> ermite.collapse(); | |
| 675 | + | k-permalink from:body -> ermite.permalink()"> | |
| 722 | 676 | </div> | |
| 723 | 677 | </body> | |
| 724 | 678 | </html> | |
M cmd/ermite/testdata/web/tree-slash-ref.html +28 -74
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">feature/x</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -88,7 +88,7 @@ | |||
| 88 | 88 | <main id="main" tabindex="-1"> | |
| 89 | 89 | <div class="filter"> | |
| 90 | 90 | <label for="tree-filter" class="sr">filter</label> | |
| 91 | - | <input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |
| 91 | + | <input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 92 | 92 | </div> | |
| 93 | 93 | <table class="list tree" aria-label="Entries"> | |
| 94 | 94 | <thead> | |
| @@ -238,7 +238,7 @@ | |||
| 238 | 238 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 239 | 239 | <div class="panel-head"> | |
| 240 | 240 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 241 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 241 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 242 | 242 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 243 | 243 | <kbd>esc</kbd> | |
| 244 | 244 | </button> | |
| @@ -273,13 +273,7 @@ | |||
| 273 | 273 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 274 | 274 | <div class="panel-head"> | |
| 275 | 275 | <label for="cmd-q" class="sr">go to</label> | |
| 276 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 277 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 278 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 279 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 280 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 281 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 282 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 276 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 283 | 277 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 284 | 278 | <kbd>esc</kbd> | |
| 285 | 279 | </button> | |
| @@ -356,67 +350,27 @@ | |||
| 356 | 350 | <kbd>esc</kbd> close</span> | |
| 357 | 351 | </p> | |
| 358 | 352 | </dialog> | |
| 359 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 360 | - | if (q('dialog[open]').count) return; | |
| 361 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 362 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 363 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 364 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 365 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 366 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 367 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 368 | - | })(1); | |
| 369 | - | k-k from:body -> (async (d) => { | |
| 370 | - | if (q('dialog[open]').count) return; | |
| 371 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 372 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 373 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 374 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 375 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 376 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 377 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 378 | - | })(-1); | |
| 379 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 380 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 381 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 382 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 383 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 384 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 385 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 386 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 387 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 388 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 389 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 390 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 391 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 392 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 393 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 394 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 395 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 396 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 397 | - | k-next from:body -> { | |
| 398 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 399 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 400 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 401 | - | }; | |
| 402 | - | k-split from:body -> { | |
| 403 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 404 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 405 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 406 | - | }; | |
| 407 | - | k-unified from:body -> { | |
| 408 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 409 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 410 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 411 | - | }; | |
| 412 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 413 | - | k-permalink from:body -> { | |
| 414 | - | let b = document.body.dataset; | |
| 415 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 416 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 417 | - | navigator.clipboard?.writeText(location.href); | |
| 418 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 419 | - | }"> | |
| 353 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 354 | + | k-k from:body -> ermite.move(-1); | |
| 355 | + | k-top from:body -> ermite.edge(false); | |
| 356 | + | k-bottom from:body -> ermite.edge(true); | |
| 357 | + | k-open from:body -> ermite.open(); | |
| 358 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 359 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 360 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 361 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 362 | + | k-filter from:body -> ermite.focusFilter(); | |
| 363 | + | k-gh from:body -> ermite.go('.brand'); | |
| 364 | + | k-s from:body -> ermite.tab('s'); | |
| 365 | + | k-t from:body -> ermite.tab('t'); | |
| 366 | + | k-c from:body -> ermite.tab('c'); | |
| 367 | + | k-r from:body -> ermite.tab('r'); | |
| 368 | + | k-m from:body -> ermite.tab('m'); | |
| 369 | + | k-next from:body -> ermite.nextNote(); | |
| 370 | + | k-split from:body -> ermite.layout('split'); | |
| 371 | + | k-unified from:body -> ermite.layout('unified'); | |
| 372 | + | k-collapse from:body -> ermite.collapse(); | |
| 373 | + | k-permalink from:body -> ermite.permalink()"> | |
| 420 | 374 | </div> | |
| 421 | 375 | </body> | |
| 422 | 376 | </html> | |
M cmd/ermite/testdata/web/tree-subdir.html +28 -74
| @@ -44,19 +44,19 @@ | |||
| 44 | 44 | </ol> | |
| 45 | 45 | </nav> | |
| 46 | 46 | <div class="hdr-acts"> | |
| 47 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 47 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 48 | 48 | <span class="ico i-branch" aria-hidden="true"> | |
| 49 | 49 | </span> | |
| 50 | 50 | <span class="ref-name">main</span> | |
| 51 | 51 | <span class="sha">SHA7</span> | |
| 52 | - | </button> | |
| 53 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 52 | + | </a> | |
| 53 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 54 | 54 | <span class="ico i-search" aria-hidden="true"> | |
| 55 | 55 | </span> | |
| 56 | 56 | <span class="search-label">Go to…</span> | |
| 57 | 57 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 58 | 58 | </button> | |
| 59 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 59 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 60 | 60 | </div> | |
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="tabs" aria-label="Sections"> | |
| @@ -88,7 +88,7 @@ | |||
| 88 | 88 | <main id="main" tabindex="-1"> | |
| 89 | 89 | <div class="filter"> | |
| 90 | 90 | <label for="tree-filter" class="sr">filter</label> | |
| 91 | - | <input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |
| 91 | + | <input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 92 | 92 | </div> | |
| 93 | 93 | <table class="list tree" aria-label="Entries"> | |
| 94 | 94 | <thead> | |
| @@ -263,7 +263,7 @@ | |||
| 263 | 263 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 264 | 264 | <div class="panel-head"> | |
| 265 | 265 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 266 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 266 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 267 | 267 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 268 | 268 | <kbd>esc</kbd> | |
| 269 | 269 | </button> | |
| @@ -298,13 +298,7 @@ | |||
| 298 | 298 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 299 | 299 | <div class="panel-head"> | |
| 300 | 300 | <label for="cmd-q" class="sr">go to</label> | |
| 301 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 302 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 303 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 304 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 305 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 306 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 307 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 301 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 308 | 302 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 309 | 303 | <kbd>esc</kbd> | |
| 310 | 304 | </button> | |
| @@ -387,67 +381,27 @@ | |||
| 387 | 381 | <kbd>esc</kbd> close</span> | |
| 388 | 382 | </p> | |
| 389 | 383 | </dialog> | |
| 390 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 391 | - | if (q('dialog[open]').count) return; | |
| 392 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 393 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 394 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 395 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 396 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 397 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 398 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 399 | - | })(1); | |
| 400 | - | k-k from:body -> (async (d) => { | |
| 401 | - | if (q('dialog[open]').count) return; | |
| 402 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 403 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 404 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 405 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 406 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 407 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 408 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 409 | - | })(-1); | |
| 410 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 411 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 412 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 413 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 414 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 415 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 416 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 417 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 418 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 419 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 420 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 421 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 422 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 423 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 424 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 425 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 426 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 427 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 428 | - | k-next from:body -> { | |
| 429 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 430 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 431 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 432 | - | }; | |
| 433 | - | k-split from:body -> { | |
| 434 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 435 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 436 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 437 | - | }; | |
| 438 | - | k-unified from:body -> { | |
| 439 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 440 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 441 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 442 | - | }; | |
| 443 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 444 | - | k-permalink from:body -> { | |
| 445 | - | let b = document.body.dataset; | |
| 446 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 447 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 448 | - | navigator.clipboard?.writeText(location.href); | |
| 449 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 450 | - | }"> | |
| 384 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 385 | + | k-k from:body -> ermite.move(-1); | |
| 386 | + | k-top from:body -> ermite.edge(false); | |
| 387 | + | k-bottom from:body -> ermite.edge(true); | |
| 388 | + | k-open from:body -> ermite.open(); | |
| 389 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 390 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 391 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 392 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 393 | + | k-filter from:body -> ermite.focusFilter(); | |
| 394 | + | k-gh from:body -> ermite.go('.brand'); | |
| 395 | + | k-s from:body -> ermite.tab('s'); | |
| 396 | + | k-t from:body -> ermite.tab('t'); | |
| 397 | + | k-c from:body -> ermite.tab('c'); | |
| 398 | + | k-r from:body -> ermite.tab('r'); | |
| 399 | + | k-m from:body -> ermite.tab('m'); | |
| 400 | + | k-next from:body -> ermite.nextNote(); | |
| 401 | + | k-split from:body -> ermite.layout('split'); | |
| 402 | + | k-unified from:body -> ermite.layout('unified'); | |
| 403 | + | k-collapse from:body -> ermite.collapse(); | |
| 404 | + | k-permalink from:body -> ermite.permalink()"> | |
| 451 | 405 | </div> | |
| 452 | 406 | </body> | |
| 453 | 407 | </html> | |
M cmd/ermite/web_events_test.go +1 -1
| @@ -162,7 +162,7 @@ func TestWebEventsRepoFragments(t *testing.T) { | |||
| 162 | 162 | refs := h.sse("topic=repo:x&view=refs", "") | |
| 163 | 163 | ||
| 164 | 164 | _, _, page := h.get("/x/commits/main") | |
| 165 | - | require.Contains(t, page, `<div id="commits-head">`, "the slot of the banner") | |
| 165 | + | require.Contains(t, page, `<div id="commits-head" class="live-slot" role="status" data-slot>`, "the slot of the banner, a status region that stays in the page") | |
| 166 | 166 | m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page) | |
| 167 | 167 | require.Len(t, m, 2, "the commits page is live") | |
| 168 | 168 | url := html.UnescapeString(m[1]) | |
M cmd/ermite/web_mr_test.go +1 -1
| @@ -511,7 +511,7 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||
| 511 | 511 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | |
| 512 | 512 | ev := list.next(t, watchWait) | |
| 513 | 513 | assert.Equal(t, "mr-notice", ev.name) | |
| 514 | - | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-banner" role="status">`) | |
| 514 | + | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`) | |
| 515 | 515 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | |
| 516 | 516 | assert.NotContains(t, ev.data, "Secret") | |
| 517 | 517 | ||
M internal/adapters/web/ASSETS.md +1 -1
| @@ -8,7 +8,7 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 8 | 8 | | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` | | |
| 9 | 9 | | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` | | |
| 10 | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | |
| 11 | - | | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | | | |
| 11 | + | | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | | | |
| 12 | 12 | | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | | | |
| 13 | 13 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | |
| 14 | 14 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | |
M internal/adapters/web/commits.templ +4 -5
| @@ -11,19 +11,18 @@ templ Commits(l Layout, p CommitsPage) { | |||
| 11 | 11 | <input | |
| 12 | 12 | id="commit-filter" | |
| 13 | 13 | type="search" | |
| 14 | - | data-filter | |
| 14 | + | data-filter=".days tr" | |
| 15 | + | data-groups=".days section" | |
| 15 | 16 | placeholder="Filter by subject, sha or author…" | |
| 16 | 17 | autocomplete="off" | |
| 17 | - | hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 18 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 19 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')" | |
| 18 | + | hx-on:input="ermite.filter(this)" | |
| 20 | 19 | /> | |
| 21 | 20 | </div> | |
| 22 | 21 | if p.Path != "" { | |
| 23 | 22 | <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p> | |
| 24 | 23 | } | |
| 25 | 24 | if p.Live { | |
| 26 | - | <div id="commits-head"></div> | |
| 25 | + | @LiveSlot("commits-head") | |
| 27 | 26 | } | |
| 28 | 27 | <div class="days"> | |
| 29 | 28 | for _, g := range p.Days { | |
M internal/adapters/web/commits_templ.go +47 -47 generated file: collapsed
M internal/adapters/web/components.templ +2 -1
| @@ -22,9 +22,10 @@ templ CloneBlock(c Clone) { | |||
| 22 | 22 | <button | |
| 23 | 23 | type="button" | |
| 24 | 24 | class="btn copy" | |
| 25 | + | data-js | |
| 25 | 26 | data-label="Copy" | |
| 26 | 27 | aria-label={ "copy: " + line } | |
| 27 | - | hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label" | |
| 28 | + | hx-on:click="ermite.copy(this)" | |
| 28 | 29 | >Copy</button> | |
| 29 | 30 | </div> | |
| 30 | 31 | } | |
M internal/adapters/web/components_templ.go +25 -25 generated file: collapsed
M internal/adapters/web/diff.templ +3 -4
| @@ -106,8 +106,8 @@ templ viewLink(p DiffView, view, label, key, href string) { | |||
| 106 | 106 | aria-current="true" | |
| 107 | 107 | } | |
| 108 | 108 | if p.Both { | |
| 109 | - | hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" | |
| 110 | - | :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" } | |
| 109 | + | data-fire={ view } | |
| 110 | + | :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false" | |
| 111 | 111 | } | |
| 112 | 112 | >{ label }<kbd aria-hidden="true">{ key }</kbd></a> | |
| 113 | 113 | } | |
| @@ -115,7 +115,7 @@ templ viewLink(p DiffView, view, label, key, href string) { | |||
| 115 | 115 | // collapseButton folds every file of the diff, or opens them all again when | |
| 116 | 116 | // none is open; its label follows the state. | |
| 117 | 117 | templ collapseButton() { | |
| 118 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 118 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 119 | 119 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span><kbd aria-hidden="true">x</kbd> | |
| 120 | 120 | </button> | |
| 121 | 121 | } | |
| @@ -142,7 +142,6 @@ templ FileList(files []DiffFile) { | |||
| 142 | 142 | <li> | |
| 143 | 143 | <a | |
| 144 | 144 | href={ templ.SafeURL("#" + f.ID) } | |
| 145 | - | :aria-current={ "(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === '" + f.ID + "' ? 'true' : false })()" } | |
| 146 | 145 | hx-on:click="document.getElementById(this.hash.slice(1)).open = true" | |
| 147 | 146 | > | |
| 148 | 147 | <span class="st" title={ f.Status }>{ f.Status }</span> | |
M internal/adapters/web/diff_templ.go +199 -212 generated file: collapsed
M internal/adapters/web/events.go +4 -1
| @@ -51,7 +51,10 @@ import ( | |||
| 51 | 51 | // changed: the page fetches itself again (ermiteLive in ermite.js) | |
| 52 | 52 | // | |
| 53 | 53 | // The data of an event is one fragment, a top-level element with an id, one | |
| 54 | - | // "data:" line per line of HTML; the page swaps the element that has that id. | |
| 54 | + | // "data:" line per line of HTML; the page swaps the element that has that id | |
| 55 | + | // (ermiteLive.swap). The slots of the banners (commits-head, mr-notice) are | |
| 56 | + | // status regions that stay in the page: only their children are replaced, so | |
| 57 | + | // that a screen reader announces the banner. | |
| 55 | 58 | // An event that changes nothing on the page is still sent as an id alone, so | |
| 56 | 59 | // that a reconnection resumes after it. | |
| 57 | 60 | ||
M internal/adapters/web/fragments.go +1 -1
| @@ -312,7 +312,7 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||
| 312 | 312 | text = "the branch was rewritten — reload" | |
| 313 | 313 | } | |
| 314 | 314 | } | |
| 315 | - | c := CommitsSlot() | |
| 315 | + | c := LiveSlot("commits-head") | |
| 316 | 316 | if text != "" { | |
| 317 | 317 | c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref)) | |
| 318 | 318 | } | |
M internal/adapters/web/fragments.templ +22 -11
| @@ -55,31 +55,40 @@ templ RefsKV(p RefsPage) { | |||
| 55 | 55 | </dl> | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | // CommitsSlot is the place of the banner of the commits page, empty. | |
| 59 | - | templ CommitsSlot() { | |
| 60 | - | <div id="commits-head"></div> | |
| 58 | + | // LiveSlot is the place of a live banner, empty: a status region that stays | |
| 59 | + | // in the page. The live updates (ermiteLive in ermite.js) replace its children, | |
| 60 | + | // never the element, so that a screen reader announces the new banner (a | |
| 61 | + | // region that is inserted with its content is not announced). | |
| 62 | + | templ LiveSlot(id string) { | |
| 63 | + | <div id={ id } class="live-slot" role="status" data-slot></div> | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // liveBanner is the slot with its banner: a link that reloads or opens the | |
| 67 | + | // page. Only the children of the element reach the page that has the slot. | |
| 68 | + | templ liveBanner(id, text, href string) { | |
| 69 | + | <div id={ id } class="live-slot" role="status" data-slot><a href={ templ.SafeURL(href) }>{ text }</a></div> | |
| 61 | 70 | } | |
| 62 | 71 | ||
| 63 | 72 | // CommitsBanner announces new commits on the branch the page lists. It links | |
| 64 | 73 | // to the first page of the branch. | |
| 65 | 74 | templ CommitsBanner(text, href string) { | |
| 66 | - | <div id="commits-head" class="live-banner" role="status"> | |
| 67 | - | <a href={ templ.SafeURL(href) }>{ text }</a> | |
| 68 | - | </div> | |
| 75 | + | @liveBanner("commits-head", text, href) | |
| 69 | 76 | } | |
| 70 | 77 | ||
| 71 | - | // MRNotice tells a merge request page that it is out of date. The empty slot | |
| 72 | - | // of the page has the same id, so the first notice replaces it. | |
| 78 | + | // MRNotice tells a merge request page that it is out of date, or the list that | |
| 79 | + | // its content changed. | |
| 73 | 80 | templ MRNotice(text, href string) { | |
| 74 | - | <div id="mr-notice" class="live-banner" role="status"> | |
| 75 | - | <a href={ templ.SafeURL(href) }>{ text }</a> | |
| 76 | - | </div> | |
| 81 | + | @liveBanner("mr-notice", text, href) | |
| 77 | 82 | } | |
| 78 | 83 | ||
| 79 | 84 | // Live is the connection to the event stream, in the hint badge. It shows | |
| 80 | 85 | // only once a connection was lost: a page that never connected, or that is | |
| 81 | 86 | // live, says nothing. The events of the stream are fragments that ermiteLive | |
| 82 | 87 | // (ermite.js) swaps in the page. A page without a stream renders nothing. | |
| 88 | + | // | |
| 89 | + | // hx-sse retries a lost connection for ever, with a growing delay. A refusal | |
| 90 | + | // that will not change (404: the repository is gone or hidden; 401) is handled | |
| 91 | + | // by ermiteLive.error, which has the page fetch itself again. | |
| 83 | 92 | templ Live(l Layout) { | |
| 84 | 93 | if l.Live && l.LiveURL != "" { | |
| 85 | 94 | <span | |
| @@ -90,6 +99,8 @@ templ Live(l Layout) { | |||
| 90 | 99 | :text="data.state === 'offline' ? 'live updates paused' : ''" | |
| 91 | 100 | hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 92 | 101 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 102 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 103 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 93 | 104 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 94 | 105 | gap -> ermiteLive.reload()" | |
| 95 | 106 | ></span> | |
M internal/adapters/web/fragments_templ.go +94 -56 generated file: collapsed
A internal/adapters/web/fragments_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/a-h/templ" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func renderToString(t *testing.T, c templ.Component) string { | |
| 16 | + | t.Helper() | |
| 17 | + | var b bytes.Buffer | |
| 18 | + | require.NoError(t, c.Render(t.Context(), &b)) | |
| 19 | + | return b.String() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // A live banner is the content of a status region that stays in the page: the | |
| 23 | + | // empty slot of the page and the fragment of the stream are the same element | |
| 24 | + | // (same id, role and marker), so that ermiteLive can replace the children and | |
| 25 | + | // leave the region where a screen reader watches it. | |
| 26 | + | func TestLiveSlotsAreStableStatusRegions(t *testing.T) { | |
| 27 | + | for _, tt := range []struct { | |
| 28 | + | name string | |
| 29 | + | c templ.Component | |
| 30 | + | id string | |
| 31 | + | text string | |
| 32 | + | }{ | |
| 33 | + | {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""}, | |
| 34 | + | {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""}, | |
| 35 | + | {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"}, | |
| 36 | + | {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"}, | |
| 37 | + | } { | |
| 38 | + | t.Run(tt.name, func(t *testing.T) { | |
| 39 | + | got := renderToString(t, tt.c) | |
| 40 | + | assert.True(t, strings.HasPrefix(got, `<div id="`+tt.id+`" class="live-slot" role="status" data-slot>`), got) | |
| 41 | + | if tt.text == "" { | |
| 42 | + | assert.True(t, strings.HasSuffix(got, "data-slot></div>"), "an empty slot has no child: %s", got) | |
| 43 | + | } else { | |
| 44 | + | assert.Contains(t, got, tt.text) | |
| 45 | + | } | |
| 46 | + | assert.True(t, strings.HasSuffix(got, "</div>"), got) | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // The header's refs button is a link to the refs page, which the script turns | |
| 52 | + | // into the dialog; the filters and the buttons that need scripts are marked. | |
| 53 | + | func TestShellDegradesWithoutScripts(t *testing.T) { | |
| 54 | + | l := Layout{Repo: "demo", Ref: "main", Short: "abc1234", Assets: Assets{}} | |
| 55 | + | got := renderToString(t, Header(l)) | |
| 56 | + | assert.Contains(t, got, `<a class="btn ref" href="/demo/refs" data-fire="refs"`) | |
| 57 | + | assert.NotContains(t, got, "hx-on:click", "no handler is inlined in the header") | |
| 58 | + | for _, want := range []string{`class="btn search" data-js`, `class="btn icon" data-js data-fire="help"`} { | |
| 59 | + | assert.Contains(t, got, want) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Data reaches the scripts through data-* attributes, never spliced into an | |
| 64 | + | // attribute that is evaluated. | |
| 65 | + | func TestDiffControlsPassDataThroughAttributes(t *testing.T) { | |
| 66 | + | files := renderToString(t, FileList([]DiffFile{{ID: "f1'x", Path: "a.go", Status: "M"}})) | |
| 67 | + | assert.NotContains(t, files, ":aria-current", "the file in view is marked by ermite.js") | |
| 68 | + | assert.NotContains(t, files, "f1'x'", "no id is spliced into a script") | |
| 69 | + | ||
| 70 | + | link := renderToString(t, viewLink(DiffView{Both: true, View: "split"}, "split", "Split", "d", "?view=split")) | |
| 71 | + | assert.Contains(t, link, `data-fire="split"`) | |
| 72 | + | assert.Contains(t, link, `this.dataset.view`) | |
| 73 | + | assert.NotContains(t, link, "=== 'split'") | |
| 74 | + | } | |
M internal/adapters/web/keys.templ +23 -63
| @@ -3,72 +3,32 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | // Keys is the keyboard map. ermite.js turns keys into k-<name> events on | |
| 6 | - | // <body>; this element (hx-on + hx-live helpers) is the only place that | |
| 7 | - | // reacts to them. | |
| 6 | + | // <body>; this element is the only place that binds them, each to one call of | |
| 7 | + | // window.ermite (ermite.js), which does the work. | |
| 8 | 8 | templ Keys() { | |
| 9 | 9 | <div | |
| 10 | 10 | id="keys" | |
| 11 | 11 | hidden | |
| 12 | - | hx-on="k-j from:body -> (async (d) => { | |
| 13 | - | if (q('dialog[open]').count) return; | |
| 14 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 15 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 16 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 17 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 18 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 19 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 20 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 21 | - | })(1); | |
| 22 | - | k-k from:body -> (async (d) => { | |
| 23 | - | if (q('dialog[open]').count) return; | |
| 24 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 25 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 26 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 27 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 28 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 29 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 30 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 31 | - | })(-1); | |
| 32 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 33 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 34 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 35 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 36 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 37 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 38 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); | |
| 39 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 40 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 41 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 42 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 43 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 44 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 45 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 46 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 47 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 48 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 49 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 50 | - | k-next from:body -> { | |
| 51 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 52 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 53 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 54 | - | }; | |
| 55 | - | k-split from:body -> { | |
| 56 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 57 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 58 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 59 | - | }; | |
| 60 | - | k-unified from:body -> { | |
| 61 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 62 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 63 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 64 | - | }; | |
| 65 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 66 | - | k-permalink from:body -> { | |
| 67 | - | let b = document.body.dataset; | |
| 68 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 69 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 70 | - | navigator.clipboard?.writeText(location.href); | |
| 71 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 72 | - | }" | |
| 12 | + | hx-on="k-j from:body -> ermite.move(1); | |
| 13 | + | k-k from:body -> ermite.move(-1); | |
| 14 | + | k-top from:body -> ermite.edge(false); | |
| 15 | + | k-bottom from:body -> ermite.edge(true); | |
| 16 | + | k-open from:body -> ermite.open(); | |
| 17 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 18 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 19 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 20 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); | |
| 21 | + | k-filter from:body -> ermite.focusFilter(); | |
| 22 | + | k-gh from:body -> ermite.go('.brand'); | |
| 23 | + | k-s from:body -> ermite.tab('s'); | |
| 24 | + | k-t from:body -> ermite.tab('t'); | |
| 25 | + | k-c from:body -> ermite.tab('c'); | |
| 26 | + | k-r from:body -> ermite.tab('r'); | |
| 27 | + | k-m from:body -> ermite.tab('m'); | |
| 28 | + | k-next from:body -> ermite.nextNote(); | |
| 29 | + | k-split from:body -> ermite.layout('split'); | |
| 30 | + | k-unified from:body -> ermite.layout('unified'); | |
| 31 | + | k-collapse from:body -> ermite.collapse(); | |
| 32 | + | k-permalink from:body -> ermite.permalink()" | |
| 73 | 33 | ></div> | |
| 74 | 34 | } | |
M internal/adapters/web/keys_templ.go +3 -3 generated file: collapsed
M internal/adapters/web/mr.templ +4 -5
| @@ -9,18 +9,17 @@ import "strings" | |||
| 9 | 9 | templ MRList(l Layout, p MRListPage) { | |
| 10 | 10 | @Shell(l, mrListRail(p)) { | |
| 11 | 11 | if p.Live { | |
| 12 | - | <div id="mr-notice"></div> | |
| 12 | + | @LiveSlot("mr-notice") | |
| 13 | 13 | } | |
| 14 | 14 | <div class="filter"> | |
| 15 | 15 | <label for="mr-filter" class="sr">filter</label> | |
| 16 | 16 | <input | |
| 17 | 17 | id="mr-filter" | |
| 18 | 18 | type="search" | |
| 19 | - | data-filter | |
| 19 | + | data-filter=".mrs tbody tr" | |
| 20 | 20 | placeholder="Filter by title, branch, author or state…" | |
| 21 | 21 | autocomplete="off" | |
| 22 | - | hx-on:input="let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 23 | - | let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')" | |
| 22 | + | hx-on:input="ermite.filter(this)" | |
| 24 | 23 | /> | |
| 25 | 24 | </div> | |
| 26 | 25 | if p.Newest == "" { | |
| @@ -140,7 +139,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 140 | 139 | } | |
| 141 | 140 | </dl> | |
| 142 | 141 | </header> | |
| 143 | - | <div id="mr-notice"></div> | |
| 142 | + | @LiveSlot("mr-notice") | |
| 144 | 143 | if len(p.Desc) > 0 { | |
| 145 | 144 | <p class="mr-desc">@userText(p.Desc)</p> | |
| 146 | 145 | } | |
M internal/adapters/web/mr_templ.go +202 -194 generated file: collapsed
M internal/adapters/web/overlays.templ +4 -10
| @@ -37,8 +37,8 @@ templ OverlayRefs(l Layout) { | |||
| 37 | 37 | type="search" | |
| 38 | 38 | placeholder="Switch branch or tag…" | |
| 39 | 39 | autocomplete="off" | |
| 40 | - | data-pick | |
| 41 | - | hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))" | |
| 40 | + | data-pick="#panel-refs li" | |
| 41 | + | hx-on:input="ermite.filter(this)" | |
| 42 | 42 | /> | |
| 43 | 43 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button> | |
| 44 | 44 | </div> | |
| @@ -96,14 +96,8 @@ templ OverlayCmd(items []CmdItem) { | |||
| 96 | 96 | autocomplete="off" | |
| 97 | 97 | spellcheck="false" | |
| 98 | 98 | aria-controls="cmd-list" | |
| 99 | - | hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 100 | - | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 101 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" | |
| 102 | - | hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 103 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 104 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 105 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 106 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }" | |
| 99 | + | hx-on:input="ermite.cmdFilter(this)" | |
| 100 | + | hx-on:keydown="ermite.cmdKey(event)" | |
| 107 | 101 | /> | |
| 108 | 102 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button> | |
| 109 | 103 | </div> | |
M internal/adapters/web/overlays_templ.go +6 -6 generated file: collapsed
M internal/adapters/web/pages.templ +4 -5
| @@ -10,10 +10,10 @@ templ Home(l Layout, p HomePage) { | |||
| 10 | 10 | <input | |
| 11 | 11 | id="home-filter" | |
| 12 | 12 | type="search" | |
| 13 | - | data-filter | |
| 13 | + | data-filter="#repo-rows > tr" | |
| 14 | 14 | placeholder="Filter repositories…" | |
| 15 | 15 | autocomplete="off" | |
| 16 | - | hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')" | |
| 16 | + | hx-on:input="ermite.filter(this)" | |
| 17 | 17 | /> | |
| 18 | 18 | </div> | |
| 19 | 19 | <table class="list home" aria-label="Repositories"> | |
| @@ -94,11 +94,10 @@ templ Refs(l Layout, p RefsPage) { | |||
| 94 | 94 | <input | |
| 95 | 95 | id="ref-filter" | |
| 96 | 96 | type="search" | |
| 97 | - | data-filter | |
| 97 | + | data-filter="#ref-rows > tr" | |
| 98 | 98 | placeholder="Filter branches and tags…" | |
| 99 | 99 | autocomplete="off" | |
| 100 | - | hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 101 | - | let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')" | |
| 100 | + | hx-on:input="ermite.filter(this)" | |
| 102 | 101 | /> | |
| 103 | 102 | </div> | |
| 104 | 103 | <table class="list refs" aria-label="Branches and tags"> | |
M internal/adapters/web/pages_templ.go +22 -22 generated file: collapsed
M internal/adapters/web/shell.templ +7 -8
| @@ -83,34 +83,33 @@ templ Header(l Layout) { | |||
| 83 | 83 | } | |
| 84 | 84 | <div class="hdr-acts"> | |
| 85 | 85 | if l.Ref != "" { | |
| 86 | - | <button | |
| 87 | - | type="button" | |
| 86 | + | <a | |
| 88 | 87 | class="btn ref" | |
| 89 | - | aria-haspopup="dialog" | |
| 90 | - | aria-controls="panel-refs" | |
| 88 | + | href={ templ.SafeURL(repoHref(l.Repo, "refs")) } | |
| 89 | + | data-fire="refs" | |
| 91 | 90 | aria-keyshortcuts="b" | |
| 92 | 91 | title="switch branch or tag (b)" | |
| 93 | - | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))" | |
| 94 | 92 | > | |
| 95 | 93 | <span class="ico i-branch" aria-hidden="true"></span> | |
| 96 | 94 | <span class="ref-name">{ l.Ref }</span> | |
| 97 | 95 | <span class="sha">{ l.Short }</span> | |
| 98 | - | </button> | |
| 96 | + | </a> | |
| 99 | 97 | } | |
| 100 | 98 | <button | |
| 101 | 99 | type="button" | |
| 102 | 100 | class="btn search" | |
| 101 | + | data-js | |
| 103 | 102 | aria-haspopup="dialog" | |
| 104 | 103 | aria-controls="panel-cmd" | |
| 105 | 104 | aria-keyshortcuts="Control+K :" | |
| 106 | 105 | title="go to a section, ref, file or repo (ctrl k or :)" | |
| 107 | - | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))" | |
| 106 | + | data-fire="cmd" | |
| 108 | 107 | > | |
| 109 | 108 | <span class="ico i-search" aria-hidden="true"></span> | |
| 110 | 109 | <span class="search-label">Go to…</span> | |
| 111 | 110 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 112 | 111 | </button> | |
| 113 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 112 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 114 | 113 | if SignedIn(ctx) { | |
| 115 | 114 | <form class="owner" method="post" action="/-/logout"> | |
| 116 | 115 | <span class="owner-badge" title="signed in as the owner">owner</span> | |
M internal/adapters/web/shell_templ.go +74 -61
| @@ -410,143 +410,156 @@ func Header(l Layout) templ.Component { | |||
| 410 | 410 | return templ_7745c5c3_Err | |
| 411 | 411 | } | |
| 412 | 412 | if l.Ref != "" { | |
| 413 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<button type=\"button\" class=\"btn ref\" aria-haspopup=\"dialog\" aria-controls=\"panel-refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-refs'))\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">") | |
| 413 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a class=\"btn ref\" href=\"") | |
| 414 | 414 | if templ_7745c5c3_Err != nil { | |
| 415 | 415 | return templ_7745c5c3_Err | |
| 416 | 416 | } | |
| 417 | - | var templ_7745c5c3_Var20 string | |
| 418 | - | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref) | |
| 417 | + | var templ_7745c5c3_Var20 templ.SafeURL | |
| 418 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs"))) | |
| 419 | 419 | if templ_7745c5c3_Err != nil { | |
| 420 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 96, Col: 36} | |
| 420 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 88, Col: 52} | |
| 421 | 421 | } | |
| 422 | 422 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |
| 423 | 423 | if templ_7745c5c3_Err != nil { | |
| 424 | 424 | return templ_7745c5c3_Err | |
| 425 | 425 | } | |
| 426 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span> <span class=\"sha\">") | |
| 426 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">") | |
| 427 | 427 | if templ_7745c5c3_Err != nil { | |
| 428 | 428 | return templ_7745c5c3_Err | |
| 429 | 429 | } | |
| 430 | 430 | var templ_7745c5c3_Var21 string | |
| 431 | - | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short) | |
| 431 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref) | |
| 432 | 432 | if templ_7745c5c3_Err != nil { | |
| 433 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 97, Col: 33} | |
| 433 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 94, Col: 36} | |
| 434 | 434 | } | |
| 435 | 435 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 436 | 436 | if templ_7745c5c3_Err != nil { | |
| 437 | 437 | return templ_7745c5c3_Err | |
| 438 | 438 | } | |
| 439 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span></button> ") | |
| 439 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span> <span class=\"sha\">") | |
| 440 | + | if templ_7745c5c3_Err != nil { | |
| 441 | + | return templ_7745c5c3_Err | |
| 442 | + | } | |
| 443 | + | var templ_7745c5c3_Var22 string | |
| 444 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short) | |
| 445 | + | if templ_7745c5c3_Err != nil { | |
| 446 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 95, Col: 33} | |
| 447 | + | } | |
| 448 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 449 | + | if templ_7745c5c3_Err != nil { | |
| 450 | + | return templ_7745c5c3_Err | |
| 451 | + | } | |
| 452 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span></a> ") | |
| 440 | 453 | if templ_7745c5c3_Err != nil { | |
| 441 | 454 | return templ_7745c5c3_Err | |
| 442 | 455 | } | |
| 443 | 456 | } | |
| 444 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<button type=\"button\" class=\"btn search\" aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-cmd'))\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-help'))\">?</button> ") | |
| 457 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\">?</button> ") | |
| 445 | 458 | if templ_7745c5c3_Err != nil { | |
| 446 | 459 | return templ_7745c5c3_Err | |
| 447 | 460 | } | |
| 448 | 461 | if SignedIn(ctx) { | |
| 449 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> <button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>") | |
| 462 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> <button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>") | |
| 450 | 463 | if templ_7745c5c3_Err != nil { | |
| 451 | 464 | return templ_7745c5c3_Err | |
| 452 | 465 | } | |
| 453 | 466 | } | |
| 454 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div>") | |
| 467 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div>") | |
| 455 | 468 | if templ_7745c5c3_Err != nil { | |
| 456 | 469 | return templ_7745c5c3_Err | |
| 457 | 470 | } | |
| 458 | 471 | if len(l.Sections) > 0 { | |
| 459 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>") | |
| 472 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>") | |
| 460 | 473 | if templ_7745c5c3_Err != nil { | |
| 461 | 474 | return templ_7745c5c3_Err | |
| 462 | 475 | } | |
| 463 | 476 | for _, s := range l.Sections { | |
| 464 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<li><a href=\"") | |
| 477 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li><a href=\"") | |
| 465 | 478 | if templ_7745c5c3_Err != nil { | |
| 466 | 479 | return templ_7745c5c3_Err | |
| 467 | 480 | } | |
| 468 | - | var templ_7745c5c3_Var22 templ.SafeURL | |
| 469 | - | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 481 | + | var templ_7745c5c3_Var23 templ.SafeURL | |
| 482 | + | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 470 | 483 | if templ_7745c5c3_Err != nil { | |
| 471 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 128, Col: 36} | |
| 484 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 127, Col: 36} | |
| 472 | 485 | } | |
| 473 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 486 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | |
| 474 | 487 | if templ_7745c5c3_Err != nil { | |
| 475 | 488 | return templ_7745c5c3_Err | |
| 476 | 489 | } | |
| 477 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" data-key=\"") | |
| 490 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" data-key=\"") | |
| 478 | 491 | if templ_7745c5c3_Err != nil { | |
| 479 | 492 | return templ_7745c5c3_Err | |
| 480 | 493 | } | |
| 481 | - | var templ_7745c5c3_Var23 string | |
| 482 | - | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 494 | + | var templ_7745c5c3_Var24 string | |
| 495 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 483 | 496 | if templ_7745c5c3_Err != nil { | |
| 484 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 129, Col: 24} | |
| 497 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 128, Col: 24} | |
| 485 | 498 | } | |
| 486 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) | |
| 499 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) | |
| 487 | 500 | if templ_7745c5c3_Err != nil { | |
| 488 | 501 | return templ_7745c5c3_Err | |
| 489 | 502 | } | |
| 490 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" aria-keyshortcuts=\"") | |
| 503 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" aria-keyshortcuts=\"") | |
| 491 | 504 | if templ_7745c5c3_Err != nil { | |
| 492 | 505 | return templ_7745c5c3_Err | |
| 493 | 506 | } | |
| 494 | - | var templ_7745c5c3_Var24 string | |
| 495 | - | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 507 | + | var templ_7745c5c3_Var25 string | |
| 508 | + | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 496 | 509 | if templ_7745c5c3_Err != nil { | |
| 497 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 130, Col: 33} | |
| 510 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 129, Col: 33} | |
| 498 | 511 | } | |
| 499 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) | |
| 512 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) | |
| 500 | 513 | if templ_7745c5c3_Err != nil { | |
| 501 | 514 | return templ_7745c5c3_Err | |
| 502 | 515 | } | |
| 503 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"") | |
| 516 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"") | |
| 504 | 517 | if templ_7745c5c3_Err != nil { | |
| 505 | 518 | return templ_7745c5c3_Err | |
| 506 | 519 | } | |
| 507 | 520 | if s.Current { | |
| 508 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " aria-current=\"page\"") | |
| 521 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " aria-current=\"page\"") | |
| 509 | 522 | if templ_7745c5c3_Err != nil { | |
| 510 | 523 | return templ_7745c5c3_Err | |
| 511 | 524 | } | |
| 512 | 525 | } | |
| 513 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">") | |
| 526 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">") | |
| 514 | 527 | if templ_7745c5c3_Err != nil { | |
| 515 | 528 | return templ_7745c5c3_Err | |
| 516 | 529 | } | |
| 517 | - | var templ_7745c5c3_Var25 string | |
| 518 | - | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 530 | + | var templ_7745c5c3_Var26 string | |
| 531 | + | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 519 | 532 | if templ_7745c5c3_Err != nil { | |
| 520 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 17} | |
| 533 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 17} | |
| 521 | 534 | } | |
| 522 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) | |
| 535 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 523 | 536 | if templ_7745c5c3_Err != nil { | |
| 524 | 537 | return templ_7745c5c3_Err | |
| 525 | 538 | } | |
| 526 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<kbd aria-hidden=\"true\">") | |
| 539 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<kbd aria-hidden=\"true\">") | |
| 527 | 540 | if templ_7745c5c3_Err != nil { | |
| 528 | 541 | return templ_7745c5c3_Err | |
| 529 | 542 | } | |
| 530 | - | var templ_7745c5c3_Var26 string | |
| 531 | - | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key) | |
| 543 | + | var templ_7745c5c3_Var27 string | |
| 544 | + | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key) | |
| 532 | 545 | if templ_7745c5c3_Err != nil { | |
| 533 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 50} | |
| 546 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 50} | |
| 534 | 547 | } | |
| 535 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 548 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) | |
| 536 | 549 | if templ_7745c5c3_Err != nil { | |
| 537 | 550 | return templ_7745c5c3_Err | |
| 538 | 551 | } | |
| 539 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</kbd></a></li>") | |
| 552 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</kbd></a></li>") | |
| 540 | 553 | if templ_7745c5c3_Err != nil { | |
| 541 | 554 | return templ_7745c5c3_Err | |
| 542 | 555 | } | |
| 543 | 556 | } | |
| 544 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</ul></nav>") | |
| 557 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</ul></nav>") | |
| 545 | 558 | if templ_7745c5c3_Err != nil { | |
| 546 | 559 | return templ_7745c5c3_Err | |
| 547 | 560 | } | |
| 548 | 561 | } | |
| 549 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</header>") | |
| 562 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</header>") | |
| 550 | 563 | if templ_7745c5c3_Err != nil { | |
| 551 | 564 | return templ_7745c5c3_Err | |
| 552 | 565 | } | |
| @@ -572,38 +585,38 @@ func Footer(l Layout) templ.Component { | |||
| 572 | 585 | }() | |
| 573 | 586 | } | |
| 574 | 587 | ctx = templ.InitializeContext(ctx) | |
| 575 | - | templ_7745c5c3_Var27 := templ.GetChildren(ctx) | |
| 576 | - | if templ_7745c5c3_Var27 == nil { | |
| 577 | - | templ_7745c5c3_Var27 = templ.NopComponent | |
| 588 | + | templ_7745c5c3_Var28 := templ.GetChildren(ctx) | |
| 589 | + | if templ_7745c5c3_Var28 == nil { | |
| 590 | + | templ_7745c5c3_Var28 = templ.NopComponent | |
| 578 | 591 | } | |
| 579 | 592 | ctx = templ.ClearChildren(ctx) | |
| 580 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<footer class=\"foot\"><span>ermite ") | |
| 593 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<footer class=\"foot\"><span>ermite ") | |
| 581 | 594 | if templ_7745c5c3_Err != nil { | |
| 582 | 595 | return templ_7745c5c3_Err | |
| 583 | 596 | } | |
| 584 | - | var templ_7745c5c3_Var28 string | |
| 585 | - | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version) | |
| 597 | + | var templ_7745c5c3_Var29 string | |
| 598 | + | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version) | |
| 586 | 599 | if templ_7745c5c3_Err != nil { | |
| 587 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 147, Col: 26} | |
| 600 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 146, Col: 26} | |
| 588 | 601 | } | |
| 589 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) | |
| 602 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 590 | 603 | if templ_7745c5c3_Err != nil { | |
| 591 | 604 | return templ_7745c5c3_Err | |
| 592 | 605 | } | |
| 593 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span> <a href=\"") | |
| 606 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span> <a href=\"") | |
| 594 | 607 | if templ_7745c5c3_Err != nil { | |
| 595 | 608 | return templ_7745c5c3_Err | |
| 596 | 609 | } | |
| 597 | - | var templ_7745c5c3_Var29 templ.SafeURL | |
| 598 | - | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL)) | |
| 610 | + | var templ_7745c5c3_Var30 templ.SafeURL | |
| 611 | + | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL)) | |
| 599 | 612 | if templ_7745c5c3_Err != nil { | |
| 600 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 148, Col: 38} | |
| 613 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 147, Col: 38} | |
| 601 | 614 | } | |
| 602 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 615 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 603 | 616 | if templ_7745c5c3_Err != nil { | |
| 604 | 617 | return templ_7745c5c3_Err | |
| 605 | 618 | } | |
| 606 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>") | |
| 619 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>") | |
| 607 | 620 | if templ_7745c5c3_Err != nil { | |
| 608 | 621 | return templ_7745c5c3_Err | |
| 609 | 622 | } | |
| @@ -631,12 +644,12 @@ func Hint(l Layout) templ.Component { | |||
| 631 | 644 | }() | |
| 632 | 645 | } | |
| 633 | 646 | ctx = templ.InitializeContext(ctx) | |
| 634 | - | templ_7745c5c3_Var30 := templ.GetChildren(ctx) | |
| 635 | - | if templ_7745c5c3_Var30 == nil { | |
| 636 | - | templ_7745c5c3_Var30 = templ.NopComponent | |
| 647 | + | templ_7745c5c3_Var31 := templ.GetChildren(ctx) | |
| 648 | + | if templ_7745c5c3_Var31 == nil { | |
| 649 | + | templ_7745c5c3_Var31 = templ.NopComponent | |
| 637 | 650 | } | |
| 638 | 651 | ctx = templ.ClearChildren(ctx) | |
| 639 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>") | |
| 652 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>") | |
| 640 | 653 | if templ_7745c5c3_Err != nil { | |
| 641 | 654 | return templ_7745c5c3_Err | |
| 642 | 655 | } | |
| @@ -644,7 +657,7 @@ func Hint(l Layout) templ.Component { | |||
| 644 | 657 | if templ_7745c5c3_Err != nil { | |
| 645 | 658 | return templ_7745c5c3_Err | |
| 646 | 659 | } | |
| 647 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div>") | |
| 660 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div>") | |
| 648 | 661 | if templ_7745c5c3_Err != nil { | |
| 649 | 662 | return templ_7745c5c3_Err | |
| 650 | 663 | } | |
M internal/adapters/web/static/css/10-base.css +2 -0
| @@ -70,6 +70,8 @@ | |||
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | @layer utilities { | |
| 73 | + | /* what does nothing without scripts waits for ermite.js, which sets html.js */ | |
| 74 | + | html:not(.js) [data-js], html:not(.js) .filter { display: none; } | |
| 73 | 75 | .dim { color: var(--ink-dim); } | |
| 74 | 76 | .mute { color: var(--ink-mute); } | |
| 75 | 77 | .nowrap { white-space: nowrap; } | |
M internal/adapters/web/static/css/20-shell.css +6 -1
| @@ -169,7 +169,12 @@ | |||
| 169 | 169 | & > :empty { display: none; } | |
| 170 | 170 | & .hint-live { color: var(--red); } | |
| 171 | 171 | } | |
| 172 | - | .hint:not(:has(> :not(:empty))) { display: none; } | |
| 172 | + | /* empty, the badge is out of sight but stays a status region (display: none | |
| 173 | + | would drop it from the accessibility tree and its next message would not be | |
| 174 | + | announced) */ | |
| 175 | + | .hint:not(:has(> :not(:empty))) { | |
| 176 | + | width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none; | |
| 177 | + | } | |
| 173 | 178 | ||
| 174 | 179 | @media (max-width: 1080px) { | |
| 175 | 180 | /* the rail goes under the content, in two columns while there is room */ | |
M internal/adapters/web/static/css/80-live.css +7 -4
| @@ -1,9 +1,12 @@ | |||
| 1 | 1 | @layer components { | |
| 2 | - | /* live updates: the banner of new commits, an empty slot takes no room */ | |
| 3 | - | #commits-head:empty { display: none; } | |
| 4 | - | .live-banner { | |
| 2 | + | /* live updates: a slot is a status region that stays in the page; its banner | |
| 3 | + | comes and goes as its content. Empty, it is out of the way but still in the | |
| 4 | + | accessibility tree (display: none would drop the region, and the next banner | |
| 5 | + | would not be announced). */ | |
| 6 | + | .live-slot:empty { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } | |
| 7 | + | .live-slot:not(:empty) { | |
| 5 | 8 | padding: var(--s3) var(--s4); font-weight: 700; | |
| 6 | 9 | background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s); | |
| 7 | 10 | } | |
| 8 | - | .live-banner a { color: var(--ink-hi); } | |
| 11 | + | .live-slot a { color: var(--ink-hi); } | |
| 9 | 12 | } | |
M internal/adapters/web/static/js/ermite.js +279 -14
| @@ -1,50 +1,297 @@ | |||
| 1 | - | // ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine | |
| 2 | - | // ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused. | |
| 3 | - | // It only translates keys into DOM events named "k-<name>" on <body>; every reaction | |
| 4 | - | // (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live. | |
| 1 | + | // ermite.js: the behaviour of the pages that htmx and hx-live do not give. | |
| 2 | + | // | |
| 3 | + | // 1. Keys. A global key *sequence* state machine ("g" then "h") and the | |
| 4 | + | // keystrokes aimed at inputs cannot be declared with hx-on. The keydown | |
| 5 | + | // handler only translates keys into DOM events named "k-<name>" on <body>; | |
| 6 | + | // keys.templ binds each of them (hx-on, one line each) to a function of | |
| 7 | + | // window.ermite below, which does the work: row cursor, dialogs, filters, | |
| 8 | + | // diff layout, permalink. | |
| 9 | + | // 2. Clicks on [data-fire] elements fire the same events (header buttons). | |
| 10 | + | // 3. ermiteLive applies the fragments of the event stream (see events.go). | |
| 11 | + | // 4. The file of the diff in view is marked in the table of contents. | |
| 12 | + | // 5. A handler that fails is shown in the hint badge. | |
| 5 | 13 | (() => { | |
| 14 | + | // Marks the page as scripted: what does nothing without scripts (the filters, | |
| 15 | + | // the "go to" and help buttons) is hidden until this class is set (see the | |
| 16 | + | // utilities of 10-base.css). | |
| 17 | + | document.documentElement.classList.add('js'); | |
| 18 | + | ||
| 19 | + | const $ = (sel, root = document) => root.querySelector(sel); | |
| 20 | + | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |
| 21 | + | const body = () => document.body; | |
| 22 | + | ||
| 23 | + | // flash shows a message in the hint badge (body[data-flash], a status | |
| 24 | + | // region), then clears it unless another message came in the meantime. | |
| 25 | + | let flashTimer = 0; | |
| 26 | + | const flash = (msg, ms = 2000) => { | |
| 27 | + | body().dataset.flash = msg; | |
| 28 | + | clearTimeout(flashTimer); | |
| 29 | + | flashTimer = setTimeout(() => (body().dataset.flash = ''), ms); | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | const visibleRows = () => $$('[data-rows] > :not([hidden])'); | |
| 33 | + | // setCursor puts data-cursor on el and takes it from the others. | |
| 34 | + | const setCursor = (el, others) => { | |
| 35 | + | for (const o of others) if (o !== el) o.removeAttribute('data-cursor'); | |
| 36 | + | el.setAttribute('data-cursor', ''); | |
| 37 | + | }; | |
| 38 | + | const scrollPage = (to) => { | |
| 39 | + | scrollTo(0, to); | |
| 40 | + | $('#main')?.scrollTo(0, to); | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | const ermite = { | |
| 44 | + | fire(name) { | |
| 45 | + | body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | |
| 46 | + | }, | |
| 47 | + | ||
| 48 | + | // Row cursor: j / k. | |
| 49 | + | move(d) { | |
| 50 | + | if ($('dialog[open]')) return; | |
| 51 | + | const rows = visibleRows(); | |
| 52 | + | if (!rows.length) { | |
| 53 | + | scrollBy(0, d * 180); | |
| 54 | + | $('#main')?.scrollBy(0, d * 180); | |
| 55 | + | return; | |
| 56 | + | } | |
| 57 | + | const i = rows.findIndex((r) => r.hasAttribute('data-cursor')); | |
| 58 | + | const j = Math.max(0, Math.min(rows.length - 1, i + d)); | |
| 59 | + | setCursor(rows[j], $$('[data-rows] > [data-cursor]')); | |
| 60 | + | rows[j].scrollIntoView({ block: 'nearest' }); | |
| 61 | + | const mark = j + 1 + '/' + rows.length; | |
| 62 | + | body().dataset.nav = mark; | |
| 63 | + | setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500); | |
| 64 | + | }, | |
| 65 | + | // gg / G: first or last row, or the top or bottom of the page. | |
| 66 | + | edge(last) { | |
| 67 | + | const rows = visibleRows(); | |
| 68 | + | const row = last ? rows.at(-1) : rows[0]; | |
| 69 | + | if (row) row.scrollIntoView({ block: 'nearest' }); | |
| 70 | + | else scrollPage(last ? 1e9 : 0); | |
| 71 | + | }, | |
| 72 | + | open() { | |
| 73 | + | if (!$('dialog[open]')) $('[data-cursor] a')?.click(); | |
| 74 | + | }, | |
| 75 | + | focusFilter() { | |
| 76 | + | $('[data-filter]')?.focus(); | |
| 77 | + | }, | |
| 78 | + | go(sel) { | |
| 79 | + | $(sel)?.click(); | |
| 80 | + | }, | |
| 81 | + | tab(key) { | |
| 82 | + | $(`.tabs [data-key=${key}]`)?.click(); | |
| 83 | + | }, | |
| 84 | + | ||
| 85 | + | // Dialogs. | |
| 86 | + | closeDialogs() { | |
| 87 | + | for (const d of $$('dialog[open]')) d.close(); | |
| 88 | + | }, | |
| 89 | + | dialog(id, focusId) { | |
| 90 | + | const d = document.getElementById(id); | |
| 91 | + | if (!d) return; | |
| 92 | + | ermite.closeDialogs(); | |
| 93 | + | d.showModal(); | |
| 94 | + | if (focusId) document.getElementById(focusId)?.focus(); | |
| 95 | + | }, | |
| 96 | + | ||
| 97 | + | // filter hides the rows that do not contain the text of an input. The | |
| 98 | + | // input says which rows (data-filter, or data-pick for a list without a | |
| 99 | + | // cursor) and, optionally, the groups to hide when they have no row left | |
| 100 | + | // (data-groups). The cursor goes to the first row left. | |
| 101 | + | filter(input) { | |
| 102 | + | const sel = input.dataset.filter || input.dataset.pick; | |
| 103 | + | const t = input.value.toLowerCase(); | |
| 104 | + | const rows = $$(sel); | |
| 105 | + | for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t); | |
| 106 | + | if (input.dataset.groups) { | |
| 107 | + | for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r)); | |
| 108 | + | } | |
| 109 | + | if (input.dataset.filter) { | |
| 110 | + | const first = rows.find((r) => !r.hidden); | |
| 111 | + | for (const r of rows) r.toggleAttribute('data-cursor', r === first); | |
| 112 | + | } | |
| 113 | + | }, | |
| 114 | + | // The "go to" palette. | |
| 115 | + | cmdFilter(input) { | |
| 116 | + | const t = input.value.toLowerCase().trim(); | |
| 117 | + | const items = $$('#cmd-list > li'); | |
| 118 | + | for (const li of items) li.hidden = !li.dataset.k.includes(t); | |
| 119 | + | const first = items.find((li) => !li.hidden); | |
| 120 | + | if (first) setCursor(first, items); | |
| 121 | + | }, | |
| 122 | + | cmdKey(e) { | |
| 123 | + | const vis = $$('#cmd-list > li:not([hidden])'); | |
| 124 | + | const i = vis.findIndex((li) => li.hasAttribute('data-cursor')); | |
| 125 | + | const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0; | |
| 126 | + | if (step) { | |
| 127 | + | e.preventDefault(); | |
| 128 | + | const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; | |
| 129 | + | if (n) { | |
| 130 | + | setCursor(n, $$('#cmd-list > li')); | |
| 131 | + | n.scrollIntoView({ block: 'nearest' }); | |
| 132 | + | } | |
| 133 | + | } else if (e.key === 'Enter') { | |
| 134 | + | e.preventDefault(); | |
| 135 | + | vis[Math.max(i, 0)]?.querySelector('a')?.click(); | |
| 136 | + | } | |
| 137 | + | }, | |
| 138 | + | ||
| 139 | + | // Diff. | |
| 140 | + | layout(mode) { | |
| 141 | + | const el = $('[data-diff]'); | |
| 142 | + | if (!el) return; | |
| 143 | + | el.dataset.diff = mode; | |
| 144 | + | try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ } | |
| 145 | + | for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true; | |
| 146 | + | }, | |
| 147 | + | collapse() { | |
| 148 | + | const files = $$('[data-file]'); | |
| 149 | + | const open = !files.some((d) => d.open); | |
| 150 | + | for (const d of files) d.open = open; | |
| 151 | + | }, | |
| 152 | + | nextNote() { | |
| 153 | + | const notes = $$('#diffs .cmt'); | |
| 154 | + | if (!notes.length) return; | |
| 155 | + | const i = notes.findIndex((n) => n.getBoundingClientRect().top > innerHeight / 3); | |
| 156 | + | const n = notes[i < 0 ? 0 : i]; | |
| 157 | + | const d = n.closest('details'); | |
| 158 | + | if (d) d.open = true; | |
| 159 | + | n.scrollIntoView({ block: 'center' }); | |
| 160 | + | }, | |
| 161 | + | ||
| 162 | + | // y: pin the URL to the commit and copy it. | |
| 163 | + | async permalink() { | |
| 164 | + | const b = body().dataset; | |
| 165 | + | if (!b.sha) return flash('no ref to pin on this page'); | |
| 166 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 167 | + | try { | |
| 168 | + | await navigator.clipboard.writeText(location.href); | |
| 169 | + | flash('permalink ' + b.sha.slice(0, 7) + ' copied'); | |
| 170 | + | } catch { | |
| 171 | + | flash('permalink in the address bar (copy refused)'); | |
| 172 | + | } | |
| 173 | + | }, | |
| 174 | + | ||
| 175 | + | // The [copy] button of a clone line: the label says "Copied" for 1.5 s, | |
| 176 | + | // and so does the accessible name; the hint badge (a status region) | |
| 177 | + | // announces it. | |
| 178 | + | async copy(btn) { | |
| 179 | + | const line = btn.closest('.clone-line')?.querySelector('code')?.textContent ?? ''; | |
| 180 | + | btn.dataset.idleName ||= btn.getAttribute('aria-label') || ''; | |
| 181 | + | let ok = true; | |
| 182 | + | try { await navigator.clipboard.writeText(line); } catch { ok = false; } | |
| 183 | + | btn.textContent = ok ? 'Copied' : 'Copy failed'; | |
| 184 | + | btn.setAttribute('aria-label', (ok ? 'copied: ' : 'copy failed: ') + line); | |
| 185 | + | flash(ok ? 'copied' : 'copy failed', 1500); | |
| 186 | + | clearTimeout(btn._restore); | |
| 187 | + | btn._restore = setTimeout(() => { | |
| 188 | + | btn.textContent = btn.dataset.label; | |
| 189 | + | btn.setAttribute('aria-label', btn.dataset.idleName); | |
| 190 | + | }, 1500); | |
| 191 | + | }, | |
| 192 | + | }; | |
| 193 | + | window.ermite = ermite; | |
| 194 | + | ||
| 195 | + | // 1. Keys. | |
| 6 | 196 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | |
| 7 | 197 | s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' }; | |
| 8 | - | const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', m: 'gm', g: 'top' }; | |
| 198 | + | // "g t" is the key "t": the tabs have one event each. | |
| 199 | + | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' }; | |
| 9 | 200 | let pending = 0; | |
| 10 | - | const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | |
| 11 | 201 | addEventListener('keydown', (e) => { | |
| 12 | 202 | const typing = e.target.closest('input, textarea, select, [contenteditable]'); | |
| 13 | 203 | // ctrl k / cmd k opens the palette; in a field only cmd k, so that the | |
| 14 | 204 | // field keeps ctrl k (kill to end of line on macOS). | |
| 15 | - | if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return fire('cmd'); } | |
| 205 | + | if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); } | |
| 16 | 206 | if (e.ctrlKey || e.metaKey || e.altKey) return; | |
| 17 | - | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); } | |
| 207 | + | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return ermite.fire('esc'); } | |
| 18 | 208 | if (typing) return; | |
| 19 | 209 | // An open dialog takes only the keys that move between dialogs: the page | |
| 20 | 210 | // behind it does not react (scripted clicks would go through inert). | |
| 21 | - | if (document.querySelector('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; } | |
| 211 | + | if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; } | |
| 22 | 212 | if (e.key === 'Enter' && e.target.closest('a, button, summary')) return; | |
| 23 | 213 | if (pending) { | |
| 24 | 214 | const name = goto[e.key]; | |
| 25 | 215 | pending = 0; | |
| 26 | - | if (name) { e.preventDefault(); fire(name); } | |
| 216 | + | if (name) { e.preventDefault(); ermite.fire(name); } | |
| 27 | 217 | return; | |
| 28 | 218 | } | |
| 29 | 219 | if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; } | |
| 30 | 220 | const name = single[e.key]; | |
| 31 | 221 | if (!name) return; | |
| 32 | - | if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault(); | |
| 33 | - | fire(name); | |
| 222 | + | if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault(); | |
| 223 | + | ermite.fire(name); | |
| 224 | + | }); | |
| 225 | + | ||
| 226 | + | // 2. A [data-fire] element does what the key of that name does (the header | |
| 227 | + | // buttons, the diff layout links). The link to the refs page works without | |
| 228 | + | // scripts and is upgraded here to open the dialog; a click with a modifier | |
| 229 | + | // keeps its meaning (new tab). | |
| 230 | + | addEventListener('click', (e) => { | |
| 231 | + | const el = e.target.closest?.('[data-fire]'); | |
| 232 | + | if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | |
| 233 | + | e.preventDefault(); | |
| 234 | + | ermite.fire(el.dataset.fire); | |
| 34 | 235 | }); | |
| 236 | + | for (const a of $$('a[data-fire="refs"]')) { | |
| 237 | + | a.setAttribute('aria-haspopup', 'dialog'); | |
| 238 | + | a.setAttribute('aria-controls', 'panel-refs'); | |
| 239 | + | } | |
| 240 | + | ||
| 35 | 241 | // In the app shell the page does not scroll, <main> does: give it focus so arrows, | |
| 36 | 242 | // PageUp/PageDown and space scroll the content without a click first. | |
| 37 | 243 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | |
| 244 | + | ||
| 245 | + | // 4. The table of contents of a diff marks the file in view: the last file | |
| 246 | + | // whose top is above the first third of the window (the first file when none | |
| 247 | + | // is). One observer watches the files against that band; a file has passed | |
| 248 | + | // the line when it is in the band or wholly above it. It reports only when a | |
| 249 | + | // file enters or leaves the band, so a scroll costs nothing in between. | |
| 250 | + | const files = $$('.dfile'); | |
| 251 | + | const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a])); | |
| 252 | + | if (files.length && links.size && 'IntersectionObserver' in window) { | |
| 253 | + | const passed = new Set(); | |
| 254 | + | let current = null; | |
| 255 | + | const mark = () => { | |
| 256 | + | const f = files.findLast((x) => passed.has(x)) ?? files[0]; | |
| 257 | + | if (f === current) return; | |
| 258 | + | links.get(current?.id)?.removeAttribute('aria-current'); | |
| 259 | + | links.get(f.id)?.setAttribute('aria-current', 'true'); | |
| 260 | + | current = f; | |
| 261 | + | }; | |
| 262 | + | const io = new IntersectionObserver((entries) => { | |
| 263 | + | for (const e of entries) { | |
| 264 | + | const line = e.rootBounds ? e.rootBounds.bottom : innerHeight / 3; | |
| 265 | + | if (e.isIntersecting || e.boundingClientRect.top < line) passed.add(e.target); | |
| 266 | + | else passed.delete(e.target); | |
| 267 | + | } | |
| 268 | + | mark(); | |
| 269 | + | }, { rootMargin: '0px 0px -67% 0px' }); | |
| 270 | + | for (const f of files) io.observe(f); | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // 5. A failure of a handler or of a request (htmx logs it to the console | |
| 274 | + | // already) is shown, so that a key that does nothing is not a mystery. | |
| 275 | + | addEventListener('htmx:error', (e) => { | |
| 276 | + | if (e.target.closest?.('.hint-live')) return; // the stream says so itself ("live updates paused") | |
| 277 | + | const err = e.detail?.error; | |
| 278 | + | flash('something went wrong' + (err?.message ? ': ' + err.message : ''), 4000); | |
| 279 | + | }); | |
| 38 | 280 | })(); | |
| 39 | 281 | ||
| 40 | 282 | // ermiteLive applies the fragments of the event stream (/-/events, see | |
| 41 | 283 | // internal/adapters/web/events.go). A fragment is a top-level element with an | |
| 42 | 284 | // id: it replaces the element of the page that has the same id. A fragment | |
| 43 | 285 | // marked data-gone removes it; a row of a repository the home page does not | |
| 44 | - | // list yet is added at the top of the list. A gap, which says the page may | |
| 45 | - | // have missed events or that what it shows changed, reloads the page. | |
| 286 | + | // list yet is added at the top of the list. A slot (data-slot: the place of a | |
| 287 | + | // banner, a persistent status region) keeps its element and only gets the | |
| 288 | + | // children of the fragment, so that screen readers announce the change. A gap, | |
| 289 | + | // which says the page may have missed events or that what it shows changed, | |
| 290 | + | // reloads the page. A stream the server refuses on reconnection (the | |
| 291 | + | // repository is gone, the session ended) makes the page reload. | |
| 46 | 292 | window.ermiteLive = (() => { | |
| 47 | 293 | let reloading = false; | |
| 294 | + | let stopped = false; | |
| 48 | 295 | return { | |
| 49 | 296 | swap(html) { | |
| 50 | 297 | const t = document.createElement('template'); | |
| @@ -57,6 +304,7 @@ window.ermiteLive = (() => { | |||
| 57 | 304 | if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); } | |
| 58 | 305 | continue; | |
| 59 | 306 | } | |
| 307 | + | if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; } | |
| 60 | 308 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | |
| 61 | 309 | if (old.hidden && old.tagName === 'TR') el.hidden = true; | |
| 62 | 310 | old.replaceWith(el); | |
| @@ -68,5 +316,22 @@ window.ermiteLive = (() => { | |||
| 68 | 316 | reloading = true; | |
| 69 | 317 | setTimeout(() => location.reload(), 300); | |
| 70 | 318 | }, | |
| 319 | + | // error handles htmx:sse:error (detail.status is the status of a refused | |
| 320 | + | // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by | |
| 321 | + | // itself, and the fetched page says what is the matter (or has no stream). | |
| 322 | + | // A page that reloaded for this a moment ago and still gets refused would | |
| 323 | + | // loop: it stops following the stream instead (see gate). | |
| 324 | + | error(status) { | |
| 325 | + | if (status !== 404 && status !== 401) return; | |
| 326 | + | let last = 0; | |
| 327 | + | try { last = +sessionStorage.getItem('ermite-live-reload') || 0; } catch { /* no storage: no guard */ } | |
| 328 | + | if (Date.now() - last < 15000) { stopped = true; return; } | |
| 329 | + | try { sessionStorage.setItem('ermite-live-reload', String(Date.now())); } catch { /* idem */ } | |
| 330 | + | this.reload(); | |
| 331 | + | }, | |
| 332 | + | // gate is htmx:sse:before:connection: once stopped, no more attempts. | |
| 333 | + | gate(event) { | |
| 334 | + | if (stopped) event.preventDefault(); | |
| 335 | + | }, | |
| 71 | 336 | }; | |
| 72 | 337 | })(); | |
M internal/adapters/web/tree.templ +2 -2
| @@ -11,10 +11,10 @@ templ Tree(l Layout, p TreePage) { | |||
| 11 | 11 | <input | |
| 12 | 12 | id="tree-filter" | |
| 13 | 13 | type="search" | |
| 14 | - | data-filter | |
| 14 | + | data-filter="#tree-rows > tr" | |
| 15 | 15 | placeholder="Filter this directory…" | |
| 16 | 16 | autocomplete="off" | |
| 17 | - | hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')" | |
| 17 | + | hx-on:input="ermite.filter(this)" | |
| 18 | 18 | /> | |
| 19 | 19 | </div> | |
| 20 | 20 | <table class="list tree" aria-label="Entries"> | |