diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index e620a180d..092cb23ce 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -535,3 +535,54 @@ Please see LICENSE in the repository root for full details. .selfPreview > .mirrored { transform: scaleX(-1); } + +/* + * A fade at each edge of the list while there is more that way. + * + * In place of the scrollbar, which the platform fades out after a moment: with + * a trackpad on a Mac there was otherwise nothing to say that the list scrolls, + * and it looked as though the backgrounds simply stopped. Sticky, so each holds + * its edge of what is in view, and taking no room: each gives back its own + * height in a margin. Over the content, so the rows and tiles fade into it + * rather than past it, since they are opaque and a background would not show. + * + * The top one stands below whichever heading holds the top of the list, which + * is where the content is coming out from under. + */ +.scrollEdge { + position: sticky; + z-index: 1; + block-size: var(--cpd-space-6x); + pointer-events: none; + opacity: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .scrollEdge { + transition: opacity 150ms ease-out; + } +} + +.scrollEdgeShown { + opacity: 1; +} + +.scrollEdgeTop { + inset-block-start: var(--device-list-stuck-heading-height, 0); + margin-block-end: calc(-1 * var(--cpd-space-6x)); + background: linear-gradient( + to bottom, + var(--cpd-color-bg-canvas-default), + transparent + ); +} + +.scrollEdgeBottom { + inset-block-end: 0; + margin-block-start: calc(-1 * var(--cpd-space-6x)); + background: linear-gradient( + to top, + var(--cpd-color-bg-canvas-default), + transparent + ); +} diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index f5f17a4f1..ecde1aba4 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -1074,6 +1074,67 @@ export const BackgroundEffectsHeadingStaysOverTheGrid: Story = { }, }; +/** + * The list says there is more, at whichever edge there is more. The platform + * fades its scrollbar out after a moment, and with a trackpad on a Mac there + * was then nothing to say the list scrolled — the backgrounds seemed to stop. + */ +export const BackgroundEffectsShowThereIsMore: Story = { + args: BackgroundEffectsHeadingStaysOverTheGrid.args, + 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(`.${styles.deviceList}`)!; + const top = list.querySelector(`.${styles.scrollEdgeTop}`)!; + const bottom = list.querySelector( + `.${styles.scrollEdgeBottom}`, + )!; + const shown = (edge: HTMLElement): boolean => + edge.classList.contains(styles.scrollEdgeShown); + + // Opened at the top: more below, nothing above. + await waitFor(async () => expect(shown(bottom)).toBe(true)); + await expect(shown(top)).toBe(false); + // And the fade is at the foot of what is in view, not of the content. + await expect( + Math.round( + list.getBoundingClientRect().bottom - + bottom.getBoundingClientRect().bottom, + ), + ).toBeLessThanOrEqual(1); + + // At the end: nothing below, more above. + list.scrollTop = list.scrollHeight; + await waitFor(async () => expect(shown(bottom)).toBe(false)); + await waitFor(async () => expect(shown(top)).toBe(true)); + + // Standing just below the heading holding the top, where the content comes + // out from under it — not behind the heading, where it could not be seen. + const headings = list.querySelectorAll( + `.${styles.sectionHeading}`, + ); + const stuck = [...headings].find( + (h) => + Math.abs( + h.getBoundingClientRect().top - list.getBoundingClientRect().top, + ) < 2, + )!; + await expect(stuck).toBeDefined(); + await waitFor(async () => + expect( + Math.round( + top.getBoundingClientRect().top - + stuck.getBoundingClientRect().bottom, + ), + ).toBe(0), + ); + }, +}; + /** * The sequence a user sees on the first effect of a session. * diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 1e7ef4f48..83ecc68ac 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -14,6 +14,7 @@ import { useRef, type ReactElement, type ReactNode, + useLayoutEffect, } from "react"; import { Alert, @@ -333,6 +334,62 @@ export const MediaMuteAndSwitchButton: FC = ({ return (): void => subscription.unsubscribe(); }, [menuOpen, rootElement]); + // Whether the list has more to show above or below what is in view, and how + // tall the heading stuck at its top is. Said with a fade at each edge rather + // than left to the scrollbar, which the platform fades away after a moment — + // with a trackpad on a Mac there is otherwise nothing to say the list scrolls + // at all. Worked out from the list itself, on scrolling, on resizing and + // after every render, since adding or removing a background changes its + // length without doing either. + const [listElement, setListElement] = useState(null); + const listRef = useCallback( + (list: HTMLDivElement | null): (() => void) | undefined => { + setListElement(list); + return trackFocusModality(list); + }, + [trackFocusModality], + ); + const [edges, setEdges] = useState({ above: false, below: false, stuck: 0 }); + const measureEdges = useCallback((): void => { + if (listElement === null) return; + const above = listElement.scrollTop > 0; + const below = + listElement.scrollTop + listElement.clientHeight < + listElement.scrollHeight - 1; + // The heading holding the top is the one at the list's own top edge, give + // or take the border width it keeps clear of the frame. + const top = listElement.getBoundingClientRect().top; + let stuck = 0; + if (above) + for (const heading of listElement.querySelectorAll( + `.${styles.sectionHeading}`, + )) { + const box = heading.getBoundingClientRect(); + // How far down it reaches, to the fraction: a heading's line height + // lands it on half pixels, and rounding left the fade half a pixel + // short of it or over it. + if (Math.abs(box.top - top) < 2) stuck = box.bottom - top; + } + setEdges((previous) => + previous.above === above && + previous.below === below && + previous.stuck === stuck + ? previous + : { above, below, stuck }, + ); + }, [listElement]); + useLayoutEffect(measureEdges); + useEffect(() => { + if (listElement === null) return; + listElement.addEventListener("scroll", measureEdges, { passive: true }); + const subscription = + observeElementSize$(listElement).subscribe(measureEdges); + return (): void => { + listElement.removeEventListener("scroll", measureEdges); + subscription.unsubscribe(); + }; + }, [listElement, measureEdges]); + const [cameraMenuWidth, setCameraMenuWidth] = useState(CAMERA_MENU_WIDTH); useEffect(() => { if (!menuOpen || iconsAndLabels !== "video") return; @@ -812,7 +869,7 @@ export const MediaMuteAndSwitchButton: FC = ({ beneath it, because it is what the choosing below is for. */} {previewPinned && preview}
= ({ meterHeight === undefined ? undefined : `${meterHeight}px`, "--device-list-scroll-padding-start": headingHeight === undefined ? undefined : `${headingHeight}px`, + "--device-list-stuck-heading-height": `${edges.stuck}px`, } as CSSProperties } > + {iconsAndLabels === "video" && ( +
+ )} {hasPreview && !previewPinned && preview} {iconsAndLabels === "audio" && speakerOptions && ( <> @@ -894,6 +960,18 @@ export const MediaMuteAndSwitchButton: FC = ({
)} + {iconsAndLabels === "video" && ( +
+ )}
{backgroundEffectError !== undefined && !refusalSeen && (