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,