mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Size and scroll the device menus with Compound's menu
- Compound 10.3.0 bounds a menu by the space it has and scrolls what doesn't fit, within a boundary the app passes. The menus pass the call area, 16px in from its sides, rather than measuring its width. - The 296px width is the menu's own style. Compound gives that style only to the dropdown, so menuIsDrawer, which copied its phone rule, goes. - Compound draws the menu's frame over its content, so nothing that scrolls can hide it in Safari: the list, headings and meter no longer keep a border width clear of it, and the checks that they did go. - The device list is Compound's scroll area, so it fades out at the foot with Compound's chevron while there is more below. - The microphone menu's meter sticks at that foot, with rows scrolling under it as before, so the list has no fade above it: Compound's is a mask fixed to the list's edge, while a bounce at the top carries the meter with the rows. The band under the meter is filled, or rows would show there. The edge fades from where the chevron starts, so a bounce fades the meter out before it reaches the chevron. A row reached by keyboard stays above the meter. - The list's height is still its share of the call. - The component harness leaves the host's stylesheets unscoped, Compound's among them, as a host does: scoped, they never reached the menu, which is portalled out of Element Call's root. - Its specs check only Element Call's own document styles on the host page, and find the device list within Compound's scroll area. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -109,4 +109,12 @@ describe("scopeStylesToRoot", () => {
|
|||||||
".a .b_abc12:hover { border: 0 }",
|
".a .b_abc12:hover { border: 0 }",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves an excluded stylesheet as it is", async () => {
|
||||||
|
const css = "body { margin: 0 } .menu { color: red }";
|
||||||
|
const result = await postcss([
|
||||||
|
scopeStylesToRoot({ exclude: [/host\.css$/] }),
|
||||||
|
]).process(css, { from: "/app/host.css" });
|
||||||
|
expect(result.css).toBe(css);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,12 +60,18 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
|||||||
* tokens, the component styles, the fonts — is not in this build at all: the
|
* tokens, the component styles, the fonts — is not in this build at all: the
|
||||||
* host supplies it, unscoped, along with its copy of Compound.
|
* host supplies it, unscoped, along with its copy of Compound.
|
||||||
*/
|
*/
|
||||||
export function scopeStylesToRoot(): Plugin {
|
interface Options {
|
||||||
|
/** Stylesheets left as they are: a host's own, which it doesn't scope. */
|
||||||
|
exclude?: RegExp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopeStylesToRoot({ exclude = [] }: Options = {}): Plugin {
|
||||||
return {
|
return {
|
||||||
postcssPlugin: "element-call-scope-styles-to-root",
|
postcssPlugin: "element-call-scope-styles-to-root",
|
||||||
Once(root) {
|
Once(root) {
|
||||||
const isModule =
|
const file = root.source?.input.file ?? "";
|
||||||
root.source?.input.file?.endsWith(".module.css") ?? false;
|
if (exclude.some((pattern) => pattern.test(file))) return;
|
||||||
|
const isModule = file.endsWith(".module.css");
|
||||||
root.walkRules((rule) => {
|
root.walkRules((rule) => {
|
||||||
if (isRelative(rule)) return;
|
if (isRelative(rule)) return;
|
||||||
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
|
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@
|
|||||||
"@typescript-eslint/utils": "^8.61.0",
|
"@typescript-eslint/utils": "^8.61.0",
|
||||||
"@use-gesture/react": "^10.2.11",
|
"@use-gesture/react": "^10.2.11",
|
||||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||||
"@vector-im/compound-web": "^10.0.0",
|
"@vector-im/compound-web": "^10.3.0",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"@vitest/browser-playwright": "^4.1.5",
|
"@vitest/browser-playwright": "^4.1.5",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
|||||||
@@ -129,7 +129,10 @@ async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
|
|||||||
.getByRole("button", { name: "Microphone" })
|
.getByRole("button", { name: "Microphone" })
|
||||||
.click({ timeout: 60_000 });
|
.click({ timeout: 60_000 });
|
||||||
await expect(page.getByRole("menu")).toBeVisible();
|
await expect(page.getByRole("menu")).toBeVisible();
|
||||||
const list = page.locator("[role='menu'] div[role='none']").first();
|
// The area of the menu holding the device groups, not the menu's own.
|
||||||
|
const list = page.locator(
|
||||||
|
"[role='menu'] div[role='none']:has(> [role='group'])",
|
||||||
|
);
|
||||||
await expect(list).toBeVisible();
|
await expect(list).toBeVisible();
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,25 +125,19 @@ test("leaves the host's own page unstyled", async ({ page }) => {
|
|||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Element Call's stylesheet is written for a page of its own: normalize.css
|
// Compound's document styles reach the host, but it loads them itself.
|
||||||
// gives `html` a line height, Compound gives `body` its font and feature
|
|
||||||
// settings, and the design tokens live on `:root`. None of that may reach the
|
|
||||||
// host's document — the harness sets none of these itself, so anything other
|
|
||||||
// than the browser's defaults here came from us.
|
|
||||||
const host = await page.evaluate(() => {
|
const host = await page.evaluate(() => {
|
||||||
const html = getComputedStyle(document.documentElement);
|
const sub = document.body.appendChild(document.createElement("sub"));
|
||||||
const body = getComputedStyle(document.body);
|
const verticalAlign = getComputedStyle(sub).verticalAlign;
|
||||||
|
sub.remove();
|
||||||
return {
|
return {
|
||||||
lineHeight: html.lineHeight,
|
verticalAlign,
|
||||||
fontFeatureSettings: body.fontFeatureSettings,
|
token: getComputedStyle(document.documentElement).getPropertyValue(
|
||||||
token: html.getPropertyValue("--cpd-color-text-primary"),
|
"--font-scale",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(host).toEqual({
|
expect(host).toEqual({ verticalAlign: "sub", token: "" });
|
||||||
lineHeight: "normal",
|
|
||||||
fontFeatureSettings: "normal",
|
|
||||||
token: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// While inside the container, the same rules do apply
|
// While inside the container, the same rules do apply
|
||||||
const root = panes.first().locator("[data-element-call-root]");
|
const root = panes.first().locator("[data-element-call-root]");
|
||||||
|
|||||||
Generated
+6
-6
@@ -134,8 +134,8 @@ importers:
|
|||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.0(@types/react@19.2.18)(react@19.2.8)
|
version: 11.1.0(@types/react@19.2.18)(react@19.2.8)
|
||||||
'@vector-im/compound-web':
|
'@vector-im/compound-web':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.3.0
|
||||||
version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 10.3.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||||
@@ -3240,13 +3240,13 @@ packages:
|
|||||||
react:
|
react:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vector-im/compound-web@10.1.0':
|
'@vector-im/compound-web@10.3.0':
|
||||||
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
|
resolution: {integrity: sha512-tce5hsNpdvYXfB5nYQgTK7WjTDqjdxpV1BeGsw67gYAWSFKJr9p6X5gzE79o+Tp/q5uykLuO4ozTBdjLsmUWMw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@fontsource/inconsolata': ^5
|
'@fontsource/inconsolata': ^5
|
||||||
'@fontsource/inter': ^5
|
'@fontsource/inter': ^5
|
||||||
'@types/react': '*'
|
'@types/react': '*'
|
||||||
'@vector-im/compound-design-tokens': '>=1.6.1 <11.0.0'
|
'@vector-im/compound-design-tokens': '>=1.6.1 <12.0.0'
|
||||||
react: ^18 || ^19.0.0
|
react: ^18 || ^19.0.0
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
@@ -8672,7 +8672,7 @@ snapshots:
|
|||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
|
|
||||||
'@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
'@vector-im/compound-web@10.3.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ exports[`AppBar > renders 1`] = `
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_indicator-icon_147l5_17"
|
class="_indicator-icon_j097f_17"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -58,7 +58,7 @@ exports[`AppBar > renders with title and subtitle 1`] = `
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_indicator-icon_147l5_17"
|
class="_indicator-icon_j097f_17"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -36,25 +36,17 @@ Please see LICENSE in the repository root for full details.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The width design sets: a long device name wraps. */
|
||||||
.menu {
|
.menu {
|
||||||
display: flex;
|
inline-size: 296px;
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only the device lists scroll, bounded by the measured call area: the menu is
|
/* Its share of the measured call area: the menu is portalled out of the root,
|
||||||
portalled out of the root, so CSS can't size it against the call. */
|
so CSS can't size it against the call. */
|
||||||
.deviceList {
|
.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);
|
max-block-size: var(--device-list-max-height);
|
||||||
/* Keeps a row reached by keyboard clear of the sticky heading and meter. */
|
/* Keeps a row reached by keyboard clear of the sticky heading. */
|
||||||
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
|
scroll-padding-block-start: var(--device-list-scroll-padding-start, 0);
|
||||||
var(--device-list-scroll-padding-end, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compound's heading rule heads each section; the design adds space below it. */
|
/* Compound's heading rule heads each section; the design adds space below it. */
|
||||||
@@ -67,21 +59,38 @@ Please see LICENSE in the repository root for full details.
|
|||||||
margin-block-start: var(--cpd-space-7x);
|
margin-block-start: var(--cpd-space-7x);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky within its own section. Opaque and inset a border width so it doesn't
|
/* Sticky within its own section, and opaque so rows scroll under it. */
|
||||||
paint over the menu's outline. */
|
|
||||||
.sectionHeading {
|
.sectionHeading {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
background: var(--cpd-color-bg-canvas-default);
|
background: var(--cpd-color-bg-canvas-default);
|
||||||
margin-block-start: var(--cpd-border-width-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky at the foot of the microphone section; opaque and inset as above. */
|
/* Sticky at the foot of the microphone section; opaque as above. */
|
||||||
.stickyMeter {
|
.stickyMeter {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
inset-block-end: 0;
|
inset-block-end: 0;
|
||||||
background: var(--cpd-color-bg-canvas-default);
|
background: var(--cpd-color-bg-canvas-default);
|
||||||
margin-block-end: var(--cpd-border-width-1);
|
}
|
||||||
|
|
||||||
|
/* Fills the band between the meter and the frame, or rows show through it. */
|
||||||
|
.stickyMeter::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
inset-block-start: 100%;
|
||||||
|
block-size: var(--cpd-menu-padding-block-end, 0px);
|
||||||
|
background: var(--cpd-color-bg-canvas-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Not Compound's fade: that stays at the edge while a bounce carries the meter.
|
||||||
|
This one starts at the chevron, below the meter at rest. */
|
||||||
|
.deviceList.deviceListWithMeter[data-more-below] {
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#000 calc(100% - var(--cpd-space-6x)),
|
||||||
|
transparent calc(100% - var(--cpd-space-3x))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Radix focuses whatever the pointer is over, so the browser's ring would
|
/* Radix focuses whatever the pointer is over, so the browser's ring would
|
||||||
|
|||||||
@@ -469,17 +469,6 @@ export const ManyDevices: Story = {
|
|||||||
.getBoundingClientRect();
|
.getBoundingClientRect();
|
||||||
await expect(frame.top).toBeGreaterThanOrEqual(0);
|
await expect(frame.top).toBeGreaterThanOrEqual(0);
|
||||||
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
|
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
|
||||||
|
|
||||||
// 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);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -647,13 +636,6 @@ export const HeadingsStayWhileScrolling: Story = {
|
|||||||
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
|
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
|
||||||
scrollport.top,
|
scrollport.top,
|
||||||
);
|
);
|
||||||
// Clear of the menu's frame.
|
|
||||||
const frame = document.body
|
|
||||||
.querySelector("[role='menu']")!
|
|
||||||
.getBoundingClientRect();
|
|
||||||
await expect(heading.getBoundingClientRect().left).toBeGreaterThan(
|
|
||||||
frame.left,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,6 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
|
|||||||
import { type MediaDevices } from "../state/MediaDevices";
|
import { type MediaDevices } from "../state/MediaDevices";
|
||||||
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
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 {
|
interface RenderOptions {
|
||||||
requestDeviceNames: () => void;
|
requestDeviceNames: () => void;
|
||||||
}
|
}
|
||||||
@@ -157,28 +149,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
expect(onMute).not.toHaveBeenCalled();
|
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 () => {
|
test("requests device names when opened", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const requestDeviceNames = vi.fn();
|
const requestDeviceNames = vi.fn();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
MenuScrollArea,
|
||||||
MenuTitle,
|
MenuTitle,
|
||||||
RadioInput,
|
RadioInput,
|
||||||
ToggleMenuItem,
|
ToggleMenuItem,
|
||||||
@@ -40,7 +41,6 @@ import { useMediaDevices } from "../MediaDevicesContext";
|
|||||||
import { useRootElement } from "../RootElementContext";
|
import { useRootElement } from "../RootElementContext";
|
||||||
import { observeElementSize$ } from "../utils/elementSize";
|
import { observeElementSize$ } from "../utils/elementSize";
|
||||||
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||||
import { menuIsDrawer } from "./menuIsDrawer";
|
|
||||||
|
|
||||||
export interface MenuOptions {
|
export interface MenuOptions {
|
||||||
label: DeviceLabel | AudioOutputDeviceLabel;
|
label: DeviceLabel | AudioOutputDeviceLabel;
|
||||||
@@ -85,9 +85,6 @@ const LIST_SHARE_OF_CALL = 0.6;
|
|||||||
/** Smallest device list height in px, so a short call still shows more than one device. */
|
/** Smallest device list height in px, so a short call still shows more than one device. */
|
||||||
const MIN_LIST_HEIGHT = 160;
|
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. */
|
/** Space kept between the menu and the call area's sides. */
|
||||||
const MENU_MARGIN = 16;
|
const MENU_MARGIN = 16;
|
||||||
|
|
||||||
@@ -150,27 +147,17 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
// Measured on the call area: CSS can't size the portalled menu against it.
|
// Measured on the call area: CSS can't size the portalled menu against it.
|
||||||
const rootElement = useRootElement();
|
const rootElement = useRootElement();
|
||||||
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
||||||
const [menuWidth, setMenuWidth] = useState(MENU_WIDTH);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
// Followed, since a host can resize the call while the menu is open.
|
// Followed, since a host can resize the call while the menu is open.
|
||||||
const subscription = observeElementSize$(rootElement)
|
const subscription = observeElementSize$(rootElement)
|
||||||
.pipe(
|
.pipe(
|
||||||
map(({ width, height }) => ({
|
map(({ height }) =>
|
||||||
height: Math.max(
|
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
|
||||||
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(({ height, width }) => {
|
.subscribe(setListMaxHeight);
|
||||||
setListMaxHeight(height);
|
|
||||||
setMenuWidth(width);
|
|
||||||
});
|
|
||||||
return (): void => subscription.unsubscribe();
|
return (): void => subscription.unsubscribe();
|
||||||
}, [menuOpen, rootElement]);
|
}, [menuOpen, rootElement]);
|
||||||
|
|
||||||
@@ -343,6 +330,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
open={menuOpen}
|
open={menuOpen}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
side="top"
|
side="top"
|
||||||
|
collisionBoundary={rootElement}
|
||||||
|
collisionPadding={MENU_MARGIN}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
iconOnly
|
iconOnly
|
||||||
@@ -357,21 +346,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<MenuScrollArea
|
||||||
ref={trackFocusSource}
|
ref={trackFocusSource}
|
||||||
// Keeps the items the menu's own children for assistive tech.
|
className={classNames(styles.deviceList, {
|
||||||
role="none"
|
[styles.deviceListWithMeter]: iconsAndLabels === "audio",
|
||||||
className={styles.deviceList}
|
})}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--device-list-max-height":
|
"--device-list-max-height":
|
||||||
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
|
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
|
||||||
// On a phone Compound renders the menu as a drawer, which sets its own width.
|
// A row reached by keyboard stays above the meter and the band its
|
||||||
"--device-list-inline-size": menuIsDrawer()
|
// ground fills.
|
||||||
? undefined
|
scrollPaddingBlockEnd:
|
||||||
: `${menuWidth}px`,
|
meterHeight === undefined
|
||||||
"--device-list-scroll-padding-end":
|
? undefined
|
||||||
meterHeight === undefined ? undefined : `${meterHeight}px`,
|
: `calc(${meterHeight}px + var(--cpd-menu-padding-block-end, 0px))`,
|
||||||
"--device-list-scroll-padding-start":
|
"--device-list-scroll-padding-start":
|
||||||
headingHeight === undefined ? undefined : `${headingHeight}px`,
|
headingHeight === undefined ? undefined : `${headingHeight}px`,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
@@ -419,7 +408,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</MenuScrollArea>
|
||||||
{toggles.length > 0 && <hr />}
|
{toggles.length > 0 && <hr />}
|
||||||
{toggles.map((toggle) => (
|
{toggles.map((toggle) => (
|
||||||
<ToggleMenuItem
|
<ToggleMenuItem
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ Please see LICENSE in the repository root for full details.
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--cpd-space-3x);
|
gap: var(--cpd-space-3x);
|
||||||
padding-block: var(--cpd-space-2x);
|
padding-block: var(--cpd-space-2x);
|
||||||
/* Centres the icon on the radio column: less the row's inset border and half
|
/* Centres the icon on the radio column: less half the icon's extra width.
|
||||||
the icon's extra width. Checked in the MeterAlignsWithTheDeviceRows story. */
|
Checked in the MeterAlignsWithTheDeviceRows story. */
|
||||||
padding-inline-start: calc(
|
padding-inline-start: calc(
|
||||||
var(--cpd-space-4x) - var(--cpd-border-width-1) -
|
var(--cpd-space-4x) -
|
||||||
(var(--meter-icon-size) - var(--device-control-size)) / 2
|
(var(--meter-icon-size) - var(--device-control-size)) / 2
|
||||||
);
|
);
|
||||||
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
|
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
/*
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
@@ -181,7 +181,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_indicator-icon_147l5_17"
|
class="_indicator-icon_j097f_17"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_indicator-icon_147l5_17"
|
class="_indicator-icon_j097f_17"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -111,7 +111,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_indicator-icon_147l5_17"
|
class="_indicator-icon_j097f_17"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -247,7 +247,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_indicator-icon_147l5_17"
|
class="_indicator-icon_j097f_17"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -343,7 +343,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_indicator-icon_147l5_17"
|
class="_indicator-icon_j097f_17"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -621,7 +621,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="_indicator-icon_147l5_17"
|
class="_indicator-icon_j097f_17"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -37,8 +37,21 @@ export default defineConfig(({ mode }) => {
|
|||||||
...vitePluginsConfig({ mode, html: false }),
|
...vitePluginsConfig({ mode, html: false }),
|
||||||
root: "component/dev",
|
root: "component/dev",
|
||||||
// The same scoping the library build applies, so the harness shows what a
|
// The same scoping the library build applies, so the harness shows what a
|
||||||
// host will get — including whether its own page is left alone
|
// host will get — including whether its own page is left alone. The host's
|
||||||
css: { postcss: { plugins: [scopeStylesToRoot()] } },
|
// own stylesheets, Compound's among them, stay unscoped, as on a real host.
|
||||||
|
css: {
|
||||||
|
postcss: {
|
||||||
|
plugins: [
|
||||||
|
scopeStylesToRoot({
|
||||||
|
exclude: [
|
||||||
|
/component[\\/]dev[\\/]/,
|
||||||
|
/@vector-im[\\/]compound-/,
|
||||||
|
/@fontsource[\\/]/,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
// So that the harness can read the same config.json the standalone app
|
// So that the harness can read the same config.json the standalone app
|
||||||
// does, if the developer has written one
|
// does, if the developer has written one
|
||||||
publicDir: "../../public",
|
publicDir: "../../public",
|
||||||
|
|||||||
Reference in New Issue
Block a user