Keep the headings over the grid, and the preview where there is no room

- The effect tiles are positioned, to carry their tick and the remove
  cross, and positioned content painted after a sticky heading slid over
  it: the grid ran across "Background effects" instead of under it. The
  headings now have a layer of their own.
- Where the call is too short to pin the preview, it is no longer dropped:
  it goes first into the list and scrolls away with the devices and the
  effects, seen on opening and out of the way once choosing.
- The list moves up over the menu's padding to bring it flush with the
  top, since a scrolling container cuts off what is pulled above it, and
  rounds its top corners with the frame's so a stuck heading cannot show
  past the curve.
- Guarded: the heading is what is on top where it stands, failing without
  the fix; and the preview sits inside the list and scrolls away.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-23 12:07:46 +02:00
co-authored by Claude Opus 5.5
parent efe6274636
commit b7ca586f78
3 changed files with 140 additions and 34 deletions
@@ -106,6 +106,11 @@ Please see LICENSE in the repository root for full details.
.sectionHeading { .sectionHeading {
position: sticky; position: sticky;
inset-block-start: 0; inset-block-start: 0;
/* Over what scrolls beneath it. Without a layer of its own it is painted in
order with everything else positioned, and the effect tiles — positioned,
to carry their tick and the remove cross — come after it, so the grid
slid over the heading instead of under it. */
z-index: 1;
background: var(--cpd-color-bg-canvas-default); background: var(--cpd-color-bg-canvas-default);
/* Clear of the frame's top edge; the list it sits in already keeps it clear /* Clear of the frame's top edge; the list it sits in already keeps it clear
of the sides. */ of the sides. */
@@ -472,10 +477,6 @@ Please see LICENSE in the repository root for full details.
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-block-start: calc(
-1 * var(--cpd-space-5x) + var(--cpd-border-width-1)
);
margin-inline: var(--cpd-border-width-1);
margin-block-end: var(--cpd-space-2x); margin-block-end: var(--cpd-space-2x);
block-size: 166px; block-size: 166px;
overflow: hidden; overflow: hidden;
@@ -489,6 +490,33 @@ Please see LICENSE in the repository root for full details.
color: var(--cpd-color-icon-secondary); color: var(--cpd-color-icon-secondary);
} }
/* Above the list: it takes the menu's own top padding, which is why it pulls
itself up over it, and it keeps clear of the frame's sides itself, since it
is not inside the list that otherwise does. */
.selfPreviewPinned {
margin-block-start: calc(
-1 * var(--cpd-space-5x) + var(--cpd-border-width-1)
);
margin-inline: var(--cpd-border-width-1);
}
/* Inside the list, where there was no room to pin it. A scrolling container
cuts off whatever is pulled above its top, so it is the list that moves up
over the menu's padding instead, to bring the preview flush with the top.
That puts the list's corners where the frame's are rounded, so they are
rounded too: square, a stuck heading would show past the curve. */
.deviceListLeadsWithPreview {
margin-block-start: calc(
-1 * var(--cpd-space-5x) + var(--cpd-border-width-1)
);
border-start-start-radius: calc(
var(--cpd-space-3x) - var(--cpd-border-width-1)
);
border-start-end-radius: calc(
var(--cpd-space-3x) - var(--cpd-border-width-1)
);
}
/* Laid over the box rather than in it. In flow, a sixteen-by-nine picture /* Laid over the box rather than in it. In flow, a sixteen-by-nine picture
166 pixels tall asks for 295 of width, and the menu, sized to its widest 166 pixels tall asks for 295 of width, and the menu, sized to its widest
content, grew by the pixel that asked for. Out of flow it only fills what content, grew by the pixel that asked for. Out of flow it only fills what
@@ -986,11 +986,12 @@ export const BackgroundEffectsWithPreview: Story = {
}; };
/** /**
* No room: a call area as short as a laptop browser often leaves. The list * No room to pin the preview: a call area as short as a laptop browser often
* could not keep its floor after paying for the preview, so there is none — * leaves. It is still there, but first in the list rather than above it, and
* the backgrounds and the devices matter more than a picture of the user. * it scrolls away with the devices and the effects — seen on opening, out of
* the way once the user is choosing.
*/ */
export const BackgroundEffectsNoRoomForPreview: Story = { export const BackgroundEffectsPreviewScrollsWithTheList: Story = {
args: BackgroundEffectsWithPreview.args, args: BackgroundEffectsWithPreview.args,
parameters: { callAreaHeight: 470 }, parameters: { callAreaHeight: 470 },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@@ -999,13 +1000,77 @@ export const BackgroundEffectsNoRoomForPreview: Story = {
await within(document.body).findByRole("menuitemradio", { name: "Blur" }); await within(document.body).findByRole("menuitemradio", { name: "Blur" });
const menu = document.body.querySelector("[role='menu']")!; const menu = document.body.querySelector("[role='menu']")!;
await expect(
menu.querySelector(`.${styles.selfPreview}`),
).not.toBeInTheDocument();
const list = menu.querySelector<HTMLElement>(`.${styles.deviceList}`)!; const list = menu.querySelector<HTMLElement>(`.${styles.deviceList}`)!;
const preview = menu.querySelector<HTMLElement>(`.${styles.selfPreview}`)!;
await expect(list.contains(preview)).toBe(true);
// Flush with the top and the sides, the same as when it is pinned.
const frame = menu.getBoundingClientRect();
const box = preview.getBoundingClientRect();
await expect(box.top - frame.top).toBeLessThanOrEqual(2);
await expect(box.left - frame.left).toBeLessThanOrEqual(2);
await expect(frame.right - box.right).toBeLessThanOrEqual(2);
// The list keeps its whole share, the preview being inside it.
await expect(getComputedStyle(list).maxBlockSize).toBe( await expect(getComputedStyle(list).maxBlockSize).toBe(
`${Math.round(470 * 0.6)}px`, `${Math.round(470 * 0.6)}px`,
); );
// And it scrolls away.
list.scrollTop = 200;
await waitFor(async () =>
expect(preview.getBoundingClientRect().top).toBeLessThan(box.top - 150),
);
},
};
/**
* A heading stays over what scrolls beneath it. The tiles are positioned, to
* carry their tick and the remove cross, and positioned content painted after
* the heading slid over it: the grid ran across the "Background effects"
* title instead of under it.
*/
export const BackgroundEffectsHeadingStaysOverTheGrid: Story = {
args: {
...BackgroundEffectsWithPreview.args,
backgroundEffects: [
...backgroundEffects,
...[1, 2, 3, 4].map((n) => ({
id: `added:${n}`,
label: `Background ${n + 2}`,
kind: "image" as const,
imageUrl: swatch("#1d4ed8", "#172554"),
removable: true,
})),
],
onRemoveBackgroundEffect: fn(),
},
parameters: { callAreaHeight: 400 },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
await within(document.body).findByRole("menuitemradio", { name: "Blur" });
const menu = document.body.querySelector("[role='menu']")!;
const list = menu.querySelector<HTMLElement>(`.${styles.deviceList}`)!;
const headings = menu.querySelectorAll<HTMLElement>(
`.${styles.sectionHeading}`,
);
const heading = headings[headings.length - 1];
const grid = menu.querySelector<HTMLElement>(`.${styles.effectGrid}`)!;
// Scroll the grid's first row up under the stuck heading.
list.scrollTop = grid.offsetTop - list.offsetTop + 24;
await waitFor(async () => {
const h = heading.getBoundingClientRect();
const row = grid.getBoundingClientRect();
await expect(row.top).toBeLessThan(h.bottom);
});
// What is on top where the heading is, is the heading.
const h = heading.getBoundingClientRect();
const hit = document.elementFromPoint(h.left + 40, h.top + h.height / 2);
await expect(heading.contains(hit)).toBe(true);
}, },
}; };
+36 -23
View File
@@ -717,21 +717,43 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
return tiles; return tiles;
}; };
// The preview is paid for out of the list's share, and only where the list // The preview is always there in the camera menu; what the call's height
// keeps its floor after paying, so a menu with one is no taller than a menu // decides is whether it stays put. Pinned above the list, it is paid for out
// without — which is what keeps it inside the call area at any size. // of the list's share, and only where the list keeps its floor after paying,
const showPreview = // so a menu with one is no taller than a menu without. Where the list could
// not afford it, it goes into the list instead, first, and scrolls away with
// everything else: seen on opening, out of the way once the user is choosing.
const hasPreview =
iconsAndLabels === "video" && iconsAndLabels === "video" &&
selfPreview !== undefined && selfPreview !== undefined &&
listShare !== undefined && listShare !== undefined;
listShare - PREVIEW_BLOCK >= MIN_LIST_HEIGHT; const previewPinned =
hasPreview && listShare - PREVIEW_BLOCK >= MIN_LIST_HEIGHT;
const listMaxHeight = const listMaxHeight =
listShare === undefined listShare === undefined
? undefined ? undefined
: Math.max( : Math.max(
MIN_LIST_HEIGHT, MIN_LIST_HEIGHT,
listShare - (showPreview ? PREVIEW_BLOCK : 0), listShare - (previewPinned ? PREVIEW_BLOCK : 0),
); );
const preview = hasPreview && (
// Decoration to assistive technology — the choice is announced by the
// items, and a picture of the user tells them nothing new.
<div
aria-hidden
className={classNames(styles.selfPreview, {
[styles.selfPreviewPinned]: previewPinned,
})}
>
{backgroundEffectSettling ? (
// The same wait the pressed tile shows, where the picture will be, so
// the eye does not have to go looking for why it is not.
<InlineSpinner size={32} />
) : (
selfPreview
)}
</div>
);
return ( return (
<div <div
@@ -786,27 +808,17 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/> />
} }
> >
{showPreview && ( {/* Pinned where there is room: the devices and the effects scroll
// Pinned above the list rather than inside it: the devices and the beneath it, because it is what the choosing below is for. */}
// effects scroll beneath it, because it is what the choosing below is {previewPinned && preview}
// for. Decoration to assistive technology — the choice is announced
// by the items, and a picture of the user tells them nothing new.
<div aria-hidden className={styles.selfPreview}>
{backgroundEffectSettling ? (
// The same wait the pressed tile shows, where the picture will
// be, so the eye does not have to go looking for why it is not.
<InlineSpinner size={32} />
) : (
selfPreview
)}
</div>
)}
<div <div
ref={trackFocusModality} ref={trackFocusModality}
// Transparent to assistive technology, so the menu still sees its // Transparent to assistive technology, so the menu still sees its
// items as its own children. // items as its own children.
role="none" role="none"
className={styles.deviceList} className={classNames(styles.deviceList, {
[styles.deviceListLeadsWithPreview]: hasPreview && !previewPinned,
})}
style={ style={
{ {
"--device-list-max-height": "--device-list-max-height":
@@ -822,6 +834,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
} as CSSProperties } as CSSProperties
} }
> >
{hasPreview && !previewPinned && preview}
{iconsAndLabels === "audio" && speakerOptions && ( {iconsAndLabels === "audio" && speakerOptions && (
<> <>
{/* A menu may only contain items, separators and groups, so each {/* A menu may only contain items, separators and groups, so each