From eb086bd795da568c8cae2a751d1a4cdd77263569 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 2 Sep 2025 15:03:01 +0200 Subject: [PATCH] A11y: improve accessibility of pinned messages (#30558) * fix: improve aria role and label on pinned message banner * fix: change pinned message badge background for contrast * fix: link pinned message button to content * test: update tests * fix: add aria-describedby on pinned message badge * feat: use `aria-describedby` instead of `aria-description` * test: update room view snapshot * test: update snapshot * fix: put id only textual body upper div * fix: use lodash uniqueId * test: update snapshots --- .../views/messages/_PinnedMessageBadge.pcss | 2 +- src/components/views/messages/IBodyProps.ts | 5 ++ .../views/messages/MessageEvent.tsx | 6 ++ .../views/messages/PinnedMessageBadge.tsx | 6 +- src/components/views/messages/TextualBody.tsx | 13 +++- src/components/views/rooms/EventTile.tsx | 9 ++- .../views/rooms/PinnedEventTile.tsx | 21 +++-- .../views/rooms/PinnedMessageBanner.tsx | 16 +++- src/i18n/strings/en_EN.json | 5 +- .../__snapshots__/RoomView-test.tsx.snap | 78 +++++++++---------- .../PinnedMessagesCard-test.tsx.snap | 30 ++++--- .../views/rooms/PinnedMessageBanner-test.tsx | 25 +++++- .../PinnedEventTile-test.tsx.snap | 20 +++-- .../PinnedMessageBanner-test.tsx.snap | 63 ++++++++++----- .../LayoutSwitcher-test.tsx.snap | 3 + .../AppearanceUserSettingsTab-test.tsx.snap | 3 + .../__snapshots__/HTMLExport-test.ts.snap | 2 +- 17 files changed, 205 insertions(+), 102 deletions(-) diff --git a/res/css/views/messages/_PinnedMessageBadge.pcss b/res/css/views/messages/_PinnedMessageBadge.pcss index 69b592d789..722945a49c 100644 --- a/res/css/views/messages/_PinnedMessageBadge.pcss +++ b/res/css/views/messages/_PinnedMessageBadge.pcss @@ -13,7 +13,7 @@ padding: var(--cpd-space-1x) var(--cpd-space-3x) var(--cpd-space-1x) var(--cpd-space-1x); font: var(--cpd-font-body-xs-medium); - background-color: var(--cpd-color-alpha-gray-200); + background-color: var(--cpd-color-bg-subtle-secondary); color: var(--cpd-color-text-secondary); border-radius: 99px; diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts index 37aae37de6..a85f86644d 100644 --- a/src/components/views/messages/IBodyProps.ts +++ b/src/components/views/messages/IBodyProps.ts @@ -48,4 +48,9 @@ export interface IBodyProps { // Set to `true` to disable interactions (e.g. video controls) and to remove controls from the tab order. // This may be useful when displaying a preview of the event. inhibitInteraction?: boolean; + + /** + * Optional ID for the root element. + */ + id?: string; } diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 231fa7f1fe..fc800b72b0 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -51,6 +51,11 @@ interface IProps extends Omit implements IMe getRelationsForEvent: this.props.getRelationsForEvent, isSeeingThroughMessageHiddenForModeration: this.props.isSeeingThroughMessageHiddenForModeration, inhibitInteraction: this.props.inhibitInteraction, + id: this.props.id, }; if (hasCaption) { return ; diff --git a/src/components/views/messages/PinnedMessageBadge.tsx b/src/components/views/messages/PinnedMessageBadge.tsx index 2652d84ed8..373e577f63 100644 --- a/src/components/views/messages/PinnedMessageBadge.tsx +++ b/src/components/views/messages/PinnedMessageBadge.tsx @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type HTMLProps, type JSX } from "react"; import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin-solid"; import { _t } from "../../../languageHandler"; @@ -13,9 +13,9 @@ import { _t } from "../../../languageHandler"; /** * A badge to indicate that a message is pinned. */ -export function PinnedMessageBadge(): JSX.Element { +export function PinnedMessageBadge(props: Readonly>): JSX.Element { return ( -
+
{_t("room|pinned_message_badge")}
diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index d0107b31ec..34d6e9be94 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -384,7 +384,12 @@ export default class TextualBody extends React.Component { if (isEmote) { return ( -
+
{mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()} @@ -397,7 +402,7 @@ export default class TextualBody extends React.Component { } if (isNotice) { return ( -
+
{body} {widgets}
@@ -405,14 +410,14 @@ export default class TextualBody extends React.Component { } if (isCaption) { return ( -
+
{body} {widgets}
); } return ( -
+
{body} {widgets}
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 1379560d55..a7fd7527f6 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -34,6 +34,7 @@ import { type UserVerificationStatus, } from "matrix-js-sdk/src/crypto-api"; import { Tooltip } from "@vector-im/compound-web"; +import { uniqueId } from "lodash"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; @@ -918,6 +919,8 @@ export class UnwrappedEventTile extends React.Component public render(): ReactNode { const msgtype = this.props.mxEvent.getContent().msgtype; const eventType = this.props.mxEvent.getType(); + const id = uniqueId(); + const { hasRenderer, isBubbleMessage, @@ -1142,7 +1145,7 @@ export class UnwrappedEventTile extends React.Component let pinnedMessageBadge: JSX.Element | undefined; if (PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent)) { - pinnedMessageBadge = ; + pinnedMessageBadge = ; } let reactionsRow: JSX.Element | undefined; @@ -1237,7 +1240,7 @@ export class UnwrappedEventTile extends React.Component {avatar} {sender}
, -
+
{this.renderContextMenu()} {replyChain} {renderTile(TimelineRenderingType.Thread, { @@ -1425,7 +1428,7 @@ export class UnwrappedEventTile extends React.Component {sender} {ircPadlock} {avatar} -
+
{this.renderContextMenu()} {groupTimestamp} {groupPadlock} diff --git a/src/components/views/rooms/PinnedEventTile.tsx b/src/components/views/rooms/PinnedEventTile.tsx index afb612c397..8324d357c5 100644 --- a/src/components/views/rooms/PinnedEventTile.tsx +++ b/src/components/views/rooms/PinnedEventTile.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, useCallback, useState } from "react"; +import React, { type JSX, useCallback, useId, useState } from "react"; import { EventTimeline, EventType, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; import { IconButton, Menu, MenuItem, Separator, Tooltip } from "@vector-im/compound-web"; import ViewIcon from "@vector-im/compound-design-tokens/assets/web/icons/visibility-on"; @@ -67,6 +67,7 @@ export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTi const isInThread = Boolean(event.threadRootId); const displayThreadInfo = !event.isThreadRoot && isInThread; + const id = useId(); return (
@@ -85,9 +86,10 @@ export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTi {event.sender?.name || sender} - +
+ } diff --git a/src/components/views/rooms/PinnedMessageBanner.tsx b/src/components/views/rooms/PinnedMessageBanner.tsx index 2b9f335c7f..200b35a73d 100644 --- a/src/components/views/rooms/PinnedMessageBanner.tsx +++ b/src/components/views/rooms/PinnedMessageBanner.tsx @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, useEffect, useRef, useState } from "react"; +import React, { type JSX, useEffect, useId, useRef, useState } from "react"; import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin-solid"; import { Button } from "@vector-im/compound-web"; import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; @@ -64,9 +64,13 @@ export function PinnedMessageBanner({ setCurrentEventIndex(() => eventCount - 1); }, [eventCount]); + const isLastMessage = currentEventIndex === eventCount - 1; + const pinnedEvent = pinnedEvents[currentEventIndex]; useNotifyTimeline(pinnedEvent, resizeNotifier); + const id = useId(); + if (!pinnedEvent) return null; const shouldUseMessageEvent = pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure(); @@ -90,18 +94,24 @@ export function PinnedMessageBanner({ return (
should show two pinned messages 1`] = ` class="mx_PinnedEventTile_top" > @alice:example.org
unpin all should not allow to unpinall 1`] = `

unpin all should not allow to unpinall 1`] = ` class="mx_PinnedEventTile_top" > @alice:example.org
", () => { jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]); const { asFragment, rerender } = renderBanner(); - await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." })); + await expect(screen.findByText("Second pinned message")).resolves.toBeVisible(); + await userEvent.click( + screen.getByRole("button", { + name: "View the pinned message in the timeline and the newest pinned message here", + }), + ); expect(screen.getByText("First pinned message")).toBeVisible(); jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([ @@ -162,7 +167,11 @@ describe("", () => { renderBanner(); await expect(screen.findByText("Second pinned message")).resolves.toBeVisible(); - await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." })); + await userEvent.click( + screen.getByRole("button", { + name: "View the pinned message in the timeline and the newest pinned message here", + }), + ); expect(screen.getByText("First pinned message")).toBeVisible(); expect(screen.getByTestId("banner-counter")).toHaveTextContent("1 of 2 Pinned messages"); expect(dis.dispatch).toHaveBeenCalledWith({ @@ -173,7 +182,11 @@ describe("", () => { metricsTrigger: undefined, // room doesn't change }); - await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." })); + await userEvent.click( + screen.getByRole("button", { + name: "View the pinned message in the timeline and the next oldest pinned message here", + }), + ); expect(screen.getByText("Second pinned message")).toBeVisible(); expect(screen.getByTestId("banner-counter")).toHaveTextContent("2 of 2 Pinned messages"); expect(dis.dispatch).toHaveBeenCalledWith({ @@ -224,7 +237,11 @@ describe("", () => { // The banner is displayed, so we need to resize the timeline expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalledTimes(1); - await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." })); + await userEvent.click( + screen.getByRole("button", { + name: "View the pinned message in the timeline and the newest pinned message here", + }), + ); await expect(screen.findByText("First pinned message")).resolves.toBeVisible(); // The banner is already displayed, so we don't need to resize the timeline expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalledTimes(1); diff --git a/test/unit-tests/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap index 0201e03073..0486496986 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap @@ -25,12 +25,13 @@ exports[` should render pinned event 1`] = ` class="mx_PinnedEventTile_top" > @alice:server.org
should render pinned event with thread info 1`] = ` class="mx_PinnedEventTile_top" > @alice:server.org
should render pinned event with thread info 1`] = ` exports[` should render the menu with all the options 1`] = `
should render the menu with all the options 1`] = ` data-side="right" data-state="open" dir="ltr" - id="radix-«r11»" + id="radix-«r15»" role="menu" style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;" tabindex="-1" @@ -374,7 +378,7 @@ exports[` should render the menu with all the options 1`] = ` exports[` should render the menu without unpin and delete 1`] = `
should render the menu without unpin and delete 1`] data-side="right" data-state="open" dir="ltr" - id="radix-«rm»" + id="radix-«rp»" role="menu" style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;" tabindex="-1" diff --git a/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap index 7b4bedeffa..b3ddc9f8ad 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap @@ -3,18 +3,21 @@ exports[` should display display a poll event 1`] = `
Message #49
  • @user48:example.com
    Message #48
  • @user47:example.com
    Message #47
  • @user46:example.com
    Message #46
  • @user45:example.com
    Message #45
  • @user44:example.com
    Message #44
  • @user43:example.com
    Message #43
  • @user42:example.com
    Message #42
  • @user41:example.com
    Message #41
  • @user40:example.com
    Message #40
  • @user39:example.com
    Message #39
  • @user38:example.com
    Message #38
  • @user37:example.com
    Message #37
  • @user36:example.com
    Message #36
  • @user35:example.com
    Message #35
  • @user34:example.com
    Message #34
  • @user33:example.com
    Message #33
  • @user32:example.com
    Message #32
  • @user31:example.com
    Message #31
  • @user30:example.com
    Message #30
  • @user29:example.com
    Message #29
  • @user28:example.com
    Message #28
  • @user27:example.com
    Message #27
  • @user26:example.com
    Message #26
  • @user25:example.com
    Message #25
  • @user24:example.com
    Message #24
  • @user23:example.com
    Message #23
  • @user22:example.com
    Message #22
  • @user21:example.com
    Message #21
  • @user20:example.com
    Message #20
  • @user19:example.com
    Message #19
  • @user18:example.com
    Message #18
  • @user17:example.com
    Message #17
  • @user16:example.com
    Message #16
  • @user15:example.com
    Message #15
  • @user14:example.com
    Message #14
  • @user13:example.com
    Message #13
  • @user12:example.com
    Message #12
  • @user11:example.com
    Message #11
  • @user10:example.com
    Message #10
  • @user9:example.com
    Message #9
  • @user8:example.com
    Message #8
  • @user7:example.com
    Message #7
  • @user6:example.com
    Message #6
  • @user5:example.com
    Message #5
  • @user4:example.com
    Message #4
  • @user3:example.com
    Message #3
  • @user2:example.com
    Message #2
  • @user1:example.com
    Message #1
  • @user0:example.com
    Message #0
  • +
  • @user49:example.com
    Message #49
  • @user48:example.com
    Message #48
  • @user47:example.com
    Message #47
  • @user46:example.com
    Message #46
  • @user45:example.com
    Message #45
  • @user44:example.com
    Message #44
  • @user43:example.com
    Message #43
  • @user42:example.com
    Message #42
  • @user41:example.com
    Message #41
  • @user40:example.com
    Message #40
  • @user39:example.com
    Message #39
  • @user38:example.com
    Message #38
  • @user37:example.com
    Message #37
  • @user36:example.com
    Message #36
  • @user35:example.com
    Message #35
  • @user34:example.com
    Message #34
  • @user33:example.com
    Message #33
  • @user32:example.com
    Message #32
  • @user31:example.com
    Message #31
  • @user30:example.com
    Message #30
  • @user29:example.com
    Message #29
  • @user28:example.com
    Message #28
  • @user27:example.com
    Message #27
  • @user26:example.com
    Message #26
  • @user25:example.com
    Message #25
  • @user24:example.com
    Message #24
  • @user23:example.com
    Message #23
  • @user22:example.com
    Message #22
  • @user21:example.com
    Message #21
  • @user20:example.com
    Message #20
  • @user19:example.com
    Message #19
  • @user18:example.com
    Message #18
  • @user17:example.com
    Message #17
  • @user16:example.com
    Message #16
  • @user15:example.com
    Message #15
  • @user14:example.com
    Message #14
  • @user13:example.com
    Message #13
  • @user12:example.com
    Message #12
  • @user11:example.com
    Message #11
  • @user10:example.com
    Message #10
  • @user9:example.com
    Message #9
  • @user8:example.com
    Message #8
  • @user7:example.com
    Message #7
  • @user6:example.com
    Message #6
  • @user5:example.com
    Message #5
  • @user4:example.com
    Message #4
  • @user3:example.com
    Message #3
  • @user2:example.com
    Message #2
  • @user1:example.com
    Message #1
  • @user0:example.com
    Message #0