mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 13:32:53 +00:00
Ensure accessible form controls
This commit is contained in:
1 parent
578abce88f
commit
8bb1772fc6
5 files changed
+105
-8
No files matched your search
@@ -176,14 +176,16 @@
|
||||
</div>
|
||||
<div id="key-prefix" class="mb-2" hidden>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="prefix" placeholder="e.g. beef" pattern="[0-9a-fA-F]*">
|
||||
<input type="text" class="form-control font-monospace" id="prefix" placeholder="e.g. beef" pattern="[0-9a-fA-F]*"
|
||||
aria-label="Desired public key prefix (hex)">
|
||||
<button type="button" class="btn" id="grind">Generate</button>
|
||||
<button type="button" class="btn btn-link" id="grind-stop" hidden>Stop</button>
|
||||
</div>
|
||||
<small class="form-hint">Hex only (0-9, a-f). Each extra character takes ~16× longer to find — long prefixes may take a very long time.</small>
|
||||
</div>
|
||||
<div id="key-paste" class="mb-2" hidden>
|
||||
<textarea class="form-control font-monospace" id="privkey" rows="2" placeholder="128 hex chars (64-byte key) or 64 hex chars (32-byte seed)"></textarea>
|
||||
<textarea class="form-control font-monospace" id="privkey" rows="2" placeholder="128 hex chars (64-byte key) or 64 hex chars (32-byte seed)"
|
||||
aria-label="Existing private key or seed (hex)"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -129,11 +129,14 @@ function addBlock(kind) {
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12"><label class="form-label mb-1">Display name</label>
|
||||
<input class="form-control" name="region_display" value="{{.DisplayName}}" placeholder="Buffalo"></div>
|
||||
<input class="form-control" name="region_display" value="{{.DisplayName}}" placeholder="Buffalo"
|
||||
aria-label="Region display name"></div>
|
||||
<div class="col"><label class="form-label mb-1">Name</label>
|
||||
<input class="form-control font-monospace" name="region_token" value="{{.Token}}" placeholder="buf"></div>
|
||||
<input class="form-control font-monospace" name="region_token" value="{{.Token}}" placeholder="buf"
|
||||
aria-label="Region name (token used in commands)"></div>
|
||||
<div class="col-auto"><label class="form-label mb-1">Level</label>
|
||||
<input class="form-control" type="number" name="region_layer" value="{{.Layer}}" style="width:5rem"></div>
|
||||
<input class="form-control" type="number" name="region_layer" value="{{.Layer}}" style="width:5rem"
|
||||
aria-label="Region level"></div>
|
||||
</div>
|
||||
{{/* Settings/state: the region's own properties, shown as switches (matching
|
||||
the root's flood switch) so each toggle reads clearly with its label,
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
Shown on the public page when one is published.
|
||||
<strong>Do not put gate codes, lock combinations, or other site-access secrets here</strong> — use the internal section below for those.
|
||||
</p>
|
||||
<textarea class="form-control font-monospace" name="doc_public" rows="10" maxlength="20000" placeholder="Hardware, antenna, power/solar, firmware, how to service…">{{.Repeater.DocPublic}}</textarea>
|
||||
<textarea class="form-control font-monospace" name="doc_public" rows="10" maxlength="20000" placeholder="Hardware, antenna, power/solar, firmware, how to service…"
|
||||
aria-label="Public documentation (Markdown)">{{.Repeater.DocPublic}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +30,8 @@
|
||||
Sensitive site-access details — gate codes, lock combinations, landlord/contact info, climbing notes.
|
||||
Only the owner, stewards, and people you've shared with can read this. <strong>Never shown on the public page.</strong>
|
||||
</p>
|
||||
<textarea class="form-control font-monospace" name="doc_internal" rows="8" maxlength="20000" placeholder="Gate code, lock combo, who to call for site access…">{{.Repeater.DocInternal}}</textarea>
|
||||
<textarea class="form-control font-monospace" name="doc_internal" rows="8" maxlength="20000" placeholder="Gate code, lock combo, who to call for site access…"
|
||||
aria-label="Internal notes (Markdown, not public)">{{.Repeater.DocInternal}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
<p>Run this on your repeater — through the <strong>repeater console in the MeshCore companion app</strong>, directly over USB serial, or let MeshTender run it for you during USB setup:</p>
|
||||
{{if .SetPermCommand}}
|
||||
<div class="input-group mb-2">
|
||||
<input type="text" class="form-control font-monospace" id="setperm-cmd" value="{{.SetPermCommand}}" readonly>
|
||||
<input type="text" class="form-control font-monospace" id="setperm-cmd" value="{{.SetPermCommand}}"
|
||||
aria-label="setperm command with MeshTender's public key" readonly>
|
||||
<button type="button" class="btn" data-copy-target="#setperm-cmd">Copy</button>
|
||||
</div>
|
||||
<p class="text-secondary small mb-0">The long value is <strong>MeshTender's</strong> public key — not your repeater's. If you see a guest-access warning after confirming a repeater, this is the fix: run the command above, then confirm again.</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -192,6 +193,94 @@ func TestTemplatesHeadingConventions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// controlRe matches a form control's opening tag.
|
||||
var controlRe = regexp.MustCompile(`(?is)<(input|select|textarea)\b([^>]*)>`)
|
||||
|
||||
// namedRe matches the attributes that give a control an accessible name directly.
|
||||
var namedRe = regexp.MustCompile(`(?i)aria-label\s*=|aria-labelledby\s*=|\btitle\s*=`)
|
||||
|
||||
// skipTypes are input types with no user-visible value to name.
|
||||
var skipTypes = map[string]bool{
|
||||
"hidden": true, "submit": true, "button": true, "image": true, "reset": true,
|
||||
}
|
||||
|
||||
// TestTemplatesFormControlsAreLabeled enforces that every form control has an
|
||||
// accessible name. Without one, a screen reader announces "edit text, blank" and the
|
||||
// user has to guess from surrounding prose — assuming they find it at all.
|
||||
//
|
||||
// A control counts as named by any of:
|
||||
// - aria-label / aria-labelledby / title on the control
|
||||
// - a <label for="..."> anywhere in the template set (labels sometimes live in a
|
||||
// different file from their control, e.g. shared partials)
|
||||
// - being wrapped in its own <label> (Bootstrap's form-check pattern) — this is why
|
||||
// a naive for=/id scan is useless here: it reports 22 false positives on this
|
||||
// codebase.
|
||||
//
|
||||
// Note a placeholder is NOT a label: it vanishes on input and is not reliably
|
||||
// announced. Neither is a nearby heading, nor a <label> that lacks for= and doesn't
|
||||
// wrap its control — that last one looks correct in the markup and is the trap this
|
||||
// test exists to catch (audit A3 found three of them in the region editor).
|
||||
func TestTemplatesFormControlsAreLabeled(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := moduleRoot(t)
|
||||
|
||||
// Pass 1: every for= target across all templates.
|
||||
labelFor := map[string]bool{}
|
||||
forRe := regexp.MustCompile(`(?is)<label[^>]*\bfor="([^"]+)"`)
|
||||
var files []string
|
||||
err := filepath.WalkDir(filepath.Join(root, "internal"), func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".html") {
|
||||
return nil
|
||||
}
|
||||
files = append(files, path)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range forRe.FindAllSubmatch(b, -1) {
|
||||
labelFor[string(m[1])] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk templates: %v", err)
|
||||
}
|
||||
|
||||
idRe := regexp.MustCompile(`(?i)\bid="([^"]+)"`)
|
||||
typeRe := regexp.MustCompile(`(?i)type="([^"]+)"`)
|
||||
for _, path := range files {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
for _, loc := range controlRe.FindAllSubmatchIndex(b, -1) {
|
||||
tag := string(b[loc[2]:loc[3]])
|
||||
attrs := b[loc[4]:loc[5]]
|
||||
if tm := typeRe.FindSubmatch(attrs); tag == "input" && tm != nil &&
|
||||
skipTypes[strings.ToLower(string(tm[1]))] {
|
||||
continue
|
||||
}
|
||||
if namedRe.Match(attrs) {
|
||||
continue
|
||||
}
|
||||
if im := idRe.FindSubmatch(attrs); im != nil && labelFor[string(im[1])] {
|
||||
continue
|
||||
}
|
||||
// Wrapped by its own <label>? Count unclosed <label> tags before it.
|
||||
before := b[:loc[0]]
|
||||
if bytes.Count(before, []byte("<label")) > bytes.Count(before, []byte("</label>")) {
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s: <%s> has no accessible name (add aria-label, or a <label for=> / "+
|
||||
"wrapping <label>): %q", rel, tag, snippet(b, loc[0]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snippet(b []byte, at int) string {
|
||||
end := at + 40
|
||||
if end > len(b) {
|
||||
|
||||
Reference in new issue
Block a user