Update polls UX to match EX Mobile and improve accessibility (#31245)

* Remove poll ended event UI.

* Add better aria labels for screen reader and change ui to match mobile UX.

- Checkmark and progress bar are only green if the poll is ended.
- Updated the Poll icon for open and ended state and added labels
- Right align total votes count and update text

* Update jest tests

* Fix total votes alignment

* Fix screenshots

* Update snapshot

* Update e2e tests

* fix more e2e tests

* Clean up CSS

* Add back text for undisclosed poll (total should be hidden)

* Update checkmark and progress colours to more closely match mobile

* Don't compute optionNumber on each render

* "Total votes" working doesn't really work with the current web behaviour

Web doesn't show the votes for undisclosed polls(mobile does). reverting and that behaviour change should be addressed in a different PR(or on mobile.).

* Fix e2e test

* Update screenshots

* Move positioning of total votes label back to the left side

as we are no longer changing the copy to match mobile

* Don't concatenate label

* Fix translation order

* Remove unneeded translations

* remove O(n^2) code

* fix snapshots

* Fix check style in poll option

* prettier
This commit is contained in:
David Langley
2025-12-05 19:56:04 +00:00
committed by GitHub
parent d610c3d1ae
commit be8a18964e
21 changed files with 1095 additions and 870 deletions

View File

@@ -505,11 +505,11 @@ describe("MPollBody", () => {
expect(runFindTopAnswer([])).toEqual("");
});
it("shows non-radio buttons if the poll is ended", async () => {
it("shows disabled radio buttons if the poll is ended", async () => {
const events = [newPollEndEvent()];
const { container } = await newMPollBody([], events);
expect(container.querySelector(".mx_StyledRadioButton")).not.toBeInTheDocument();
expect(container.querySelector('input[type="radio"]')).not.toBeInTheDocument();
expect(container.querySelector(".mx_StyledRadioButton")).toBeInTheDocument();
expect(container.querySelector('input[type="radio"][disabled]')).toBeInTheDocument();
});
it("counts votes as normal if the poll is ended", async () => {
@@ -551,8 +551,8 @@ describe("MPollBody", () => {
const ends = [newPollEndEvent("@me:example.com", 25)];
const renderResult = await newMPollBody(votes, ends);
expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(0);
expect(renderResult.container.querySelectorAll('input[type="radio"]')).toHaveLength(0);
expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(4);
expect(renderResult.container.querySelectorAll('input[type="radio"][disabled]')).toHaveLength(4);
expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes");
expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes");
expect(endedVotesCount(renderResult, "italian")).toBe("0 votes");
@@ -646,9 +646,9 @@ describe("MPollBody", () => {
expect(endedVoteChecked(renderResult, "wings")).toBe(true);
expect(endedVoteChecked(renderResult, "pizza")).toBe(false);
// Double-check by looking for the endedOptionWinner class
expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_endedOptionWinner")).toBe(true);
expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_endedOptionWinner")).toBe(false);
// Double-check by looking for the checked class
expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_checked")).toBe(true);
expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_checked")).toBe(false);
});
it("highlights multiple winning votes", async () => {
@@ -731,9 +731,7 @@ describe("MPollBody", () => {
});
pollEvent.makeReplaced(replacingEvent);
const { getByTestId, container } = await newMPollBodyFromEvent(pollEvent, []);
expect(getByTestId("pollQuestion").innerHTML).toEqual(
'new question<span class="mx_MPollBody_edited"> (edited)</span>',
);
expect(getByTestId("pollQuestion").textContent).toEqual("new question (edited)");
const inputs = container.querySelectorAll('input[type="radio"]');
expect(inputs).toHaveLength(3);
expect(inputs[0].getAttribute("value")).toEqual("n1");
@@ -951,7 +949,7 @@ function endedVoteChecked({ getByTestId }: RenderResult, value: string): boolean
}
function endedVoteDiv({ getByTestId }: RenderResult, value: string): Element {
return getByTestId(`pollOption-${value}`).firstElementChild!;
return getByTestId(`pollOption-${value}`);
}
function endedVotesCount(renderResult: RenderResult, value: string): string {

View File

@@ -1,193 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, waitFor } from "jest-matrix-react";
import { type EventTimeline, type MatrixEvent, Room, M_TEXT } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type IBodyProps } from "../../../../../src/components/views/messages/IBodyProps";
import { MPollEndBody } from "../../../../../src/components/views/messages/MPollEndBody";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { type RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import {
flushPromises,
getMockClientWithEventEmitter,
makePollEndEvent,
makePollStartEvent,
mockClientMethodsEvents,
mockClientMethodsUser,
setupRoomWithPollEvents,
} from "../../../../test-utils";
describe("<MPollEndBody />", () => {
const userId = "@alice:domain.org";
const roomId = "!room:domain.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsEvents(),
getRoom: jest.fn(),
relations: jest.fn(),
fetchRoomEvent: jest.fn(),
});
const pollStartEvent = makePollStartEvent("Question?", userId, undefined, { roomId });
const pollEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, userId, 123);
const setupRoomWithEventsTimeline = async (pollEnd: MatrixEvent, pollStart?: MatrixEvent): Promise<Room> => {
if (pollStart) {
await setupRoomWithPollEvents([pollStart], [], [pollEnd], mockClient);
}
const room = mockClient.getRoom(roomId) || new Room(roomId, mockClient, userId);
// end events validate against this
jest.spyOn(room.currentState, "maySendRedactionForEvent").mockImplementation(
(_evt: MatrixEvent, id: string) => {
return id === mockClient.getSafeUserId();
},
);
const timelineSet = room.getUnfilteredTimelineSet();
const getTimelineForEventSpy = jest.spyOn(timelineSet, "getTimelineForEvent");
// if we have a pollStart, mock the room timeline to include it
if (pollStart) {
const eventTimeline = {
getEvents: jest.fn().mockReturnValue([pollEnd, pollStart]),
} as unknown as EventTimeline;
getTimelineForEventSpy.mockReturnValue(eventTimeline);
}
mockClient.getRoom.mockReturnValue(room);
return room;
};
const defaultProps = {
mxEvent: pollEndEvent,
highlightLink: "unused",
mediaEventHelper: {} as unknown as MediaEventHelper,
onMessageAllowed: () => {},
permalinkCreator: {} as unknown as RoomPermalinkCreator,
ref: undefined as any,
};
const getComponent = (props: Partial<IBodyProps> = {}) =>
render(<MPollEndBody {...defaultProps} {...props} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
),
});
beforeEach(() => {
mockClient.getRoom.mockReset();
mockClient.relations.mockResolvedValue({
events: [],
});
mockClient.fetchRoomEvent.mockResolvedValue(pollStartEvent.getEffectiveEvent());
});
afterEach(() => {
jest.spyOn(logger, "error").mockRestore();
});
describe("when poll start event exists in current timeline", () => {
it("renders an ended poll", async () => {
await setupRoomWithEventsTimeline(pollEndEvent, pollStartEvent);
const { container } = getComponent();
// ended poll rendered
expect(container).toMatchSnapshot();
// didnt try to fetch start event while it was already in timeline
expect(mockClient.fetchRoomEvent).not.toHaveBeenCalled();
});
it("does not render a poll tile when end event is invalid", async () => {
// sender of end event does not match start event
const invalidEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, "@mallory:domain.org", 123);
await setupRoomWithEventsTimeline(invalidEndEvent, pollStartEvent);
const { getByText } = getComponent({ mxEvent: invalidEndEvent });
// no poll tile rendered
expect(getByText("The poll has ended. Something.")).toBeTruthy();
});
});
describe("when poll start event does not exist in current timeline", () => {
it("fetches the related poll start event and displays a poll tile", async () => {
await setupRoomWithEventsTimeline(pollEndEvent);
const { container, getByTestId, getByRole, queryByRole } = getComponent();
// while fetching event, only icon is shown
expect(container).toMatchSnapshot();
await waitFor(() => expect(getByRole("progressbar")).toBeInTheDocument());
await waitFor(() => expect(queryByRole("progressbar")).not.toBeInTheDocument());
expect(mockClient.fetchRoomEvent).toHaveBeenCalledWith(roomId, pollStartEvent.getId());
// quick check for poll tile
expect(getByTestId("pollQuestion").innerHTML).toEqual("Question?");
expect(getByTestId("totalVotes").innerHTML).toEqual("Final result based on 0 votes");
});
it("does not render a poll tile when end event is invalid", async () => {
// sender of end event does not match start event
const invalidEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, "@mallory:domain.org", 123);
await setupRoomWithEventsTimeline(invalidEndEvent);
const { getByText } = getComponent({ mxEvent: invalidEndEvent });
// flush the fetch event promise
await flushPromises();
// no poll tile rendered
expect(getByText("The poll has ended. Something.")).toBeTruthy();
});
it("logs an error and displays the text fallback when fetching the start event fails", async () => {
await setupRoomWithEventsTimeline(pollEndEvent);
mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 });
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const { getByText } = getComponent();
// flush the fetch event promise
await flushPromises();
// poll end event fallback text used
expect(getByText("The poll has ended. Something.")).toBeTruthy();
expect(logSpy).toHaveBeenCalledWith("Failed to fetch related poll start event", { code: 404 });
});
it("logs an error and displays the extensible event text when fetching the start event fails", async () => {
await setupRoomWithEventsTimeline(pollEndEvent);
mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 });
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const { getByText } = getComponent();
// flush the fetch event promise
await flushPromises();
// poll end event fallback text used
expect(getByText("The poll has ended. Something.")).toBeTruthy();
expect(logSpy).toHaveBeenCalledWith("Failed to fetch related poll start event", { code: 404 });
});
it("displays fallback text when the poll end event does not have text", async () => {
const endWithoutText = makePollEndEvent(pollStartEvent.getId()!, roomId, userId, 123);
delete endWithoutText.getContent()[M_TEXT.name];
await setupRoomWithEventsTimeline(endWithoutText);
mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 });
const { getByText } = getComponent({ mxEvent: endWithoutText });
// flush the fetch event promise
await flushPromises();
// default fallback text used
expect(getByText("@alice:domain.org has ended a poll")).toBeTruthy();
});
});
});

View File

@@ -1,129 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<MPollEndBody /> when poll start event does not exist in current timeline fetches the related poll start event and displays a poll tile 1`] = `
<div>
<svg
class="mx_MPollEndBody_icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 10.659V19q0 .824-.587 1.413A1.93 1.93 0 0 1 19 21H5q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19V5q0-.824.587-1.412A1.93 1.93 0 0 1 5 3h8.341A6 6 0 0 0 13 5H5v14h14v-8a6 6 0 0 0 2-.341"
/>
<path
d="M13.803 8a6 6 0 0 0 1.88 2H13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 9q0-.424.287-.713A.97.97 0 0 1 13 8zm2.91 7.713A.97.97 0 0 1 16 16h-3a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 15q0-.424.287-.713A.97.97 0 0 1 13 14h3q.424 0 .712.287.288.288.288.713 0 .424-.288.713m-6.299-5.3A1.93 1.93 0 0 1 9 11q-.825 0-1.412-.588A1.93 1.93 0 0 1 7 9q0-.825.588-1.412A1.93 1.93 0 0 1 9 7q.825 0 1.412.588Q11 8.175 11 9t-.588 1.412m.001 6.001A1.93 1.93 0 0 1 9 17q-.825 0-1.412-.587A1.93 1.93 0 0 1 7 15q0-.825.588-1.412A1.93 1.93 0 0 1 9 13q.825 0 1.412.588Q11 14.175 11 15q0 .824-.588 1.413m12.295-14.12a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L18 5.586l3.293-3.293a1 1 0 0 1 1.414 0"
/>
</svg>
</div>
`;
exports[`<MPollEndBody /> when poll start event exists in current timeline renders an ended poll 1`] = `
<div>
<div
class="mx_MPollEndBody"
>
<span
class="mx_Caption"
>
Ended a poll
</span>
<fieldset
class="mx_MPollBody"
>
<legend
data-testid="pollQuestion"
>
Question?
</legend>
<div
class="mx_MPollBody_allOptions"
>
<div
class="mx_PollOption mx_PollOption_ended"
data-testid="pollOption-socks"
>
<div
class="mx_PollOption_endedOption"
data-value="socks"
>
<div
class="mx_PollOption_content"
>
<div
class="mx_PollOption_optionText"
>
Socks
</div>
<div
class="mx_PollOption_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_PollOption_popularityBackground"
>
<div
class="mx_PollOption_popularityAmount"
style="width: 0%;"
/>
</div>
</div>
<div
class="mx_PollOption mx_PollOption_ended"
data-testid="pollOption-shoes"
>
<div
class="mx_PollOption_endedOption"
data-value="shoes"
>
<div
class="mx_PollOption_content"
>
<div
class="mx_PollOption_optionText"
>
Shoes
</div>
<div
class="mx_PollOption_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_PollOption_popularityBackground"
>
<div
class="mx_PollOption_popularityAmount"
style="width: 0%;"
/>
</div>
</div>
</div>
<div
class="mx_MPollBody_totalVotes"
data-testid="totalVotes"
>
Final result based on 0 votes
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 16px; height: 16px;"
/>
</div>
</div>
</fieldset>
</div>
</div>
`;