mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
fix media queries in component based element call.
This commit is contained in:
@@ -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
|
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
|
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.
|
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
|
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
|
build rewrites every selector so that it matches only Element Call's root or
|
||||||
|
|||||||
@@ -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.
|
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 { createUserAndRoom, expectWithin, startHarness } from "./harness.ts";
|
||||||
|
import { SpaHelpers } from "../spa-helpers.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Element Call embedded as a React component, driven through the development
|
* 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 resize(900, 700);
|
||||||
await expect(call).not.toHaveAttribute("data-layout", "pip");
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide everything but the subtitle in small windows */
|
/* Hide everything but the subtitle in small windows */
|
||||||
@media (max-height: 450px) {
|
@container element-call (max-height: 450px) {
|
||||||
.bar {
|
.bar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide everything but the subtitle in small windows */
|
/* Hide everything but the subtitle in small windows */
|
||||||
@media (max-height: 450px) {
|
@container element-call (max-height: 450px) {
|
||||||
.bar:has(.subtitle) > header {
|
.bar:has(.subtitle) > header {
|
||||||
grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto);
|
grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto);
|
||||||
grid-template-areas: "." "subtitle";
|
grid-template-areas: "." "subtitle";
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
gap: var(--cpd-space-1-5x);
|
gap: var(--cpd-space-1-5x);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
@container element-call (min-width: 800px) {
|
||||||
.headerLogo,
|
.headerLogo,
|
||||||
.leftNav.hideMobile,
|
.leftNav.hideMobile,
|
||||||
.rightNav.hideMobile {
|
.rightNav.hideMobile {
|
||||||
|
|||||||
+11
-1
@@ -33,7 +33,8 @@ the app and for the component build. */
|
|||||||
@import url("@fontsource/inconsolata/700.css");
|
@import url("@fontsource/inconsolata/700.css");
|
||||||
|
|
||||||
@import url("normalize.css/normalize.css") layer(normalize);
|
@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);
|
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -76,6 +77,15 @@ the app and for the component build. */
|
|||||||
--video-tile-background: var(--cpd-color-bg-subtle-secondary);
|
--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-theme-dark {
|
||||||
--cpd-color-border-accent: var(--cpd-color-green-1100);
|
--cpd-color-border-accent: var(--cpd-color-green-1100);
|
||||||
--stopgap-color-on-solid-accent: var(--cpd-color-text-primary);
|
--stopgap-color-on-solid-accent: var(--cpd-color-text-primary);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@container element-call (max-width: 420px) {
|
||||||
.reactionPopupMenu {
|
.reactionPopupMenu {
|
||||||
--reaction-button-padding: 8px;
|
--reaction-button-padding: 8px;
|
||||||
--reaction-button-fontsize: 16px;
|
--reaction-button-fontsize: 16px;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*First hide the logo*/
|
/*First hide the logo*/
|
||||||
@media (max-width: 750px) {
|
@container element-call (max-width: 750px) {
|
||||||
.logo {
|
.logo {
|
||||||
display: none;
|
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.
|
With the logo hidden >500px is enough space to show overflow, buttons, layout.
|
||||||
Once we exceed 500 we hide everything except the buttons.
|
Once we exceed 500 we hide everything except the buttons.
|
||||||
*/
|
*/
|
||||||
@media (max-width: 500px) {
|
@container element-call (max-width: 500px) {
|
||||||
.footer {
|
.footer {
|
||||||
grid-template-areas: "buttons buttons buttons";
|
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 {
|
.footer {
|
||||||
padding-block: var(--cpd-space-8x)
|
padding-block: var(--cpd-space-8x)
|
||||||
calc(env(safe-area-inset-bottom) + 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 {
|
.footer {
|
||||||
padding-block: var(--cpd-space-4x)
|
padding-block: var(--cpd-space-4x)
|
||||||
calc(env(safe-area-inset-bottom) + 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 {
|
.shareScreen {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PIP custom css */
|
/* PIP custom css */
|
||||||
@media (max-height: 400px) {
|
@container element-call (max-height: 400px) {
|
||||||
.shareScreen {
|
.shareScreen {
|
||||||
display: flex;
|
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 {
|
.raiseHand {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
@container element-call (min-width: 800px) {
|
||||||
.buttons {
|
.buttons {
|
||||||
gap: var(--cpd-space-4x);
|
gap: var(--cpd-space-4x);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
block-size: 140px;
|
block-size: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@container element-call (max-width: 600px) {
|
||||||
/* Give the PiP a portrait aspect ratio */
|
/* Give the PiP a portrait aspect ratio */
|
||||||
.pip[data-size="sm"] {
|
.pip[data-size="sm"] {
|
||||||
inline-size: 88px;
|
inline-size: 88px;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
var(--content-inset-left);
|
var(--content-inset-left);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
@container element-call (min-width: 600px) {
|
||||||
.pip {
|
.pip {
|
||||||
inline-size: 180px;
|
inline-size: 180px;
|
||||||
block-size: 135px;
|
block-size: 135px;
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
margin-bottom: 44px;
|
margin-bottom: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
@container element-call (min-width: 800px) {
|
||||||
.logo {
|
.logo {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ spotlight tile is maximised and displaying video, apply a gradient background. *
|
|||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 320px) {
|
@container element-call (max-width: 320px) {
|
||||||
.invite {
|
.invite {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ Please see LICENSE in the repository root for full details.
|
|||||||
color: var(--cpd-color-theme-primary) !important;
|
color: var(--cpd-color-theme-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
@container element-call (max-width: 500px) {
|
||||||
.join {
|
.join {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-height: 650px) {
|
@container element-call (min-height: 650px) {
|
||||||
.content {
|
.content {
|
||||||
gap: var(--cpd-space-10x);
|
gap: var(--cpd-space-10x);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ video.mirror {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 550px) {
|
@container element-call (max-width: 550px) {
|
||||||
.preview {
|
.preview {
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user