diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 06c1d3b73..1fdb9a00c 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -491,9 +491,11 @@ lobbyTest( // And the wall is still reachable, just not in the way. await page.click("details.problem-picker summary"); + await page.locator(".problem-grid").waitFor({ state: "visible" }); const open = await page.evaluate(() => document.body.scrollHeight); assert.ok(open > closed, "opening the picker shows nothing"); await page.click("details.problem-picker summary"); + await page.locator(".problem-grid").waitFor({ state: "hidden" }); } }, { viewport: { width: 1280, height: 800 } }, @@ -5280,3 +5282,437 @@ for (const storage of ["account", "device"]) { } }); } + +lobbyTest( + "problem cards span desktop panels and return home on narrow screens", + async (page) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await lobby(page); + const grid = page.locator(".problem-grid"); + const picker = page.locator("details.problem-picker"); + const summary = picker.locator("summary"); + assert.equal(await grid.isVisible(), false); + await openTopicFilter(page); + await grid.waitFor({ state: "visible" }); + assert.ok( + await grid.evaluate( + (node) => + node.previousElementSibling === + document.querySelector(".setup-panel") && + node.nextElementSibling === document.querySelector(".session-panel"), + ), + ); + await page.locator("#problem-filters-reset").focus(); + await page.keyboard.press("Tab"); + assert.ok( + await grid.evaluate((node) => node.contains(document.activeElement)), + ); + const framesAgree = await page.evaluate(async () => { + const picker = document.querySelector("details.problem-picker"); + const grid = document.querySelector(".problem-grid"); + const outline = document.querySelector(".problem-picker-outline"); + const summary = picker.querySelector("summary"); + for (let index = 0; index < 12; index++) { + summary.click(); + await new Promise(requestAnimationFrame); + if ( + Boolean(grid.hidden) === picker.open || + outline.hasAttribute("hidden") === picker.open || + document + .querySelector(".lobby") + .classList.contains("problem-picker-connected") !== picker.open + ) + return false; + } + return picker.open; + }); + assert.ok( + framesAgree, + "cards and outline must agree with the native picker before paint", + ); + await grid.evaluate((node) => { + for (const [index, card] of [ + ...node.querySelectorAll(".problem-card"), + ].entries()) + card.hidden = index > 0; + }); + await page.waitForFunction(() => { + const path = document.querySelector(".problem-picker-outline path"); + const lobby = document.querySelector(".lobby").getBoundingClientRect(); + const panel = document + .querySelector(".setup-panel") + .getBoundingClientRect(); + const grid = document.querySelector(".problem-grid"); + const card = grid.querySelector(".problem-card").getBoundingClientRect(); + const expected = + Math.max( + panel.right, + card.right + parseFloat(getComputedStyle(grid).paddingRight), + ) - lobby.left; + const bounds = path.getBBox(); + return Math.abs(bounds.x + bounds.width - expected) < 1; + }); + await grid.evaluate((node) => { + for (const card of node.querySelectorAll(".problem-card")) + card.hidden = false; + }); + const bounds = await page.evaluate(() => { + const setup = document + .querySelector(".setup-grid") + .getBoundingClientRect(); + const cards = document + .querySelector(".problem-grid") + .getBoundingClientRect(); + return { + left: Math.abs(setup.left - cards.left), + width: Math.abs(setup.width - cards.width), + below: [...document.querySelectorAll(".setup-panel")].every( + (panel) => cards.top >= panel.getBoundingClientRect().bottom + 24, + ), + }; + }); + assert.ok(bounds.left < 1 && bounds.width < 1 && bounds.below); + assert.equal( + await page.locator(".setup-panel:first-child #problem-topic").count(), + 1, + ); + for (const selector of [".setup-panel", ".session-panel", ".start-panel"]) { + const outline = await page + .locator(".problem-picker-outline path") + .getAttribute("d"); + await page + .locator(selector) + .first() + .evaluate((node) => { + node.style.minHeight = `${node.getBoundingClientRect().height + 600}px`; + }); + await page.waitForFunction( + (previous) => + document + .querySelector(".problem-picker-outline path") + .getAttribute("d") !== previous, + outline, + ); + assert.ok( + await page.evaluate(() => { + const cards = document + .querySelector(".problem-grid") + .getBoundingClientRect(); + return [...document.querySelectorAll(".setup-panel")].every( + (panel) => cards.top >= panel.getBoundingClientRect().bottom + 24, + ); + }), + ); + await page + .locator(selector) + .first() + .evaluate((node) => node.style.removeProperty("min-height")); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + } + const first = grid.locator(".problem-card:visible").first(); + const id = await first.getAttribute("data-problem"); + await first.click(); + assert.equal( + await grid.evaluate((node) => { + const cards = [...node.querySelectorAll(".problem-card:not([hidden])")]; + const top = cards[0].getBoundingClientRect().top; + return cards.filter( + (card) => Math.abs(card.getBoundingClientRect().top - top) < 1, + ).length; + }), + 4, + ); + assert.ok( + await page.evaluate(() => { + const topic = document + .querySelector("#problem-topic") + .getBoundingClientRect(); + const reset = document + .querySelector("#problem-filters-reset") + .getBoundingClientRect(); + return Math.abs(topic.top - reset.top) < 1; + }), + ); + const width = (await first.boundingBox()).width; + await page.setViewportSize({ width: 1600, height: 1000 }); + assert.ok(Math.abs((await first.boundingBox()).width - width) < 1); + for (const viewportWidth of [1101, 1100, 1000, 761]) { + await page.setViewportSize({ width: viewportWidth, height: 1000 }); + await page.waitForFunction(() => + document.querySelector(".setup-grid > .problem-grid"), + ); + const layout = await page.locator(".setup-grid").evaluate((node) => { + const start = node + .querySelector(".start-panel") + .getBoundingClientRect(); + const cards = node + .querySelector(".problem-grid") + .getBoundingClientRect(); + return { + columns: getComputedStyle(node).gridTemplateColumns.split(" ").length, + startAboveCards: start.bottom <= cards.top, + startBelowCards: start.top >= cards.bottom, + }; + }); + assert.equal(layout.columns, viewportWidth > 1100 ? 3 : 2); + assert.equal(layout.startAboveCards, viewportWidth > 1100); + assert.equal(layout.startBelowCards, viewportWidth <= 1100); + assert.ok( + await page.evaluate(() => { + const setup = document + .querySelector(".setup-grid") + .getBoundingClientRect(); + const cards = document + .querySelector(".problem-grid") + .getBoundingClientRect(); + const panels = [...document.querySelectorAll(".setup-panel")].slice( + 0, + 2, + ); + return ( + Math.abs(setup.width - cards.width) < 1 && + Math.abs(setup.left - cards.left) < 1 && + panels.every( + (panel) => cards.top >= panel.getBoundingClientRect().bottom + 24, + ) && + document.documentElement.scrollWidth <= innerWidth + ); + }), + ); + } + await first.focus(); + await page.setViewportSize({ width: 760, height: 1000 }); + await page.waitForFunction(() => + document.querySelector("details.problem-picker .problem-grid"), + ); + assert.equal( + await first.evaluate((node) => node === document.activeElement), + true, + ); + assert.equal( + await page.locator(`[data-problem="${id}"]`).getAttribute("aria-pressed"), + "true", + ); + await summary.click(); + await grid.waitFor({ state: "hidden" }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.waitForFunction(() => + document.querySelector(".setup-grid > .problem-grid"), + ); + assert.equal(await grid.isVisible(), false); + await summary.click(); + await grid.waitFor({ state: "visible" }); + assert.equal(await first.getAttribute("aria-pressed"), "true"); + await page.setViewportSize({ width: 320, height: 800 }); + await page.waitForFunction(() => + document.querySelector("details.problem-picker .problem-grid"), + ); + assert.ok( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ); + }, + { viewport: { width: 1440, height: 1000 } }, +); + +lobbyTest( + "picker preserves compact spacing and mobile field sizing", + async (page) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await lobby(page); + for (const width of [639, 640, 760, 761, 1023, 1024, 1280]) { + await page.setViewportSize({ width, height: 800 }); + assert.equal( + await page + .locator(".problem-picker") + .evaluate((node) => getComputedStyle(node).marginTop), + width >= 640 ? "16px" : "36px", + ); + } + await page.setViewportSize({ width: 1280, height: 851 }); + assert.equal( + await page + .locator(".problem-picker") + .evaluate((node) => getComputedStyle(node).marginTop), + "36px", + ); + await openTopicFilter(page); + await page.locator(".problem-grid").waitFor({ state: "visible" }); + await page.setViewportSize({ width: 375, height: 800 }); + await page.waitForFunction(() => + document.querySelector("details.problem-picker .problem-grid"), + ); + assert.equal( + await page + .locator("#problem-topic") + .evaluate((node) => getComputedStyle(node).fontSize), + "16px", + ); + assert.deepEqual(errors, []); + }, +); + +lobbyTest( + "picker outline stays aligned after lower content grows", + async (page) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await lobby(page); + await openTopicFilter(page); + const path = page.locator(".problem-picker-outline path"); + await path.waitFor({ state: "visible" }); + const before = await path.boundingBox(); + for (const selector of ["#recent-performance", "#history"]) { + const previousHeight = await page + .locator(".lobby") + .evaluate((node) => node.getBoundingClientRect().height); + await page.locator(selector).evaluate((node) => { + node.hidden = false; + node.style.minHeight = `${node.getBoundingClientRect().height + 600}px`; + }); + await page.waitForFunction( + (height) => + document.querySelector(".lobby").getBoundingClientRect().height > + height + 500, + previousHeight, + ); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + const after = await path.boundingBox(); + for (const property of ["x", "y", "width", "height"]) { + assert.ok( + Math.abs(after[property] - before[property]) < 1, + `${selector} growth must not change outline ${property}`, + ); + } + } + }, +); + +lobbyTest( + "fragment navigation reveals a collapsed problem card", + async (page) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await lobby(page); + // Remove the native until-found skip so the application must hide contents. + await page.addStyleTag({ + content: + ':where([hidden="until-found"]) { content-visibility: visible; }', + }); + const picker = page.locator("details.problem-picker"); + const grid = page.locator(".problem-grid"); + assert.equal(await picker.evaluate((node) => node.open), false); + assert.equal(await grid.getAttribute("hidden"), "until-found"); + assert.equal( + await grid.evaluate((node) => getComputedStyle(node).contentVisibility), + "hidden", + ); + assert.equal( + await page.evaluate(() => { + const selected = document.querySelector("#recommendation").textContent; + const title = [ + ...document.querySelectorAll( + ".problem-card:not([hidden]) .problem-title", + ), + ].find((node) => !selected.includes(node.textContent)).textContent; + const found = window.find(title); + window.getSelection().removeAllRanges(); + return found; + }), + true, + ); + await picker.locator("summary").focus(); + await page.keyboard.press("Tab"); + assert.equal( + await page.evaluate( + () => !!document.activeElement.closest(".session-panel"), + ), + true, + ); + await page.evaluate(() => { + const card = document.querySelector(".problem-card:not([hidden])"); + card.id = "find-problem-card"; + const link = document.createElement("a"); + link.href = "#find-problem-card"; + link.textContent = "Find collapsed problem"; + document.querySelector(".lobby").prepend(link); + }); + await page.getByRole("link", { name: "Find collapsed problem" }).click(); + await page.waitForFunction( + () => document.querySelector("details.problem-picker").open, + ); + assert.equal(await grid.getAttribute("hidden"), null); + assert.equal(await page.locator("#find-problem-card").isVisible(), true); + await picker.locator("summary").click(); + await page.waitForFunction( + () => document.querySelector(".problem-grid").hidden === "until-found", + ); + await page.setViewportSize({ width: 375, height: 800 }); + await page.waitForFunction(() => + document.querySelector("details.problem-picker .problem-grid"), + ); + assert.equal(await grid.getAttribute("hidden"), null); + }, +); + +lobbyTest( + "picker outline follows mode changes and a late topic error", + async (page) => { + let releaseTopics; + const topicsHeld = new Promise((resolve) => { + releaseTopics = resolve; + }); + await page.route("**/problem-topics.json", async (route) => { + await topicsHeld; + await route.fulfill({ status: 500, body: "nope" }); + }); + try { + await page.setViewportSize({ width: 1101, height: 1000 }); + await lobby(page); + await page.locator("details.problem-picker summary").click(); + const aligned = () => + page.waitForFunction(() => { + const panel = document + .querySelector(".setup-panel") + .getBoundingClientRect(); + const grid = document + .querySelector(".problem-grid") + .getBoundingClientRect(); + const outline = document + .querySelector(".problem-picker-outline path") + .getBoundingClientRect(); + return ( + Math.abs(outline.left - panel.left) < 1 && + Math.abs(outline.top - panel.top) < 1 && + Math.abs(outline.bottom - grid.bottom) < 1 + ); + }); + await aligned(); + await page.locator('[data-mode="whiteboard"]').click(); + assert.equal(await page.locator("#mode-note").isVisible(), true); + await aligned(); + releaseTopics(); + await page.waitForFunction(() => + document + .querySelector("#problem-filter-summary") + .textContent.includes("could not be loaded"), + ); + await aligned(); + await page.locator('[data-mode="coding"]').click(); + assert.equal(await page.locator("#mode-note").isVisible(), false); + await aligned(); + } finally { + releaseTopics(); + } + }, +); diff --git a/web/app.js b/web/app.js index 1c531a368..f50c8ff58 100644 --- a/web/app.js +++ b/web/app.js @@ -1,3 +1,4 @@ +import { mountProblemGrid } from "./problem-grid.js"; import { FRAMEWORKS, SHOW_SOURCES_KEY, @@ -1908,3 +1909,5 @@ function select(selector, chosen) { mark(button, button === chosen); } } + +mountProblemGrid(); diff --git a/web/index.html b/web/index.html index 3f80055c8..18ccfc1bb 100644 --- a/web/index.html +++ b/web/index.html @@ -101,26 +101,32 @@

Choose a problem

Choose a specific problem instead - -
- Filter problems - - - -

-
+
+ +
+
+ Filter problems + + + +

+
+
+
diff --git a/web/problem-grid.js b/web/problem-grid.js new file mode 100644 index 000000000..5ccc60f5e --- /dev/null +++ b/web/problem-grid.js @@ -0,0 +1,108 @@ +/// Only cards leave the first panel; controls retain their reading order. +export function mountProblemGrid() { + if (typeof window.matchMedia !== "function") return; + const picker = document.querySelector("details.problem-picker"); + const grid = picker.querySelector(".problem-grid"); + const setup = document.querySelector(".setup-grid"); + const summary = picker.querySelector("summary"); + const home = document.createComment("problem-grid-home"); + grid.before(home); + grid.id = "problem-cards"; + summary.setAttribute("aria-controls", grid.id); + const columns = window.matchMedia("(min-width: 761px)"); + + const lobby = setup.parentElement; + const panel = setup.querySelector(".setup-panel"); + const outline = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + outline.classList.add("problem-picker-outline"); + outline.setAttribute("aria-hidden", "true"); + const path = document.createElementNS(outline.namespaceURI, "path"); + outline.append(path); + lobby.append(outline); + + // Measure the live layout, including changes to any setup panel's height. + function drawOutline() { + const connected = columns.matches && picker.open; + lobby.classList.toggle("problem-picker-connected", connected); + outline.toggleAttribute("hidden", !connected); + if (!connected) return; + const origin = lobby.getBoundingClientRect(); + const top = panel.getBoundingClientRect(); + const bottom = grid.getBoundingClientRect(); + const x = top.left - origin.left; + const y = top.top - origin.top; + const padding = parseFloat(getComputedStyle(grid).paddingRight); + const cards = [...grid.querySelectorAll(".problem-card")] + .filter((card) => !card.hidden) + .map((card) => card.getBoundingClientRect()); + const right = + Math.max(top.right, ...cards.map((card) => card.right + padding)) - + origin.left; + const edge = top.right - origin.left; + const join = bottom.top - origin.top; + const end = bottom.bottom - origin.top; + const radius = parseFloat(getComputedStyle(panel).borderTopLeftRadius); + path.setAttribute( + "d", + [ + `M ${x + radius} ${y}`, + `H ${edge - radius} Q ${edge} ${y} ${edge} ${y + radius}`, + ...(right - edge >= 2 * radius + ? [ + `V ${join - radius} Q ${edge} ${join} ${edge + radius} ${join}`, + `H ${right - radius} Q ${right} ${join} ${right} ${join + radius}`, + ] + : right > edge + ? [`V ${join} H ${right}`] + : []), + `V ${end - radius} Q ${right} ${end} ${right - radius} ${end}`, + `H ${x + radius} Q ${x} ${end} ${x} ${end - radius}`, + `V ${y + radius} Q ${x} ${y} ${x + radius} ${y} Z`, + ].join(" "), + ); + } + const cardObserver = new MutationObserver(drawOutline); + cardObserver.observe(grid, { + subtree: true, + attributes: true, + attributeFilter: ["hidden"], + }); + const observer = new ResizeObserver(drawOutline); + observer.observe(lobby); + observer.observe(grid); + for (const item of setup.querySelectorAll(".setup-panel")) + observer.observe(item); + + grid.addEventListener("beforematch", () => { + picker.open = true; + update(); + }); + + function update() { + const focused = document.activeElement; + const hadFocus = grid.contains(focused); + if (columns.matches) { + if (grid.previousElementSibling !== panel) panel.after(grid); + if (picker.open) grid.removeAttribute("hidden"); + else grid.setAttribute("hidden", "until-found"); + } else { + if (grid.parentElement !== picker) home.after(grid); + grid.hidden = false; + } + drawOutline(); + if (hadFocus) { + if (!picker.open) summary.focus({ preventScroll: true }); + else focused.focus({ preventScroll: true }); + } + } + + // The toggle event is queued and may run after a paint. Observe open instead + // so native controls, detached cards and their outline change before paint. + const stateObserver = new MutationObserver(update); + stateObserver.observe(picker, { + attributes: true, + attributeFilter: ["open"], + }); + columns.addEventListener("change", update); + update(); +} diff --git a/web/styles.css b/web/styles.css index e9f369f93..a87e80993 100644 --- a/web/styles.css +++ b/web/styles.css @@ -3074,6 +3074,8 @@ body:not([data-recording]) .replay-detail > :not(header) { --inset-surface: var(--page); --selected-surface: var(--hairline); --shadow-card: 0 1px 2px rgb(23 29 45 / 4%), 0 8px 24px rgb(23 29 45 / 6%); + --picker-shadow: drop-shadow(0 1px 2px rgb(23 29 45 / 4%)) + drop-shadow(0 8px 24px rgb(23 29 45 / 6%)); --shadow-cta: 0 6px 16px rgb(110 43 240 / 24%); } } @@ -3089,6 +3091,8 @@ body:not([data-recording]) .replay-detail > :not(header) { ); --practice-tint-edge: #473983; --shadow-card: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 24px rgb(0 0 0 / 24%); + --picker-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 30%)) + drop-shadow(0 8px 24px rgb(0 0 0 / 24%)); --shadow-cta: 0 6px 16px rgb(116 76 230 / 28%); } } @@ -3383,10 +3387,9 @@ body:not([data-recording]) .replay-detail > :not(header) { cursor: pointer; } -/* The panel is narrower than the page, and a phone narrower than one 16rem - card, so a column never asks for more than the width it has. */ +/* Fixed-width cards wrap without stretching; narrow screens still fit one. */ .lobby .problem-grid { - grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + grid-template-columns: repeat(auto-fill, min(100%, 17rem)); } .lobby .problem-card { @@ -3688,3 +3691,172 @@ body:not([data-recording]) .replay-detail > :not(header) { transition: none; } } + +/* In two columns, cards span the row before the final setup step. */ +.setup-grid > .problem-grid { + grid-row: 2; + grid-column: 1 / -1; +} + +@media (min-width: 761px) { + .setup-grid > .session-panel { + grid-row: 1; + grid-column: 2; + } +} + +@media (min-width: 1101px) { + .setup-grid > .start-panel { + grid-row: 1; + grid-column: 3; + } +} + +/* Only the source-title option uses the problem cards' surface. */ +.lobby .problem-picker { + margin-top: 2.25rem; +} + +.lobby .problem-picker > summary { + font-size: 1rem; +} + +.lobby .problem-picker-controls { + margin-top: 0.75rem; + min-width: 0; +} + +.lobby .problem-picker-controls .source-toggle { + display: flex; + align-items: flex-start; + gap: 0.375rem; + margin: 0; + padding: 1rem; + border: 1px solid var(--hairline); + border-radius: var(--radius-md); + background: var(--surface); +} + +.lobby .problem-picker-controls .source-toggle input { + flex: none; + margin: 0.2em 0 0; +} + +.lobby .problem-picker-controls .practice-filters { + min-width: 0; + margin-top: 2rem; +} + +.lobby .problem-picker-controls .practice-filters legend { + margin-bottom: 0.5rem; +} + +.lobby .problem-picker-controls .practice-filters > label, +.lobby .problem-picker-controls .practice-filters > select, +.lobby .problem-picker-controls #problem-filters-reset { + font-size: 0.8125rem; +} + +.lobby .problem-picker-controls select { + flex: 1 1 5rem; + width: 0; + min-width: min(5rem, 100%); + max-width: 100%; +} + +.lobby .problem-picker-controls #problem-filters-reset { + flex: none; + padding-inline: 0.5rem; +} + +/* The measured outline joins Step 1 to its cards without fixed panel heights. */ +.lobby { + position: relative; + isolation: isolate; +} + +.problem-picker-outline { + position: absolute; + inset: 0; + z-index: -1; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.problem-picker-outline path { + filter: var(--picker-shadow); + fill: var(--surface); + stroke: var(--hairline); + stroke-width: 1; +} + +.problem-picker-connected .setup-panel:first-child { + background: transparent; + border-color: transparent; + box-shadow: none; +} + +.problem-picker-connected .problem-grid { + padding: 0.75rem; +} + +/* Let spare row height sit above the filters, without scripted movement. */ +.problem-picker-connected .setup-panel:first-child { + display: flex; + flex-direction: column; + align-self: stretch; + padding-bottom: 0.75rem; +} + +.problem-picker-connected .problem-picker[open] { + display: flex; + flex-direction: column; + flex: 1; +} + +.problem-picker-connected .problem-picker[open]::details-content { + display: flex; + flex-direction: column; + flex: 1; +} + +.problem-picker-connected .problem-picker-controls { + display: flex; + flex-direction: column; + flex: 1; +} + +.problem-picker-connected .problem-filter-group { + margin-top: auto; + padding-top: 2rem; +} + +.problem-picker-connected .problem-filter-group .practice-filters { + margin-top: 0; +} + +@media (min-width: 640px) and (max-height: 850px) { + .lobby .problem-picker { + margin-top: 1rem; + } +} + +@media (max-width: 480px) { + .lobby .problem-picker-controls .practice-filters > select { + font-size: 1rem; + } +} + +/* Keep collapsed cards searchable without reserving a row in the setup grid. */ +.setup-grid > .problem-grid[hidden="until-found"] { + display: grid !important; + content-visibility: hidden; + position: absolute; + width: 100%; + height: 0; + margin: 0; + padding: 0; + overflow: clip; +}