diff --git a/src/components/CallFooter.module.css b/src/components/CallFooter.module.css index e006a5f7d..01c1ac901 100644 --- a/src/components/CallFooter.module.css +++ b/src/components/CallFooter.module.css @@ -43,7 +43,10 @@ Please see LICENSE in the repository root for full details. inset-inline: 0; } -.footer.overlay:has(:focus-visible) { +/* Keep the footer while it is in use: focus inside it, or a menu opened from + it. The menu is portalled out, so the trigger's aria-expanded tracks it. */ +.footer.overlay:has(:focus-visible), +.footer.overlay:has([aria-expanded="true"]) { opacity: 1; pointer-events: initial; } diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 6b94c929a..b99bfa132 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -13,6 +13,7 @@ import { Link } from "@vector-im/compound-web"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { CallFooter, type FooterSnapshot } from "./CallFooter"; import inCallViewStyles from "../room/InCallView.module.css"; +import styles from "./CallFooter.module.css"; import { useStaticViewModel } from "../state/ViewModel"; import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { type ReactionOption } from "../reactions"; @@ -175,6 +176,41 @@ export const WithAudioAndVideoOptions: Story = { }, }; +export const StaysWhileAMenuIsOpen: Story = { + ...Default, + args: { + ...WithAudioAndVideoOptions.args, + // As it is in a short window, where the footer overlays the call and hides + // itself once nothing is happening. + asOverlay: true, + showFooter: true, + }, + play: async ({ canvasElement }): Promise => { + const footer = canvasElement.querySelector( + '[data-testid="footer-container"]', + )!; + await userEvent.click( + within(canvasElement).getByRole("button", { name: "Microphone" }), + ); + await expect(document.body.querySelector('[role="menu"]')).not.toBeNull(); + + // The call now decides to hide the footer, which is the class it does it + // with. The menu is portalled out of the footer, so the focus inside it is + // not something the footer can see: without the trigger's aria-expanded to + // go on, this would fade the footer out and take the menu's anchor with it. + // The call now decides to hide the footer, which is the class it does that + // with. The menu is portalled out of the footer, so the focus inside it is + // not something the footer can see: without the trigger's aria-expanded to + // go on, this fades the footer out and takes the menu's anchor with it. + footer.classList.add(styles.hidden); + + // The footer fades over 0.15s, so a reading taken now is the value it + // started from whatever happens next. Let the transition finish first. + await new Promise((settled) => setTimeout(settled, 400)); + await expect(getComputedStyle(footer).opacity).toBe("1"); + }, +}; + export const AudioBusy: Story = { ...Default, args: {