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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
*
|
||||
<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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user