| 1 | //go:build browser |
| 2 | |
| 3 | // The side nav as a reader meets it: a column of links beside the |
| 4 | // panel's content where the panel is wide, and a row of links above it |
| 5 | // where the panel is narrow, whatever the window's width, since a |
| 6 | // settings card on a desktop is as narrow as a phone; links that go |
| 7 | // somewhere with no script; and links that look and answer like the |
| 8 | // rows of a menu, focus ring included, on a page with no rst-page. |
| 9 | package check |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "fmt" |
| 14 | "maps" |
| 15 | "math" |
| 16 | "slices" |
| 17 | "strings" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "github.com/chromedp/cdproto/emulation" |
| 22 | "github.com/chromedp/cdproto/input" |
| 23 | "github.com/chromedp/chromedp" |
| 24 | "github.com/chromedp/chromedp/kb" |
| 25 | |
| 26 | "amadan.net/rastrillo/fiesta" |
| 27 | "amadan.net/rastrillo/fiesta/example" |
| 28 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 29 | ) |
| 30 | |
| 31 | // sideNavExample is the side-nav example's one state (its id and the |
| 32 | // routes its links point at) and that state as a document, its assets |
| 33 | // flattened beside the page as the sizing mux serves them. |
| 34 | func sideNavExample(t *testing.T) (example.State, string) { |
| 35 | t.Helper() |
| 36 | entry, ok := manifest(t).Entry("side-nav") |
| 37 | if !ok || len(entry.States) != 1 { |
| 38 | t.Fatal("examples.json has no side-nav entry with one state") |
| 39 | } |
| 40 | state := entry.States[0] |
| 41 | doc, err := example.Document(fiesta.FS(), "side-nav", state.ID) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | return state, parityLinks(doc) |
| 46 | } |
| 47 | |
| 48 | // inPanel is doc with its body's markup inside a panel of the given |
| 49 | // width, the way a page puts the side nav in a card. |
| 50 | func inPanel(t *testing.T, doc, width string) string { |
| 51 | t.Helper() |
| 52 | if !strings.Contains(doc, "<body>\n") || !strings.Contains(doc, "\n</body>") { |
| 53 | t.Fatal("the side-nav document has no <body> lines to wrap in a panel") |
| 54 | } |
| 55 | doc = strings.Replace(doc, "<body>\n", `<body>`+"\n"+`<div id="panel" style="inline-size: `+width+`">`+"\n", 1) |
| 56 | return strings.Replace(doc, "\n</body>", "\n</div>\n</body>", 1) |
| 57 | } |
| 58 | |
| 59 | // longLabels is doc with each link's label made long, so four of them |
| 60 | // cannot fit a narrow panel side by side. |
| 61 | func longLabels(t *testing.T, doc string) string { |
| 62 | t.Helper() |
| 63 | for _, l := range []string{"General", "Members", "Billing", "Notifications"} { |
| 64 | was := ">" + l + "</a>" |
| 65 | if !strings.Contains(doc, was) { |
| 66 | t.Fatalf("the side-nav document has no link labelled %s", l) |
| 67 | } |
| 68 | doc = strings.Replace(doc, was, ">"+l+" for the whole workspace</a>", 1) |
| 69 | } |
| 70 | return doc |
| 71 | } |
| 72 | |
| 73 | // sideGeometry is what the layout tests read: the panel's content box |
| 74 | // (the body's, for the page with no panel), the nav's box with its |
| 75 | // scroll and client widths and its four border widths, the content's |
| 76 | // box, each link's box and how many lines its label takes, and the |
| 77 | // document's scroll and client widths. |
| 78 | type sideGeometry struct { |
| 79 | Panel, Nav, Content sideBox |
| 80 | Links []sideBox |
| 81 | Lines []int |
| 82 | NavScroll, NavClient float64 |
| 83 | Start, End, Top, Bottom string |
| 84 | DocScroll, DocClient float64 |
| 85 | } |
| 86 | |
| 87 | type sideBox struct{ Left, Right, Top, Bottom float64 } |
| 88 | |
| 89 | const sideGeometryJS = `(() => { |
| 90 | const side = document.querySelector("[rst-side]"), nav = side.querySelector("[rst-side-nav]"), |
| 91 | content = side.querySelector(":scope > :not([rst-side-nav])"), ns = getComputedStyle(nav); |
| 92 | const b = (el) => { const r = el.getBoundingClientRect(); return {Left: r.left, Right: r.right, Top: r.top, Bottom: r.bottom}; }; |
| 93 | const p = side.parentElement, pr = p.getBoundingClientRect(), ps = getComputedStyle(p), px = (v) => parseFloat(v) || 0; |
| 94 | const rtl = getComputedStyle(side).direction === "rtl"; |
| 95 | return JSON.stringify({ |
| 96 | Panel: {Left: pr.left + px(ps.borderLeftWidth) + px(ps.paddingLeft), Right: pr.right - px(ps.borderRightWidth) - px(ps.paddingRight), Top: pr.top, Bottom: pr.bottom}, |
| 97 | Nav: b(nav), Content: b(content), Links: [...nav.querySelectorAll("a")].map(b), |
| 98 | Lines: [...nav.querySelectorAll("a")].map((a) => { const r = document.createRange(); r.selectNodeContents(a); return new Set([...r.getClientRects()].map((q) => Math.round(q.top))).size; }), |
| 99 | NavScroll: nav.scrollWidth, NavClient: nav.clientWidth, |
| 100 | Start: rtl ? ns.borderRightWidth : ns.borderLeftWidth, End: rtl ? ns.borderLeftWidth : ns.borderRightWidth, |
| 101 | Top: ns.borderTopWidth, Bottom: ns.borderBottomWidth, |
| 102 | DocScroll: document.documentElement.scrollWidth, DocClient: document.documentElement.clientWidth}); |
| 103 | })()` |
| 104 | |
| 105 | // readSide opens path at the given viewport width and reads it. |
| 106 | func readSide(t *testing.T, ctx context.Context, origin, path string, width int64) sideGeometry { |
| 107 | t.Helper() |
| 108 | mustRun(t, ctx, chromedp.EmulateViewport(width, 800), chromedp.Navigate(origin+path), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) |
| 109 | settleUntil(t, ctx, `document.readyState === "complete"`) |
| 110 | var g sideGeometry |
| 111 | at(t, ctx, sideGeometryJS, &g) |
| 112 | if len(g.Links) != 4 { |
| 113 | t.Fatalf("%s: the nav holds %d links; the example has four", path, len(g.Links)) |
| 114 | } |
| 115 | return g |
| 116 | } |
| 117 | |
| 118 | // sameEdge is whether a and b are the same edge, to half a pixel. |
| 119 | func sameEdge(a, b float64) bool { return math.Abs(a-b) <= 0.5 } |
| 120 | |
| 121 | // TestTheSideNavIsAColumnBesideItsPanel: in a 640px panel the links |
| 122 | // stack in a column with one shared edge, the content sits beside the |
| 123 | // column, and a 1px hairline at the column's inline end divides them; |
| 124 | // right to left, all of it mirrors. |
| 125 | func TestTheSideNavIsAColumnBesideItsPanel(t *testing.T) { |
| 126 | _, doc := sideNavExample(t) |
| 127 | wide := inPanel(t, doc, "640px") |
| 128 | rig := sizingRig(t, false, map[string]string{"/": wide, "/rtl": inArabic(t, wide)}) |
| 129 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 130 | defer cancel() |
| 131 | for _, path := range []string{"/", "/rtl"} { |
| 132 | g := readSide(t, ctx, rig.Origin, path, 1280) |
| 133 | rtl := path == "/rtl" |
| 134 | // The link's edge at the column's inline start: the left in |
| 135 | // English, the right in Arabic. |
| 136 | start := func(b sideBox) float64 { |
| 137 | if rtl { |
| 138 | return b.Right |
| 139 | } |
| 140 | return b.Left |
| 141 | } |
| 142 | for i, l := range g.Links { |
| 143 | if !sameEdge(start(l), start(g.Links[0])) { |
| 144 | t.Errorf("%s: link %d starts at %.1f, link 1 at %.1f; the links of a column share one edge", path, i+1, start(l), start(g.Links[0])) |
| 145 | } |
| 146 | if i > 0 && l.Top < g.Links[i-1].Bottom-0.5 { |
| 147 | t.Errorf("%s: link %d's top is %.1f, above link %d's bottom %.1f; the links stack", path, i+1, l.Top, i, g.Links[i-1].Bottom) |
| 148 | } |
| 149 | } |
| 150 | if !rtl && g.Content.Left < g.Nav.Right-0.5 { |
| 151 | t.Errorf("%s: the content's left edge is %.1f, left of the nav's right edge %.1f; the content sits beside the nav", path, g.Content.Left, g.Nav.Right) |
| 152 | } |
| 153 | if rtl && g.Content.Right > g.Nav.Left+0.5 { |
| 154 | t.Errorf("%s: the content's right edge is %.1f, right of the nav's left edge %.1f; right to left, the content sits to the nav's left", path, g.Content.Right, g.Nav.Left) |
| 155 | } |
| 156 | if g.End != "1px" || g.Start != "0px" || g.Bottom != "0px" { |
| 157 | t.Errorf("%s: the nav's borders are %s at its inline end, %s at its start and %s below; want one 1px hairline at the inline end", path, g.End, g.Start, g.Bottom) |
| 158 | } |
| 159 | if !sameEdge(g.Nav.Top, g.Content.Top) { |
| 160 | t.Errorf("%s: the nav's top is %.1f and the content's %.1f; side by side, they start on one line", path, g.Nav.Top, g.Content.Top) |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // TestANarrowPanelPutsTheLinksInARow: in a 300px panel on a wide |
| 166 | // screen, and on a 320px phone, the nav spans the panel above the |
| 167 | // content, its links one row, each label on one line, with the |
| 168 | // hairline under the row; with |
| 169 | // labels too long for the width, the row scrolls inside the nav while |
| 170 | // the page itself never scrolls sideways. |
| 171 | func TestANarrowPanelPutsTheLinksInARow(t *testing.T) { |
| 172 | _, doc := sideNavExample(t) |
| 173 | narrow, long := inPanel(t, doc, "300px"), inPanel(t, longLabels(t, doc), "300px") |
| 174 | pages := map[string]string{ |
| 175 | "/panel": narrow, "/panel-rtl": inArabic(t, narrow), |
| 176 | "/panel-long": long, "/panel-long-rtl": inArabic(t, long), |
| 177 | "/page": doc, "/page-rtl": inArabic(t, doc), |
| 178 | "/page-long": longLabels(t, doc), "/page-long-rtl": inArabic(t, longLabels(t, doc)), |
| 179 | } |
| 180 | rig := sizingRig(t, false, pages) |
| 181 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 182 | defer cancel() |
| 183 | for _, path := range slices.Sorted(maps.Keys(pages)) { |
| 184 | width := int64(1280) |
| 185 | if strings.HasPrefix(path, "/page") { |
| 186 | width = 320 |
| 187 | } |
| 188 | where := fmt.Sprintf("%s at %d", path, width) |
| 189 | g := readSide(t, ctx, rig.Origin, path, width) |
| 190 | if !sameEdge(g.Nav.Left, g.Panel.Left) || !sameEdge(g.Nav.Right, g.Panel.Right) { |
| 191 | t.Errorf("%s: the nav spans %.1f to %.1f in a panel of %.1f to %.1f; in a narrow panel it spans the panel", where, g.Nav.Left, g.Nav.Right, g.Panel.Left, g.Panel.Right) |
| 192 | } |
| 193 | if g.Content.Top < g.Nav.Bottom-0.5 { |
| 194 | t.Errorf("%s: the content's top is %.1f, above the nav's bottom %.1f; in a narrow panel the nav sits above the content", where, g.Content.Top, g.Nav.Bottom) |
| 195 | } |
| 196 | for i, l := range g.Links { |
| 197 | if !sameEdge(l.Top, g.Links[0].Top) { |
| 198 | t.Errorf("%s: link %d's top is %.1f, link 1's %.1f; in a narrow panel the links are one row", where, i+1, l.Top, g.Links[0].Top) |
| 199 | } |
| 200 | if g.Lines[i] != 1 { |
| 201 | t.Errorf("%s: link %d's label takes %d lines; in the row a label stays on one line and the row scrolls", where, i+1, g.Lines[i]) |
| 202 | } |
| 203 | } |
| 204 | if g.Bottom != "1px" || g.End != "0px" || g.Start != "0px" { |
| 205 | t.Errorf("%s: the nav's borders are %s below, %s at its inline end and %s at its start; want the hairline under the nav and none beside it", where, g.Bottom, g.End, g.Start) |
| 206 | } |
| 207 | if strings.Contains(path, "-long") && g.NavScroll <= g.NavClient { |
| 208 | t.Errorf("%s: the nav's row is %.0fpx wide in %.0fpx; four long labels must overflow it, or this leg proves nothing about scrolling", where, g.NavScroll, g.NavClient) |
| 209 | } |
| 210 | if g.DocScroll > g.DocClient { |
| 211 | t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; the row scrolls inside the nav, never the page", where, g.DocScroll, g.DocClient) |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // arrivedPage is what each of the example's routes answers: a page |
| 217 | // that a drive can tell it arrived at, and whose own script would |
| 218 | // rewrite it, so the drive can also tell that scripts were off. |
| 219 | const arrivedPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>arrived</title></head><body><p id="arrived">arrived</p>` + |
| 220 | `<script>document.getElementById("arrived").textContent = "scripted";</script></body></html>` |
| 221 | |
| 222 | // TestTheSideNavLinksWorkWithNoScript: with scripts off, each link is |
| 223 | // reached with Tab and followed with Enter to the route it names, and |
| 224 | // every route is one the state declares, so the reference markup has no |
| 225 | // link that goes nowhere and needs no script to switch sections. |
| 226 | func TestTheSideNavLinksWorkWithNoScript(t *testing.T) { |
| 227 | state, doc := sideNavExample(t) |
| 228 | pages := map[string]string{"/": doc} |
| 229 | for _, u := range state.SampleURLs { |
| 230 | pages[u] = arrivedPage |
| 231 | } |
| 232 | rig := sizingRig(t, false, pages) |
| 233 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 234 | defer cancel() |
| 235 | mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true)) |
| 236 | var hrefs []string |
| 237 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) |
| 238 | at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getAttribute("href")))`, &hrefs) |
| 239 | if len(hrefs) != 4 { |
| 240 | t.Fatalf("the nav holds %d links; the example has four", len(hrefs)) |
| 241 | } |
| 242 | for _, h := range hrefs { |
| 243 | if !slices.Contains(state.SampleURLs, h) { |
| 244 | 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) |
| 245 | } |
| 246 | } |
| 247 | for i, h := range hrefs { |
| 248 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) |
| 249 | for range i + 1 { |
| 250 | mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) |
| 251 | } |
| 252 | var focused string |
| 253 | at(t, ctx, `JSON.stringify(document.activeElement.getAttribute("href") || document.activeElement.tagName)`, &focused) |
| 254 | if focused != h { |
| 255 | t.Errorf("after %d presses of Tab the focus is on %q, want the link to %q", i+1, focused, h) |
| 256 | continue |
| 257 | } |
| 258 | var path, said string |
| 259 | mustRun(t, ctx, chromedp.KeyEvent(kb.Enter), chromedp.WaitReady("#arrived", chromedp.ByQuery)) |
| 260 | at(t, ctx, `JSON.stringify(location.pathname)`, &path) |
| 261 | at(t, ctx, `JSON.stringify(document.getElementById("arrived").textContent)`, &said) |
| 262 | if said != "arrived" { |
| 263 | t.Fatalf("the route's own script ran (%q); this leg is not the page a reader with no script gets", said) |
| 264 | } |
| 265 | if path != h { |
| 266 | t.Errorf("Enter on the link to %q arrived at %q", h, path) |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // menuRowsPage is the example beside a dropdown's menu, open, with a |
| 272 | // current item and a plain one, in Day's light scheme, so each link's |
| 273 | // states are read against a menu row's under the same stylesheets. |
| 274 | func menuRowsPage(t *testing.T, doc string) string { |
| 275 | t.Helper() |
| 276 | const menu = `<details rst-dropdown open><summary>Menu</summary><div rst-dropdown-menu>` + |
| 277 | `<a id="menu-current" href="#one" aria-current="page">One</a><a id="menu-plain" href="#two">Two</a></div></details>` |
| 278 | doc = strings.Replace(doc, `<html lang="en" dir="ltr">`, `<html lang="en" dir="ltr" data-theme="light">`, 1) |
| 279 | return strings.Replace(doc, "\n</body>", "\n"+menu+"\n</body>", 1) |
| 280 | } |
| 281 | |
| 282 | // TestTheSideNavLinksAreMenuRows: in Day, light, with a mouse, the |
| 283 | // current link wears the menu's current row, a hovered link the menu's |
| 284 | // hovered row with its top highlight, a pressed one the press tint and |
| 285 | // a tapped one the tap tint; every link is at least the target floor |
| 286 | // tall; and a link focused from the keyboard shows the 2px ring though |
| 287 | // no rst-page is around it. |
| 288 | func TestTheSideNavLinksAreMenuRows(t *testing.T) { |
| 289 | _, doc := sideNavExample(t) |
| 290 | rig := sizingRig(t, false, map[string]string{"/": menuRowsPage(t, doc)}, harness.WithHover()) |
| 291 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 292 | defer cancel() |
| 293 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("[rst-side-nav]", chromedp.ByQuery)) |
| 294 | var hover bool |
| 295 | at(t, ctx, `JSON.stringify(matchMedia("(hover: hover)").matches)`, &hover) |
| 296 | if !hover { |
| 297 | t.Fatal("(hover: hover) is false on this rig; the hover rules cannot be read here") |
| 298 | } |
| 299 | still(t, ctx) |
| 300 | const current, plain = `[rst-side-nav] a[aria-current]`, `[rst-side-nav] a[href="/settings/members"]` |
| 301 | face := func(sel string, states ...string) (bg, fg, shadow string) { |
| 302 | forceState(t, ctx, sel, states...) |
| 303 | bg, fg, shadow = computed(t, ctx, sel, "background-color"), computed(t, ctx, sel, "color"), computed(t, ctx, sel, "box-shadow") |
| 304 | forceState(t, ctx, sel) |
| 305 | return bg, fg, shadow |
| 306 | } |
| 307 | for _, c := range []struct { |
| 308 | what, link, item string |
| 309 | states []string |
| 310 | }{ |
| 311 | {"current", current, "#menu-current", nil}, |
| 312 | {"hovered", plain, "#menu-plain", []string{"hover"}}, |
| 313 | {"pressed", plain, "#menu-plain", []string{"hover", "active"}}, |
| 314 | {"tapped", plain, "#menu-plain", []string{"active"}}, |
| 315 | } { |
| 316 | lb, lf, ls := face(c.link, c.states...) |
| 317 | mb, mf, ms := face(c.item, c.states...) |
| 318 | if lb != mb || ls != ms { |
| 319 | 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) |
| 320 | } |
| 321 | if c.what != "tapped" && lf != mf { |
| 322 | t.Errorf("the %s link's text is %s; the %s menu row's is %s", c.what, lf, c.what, mf) |
| 323 | } |
| 324 | } |
| 325 | if rest, _, _ := face(plain); rest == computed(t, ctx, "#menu-current", "background-color") { |
| 326 | t.Errorf("a link at rest is %s, the current row's wash; only the current link is on it", rest) |
| 327 | } |
| 328 | if w := computed(t, ctx, current, "font-weight"); w != "600" { |
| 329 | t.Errorf("the current link's weight is %s, want 600, as a current menu row's label", w) |
| 330 | } |
| 331 | var floor struct { |
| 332 | Target float64 |
| 333 | Heights []float64 |
| 334 | } |
| 335 | at(t, ctx, `(() => { const p = document.createElement("div"); p.style.blockSize = "var(--rst-target)"; document.body.append(p); |
| 336 | const target = p.getBoundingClientRect().height; p.remove(); |
| 337 | return JSON.stringify({Target: target, Heights: [...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getBoundingClientRect().height)}); })()`, &floor) |
| 338 | if floor.Target < 30 { |
| 339 | t.Fatalf("--rst-target reads %.1fpx; the probe is broken, not the links", floor.Target) |
| 340 | } |
| 341 | for i, h := range floor.Heights { |
| 342 | if h < floor.Target-0.5 { |
| 343 | t.Errorf("link %d is %.1fpx tall, under the %.1fpx target floor", i+1, h, floor.Target) |
| 344 | } |
| 345 | } |
| 346 | mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) |
| 347 | var ring struct { |
| 348 | Href, Style, Width string |
| 349 | InPage bool |
| 350 | } |
| 351 | at(t, ctx, `(() => { const a = document.activeElement, s = getComputedStyle(a); |
| 352 | return JSON.stringify({Href: a.getAttribute("href") || a.tagName, Style: s.outlineStyle, Width: s.outlineWidth, InPage: !!a.closest("[rst-page], .rst-page")}); })()`, &ring) |
| 353 | if ring.Href != "/settings/general" { |
| 354 | t.Fatalf("the first Tab focused %q, not the first link", ring.Href) |
| 355 | } |
| 356 | if ring.InPage { |
| 357 | t.Fatal("the focused link is inside an rst-page; this leg is for a side nav outside one") |
| 358 | } |
| 359 | if ring.Style != "solid" || ring.Width != "2px" { |
| 360 | 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) |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | // ringJS reads the focused element's whole focus ring (its border box |
| 365 | // grown by the outline's offset and width, which a negative offset |
| 366 | // shrinks) against the nav's scrollport (its padding box), where the |
| 367 | // row's overflow clips anything outside. |
| 368 | const ringJS = `(() => { |
| 369 | const a = document.activeElement, nav = a.closest("[rst-side-nav]"); |
| 370 | if (!nav) return JSON.stringify({Href: a.getAttribute("href") || a.tagName}); |
| 371 | const s = getComputedStyle(a), ns = getComputedStyle(nav), r = a.getBoundingClientRect(), n = nav.getBoundingClientRect(); |
| 372 | const px = (v) => parseFloat(v) || 0, grow = s.outlineStyle === "none" ? 0 : px(s.outlineOffset) + px(s.outlineWidth); |
| 373 | return JSON.stringify({Href: a.getAttribute("href"), Style: s.outlineStyle, Grow: grow, |
| 374 | Ring: {Left: r.left - grow, Right: r.right + grow, Top: r.top - grow, Bottom: r.bottom + grow}, |
| 375 | 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)}}); |
| 376 | })()` |
| 377 | |
| 378 | // TestKeyboardFocusShowsTheWholeRingInTheRow: in a narrow panel whose |
| 379 | // row of links is wider than the panel, Tab reaches each link in turn, |
| 380 | // links that start out of view included, and Shift+Tab walks back to |
| 381 | // the first after the row has scrolled to its end; each focused link |
| 382 | // and its whole focus ring are inside the row's visible part, left to |
| 383 | // right and right to left; and the ring is drawn inside the link, since |
| 384 | // the row's edge cuts anything outside a link that meets it. |
| 385 | func TestKeyboardFocusShowsTheWholeRingInTheRow(t *testing.T) { |
| 386 | _, doc := sideNavExample(t) |
| 387 | long := inPanel(t, longLabels(t, doc), "300px") |
| 388 | pages := map[string]string{"/": long, "/rtl": inArabic(t, long), "/page": longLabels(t, doc), "/page-rtl": inArabic(t, longLabels(t, doc))} |
| 389 | rig := sizingRig(t, false, pages) |
| 390 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 391 | defer cancel() |
| 392 | for _, path := range slices.Sorted(maps.Keys(pages)) { |
| 393 | width := int64(1280) |
| 394 | if strings.HasPrefix(path, "/page") { |
| 395 | width = 320 |
| 396 | } |
| 397 | g := readSide(t, ctx, rig.Origin, path, width) |
| 398 | if g.NavScroll <= g.NavClient { |
| 399 | t.Fatalf("%s: the row is %.0fpx in %.0fpx; with nothing out of view this leg proves nothing", path, g.NavScroll, g.NavClient) |
| 400 | } |
| 401 | var hrefs []string |
| 402 | at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-side-nav] a")].map(a => a.getAttribute("href")))`, &hrefs) |
| 403 | var hidden int |
| 404 | at(t, ctx, `(() => { const n = document.querySelector("[rst-side-nav]").getBoundingClientRect(); |
| 405 | 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) |
| 406 | if hidden == 0 { |
| 407 | t.Fatalf("%s: every link starts in view; this leg is for links the row has to scroll to", path) |
| 408 | } |
| 409 | steps := []struct { |
| 410 | key string |
| 411 | want string |
| 412 | }{} |
| 413 | for _, h := range hrefs { |
| 414 | steps = append(steps, struct{ key, want string }{"Tab", h}) |
| 415 | } |
| 416 | for i := len(hrefs) - 2; i >= 0; i-- { |
| 417 | steps = append(steps, struct{ key, want string }{"Shift+Tab", hrefs[i]}) |
| 418 | } |
| 419 | for _, s := range steps { |
| 420 | if s.key == "Tab" { |
| 421 | mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) |
| 422 | } else { |
| 423 | mustRun(t, ctx, chromedp.KeyEvent(kb.Tab, chromedp.KeyModifiers(input.ModifierShift))) |
| 424 | } |
| 425 | var f struct { |
| 426 | Href, Style string |
| 427 | Grow float64 |
| 428 | Ring, Port sideBox |
| 429 | } |
| 430 | at(t, ctx, ringJS, &f) |
| 431 | where := fmt.Sprintf("%s at %d, %s to %s", path, width, s.key, s.want) |
| 432 | if f.Href != s.want { |
| 433 | t.Errorf("%s: the focus is on %q", where, f.Href) |
| 434 | continue |
| 435 | } |
| 436 | if f.Style == "none" { |
| 437 | t.Errorf("%s: the focused link draws no outline", where) |
| 438 | } |
| 439 | if f.Grow > 0 { |
| 440 | 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) |
| 441 | } |
| 442 | 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 { |
| 443 | 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", |
| 444 | 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) |
| 445 | } |
| 446 | } |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | // flexRows are a side in a bounded flex row: given its width by |
| 451 | // flex: 1 1 0 beside a 200px column, in a wide row and a narrow one; |
| 452 | // and left to size itself, as a flex item does by its content. |
| 453 | const flexRows = `<div id="wide" style="display: flex; inline-size: 1000px"><div style="flex: none; inline-size: 200px">Aside</div>%[1]s</div>` + |
| 454 | `<div id="narrow" style="display: flex; inline-size: 500px"><div style="flex: none; inline-size: 200px">Aside</div>%[1]s</div>` + |
| 455 | `<div id="unsized" style="display: flex; inline-size: 1000px">%[2]s</div>` |
| 456 | |
| 457 | // TestASideInAFlexRowIsGivenItsWidth: the side answers to its own |
| 458 | // width, so it cannot take that width from its content; a flex item |
| 459 | // sized by its content is zero wide. Given flex: 1 1 0 in a bounded |
| 460 | // row it fills what the row leaves, a column of links in a wide row |
| 461 | // and a row of links in a narrow one. Left to size itself it |
| 462 | // collapses, which is why a page must give it its width; if a browser |
| 463 | // ever sizes it some other way, this says so. |
| 464 | func TestASideInAFlexRowIsGivenItsWidth(t *testing.T) { |
| 465 | _, doc := sideNavExample(t) |
| 466 | side, err := example.Element(doc, "rst-side") |
| 467 | if err != nil { |
| 468 | t.Fatal(err) |
| 469 | } |
| 470 | given := strings.Replace(side, "<div rst-side>", `<div rst-side style="flex: 1 1 0">`, 1) |
| 471 | if given == side { |
| 472 | t.Fatal("the example's side is not written <div rst-side>") |
| 473 | } |
| 474 | page := strings.Replace(doc, side, fmt.Sprintf(flexRows, given, side), 1) |
| 475 | rig := sizingRig(t, false, map[string]string{"/": page}) |
| 476 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 477 | defer cancel() |
| 478 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#unsized", chromedp.ByQuery)) |
| 479 | var got map[string]struct { |
| 480 | Width float64 |
| 481 | Tops int |
| 482 | Flow string |
| 483 | Content float64 |
| 484 | } |
| 485 | at(t, ctx, `(() => { const out = {}; |
| 486 | for (const id of ["wide", "narrow", "unsized"]) { |
| 487 | const side = document.querySelector("#" + id + " [rst-side]"), nav = side.querySelector("[rst-side-nav]"); |
| 488 | out[id] = {Width: side.getBoundingClientRect().width, Tops: new Set([...nav.querySelectorAll("a")].map(a => Math.round(a.getBoundingClientRect().top))).size, |
| 489 | Flow: getComputedStyle(nav).gridAutoFlow, Content: side.querySelector(":scope > :not([rst-side-nav])").getBoundingClientRect().width}; |
| 490 | } |
| 491 | return JSON.stringify(out); })()`, &got) |
| 492 | if w := got["wide"]; !sameEdge(w.Width, 800) || w.Tops != 4 || w.Content <= 0 { |
| 493 | t.Errorf("in a 1000px row beside a 200px column, flex: 1 1 0: the side is %.1fpx wide, its links on %d lines, its content %.1fpx; want 800px, a column of four, and room for the content", w.Width, w.Tops, w.Content) |
| 494 | } |
| 495 | if n := got["narrow"]; !sameEdge(n.Width, 300) || n.Tops != 1 || n.Flow != "column" { |
| 496 | t.Errorf("in a 500px row beside a 200px column, flex: 1 1 0: the side is %.1fpx wide, its links on %d lines, flowing %s; want 300px and one row", n.Width, n.Tops, n.Flow) |
| 497 | } |
| 498 | if u := got["unsized"]; u.Width > 1 { |
| 499 | t.Errorf("left to size itself in a flex row, the side is %.1fpx wide; it was zero wide, and pages are told to give it its width: change that advice if this is now true", u.Width) |
| 500 | } |
| 501 | } |
| 502 | |