diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index a611ddc1d..e4376ddb2 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -92,6 +92,32 @@ test("keeps its modals inside the container it was given", async ({ page }) => { ); }); +test("keeps the call inside the container, wherever the host put it", async ({ + page, +}) => { + const { username, roomId } = await createUserAndRoom("tileswithin"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const container = pane.getByTestId("call-container"); + + // Large enough for the layout switch to be offered + await resizeContainer(container, { width: 900, height: 640 }); + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(pane.getByTestId("footer-container")).toBeVisible({ + timeout: 60_000, + }); + + // The spotlight layout draws its tile in the fixed grid, which is positioned + // against Element Call's root rather than laid out in flow. The harness puts + // the container below a header of its own, so a grid offset measured from + // the top of the page instead of from the root would land the tile on top of + // the footer and out of the bottom of the container. + await pane.getByRole("radio", { name: "Spotlight" }).check(); + const tile = pane.getByTestId("videoTile").first(); + await expect(tile).toBeVisible({ timeout: 60_000 }); + await expectWithin(tile, container); +}); + test("leaves the host's own page unstyled", async ({ page }) => { const { username, roomId } = await createUserAndRoom("hoststyles"); const panes = await startHarness(page, username, roomId); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 13c2bffeb..5f706cabd 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -267,6 +267,20 @@ export const InCallView: FC = ({ // Merge the refs so they can attach to the same element const containerRef = useMergedRefs(containerRef1, containerRef2); + // The fixed grid is positioned against Element Call's root, so offsets + // handed to it have to be measured from there rather than from the + // viewport. Standalone the two are the same, the root being the page; for a + // component the root sits wherever the host put it, and measuring from the + // viewport would push the grid down by that much again. Taken at the same + // moment as `bounds`, so that the two agree however the host has scrolled. + const rootElement = useRootElement(); + const rootTop = useMemo( + () => rootElement.getBoundingClientRect().top, + // eslint-disable-next-line react-hooks/exhaustive-deps + [rootElement, bounds], + ); + const topWithinRoot = bounds.top - rootTop; + const { showControls, header: headerStyle } = useUrlParams(); const muteAllAudio = useBehavior(muteAllAudio$); @@ -564,7 +578,7 @@ export const InCallView: FC = ({ className={styles.fixedGrid} style={{ // If not edge-to-edge, consume the header insets right here. - insetBlockStart: edgeToEdge ? 0 : bounds.top + headerBounds.height, + insetBlockStart: edgeToEdge ? 0 : topWithinRoot + headerBounds.height, height: edgeToEdge ? "100%" : gridBounds.height, // If edge-to-edge, compute new safe area insets that account for the // header and footer, passing them down to the tiles. @@ -575,7 +589,7 @@ export const InCallView: FC = ({ // itself. Otherwise account for the safe area and header size // as part of the InCallView. headerStyle === HeaderStyle.AppBar - ? `${bounds.top}px` + ? `${topWithinRoot}px` : `calc(env(safe-area-inset-top) + ${headerBounds.height}px)` : undefined, "--call-view-safe-area-inset-bottom":