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
@@ -137,245 +137,314 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
137137 if templ_7745c5c3_Err != nil {
138138 return templ_7745c5c3_Err
139139 }
140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")
140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" aria-describedby=\"")
141141 if templ_7745c5c3_Err != nil {
142142 return templ_7745c5c3_Err
143143 }
144144 var templ_7745c5c3_Var10 string
145- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
145+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("title", p.ErrorField))
146146 if templ_7745c5c3_Err != nil {
147- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 95}
147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 34, Col: 67}
148148 }
149149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
150150 if templ_7745c5c3_Err != nil {
151151 return templ_7745c5c3_Err
152152 }
153- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"> <output data-for=\"issue-title\" data-max=\"")
153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
154+ if templ_7745c5c3_Err != nil {
155+ return templ_7745c5c3_Err
156+ }
157+ if p.ErrorField == "title" {
158+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-invalid=\"true\"")
159+ if templ_7745c5c3_Err != nil {
160+ return templ_7745c5c3_Err
161+ }
162+ }
163+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")
154164 if templ_7745c5c3_Err != nil {
155165 return templ_7745c5c3_Err
156166 }
157167 var templ_7745c5c3_Var11 string
158- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))
168+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
159169 if templ_7745c5c3_Err != nil {
160- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 61}
170+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 42, Col: 19}
161171 }
162172 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
163173 if templ_7745c5c3_Err != nil {
164174 return templ_7745c5c3_Err
165175 }
166- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" for=\"issue-title\">")
176+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"> <output id=\"issue-title-count\" aria-live=\"off\" data-for=\"issue-title\" data-max=\"")
167177 if templ_7745c5c3_Err != nil {
168178 return templ_7745c5c3_Err
169179 }
170180 var templ_7745c5c3_Var12 string
171- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))
181+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))
172182 if templ_7745c5c3_Err != nil {
173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 108}
183+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 100}
174184 }
175- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
185+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
176186 if templ_7745c5c3_Err != nil {
177187 return templ_7745c5c3_Err
178188 }
179- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " / ")
189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" for=\"issue-title\">")
180190 if templ_7745c5c3_Err != nil {
181191 return templ_7745c5c3_Err
182192 }
183193 var templ_7745c5c3_Var13 string
184- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))
194+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))
185195 if templ_7745c5c3_Err != nil {
186- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 131}
196+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 147}
187197 }
188198 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
189199 if templ_7745c5c3_Err != nil {
190200 return templ_7745c5c3_Err
191201 }
192- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</output> ")
202+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " / ")
203+ if templ_7745c5c3_Err != nil {
204+ return templ_7745c5c3_Err
205+ }
206+ var templ_7745c5c3_Var14 string
207+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))
208+ if templ_7745c5c3_Err != nil {
209+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 170}
210+ }
211+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
212+ if templ_7745c5c3_Err != nil {
213+ return templ_7745c5c3_Err
214+ }
215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</output> ")
193216 if templ_7745c5c3_Err != nil {
194217 return templ_7745c5c3_Err
195218 }
196219 if p.ErrorField == "title" {
197- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"f-error\" role=\"alert\">")
220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<p id=\"issue-title-error\" class=\"f-error\" role=\"alert\">")
198221 if templ_7745c5c3_Err != nil {
199222 return templ_7745c5c3_Err
200223 }
201- var templ_7745c5c3_Var14 string
202- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
224+ var templ_7745c5c3_Var15 string
225+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
203226 if templ_7745c5c3_Err != nil {
204- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 35, Col: 45}
227+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 46, Col: 68}
205228 }
206- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
229+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
207230 if templ_7745c5c3_Err != nil {
208231 return templ_7745c5c3_Err
209232 }
210- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
233+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p>")
211234 if templ_7745c5c3_Err != nil {
212235 return templ_7745c5c3_Err
213236 }
214237 }
215- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")
238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" aria-describedby=\"")
216239 if templ_7745c5c3_Err != nil {
217240 return templ_7745c5c3_Err
218241 }
219- var templ_7745c5c3_Var15 string
220- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
242+ var templ_7745c5c3_Var16 string
243+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("body", p.ErrorField))
244+ if templ_7745c5c3_Err != nil {
245+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 51, Col: 66}
246+ }
247+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
221248 if templ_7745c5c3_Err != nil {
222- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 91}
249+ return templ_7745c5c3_Err
223250 }
224- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
251+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"")
225252 if templ_7745c5c3_Err != nil {
226253 return templ_7745c5c3_Err
227254 }
228- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</textarea> <output data-for=\"issue-body\" data-max=\"")
255+ if p.ErrorField == "body" {
256+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " aria-invalid=\"true\"")
257+ if templ_7745c5c3_Err != nil {
258+ return templ_7745c5c3_Err
259+ }
260+ }
261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")
229262 if templ_7745c5c3_Err != nil {
230263 return templ_7745c5c3_Err
231264 }
232- var templ_7745c5c3_Var16 string
233- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))
265+ var templ_7745c5c3_Var17 string
266+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
234267 if templ_7745c5c3_Err != nil {
235- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 59}
268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 58, Col: 12}
236269 }
237- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
270+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
238271 if templ_7745c5c3_Err != nil {
239272 return templ_7745c5c3_Err
240273 }
241- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" for=\"issue-body\">")
274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</textarea> <output id=\"issue-body-count\" aria-live=\"off\" data-for=\"issue-body\" data-max=\"")
242275 if templ_7745c5c3_Err != nil {
243276 return templ_7745c5c3_Err
244277 }
245- var templ_7745c5c3_Var17 string
246- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))
278+ var templ_7745c5c3_Var18 string
279+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))
247280 if templ_7745c5c3_Err != nil {
248- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 104}
281+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 97}
249282 }
250- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
283+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
251284 if templ_7745c5c3_Err != nil {
252285 return templ_7745c5c3_Err
253286 }
254- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " / ")
287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" for=\"issue-body\">")
255288 if templ_7745c5c3_Err != nil {
256289 return templ_7745c5c3_Err
257290 }
258- var templ_7745c5c3_Var18 string
259- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))
291+ var templ_7745c5c3_Var19 string
292+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))
260293 if templ_7745c5c3_Err != nil {
261- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 126}
294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 142}
262295 }
263- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
296+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
264297 if templ_7745c5c3_Err != nil {
265298 return templ_7745c5c3_Err
266299 }
267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</output> ")
300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " / ")
301+ if templ_7745c5c3_Err != nil {
302+ return templ_7745c5c3_Err
303+ }
304+ var templ_7745c5c3_Var20 string
305+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))
306+ if templ_7745c5c3_Err != nil {
307+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 164}
308+ }
309+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
310+ if templ_7745c5c3_Err != nil {
311+ return templ_7745c5c3_Err
312+ }
313+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</output> ")
268314 if templ_7745c5c3_Err != nil {
269315 return templ_7745c5c3_Err
270316 }
271317 if p.ErrorField == "body" {
272- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<p class=\"f-error\" role=\"alert\">")
318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p id=\"issue-body-error\" class=\"f-error\" role=\"alert\">")
273319 if templ_7745c5c3_Err != nil {
274320 return templ_7745c5c3_Err
275321 }
276- var templ_7745c5c3_Var19 string
277- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
322+ var templ_7745c5c3_Var21 string
323+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
278324 if templ_7745c5c3_Err != nil {
279- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 41, Col: 45}
325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 61, Col: 67}
280326 }
281- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
327+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
282328 if templ_7745c5c3_Err != nil {
283329 return templ_7745c5c3_Err
284330 }
285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p>")
331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p>")
286332 if templ_7745c5c3_Err != nil {
287333 return templ_7745c5c3_Err
288334 }
289335 }
290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")
336+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" aria-describedby=\"")
291337 if templ_7745c5c3_Err != nil {
292338 return templ_7745c5c3_Err
293339 }
294- var templ_7745c5c3_Var20 string
295- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
340+ var templ_7745c5c3_Var22 string
341+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("author", p.ErrorField))
342+ if templ_7745c5c3_Err != nil {
343+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 66, Col: 68}
344+ }
345+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
346+ if templ_7745c5c3_Err != nil {
347+ return templ_7745c5c3_Err
348+ }
349+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\"")
350+ if templ_7745c5c3_Err != nil {
351+ return templ_7745c5c3_Err
352+ }
353+ if p.ErrorField == "author" {
354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " aria-invalid=\"true\"")
355+ if templ_7745c5c3_Err != nil {
356+ return templ_7745c5c3_Err
357+ }
358+ }
359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")
296360 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 89}
361+ return templ_7745c5c3_Err
298362 }
299- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
363+ var templ_7745c5c3_Var23 string
364+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
365+ if templ_7745c5c3_Err != nil {
366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 73, Col: 20}
367+ }
368+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
300369 if templ_7745c5c3_Err != nil {
301370 return templ_7745c5c3_Err
302371 }
303- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"> <output data-for=\"issue-author\" data-max=\"")
372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"> <output id=\"issue-author-count\" aria-live=\"off\" data-for=\"issue-author\" data-max=\"")
304373 if templ_7745c5c3_Err != nil {
305374 return templ_7745c5c3_Err
306375 }
307- var templ_7745c5c3_Var21 string
308- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))
376+ var templ_7745c5c3_Var24 string
377+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))
309378 if templ_7745c5c3_Err != nil {
310- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 63}
379+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 103}
311380 }
312- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
381+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
313382 if templ_7745c5c3_Err != nil {
314383 return templ_7745c5c3_Err
315384 }
316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" for=\"issue-author\">")
385+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" for=\"issue-author\">")
317386 if templ_7745c5c3_Err != nil {
318387 return templ_7745c5c3_Err
319388 }
320- var templ_7745c5c3_Var22 string
321- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))
389+ var templ_7745c5c3_Var25 string
390+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))
322391 if templ_7745c5c3_Err != nil {
323- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 112}
392+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 152}
324393 }
325- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
394+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
326395 if templ_7745c5c3_Err != nil {
327396 return templ_7745c5c3_Err
328397 }
329- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " / ")
398+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " / ")
330399 if templ_7745c5c3_Err != nil {
331400 return templ_7745c5c3_Err
332401 }
333- var templ_7745c5c3_Var23 string
334- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))
402+ var templ_7745c5c3_Var26 string
403+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))
335404 if templ_7745c5c3_Err != nil {
336- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 136}
405+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 176}
337406 }
338- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
407+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
339408 if templ_7745c5c3_Err != nil {
340409 return templ_7745c5c3_Err
341410 }
342- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</output> ")
411+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</output> ")
343412 if templ_7745c5c3_Err != nil {
344413 return templ_7745c5c3_Err
345414 }
346415 if p.ErrorField == "author" {
347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"f-error\" role=\"alert\">")
416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<p id=\"issue-author-error\" class=\"f-error\" role=\"alert\">")
348417 if templ_7745c5c3_Err != nil {
349418 return templ_7745c5c3_Err
350419 }
351- var templ_7745c5c3_Var24 string
352- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
420+ var templ_7745c5c3_Var27 string
421+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
353422 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 47, Col: 45}
423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 77, Col: 69}
355424 }
356- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
425+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
357426 if templ_7745c5c3_Err != nil {
358427 return templ_7745c5c3_Err
359428 }
360- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")
429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</p>")
361430 if templ_7745c5c3_Err != nil {
362431 return templ_7745c5c3_Err
363432 }
364433 }
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")
434+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")
366435 if templ_7745c5c3_Err != nil {
367436 return templ_7745c5c3_Err
368437 }
369- var templ_7745c5c3_Var25 templ.SafeURL
370- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))
438+ var templ_7745c5c3_Var28 templ.SafeURL
439+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))
371440 if templ_7745c5c3_Err != nil {
372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 56, Col: 49}
441+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 86, Col: 49}
373442 }
374- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
443+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
375444 if templ_7745c5c3_Err != nil {
376445 return templ_7745c5c3_Err
377446 }
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">Cancel</a></div></form>")
447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Cancel</a></div></form>")
379448 if templ_7745c5c3_Err != nil {
380449 return templ_7745c5c3_Err
381450 }
@@ -405,12 +474,12 @@ func issueFormRail() templ.Component {
405474 }()
406475 }
407476 ctx = templ.InitializeContext(ctx)
408- templ_7745c5c3_Var26 := templ.GetChildren(ctx)
409- if templ_7745c5c3_Var26 == nil {
410- templ_7745c5c3_Var26 = templ.NopComponent
477+ templ_7745c5c3_Var29 := templ.GetChildren(ctx)
478+ if templ_7745c5c3_Var29 == nil {
479+ templ_7745c5c3_Var29 = templ.NopComponent
411480 }
412481 ctx = templ.ClearChildren(ctx)
413- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")
482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")
414483 if templ_7745c5c3_Err != nil {
415484 return templ_7745c5c3_Err
416485 }
@@ -418,4 +487,12 @@ func issueFormRail() templ.Component {
418487 })
419488 }
420489
490+func issueFieldDescription(field, invalid string) string {
491+ ids := "issue-" + field + "-count"
492+ if field == invalid {
493+ ids += " issue-" + field + "-error"
494+ }
495+ return ids
496+}
497+
421498 var _ = templruntime.GeneratedTemplate
@@ -137,245 +137,314 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
137 if templ_7745c5c3_Err != nil {137 if templ_7745c5c3_Err != nil {
138 return templ_7745c5c3_Err138 return templ_7745c5c3_Err
139 }139 }
140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" aria-describedby=\"")
141 if templ_7745c5c3_Err != nil {141 if templ_7745c5c3_Err != nil {
142 return templ_7745c5c3_Err142 return templ_7745c5c3_Err
143 }143 }
144 var templ_7745c5c3_Var10 string144 var templ_7745c5c3_Var10 string
145- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)145+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("title", p.ErrorField))
146 if templ_7745c5c3_Err != nil {146 if templ_7745c5c3_Err != nil {
147- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 95}147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 34, Col: 67}
148 }148 }
149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
150 if templ_7745c5c3_Err != nil {150 if templ_7745c5c3_Err != nil {
151 return templ_7745c5c3_Err151 return templ_7745c5c3_Err
152 }152 }
153- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"> <output data-for=\"issue-title\" data-max=\"")153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
154+ if templ_7745c5c3_Err != nil {
155+ return templ_7745c5c3_Err
156+ }
157+ if p.ErrorField == "title" {
158+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-invalid=\"true\"")
159+ if templ_7745c5c3_Err != nil {
160+ return templ_7745c5c3_Err
161+ }
162+ }
163+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")
154 if templ_7745c5c3_Err != nil {164 if templ_7745c5c3_Err != nil {
155 return templ_7745c5c3_Err165 return templ_7745c5c3_Err
156 }166 }
157 var templ_7745c5c3_Var11 string167 var templ_7745c5c3_Var11 string
158- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))168+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
159 if templ_7745c5c3_Err != nil {169 if templ_7745c5c3_Err != nil {
160- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 61}170+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 42, Col: 19}
161 }171 }
162 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)172 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
163 if templ_7745c5c3_Err != nil {173 if templ_7745c5c3_Err != nil {
164 return templ_7745c5c3_Err174 return templ_7745c5c3_Err
165 }175 }
166- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" for=\"issue-title\">")176+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"> <output id=\"issue-title-count\" aria-live=\"off\" data-for=\"issue-title\" data-max=\"")
167 if templ_7745c5c3_Err != nil {177 if templ_7745c5c3_Err != nil {
168 return templ_7745c5c3_Err178 return templ_7745c5c3_Err
169 }179 }
170 var templ_7745c5c3_Var12 string180 var templ_7745c5c3_Var12 string
171- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))181+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))
172 if templ_7745c5c3_Err != nil {182 if templ_7745c5c3_Err != nil {
173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 108}183+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 100}
174 }184 }
175- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))185+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
176 if templ_7745c5c3_Err != nil {186 if templ_7745c5c3_Err != nil {
177 return templ_7745c5c3_Err187 return templ_7745c5c3_Err
178 }188 }
179- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " / ")189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" for=\"issue-title\">")
180 if templ_7745c5c3_Err != nil {190 if templ_7745c5c3_Err != nil {
181 return templ_7745c5c3_Err191 return templ_7745c5c3_Err
182 }192 }
183 var templ_7745c5c3_Var13 string193 var templ_7745c5c3_Var13 string
184- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))194+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))
185 if templ_7745c5c3_Err != nil {195 if templ_7745c5c3_Err != nil {
186- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 131}196+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 147}
187 }197 }
188 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))198 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
189 if templ_7745c5c3_Err != nil {199 if templ_7745c5c3_Err != nil {
190 return templ_7745c5c3_Err200 return templ_7745c5c3_Err
191 }201 }
192- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</output> ")202+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " / ")
203+ if templ_7745c5c3_Err != nil {
204+ return templ_7745c5c3_Err
205+ }
206+ var templ_7745c5c3_Var14 string
207+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))
208+ if templ_7745c5c3_Err != nil {
209+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 170}
210+ }
211+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
212+ if templ_7745c5c3_Err != nil {
213+ return templ_7745c5c3_Err
214+ }
215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</output> ")
193 if templ_7745c5c3_Err != nil {216 if templ_7745c5c3_Err != nil {
194 return templ_7745c5c3_Err217 return templ_7745c5c3_Err
195 }218 }
196 if p.ErrorField == "title" {219 if p.ErrorField == "title" {
197- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"f-error\" role=\"alert\">")220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<p id=\"issue-title-error\" class=\"f-error\" role=\"alert\">")
198 if templ_7745c5c3_Err != nil {221 if templ_7745c5c3_Err != nil {
199 return templ_7745c5c3_Err222 return templ_7745c5c3_Err
200 }223 }
201- var templ_7745c5c3_Var14 string224+ var templ_7745c5c3_Var15 string
202- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)225+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
203 if templ_7745c5c3_Err != nil {226 if templ_7745c5c3_Err != nil {
204- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 35, Col: 45}227+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 46, Col: 68}
205 }228 }
206- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))229+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
207 if templ_7745c5c3_Err != nil {230 if templ_7745c5c3_Err != nil {
208 return templ_7745c5c3_Err231 return templ_7745c5c3_Err
209 }232 }
210- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")233+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p>")
211 if templ_7745c5c3_Err != nil {234 if templ_7745c5c3_Err != nil {
212 return templ_7745c5c3_Err235 return templ_7745c5c3_Err
213 }236 }
214 }237 }
215- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" aria-describedby=\"")
216 if templ_7745c5c3_Err != nil {239 if templ_7745c5c3_Err != nil {
217 return templ_7745c5c3_Err240 return templ_7745c5c3_Err
218 }241 }
219- var templ_7745c5c3_Var15 string242+ var templ_7745c5c3_Var16 string
220- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)243+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("body", p.ErrorField))
244+ if templ_7745c5c3_Err != nil {
245+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 51, Col: 66}
246+ }
247+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
221 if templ_7745c5c3_Err != nil {248 if templ_7745c5c3_Err != nil {
222- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 91}249+ return templ_7745c5c3_Err
223 }250 }
224- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))251+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"")
225 if templ_7745c5c3_Err != nil {252 if templ_7745c5c3_Err != nil {
226 return templ_7745c5c3_Err253 return templ_7745c5c3_Err
227 }254 }
228- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</textarea> <output data-for=\"issue-body\" data-max=\"")255+ if p.ErrorField == "body" {
256+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " aria-invalid=\"true\"")
257+ if templ_7745c5c3_Err != nil {
258+ return templ_7745c5c3_Err
259+ }
260+ }
261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")
229 if templ_7745c5c3_Err != nil {262 if templ_7745c5c3_Err != nil {
230 return templ_7745c5c3_Err263 return templ_7745c5c3_Err
231 }264 }
232- var templ_7745c5c3_Var16 string265+ var templ_7745c5c3_Var17 string
233- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))266+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
234 if templ_7745c5c3_Err != nil {267 if templ_7745c5c3_Err != nil {
235- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 59}268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 58, Col: 12}
236 }269 }
237- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)270+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
238 if templ_7745c5c3_Err != nil {271 if templ_7745c5c3_Err != nil {
239 return templ_7745c5c3_Err272 return templ_7745c5c3_Err
240 }273 }
241- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" for=\"issue-body\">")274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</textarea> <output id=\"issue-body-count\" aria-live=\"off\" data-for=\"issue-body\" data-max=\"")
242 if templ_7745c5c3_Err != nil {275 if templ_7745c5c3_Err != nil {
243 return templ_7745c5c3_Err276 return templ_7745c5c3_Err
244 }277 }
245- var templ_7745c5c3_Var17 string278+ var templ_7745c5c3_Var18 string
246- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))279+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))
247 if templ_7745c5c3_Err != nil {280 if templ_7745c5c3_Err != nil {
248- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 104}281+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 97}
249 }282 }
250- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))283+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
251 if templ_7745c5c3_Err != nil {284 if templ_7745c5c3_Err != nil {
252 return templ_7745c5c3_Err285 return templ_7745c5c3_Err
253 }286 }
254- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " / ")287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" for=\"issue-body\">")
255 if templ_7745c5c3_Err != nil {288 if templ_7745c5c3_Err != nil {
256 return templ_7745c5c3_Err289 return templ_7745c5c3_Err
257 }290 }
258- var templ_7745c5c3_Var18 string291+ var templ_7745c5c3_Var19 string
259- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))292+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))
260 if templ_7745c5c3_Err != nil {293 if templ_7745c5c3_Err != nil {
261- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 126}294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 142}
262 }295 }
263- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))296+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
264 if templ_7745c5c3_Err != nil {297 if templ_7745c5c3_Err != nil {
265 return templ_7745c5c3_Err298 return templ_7745c5c3_Err
266 }299 }
267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</output> ")300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " / ")
301+ if templ_7745c5c3_Err != nil {
302+ return templ_7745c5c3_Err
303+ }
304+ var templ_7745c5c3_Var20 string
305+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))
306+ if templ_7745c5c3_Err != nil {
307+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 164}
308+ }
309+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
310+ if templ_7745c5c3_Err != nil {
311+ return templ_7745c5c3_Err
312+ }
313+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</output> ")
268 if templ_7745c5c3_Err != nil {314 if templ_7745c5c3_Err != nil {
269 return templ_7745c5c3_Err315 return templ_7745c5c3_Err
270 }316 }
271 if p.ErrorField == "body" {317 if p.ErrorField == "body" {
272- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<p class=\"f-error\" role=\"alert\">")318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p id=\"issue-body-error\" class=\"f-error\" role=\"alert\">")
273 if templ_7745c5c3_Err != nil {319 if templ_7745c5c3_Err != nil {
274 return templ_7745c5c3_Err320 return templ_7745c5c3_Err
275 }321 }
276- var templ_7745c5c3_Var19 string322+ var templ_7745c5c3_Var21 string
277- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)323+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
278 if templ_7745c5c3_Err != nil {324 if templ_7745c5c3_Err != nil {
279- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 41, Col: 45}325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 61, Col: 67}
280 }326 }
281- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))327+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
282 if templ_7745c5c3_Err != nil {328 if templ_7745c5c3_Err != nil {
283 return templ_7745c5c3_Err329 return templ_7745c5c3_Err
284 }330 }
285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p>")331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p>")
286 if templ_7745c5c3_Err != nil {332 if templ_7745c5c3_Err != nil {
287 return templ_7745c5c3_Err333 return templ_7745c5c3_Err
288 }334 }
289 }335 }
290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")336+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" aria-describedby=\"")
291 if templ_7745c5c3_Err != nil {337 if templ_7745c5c3_Err != nil {
292 return templ_7745c5c3_Err338 return templ_7745c5c3_Err
293 }339 }
294- var templ_7745c5c3_Var20 string340+ var templ_7745c5c3_Var22 string
295- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)341+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("author", p.ErrorField))
342+ if templ_7745c5c3_Err != nil {
343+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 66, Col: 68}
344+ }
345+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
346+ if templ_7745c5c3_Err != nil {
347+ return templ_7745c5c3_Err
348+ }
349+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\"")
350+ if templ_7745c5c3_Err != nil {
351+ return templ_7745c5c3_Err
352+ }
353+ if p.ErrorField == "author" {
354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " aria-invalid=\"true\"")
355+ if templ_7745c5c3_Err != nil {
356+ return templ_7745c5c3_Err
357+ }
358+ }
359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")
296 if templ_7745c5c3_Err != nil {360 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 89}361+ return templ_7745c5c3_Err
298 }362 }
299- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)363+ var templ_7745c5c3_Var23 string
364+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
365+ if templ_7745c5c3_Err != nil {
366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 73, Col: 20}
367+ }
368+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
300 if templ_7745c5c3_Err != nil {369 if templ_7745c5c3_Err != nil {
301 return templ_7745c5c3_Err370 return templ_7745c5c3_Err
302 }371 }
303- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"> <output data-for=\"issue-author\" data-max=\"")372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"> <output id=\"issue-author-count\" aria-live=\"off\" data-for=\"issue-author\" data-max=\"")
304 if templ_7745c5c3_Err != nil {373 if templ_7745c5c3_Err != nil {
305 return templ_7745c5c3_Err374 return templ_7745c5c3_Err
306 }375 }
307- var templ_7745c5c3_Var21 string376+ var templ_7745c5c3_Var24 string
308- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))377+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))
309 if templ_7745c5c3_Err != nil {378 if templ_7745c5c3_Err != nil {
310- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 63}379+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 103}
311 }380 }
312- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)381+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
313 if templ_7745c5c3_Err != nil {382 if templ_7745c5c3_Err != nil {
314 return templ_7745c5c3_Err383 return templ_7745c5c3_Err
315 }384 }
316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" for=\"issue-author\">")385+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" for=\"issue-author\">")
317 if templ_7745c5c3_Err != nil {386 if templ_7745c5c3_Err != nil {
318 return templ_7745c5c3_Err387 return templ_7745c5c3_Err
319 }388 }
320- var templ_7745c5c3_Var22 string389+ var templ_7745c5c3_Var25 string
321- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))390+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))
322 if templ_7745c5c3_Err != nil {391 if templ_7745c5c3_Err != nil {
323- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 112}392+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 152}
324 }393 }
325- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))394+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
326 if templ_7745c5c3_Err != nil {395 if templ_7745c5c3_Err != nil {
327 return templ_7745c5c3_Err396 return templ_7745c5c3_Err
328 }397 }
329- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " / ")398+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " / ")
330 if templ_7745c5c3_Err != nil {399 if templ_7745c5c3_Err != nil {
331 return templ_7745c5c3_Err400 return templ_7745c5c3_Err
332 }401 }
333- var templ_7745c5c3_Var23 string402+ var templ_7745c5c3_Var26 string
334- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))403+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))
335 if templ_7745c5c3_Err != nil {404 if templ_7745c5c3_Err != nil {
336- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 136}405+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 176}
337 }406 }
338- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))407+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
339 if templ_7745c5c3_Err != nil {408 if templ_7745c5c3_Err != nil {
340 return templ_7745c5c3_Err409 return templ_7745c5c3_Err
341 }410 }
342- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</output> ")411+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</output> ")
343 if templ_7745c5c3_Err != nil {412 if templ_7745c5c3_Err != nil {
344 return templ_7745c5c3_Err413 return templ_7745c5c3_Err
345 }414 }
346 if p.ErrorField == "author" {415 if p.ErrorField == "author" {
347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"f-error\" role=\"alert\">")416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<p id=\"issue-author-error\" class=\"f-error\" role=\"alert\">")
348 if templ_7745c5c3_Err != nil {417 if templ_7745c5c3_Err != nil {
349 return templ_7745c5c3_Err418 return templ_7745c5c3_Err
350 }419 }
351- var templ_7745c5c3_Var24 string420+ var templ_7745c5c3_Var27 string
352- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)421+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
353 if templ_7745c5c3_Err != nil {422 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 47, Col: 45}423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 77, Col: 69}
355 }424 }
356- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))425+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
357 if templ_7745c5c3_Err != nil {426 if templ_7745c5c3_Err != nil {
358 return templ_7745c5c3_Err427 return templ_7745c5c3_Err
359 }428 }
360- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</p>")
361 if templ_7745c5c3_Err != nil {430 if templ_7745c5c3_Err != nil {
362 return templ_7745c5c3_Err431 return templ_7745c5c3_Err
363 }432 }
364 }433 }
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")434+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")
366 if templ_7745c5c3_Err != nil {435 if templ_7745c5c3_Err != nil {
367 return templ_7745c5c3_Err436 return templ_7745c5c3_Err
368 }437 }
369- var templ_7745c5c3_Var25 templ.SafeURL438+ var templ_7745c5c3_Var28 templ.SafeURL
370- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))439+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))
371 if templ_7745c5c3_Err != nil {440 if templ_7745c5c3_Err != nil {
372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 56, Col: 49}441+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 86, Col: 49}
373 }442 }
374- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))443+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
375 if templ_7745c5c3_Err != nil {444 if templ_7745c5c3_Err != nil {
376 return templ_7745c5c3_Err445 return templ_7745c5c3_Err
377 }446 }
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">Cancel</a></div></form>")447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Cancel</a></div></form>")
379 if templ_7745c5c3_Err != nil {448 if templ_7745c5c3_Err != nil {
380 return templ_7745c5c3_Err449 return templ_7745c5c3_Err
381 }450 }
@@ -405,12 +474,12 @@ func issueFormRail() templ.Component {
405 }()474 }()
406 }475 }
407 ctx = templ.InitializeContext(ctx)476 ctx = templ.InitializeContext(ctx)
408- templ_7745c5c3_Var26 := templ.GetChildren(ctx)477+ templ_7745c5c3_Var29 := templ.GetChildren(ctx)
409- if templ_7745c5c3_Var26 == nil {478+ if templ_7745c5c3_Var29 == nil {
410- templ_7745c5c3_Var26 = templ.NopComponent479+ templ_7745c5c3_Var29 = templ.NopComponent
411 }480 }
412 ctx = templ.ClearChildren(ctx)481 ctx = templ.ClearChildren(ctx)
413- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")
414 if templ_7745c5c3_Err != nil {483 if templ_7745c5c3_Err != nil {
415 return templ_7745c5c3_Err484 return templ_7745c5c3_Err
416 }485 }
@@ -418,4 +487,12 @@ func issueFormRail() templ.Component {
418 })487 })
419 }488 }
420 489
490+func issueFieldDescription(field, invalid string) string {
491+ ids := "issue-" + field + "-count"
492+ if field == invalid {
493+ ids += " issue-" + field + "-error"
494+ }
495+ return ids
496+}
497+
421 var _ = templruntime.GeneratedTemplate498 var _ = templruntime.GeneratedTemplate
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 generated file: collapsed
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