mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Finish measuring the root rather than the viewport
The move from media queries to `@container element-call` queries left a few places still asking the viewport: the room header's compact size and the lobby's footer placement went through `useMediaQuery`, the lobby's video preview was `50vh` tall, the reaction picker was capped at `100vw`, and the content insets and the gradient background were sized from `100vw`/`100vh`. Embedded in a corner of a host's page, each of those answered for the page rather than the corner. `useRootSizeMatches` is `useMediaQuery` for the root element, built on the same `observeElementSize$` the layout uses; the lengths become container units. Container units resolve against the nearest query container, and there are others in the tree (the spotlight layouts, the media tiles), so base.css says when they may be used. jsdom gives the body no size at all, which would have every such query read as a tiny window; the test setup now gives it a desktop's, matching what the media query mock already answered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7cd21476f7
commit
779ee7a008
+2
-2
@@ -16,7 +16,7 @@ import styles from "./Header.module.css";
|
||||
import Logo from "./icons/Logo.svg?react";
|
||||
import { Avatar, Size } from "./Avatar";
|
||||
import { EncryptionLock } from "./room/EncryptionLock";
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
import { useRootSizeMatches } from "./useRootSize";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
|
||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
@@ -142,7 +142,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
participantCount,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const size = useMediaQuery("(max-width: 550px)") ? "sm" : "lg";
|
||||
const size = useRootSizeMatches(({ width }) => width <= 550) ? "sm" : "lg";
|
||||
|
||||
return (
|
||||
<div className={styles.roomHeaderInfo} data-size={size}>
|
||||
|
||||
+15
-7
@@ -46,21 +46,23 @@ the app and for the component build. */
|
||||
--font-size-headline: calc(32px * var(--font-scale));
|
||||
|
||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||
/* The distance to inset non-full-width content from the edge of the window
|
||||
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
||||
96px for typical desktop windows, and accounts for the safe area. */
|
||||
/* The distance to inset non-full-width content from the edge of Element
|
||||
Call's root along the inline axis. This ramps up from 16px for typical mobile
|
||||
windows, to 96px for typical desktop windows, and accounts for the safe area.
|
||||
Container units resolve where the property is used, so this must only be used
|
||||
by elements whose nearest query container is the root. */
|
||||
--content-inset-left: calc(
|
||||
env(safe-area-inset-left) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
max(var(--cpd-space-4x), calc((100cqw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--content-inset-right: calc(
|
||||
env(safe-area-inset-right) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
max(var(--cpd-space-4x), calc((100cqw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
@@ -80,7 +82,13 @@ the app and for the component build. */
|
||||
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. */
|
||||
corner that the layout has to fit.
|
||||
|
||||
For the same reason, lengths that were once a share of the viewport (`100vw`)
|
||||
are a share of the nearest query container (`100cqw`). Container units cannot
|
||||
name their container, so they only mean this element where no other query
|
||||
container — a spotlight layout, a media tile — lies in between; check that
|
||||
before using one further down the tree. */
|
||||
[data-element-call-root] {
|
||||
container: element-call / size;
|
||||
}
|
||||
@@ -107,7 +115,7 @@ corner that the layout has to fit. */
|
||||
|
||||
[data-element-call-root][data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.png");
|
||||
background-size: max(1440px, 100vw) max(1440px, 100vh);
|
||||
background-size: max(1440px, 100cqw) max(1440px, 100cqh);
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ div.reactionPopupMenuRoot {
|
||||
|
||||
.reactionPopupMenuRoot > div {
|
||||
width: fit-content;
|
||||
max-width: 100vw;
|
||||
max-width: 100cqw;
|
||||
}
|
||||
|
||||
div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
|
||||
|
||||
@@ -34,7 +34,7 @@ import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
import { useRootSizeMatches } from "../useRootSize";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { Link } from "../button/Link";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
@@ -117,7 +117,9 @@ export const LobbyView: FC<Props> = ({
|
||||
}, [navigate]);
|
||||
const hangup = confineToRoom ? undefined : onLeaveClick;
|
||||
|
||||
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
|
||||
const recentsButtonInFooter = useRootSizeMatches(
|
||||
({ height }) => height <= 500,
|
||||
);
|
||||
const recentsButton = !confineToRoom && (
|
||||
<Link className={styles.recents} to="/">
|
||||
{t("lobby.leave_button")}
|
||||
|
||||
@@ -9,7 +9,9 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-left: var(--content-inset-left);
|
||||
margin-right: var(--content-inset-right);
|
||||
min-block-size: 0;
|
||||
block-size: 50vh;
|
||||
/* Half the height of Element Call's root, not of the viewport: embedded,
|
||||
the two differ (see the note on container units in base.css) */
|
||||
block-size: 50cqh;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-width: 100%;
|
||||
border-radius: var(--cpd-space-4x);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { createElement, type FC, type PropsWithChildren } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useRootSizeMatches } from "./useRootSize";
|
||||
import { RootElementProvider } from "./RootElementContext";
|
||||
|
||||
/** A ResizeObserver the test fires itself. jsdom does not ship one. */
|
||||
class MockResizeObserver {
|
||||
public static instances: MockResizeObserver[] = [];
|
||||
|
||||
public constructor(private readonly callback: ResizeObserverCallback) {
|
||||
MockResizeObserver.instances.push(this);
|
||||
}
|
||||
|
||||
public observe(): void {
|
||||
this.fire();
|
||||
}
|
||||
|
||||
public unobserve(): void {}
|
||||
|
||||
public disconnect(): void {}
|
||||
|
||||
public fire(): void {
|
||||
this.callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
}
|
||||
|
||||
describe("useRootSizeMatches", () => {
|
||||
const originalResizeObserver = window.ResizeObserver;
|
||||
let root: HTMLDivElement;
|
||||
let size: { width: number; height: number };
|
||||
let Wrapper: FC<PropsWithChildren>;
|
||||
|
||||
beforeEach(() => {
|
||||
MockResizeObserver.instances = [];
|
||||
window.ResizeObserver =
|
||||
MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
size = { width: 800, height: 600 };
|
||||
root = document.createElement("div");
|
||||
Object.defineProperty(root, "clientWidth", { get: () => size.width });
|
||||
Object.defineProperty(root, "clientHeight", { get: () => size.height });
|
||||
Wrapper = ({ children }) =>
|
||||
createElement(RootElementProvider, { value: root }, children);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.ResizeObserver = originalResizeObserver;
|
||||
});
|
||||
|
||||
const resize = (to: { width: number; height: number }): void => {
|
||||
size = to;
|
||||
act(() => {
|
||||
for (const observer of MockResizeObserver.instances) observer.fire();
|
||||
});
|
||||
};
|
||||
|
||||
test("answers for the root's current size straight away", () => {
|
||||
const { result } = renderHook(
|
||||
() => useRootSizeMatches(({ width }) => width <= 550),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
test("follows the root as it is resized", () => {
|
||||
const { result } = renderHook(
|
||||
() => useRootSizeMatches(({ width }) => width <= 550),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
resize({ width: 300, height: 600 });
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
resize({ width: 900, height: 600 });
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
test("only re-renders when the answer changes", () => {
|
||||
const renders = vi.fn();
|
||||
renderHook(
|
||||
() => {
|
||||
renders();
|
||||
return useRootSizeMatches(({ height }) => height <= 500);
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
const before = renders.mock.calls.length;
|
||||
|
||||
// Still tall enough either way
|
||||
resize({ width: 800, height: 700 });
|
||||
resize({ width: 500, height: 650 });
|
||||
expect(renders.mock.calls.length).toBe(before);
|
||||
|
||||
resize({ width: 500, height: 400 });
|
||||
expect(renders.mock.calls.length).toBe(before + 1);
|
||||
});
|
||||
|
||||
test("keeps one subscription across renders with an inline predicate", () => {
|
||||
const { rerender } = renderHook(
|
||||
() => useRootSizeMatches(({ width }) => width <= 550),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
rerender();
|
||||
rerender();
|
||||
expect(MockResizeObserver.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
import { useLatest } from "./useLatest";
|
||||
import { type ElementSize, observeElementSize$ } from "./utils/elementSize";
|
||||
|
||||
/**
|
||||
* Whether the space Element Call is drawn in satisfies a condition on its
|
||||
* size: the counterpart of {@link useMediaQuery} for the container rather than
|
||||
* the viewport, and of the `@container element-call` queries in the
|
||||
* stylesheets. The two are the same thing standalone, where the root is the
|
||||
* page; for a component in a corner of a host's page they are not, and it is
|
||||
* the corner that matters.
|
||||
*
|
||||
* Re-renders only when the answer changes, not on every pixel of resize.
|
||||
*/
|
||||
export function useRootSizeMatches(
|
||||
matches: (size: ElementSize) => boolean,
|
||||
): boolean {
|
||||
const rootElement = useRootElement();
|
||||
// The latest predicate, so that an inline arrow does not resubscribe on
|
||||
// every render
|
||||
const latestMatches = useLatest(matches);
|
||||
const [result, setResult] = useState(() =>
|
||||
matches({
|
||||
width: rootElement.clientWidth,
|
||||
height: rootElement.clientHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = observeElementSize$(rootElement)
|
||||
.pipe(
|
||||
map((size) => latestMatches.current(size)),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setResult);
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [rootElement, latestMatches]);
|
||||
|
||||
return result;
|
||||
}
|
||||
+15
-1
@@ -55,13 +55,27 @@ window.matchMedia = global.matchMedia = (): MediaQueryList =>
|
||||
|
||||
// jsdom does no layout and has no ResizeObserver. The call view observes the
|
||||
// size of its root element; this one reports nothing, so that element stays at
|
||||
// whatever size jsdom says it is (zero) unless a test says otherwise.
|
||||
// whatever size jsdom says it is unless a test says otherwise.
|
||||
window.ResizeObserver ??= class ResizeObserver {
|
||||
public observe(): void {}
|
||||
public unobserve(): void {}
|
||||
public disconnect(): void {}
|
||||
};
|
||||
|
||||
// And what jsdom says is zero, for everything — which would have every size
|
||||
// query against Element Call's root (the body, with no provider) read as a tiny
|
||||
// window. Give the body the size of a typical desktop window instead, the same
|
||||
// answer the media query mock above gives. A test that wants another size
|
||||
// supplies a root element of its own.
|
||||
for (const [property, value] of [
|
||||
["clientWidth", 1024],
|
||||
["clientHeight", 768],
|
||||
] as const)
|
||||
Object.defineProperty(document.body, property, {
|
||||
get: () => value,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const storage: Record<string, string> = {};
|
||||
const localStoragePolyfill = {
|
||||
getItem(key: string) {
|
||||
|
||||
Reference in New Issue
Block a user