From c769b7f38e6323eed23ed85e8c40a64d2816a46c Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Mar 2026 15:09:02 +0100 Subject: [PATCH] Improve accessibility of microphone, camera, and screen share buttons Taking Valere's suggestion of giving them the 'switch' role. Also, the aria-label attributes were redundant (having tooltips already gives the buttons aria-labelledby). --- playwright/create-call.spec.ts | 4 +- playwright/reconnect.spec.ts | 6 +- playwright/widget/huddle-call.test.ts | 19 +++--- .../pip-call-button-interaction.test.ts | 17 ++--- playwright/widget/pip-call.test.ts | 14 ++-- playwright/widget/voice-call-dm.spec.ts | 68 ++++++++----------- src/button/Button.tsx | 9 ++- .../__snapshots__/InCallView.test.tsx.snap | 9 ++- 8 files changed, 65 insertions(+), 81 deletions(-) diff --git a/playwright/create-call.spec.ts b/playwright/create-call.spec.ts index 6f03272e..b71f39ad 100644 --- a/playwright/create-call.spec.ts +++ b/playwright/create-call.spec.ts @@ -22,8 +22,8 @@ test("Start a new call then leave and show the feedback screen", async ({ await expect(page.getByTestId("lobby_joinCall")).toBeVisible(); // Check the button toolbar - // await expect(page.getByRole('button', { name: 'Mute microphone' })).toBeVisible(); - // await expect(page.getByRole('button', { name: 'Stop video' })).toBeVisible(); + // await expect(page.getByRole('switch', { name: 'Mute microphone' })).toBeVisible(); + // await expect(page.getByRole('switch', { name: 'Stop video' })).toBeVisible(); await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); await expect(page.getByRole("button", { name: "End call" })).toBeVisible(); diff --git a/playwright/reconnect.spec.ts b/playwright/reconnect.spec.ts index 3b419af4..1a8f2c28 100644 --- a/playwright/reconnect.spec.ts +++ b/playwright/reconnect.spec.ts @@ -49,12 +49,12 @@ test("can only interact with header and footer while reconnecting", async ({ ).toBeVisible(); // 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( - page.getByRole("button", { name: "Mute microphone" }), + page.getByRole("switch", { name: "Mute microphone" }), ).toBeFocused(); 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 await page.getByRole("button", { name: "End call" }).click(); }); diff --git a/playwright/widget/huddle-call.test.ts b/playwright/widget/huddle-call.test.ts index d4ba0006..692d4b62 100644 --- a/playwright/widget/huddle-call.test.ts +++ b/playwright/widget/huddle-call.test.ts @@ -55,13 +55,10 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { const frame = user.page .locator('iframe[title="Element Call"]') .contentFrame(); - // 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.. - const videoButton = frame.getByTestId("incall_videomute"); - await expect(videoButton).toBeVisible(); - // video should be on - await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); + await expect( + frame.getByRole("switch", { name: "Stop video" }), + ).toBeVisible(); } // We should see 5 video tiles everywhere now @@ -101,13 +98,13 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { const florianFrame = florian.page .locator('iframe[title="Element Call"]') .contentFrame(); - const florianMuteButton = florianFrame.getByTestId("incall_videomute"); + const florianMuteButton = florianFrame.getByRole("switch", { + name: "Stop video", + }); await florianMuteButton.click(); // Now the button should indicate we can start video - await expect(florianMuteButton).toHaveAttribute( - "aria-label", - /^Start video$/, - ); + await expect(florianMuteButton).toHaveAccessibleName("Start video"); + await expect(florianMuteButton).not.toBeChecked(); // wait a bit for the state to propagate await valere.page.waitForTimeout(3000); diff --git a/playwright/widget/pip-call-button-interaction.test.ts b/playwright/widget/pip-call-button-interaction.test.ts index 1dda652d..7a1fda3f 100644 --- a/playwright/widget/pip-call-button-interaction.test.ts +++ b/playwright/widget/pip-call-button-interaction.test.ts @@ -47,14 +47,13 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => { { // Check for a bug where the video had the wrong fit in PIP - const hangupBtn = iFrame.getByRole("button", { name: "End call" }); - const audioBtn = iFrame.getByTestId("incall_mute"); - const videoBtn = iFrame.getByTestId("incall_videomute"); - await expect(hangupBtn).toBeVisible(); + const audioBtn = iFrame.getByRole("switch", { name: "Mute microphone" }); + const videoBtn = iFrame.getByRole("switch", { name: "Stop video" }); + await expect( + iFrame.getByRole("button", { name: "End call" }), + ).toBeVisible(); await expect(audioBtn).toBeVisible(); await expect(videoBtn).toBeVisible(); - await expect(audioBtn).toHaveAttribute("aria-label", /^Mute microphone$/); - await expect(videoBtn).toHaveAttribute("aria-label", /^Stop video$/); await videoBtn.click(); await audioBtn.click(); @@ -62,7 +61,9 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => { // stop hovering on any of the buttons await iFrame.getByTestId("videoTile").hover(); - await expect(audioBtn).toHaveAttribute("aria-label", /^Unmute microphone$/); - await expect(videoBtn).toHaveAttribute("aria-label", /^Start video$/); + await expect(audioBtn).toHaveAccessibleName("Unmute microphone"); + await expect(audioBtn).toBeChecked(); + await expect(videoBtn).toHaveAccessibleName("Start video"); + await expect(videoBtn).not.toBeChecked(); } }); diff --git a/playwright/widget/pip-call.test.ts b/playwright/widget/pip-call.test.ts index d57befc1..8d4dd31b 100644 --- a/playwright/widget/pip-call.test.ts +++ b/playwright/widget/pip-call.test.ts @@ -40,16 +40,12 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => { await TestHelpers.joinCallInCurrentRoom(timo.page); - { - const frame = timo.page - .locator('iframe[title="Element Call"]') - .contentFrame(); + const frame = timo.page + .locator('iframe[title="Element Call"]') + .contentFrame(); - const videoButton = frame.getByTestId("incall_videomute"); - await expect(videoButton).toBeVisible(); - // check that the video is on - await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); - } + // check that the video is on + await expect(frame.getByRole("switch", { name: "Stop video" })).toBeVisible(); // Switch to the other room, the call should go to PIP await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask"); diff --git a/playwright/widget/voice-call-dm.spec.ts b/playwright/widget/voice-call-dm.spec.ts index 6a8473cf..bc3d8226 100644 --- a/playwright/widget/voice-call-dm.spec.ts +++ b/playwright/widget/voice-call-dm.spec.ts @@ -52,32 +52,26 @@ widgetTest( // ASSERT the button states for whistler (the callee) { - // The only way to know if it is muted or not is to look at the data-kind attribute.. - const videoButton = whistlerFrame.getByTestId("incall_videomute"); // video should be off by default in a voice call - await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/); - - const audioButton = whistlerFrame.getByTestId("incall_mute"); + await expect( + whistlerFrame.getByRole("switch", { name: "Start video" }), + ).toBeVisible(); // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); + await expect( + whistlerFrame.getByRole("switch", { name: "Mute microphone" }), + ).toBeVisible(); } // ASSERT the button states for brools (the caller) { - // The only way to know if it is muted or not is to look at the data-kind attribute.. - const videoButton = brooksFrame.getByTestId("incall_videomute"); // video should be off by default in a voice call - await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/); - - const audioButton = brooksFrame.getByTestId("incall_mute"); + await expect( + whistlerFrame.getByRole("switch", { name: "Start video" }), + ).toBeVisible(); // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); + await expect( + whistlerFrame.getByRole("switch", { name: "Mute microphone" }), + ).toBeVisible(); } // In order to confirm that the call is disconnected we will check that the message composer is shown again. @@ -143,32 +137,26 @@ widgetTest( // ASSERT the button states for whistler (the callee) { - // The only way to know if it is muted or not is to look at the data-kind attribute.. - const videoButton = whistlerFrame.getByTestId("incall_videomute"); - // video should be on by default in a voice call - await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); - - const audioButton = whistlerFrame.getByTestId("incall_mute"); - // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); + // video should be off by default in a video call + await expect( + whistlerFrame.getByRole("switch", { name: "Stop video" }), + ).toBeVisible(); + // audio should be on too + await expect( + whistlerFrame.getByRole("switch", { name: "Mute microphone" }), + ).toBeVisible(); } // ASSERT the button states for brools (the caller) { - // The only way to know if it is muted or not is to look at the data-kind attribute.. - const videoButton = brooksFrame.getByTestId("incall_videomute"); - // video should be on by default in a voice call - await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); - - const audioButton = brooksFrame.getByTestId("incall_mute"); - // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); + // video should be off by default in a video call + await expect( + whistlerFrame.getByRole("switch", { name: "Stop video" }), + ).toBeVisible(); + // audio should be on too + await expect( + whistlerFrame.getByRole("switch", { name: "Mute microphone" }), + ).toBeVisible(); } // In order to confirm that the call is disconnected we will check that the message composer is shown again. diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 1aff9fa3..0b8d7144 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -37,9 +37,10 @@ export const MicButton: FC = ({ enabled, ...props }) => { @@ -62,9 +63,10 @@ export const VideoButton: FC = ({ enabled, ...props }) => { @@ -91,6 +93,8 @@ export const ShareScreenButton: FC = ({ iconOnly Icon={ShareScreenSolidIcon} kind={enabled ? "primary" : "secondary"} + role="switch" + aria-checked={enabled} {...props} /> @@ -112,7 +116,6 @@ export const EndCallButton: FC = ({ rendering > renders 1`] = ` class="buttons" >