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
This commit is contained in:
Florian Duros
2025-09-02 15:03:01 +02:00
committed by GitHub
parent 1925132a3c
commit eb086bd795
17 changed files with 205 additions and 102 deletions

View File

@@ -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;
}

View File

@@ -51,6 +51,11 @@ interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper
getRelationsForEvent?: GetRelationsForEvent;
isSeeingThroughMessageHiddenForModeration?: boolean;
/**
* Optional ID for the root element.
*/
id?: string;
}
export interface IOperableEventTile {
@@ -308,6 +313,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
getRelationsForEvent: this.props.getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration: this.props.isSeeingThroughMessageHiddenForModeration,
inhibitInteraction: this.props.inhibitInteraction,
id: this.props.id,
};
if (hasCaption) {
return <CaptionBody {...bodyProps} WrappedBodyType={BodyType} />;

View File

@@ -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<HTMLProps<HTMLDivElement>>): JSX.Element {
return (
<div className="mx_PinnedMessageBadge">
<div {...props} className="mx_PinnedMessageBadge">
<PinIcon width="16px" height="16px" />
{_t("room|pinned_message_badge")}
</div>

View File

@@ -384,7 +384,12 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
if (isEmote) {
return (
<div className="mx_MEmoteBody mx_EventTile_content" onClick={this.onBodyLinkClick} dir="auto">
<div
id={this.props.id}
className="mx_MEmoteBody mx_EventTile_content"
onClick={this.onBodyLinkClick}
dir="auto"
>
*&nbsp;
<span className="mx_MEmoteBody_sender" onClick={this.onEmoteSenderClick}>
{mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()}
@@ -397,7 +402,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
if (isNotice) {
return (
<div className="mx_MNoticeBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
<div id={this.props.id} className="mx_MNoticeBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
{body}
{widgets}
</div>
@@ -405,14 +410,14 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
if (isCaption) {
return (
<div className="mx_MTextBody mx_EventTile_caption" onClick={this.onBodyLinkClick}>
<div id={this.props.id} className="mx_MTextBody mx_EventTile_caption" onClick={this.onBodyLinkClick}>
{body}
{widgets}
</div>
);
}
return (
<div className="mx_MTextBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
<div id={this.props.id} className="mx_MTextBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
{body}
{widgets}
</div>

View File

@@ -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<EventTileProps, IState>
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<EventTileProps, IState>
let pinnedMessageBadge: JSX.Element | undefined;
if (PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
pinnedMessageBadge = <PinnedMessageBadge />;
pinnedMessageBadge = <PinnedMessageBadge aria-describedby={id} tabIndex={0} />;
}
let reactionsRow: JSX.Element | undefined;
@@ -1237,7 +1240,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{avatar}
{sender}
</div>,
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
<div id={id} className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{this.renderContextMenu()}
{replyChain}
{renderTile(TimelineRenderingType.Thread, {
@@ -1425,7 +1428,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{sender}
{ircPadlock}
{avatar}
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
<div id={id} className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{this.renderContextMenu()}
{groupTimestamp}
{groupPadlock}

View File

@@ -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 (
<div className="mx_PinnedEventTile" role="listitem">
@@ -85,9 +86,10 @@ export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTi
{event.sender?.name || sender}
</span>
</Tooltip>
<PinMenu event={event} room={room} permalinkCreator={permalinkCreator} />
<PinMenu event={event} room={room} permalinkCreator={permalinkCreator} contentId={id} />
</div>
<MessageEvent
id={id}
mxEvent={event}
maxImageHeight={150}
permalinkCreator={permalinkCreator}
@@ -131,12 +133,17 @@ export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTi
/**
* Properties for {@link PinMenu}.
*/
interface PinMenuProps extends PinnedEventTileProps {}
interface PinMenuProps extends PinnedEventTileProps {
/**
* HTML ID of the pinned message content.
*/
contentId: string;
}
/**
* A popover menu with actions on the pinned event
*/
function PinMenu({ event, room, permalinkCreator }: PinMenuProps): JSX.Element {
function PinMenu({ event, room, permalinkCreator, contentId }: PinMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const matrixClient = useMatrixClientContext();
@@ -217,7 +224,11 @@ function PinMenu({ event, room, permalinkCreator }: PinMenuProps): JSX.Element {
side="right"
align="start"
trigger={
<IconButton size="24px" aria-label={_t("right_panel|pinned_messages|menu")}>
<IconButton
size="24px"
aria-label={_t("right_panel|pinned_messages|menu")}
aria-describedby={contentId}
>
<TriggerIcon />
</IconButton>
}

View File

@@ -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 (
<div
role="region"
className="mx_PinnedMessageBanner"
data-single-message={isSinglePinnedEvent}
aria-label={_t("room|pinned_message_banner|description")}
data-testid="pinned-message-banner"
>
<button
aria-label={_t("room|pinned_message_banner|go_to_message")}
aria-label={
isLastMessage
? _t("room|pinned_message_banner|go_to_newest_message")
: _t("room|pinned_message_banner|go_to_next_message")
}
aria-describedby={id}
type="button"
className="mx_PinnedMessageBanner_main"
onClick={onBannerClick}
>
<div className="mx_PinnedMessageBanner_content">
<div className="mx_PinnedMessageBanner_content" id={id}>
<Indicators count={eventCount} currentIndex={currentEventIndex} />
<PinIcon width="20px" height="20px" className="mx_PinnedMessageBanner_PinIcon" />
{!isSinglePinnedEvent && (