mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-25 22:35:49 +00:00
Show that the camera menu scrolls, whatever the scrollbar does
- 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 simply to stop. Keeping the scrollbar would mean drawing our own, overriding a setting the user chose, and still not working in Firefox. - Instead a fade at each edge of the list while there is more that way, as iOS, Android and Material do: the rows and tiles fade into it at the foot, and at the top it stands just below whichever heading is stuck there, where the content comes out from under it. - Worked out from the list on scrolling, on resizing and after every render, since adding or removing a background changes its length without either. - The camera menu only: in the microphone menu the level meter holds the foot of the list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>(`.${styles.deviceList}`)!;
|
||||
const top = list.querySelector<HTMLElement>(`.${styles.scrollEdgeTop}`)!;
|
||||
const bottom = list.querySelector<HTMLElement>(
|
||||
`.${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<HTMLElement>(
|
||||
`.${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.
|
||||
*
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -333,6 +334,62 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
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<HTMLDivElement | null>(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<HTMLElement>(
|
||||
`.${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<MediaMuteAndSwitchButtonProps> = ({
|
||||
beneath it, because it is what the choosing below is for. */}
|
||||
{previewPinned && preview}
|
||||
<div
|
||||
ref={trackFocusModality}
|
||||
ref={listRef}
|
||||
// Transparent to assistive technology, so the menu still sees its
|
||||
// items as its own children.
|
||||
role="none"
|
||||
@@ -831,9 +888,18 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
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" && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(styles.scrollEdge, styles.scrollEdgeTop, {
|
||||
[styles.scrollEdgeShown]: edges.above,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{hasPreview && !previewPinned && preview}
|
||||
{iconsAndLabels === "audio" && speakerOptions && (
|
||||
<>
|
||||
@@ -894,6 +960,18 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{iconsAndLabels === "video" && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
styles.scrollEdge,
|
||||
styles.scrollEdgeBottom,
|
||||
{
|
||||
[styles.scrollEdgeShown]: edges.below,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{backgroundEffectError !== undefined &&
|
||||
!refusalSeen && (
|
||||
|
||||
Reference in New Issue
Block a user