| 1 | //go:build browser |
| 2 | |
| 3 | // A dropdown's panel under its summary, with and without anchor |
| 4 | // positioning and scripts, from rastrillo's ui/dropdown_browser_test.go |
| 5 | // at the seed commit. The partial is read through fixture; the gallery |
| 6 | // sample is the example state the manifest's idiom table names for |
| 7 | // dropdown. |
| 8 | package check |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "fmt" |
| 13 | "math" |
| 14 | "net/http" |
| 15 | "slices" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | cdppage "github.com/chromedp/cdproto/page" |
| 21 | "github.com/chromedp/chromedp" |
| 22 | |
| 23 | "amadan.net/rastrillo/fiesta" |
| 24 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 25 | ) |
| 26 | |
| 27 | // withoutAnchorSupport is what anchor.js's feature test sees in a |
| 28 | // browser without anchor positioning: CSS.supports says no to it, run |
| 29 | // before any script on the page. |
| 30 | const withoutAnchorSupport = `(() => { const s = CSS.supports.bind(CSS); CSS.supports = (...a) => !/position-area|position-try/.test(a.join(" ")) && s(...a); })()` |
| 31 | |
| 32 | // tokensWithoutAnchorPositioning is tokens.css as an engine without |
| 33 | // anchor positioning applies it: the @supports block that places a |
| 34 | // panel by its anchor has a condition no engine meets. The rewrite is |
| 35 | // checked with the declaration reader, not by the text changing: the |
| 36 | // block's declarations sit under the real condition before and under |
| 37 | // no position-area condition but the false one after, so a block |
| 38 | // spelt another way cannot stay in force and turn the no-anchor legs |
| 39 | // into anchor ones. |
| 40 | func tokensWithoutAnchorPositioning(t testing.TB) string { |
| 41 | t.Helper() |
| 42 | const real, never = "@supports (position-area: block-end) and (position-try-fallbacks: flip-block)", "@supports (position-area: rastrillo-no-such-value)" |
| 43 | css := string(fiesta.TokensCSS()) |
| 44 | under := func(css string, keep func(string) bool) int { |
| 45 | decls, err := declarationsOf("tokens.css", css) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | n := 0 |
| 50 | for _, d := range decls { |
| 51 | if slices.ContainsFunc(strings.Split(d.Context, " > "), keep) { |
| 52 | n++ |
| 53 | } |
| 54 | } |
| 55 | return n |
| 56 | } |
| 57 | if under(css, func(c string) bool { return c == real }) == 0 { |
| 58 | t.Fatal("tokens.css has no anchor-positioning block to take away; the no-anchor legs would test nothing") |
| 59 | } |
| 60 | out := strings.ReplaceAll(css, real, never) |
| 61 | if n := under(out, func(c string) bool { return strings.Contains(c, "position-area") && c != never }); n != 0 { |
| 62 | t.Fatalf("tokens.css without anchor positioning still has %d declarations under a position-area condition", n) |
| 63 | } |
| 64 | return out |
| 65 | } |
| 66 | |
| 67 | // TestADropdownOpensUnderItsOwnSummary: the panel hangs from the |
| 68 | // summary, wherever the <details> is put. It hung from the <details>, |
| 69 | // which in a block or a stretching flex column is as wide as its |
| 70 | // container, so at 1280 the gallery's Filter opened its panel at the |
| 71 | // far trailing side of the frame, nowhere near the word just pressed. |
| 72 | // The same bug as the row menu's, without the row menu's way out: a |
| 73 | // dropdown stays where the page puts it, at the leading edge of a block |
| 74 | // as often as at the trailing end of a bar. |
| 75 | // |
| 76 | // Both partial spellings are read (the dropdown partial's summary is a |
| 77 | // button, the gallery sample's a bare summary), each in a block, in a |
| 78 | // stretching flex column, and pushed to the trailing edge. At the |
| 79 | // leading edge there is no room for the panel to open toward the |
| 80 | // leading side, so it opens from the summary's leading edge; at the |
| 81 | // trailing edge it opens from the summary's trailing edge. With and |
| 82 | // without anchor positioning, at 1280 with a mouse and 390 with touch, |
| 83 | // in both directions. An engine without it is flipped by anchor.js: the |
| 84 | // no-anchor legs take the feature from the script's own test as well as |
| 85 | // from the stylesheet. The last leg takes it from the stylesheet alone, |
| 86 | // so the panel is placed the no-anchor way while the browser still |
| 87 | // reports the feature, and anchor.js must do nothing: the dropdown keeps |
| 88 | // its container's width and the panel its far edge, the geometry of a |
| 89 | // browser with neither (TestADropdownWithoutAnchorsOrScriptsStaysOnScreen). |
| 90 | func TestADropdownOpensUnderItsOwnSummary(t *testing.T) { |
| 91 | partial := fixture(t, "dropdown_browser_test.go", "TestADropdownOpensUnderItsOwnSummary", "partial") |
| 92 | sample := dropdownSample(t) |
| 93 | box := func(style, menu string) string { |
| 94 | return `<style>#box { padding: 1rem; } ` + style + `</style><div id="box">` + menu + `</div>` |
| 95 | } |
| 96 | pages := map[string]string{} |
| 97 | for name, menu := range map[string]string{"partial": partial, "sample": sample} { |
| 98 | pages["/"+name+"/block"] = sizingDoc("a dropdown in a block", box("", menu)) |
| 99 | pages["/"+name+"/stretch"] = sizingDoc("a dropdown in a stretching column", box("#box { display: flex; flex-direction: column; }", menu)) |
| 100 | pages["/"+name+"/end"] = sizingDoc("a dropdown at the trailing edge", box("#box > [rst-dropdown] { margin-inline-start: auto; }", menu)) |
| 101 | } |
| 102 | for path, html := range pages { |
| 103 | pages["/rtl"+path] = strings.Replace(html, `dir="ltr"`, `dir="rtl"`, 1) |
| 104 | } |
| 105 | type leg struct { |
| 106 | name string |
| 107 | w int64 |
| 108 | coarse bool |
| 109 | anchor bool |
| 110 | } |
| 111 | for _, l := range []leg{ |
| 112 | {"1280 mouse", 1280, false, true}, |
| 113 | {"390 touch", 390, true, true}, |
| 114 | {"1280 mouse without anchor positioning", 1280, false, false}, |
| 115 | {"390 touch without anchor positioning", 390, true, false}, |
| 116 | {"1280 mouse, anchor positioning reported but not applied", 1280, false, false}, |
| 117 | } { |
| 118 | t.Run(l.name, func(t *testing.T) { |
| 119 | rig := sizingRig(t, l.coarse, pages) |
| 120 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 121 | defer cancel() |
| 122 | reported := strings.Contains(l.name, "reported") |
| 123 | if !l.anchor && !reported { |
| 124 | mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| 125 | _, err := cdppage.AddScriptToEvaluateOnNewDocument(withoutAnchorSupport).Do(ctx) |
| 126 | return err |
| 127 | })) |
| 128 | } |
| 129 | for _, dir := range []string{"", "/rtl"} { |
| 130 | for _, spelling := range []string{"partial", "sample"} { |
| 131 | for _, where := range []string{"block", "stretch", "end"} { |
| 132 | path := dir + "/" + spelling + "/" + where |
| 133 | mustRun(t, ctx, chromedp.EmulateViewport(l.w, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#box", chromedp.ByQuery)) |
| 134 | requirePointer(t, ctx, l.coarse) |
| 135 | if !l.anchor { |
| 136 | mustRun(t, ctx, chromedp.Evaluate(WithoutAnchorPositioning, nil, awaitPromise)) |
| 137 | } |
| 138 | RequireAnchorPositioning(t, ctx, path, "#box > [rst-dropdown] > [rst-dropdown-menu]", l.anchor) |
| 139 | // Edges are distances from the root box's leading edge, |
| 140 | // as in the row menu's test: the scrollbar gutter is on |
| 141 | // the left in RTL. |
| 142 | var g struct { |
| 143 | Open bool |
| 144 | SummaryStart, SummaryEnd, PanelStart, PanelEnd float64 |
| 145 | PanelTop, SummaryBottom, VW float64 |
| 146 | } |
| 147 | at(t, ctx, `(async () => { const d = document.querySelector("#box > [rst-dropdown]"); d.querySelector("summary").click(); |
| 148 | await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); |
| 149 | const s = d.querySelector("summary").getBoundingClientRect(), p = d.querySelector(":scope > [rst-dropdown-menu]").getBoundingClientRect(); |
| 150 | const rtl = getComputedStyle(d).direction === "rtl", h = document.documentElement.getBoundingClientRect(); |
| 151 | const start = r => rtl ? h.right - r.right : r.left - h.left, end = r => rtl ? h.right - r.left : r.right - h.left; |
| 152 | return JSON.stringify({Open: d.open, SummaryStart: start(s), SummaryEnd: end(s), PanelStart: start(p), PanelEnd: end(p), PanelTop: p.top, SummaryBottom: s.bottom, VW: h.width}); })()`, &g, awaitPromise) |
| 153 | if !g.Open { |
| 154 | t.Fatalf("%s: the menu did not open; this leg proves nothing", path) |
| 155 | } |
| 156 | // The control: the summary is where the page put it, |
| 157 | // or the edge the panel shares proves nothing. |
| 158 | if where == "end" && g.SummaryEnd < g.VW-40 || where != "end" && g.SummaryStart > 40 { |
| 159 | t.Fatalf("%s: the summary spans %.0f to %.0f of %.0f; it is not at the edge this page puts it", path, g.SummaryStart, g.SummaryEnd, g.VW) |
| 160 | } |
| 161 | if reported { |
| 162 | if where != "end" && (g.SummaryEnd-g.SummaryStart < g.VW/2 || g.PanelStart < g.VW/2) { |
| 163 | t.Errorf("%s: the summary spans %.0f to %.0f and the panel %.0f to %.0f; with anchor positioning reported, anchor.js must do nothing, leaving the dropdown its container's width and the panel at its far edge", path, g.SummaryStart, g.SummaryEnd, g.PanelStart, g.PanelEnd) |
| 164 | } |
| 165 | continue |
| 166 | } |
| 167 | if where == "end" { |
| 168 | if d := g.PanelEnd - g.SummaryEnd; d < -0.5 || d > 0.5 { |
| 169 | t.Errorf("%s: the panel's trailing edge is at %.0f, the summary's at %.0f; it must open from the summary", path, g.PanelEnd, g.SummaryEnd) |
| 170 | } |
| 171 | } else if d := g.PanelStart - g.SummaryStart; d < -0.5 || d > 0.5 { |
| 172 | t.Errorf("%s: the panel spans %.0f to %.0f, the summary %.0f to %.0f; with no room before the summary it must open from its leading edge", path, g.PanelStart, g.PanelEnd, g.SummaryStart, g.SummaryEnd) |
| 173 | } |
| 174 | if g.PanelStart < -0.5 || g.PanelEnd > g.VW+0.5 { |
| 175 | t.Errorf("%s: the panel spans %.0f to %.0f of a %.0fpx viewport; it must stay on screen", path, g.PanelStart, g.PanelEnd, g.VW) |
| 176 | } |
| 177 | if g.PanelTop < g.SummaryBottom || g.PanelTop > g.SummaryBottom+8 { |
| 178 | t.Errorf("%s: the panel starts at %.0f, the summary's foot is at %.0f; it must drop just below the summary", path, g.PanelTop, g.SummaryBottom) |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | }) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // TestADropdownWithoutAnchorsOrScriptsStaysOnScreen: in a browser with |
| 188 | // neither anchor positioning nor scripts, nothing can flip a panel, so |
| 189 | // a dropdown keeps the geometry it had before it learned to hug its |
| 190 | // summary. A <details> as wide as its block puts the panel at the |
| 191 | // block's trailing edge, far from a summary at the leading edge but on |
| 192 | // the screen, where a hugging one would hang it off the leading edge. |
| 193 | // The page links no script at all and a stylesheet whose anchor block |
| 194 | // can never apply. Both spellings, both directions, 1280 and 390. |
| 195 | func TestADropdownWithoutAnchorsOrScriptsStaysOnScreen(t *testing.T) { |
| 196 | partial := fixture(t, "dropdown_browser_test.go", "TestADropdownWithoutAnchorsOrScriptsStaysOnScreen", "partial") |
| 197 | bare := func(dir, menu string) string { |
| 198 | return `<!doctype html><html lang="en" dir="` + dir + `"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>no scripts</title>` + |
| 199 | `<link rel="stylesheet" href="/tokens-noanchor.css"><link rel="stylesheet" href="/theme.css"></head><body><div id="box" style="padding: 1rem">` + menu + `</div></body></html>` |
| 200 | } |
| 201 | pages := map[string]string{} |
| 202 | for name, menu := range map[string]string{"partial": partial, "sample": dropdownSample(t)} { |
| 203 | pages["/"+name] = bare("ltr", menu) |
| 204 | pages["/rtl/"+name] = bare("rtl", menu) |
| 205 | } |
| 206 | noAnchor := tokensWithoutAnchorPositioning(t) |
| 207 | for _, leg := range []struct { |
| 208 | name string |
| 209 | w int64 |
| 210 | coarse bool |
| 211 | }{{"1280 mouse", 1280, false}, {"390 touch", 390, true}} { |
| 212 | t.Run(leg.name, func(t *testing.T) { |
| 213 | var opts []harness.Option |
| 214 | if leg.coarse { |
| 215 | opts = append(opts, harness.WithCoarsePointer()) |
| 216 | } |
| 217 | rig := harness.New(t, func(string) http.Handler { |
| 218 | mux := sizingMux(t, pages) |
| 219 | mux.HandleFunc("GET /tokens-noanchor.css", func(w http.ResponseWriter, _ *http.Request) { |
| 220 | w.Header().Set("Content-Type", "text/css") |
| 221 | fmt.Fprint(w, noAnchor) |
| 222 | }) |
| 223 | return mux |
| 224 | }, opts...) |
| 225 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 226 | defer cancel() |
| 227 | for path := range pages { |
| 228 | mustRun(t, ctx, chromedp.EmulateViewport(leg.w, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#box", chromedp.ByQuery), |
| 229 | chromedp.Click("#box > [rst-dropdown] > summary", chromedp.ByQuery)) |
| 230 | RequireAnchorPositioning(t, ctx, path, "#box > [rst-dropdown] > [rst-dropdown-menu]", false) |
| 231 | var g struct { |
| 232 | Open bool |
| 233 | Left, Right, VW, Top, Foot float64 |
| 234 | } |
| 235 | at(t, ctx, `(() => { const d = document.querySelector("#box > [rst-dropdown]"), p = d.querySelector(":scope > [rst-dropdown-menu]").getBoundingClientRect(), s = d.querySelector("summary").getBoundingClientRect(), h = document.documentElement.getBoundingClientRect(); |
| 236 | return JSON.stringify({Open: d.open, Left: p.left - h.left, Right: p.right - h.left, VW: h.width, Top: p.top, Foot: s.bottom}); })()`, &g) |
| 237 | if !g.Open { |
| 238 | t.Fatalf("%s: the menu did not open; this leg proves nothing", path) |
| 239 | } |
| 240 | if g.Left < -0.5 || g.Right > g.VW+0.5 { |
| 241 | t.Errorf("%s: with neither anchor positioning nor scripts the panel spans %.0f to %.0f of a %.0fpx page; it must stay on the screen", path, g.Left, g.Right, g.VW) |
| 242 | } |
| 243 | if g.Top < g.Foot || g.Top > g.Foot+8 { |
| 244 | t.Errorf("%s: the panel starts at %.0f, the summary's foot is at %.0f; it must drop just below", path, g.Top, g.Foot) |
| 245 | } |
| 246 | } |
| 247 | }) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // TestAnOpenDropdownIsPlacedAgainWhenTheWindowChanges: without anchor |
| 252 | // positioning, anchor.js places a menu as it opens, and a window that |
| 253 | // is resized or rotated with the menu open can move the summary under |
| 254 | // it. Here a narrow window puts the dropdown at the trailing edge: the |
| 255 | // menu flipped to open from the summary's leading edge at 1280 would |
| 256 | // run off the trailing edge at 500, so it is placed again on resize. |
| 257 | // It stays on screen and under its summary, in both directions. |
| 258 | func TestAnOpenDropdownIsPlacedAgainWhenTheWindowChanges(t *testing.T) { |
| 259 | menu := fixture(t, "dropdown_browser_test.go", "TestAnOpenDropdownIsPlacedAgainWhenTheWindowChanges", "menu") |
| 260 | doc := sizingDoc("resize", `<style>#box { display: flex; padding: 1rem; } @media (max-width: 600px) { #box > [rst-dropdown] { margin-inline-start: auto; } }</style><div id="box">`+menu+`</div>`) |
| 261 | pages := map[string]string{"/": doc, "/rtl": strings.Replace(doc, `dir="ltr"`, `dir="rtl"`, 1)} |
| 262 | rig := sizingRig(t, false, pages) |
| 263 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 264 | defer cancel() |
| 265 | mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| 266 | _, err := cdppage.AddScriptToEvaluateOnNewDocument(withoutAnchorSupport).Do(ctx) |
| 267 | return err |
| 268 | })) |
| 269 | for _, path := range []string{"/", "/rtl"} { |
| 270 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#box", chromedp.ByQuery), |
| 271 | chromedp.Evaluate(WithoutAnchorPositioning, nil, awaitPromise)) |
| 272 | RequireAnchorPositioning(t, ctx, path, "#box > [rst-dropdown] > [rst-dropdown-menu]", false) |
| 273 | frames := chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise) |
| 274 | // The control: opened at 1280 the menu is flipped, from the |
| 275 | // summary's leading edge, or the resize has nothing to undo. |
| 276 | var flipped bool |
| 277 | mustRun(t, ctx, chromedp.Click("#box > [rst-dropdown] > summary", chromedp.ByQuery), frames, |
| 278 | chromedp.Evaluate(`document.querySelector("#box > [rst-dropdown]").classList.contains("is-flip-inline")`, &flipped)) |
| 279 | if !flipped { |
| 280 | t.Fatalf("%s: opened at the leading edge at 1280, the menu was not flipped; this leg proves nothing", path) |
| 281 | } |
| 282 | mustRun(t, ctx, chromedp.EmulateViewport(500, 800), frames) |
| 283 | var g struct { |
| 284 | Open bool |
| 285 | PanelL, PanelR, SummaryL, SummaryR, VW float64 |
| 286 | } |
| 287 | at(t, ctx, `(() => { const d = document.querySelector("#box > [rst-dropdown]"), p = d.querySelector(":scope > [rst-dropdown-menu]").getBoundingClientRect(), s = d.querySelector("summary").getBoundingClientRect(), h = document.documentElement.getBoundingClientRect(); |
| 288 | return JSON.stringify({Open: d.open, PanelL: p.left - h.left, PanelR: p.right - h.left, SummaryL: s.left - h.left, SummaryR: s.right - h.left, VW: h.width}); })()`, &g) |
| 289 | if !g.Open { |
| 290 | t.Fatalf("%s: the menu closed; this leg proves nothing", path) |
| 291 | } |
| 292 | if g.PanelL < -0.5 || g.PanelR > g.VW+0.5 { |
| 293 | t.Errorf("%s: resized to 500 with the menu open, the panel spans %.0f to %.0f of %.0f; it must be placed again, on the screen", path, g.PanelL, g.PanelR, g.VW) |
| 294 | } |
| 295 | if math.Abs(g.PanelL-g.SummaryL) > 0.5 && math.Abs(g.PanelR-g.SummaryR) > 0.5 { |
| 296 | t.Errorf("%s: the panel spans %.0f to %.0f, the summary %.0f to %.0f; it must open from one of the summary's edges", path, g.PanelL, g.PanelR, g.SummaryL, g.SummaryR) |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | |