mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-23 00:02:49 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
ac964cda04
commit
6bb5dad069
4 files changed
+250
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLElement>(selector)!,
|
||||
);
|
||||
return { top, bottom };
|
||||
}
|
||||
const shown = async (fade: HTMLElement, visible: boolean): Promise<void> =>
|
||||
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 => (
|
||||
<WithACallArea blockSize={300}>
|
||||
<Story />
|
||||
</WithACallArea>
|
||||
),
|
||||
],
|
||||
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<HTMLElement>(
|
||||
`.${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<HTMLElement>(`.${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<HTMLElement>(
|
||||
`.${styles.stickyMeter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
});
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${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: {
|
||||
|
||||
@@ -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(
|
||||
<Profiler
|
||||
id="menu"
|
||||
onRender={(): void => {
|
||||
commits++;
|
||||
}}
|
||||
>
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
|
||||
selectedOption="cam1"
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={vi.fn()}
|
||||
/>
|
||||
</Profiler>,
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -203,6 +203,18 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
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<number>();
|
||||
@@ -421,7 +433,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={trackFocusSource}
|
||||
ref={watchList}
|
||||
// Keeps the items the menu's own children for assistive tech.
|
||||
role="none"
|
||||
className={styles.deviceList}
|
||||
@@ -440,6 +452,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(styles.fade, styles.fadeTop)}
|
||||
/>
|
||||
{iconsAndLabels === "audio" && speakerOptions && (
|
||||
<>
|
||||
{/* A menu may only contain items, separators and groups, so each
|
||||
@@ -528,12 +544,60 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
<span>{backgroundEffectNotice}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(styles.fade, styles.fadeBottom)}
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
Reference in new issue
Block a user