mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-09 07:37:26 +00:00
Hold both device menus at 296px, framed in Safari too
- The camera and microphone menus were as wide as their longest device name, so a long one widened the menu over more of the call: 340px for a long speaker name. Both are now 296px, as design sets them, and a long name wraps. - Narrower only where the call area is, so the menu still fits inside it: 268px in a 300px call. Taken from the observation of the call area that already bounds the list's height. - Held only for the dropdown. On Android and iOS Compound renders the menu as a drawer as wide as the screen; menuIsDrawer follows its rule. - Once the list is long enough to scroll, Safari paints it above the outline the menu is framed with, and the frame vanished down both sides of it: 384px of it on the many-devices microphone menu, in WebKit. The list now keeps a border width clear of the frame, so the headings and the meter inside it no longer need their own. - Guarded with a long speaker name, which fails at 340px on main's sizing; a unit test run as a phone, which fails with the width held there; and the many-devices story's inset, which fails on the old stylesheet, since the stories run in Chromium, which never showed the Safari bug. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,11 @@ Please see LICENSE in the repository root for full details.
|
||||
/* Only the device lists scroll, bounded by the measured call area: the menu is
|
||||
portalled out of the root, so CSS can't size it against the call. */
|
||||
.deviceList {
|
||||
/* A list that scrolls can paint above the menu's outline, hiding it. */
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
inline-size: calc(
|
||||
var(--device-list-inline-size) - 2 * var(--cpd-border-width-1)
|
||||
);
|
||||
overflow-y: auto;
|
||||
min-block-size: 0;
|
||||
max-block-size: var(--device-list-max-height);
|
||||
@@ -68,7 +73,6 @@ Please see LICENSE in the repository root for full details.
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-start: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
@@ -77,7 +81,6 @@ Please see LICENSE in the repository root for full details.
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-end: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,10 @@ const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
<div
|
||||
ref={setCallArea}
|
||||
style={{
|
||||
// A call's size: anything smaller makes a short device list scroll.
|
||||
// A call's size: anything smaller makes a short device list scroll, and
|
||||
// a narrower one narrows the menu.
|
||||
blockSize: 720,
|
||||
inlineSize: 1024,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
@@ -256,6 +258,37 @@ export const SpeakerAndMicrophoneSections: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A long name wraps rather than widening the menu, which design sets. */
|
||||
export const LongDeviceNameWraps: Story = {
|
||||
args: {
|
||||
...SpeakerAndMicrophoneSections.args,
|
||||
outputOptions: [
|
||||
{
|
||||
label: {
|
||||
type: "name",
|
||||
name: "Logitech BRIO 4K Ultra HD Pro Business Webcam Speakers (046d:085e)",
|
||||
},
|
||||
id: "long",
|
||||
},
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "long",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const body = within(document.body);
|
||||
const long = await body.findByRole("menuitemradio", { name: /Logitech/ });
|
||||
const short = await body.findByRole("menuitemradio", { name: "Headset" });
|
||||
|
||||
const menu = document.body.querySelector("[role='menu']")!;
|
||||
await expect(Math.round(menu.getBoundingClientRect().width)).toBe(296);
|
||||
await expect(long.getBoundingClientRect().height).toBeGreaterThan(
|
||||
short.getBoundingClientRect().height,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OutputCannotBeChosen: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
@@ -440,6 +473,13 @@ export const ManyDevices: Story = {
|
||||
// The meter is the menu's one opaque part, so it must stay inside the frame.
|
||||
await expect(pinned.left).toBeGreaterThan(frame.left);
|
||||
await expect(pinned.right).toBeLessThan(frame.right);
|
||||
|
||||
// A list long enough to scroll keeps clear of the frame, which it can
|
||||
// otherwise paint over.
|
||||
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
|
||||
const box = list.getBoundingClientRect();
|
||||
await expect(box.left).toBeGreaterThanOrEqual(frame.left + 1);
|
||||
await expect(box.right).toBeLessThanOrEqual(frame.right - 1);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
get platform(): string {
|
||||
return platformMock();
|
||||
},
|
||||
isFirefox: (): boolean => false,
|
||||
}));
|
||||
|
||||
interface RenderOptions {
|
||||
requestDeviceNames: () => void;
|
||||
}
|
||||
@@ -149,6 +157,28 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
expect(onMute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// On a phone the menu is a drawer, not a dropdown.
|
||||
test("holds the menu's width only where it is a dropdown", async () => {
|
||||
const width = async (platform: string): Promise<string> => {
|
||||
platformMock.mockReturnValue(platform);
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderComponent(
|
||||
<MediaMuteAndSwitchButton iconsAndLabels="audio" enabled />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
"[style*='--device-list-max-height']",
|
||||
)!;
|
||||
const value = list.style.getPropertyValue("--device-list-inline-size");
|
||||
unmount();
|
||||
return value;
|
||||
};
|
||||
expect(await width("desktop")).toBe("296px");
|
||||
expect(await width("ios")).toBe("");
|
||||
expect(await width("android")).toBe("");
|
||||
platformMock.mockReturnValue("desktop");
|
||||
});
|
||||
|
||||
test("requests device names when opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const requestDeviceNames = vi.fn();
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useRootElement } from "../RootElementContext";
|
||||
import { observeElementSize$ } from "../utils/elementSize";
|
||||
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||
import { menuIsDrawer } from "./menuIsDrawer";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: DeviceLabel | AudioOutputDeviceLabel;
|
||||
@@ -84,6 +85,12 @@ const LIST_SHARE_OF_CALL = 0.6;
|
||||
/** Smallest device list height in px, so a short call still shows more than one device. */
|
||||
const MIN_LIST_HEIGHT = 160;
|
||||
|
||||
/** The width design sets for the menu; a long device name wraps instead. */
|
||||
const MENU_WIDTH = 296;
|
||||
|
||||
/** Space kept between the menu and the call area's sides. */
|
||||
const MENU_MARGIN = 16;
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
enabled,
|
||||
busy,
|
||||
@@ -143,17 +150,27 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
// Measured on the call area: CSS can't size the portalled menu against it.
|
||||
const rootElement = useRootElement();
|
||||
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
||||
const [menuWidth, setMenuWidth] = useState(MENU_WIDTH);
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
// Followed, since a host can resize the call while the menu is open.
|
||||
const subscription = observeElementSize$(rootElement)
|
||||
.pipe(
|
||||
map(({ height }) =>
|
||||
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
|
||||
map(({ width, height }) => ({
|
||||
height: Math.max(
|
||||
MIN_LIST_HEIGHT,
|
||||
Math.round(height * LIST_SHARE_OF_CALL),
|
||||
),
|
||||
width: Math.min(MENU_WIDTH, Math.round(width - 2 * MENU_MARGIN)),
|
||||
})),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a.height === b.height && a.width === b.width,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setListMaxHeight);
|
||||
.subscribe(({ height, width }) => {
|
||||
setListMaxHeight(height);
|
||||
setMenuWidth(width);
|
||||
});
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [menuOpen, rootElement]);
|
||||
|
||||
@@ -349,6 +366,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
{
|
||||
"--device-list-max-height":
|
||||
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
|
||||
// On a phone Compound renders the menu as a drawer, which sets its own width.
|
||||
"--device-list-inline-size": menuIsDrawer()
|
||||
? undefined
|
||||
: `${menuWidth}px`,
|
||||
"--device-list-scroll-padding-end":
|
||||
meterHeight === undefined ? undefined : `${meterHeight}px`,
|
||||
"--device-list-scroll-padding-start":
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
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 { platform } from "../Platform";
|
||||
|
||||
/** Whether Compound opens a menu as a drawer, as it does on a phone. */
|
||||
export function menuIsDrawer(): boolean {
|
||||
return platform === "android" || platform === "ios";
|
||||
}
|
||||
Reference in New Issue
Block a user