Merge pull request #3810 from element-hq/robin/button-accessibility

Improve accessibility of microphone, camera, and screen share buttons
This commit is contained in:
Robin
2026-03-24 17:36:36 +01:00
committed by GitHub
8 changed files with 98 additions and 102 deletions

View File

@@ -22,8 +22,8 @@ test("Start a new call then leave and show the feedback screen", async ({
await expect(page.getByTestId("lobby_joinCall")).toBeVisible(); await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
// Check the button toolbar // Check the button toolbar
// await expect(page.getByRole('button', { name: 'Mute microphone' })).toBeVisible(); // await expect(page.getByRole('switch', { name: 'Mute microphone' })).toBeVisible();
// await expect(page.getByRole('button', { name: 'Stop video' })).toBeVisible(); // await expect(page.getByRole('switch', { name: 'Stop video' })).toBeVisible();
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
await expect(page.getByRole("button", { name: "End call" })).toBeVisible(); await expect(page.getByRole("button", { name: "End call" })).toBeVisible();

View File

@@ -49,12 +49,12 @@ test("can only interact with header and footer while reconnecting", async ({
).toBeVisible(); ).toBeVisible();
// Tab order should jump directly from header to footer, skipping media tiles // Tab order should jump directly from header to footer, skipping media tiles
await page.getByRole("button", { name: "Mute microphone" }).focus(); await page.getByRole("switch", { name: "Mute microphone" }).focus();
await expect( await expect(
page.getByRole("button", { name: "Mute microphone" }), page.getByRole("switch", { name: "Mute microphone" }),
).toBeFocused(); ).toBeFocused();
await page.keyboard.press("Tab"); await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused(); await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button // Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click(); await page.getByRole("button", { name: "End call" }).click();
}); });

View File

@@ -55,13 +55,10 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
const frame = user.page const frame = user.page
.locator('iframe[title="Element Call"]') .locator('iframe[title="Element Call"]')
.contentFrame(); .contentFrame();
// No lobby, should start with video on // No lobby, should start with video on
// The only way to know if it is muted or not is to look at the data-kind attribute.. await expect(
const videoButton = frame.getByTestId("incall_videomute"); frame.getByRole("switch", { name: "Stop video", checked: true }),
await expect(videoButton).toBeVisible(); ).toBeVisible();
// video should be on
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
} }
// We should see 5 video tiles everywhere now // We should see 5 video tiles everywhere now
@@ -101,13 +98,15 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
const florianFrame = florian.page const florianFrame = florian.page
.locator('iframe[title="Element Call"]') .locator('iframe[title="Element Call"]')
.contentFrame(); .contentFrame();
const florianMuteButton = florianFrame.getByTestId("incall_videomute"); const florianVideoButton = florianFrame.getByRole("switch", {
await florianMuteButton.click(); name: /video/,
});
await expect(florianVideoButton).toHaveAccessibleName("Stop video");
await expect(florianVideoButton).toBeChecked();
await florianVideoButton.click();
// Now the button should indicate we can start video // Now the button should indicate we can start video
await expect(florianMuteButton).toHaveAttribute( await expect(florianVideoButton).toHaveAccessibleName("Start video");
"aria-label", await expect(florianVideoButton).not.toBeChecked();
/^Start video$/,
);
// wait a bit for the state to propagate // wait a bit for the state to propagate
await valere.page.waitForTimeout(3000); await valere.page.waitForTimeout(3000);

View File

@@ -47,14 +47,17 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
{ {
// Check for a bug where the video had the wrong fit in PIP // Check for a bug where the video had the wrong fit in PIP
const hangupBtn = iFrame.getByRole("button", { name: "End call" }); const audioBtn = iFrame.getByRole("switch", { name: /microphone/ });
const audioBtn = iFrame.getByTestId("incall_mute"); const videoBtn = iFrame.getByRole("switch", { name: /video/ });
const videoBtn = iFrame.getByTestId("incall_videomute"); await expect(
await expect(hangupBtn).toBeVisible(); iFrame.getByRole("button", { name: "End call" }),
).toBeVisible();
await expect(audioBtn).toBeVisible(); await expect(audioBtn).toBeVisible();
await expect(videoBtn).toBeVisible(); await expect(videoBtn).toBeVisible();
await expect(audioBtn).toHaveAttribute("aria-label", /^Mute microphone$/); await expect(audioBtn).toHaveAccessibleName("Mute microphone");
await expect(videoBtn).toHaveAttribute("aria-label", /^Stop video$/); await expect(audioBtn).toBeChecked();
await expect(videoBtn).toHaveAccessibleName("Stop video");
await expect(videoBtn).toBeChecked();
await videoBtn.click(); await videoBtn.click();
await audioBtn.click(); await audioBtn.click();
@@ -62,7 +65,9 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
// stop hovering on any of the buttons // stop hovering on any of the buttons
await iFrame.getByTestId("videoTile").hover(); await iFrame.getByTestId("videoTile").hover();
await expect(audioBtn).toHaveAttribute("aria-label", /^Unmute microphone$/); await expect(audioBtn).toHaveAccessibleName("Unmute microphone");
await expect(videoBtn).toHaveAttribute("aria-label", /^Start video$/); await expect(audioBtn).toBeChecked();
await expect(videoBtn).toHaveAccessibleName("Start video");
await expect(videoBtn).not.toBeChecked();
} }
}); });

