rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

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.
8package check
9
10import (
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.
30const 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.
40func 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).
90func 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.
195func 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.
258func 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