//go:build browser // The side nav as a reader meets it: a column of links beside the // panel's content where the panel is wide, and a row of links above it // where the panel is narrow, whatever the window's width, since a // settings card on a desktop is as narrow as a phone; links that go // somewhere with no script; and links that look and answer like the // rows of a menu, focus ring included, on a page with no rst-page. package check import ( "context" "fmt" "maps" "math" "slices" "strings" "testing" "time" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/input" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/harness" ) // sideNavExample is the side-nav example's one state (its id and the // routes its links point at) and that state as a document, its assets // flattened beside the page as the sizing mux serves them. func sideNavExample(t *testing.T) (example.State, string) { t.Helper() entry, ok := manifest(t).Entry("side-nav") if !ok || len(entry.States) != 1 { t.Fatal("examples.json has no side-nav entry with one state") } state := entry.States[0] doc, err := example.Document(fiesta.FS(), "side-nav", state.ID) if err != nil { t.Fatal(err) } return state, parityLinks(doc) } // inPanel is doc with its body's markup inside a panel of the given // width, the way a page puts the side nav in a card. func inPanel(t *testing.T, doc, width string) string { t.Helper() if !strings.Contains(doc, "
\n") || !strings.Contains(doc, "\n") { t.Fatal("the side-nav document has no lines to wrap in a panel") } doc = strings.Replace(doc, "\n", ``+"\n"+`arrived
` + `` // TestTheSideNavLinksWorkWithNoScript: with scripts off, each link is // reached with Tab and followed with Enter to the route it names, and // every route is one the state declares, so the reference markup has no // link that goes nowhere and needs no script to switch sections. func TestTheSideNavLinksWorkWithNoScript(t *testing.T) { state, doc := sideNavExample(t) pages := map[string]string{"/": doc} for _, u := range state.SampleURLs { pages[u] = arrivedPage } rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true)) var hrefs []string mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getAttribute("href")))`, &hrefs) if len(hrefs) != 4 { t.Fatalf("the nav holds %d links; the example has four", len(hrefs)) } for _, h := range hrefs { if !slices.Contains(state.SampleURLs, h) { t.Fatalf("the link to %q is not one of the state's sampleURLs %v; a link must go somewhere the app has", h, state.SampleURLs) } } for i, h := range hrefs { mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) for range i + 1 { mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) } var focused string at(t, ctx, `JSON.stringify(document.activeElement.getAttribute("href") || document.activeElement.tagName)`, &focused) if focused != h { t.Errorf("after %d presses of Tab the focus is on %q, want the link to %q", i+1, focused, h) continue } var path, said string mustRun(t, ctx, chromedp.KeyEvent(kb.Enter), chromedp.WaitReady("#arrived", chromedp.ByQuery)) at(t, ctx, `JSON.stringify(location.pathname)`, &path) at(t, ctx, `JSON.stringify(document.getElementById("arrived").textContent)`, &said) if said != "arrived" { t.Fatalf("the route's own script ran (%q); this leg is not the page a reader with no script gets", said) } if path != h { t.Errorf("Enter on the link to %q arrived at %q", h, path) } } } // menuRowsPage is the example beside a dropdown's menu, open, with a // current item and a plain one, in Day's light scheme, so each link's // states are read against a menu row's under the same stylesheets. func menuRowsPage(t *testing.T, doc string) string { t.Helper() const menu = `` doc = strings.Replace(doc, ``, ``, 1) return strings.Replace(doc, "\n", "\n"+menu+"\n", 1) } // TestTheSideNavLinksAreMenuRows: in Day, light, with a mouse, the // current link wears the menu's current row, a hovered link the menu's // hovered row with its top highlight, a pressed one the press tint and // a tapped one the tap tint; every link is at least the target floor // tall; and a link focused from the keyboard shows the 2px ring though // no rst-page is around it. func TestTheSideNavLinksAreMenuRows(t *testing.T) { _, doc := sideNavExample(t) rig := sizingRig(t, false, map[string]string{"/": menuRowsPage(t, doc)}, harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) var hover bool at(t, ctx, `JSON.stringify(matchMedia("(hover: hover)").matches)`, &hover) if !hover { t.Fatal("(hover: hover) is false on this rig; the hover rules cannot be read here") } still(t, ctx) const current, plain = `[rst-side-nav] a[aria-current]`, `[rst-side-nav] a[href="/settings/members"]` face := func(sel string, states ...string) (bg, fg, shadow string) { forceState(t, ctx, sel, states...) bg, fg, shadow = computed(t, ctx, sel, "background-color"), computed(t, ctx, sel, "color"), computed(t, ctx, sel, "box-shadow") forceState(t, ctx, sel) return bg, fg, shadow } for _, c := range []struct { what, link, item string states []string }{ {"current", current, "#menu-current", nil}, {"hovered", plain, "#menu-plain", []string{"hover"}}, {"pressed", plain, "#menu-plain", []string{"hover", "active"}}, {"tapped", plain, "#menu-plain", []string{"active"}}, } { lb, lf, ls := face(c.link, c.states...) mb, mf, ms := face(c.item, c.states...) if lb != mb || ls != ms { t.Errorf("the %s link is %s with box-shadow %q; the %s menu row is %s with %q", c.what, lb, ls, c.what, mb, ms) } if c.what != "tapped" && lf != mf { t.Errorf("the %s link's text is %s; the %s menu row's is %s", c.what, lf, c.what, mf) } } if rest, _, _ := face(plain); rest == computed(t, ctx, "#menu-current", "background-color") { t.Errorf("a link at rest is %s, the current row's wash; only the current link is on it", rest) } if w := computed(t, ctx, current, "font-weight"); w != "600" { t.Errorf("the current link's weight is %s, want 600, as a current menu row's label", w) } var floor struct { Target float64 Heights []float64 } at(t, ctx, `(() => { const p = document.createElement("div"); p.style.blockSize = "var(--rst-target)"; document.body.append(p); const target = p.getBoundingClientRect().height; p.remove(); return JSON.stringify({Target: target, Heights: [...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getBoundingClientRect().height)}); })()`, &floor) if floor.Target < 30 { t.Fatalf("--rst-target reads %.1fpx; the probe is broken, not the links", floor.Target) } for i, h := range floor.Heights { if h < floor.Target-0.5 { t.Errorf("link %d is %.1fpx tall, under the %.1fpx target floor", i+1, h, floor.Target) } } mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) var ring struct { Href, Style, Width string InPage bool } at(t, ctx, `(() => { const a = document.activeElement, s = getComputedStyle(a); return JSON.stringify({Href: a.getAttribute("href") || a.tagName, Style: s.outlineStyle, Width: s.outlineWidth, InPage: !!a.closest("[rst-page], .rst-page")}); })()`, &ring) if ring.Href != "/settings/general" { t.Fatalf("the first Tab focused %q, not the first link", ring.Href) } if ring.InPage { t.Fatal("the focused link is inside an rst-page; this leg is for a side nav outside one") } if ring.Style != "solid" || ring.Width != "2px" { t.Errorf("a link focused from the keyboard has outline %s %s, want solid 2px though no rst-page is around it", ring.Style, ring.Width) } } // ringJS reads the focused element's whole focus ring (its border box // grown by the outline's offset and width, which a negative offset // shrinks) against the nav's scrollport (its padding box), where the // row's overflow clips anything outside. const ringJS = `(() => { const a = document.activeElement, nav = a.closest("[rst-side-nav]"); if (!nav) return JSON.stringify({Href: a.getAttribute("href") || a.tagName}); const s = getComputedStyle(a), ns = getComputedStyle(nav), r = a.getBoundingClientRect(), n = nav.getBoundingClientRect(); const px = (v) => parseFloat(v) || 0, grow = s.outlineStyle === "none" ? 0 : px(s.outlineOffset) + px(s.outlineWidth); return JSON.stringify({Href: a.getAttribute("href"), Style: s.outlineStyle, Grow: grow, Ring: {Left: r.left - grow, Right: r.right + grow, Top: r.top - grow, Bottom: r.bottom + grow}, Port: {Left: n.left + px(ns.borderLeftWidth), Right: n.right - px(ns.borderRightWidth), Top: n.top + px(ns.borderTopWidth), Bottom: n.bottom - px(ns.borderBottomWidth)}}); })()` // TestKeyboardFocusShowsTheWholeRingInTheRow: in a narrow panel whose // row of links is wider than the panel, Tab reaches each link in turn, // links that start out of view included, and Shift+Tab walks back to // the first after the row has scrolled to its end; each focused link // and its whole focus ring are inside the row's visible part, left to // right and right to left; and the ring is drawn inside the link, since // the row's edge cuts anything outside a link that meets it. func TestKeyboardFocusShowsTheWholeRingInTheRow(t *testing.T) { _, doc := sideNavExample(t) long := inPanel(t, longLabels(t, doc), "300px") pages := map[string]string{"/": long, "/rtl": inArabic(t, long), "/page": longLabels(t, doc), "/page-rtl": inArabic(t, longLabels(t, doc))} rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() for _, path := range slices.Sorted(maps.Keys(pages)) { width := int64(1280) if strings.HasPrefix(path, "/page") { width = 320 } g := readSide(t, ctx, rig.Origin, path, width) if g.NavScroll <= g.NavClient { t.Fatalf("%s: the row is %.0fpx in %.0fpx; with nothing out of view this leg proves nothing", path, g.NavScroll, g.NavClient) } var hrefs []string at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getAttribute("href")))`, &hrefs) var hidden int at(t, ctx, `(() => { const n = document.querySelector("[rst-side-nav]").getBoundingClientRect(); return JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].filter(a => { const r = a.getBoundingClientRect(); return r.right > n.right + 0.5 || r.left < n.left - 0.5; }).length); })()`, &hidden) if hidden == 0 { t.Fatalf("%s: every link starts in view; this leg is for links the row has to scroll to", path) } steps := []struct { key string want string }{} for _, h := range hrefs { steps = append(steps, struct{ key, want string }{"Tab", h}) } for i := len(hrefs) - 2; i >= 0; i-- { steps = append(steps, struct{ key, want string }{"Shift+Tab", hrefs[i]}) } for _, s := range steps { if s.key == "Tab" { mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) } else { mustRun(t, ctx, chromedp.KeyEvent(kb.Tab, chromedp.KeyModifiers(input.ModifierShift))) } var f struct { Href, Style string Grow float64 Ring, Port sideBox } at(t, ctx, ringJS, &f) where := fmt.Sprintf("%s at %d, %s to %s", path, width, s.key, s.want) if f.Href != s.want { t.Errorf("%s: the focus is on %q", where, f.Href) continue } if f.Style == "none" { t.Errorf("%s: the focused link draws no outline", where) } if f.Grow > 0 { t.Errorf("%s: the focus ring reaches %.1fpx outside the link; in the row it is drawn inside, or the row's edge cuts it wherever the link meets that edge", where, f.Grow) } if f.Ring.Left < f.Port.Left-0.5 || f.Ring.Right > f.Port.Right+0.5 || f.Ring.Top < f.Port.Top-0.5 || f.Ring.Bottom > f.Port.Bottom+0.5 { t.Errorf("%s: the focus ring spans %.1f to %.1f across and %.1f to %.1f down, outside the row's visible %.1f to %.1f and %.1f to %.1f; the row cuts it off", where, f.Ring.Left, f.Ring.Right, f.Ring.Top, f.Ring.Bottom, f.Port.Left, f.Port.Right, f.Port.Top, f.Port.Bottom) } } } } // flexRows are a side in a bounded flex row: given its width by // flex: 1 1 0 beside a 200px column, in a wide row and a narrow one; // and left to size itself, as a flex item does by its content. const flexRows = `