detached f5cbe22

#31 fix(web): ARIA semantics for MR panels, dialogs, palette, forms and status regions

state
merged · 5d69f2b · squash
branch
fix/a11y → main
opened
bnema ·
updated
CommitSubjectAuthorDateSignatureStatus
f5cbe22test(app): update web goldens for the ARIA changesbriceGPG Verified
1f47bc8fix(web): keep MR panels open on refresh, expose one option per palette result, clear finder statusbriceGPG Verified
21b3830fix(web): improve ARIA semantics of MR panels, dialogs, palette, forms and status regionsbriceGPG Verified

Checks 4

55 files · +1051 -671
4/4
A internal/adapters/web/accessibility_test.go +53 -0
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestIssueFieldAccessibility(t *testing.T) {
12+ for _, field := range []string{"title", "body", "author"} {
13+ t.Run(field, func(t *testing.T) {
14+ html := renderToString(t, IssueForm(Layout{}, IssueFormPage{ErrorField: field, Error: "Too long"}))
15+ assert.Contains(t, html, `id="issue-`+field+`" aria-describedby="issue-`+field+`-count issue-`+field+`-error" aria-invalid="true"`)
16+ assert.Contains(t, html, `<p id="issue-`+field+`-error" class="f-error" role="alert">Too long</p>`)
17+ for _, f := range []string{"title", "body", "author"} {
18+ assert.Contains(t, html, `<output id="issue-`+f+`-count" aria-live="off"`)
19+ }
20+ })
21+ }
22+}
23+
24+func TestHintAccessibility(t *testing.T) {
25+ html := renderToString(t, Hint(Layout{Live: true, LiveURL: "/events"}))
26+ assert.Contains(t, html, `<div class="hint"><span aria-hidden="true" class="hint-nav"`)
27+ assert.Contains(t, html, `<span role="status" aria-live="polite"><span class="hint-sel"`)
28+ assert.Contains(t, html, `</span></span><span class="hint-live" role="status"`)
29+}
30+
31+func TestPaletteAccessibilityWithoutNavigationItems(t *testing.T) {
32+ html := renderToString(t, OverlayCmd(nil))
33+ // The shortcut preference action is always present, even with no targets.
34+ assert.Contains(t, html, `aria-expanded="true"`)
35+ assert.Contains(t, html, `aria-activedescendant="cmd-o0"`)
36+ assert.Contains(t, html, `id="cmd-o0" role="option" aria-selected="true" data-cursor`)
37+ assert.Contains(t, html, `<a role="presentation" data-fire="keys">`)
38+ assert.Contains(t, html, `aria-label="Close (esc)"`)
39+ assert.NotContains(t, html, ` href=`)
40+ assert.NotContains(t, html, `tabindex="-1"`)
41+}
42+
43+func TestFinderStatusOutsideHiddenResults(t *testing.T) {
44+ html := renderToString(t, Tree(Layout{}, TreePage{}))
45+ assert.Contains(t, html, `<p id="finder-status" class="dim" role="status"></p><div id="finder" class="tree-card" hidden>`)
46+}
47+
48+func TestPaletteNavigationOptionsAccessibility(t *testing.T) {
49+ html := renderToString(t, OverlayCmd([]CmdItem{{Kind: "section", Label: "Files", Href: "/files"}}))
50+ assert.Contains(t, html, `<a role="presentation" data-href="/files">`)
51+ assert.NotContains(t, html, ` href=`)
52+ assert.NotContains(t, html, `tabindex="-1"`)
53+}
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestIssueFieldAccessibility(t *testing.T) {
12+ for _, field := range []string{"title", "body", "author"} {
13+ t.Run(field, func(t *testing.T) {
14+ html := renderToString(t, IssueForm(Layout{}, IssueFormPage{ErrorField: field, Error: "Too long"}))
15+ assert.Contains(t, html, `id="issue-`+field+`" aria-describedby="issue-`+field+`-count issue-`+field+`-error" aria-invalid="true"`)
16+ assert.Contains(t, html, `<p id="issue-`+field+`-error" class="f-error" role="alert">Too long</p>`)
17+ for _, f := range []string{"title", "body", "author"} {
18+ assert.Contains(t, html, `<output id="issue-`+f+`-count" aria-live="off"`)
19+ }
20+ })
21+ }
22+}
23+
24+func TestHintAccessibility(t *testing.T) {
25+ html := renderToString(t, Hint(Layout{Live: true, LiveURL: "/events"}))
26+ assert.Contains(t, html, `<div class="hint"><span aria-hidden="true" class="hint-nav"`)
27+ assert.Contains(t, html, `<span role="status" aria-live="polite"><span class="hint-sel"`)
28+ assert.Contains(t, html, `</span></span><span class="hint-live" role="status"`)
29+}
30+
31+func TestPaletteAccessibilityWithoutNavigationItems(t *testing.T) {
32+ html := renderToString(t, OverlayCmd(nil))
33+ // The shortcut preference action is always present, even with no targets.
34+ assert.Contains(t, html, `aria-expanded="true"`)
35+ assert.Contains(t, html, `aria-activedescendant="cmd-o0"`)
36+ assert.Contains(t, html, `id="cmd-o0" role="option" aria-selected="true" data-cursor`)
37+ assert.Contains(t, html, `<a role="presentation" data-fire="keys">`)
38+ assert.Contains(t, html, `aria-label="Close (esc)"`)
39+ assert.NotContains(t, html, ` href=`)
40+ assert.NotContains(t, html, `tabindex="-1"`)
41+}
42+
43+func TestFinderStatusOutsideHiddenResults(t *testing.T) {
44+ html := renderToString(t, Tree(Layout{}, TreePage{}))
45+ assert.Contains(t, html, `<p id="finder-status" class="dim" role="status"></p><div id="finder" class="tree-card" hidden>`)
46+}
47+
48+func TestPaletteNavigationOptionsAccessibility(t *testing.T) {
49+ html := renderToString(t, OverlayCmd([]CmdItem{{Kind: "section", Label: "Files", Href: "/files"}}))
50+ assert.Contains(t, html, `<a role="presentation" data-href="/files">`)
51+ assert.NotContains(t, html, ` href=`)
52+ assert.NotContains(t, html, `tabindex="-1"`)
53+}
M internal/adapters/web/fragments.templ +1 -0
@@ -94,6 +94,7 @@ templ Live(l Layout) {
9494 if l.Live && l.LiveURL != "" {
9595 <span
9696 class="hint-live"
97+ role="status"
9798 data-state="connecting"
9899 hx-sse:connect={ l.LiveURL }
99100 hx-swap="none"
@@ -94,6 +94,7 @@ templ Live(l Layout) {
94 if l.Live && l.LiveURL != "" {94 if l.Live && l.LiveURL != "" {
95 <span95 <span
96 class="hint-live"96 class="hint-live"
97+ role="status"
97 data-state="connecting"98 data-state="connecting"
98 hx-sse:connect={ l.LiveURL }99 hx-sse:connect={ l.LiveURL }
99 hx-swap="none"100 hx-swap="none"
M internal/adapters/web/fragments_templ.go +2 -2 generated file: collapsed
M internal/adapters/web/issues_form.templ +47 -9
@@ -29,22 +29,52 @@ templ IssueForm(l Layout, p IssueFormPage) {
2929 <input type="hidden" name="form_token" value={ p.FormToken }/>
3030 <input type="hidden" name="solution" value=""/>
3131 <label for="issue-title">title</label>
32- <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/>
33- <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
32+ <input
33+ id="issue-title"
34+ aria-describedby={ issueFieldDescription("title", p.ErrorField) }
35+ if p.ErrorField == "title" {
36+ aria-invalid="true"
37+ }
38+ name="title"
39+ type="text"
40+ required
41+ autocomplete="off"
42+ value={ p.Title }
43+ />
44+ <output id="issue-title-count" aria-live="off" data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
3445 if p.ErrorField == "title" {
35- <p class="f-error" role="alert">{ p.Error }</p>
46+ <p id="issue-title-error" class="f-error" role="alert">{ p.Error }</p>
3647 }
3748 <label for="issue-body">description</label>
38- <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea>
39- <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
49+ <textarea
50+ id="issue-body"
51+ aria-describedby={ issueFieldDescription("body", p.ErrorField) }
52+ if p.ErrorField == "body" {
53+ aria-invalid="true"
54+ }
55+ name="body"
56+ rows="8"
57+ placeholder="Markdown, optional"
58+ >{ p.Body }</textarea>
59+ <output id="issue-body-count" aria-live="off" data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
4060 if p.ErrorField == "body" {
41- <p class="f-error" role="alert">{ p.Error }</p>
61+ <p id="issue-body-error" class="f-error" role="alert">{ p.Error }</p>
4262 }
4363 <label for="issue-author">name (optional, not verified)</label>
44- <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/>
45- <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
64+ <input
65+ id="issue-author"
66+ aria-describedby={ issueFieldDescription("author", p.ErrorField) }
67+ if p.ErrorField == "author" {
68+ aria-invalid="true"
69+ }
70+ name="author"
71+ type="text"
72+ autocomplete="off"
73+ value={ p.Author }
74+ />
75+ <output id="issue-author-count" aria-live="off" data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
4676 if p.ErrorField == "author" {
47- <p class="f-error" role="alert">{ p.Error }</p>
77+ <p id="issue-author-error" class="f-error" role="alert">{ p.Error }</p>
4878 }
4979 <div class="hp" aria-hidden="true">
5080 <label for="issue-website">Leave this field empty</label>
@@ -65,3 +95,11 @@ templ issueFormRail() {
6595 <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>
6696 </section>
6797 }
98+
99+func issueFieldDescription(field, invalid string) string {
100+ ids := "issue-" + field + "-count"
101+ if field == invalid {
102+ ids += " issue-" + field + "-error"
103+ }
104+ return ids
105+}
@@ -29,22 +29,52 @@ templ IssueForm(l Layout, p IssueFormPage) {
29 <input type="hidden" name="form_token" value={ p.FormToken }/>29 <input type="hidden" name="form_token" value={ p.FormToken }/>
30 <input type="hidden" name="solution" value=""/>30 <input type="hidden" name="solution" value=""/>
31 <label for="issue-title">title</label>31 <label for="issue-title">title</label>
32- <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/>32+ <input
33- <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>33+ id="issue-title"
34+ aria-describedby={ issueFieldDescription("title", p.ErrorField) }
35+ if p.ErrorField == "title" {
36+ aria-invalid="true"
37+ }
38+ name="title"
39+ type="text"
40+ required
41+ autocomplete="off"
42+ value={ p.Title }
43+ />
44+ <output id="issue-title-count" aria-live="off" data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
34 if p.ErrorField == "title" {45 if p.ErrorField == "title" {
35- <p class="f-error" role="alert">{ p.Error }</p>46+ <p id="issue-title-error" class="f-error" role="alert">{ p.Error }</p>
36 }47 }
37 <label for="issue-body">description</label>48 <label for="issue-body">description</label>
38- <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea>49+ <textarea
39- <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>50+ id="issue-body"
51+ aria-describedby={ issueFieldDescription("body", p.ErrorField) }
52+ if p.ErrorField == "body" {
53+ aria-invalid="true"
54+ }
55+ name="body"
56+ rows="8"
57+ placeholder="Markdown, optional"
58+ >{ p.Body }</textarea>
59+ <output id="issue-body-count" aria-live="off" data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
40 if p.ErrorField == "body" {60 if p.ErrorField == "body" {
41- <p class="f-error" role="alert">{ p.Error }</p>61+ <p id="issue-body-error" class="f-error" role="alert">{ p.Error }</p>
42 }62 }
43 <label for="issue-author">name (optional, not verified)</label>63 <label for="issue-author">name (optional, not verified)</label>
44- <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/>64+ <input
45- <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>65+ id="issue-author"
66+ aria-describedby={ issueFieldDescription("author", p.ErrorField) }
67+ if p.ErrorField == "author" {
68+ aria-invalid="true"
69+ }
70+ name="author"
71+ type="text"
72+ autocomplete="off"
73+ value={ p.Author }
74+ />
75+ <output id="issue-author-count" aria-live="off" data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
46 if p.ErrorField == "author" {76 if p.ErrorField == "author" {
47- <p class="f-error" role="alert">{ p.Error }</p>77+ <p id="issue-author-error" class="f-error" role="alert">{ p.Error }</p>
48 }78 }
49 <div class="hp" aria-hidden="true">79 <div class="hp" aria-hidden="true">
50 <label for="issue-website">Leave this field empty</label>80 <label for="issue-website">Leave this field empty</label>
@@ -65,3 +95,11 @@ templ issueFormRail() {
65 <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>95 <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>
66 </section>96 </section>
67 }97 }
98+
99+func issueFieldDescription(field, invalid string) string {
100+ ids := "issue-" + field + "-count"
101+ if field == invalid {
102+ ids += " issue-" + field + "-error"
103+ }
104+ return ids
105+}
M internal/adapters/web/issues_form_templ.go +161 -84 generated file: collapsed
M internal/adapters/web/issues_form_test.go +2 -2
@@ -79,9 +79,9 @@ func TestIssueFormGet(t *testing.T) {
7979 `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
8080 `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
8181 `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82- `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,
82+ `name="title" type="text" required`, `<output id="issue-body-count" aria-live="off" data-for="issue-body" data-max="10000"`, `<output id="issue-author-count" aria-live="off" data-for="issue-author" data-max="40"`,
8383 `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84- `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
84+ `<output id="issue-title-count" aria-live="off" data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
8585 `/-/static/js/issue-form.`,
8686 } {
8787 assert.Contains(t, body, want)
@@ -79,9 +79,9 @@ func TestIssueFormGet(t *testing.T) {
79 `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,79 `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
80 `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,80 `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
81 `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,81 `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82- `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,82+ `name="title" type="text" required`, `<output id="issue-body-count" aria-live="off" data-for="issue-body" data-max="10000"`, `<output id="issue-author-count" aria-live="off" data-for="issue-author" data-max="40"`,
83 `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,83 `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84- `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,84+ `<output id="issue-title-count" aria-live="off" data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
85 `/-/static/js/issue-form.`,85 `/-/static/js/issue-form.`,
86 } {86 } {
87 assert.Contains(t, body, want)87 assert.Contains(t, body, want)
M internal/adapters/web/jstest/a11y.test.mjs +79 -1
@@ -83,7 +83,7 @@ function keydown(target, key, elementHandler, extra = {}) {
8383 // ---- the palette --------------------------------------------------------
8484
8585 function palette() {
86- const li = (k, label) => el('li', { attrs: { 'data-k': k } }, el('a', { attrs: { href: '/' + label } }));
86+ const li = (k, label) => el('li', { attrs: { 'data-k': k } }, el('a', { attrs: { 'data-href': '/' + label, role: 'presentation' } }));
8787 const items = [li('go home', 'home'), li('section files', 'files'), li('branch main', 'main')];
8888 items[0].setAttribute('data-cursor', '');
8989 const input = el('input', { id: 'cmd-q', attrs: { role: 'combobox', 'aria-controls': 'cmd-list' } });
@@ -137,6 +137,8 @@ test('the palette: the file hits that arrive later get ids, roles and the active
137137 assert.ok(hits.length > 0, 'a file hit came');
138138 for (const h of hits) {
139139 assert.equal(h.getAttribute('role'), 'option');
140+ assert.equal(h.querySelector('a[href]'), null);
141+ assert.equal(h.querySelector('a').hasAttribute('tabindex'), false);
140142 assert.ok(h.id);
141143 }
142144 assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');
@@ -173,6 +175,7 @@ test('Escape in the finder clears the query, keeps the focus in the field and do
173175 f.q.closest = () => f.q; // the global handler sees a field being typed in
174176 const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
175177 assert.equal(f.q.value, '');
178+ assert.equal(doc.getElementById('finder-status').textContent, '');
176179 assert.equal(e.stopped, true);
177180 assert.equal(e.prevented, true);
178181 assert.equal(blurred, false, 'the field keeps the focus');
@@ -381,3 +384,78 @@ test('mobile MR layout shortcuts preserve the saved preference until back on des
381384 doc.querySelector = oldQuery;
382385 }
383386 });
387+
388+test('dialog restores its row opener on close, but not over a replacement dialog', () => {
389+ const row = el('li', { tabindex: '0' });
390+ const first = el('dialog', { id: 'focus-first' });
391+ const second = el('dialog', { id: 'focus-second' });
392+ dom.body.append(row, first, second);
393+ const queued = [];
394+ const all = doc.querySelectorAll;
395+ const one = doc.querySelector;
396+ const open = () => [first, second].filter((d) => d.hasAttribute('open'));
397+ doc.querySelectorAll = (sel) => sel === 'dialog[open]' ? open() : all.call(doc, sel);
398+ doc.querySelector = (sel) => sel === 'dialog[open]' ? open()[0] ?? null : one.call(doc, sel);
399+ for (const d of [first, second]) {
400+ let onClose;
401+ d.addEventListener = (type, fn) => { if (type === 'close') onClose = fn; };
402+ d.showModal = () => { d.setAttribute('open', ''); d.focus(); };
403+ d.close = () => { d.removeAttribute('open'); queued.push(onClose); };
404+ }
405+ row.focus();
406+ window.ermite.dialog('focus-first');
407+ first.close();
408+ queued.shift()();
409+ assert.equal(doc.activeElement, row);
410+
411+ row.focus();
412+ window.ermite.dialog('focus-first');
413+ window.ermite.dialog('focus-second');
414+ queued.shift()();
415+ assert.equal(doc.activeElement, second);
416+ second.close();
417+ queued.shift()();
418+ doc.querySelectorAll = all;
419+ doc.querySelector = one;
420+});
421+
422+
423+test('listbox result wrappers have no links; Enter and delegated clicks navigate', async () => {
424+ doc.querySelector = query.one;
425+ doc.querySelectorAll = query.all;
426+ const assigned = [];
427+ location.assign = (href) => assigned.push(href);
428+ const activate = (anchor) => {
429+ const e = { target: anchor, button: 0, preventDefault() {} };
430+ listeners.click.find((fn) => fn.toString().includes('location.assign'))(e);
431+ };
432+ const p = palette();
433+ p.list.setAttribute('role', 'listbox');
434+ window.ermiteCmd.sync();
435+ for (const item of p.items) assert.equal(item.querySelector('a[href]'), null);
436+ const a = p.items[0].querySelector('a');
437+ a.click = () => activate(a);
438+ window.ermite.cmdKey({ key: 'Enter', preventDefault() {} });
439+ assert.deepEqual(assigned, ['/home']);
440+ activate(p.items[1].querySelector('a'));
441+ assert.deepEqual(assigned, ['/home', '/files']);
442+
443+ const f = finder();
444+ f.list.setAttribute('role', 'listbox');
445+ f.q.value = 'main';
446+ F.input(f.q);
447+ flushFrames();
448+ await settle();
449+ assert.equal(f.list.querySelector('a[href]'), null);
450+ const hit = f.list.children[0].querySelector('a');
451+ assert.equal(hit.hasAttribute('tabindex'), false);
452+ hit.click = () => activate(hit);
453+ F.key(f.q, { key: 'Enter', preventDefault() {} });
454+ assert.equal(assigned.at(-1), hit.dataset.href);
455+ activate(hit);
456+ assert.equal(assigned.at(-1), hit.dataset.href);
457+ assert.notEqual(doc.getElementById('finder-status').textContent, '');
458+ f.q.value = '';
459+ F.input(f.q);
460+ assert.equal(doc.getElementById('finder-status').textContent, '');
461+});
@@ -83,7 +83,7 @@ function keydown(target, key, elementHandler, extra = {}) {
83 // ---- the palette --------------------------------------------------------83 // ---- the palette --------------------------------------------------------
84 84
85 function palette() {85 function palette() {
86- const li = (k, label) => el('li', { attrs: { 'data-k': k } }, el('a', { attrs: { href: '/' + label } }));86+ const li = (k, label) => el('li', { attrs: { 'data-k': k } }, el('a', { attrs: { 'data-href': '/' + label, role: 'presentation' } }));
87 const items = [li('go home', 'home'), li('section files', 'files'), li('branch main', 'main')];87 const items = [li('go home', 'home'), li('section files', 'files'), li('branch main', 'main')];
88 items[0].setAttribute('data-cursor', '');88 items[0].setAttribute('data-cursor', '');
89 const input = el('input', { id: 'cmd-q', attrs: { role: 'combobox', 'aria-controls': 'cmd-list' } });89 const input = el('input', { id: 'cmd-q', attrs: { role: 'combobox', 'aria-controls': 'cmd-list' } });
@@ -137,6 +137,8 @@ test('the palette: the file hits that arrive later get ids, roles and the active
137 assert.ok(hits.length > 0, 'a file hit came');137 assert.ok(hits.length > 0, 'a file hit came');
138 for (const h of hits) {138 for (const h of hits) {
139 assert.equal(h.getAttribute('role'), 'option');139 assert.equal(h.getAttribute('role'), 'option');
140+ assert.equal(h.querySelector('a[href]'), null);
141+ assert.equal(h.querySelector('a').hasAttribute('tabindex'), false);
140 assert.ok(h.id);142 assert.ok(h.id);
141 }143 }
142 assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');144 assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');
@@ -173,6 +175,7 @@ test('Escape in the finder clears the query, keeps the focus in the field and do
173 f.q.closest = () => f.q; // the global handler sees a field being typed in175 f.q.closest = () => f.q; // the global handler sees a field being typed in
174 const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));176 const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
175 assert.equal(f.q.value, '');177 assert.equal(f.q.value, '');
178+ assert.equal(doc.getElementById('finder-status').textContent, '');
176 assert.equal(e.stopped, true);179 assert.equal(e.stopped, true);
177 assert.equal(e.prevented, true);180 assert.equal(e.prevented, true);
178 assert.equal(blurred, false, 'the field keeps the focus');181 assert.equal(blurred, false, 'the field keeps the focus');
@@ -381,3 +384,78 @@ test('mobile MR layout shortcuts preserve the saved preference until back on des
381 doc.querySelector = oldQuery;384 doc.querySelector = oldQuery;
382 }385 }
383 });386 });
387+
388+test('dialog restores its row opener on close, but not over a replacement dialog', () => {
389+ const row = el('li', { tabindex: '0' });
390+ const first = el('dialog', { id: 'focus-first' });
391+ const second = el('dialog', { id: 'focus-second' });
392+ dom.body.append(row, first, second);
393+ const queued = [];
394+ const all = doc.querySelectorAll;
395+ const one = doc.querySelector;
396+ const open = () => [first, second].filter((d) => d.hasAttribute('open'));
397+ doc.querySelectorAll = (sel) => sel === 'dialog[open]' ? open() : all.call(doc, sel);
398+ doc.querySelector = (sel) => sel === 'dialog[open]' ? open()[0] ?? null : one.call(doc, sel);
399+ for (const d of [first, second]) {
400+ let onClose;
401+ d.addEventListener = (type, fn) => { if (type === 'close') onClose = fn; };
402+ d.showModal = () => { d.setAttribute('open', ''); d.focus(); };
403+ d.close = () => { d.removeAttribute('open'); queued.push(onClose); };
404+ }
405+ row.focus();
406+ window.ermite.dialog('focus-first');
407+ first.close();
408+ queued.shift()();
409+ assert.equal(doc.activeElement, row);
410+
411+ row.focus();
412+ window.ermite.dialog('focus-first');
413+ window.ermite.dialog('focus-second');
414+ queued.shift()();
415+ assert.equal(doc.activeElement, second);
416+ second.close();
417+ queued.shift()();
418+ doc.querySelectorAll = all;
419+ doc.querySelector = one;
420+});
421+
422+
423+test('listbox result wrappers have no links; Enter and delegated clicks navigate', async () => {
424+ doc.querySelector = query.one;
425+ doc.querySelectorAll = query.all;
426+ const assigned = [];
427+ location.assign = (href) => assigned.push(href);
428+ const activate = (anchor) => {
429+ const e = { target: anchor, button: 0, preventDefault() {} };
430+ listeners.click.find((fn) => fn.toString().includes('location.assign'))(e);
431+ };
432+ const p = palette();
433+ p.list.setAttribute('role', 'listbox');
434+ window.ermiteCmd.sync();
435+ for (const item of p.items) assert.equal(item.querySelector('a[href]'), null);
436+ const a = p.items[0].querySelector('a');
437+ a.click = () => activate(a);
438+ window.ermite.cmdKey({ key: 'Enter', preventDefault() {} });
439+ assert.deepEqual(assigned, ['/home']);
440+ activate(p.items[1].querySelector('a'));
441+ assert.deepEqual(assigned, ['/home', '/files']);
442+
443+ const f = finder();
444+ f.list.setAttribute('role', 'listbox');
445+ f.q.value = 'main';
446+ F.input(f.q);
447+ flushFrames();
448+ await settle();
449+ assert.equal(f.list.querySelector('a[href]'), null);
450+ const hit = f.list.children[0].querySelector('a');
451+ assert.equal(hit.hasAttribute('tabindex'), false);
452+ hit.click = () => activate(hit);
453+ F.key(f.q, { key: 'Enter', preventDefault() {} });
454+ assert.equal(assigned.at(-1), hit.dataset.href);
455+ activate(hit);
456+ assert.equal(assigned.at(-1), hit.dataset.href);
457+ assert.notEqual(doc.getElementById('finder-status').textContent, '');
458+ f.q.value = '';
459+ F.input(f.q);
460+ assert.equal(doc.getElementById('finder-status').textContent, '');
461+});
M internal/adapters/web/jstest/finder.test.mjs +9 -8
@@ -53,7 +53,7 @@ const key = (k, extra = {}) => {
5353 return e;
5454 };
5555 const status = () => document.getElementById('finder-status').textContent;
56-const hrefs = (list) => list.children.map((li) => li.querySelector('a').href);
56+const hrefs = (list) => list.children.map((li) => li.querySelector('a').dataset.href);
5757
5858 test('Enter before the results opens the top hit once they come', async () => {
5959 const p = page();
@@ -66,8 +66,8 @@ test('Enter before the results opens the top hit once they come', async () => {
6666 await settle();
6767 assert.equal(p.finderList.children.length > 0, true);
6868 assert.equal(dom.clicked.length, 1);
69- assert.equal(dom.clicked[0].href, hrefs(p.finderList)[0]);
70- assert.match(dom.clicked[0].href, /^\/blob\/main\/.*tree/);
69+ assert.equal(dom.clicked[0].dataset.href, hrefs(p.finderList)[0]);
70+ assert.match(dom.clicked[0].dataset.href, /^\/blob\/main\/.*tree/);
7171 assert.equal(fetches, 1);
7272 });
7373
@@ -112,7 +112,7 @@ test('Enter on current results opens the cursor row, whichever way the cursor mo
112112 assert.equal(items[2].getAttribute('aria-selected'), 'true');
113113 assert.equal(items[0].getAttribute('aria-selected'), 'false');
114114 assert.equal(p.q.getAttribute('aria-activedescendant'), items[2].id);
115- assert.equal(dom.clicked[0].href, items[2].querySelector('a').href);
115+ assert.equal(dom.clicked[0].dataset.href, items[2].querySelector('a').dataset.href);
116116 // The arrows move from there.
117117 F.key(p.q, key('ArrowUp'));
118118 assert.equal(p.q.getAttribute('aria-activedescendant'), items[1].id);
@@ -179,13 +179,14 @@ test('the palette lists the file hits under its entries', async () => {
179179 const hits = p.cmdList.children.filter((li) => li.className === 'finder-hit');
180180 assert.equal(hits.length, 4);
181181 assert.equal(p.cmdList.children[0].className, 'entry', 'the entries stay first');
182- assert.deepEqual(hits.map((li) => li.querySelector('a').href).sort(), [
182+ assert.deepEqual(hits.map((li) => li.querySelector('a').dataset.href).sort(), [
183183 '/blob/main/docs/web/tree-view.md',
184184 '/blob/main/internal/adapters/web/tree.go',
185185 '/blob/main/internal/adapters/web/tree.templ',
186186 '/blob/main/src/treehouse.txt',
187187 ]);
188- assert.equal(hits[0].querySelector('a').tabIndex, -1);
188+ assert.equal(hits[0].querySelector('a').hasAttribute('href'), false);
189+ assert.equal(hits[0].querySelector('a').hasAttribute('tabindex'), false);
189190 assert.equal(hits[0].querySelector('.k').textContent, 'file');
190191 assert.equal(p.cmdList.children.filter((li) => li.hasAttribute('data-cursor')).length, 1, 'one cursor');
191192
@@ -213,7 +214,7 @@ test('Enter typed before the frame of the query runs opens the top hit', async (
213214 assert.equal(dom.clicked.length, 0, 'nothing to open before the frame');
214215 flushFrames();
215216 await settle();
216- assert.deepEqual(dom.clicked.map((a) => a.href), ['/blob/main/cmd/ermite/main.go']);
217+ assert.deepEqual(dom.clicked.map((a) => a.dataset.href), ['/blob/main/cmd/ermite/main.go']);
217218 } finally {
218219 dom.holdFrames = false;
219220 }
@@ -257,7 +258,7 @@ test('the palette drops the results of a query that is stale', async () => {
257258 p.cmdQ.value = 'readme'; // typed before the search of "tree" finished
258259 F.palette(p.cmdQ);
259260 await settle();
260- const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').href);
261+ const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').dataset.href);
261262 assert.deepEqual(got, ['/blob/main/README.md']);
262263
263264 // A field changed without a new call: the pending search is dropped.
@@ -53,7 +53,7 @@ const key = (k, extra = {}) => {
53 return e;53 return e;
54 };54 };
55 const status = () => document.getElementById('finder-status').textContent;55 const status = () => document.getElementById('finder-status').textContent;
56-const hrefs = (list) => list.children.map((li) => li.querySelector('a').href);56+const hrefs = (list) => list.children.map((li) => li.querySelector('a').dataset.href);
57 57
58 test('Enter before the results opens the top hit once they come', async () => {58 test('Enter before the results opens the top hit once they come', async () => {
59 const p = page();59 const p = page();
@@ -66,8 +66,8 @@ test('Enter before the results opens the top hit once they come', async () => {
66 await settle();66 await settle();
67 assert.equal(p.finderList.children.length > 0, true);67 assert.equal(p.finderList.children.length > 0, true);
68 assert.equal(dom.clicked.length, 1);68 assert.equal(dom.clicked.length, 1);
69- assert.equal(dom.clicked[0].href, hrefs(p.finderList)[0]);69+ assert.equal(dom.clicked[0].dataset.href, hrefs(p.finderList)[0]);
70- assert.match(dom.clicked[0].href, /^\/blob\/main\/.*tree/);70+ assert.match(dom.clicked[0].dataset.href, /^\/blob\/main\/.*tree/);
71 assert.equal(fetches, 1);71 assert.equal(fetches, 1);
72 });72 });
73 73
@@ -112,7 +112,7 @@ test('Enter on current results opens the cursor row, whichever way the cursor mo
112 assert.equal(items[2].getAttribute('aria-selected'), 'true');112 assert.equal(items[2].getAttribute('aria-selected'), 'true');
113 assert.equal(items[0].getAttribute('aria-selected'), 'false');113 assert.equal(items[0].getAttribute('aria-selected'), 'false');
114 assert.equal(p.q.getAttribute('aria-activedescendant'), items[2].id);114 assert.equal(p.q.getAttribute('aria-activedescendant'), items[2].id);
115- assert.equal(dom.clicked[0].href, items[2].querySelector('a').href);115+ assert.equal(dom.clicked[0].dataset.href, items[2].querySelector('a').dataset.href);
116 // The arrows move from there.116 // The arrows move from there.
117 F.key(p.q, key('ArrowUp'));117 F.key(p.q, key('ArrowUp'));
118 assert.equal(p.q.getAttribute('aria-activedescendant'), items[1].id);118 assert.equal(p.q.getAttribute('aria-activedescendant'), items[1].id);
@@ -179,13 +179,14 @@ test('the palette lists the file hits under its entries', async () => {
179 const hits = p.cmdList.children.filter((li) => li.className === 'finder-hit');179 const hits = p.cmdList.children.filter((li) => li.className === 'finder-hit');
180 assert.equal(hits.length, 4);180 assert.equal(hits.length, 4);
181 assert.equal(p.cmdList.children[0].className, 'entry', 'the entries stay first');181 assert.equal(p.cmdList.children[0].className, 'entry', 'the entries stay first');
182- assert.deepEqual(hits.map((li) => li.querySelector('a').href).sort(), [182+ assert.deepEqual(hits.map((li) => li.querySelector('a').dataset.href).sort(), [
183 '/blob/main/docs/web/tree-view.md',183 '/blob/main/docs/web/tree-view.md',
184 '/blob/main/internal/adapters/web/tree.go',184 '/blob/main/internal/adapters/web/tree.go',
185 '/blob/main/internal/adapters/web/tree.templ',185 '/blob/main/internal/adapters/web/tree.templ',
186 '/blob/main/src/treehouse.txt',186 '/blob/main/src/treehouse.txt',
187 ]);187 ]);
188- assert.equal(hits[0].querySelector('a').tabIndex, -1);188+ assert.equal(hits[0].querySelector('a').hasAttribute('href'), false);
189+ assert.equal(hits[0].querySelector('a').hasAttribute('tabindex'), false);
189 assert.equal(hits[0].querySelector('.k').textContent, 'file');190 assert.equal(hits[0].querySelector('.k').textContent, 'file');
190 assert.equal(p.cmdList.children.filter((li) => li.hasAttribute('data-cursor')).length, 1, 'one cursor');191 assert.equal(p.cmdList.children.filter((li) => li.hasAttribute('data-cursor')).length, 1, 'one cursor');
191 192
@@ -213,7 +214,7 @@ test('Enter typed before the frame of the query runs opens the top hit', async (
213 assert.equal(dom.clicked.length, 0, 'nothing to open before the frame');214 assert.equal(dom.clicked.length, 0, 'nothing to open before the frame');
214 flushFrames();215 flushFrames();
215 await settle();216 await settle();
216- assert.deepEqual(dom.clicked.map((a) => a.href), ['/blob/main/cmd/ermite/main.go']);217+ assert.deepEqual(dom.clicked.map((a) => a.dataset.href), ['/blob/main/cmd/ermite/main.go']);
217 } finally {218 } finally {
218 dom.holdFrames = false;219 dom.holdFrames = false;
219 }220 }
@@ -257,7 +258,7 @@ test('the palette drops the results of a query that is stale', async () => {
257 p.cmdQ.value = 'readme'; // typed before the search of "tree" finished258 p.cmdQ.value = 'readme'; // typed before the search of "tree" finished
258 F.palette(p.cmdQ);259 F.palette(p.cmdQ);
259 await settle();260 await settle();
260- const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').href);261+ const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').dataset.href);
261 assert.deepEqual(got, ['/blob/main/README.md']);262 assert.deepEqual(got, ['/blob/main/README.md']);
262 263
263 // A field changed without a new call: the pending search is dropped.264 // A field changed without a new call: the pending search is dropped.
M internal/adapters/web/jstest/mr.test.mjs +33 -1
@@ -106,7 +106,7 @@ function footerHarness(extra = {}) {
106106 const nav = node('tabs'); nav.querySelectorAll = () => tabs;
107107 const listeners = {}, replaced = [];
108108 globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null,
109- querySelectorAll: (s) => s === '[data-mr-tabs] a' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } };
109+ querySelectorAll: (s) => s === '[data-mr-tabs] button' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } };
110110 globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' };
111111 globalThis.addEventListener = (k, v) => { listeners[k] = v; };
112112 globalThis.history = { replaceState: (...args) => replaced.push(args) };
@@ -531,3 +531,35 @@ test('intercepted Cancel resets the entire comment form', () => {
531531 assert.equal(window.ermiteMR.hasDraft(), false);
532532 } finally { Object.assign(globalThis, saved); c.restore(); }
533533 });
534+
535+
536+test('live refresh preserves the open button panel even when the hash points elsewhere', async () => {
537+ const { readFileSync } = await import('node:fs');
538+ const src = readFileSync(new URL('../static/js/mr.js', import.meta.url), 'utf8');
539+ const start = src.indexOf(' async function refreshPage(page)');
540+ const end = src.indexOf(' function refreshNotice', start);
541+ const { el, installDom } = await import('./dom.mjs');
542+ const saved = globalThis.document;
543+ const h = footerHarness();
544+ try {
545+ window.ermiteMR.panels();
546+ h.tabs[1].events.click({ preventDefault() {} });
547+ location.hash = '#f99';
548+ const doc = installDom();
549+ Object.defineProperty(doc, 'activeElement', { get: () => null });
550+ const diffs = el('div');
551+ diffs.dataset.diff = 'unified';
552+ const fresh = el('footer', {}, el('section', { attrs: { 'data-mr-panel': '' } }), el('section', { attrs: { 'data-mr-panel': '' } }));
553+ const page = { body: { dataset: {} } };
554+ const $ = (s, root) => s === '#diffs' ? diffs : s === '.mr-footer' ? (root === page ? fresh : h.footer) : el('div');
555+ const $$ = (s, root) => s === '[data-mr-tabs] button' ? h.tabs : s === '[data-mr-panel]' ? fresh.children : [];
556+ let selected;
557+ const lazy = { reset() {}, watch() {} };
558+ const refresh = new Function('$', '$$', 'scrollAnchor', 'hasDraft', 'replaceRegion', 'panels', 'watchBar', 'foldFiles', 'touchComments', 'restoreAnchor', 'window', 'document',
559+ 'let revealGen = 0; ' + src.slice(start, end) + '; return refreshPage;')(
560+ $, $$, () => ({}), () => false, async () => {}, (i) => { selected = i; }, () => {}, () => {}, () => {}, () => {}, { ermiteLazy: lazy }, doc);
561+ await refresh(page);
562+ assert.equal(selected, 1);
563+ assert.deepEqual(fresh.children.map((p) => p.hidden), [true, false]);
564+ } finally { h.restore(); globalThis.document = saved; }
565+});
@@ -106,7 +106,7 @@ function footerHarness(extra = {}) {
106 const nav = node('tabs'); nav.querySelectorAll = () => tabs;106 const nav = node('tabs'); nav.querySelectorAll = () => tabs;
107 const listeners = {}, replaced = [];107 const listeners = {}, replaced = [];
108 globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null,108 globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null,
109- querySelectorAll: (s) => s === '[data-mr-tabs] a' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } };109+ querySelectorAll: (s) => s === '[data-mr-tabs] button' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } };
110 globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' };110 globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' };
111 globalThis.addEventListener = (k, v) => { listeners[k] = v; };111 globalThis.addEventListener = (k, v) => { listeners[k] = v; };
112 globalThis.history = { replaceState: (...args) => replaced.push(args) };112 globalThis.history = { replaceState: (...args) => replaced.push(args) };
@@ -531,3 +531,35 @@ test('intercepted Cancel resets the entire comment form', () => {
531 assert.equal(window.ermiteMR.hasDraft(), false);531 assert.equal(window.ermiteMR.hasDraft(), false);
532 } finally { Object.assign(globalThis, saved); c.restore(); }532 } finally { Object.assign(globalThis, saved); c.restore(); }
533 });533 });
534+
535+
536+test('live refresh preserves the open button panel even when the hash points elsewhere', async () => {
537+ const { readFileSync } = await import('node:fs');
538+ const src = readFileSync(new URL('../static/js/mr.js', import.meta.url), 'utf8');
539+ const start = src.indexOf(' async function refreshPage(page)');
540+ const end = src.indexOf(' function refreshNotice', start);
541+ const { el, installDom } = await import('./dom.mjs');
542+ const saved = globalThis.document;
543+ const h = footerHarness();
544+ try {
545+ window.ermiteMR.panels();
546+ h.tabs[1].events.click({ preventDefault() {} });
547+ location.hash = '#f99';
548+ const doc = installDom();
549+ Object.defineProperty(doc, 'activeElement', { get: () => null });
550+ const diffs = el('div');
551+ diffs.dataset.diff = 'unified';
552+ const fresh = el('footer', {}, el('section', { attrs: { 'data-mr-panel': '' } }), el('section', { attrs: { 'data-mr-panel': '' } }));
553+ const page = { body: { dataset: {} } };
554+ const $ = (s, root) => s === '#diffs' ? diffs : s === '.mr-footer' ? (root === page ? fresh : h.footer) : el('div');
555+ const $$ = (s, root) => s === '[data-mr-tabs] button' ? h.tabs : s === '[data-mr-panel]' ? fresh.children : [];
556+ let selected;
557+ const lazy = { reset() {}, watch() {} };
558+ const refresh = new Function('$', '$$', 'scrollAnchor', 'hasDraft', 'replaceRegion', 'panels', 'watchBar', 'foldFiles', 'touchComments', 'restoreAnchor', 'window', 'document',
559+ 'let revealGen = 0; ' + src.slice(start, end) + '; return refreshPage;')(
560+ $, $$, () => ({}), () => false, async () => {}, (i) => { selected = i; }, () => {}, () => {}, () => {}, () => {}, { ermiteLazy: lazy }, doc);
561+ await refresh(page);
562+ assert.equal(selected, 1);
563+ assert.deepEqual(fresh.children.map((p) => p.hidden), [true, false]);
564+ } finally { h.restore(); globalThis.document = saved; }
565+});
M internal/adapters/web/mr.templ +9 -9
@@ -296,7 +296,7 @@ templ commentView(c CommentView, suffix string) {
296296 <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
297297 }
298298 if c.Edit != nil {
299- <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
299+ <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000" aria-label="Edit note">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
300300 }
301301 if c.Delete != nil {
302302 <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
@@ -373,9 +373,9 @@ templ commentForm(f CommentForm, suffix string) {
373373
374374 templ mrFooter(p MRPage) {
375375 <footer class="mr-footer">
376- <nav data-mr-tabs aria-label="Branch details">
377- <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
378- <a id="mr-tab-checks" href="#mr-checks">
376+ <div data-mr-tabs data-js role="group" aria-label="Branch details">
377+ <button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">Commits <span class="count">{ p.CommitCount }</span></button>
378+ <button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">
379379 { "Checks " }
380380 <span data-checks-label>
381381 if p.ChecksSum.Total > 0 {
@@ -384,10 +384,10 @@ templ mrFooter(p MRPage) {
384384 no checks
385385 }
386386 </span>
387- </a>
388- </nav>
389- <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits">
390- <table class="mr-commits">
387+ </button>
388+ </div>
389+ <div id="mr-commits" data-mr-panel role="group" aria-labelledby="mr-tab-commits">
390+ <table class="mr-commits" aria-label="Commits">
391391 <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
392392 <tbody>
393393 for _, c := range p.Commits {
@@ -421,7 +421,7 @@ templ mrFooter(p MRPage) {
421421 <p class="note">{ p.CommitsNote }</p>
422422 }
423423 </div>
424- <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks">
424+ <div id="mr-checks-panel" data-mr-panel role="group" aria-labelledby="mr-tab-checks">
425425 @MRChecks(p.Checks)
426426 </div>
427427 </footer>
@@ -296,7 +296,7 @@ templ commentView(c CommentView, suffix string) {
296 <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>296 <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
297 }297 }
298 if c.Edit != nil {298 if c.Edit != nil {
299- <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>299+ <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000" aria-label="Edit note">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
300 }300 }
301 if c.Delete != nil {301 if c.Delete != nil {
302 <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>302 <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
@@ -373,9 +373,9 @@ templ commentForm(f CommentForm, suffix string) {
373 373
374 templ mrFooter(p MRPage) {374 templ mrFooter(p MRPage) {
375 <footer class="mr-footer">375 <footer class="mr-footer">
376- <nav data-mr-tabs aria-label="Branch details">376+ <div data-mr-tabs data-js role="group" aria-label="Branch details">
377- <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>377+ <button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">Commits <span class="count">{ p.CommitCount }</span></button>
378- <a id="mr-tab-checks" href="#mr-checks">378+ <button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">
379 { "Checks " }379 { "Checks " }
380 <span data-checks-label>380 <span data-checks-label>
381 if p.ChecksSum.Total > 0 {381 if p.ChecksSum.Total > 0 {
@@ -384,10 +384,10 @@ templ mrFooter(p MRPage) {
384 no checks384 no checks
385 }385 }
386 </span>386 </span>
387- </a>387+ </button>
388- </nav>388+ </div>
389- <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits">389+ <div id="mr-commits" data-mr-panel role="group" aria-labelledby="mr-tab-commits">
390- <table class="mr-commits">390+ <table class="mr-commits" aria-label="Commits">
391 <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>391 <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
392 <tbody>392 <tbody>
393 for _, c := range p.Commits {393 for _, c := range p.Commits {
@@ -421,7 +421,7 @@ templ mrFooter(p MRPage) {
421 <p class="note">{ p.CommitsNote }</p>421 <p class="note">{ p.CommitsNote }</p>
422 }422 }
423 </div>423 </div>
424- <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks">424+ <div id="mr-checks-panel" data-mr-panel role="group" aria-labelledby="mr-tab-checks">
425 @MRChecks(p.Checks)425 @MRChecks(p.Checks)
426 </div>426 </div>
427 </footer>427 </footer>
M internal/adapters/web/mr_templ.go +7 -7
@@ -1555,14 +1555,14 @@ func commentView(c CommentView, suffix string) templ.Component {
15551555 if templ_7745c5c3_Err != nil {
15561556 return templ_7745c5c3_Err
15571557 }
1558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><textarea name=\"text\" maxlength=\"8000\">")
1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><textarea name=\"text\" maxlength=\"8000\" aria-label=\"Edit note\">")
15591559 if templ_7745c5c3_Err != nil {
15601560 return templ_7745c5c3_Err
15611561 }
15621562 var templ_7745c5c3_Var72 string
15631563 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)
15641564 if templ_7745c5c3_Err != nil {
1565- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 299, Col: 209}
1565+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 299, Col: 232}
15661566 }
15671567 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
15681568 if templ_7745c5c3_Err != nil {
@@ -1961,20 +1961,20 @@ func mrFooter(p MRPage) templ.Component {
19611961 templ_7745c5c3_Var91 = templ.NopComponent
19621962 }
19631963 ctx = templ.ClearChildren(ctx)
1964- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<footer class=\"mr-footer\"><nav data-mr-tabs aria-label=\"Branch details\"><a id=\"mr-tab-commits\" href=\"#mr-commits\">Commits <span class=\"count\">")
1964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<footer class=\"mr-footer\"><div data-mr-tabs data-js role=\"group\" aria-label=\"Branch details\"><button type=\"button\" id=\"mr-tab-commits\" aria-expanded=\"true\" aria-controls=\"mr-commits\">Commits <span class=\"count\">")
19651965 if templ_7745c5c3_Err != nil {
19661966 return templ_7745c5c3_Err
19671967 }
19681968 var templ_7745c5c3_Var92 string
19691969 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitCount)
19701970 if templ_7745c5c3_Err != nil {
1971- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 88}
1971+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 136}
19721972 }
19731973 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
19741974 if templ_7745c5c3_Err != nil {
19751975 return templ_7745c5c3_Err
19761976 }
1977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</span></a> <a id=\"mr-tab-checks\" href=\"#mr-checks\">")
1977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</span></button> <button type=\"button\" id=\"mr-tab-checks\" aria-expanded=\"true\" aria-controls=\"mr-checks-panel\">")
19781978 if templ_7745c5c3_Err != nil {
19791979 return templ_7745c5c3_Err
19801980 }
@@ -2007,7 +2007,7 @@ func mrFooter(p MRPage) templ.Component {
20072007 return templ_7745c5c3_Err
20082008 }
20092009 }
2010- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</span></a></nav><div id=\"mr-commits\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>")
2010+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</span></button></div><div id=\"mr-commits\" data-mr-panel role=\"group\" aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\" aria-label=\"Commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>")
20112011 if templ_7745c5c3_Err != nil {
20122012 return templ_7745c5c3_Err
20132013 }
@@ -2164,7 +2164,7 @@ func mrFooter(p MRPage) templ.Component {
21642164 return templ_7745c5c3_Err
21652165 }
21662166 }
2167- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</div><div id=\"mr-checks-panel\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-checks\">")
2167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</div><div id=\"mr-checks-panel\" data-mr-panel role=\"group\" aria-labelledby=\"mr-tab-checks\">")
21682168 if templ_7745c5c3_Err != nil {
21692169 return templ_7745c5c3_Err
21702170 }
@@ -1555,14 +1555,14 @@ func commentView(c CommentView, suffix string) templ.Component {
1555 if templ_7745c5c3_Err != nil {1555 if templ_7745c5c3_Err != nil {
1556 return templ_7745c5c3_Err1556 return templ_7745c5c3_Err
1557 }1557 }
1558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><textarea name=\"text\" maxlength=\"8000\">")1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><textarea name=\"text\" maxlength=\"8000\" aria-label=\"Edit note\">")
1559 if templ_7745c5c3_Err != nil {1559 if templ_7745c5c3_Err != nil {
1560 return templ_7745c5c3_Err1560 return templ_7745c5c3_Err
1561 }1561 }
1562 var templ_7745c5c3_Var72 string1562 var templ_7745c5c3_Var72 string
1563 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)1563 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)
1564 if templ_7745c5c3_Err != nil {1564 if templ_7745c5c3_Err != nil {
1565- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 299, Col: 209}1565+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 299, Col: 232}
1566 }1566 }
1567 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))1567 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
1568 if templ_7745c5c3_Err != nil {1568 if templ_7745c5c3_Err != nil {
@@ -1961,20 +1961,20 @@ func mrFooter(p MRPage) templ.Component {
1961 templ_7745c5c3_Var91 = templ.NopComponent1961 templ_7745c5c3_Var91 = templ.NopComponent
1962 }1962 }
1963 ctx = templ.ClearChildren(ctx)1963 ctx = templ.ClearChildren(ctx)
1964- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<footer class=\"mr-footer\"><nav data-mr-tabs aria-label=\"Branch details\"><a id=\"mr-tab-commits\" href=\"#mr-commits\">Commits <span class=\"count\">")1964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<footer class=\"mr-footer\"><div data-mr-tabs data-js role=\"group\" aria-label=\"Branch details\"><button type=\"button\" id=\"mr-tab-commits\" aria-expanded=\"true\" aria-controls=\"mr-commits\">Commits <span class=\"count\">")
1965 if templ_7745c5c3_Err != nil {1965 if templ_7745c5c3_Err != nil {
1966 return templ_7745c5c3_Err1966 return templ_7745c5c3_Err
1967 }1967 }
1968 var templ_7745c5c3_Var92 string1968 var templ_7745c5c3_Var92 string
1969 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitCount)1969 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitCount)
1970 if templ_7745c5c3_Err != nil {1970 if templ_7745c5c3_Err != nil {
1971- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 88}1971+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 136}
1972 }1972 }
1973 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))1973 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
1974 if templ_7745c5c3_Err != nil {1974 if templ_7745c5c3_Err != nil {
1975 return templ_7745c5c3_Err1975 return templ_7745c5c3_Err
1976 }1976 }
1977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</span></a> <a id=\"mr-tab-checks\" href=\"#mr-checks\">")1977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</span></button> <button type=\"button\" id=\"mr-tab-checks\" aria-expanded=\"true\" aria-controls=\"mr-checks-panel\">")
1978 if templ_7745c5c3_Err != nil {1978 if templ_7745c5c3_Err != nil {
1979 return templ_7745c5c3_Err1979 return templ_7745c5c3_Err
1980 }1980 }
@@ -2007,7 +2007,7 @@ func mrFooter(p MRPage) templ.Component {
2007 return templ_7745c5c3_Err2007 return templ_7745c5c3_Err
2008 }2008 }
2009 }2009 }
2010- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</span></a></nav><div id=\"mr-commits\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>")2010+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</span></button></div><div id=\"mr-commits\" data-mr-panel role=\"group\" aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\" aria-label=\"Commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>")
2011 if templ_7745c5c3_Err != nil {2011 if templ_7745c5c3_Err != nil {
2012 return templ_7745c5c3_Err2012 return templ_7745c5c3_Err
2013 }2013 }
@@ -2164,7 +2164,7 @@ func mrFooter(p MRPage) templ.Component {
2164 return templ_7745c5c3_Err2164 return templ_7745c5c3_Err
2165 }2165 }
2166 }2166 }
2167- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</div><div id=\"mr-checks-panel\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-checks\">")2167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</div><div id=\"mr-checks-panel\" data-mr-panel role=\"group\" aria-labelledby=\"mr-tab-checks\">")
2168 if templ_7745c5c3_Err != nil {2168 if templ_7745c5c3_Err != nil {
2169 return templ_7745c5c3_Err2169 return templ_7745c5c3_Err
2170 }2170 }
M internal/adapters/web/mr_test.go +8 -7
@@ -137,21 +137,22 @@ func TestMRPanelsAndMenus(t *testing.T) {
137137 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
138138 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
139139 footer := renderToString(t, mrFooter(p))
140- assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
140+ assert.Contains(t, footer, `<footer class="mr-footer"><div data-mr-tabs data-js role="group"`)
141141 assert.NotContains(t, footer, `<details`)
142142 assert.NotContains(t, footer, `<summary`)
143- // No hidden attribute: the CSS shows the panel of the URL fragment until
144- // mr.js takes over, so the tabs work as anchors without scripts.
145- assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
146- assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
143+ // Both panels are readable without scripts; JS enhances native buttons.
144+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="group"`)
145+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="group"`)
147146 assert.NotContains(t, footer, `data-mr-panel hidden`)
148147 css, err := staticFS.ReadFile("static/css/60-mr.css")
149148 require.NoError(t, err)
150- assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)
149+ assert.NotContains(t, string(css), `:not([data-panels])`)
150+ assert.Contains(t, footer, `<button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">`)
151+ assert.Contains(t, footer, `<button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">`)
151152 assert.Contains(t, footer, `data-mr-tabs`)
152153 assert.Contains(t, footer, `id="mr-commits"`)
153154 assert.Contains(t, footer, `id="mr-checks"`)
154- assert.Contains(t, footer, `<table class="mr-commits">`)
155+ assert.Contains(t, footer, `<table class="mr-commits" aria-label="Commits">`)
155156 assert.Contains(t, footer, "500+")
156157 assert.Contains(t, footer, "more commits")
157158 assert.Contains(t, footer, "1/2 failed")
@@ -137,21 +137,22 @@ func TestMRPanelsAndMenus(t *testing.T) {
137 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,137 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
138 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}138 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
139 footer := renderToString(t, mrFooter(p))139 footer := renderToString(t, mrFooter(p))
140- assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)140+ assert.Contains(t, footer, `<footer class="mr-footer"><div data-mr-tabs data-js role="group"`)
141 assert.NotContains(t, footer, `<details`)141 assert.NotContains(t, footer, `<details`)
142 assert.NotContains(t, footer, `<summary`)142 assert.NotContains(t, footer, `<summary`)
143- // No hidden attribute: the CSS shows the panel of the URL fragment until143+ // Both panels are readable without scripts; JS enhances native buttons.
144- // mr.js takes over, so the tabs work as anchors without scripts.144+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="group"`)
145- assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)145+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="group"`)
146- assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
147 assert.NotContains(t, footer, `data-mr-panel hidden`)146 assert.NotContains(t, footer, `data-mr-panel hidden`)
148 css, err := staticFS.ReadFile("static/css/60-mr.css")147 css, err := staticFS.ReadFile("static/css/60-mr.css")
149 require.NoError(t, err)148 require.NoError(t, err)
150- assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)149+ assert.NotContains(t, string(css), `:not([data-panels])`)
150+ assert.Contains(t, footer, `<button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">`)
151+ assert.Contains(t, footer, `<button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">`)
151 assert.Contains(t, footer, `data-mr-tabs`)152 assert.Contains(t, footer, `data-mr-tabs`)
152 assert.Contains(t, footer, `id="mr-commits"`)153 assert.Contains(t, footer, `id="mr-commits"`)
153 assert.Contains(t, footer, `id="mr-checks"`)154 assert.Contains(t, footer, `id="mr-checks"`)
154- assert.Contains(t, footer, `<table class="mr-commits">`)155+ assert.Contains(t, footer, `<table class="mr-commits" aria-label="Commits">`)
155 assert.Contains(t, footer, "500+")156 assert.Contains(t, footer, "500+")
156 assert.Contains(t, footer, "more commits")157 assert.Contains(t, footer, "more commits")
157 assert.Contains(t, footer, "1/2 failed")158 assert.Contains(t, footer, "1/2 failed")
M internal/adapters/web/overlays.templ +5 -5
@@ -11,7 +11,7 @@ import (
1111 // show on that page only.
1212 templ OverlayHelp(mr bool) {
1313 <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
14- <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>
14+ <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>
1515 <div class="keys-pref">
1616 <label><input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"/> Turn off single-key shortcuts</label>
1717 <p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
@@ -60,7 +60,7 @@ templ OverlayRefs(l Layout) {
6060 data-pick="#panel-refs li"
6161 hx-on:input="ermite.filter(this)"
6262 />
63- <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
63+ <button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
6464 </div>
6565 <div class="panel-cols" data-picklist>
6666 <section aria-label="Branches">
@@ -123,13 +123,13 @@ templ OverlayCmd(items []CmdItem) {
123123 hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"
124124 hx-on:keydown="ermite.cmdKey(event)"
125125 />
126- <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
126+ <button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
127127 </div>
128128 <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
129129 for i, c := range items {
130- <li id={ "cmd-o" + itoa(i) } role="option" aria-selected={ strconv.FormatBool(i == 0) } data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="lbl">{ c.Label }</span><span class="k">{ c.Kind }</span></a></li>
130+ <li id={ "cmd-o" + itoa(i) } role="option" aria-selected={ strconv.FormatBool(i == 0) } data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a role="presentation" data-href={ c.Href }><span class="lbl">{ c.Label }</span><span class="k">{ c.Kind }</span></a></li>
131131 }
132- <li id={ "cmd-o" + itoa(len(items)) } role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"><a href="#" data-fire="keys" tabindex="-1"><span class="lbl">turn single-key shortcuts off</span><span class="k">action</span></a></li>
132+ <li id={ "cmd-o" + itoa(len(items)) } role="option" aria-selected={ strconv.FormatBool(len(items) == 0) } data-cursor?={ len(items) == 0 } data-k="action turn single-key shortcuts on off keyboard"><a role="presentation" data-fire="keys"><span class="lbl">turn single-key shortcuts off</span><span class="k">action</span></a></li>
133133 </ul>
134134 <p class="panel-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p>
135135 </dialog>
@@ -11,7 +11,7 @@ import (
11 // show on that page only.11 // show on that page only.
12 templ OverlayHelp(mr bool) {12 templ OverlayHelp(mr bool) {
13 <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">13 <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
14- <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>14+ <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>
15 <div class="keys-pref">15 <div class="keys-pref">
16 <label><input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"/> Turn off single-key shortcuts</label>16 <label><input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"/> Turn off single-key shortcuts</label>
17 <p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>17 <p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
@@ -60,7 +60,7 @@ templ OverlayRefs(l Layout) {
60 data-pick="#panel-refs li"60 data-pick="#panel-refs li"
61 hx-on:input="ermite.filter(this)"61 hx-on:input="ermite.filter(this)"
62 />62 />
63- <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>63+ <button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
64 </div>64 </div>
65 <div class="panel-cols" data-picklist>65 <div class="panel-cols" data-picklist>
66 <section aria-label="Branches">66 <section aria-label="Branches">
@@ -123,13 +123,13 @@ templ OverlayCmd(items []CmdItem) {
123 hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"123 hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"
124 hx-on:keydown="ermite.cmdKey(event)"124 hx-on:keydown="ermite.cmdKey(event)"
125 />125 />
126- <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>126+ <button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
127 </div>127 </div>
128 <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">128 <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
129 for i, c := range items {129 for i, c := range items {
130- <li id={ "cmd-o" + itoa(i) } role="option" aria-selected={ strconv.FormatBool(i == 0) } data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="lbl">{ c.Label }</span><span class="k">{ c.Kind }</span></a></li>130+ <li id={ "cmd-o" + itoa(i) } role="option" aria-selected={ strconv.FormatBool(i == 0) } data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a role="presentation" data-href={ c.Href }><span class="lbl">{ c.Label }</span><span class="k">{ c.Kind }</span></a></li>
131 }131 }
132- <li id={ "cmd-o" + itoa(len(items)) } role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"><a href="#" data-fire="keys" tabindex="-1"><span class="lbl">turn single-key shortcuts off</span><span class="k">action</span></a></li>132+ <li id={ "cmd-o" + itoa(len(items)) } role="option" aria-selected={ strconv.FormatBool(len(items) == 0) } data-cursor?={ len(items) == 0 } data-k="action turn single-key shortcuts on off keyboard"><a role="presentation" data-fire="keys"><span class="lbl">turn single-key shortcuts off</span><span class="k">action</span></a></li>
133 </ul>133 </ul>
134 <p class="panel-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p>134 <p class="panel-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p>
135 </dialog>135 </dialog>
M internal/adapters/web/overlays_templ.go +35 -12 generated file: collapsed
M internal/adapters/web/shell.templ +4 -4
@@ -207,10 +207,10 @@ templ Footer(l Layout) {
207207 // move, the selected lines of a file, a flash message (permalink copied) and
208208 // a lost event stream. Empty, it takes no room.
209209 templ Hint(l Layout) {
210- <div class="hint" role="status" aria-live="polite">
211- <span class="hint-nav" :text="document.body.dataset.nav || ''"></span>
212- <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"></span>
213- <span class="hint-flash" :text="document.body.dataset.flash || ''"></span>
210+ <div class="hint">
211+ <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''"></span>
212+ <span role="status" aria-live="polite"><span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"></span>
213+ <span class="hint-flash" :text="document.body.dataset.flash || ''"></span></span>
214214 @Live(l)
215215 </div>
216216 }
@@ -207,10 +207,10 @@ templ Footer(l Layout) {
207 // move, the selected lines of a file, a flash message (permalink copied) and207 // move, the selected lines of a file, a flash message (permalink copied) and
208 // a lost event stream. Empty, it takes no room.208 // a lost event stream. Empty, it takes no room.
209 templ Hint(l Layout) {209 templ Hint(l Layout) {
210- <div class="hint" role="status" aria-live="polite">210+ <div class="hint">
211- <span class="hint-nav" :text="document.body.dataset.nav || ''"></span>211+ <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''"></span>
212- <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"></span>212+ <span role="status" aria-live="polite"><span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"></span>
213- <span class="hint-flash" :text="document.body.dataset.flash || ''"></span>213+ <span class="hint-flash" :text="document.body.dataset.flash || ''"></span></span>
214 @Live(l)214 @Live(l)
215 </div>215 </div>
216 }216 }
M internal/adapters/web/shell_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/static/css/20-shell.css +2 -1
@@ -204,12 +204,13 @@
204204 background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: var(--shadow);
205205 pointer-events: none;
206206 & > :empty { display: none; }
207+ & > [role="status"] { display: contents; }
207208 & .hint-live { color: var(--red); }
208209 }
209210 /* empty, the badge is out of sight but stays a status region (display: none
210211 would drop it from the accessibility tree and its next message would not be
211212 announced) */
212- .hint:not(:has(> :not(:empty))) {
213+ .hint:not(:has(.hint-nav:not(:empty), .hint-sel:not(:empty), .hint-flash:not(:empty), .hint-live:not(:empty))) {
213214 width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none;
214215 }
215216
@@ -204,12 +204,13 @@
204 background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: var(--shadow);204 background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: var(--shadow);
205 pointer-events: none;205 pointer-events: none;
206 & > :empty { display: none; }206 & > :empty { display: none; }
207+ & > [role="status"] { display: contents; }
207 & .hint-live { color: var(--red); }208 & .hint-live { color: var(--red); }
208 }209 }
209 /* empty, the badge is out of sight but stays a status region (display: none210 /* empty, the badge is out of sight but stays a status region (display: none
210 would drop it from the accessibility tree and its next message would not be211 would drop it from the accessibility tree and its next message would not be
211 announced) */212 announced) */
212- .hint:not(:has(> :not(:empty))) {213+ .hint:not(:has(.hint-nav:not(:empty), .hint-sel:not(:empty), .hint-flash:not(:empty), .hint-live:not(:empty))) {
213 width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none;214 width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none;
214 }215 }
215 216
M internal/adapters/web/static/css/60-mr.css +5 -8
@@ -17,22 +17,19 @@
1717 font-size: var(--fs-s);
1818 /* the tabs are buttons: a thin border, the count in a darker badge;
1919 the open one in the teal accent */
20- & nav { display: flex; flex-wrap: wrap; gap: var(--s2); }
21- & [data-mr-tabs] a {
20+ & [data-mr-tabs] { display: flex; flex-wrap: wrap; gap: var(--s2); }
21+ & [data-mr-tabs] button {
2222 display: inline-flex; align-items: center; gap: var(--s1);
2323 padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);
2424 color: var(--ink-dim); text-decoration: none; cursor: pointer;
2525 }
2626 & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }
2727 & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
28- & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }
29- & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
28+ & [data-mr-tabs] button:hover { background: var(--bg-strong); color: var(--ink-hi); }
29+ & [data-mr-tabs] button[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
3030 & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }
3131 & [hidden] { display: none; }
32- /* Until mr.js drives the panels (data-panels, then [hidden]), only the
33- panel of the URL fragment shows: the tabs work as anchors without
34- scripts, and no panel flashes open before the script runs. */
35- &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }
32+ /* Without scripts both panels remain visible; disclosure controls use data-js. */
3633 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
3734 & table { width: 100%; border-collapse: collapse; }
3835 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
@@ -17,22 +17,19 @@
17 font-size: var(--fs-s);17 font-size: var(--fs-s);
18 /* the tabs are buttons: a thin border, the count in a darker badge;18 /* the tabs are buttons: a thin border, the count in a darker badge;
19 the open one in the teal accent */19 the open one in the teal accent */
20- & nav { display: flex; flex-wrap: wrap; gap: var(--s2); }20+ & [data-mr-tabs] { display: flex; flex-wrap: wrap; gap: var(--s2); }
21- & [data-mr-tabs] a {21+ & [data-mr-tabs] button {
22 display: inline-flex; align-items: center; gap: var(--s1);22 display: inline-flex; align-items: center; gap: var(--s1);
23 padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);23 padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);
24 color: var(--ink-dim); text-decoration: none; cursor: pointer;24 color: var(--ink-dim); text-decoration: none; cursor: pointer;
25 }25 }
26 & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }26 & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }
27 & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }27 & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
28- & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }28+ & [data-mr-tabs] button:hover { background: var(--bg-strong); color: var(--ink-hi); }
29- & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }29+ & [data-mr-tabs] button[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
30 & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }30 & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }
31 & [hidden] { display: none; }31 & [hidden] { display: none; }
32- /* Until mr.js drives the panels (data-panels, then [hidden]), only the32+ /* Without scripts both panels remain visible; disclosure controls use data-js. */
33- panel of the URL fragment shows: the tabs work as anchors without
34- scripts, and no panel flashes open before the script runs. */
35- &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }
36 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }33 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
37 & table { width: 100%; border-collapse: collapse; }34 & table { width: 100%; border-collapse: collapse; }
38 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }35 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
M internal/adapters/web/static/css/85-mobile.css +1 -1
@@ -48,6 +48,6 @@
4848 #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }
4949 body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }
5050 #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
51- .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; }
51+ .mr-footer [data-mr-tabs] button { display: inline-flex; align-items: center; min-height: 40px; }
5252 }
5353 }
@@ -48,6 +48,6 @@
48 #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }48 #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }
49 body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }49 body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }
50 #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }50 #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
51- .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; }51+ .mr-footer [data-mr-tabs] button { display: inline-flex; align-items: center; min-height: 40px; }
52 }52 }
53 }53 }
M internal/adapters/web/static/js/ermite.js +15 -0
@@ -134,7 +134,12 @@
134134 dialog(id, focusId) {
135135 const d = document.getElementById(id);
136136 if (!d) return;
137+ const opener = document.activeElement;
137138 ermite.closeDialogs();
139+ d.addEventListener('close', () => {
140+ // close events are queued: a replacement dialog may already be open.
141+ if (!document.querySelector('dialog[open]') && opener?.isConnected) opener.focus();
142+ }, { once: true });
138143 d.showModal();
139144 if (id === 'panel-cmd') window.ermiteCmd?.sync();
140145 if (focusId) document.getElementById(focusId)?.focus();
@@ -672,6 +677,16 @@
672677 const menu = el.closest('details.menu');
673678 if (menu) closeMenu(menu);
674679 });
680+ // Listbox options own the interaction; their anchors are presentation only.
681+ // Keyboard activation also clicks this wrapper (palette, finder and j/k).
682+ addEventListener('click', (e) => {
683+ const target = e.target.closest?.('a[data-href]');
684+ const list = target?.closest?.('[role="listbox"]');
685+ if (!list || !['cmd-list', 'finder-list'].includes(list.id) || e.button) return;
686+ e.preventDefault();
687+ location.assign(target.dataset.href);
688+ });
689+
675690 // A click outside an open menu closes it.
676691 addEventListener('click', (e) => {
677692 for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
@@ -134,7 +134,12 @@
134 dialog(id, focusId) {134 dialog(id, focusId) {
135 const d = document.getElementById(id);135 const d = document.getElementById(id);
136 if (!d) return;136 if (!d) return;
137+ const opener = document.activeElement;
137 ermite.closeDialogs();138 ermite.closeDialogs();
139+ d.addEventListener('close', () => {
140+ // close events are queued: a replacement dialog may already be open.
141+ if (!document.querySelector('dialog[open]') && opener?.isConnected) opener.focus();
142+ }, { once: true });
138 d.showModal();143 d.showModal();
139 if (id === 'panel-cmd') window.ermiteCmd?.sync();144 if (id === 'panel-cmd') window.ermiteCmd?.sync();
140 if (focusId) document.getElementById(focusId)?.focus();145 if (focusId) document.getElementById(focusId)?.focus();
@@ -672,6 +677,16 @@
672 const menu = el.closest('details.menu');677 const menu = el.closest('details.menu');
673 if (menu) closeMenu(menu);678 if (menu) closeMenu(menu);
674 });679 });
680+ // Listbox options own the interaction; their anchors are presentation only.
681+ // Keyboard activation also clicks this wrapper (palette, finder and j/k).
682+ addEventListener('click', (e) => {
683+ const target = e.target.closest?.('a[data-href]');
684+ const list = target?.closest?.('[role="listbox"]');
685+ if (!list || !['cmd-list', 'finder-list'].includes(list.id) || e.button) return;
686+ e.preventDefault();
687+ location.assign(target.dataset.href);
688+ });
689+
675 // A click outside an open menu closes it.690 // A click outside an open menu closes it.
676 addEventListener('click', (e) => {691 addEventListener('click', (e) => {
677 for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;692 for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
M internal/adapters/web/static/js/files.js +4 -3
@@ -635,12 +635,12 @@ window.ermiteFinder = (() => {
635635 return lbl;
636636 }
637637
638- // hitLink is the link of path i of the result: its label, out of the tab order.
638+ // hitLink is the presentation wrapper of path i; the option owns activation.
639639 function hitLink(res, i) {
640640 const path = res.idx.paths[i];
641641 const a = document.createElement('a');
642- a.href = blobHref(path);
643- a.tabIndex = -1;
642+ a.dataset.href = blobHref(path);
643+ a.setAttribute('role', 'presentation');
644644 a.append(label(path, positions(res.idx, i, res.q)));
645645 return a;
646646 }
@@ -684,6 +684,7 @@ window.ermiteFinder = (() => {
684684
685685 // showTree: no query, the directory again, with j/k and Enter on its rows.
686686 function showTree(el) {
687+ status('');
687688 rendered = null;
688689 pendingEnter = null;
689690 $('finder').hidden = true;
@@ -635,12 +635,12 @@ window.ermiteFinder = (() => {
635 return lbl;635 return lbl;
636 }636 }
637 637
638- // hitLink is the link of path i of the result: its label, out of the tab order.638+ // hitLink is the presentation wrapper of path i; the option owns activation.
639 function hitLink(res, i) {639 function hitLink(res, i) {
640 const path = res.idx.paths[i];640 const path = res.idx.paths[i];
641 const a = document.createElement('a');641 const a = document.createElement('a');
642- a.href = blobHref(path);642+ a.dataset.href = blobHref(path);
643- a.tabIndex = -1;643+ a.setAttribute('role', 'presentation');
644 a.append(label(path, positions(res.idx, i, res.q)));644 a.append(label(path, positions(res.idx, i, res.q)));
645 return a;645 return a;
646 }646 }
@@ -684,6 +684,7 @@ window.ermiteFinder = (() => {
684 684
685 // showTree: no query, the directory again, with j/k and Enter on its rows.685 // showTree: no query, the directory again, with j/k and Enter on its rows.
686 function showTree(el) {686 function showTree(el) {
687+ status('');
687 rendered = null;688 rendered = null;
688 pendingEnter = null;689 pendingEnter = null;
689 $('finder').hidden = true;690 $('finder').hidden = true;
M internal/adapters/web/static/js/mr.js +3 -3
@@ -241,7 +241,7 @@ window.ermiteMR = (() => {
241241 reveal(id);
242242 }
243243
244- // Enhance the ordinary anchor navigation only when scripts are available.
244+ // Enhance the server-rendered disclosures when scripts are available.
245245 let disposePanels = () => {};
246246 // The tabs are disclosure buttons (aria-expanded + aria-controls), not a
247247 // tablist: a tablist always has a selected tab, and here every panel can be
@@ -250,7 +250,7 @@ window.ermiteMR = (() => {
250250 disposePanels();
251251 const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');
252252 if (!footer || !nav) return;
253- const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
253+ const tabs = $$('button', nav), panels = $$('[data-mr-panel]', footer);
254254 const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);
255255 let current = -1;
256256 // show applies the state; fromUser also keeps the URL fragment in step.
@@ -522,7 +522,7 @@ window.ermiteMR = (() => {
522522 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
523523 const folded = $('.r-files')?.hasAttribute('data-folded');
524524 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
525- const openPanel = $$('[data-mr-tabs] a').findIndex((t) => t.getAttribute('aria-expanded') === 'true');
525+ const openPanel = $$('[data-mr-tabs] button').findIndex((t) => t.getAttribute('aria-expanded') === 'true');
526526 const active = document.activeElement;
527527 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
528528 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
@@ -241,7 +241,7 @@ window.ermiteMR = (() => {
241 reveal(id);241 reveal(id);
242 }242 }
243 243
244- // Enhance the ordinary anchor navigation only when scripts are available.244+ // Enhance the server-rendered disclosures when scripts are available.
245 let disposePanels = () => {};245 let disposePanels = () => {};
246 // The tabs are disclosure buttons (aria-expanded + aria-controls), not a246 // The tabs are disclosure buttons (aria-expanded + aria-controls), not a
247 // tablist: a tablist always has a selected tab, and here every panel can be247 // tablist: a tablist always has a selected tab, and here every panel can be
@@ -250,7 +250,7 @@ window.ermiteMR = (() => {
250 disposePanels();250 disposePanels();
251 const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');251 const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');
252 if (!footer || !nav) return;252 if (!footer || !nav) return;
253- const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);253+ const tabs = $$('button', nav), panels = $$('[data-mr-panel]', footer);
254 const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);254 const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);
255 let current = -1;255 let current = -1;
256 // show applies the state; fromUser also keeps the URL fragment in step.256 // show applies the state; fromUser also keeps the URL fragment in step.
@@ -522,7 +522,7 @@ window.ermiteMR = (() => {
522 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));522 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
523 const folded = $('.r-files')?.hasAttribute('data-folded');523 const folded = $('.r-files')?.hasAttribute('data-folded');
524 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;524 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
525- const openPanel = $$('[data-mr-tabs] a').findIndex((t) => t.getAttribute('aria-expanded') === 'true');525+ const openPanel = $$('[data-mr-tabs] button').findIndex((t) => t.getAttribute('aria-expanded') === 'true');
526 const active = document.activeElement;526 const active = document.activeElement;
527 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };527 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
528 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);528 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
M internal/adapters/web/tree.templ +1 -1
@@ -28,8 +28,8 @@ templ Tree(l Layout, p TreePage) {
2828 </div>
2929 <p class="tree-keys" data-js aria-hidden="true"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
3030 </div>
31+ <p id="finder-status" class="dim" role="status"></p>
3132 <div id="finder" class="tree-card" hidden>
32- <p id="finder-status" class="dim" aria-live="polite"></p>
3333 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
3434 </div>
3535 <div id="tree-view" class="tree-card">
@@ -28,8 +28,8 @@ templ Tree(l Layout, p TreePage) {
28 </div>28 </div>
29 <p class="tree-keys" data-js aria-hidden="true"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>29 <p class="tree-keys" data-js aria-hidden="true"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
30 </div>30 </div>
31+ <p id="finder-status" class="dim" role="status"></p>
31 <div id="finder" class="tree-card" hidden>32 <div id="finder" class="tree-card" hidden>
32- <p id="finder-status" class="dim" aria-live="polite"></p>
33 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>33 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
34 </div>34 </div>
35 <div id="tree-view" class="tree-card">35 <div id="tree-view" class="tree-card">
M internal/adapters/web/tree_templ.go +1 -1 generated file: collapsed
M internal/app/testdata/web/blob-binary.html +19 -17
@@ -141,18 +141,20 @@
141141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
142142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
143143 </footer>
144-<div class="hint" role="status" aria-live="polite">
145-<span class="hint-nav" :text="document.body.dataset.nav || ''">
144+<div class="hint">
145+<span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
146146 </span>
147+<span role="status" aria-live="polite">
147148 <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
148149 </span>
149150 <span class="hint-flash" :text="document.body.dataset.flash || ''">
150151 </span>
152+</span>
151153 </div>
152154 <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
153155 <div class="panel-head">
154156 <h2>Keyboard shortcuts</h2>
155-<button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
157+<button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()">
156158 <kbd>esc</kbd> close</button>
157159 </div>
158160 <div class="keys-pref">
@@ -286,7 +288,7 @@
286288 <div class="panel-head">
287289 <label for="refs-filter" class="sr">filter branches and tags</label>
288290 <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
289-<button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
291+<button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()">
290292 <kbd>esc</kbd>
291293 </button>
292294 </div>
@@ -321,79 +323,79 @@
321323 <div class="panel-head">
322324 <label for="cmd-q" class="sr">go to</label>
323325 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
324-<button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
326+<button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()">
325327 <kbd>esc</kbd>
326328 </button>
327329 </div>
328330 <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
329331 <li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
330-<a href="/" tabindex="-1">
332+<a role="presentation" data-href="/">
331333 <span class="lbl">home</span>
332334 <span class="k">go</span>
333335 </a>
334336 </li>
335337 <li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
336-<a href="/files" tabindex="-1">
338+<a role="presentation" data-href="/files">
337339 <span class="lbl">summary</span>
338340 <span class="k">section</span>
339341 </a>
340342 </li>
341343 <li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
342-<a href="/files/tree/main" tabindex="-1">
344+<a role="presentation" data-href="/files/tree/main">
343345 <span class="lbl">files</span>
344346 <span class="k">section</span>
345347 </a>
346348 </li>
347349 <li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
348-<a href="/files/commits/main" tabindex="-1">
350+<a role="presentation" data-href="/files/commits/main">
349351 <span class="lbl">commits</span>
350352 <span class="k">section</span>
351353 </a>
352354 </li>
353355 <li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
354-<a href="/files/refs" tabindex="-1">
356+<a role="presentation" data-href="/files/refs">
355357 <span class="lbl">branches &amp; tags</span>
356358 <span class="k">section</span>
357359 </a>
358360 </li>
359361 <li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
360-<a href="/files/merge-requests" tabindex="-1">
362+<a role="presentation" data-href="/files/merge-requests">
361363 <span class="lbl">merge requests</span>
362364 <span class="k">section</span>
363365 </a>
364366 </li>
365367 <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
366-<a href="/files/releases" tabindex="-1">
368+<a role="presentation" data-href="/files/releases">
367369 <span class="lbl">releases</span>
368370 <span class="k">section</span>
369371 </a>
370372 </li>
371373 <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
372-<a href="/files/actions" tabindex="-1">
374+<a role="presentation" data-href="/files/actions">
373375 <span class="lbl">actions</span>
374376 <span class="k">section</span>
375377 </a>
376378 </li>
377379 <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
378-<a href="/files/tree/feature/x" tabindex="-1">
380+<a role="presentation" data-href="/files/tree/feature/x">
379381 <span class="lbl">feature/x</span>
380382 <span class="k">branch</span>
381383 </a>
382384 </li>
383385 <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
384-<a href="/files/tree/main" tabindex="-1">
386+<a role="presentation" data-href="/files/tree/main">
385387 <span class="lbl">main</span>
386388 <span class="k">branch</span>
387389 </a>
388390 </li>
389391 <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
390-<a href="/files/tree/v1.0" tabindex="-1">
392+<a role="presentation" data-href="/files/tree/v1.0">
391393 <span class="lbl">v1.0</span>
392394 <span class="k">tag</span>
393395 </a>
394396 </li>
395397 <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
396-<a href="#" data-fire="keys" tabindex="-1">
398+<a role="presentation" data-fire="keys">
397399 <span class="lbl">turn single-key shortcuts off</span>
398400 <span class="k">action</span>
399401 </a>
@@ -141,18 +141,20 @@
141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
143 </footer>143 </footer>
144-<div class="hint" role="status" aria-live="polite">144+<div class="hint">
145-<span class="hint-nav" :text="document.body.dataset.nav || ''">145+<span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
146 </span>146 </span>
147+<span role="status" aria-live="polite">
147 <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">148 <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
148 </span>149 </span>
149 <span class="hint-flash" :text="document.body.dataset.flash || ''">150 <span class="hint-flash" :text="document.body.dataset.flash || ''">
150 </span>151 </span>
152+</span>
151 </div>153 </div>
152 <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">154 <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
153 <div class="panel-head">155 <div class="panel-head">
154 <h2>Keyboard shortcuts</h2>156 <h2>Keyboard shortcuts</h2>
155-<button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">157+<button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()">
156 <kbd>esc</kbd> close</button>158 <kbd>esc</kbd> close</button>
157 </div>159 </div>
158 <div class="keys-pref">160 <div class="keys-pref">
@@ -286,7 +288,7 @@
286 <div class="panel-head">288 <div class="panel-head">
287 <label for="refs-filter" class="sr">filter branches and tags</label>289 <label for="refs-filter" class="sr">filter branches and tags</label>
288 <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">290 <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
289-<button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">291+<button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()">
290 <kbd>esc</kbd>292 <kbd>esc</kbd>
291 </button>293 </button>
292 </div>294 </div>
@@ -321,79 +323,79 @@
321 <div class="panel-head">323 <div class="panel-head">
322 <label for="cmd-q" class="sr">go to</label>324 <label for="cmd-q" class="sr">go to</label>
323 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">325 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
324-<button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">326+<button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()">
325 <kbd>esc</kbd>327 <kbd>esc</kbd>
326 </button>328 </button>
327 </div>329 </div>
328 <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">330 <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
329 <li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>331 <li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
330-<a href="/" tabindex="-1">332+<a role="presentation" data-href="/">
331 <span class="lbl">home</span>333 <span class="lbl">home</span>
332 <span class="k">go</span>334 <span class="k">go</span>
333 </a>335 </a>
334 </li>336 </li>
335 <li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">337 <li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
336-<a href="/files" tabindex="-1">338+<a role="presentation" data-href="/files">
337 <span class="lbl">summary</span>339 <span class="lbl">summary</span>
338 <span class="k">section</span>340 <span class="k">section</span>
339 </a>341 </a>
340 </li>342 </li>
341 <li id="cmd-o2" role="option" aria-selected="false" data-k="section files">343 <li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
342-<a href="/files/tree/main" tabindex="-1">344+<a role="presentation" data-href="/files/tree/main">
343 <span class="lbl">files</span>345 <span class="lbl">files</span>
344 <span class="k">section</span>346 <span class="k">section</span>
345 </a>347 </a>
346 </li>348 </li>
347 <li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">349 <li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
348-<a href="/files/commits/main" tabindex="-1">350+<a role="presentation" data-href="/files/commits/main">
349 <span class="lbl">commits</span>351 <span class="lbl">commits</span>
350 <span class="k">section</span>352 <span class="k">section</span>
351 </a>353 </a>
352 </li>354 </li>
353 <li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">355 <li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
354-<a href="/files/refs" tabindex="-1">356+<a role="presentation" data-href="/files/refs">
355 <span class="lbl">branches &amp; tags</span>357 <span class="lbl">branches &amp; tags</span>
356 <span class="k">section</span>358 <span class="k">section</span>
357 </a>359 </a>
358 </li>360 </li>
359 <li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">361 <li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
360-<a href="/files/merge-requests" tabindex="-1">362+<a role="presentation" data-href="/files/merge-requests">
361 <span class="lbl">merge requests</span>363 <span class="lbl">merge requests</span>
362 <span class="k">section</span>364 <span class="k">section</span>
363 </a>365 </a>
364 </li>366 </li>
365 <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">367 <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
366-<a href="/files/releases" tabindex="-1">368+<a role="presentation" data-href="/files/releases">
367 <span class="lbl">releases</span>369 <span class="lbl">releases</span>
368 <span class="k">section</span>370 <span class="k">section</span>
369 </a>371 </a>
370 </li>372 </li>
371 <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">373 <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
372-<a href="/files/actions" tabindex="-1">374+<a role="presentation" data-href="/files/actions">
373 <span class="lbl">actions</span>375 <span class="lbl">actions</span>
374 <span class="k">section</span>376 <span class="k">section</span>
375 </a>377 </a>
376 </li>378 </li>
377 <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">379 <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
378-<a href="/files/tree/feature/x" tabindex="-1">380+<a role="presentation" data-href="/files/tree/feature/x">
379 <span class="lbl">feature/x</span>381 <span class="lbl">feature/x</span>
380 <span class="k">branch</span>382 <span class="k">branch</span>
381 </a>383 </a>
382 </li>384 </li>
383 <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">385 <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
384-<a href="/files/tree/main" tabindex="-1">386+<a role="presentation" data-href="/files/tree/main">
385 <span class="lbl">main</span>387 <span class="lbl">main</span>
386 <span class="k">branch</span>388 <span class="k">branch</span>
387 </a>389 </a>
388 </li>390 </li>
389 <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">391 <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
390-<a href="/files/tree/v1.0" tabindex="-1">392+<a role="presentation" data-href="/files/tree/v1.0">
391 <span class="lbl">v1.0</span>393 <span class="lbl">v1.0</span>
392 <span class="k">tag</span>394 <span class="k">tag</span>
393 </a>395 </a>
394 </li>396 </li>
395 <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">397 <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
396-<a href="#" data-fire="keys" tabindex="-1">398+<a role="presentation" data-fire="keys">
397 <span class="lbl">turn single-key shortcuts off</span>399 <span class="lbl">turn single-key shortcuts off</span>
398 <span class="k">action</span>400 <span class="k">action</span>
399 </a>401 </a>
M internal/app/testdata/web/blob-code.html +19 -17
M internal/app/testdata/web/blob-empty.html +19 -17
M internal/app/testdata/web/blob-image.html +19 -17
M internal/app/testdata/web/blob-markdown.html +19 -17
M internal/app/testdata/web/blob-svg.html +19 -17
M internal/app/testdata/web/blob-text.html +19 -17
M internal/app/testdata/web/blob-too-large.html +19 -17
M internal/app/testdata/web/blob-trojan.html +19 -17
M internal/app/testdata/web/commit-big.html +19 -17
M internal/app/testdata/web/commit-binary.html +19 -17
M internal/app/testdata/web/commit-merge.html +19 -17
M internal/app/testdata/web/commit-modify-split.html +19 -17
M internal/app/testdata/web/commit-modify.html +19 -17
M internal/app/testdata/web/commit-rename.html +19 -17
M internal/app/testdata/web/commit-root.html +19 -17
M internal/app/testdata/web/commit-trojan.html +19 -17
M internal/app/testdata/web/commits-page2.html +18 -16
M internal/app/testdata/web/commits-path.html +18 -16
M internal/app/testdata/web/commits-root.html +19 -17
M internal/app/testdata/web/commits-small.html +21 -19
M internal/app/testdata/web/empty.html +9 -7
M internal/app/testdata/web/home.html +11 -9
M internal/app/testdata/web/not-found-page.html +8 -6
M internal/app/testdata/web/not-found.html +8 -6
M internal/app/testdata/web/refs.html +22 -20
M internal/app/testdata/web/summary.html +22 -20
M internal/app/testdata/web/tree-nested.html +21 -19
M internal/app/testdata/web/tree-root.html +21 -19
M internal/app/testdata/web/tree-slash-ref.html +21 -19
M internal/app/testdata/web/tree-subdir.html +21 -19

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close