From c0d60b2c29b73225448098fc464fc6ca29fdf5b8 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 | 23 ++-- .../pip-call-button-interaction.test.ts | 21 ++-- playwright/widget/pip-call.test.ts | 16 ++- playwright/widget/voice-call-dm.spec.ts | 112 ++++++++---------- src/button/Button.tsx | 9 +- .../__snapshots__/InCallView.test.tsx.snap | 9 +- 8 files changed, 98 insertions(+), 102 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..9c7cfc80 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", checked: true }), + ).toBeVisible(); } // 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 .locator('iframe[title="Element Call"]') .contentFrame(); - const florianMuteButton = florianFrame.getByTestId("incall_videomute"); - await florianMuteButton.click(); + const florianVideoButton = florianFrame.getByRole("switch", { + name: /video/, + }); + await expect(florianVideoButton).toHaveAccessibleName("Stop video"); + await expect(florianVideoButton).toBeChecked(); + await florianVideoButton.click(); // Now the button should indicate we can start video - await expect(florianMuteButton).toHaveAttribute( - "aria-label", - /^Start video$/, - ); + await expect(florianVideoButton).toHaveAccessibleName("Start video"); + await expect(florianVideoButton).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..bdff11f7 100644 --- a/playwright/widget/pip-call-button-interaction.test.ts +++ b/playwright/widget/pip-call-button-interaction.test.ts @@ -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 - 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: /microphone/ }); + const videoBtn = iFrame.getByRole("switch", { name: /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 expect(audioBtn).toHaveAccessibleName("Mute microphone"); + await expect(audioBtn).toBeChecked(); + await expect(videoBtn).toHaveAccessibleName("Stop video"); + await expect(videoBtn).toBeChecked(); await videoBtn.click(); await audioBtn.click(); @@ -62,7 +65,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..63ba2050 100644 --- a/playwright/widget/pip-call.test.ts +++ b/playwright/widget/pip-call.test.ts @@ -40,16 +40,14 @@ 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", checked: true }), + ).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..dca8213f 100644 --- a/playwright/widget/voice-call-dm.spec.ts +++ b/playwright/widget/voice-call-dm.spec.ts @@ -51,34 +51,36 @@ widgetTest( .contentFrame(); // 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"); - // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); - } + // video should be off by default in a voice call + await expect( + whistlerFrame.getByRole("switch", { + name: "Start video", + checked: false, + }), + ).toBeVisible(); + // audio should be on for the voice call + await expect( + whistlerFrame.getByRole("switch", { + name: "Mute microphone", + checked: true, + }), + ).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"); - // audio should be on for the voice call - await expect(audioButton).toHaveAttribute( - "aria-label", - /^Mute microphone$/, - ); - } + // video should be off by default in a voice call + await expect( + whistlerFrame.getByRole("switch", { + name: "Start video", + checked: false, + }), + ).toBeVisible(); + // audio should be on for the voice call + await expect( + whistlerFrame.getByRole("switch", { + 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. // So first we need to confirm that it is hidden when in the call. @@ -90,10 +92,7 @@ widgetTest( ).not.toBeVisible(); // ASSERT hanging up on one side ends the call for both - { - const hangupButton = brooksFrame.getByTestId("incall_leave"); - await hangupButton.click(); - } + await brooksFrame.getByRole("button", { name: "End call" }).click(); // The widget should be closed on both sides and the timeline should be back on screen await expect( @@ -142,34 +141,30 @@ widgetTest( .contentFrame(); // 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", checked: true }), + ).toBeVisible(); + // audio should be on too + await expect( + whistlerFrame.getByRole("switch", { + name: "Mute microphone", + checked: true, + }), + ).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", checked: true }), + ).toBeVisible(); + // audio should be on too + await expect( + whistlerFrame.getByRole("switch", { + 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. // So first we need to confirm that it is hidden when in the call. @@ -181,10 +176,7 @@ widgetTest( ).not.toBeVisible(); // ASSERT hanging up on one side ends the call for both - { - const hangupButton = brooksFrame.getByTestId("incall_leave"); - await hangupButton.click(); - } + await brooksFrame.getByRole("button", { name: "End call" }).click(); // The widget should be closed on both sides and the timeline should be back on screen await expect( 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" >