View File

@@ -40,16 +40,14 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
await TestHelpers.joinCallInCurrentRoom(timo.page); await TestHelpers.joinCallInCurrentRoom(timo.page);
{ const frame = timo.page
const frame = timo.page .locator('iframe[title="Element Call"]')
.locator('iframe[title="Element Call"]') .contentFrame();
.contentFrame();
const videoButton = frame.getByTestId("incall_videomute"); // check that the video is on
await expect(videoButton).toBeVisible(); await expect(
// check that the video is on frame.getByRole("switch", { name: "Stop video", checked: true }),
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); ).toBeVisible();
}
// Switch to the other room, the call should go to PIP // Switch to the other room, the call should go to PIP
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask"); await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");

View File

@@ -54,34 +54,36 @@ widgetTest(
.contentFrame(); .contentFrame();
// ASSERT the button states for whistler (the callee) // ASSERT the button states for whistler (the callee)
{ // video should be off by default in a voice call
// The only way to know if it is muted or not is to look at the data-kind attribute.. await expect(
const videoButton = whistlerFrame.getByTestId("incall_videomute"); whistlerFrame.getByRole("switch", {
// video should be off by default in a voice call name: "Start video",
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/); checked: false,
}),
const audioButton = whistlerFrame.getByTestId("incall_mute"); ).toBeVisible();
// audio should be on for the voice call // audio should be on for the voice call
await expect(audioButton).toHaveAttribute( await expect(
"aria-label", whistlerFrame.getByRole("switch", {
/^Mute microphone$/, name: "Mute microphone",
); checked: true,
} }),
).toBeVisible();
// ASSERT the button states for brools (the caller) // ASSERT the button states for brools (the caller)
{ // video should be off by default in a voice call
// The only way to know if it is muted or not is to look at the data-kind attribute.. await expect(
const videoButton = brooksFrame.getByTestId("incall_videomute"); whistlerFrame.getByRole("switch", {
// video should be off by default in a voice call name: "Start video",
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/); checked: false,
}),
const audioButton = brooksFrame.getByTestId("incall_mute"); ).toBeVisible();
// audio should be on for the voice call // audio should be on for the voice call
await expect(audioButton).toHaveAttribute( await expect(
"aria-label", whistlerFrame.getByRole("switch", {
/^Mute microphone$/, name: "Mute microphone",
); checked: true,
} }),
).toBeVisible();
// In order to confirm that the call is disconnected we will check that the message composer is shown again. // In order to confirm that the call is disconnected we will check that the message composer is shown again.
// So first we need to confirm that it is hidden when in the call. // So first we need to confirm that it is hidden when in the call.
@@ -93,10 +95,7 @@ widgetTest(
).not.toBeVisible(); ).not.toBeVisible();
// ASSERT hanging up on one side ends the call for both // ASSERT hanging up on one side ends the call for both
{ await brooksFrame.getByRole("button", { name: "End call" }).click();
const hangupButton = brooksFrame.getByTestId("incall_leave");
await hangupButton.click();
}
// The widget should be closed on both sides and the timeline should be back on screen // The widget should be closed on both sides and the timeline should be back on screen
await expect( await expect(
@@ -148,34 +147,30 @@ widgetTest(
.contentFrame(); .contentFrame();
// ASSERT the button states for whistler (the callee) // ASSERT the button states for whistler (the callee)
{ // video should be off by default in a video call
// The only way to know if it is muted or not is to look at the data-kind attribute.. await expect(
const videoButton = whistlerFrame.getByTestId("incall_videomute"); whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
// video should be on by default in a voice call ).toBeVisible();
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); // audio should be on too
await expect(
const audioButton = whistlerFrame.getByTestId("incall_mute"); whistlerFrame.getByRole("switch", {
// audio should be on for the voice call name: "Mute microphone",
await expect(audioButton).toHaveAttribute( checked: true,
"aria-label", }),
/^Mute microphone$/, ).toBeVisible();
);
}
// ASSERT the button states for brools (the caller) // ASSERT the button states for brools (the caller)
{ // video should be off by default in a video call
// The only way to know if it is muted or not is to look at the data-kind attribute.. await expect(
const videoButton = brooksFrame.getByTestId("incall_videomute"); whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
// video should be on by default in a voice call ).toBeVisible();
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); // audio should be on too
await expect(
const audioButton = brooksFrame.getByTestId("incall_mute"); whistlerFrame.getByRole("switch", {
// audio should be on for the voice call name: "Mute microphone",
await expect(audioButton).toHaveAttribute( checked: true,
"aria-label", }),
/^Mute microphone$/, ).toBeVisible();
);
}
// In order to confirm that the call is disconnected we will check that the message composer is shown again. // In order to confirm that the call is disconnected we will check that the message composer is shown again.
// So first we need to confirm that it is hidden when in the call. // So first we need to confirm that it is hidden when in the call.
@@ -187,10 +182,7 @@ widgetTest(
).not.toBeVisible(); ).not.toBeVisible();
// ASSERT hanging up on one side ends the call for both // ASSERT hanging up on one side ends the call for both
{ await brooksFrame.getByRole("button", { name: "End call" }).click();
const hangupButton = brooksFrame.getByTestId("incall_leave");
await hangupButton.click();
}
// The widget should be closed on both sides and the timeline should be back on screen // The widget should be closed on both sides and the timeline should be back on screen
await expect( await expect(

View File

@@ -37,9 +37,10 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
<Tooltip label={label}> <Tooltip label={label}>
<CpdButton <CpdButton
iconOnly iconOnly
aria-label={label}
Icon={Icon} Icon={Icon}
kind={enabled ? "primary" : "secondary"} kind={enabled ? "primary" : "secondary"}
role="switch"
aria-checked={enabled}
{...props} {...props}
/> />
</Tooltip> </Tooltip>
@@ -62,9 +63,10 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
<Tooltip label={label}> <Tooltip label={label}>
<CpdButton <CpdButton
iconOnly iconOnly
aria-label={label}
Icon={Icon} Icon={Icon}
kind={enabled ? "primary" : "secondary"} kind={enabled ? "primary" : "secondary"}
role="switch"
aria-checked={enabled}
{...props} {...props}
/> />
</Tooltip> </Tooltip>
@@ -91,6 +93,8 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
iconOnly iconOnly
Icon={ShareScreenSolidIcon} Icon={ShareScreenSolidIcon}
kind={enabled ? "primary" : "secondary"} kind={enabled ? "primary" : "secondary"}
role="switch"
aria-checked={enabled}
{...props} {...props}
/> />
</Tooltip> </Tooltip>
@@ -112,7 +116,6 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
<CpdButton <CpdButton
className={classNames(className, styles.endCall)} className={classNames(className, styles.endCall)}
iconOnly iconOnly
aria-label={t("hangup_button_label")}
Icon={EndCallIcon} Icon={EndCallIcon}
destructive destructive
{...props} {...props}

View File

@@ -272,14 +272,14 @@ exports[`InCallView > rendering > renders 1`] = `
class="buttons" class="buttons"
> >
<button <button
aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-label="Unmute microphone"
aria-labelledby="_r_8_" aria-labelledby="_r_8_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
data-testid="incall_mute" data-testid="incall_mute"
role="button" role="switch"
tabindex="0" tabindex="0"
> >
<svg <svg
@@ -296,14 +296,14 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-label="Start video"
aria-labelledby="_r_d_" aria-labelledby="_r_d_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
data-testid="incall_videomute" data-testid="incall_videomute"
role="button" role="switch"
tabindex="0" tabindex="0"
> >
<svg <svg
@@ -341,7 +341,6 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-label="End call"
aria-labelledby="_r_n_" aria-labelledby="_r_n_"
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110" class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
data-kind="primary" data-kind="primary"