From 6bb5dad069bf21f8b3afc28c8f7c6255a5743ef4 Mon Sep 17 00:00:00 2001 From: fkwp Date: Thu, 24 Sep 2026 19:14:04 +0200 Subject: [PATCH] Show at each edge of the device menus that there is more - Where the list scrolls, a fade at the top and the bottom says there is more that way, and goes at the end. The platform may hide its scrollbar until it is used, and then nothing else says the list scrolls. - Both menus. The top fade sits under the heading stuck at the top; in the microphone menu the bottom one stands just above the level meter while it holds the foot of the list, which its story fails without. - Measured from the list on scroll and on resize, and written to the element as attributes and variables rather than rendered, since it changes with every scroll. A test scrolls the list and counts the commits: none. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../MediaMuteAndSwitchButton.module.css | 35 ++++++ .../MediaMuteAndSwitchButton.stories.tsx | 112 ++++++++++++++++++ .../MediaMuteAndSwitchButton.test.tsx | 38 ++++++ src/components/MediaMuteAndSwitchButton.tsx | 66 ++++++++++- 4 files changed, 250 insertions(+), 1 deletion(-) diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index 6450440db..d5879219a 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -120,3 +120,38 @@ Please see LICENSE in the repository root for full details. margin: var(--cpd-space-2x) calc(var(--cpd-space-4x) - var(--cpd-border-width-1)) 0; } + +/* Says there is more at an edge, whether or not a scrollbar shows. */ +.fade { + position: sticky; + z-index: 1; + block-size: var(--cpd-space-6x); + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease-out; +} + +.fadeTop { + inset-block-start: var(--device-list-fade-start, 0); + margin-block-end: calc(-1 * var(--cpd-space-6x)); + background: linear-gradient( + to bottom, + var(--cpd-color-bg-canvas-default), + transparent + ); +} + +.fadeBottom { + inset-block-end: var(--device-list-fade-end, 0); + margin-block-start: calc(-1 * var(--cpd-space-6x)); + background: linear-gradient( + to top, + var(--cpd-color-bg-canvas-default), + transparent + ); +} + +.deviceList[data-more-above] > .fadeTop, +.deviceList[data-more-below] > .fadeBottom { + opacity: 1; +} diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index 1829a4fdb..eeeffb8f4 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -495,6 +495,118 @@ export const RemovingWithLiveSelection: Story = { }, }; +/** The fade at each edge of the device list. */ +function fades(): { top: HTMLElement; bottom: HTMLElement } { + const [top, bottom] = [`.${styles.fadeTop}`, `.${styles.fadeBottom}`].map( + (selector) => document.body.querySelector(selector)!, + ); + return { top, bottom }; +} +const shown = async (fade: HTMLElement, visible: boolean): Promise => + waitFor(async () => + expect(getComputedStyle(fade).opacity).toBe(visible ? "1" : "0"), + ); + +/** Where the options scroll, each edge says there is more that way. */ +export const BackgroundEffectsShowThereIsMore: Story = { + args: VideoUnmute.args, + decorators: [ + (Story): JSX.Element => ( + + + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Camera" })); + await within(document.body).findByRole("group", { + name: "Background effects", + }); + const list = document.body.querySelector( + `.${styles.deviceList}`, + )!; + const { top, bottom } = fades(); + + await shown(top, false); + await shown(bottom, true); + + list.scrollTop = 40; + await shown(top, true); + await shown(bottom, true); + const stuck = [ + ...list.querySelectorAll(`.${styles.sectionHeading}`), + ].find( + (h) => + Math.abs( + h.getBoundingClientRect().top - list.getBoundingClientRect().top, + ) < 2, + )!; + await expect(top.getBoundingClientRect().top).toBeCloseTo( + stuck.getBoundingClientRect().bottom, + 0, + ); + + list.scrollTop = list.scrollHeight; + await shown(top, true); + await shown(bottom, false); + }, +}; + +/** The microphone menu too; above the meter while the meter holds the foot. */ +export const MicrophoneMenuShowsThereIsMore: Story = { + args: { + ...Default.args, + iconsAndLabels: "audio", + enabled: true, + options: Array.from({ length: 20 }, (_, i) => ({ + label: { type: "name" as const, name: `Microphone ${i + 1}` }, + id: `mic${i + 1}`, + })), + selectedOption: "mic1", + outputOptions: Array.from({ length: 6 }, (_, i) => ({ + label: { type: "name" as const, name: `Speaker ${i + 1}` }, + id: `spk${i + 1}`, + })), + selectedOutputOption: "spk1", + onSelectOutput: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); + const meter = await waitFor(() => { + const element = document.body.querySelector( + `.${styles.stickyMeter}`, + ); + if (element === null) throw new Error("the meter has not rendered yet"); + return element; + }); + const list = document.body.querySelector( + `.${styles.deviceList}`, + )!; + const { top, bottom } = fades(); + await shown(bottom, true); + + // Into the microphones, where the meter holds the foot of the list. + const group = within(document.body).getByRole("group", { + name: "Microphone", + }); + list.scrollTop += + group.getBoundingClientRect().top - list.getBoundingClientRect().top; + await shown(top, true); + await shown(bottom, true); + await waitFor(async () => + expect(bottom.getBoundingClientRect().bottom).toBeCloseTo( + meter.getBoundingClientRect().top, + 0, + ), + ); + + list.scrollTop = list.scrollHeight; + await shown(bottom, false); + }, +}; + /** In a short call the effects scroll into view with the list. */ export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = { args: { diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index 437dab0a0..56bc68e93 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -754,6 +754,44 @@ describe("MediaMuteAndSwitchButton", () => { expect(commits - settled).toBe(0); }); + test("says there is more at the edges without re-rendering anything", async () => { + const user = userEvent.setup(); + let commits = 0; + const { getByRole } = renderComponent( + { + commits++; + }} + > + + , + ); + await user.click(getByRole("button", { name: "Camera" })); + const list = screen.getByRole("group", { name: "Camera" }).parentElement!; + // jsdom lays nothing out, so the list is given a length to scroll. + Object.defineProperty(list, "scrollHeight", { value: 600 }); + Object.defineProperty(list, "clientHeight", { value: 200 }); + await act(async () => {}); + const settled = commits; + + for (const top of [0, 100, 400]) { + list.scrollTop = top; + list.dispatchEvent(new Event("scroll")); + expect(list.hasAttribute("data-more-above")).toBe(top > 0); + expect(list.hasAttribute("data-more-below")).toBe(top < 400); + } + expect(commits - settled).toBe(0); + }); + test("camera menu uses the same selection pattern and keeps the blur toggle", async () => { const user = userEvent.setup(); const { getByRole } = renderComponent( diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 83182286e..08cf070bd 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -203,6 +203,18 @@ export const MediaMuteAndSwitchButton: FC = ({ [], ); + const watchList = useCallback( + (list: HTMLDivElement | null): (() => void) | undefined => { + const stopTracking = trackFocusSource(list); + const stopWatching = list === null ? undefined : watchScrollEdges(list); + return (): void => { + stopTracking?.(); + stopWatching?.(); + }; + }, + [trackFocusSource], + ); + // Measured on the call area: CSS can't size the portalled menu against it. const rootElement = useRootElement(); const [listMaxHeight, setListMaxHeight] = useState(); @@ -421,7 +433,7 @@ export const MediaMuteAndSwitchButton: FC = ({ } >
= ({ } as CSSProperties } > +
{iconsAndLabels === "audio" && speakerOptions && ( <> {/* A menu may only contain items, separators and groups, so each @@ -528,12 +544,60 @@ export const MediaMuteAndSwitchButton: FC = ({ {backgroundEffectNotice}
)} +
); }; +/** + * Marks on the list whether it has more above and below, for the fades at its + * edges: the platform may hide its scrollbar until it is used. Set on the + * element, not rendered, as it changes with every scroll. + */ +function watchScrollEdges(list: HTMLElement): () => void { + const measure = (): void => { + const box = list.getBoundingClientRect(); + const above = list.scrollTop > 0; + // Under the heading stuck at the top, and above the meter at the foot. + let start = 0; + if (above) + for (const heading of list.querySelectorAll( + `.${styles.sectionHeading}`, + )) { + const edge = heading.getBoundingClientRect(); + if (Math.abs(edge.top - box.top) < 2) start = edge.bottom - box.top; + } + let end = 0; + const meter = list.querySelector(`.${styles.stickyMeter}`); + if (meter !== null) { + const edge = meter.getBoundingClientRect(); + if (Math.abs(edge.bottom - box.bottom) < 2) end = edge.height; + } + list.toggleAttribute("data-more-above", above); + list.toggleAttribute( + "data-more-below", + list.scrollTop + list.clientHeight < list.scrollHeight - 1, + ); + list.style.setProperty("--device-list-fade-start", `${start}px`); + list.style.setProperty("--device-list-fade-end", `${end}px`); + }; + measure(); + list.addEventListener("scroll", measure, { passive: true }); + // The list's own size, and its sections', which change its length. + const resizes = new ResizeObserver(measure); + resizes.observe(list); + for (const section of list.children) resizes.observe(section); + return (): void => { + list.removeEventListener("scroll", measure); + resizes.disconnect(); + }; +} + /** Follows an element's height. */ function useMeasuredHeight(): [ number | undefined,