fix media queries in component based element call.

This commit is contained in:
Timo K.
2026-09-07 14:50:36 +02:00
parent 218ee46b7d
commit 365204d04c
13 changed files with 114 additions and 21 deletions
+3
View File
@@ -234,6 +234,9 @@ logged along the bottom.
The call lays itself out for the size of the element it is mounted in, not the
window: a host that shrinks the container to a corner of its page gets the
picture-in-picture layout, just as a host that shrank the whole iframe used to.
The breakpoints in Element Call's stylesheets are `@container element-call`
queries against its root element for the same reason; for the standalone app
the root is the page, so they mean what the media queries they replaced did.
The component's stylesheet is confined to the element it is mounted in: the
build rewrites every selector so that it matches only Element Call's root or
+81 -1
View File
@@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Locator, test } from "@playwright/test";
import { expect, type Locator, type Page, test } from "@playwright/test";
import { createUserAndRoom, expectWithin, startHarness } from "./harness.ts";
import { SpaHelpers } from "../spa-helpers.ts";
/**
* Element Call embedded as a React component, driven through the development
@@ -165,3 +166,82 @@ test("lays itself out for the space it is given, not the page", async ({
await resize(900, 700);
await expect(call).not.toHaveAttribute("data-layout", "pip");
});
/**
* The shape of a call at whatever size it has been given: the layout it chose,
* how much of the height the tile and the footer take, and which controls the
* footer shows. Two calls with the same shape look the same, participants aside.
*/
async function callShape(scope: Page | Locator): Promise<{
layout: string | null;
tileHeight: number;
footerHeight: number;
buttons: (string | null)[];
}> {
const call = scope.locator("[data-layout]");
const footer = scope.getByTestId("footer-container");
await expect(footer).toBeVisible();
const tile = scope.getByTestId("videoTile").first();
await expect(tile).toBeVisible();
const tileBox = (await tile.boundingBox())!;
const footerBox = (await footer.boundingBox())!;
const buttons = await footer
.getByRole("button")
.filter({ visible: true })
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("aria-label")),
);
return {
layout: await call.getAttribute("data-layout"),
tileHeight: Math.round(tileBox.height),
footerHeight: Math.round(footerBox.height),
buttons,
};
}
test("looks the same in a small container as in a small window", async ({
page,
browser,
}) => {
// Two calls to set up, one of them through the harness's two logins
test.setTimeout(240_000);
const size = { width: 300, height: 300 };
// The reference is Element Call owning a window of that size, which is what
// a mobile app's webview or a browser's picture-in-picture gives it, and
// what its small-window styling was written for.
const referenceContext = await browser.newContext({
viewport: size,
ignoreHTTPSErrors: true,
permissions: ["microphone", "camera"],
});
const referencePage = await referenceContext.newPage();
await referencePage.goto("/");
await SpaHelpers.createCall(referencePage, "Reference", "smallwindow", true);
const reference = await callShape(referencePage);
await referencePage.screenshot({
path: test.info().outputPath("small-window.png"),
});
// The component gets a container of that size, in a window that is far larger
const { username, roomId } = await createUserAndRoom("smallcontainer");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
await container.evaluate((element, { width, height }) => {
element.style.width = `${width}px`;
element.style.height = `${height}px`;
}, size);
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
await expect(pane.locator("[data-layout]")).toBeVisible({ timeout: 60_000 });
const component = await callShape(pane);
await container.screenshot({
path: test.info().outputPath("small-container.png"),
});
await referenceContext.close();
// The breakpoints in Element Call's stylesheets are container queries, so a
// small container gets the compact footer a small window does, rather than
// the full-width one the window's own size would call for
expect(component).toEqual(reference);
});
+2 -2
View File
@@ -68,7 +68,7 @@
}
/* Hide everything but the subtitle in small windows */
@media (max-height: 450px) {
@container element-call (max-height: 450px) {
.bar {
display: none;
}
@@ -166,7 +166,7 @@
}
/* Hide everything but the subtitle in small windows */
@media (max-height: 450px) {
@container element-call (max-height: 450px) {
.bar:has(.subtitle) > header {
grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto);
grid-template-areas: "." "subtitle";
+1 -1
View File
@@ -107,7 +107,7 @@ Please see LICENSE in the repository root for full details.
gap: var(--cpd-space-1-5x);
}
@media (min-width: 800px) {
@container element-call (min-width: 800px) {
.headerLogo,
.leftNav.hideMobile,
.rightNav.hideMobile {
+11 -1
View File
@@ -33,7 +33,8 @@ the app and for the component build. */
@import url("@fontsource/inconsolata/700.css");
@import url("normalize.css/normalize.css") layer(normalize);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css")
layer(compound);
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
:root {
@@ -76,6 +77,15 @@ the app and for the component build. */
--video-tile-background: var(--cpd-color-bg-subtle-secondary);
}
/* The breakpoints in Element Call's stylesheets are container queries against
this element rather than media queries against the viewport. For the standalone
app the two are the same thing, since the root is the page; for a host that
embeds Element Call in a corner of its own page they are not, and it is the
corner that the layout has to fit. */
[data-element-call-root] {
container: element-call / size;
}
.cpd-theme-dark {
--cpd-color-border-accent: var(--cpd-color-green-1100);
--stopgap-color-on-solid-accent: var(--cpd-color-text-primary);
+1 -1
View File
@@ -10,7 +10,7 @@
width: fit-content;
}
@media (max-width: 420px) {
@container element-call (max-width: 420px) {
.reactionPopupMenu {
--reaction-button-padding: 8px;
--reaction-button-fontsize: 16px;
+8 -8
View File
@@ -77,7 +77,7 @@ Please see LICENSE in the repository root for full details.
}
/*First hide the logo*/
@media (max-width: 750px) {
@container element-call (max-width: 750px) {
.logo {
display: none;
}
@@ -94,7 +94,7 @@ Please see LICENSE in the repository root for full details.
With the logo hidden >500px is enough space to show overflow, buttons, layout.
Once we exceed 500 we hide everything except the buttons.
*/
@media (max-width: 500px) {
@container element-call (max-width: 500px) {
.footer {
grid-template-areas: "buttons buttons buttons";
}
@@ -115,27 +115,27 @@ Once we exceed 500 we hide everything except the buttons.
}
}
@media (max-height: 800px) {
@container element-call (max-height: 800px) {
.footer {
padding-block: var(--cpd-space-8x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-8x));
}
}
@media (max-height: 400px) {
@container element-call (max-height: 400px) {
.footer {
padding-block: var(--cpd-space-4x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
}
}
@media (max-width: 370px) {
@container element-call (max-width: 370px) {
.shareScreen {
display: none;
}
/* PIP custom css */
@media (max-height: 400px) {
@container element-call (max-height: 400px) {
.shareScreen {
display: flex;
}
@@ -148,13 +148,13 @@ Once we exceed 500 we hide everything except the buttons.
}
}
@media (max-width: 320px) {
@container element-call (max-width: 320px) {
.raiseHand {
display: none;
}
}
@media (min-width: 800px) {
@container element-call (min-width: 800px) {
.buttons {
gap: var(--cpd-space-4x);
}
+1 -1
View File
@@ -30,7 +30,7 @@ Please see LICENSE in the repository root for full details.
block-size: 140px;
}
@media (max-width: 600px) {
@container element-call (max-width: 600px) {
/* Give the PiP a portrait aspect ratio */
.pip[data-size="sm"] {
inline-size: 88px;
+1 -1
View File
@@ -25,7 +25,7 @@ Please see LICENSE in the repository root for full details.
var(--content-inset-left);
}
@media (min-width: 600px) {
@container element-call (min-width: 600px) {
.pip {
inline-size: 180px;
block-size: 135px;
+1 -1
View File
@@ -74,7 +74,7 @@ Please see LICENSE in the repository root for full details.
margin-bottom: 44px;
}
@media (min-width: 800px) {
@container element-call (min-width: 800px) {
.logo {
display: none;
}
+1 -1
View File
@@ -75,7 +75,7 @@ spotlight tile is maximised and displaying video, apply a gradient background. *
background: none;
}
@media (max-width: 320px) {
@container element-call (max-width: 320px) {
.invite {
display: none;
}
+2 -2
View File
@@ -28,13 +28,13 @@ Please see LICENSE in the repository root for full details.
color: var(--cpd-color-theme-primary) !important;
}
@media (max-width: 500px) {
@container element-call (max-width: 500px) {
.join {
width: 100%;
}
}
@media (min-height: 650px) {
@container element-call (min-height: 650px) {
.content {
gap: var(--cpd-space-10x);
}
+1 -1
View File
@@ -71,7 +71,7 @@ video.mirror {
);
}
@media (max-width: 550px) {
@container element-call (max-width: 550px) {
.preview {
margin-inline: 0;
border-radius: 0;