Keep the footer while a menu opened from it is open

- In a short window the footer fades out when the pointer leaves the window.
  The chevron menus are portalled to the document, so they stayed behind, their
  trigger faded out beneath them.
- The footer already stays for keyboard focus. A menu's focus is outside it, so
  the trigger's aria-expanded tracks it instead.
- Pre-existing: the camera chevron does the same on main today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-22 17:43:55 +02:00
co-authored by Claude Opus 5
parent 4883267098
commit 870556d66f
2 changed files with 40 additions and 1 deletions
+4 -1
View File
@@ -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;
}
+36
View File
@@ -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<void> => {
const footer = canvasElement.querySelector<HTMLElement>(
'[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: {