Correct accessibility labels for unread rooms in spotlight (#9003)

* Correct accessibility labels for unread rooms in spotlight
* Improve public room result accessibility
* Improve room result accessibility
This commit is contained in:
Janne Mareike Koschinski 2022-07-11 13:34:23 +02:00 committed by GitHub
parent 375ff265db
commit a9d6896502
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 206 additions and 98 deletions

View file

@ -49,9 +49,9 @@ import BaseAvatar from "../avatars/BaseAvatar";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { ButtonEvent } from "../elements/AccessibleButton";
import { roomContextDetailsText } from "../../../utils/i18n-helpers";
import { isLocationEvent } from "../../../utils/EventUtils";
import { isSelfLocation, locationEventGeoUri } from "../../../utils/location";
import { RoomContextDetails } from "../rooms/RoomContextDetails";
const AVATAR_SIZE = 30;
@ -130,8 +130,6 @@ const Entry: React.FC<IEntryProps> = ({ room, type, content, matrixClient: cli,
/>;
}
const detailsText = roomContextDetailsText(room);
return <div className="mx_ForwardList_entry">
<AccessibleTooltipButton
className="mx_ForwardList_roomButton"
@ -141,9 +139,7 @@ const Entry: React.FC<IEntryProps> = ({ room, type, content, matrixClient: cli,
>
<DecoratedRoomAvatar room={room} avatarSize={32} />
<span className="mx_ForwardList_entry_name">{ room.name }</span>
{ detailsText && <span className="mx_ForwardList_entry_detail">
{ detailsText }
</span> }
<RoomContextDetails component="span" className="mx_ForwardList_entry_detail" room={room} />
</AccessibleTooltipButton>
<AccessibleTooltipButton
kind={sendState === SendState.Failed ? "danger_outline" : "primary_outline"}

View file

@ -24,7 +24,14 @@ import { getDisplayAliasForRoom } from "../../../structures/RoomDirectory";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800;
export function PublicRoomResultDetails({ room }: { room: IPublicRoomsChunkRoom }): JSX.Element {
interface Props {
room: IPublicRoomsChunkRoom;
labelId: string;
descriptionId: string;
detailsId: string;
}
export function PublicRoomResultDetails({ room, labelId, descriptionId, detailsId }: Props): JSX.Element {
let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
if (name.length > MAX_NAME_LENGTH) {
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
@ -41,12 +48,12 @@ export function PublicRoomResultDetails({ room }: { room: IPublicRoomsChunkRoom
return (
<div className="mx_SpotlightDialog_result_publicRoomDetails">
<div className="mx_SpotlightDialog_result_publicRoomHeader">
<span className="mx_SpotlightDialog_result_publicRoomName">{ name }</span>
<span className="mx_SpotlightDialog_result_publicRoomAlias">
<span id={labelId} className="mx_SpotlightDialog_result_publicRoomName">{ name }</span>
<span id={descriptionId} className="mx_SpotlightDialog_result_publicRoomAlias">
{ room.canonical_alias ?? room.room_id }
</span>
</div>
<div className="mx_SpotlightDialog_result_publicRoomDescription">
<div id={detailsId} className="mx_SpotlightDialog_result_publicRoomDescription">
<span className="mx_SpotlightDialog_result_publicRoomMemberCount">
{ _t("%(count)s Members", {
count: room.num_joined_members,

View file

@ -1,31 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { roomContextDetailsText, spaceContextDetailsText } from "../../../../utils/i18n-helpers";
export const RoomResultDetails = ({ room }: { room: Room }) => {
const contextDetails = room.isSpaceRoom() ? spaceContextDetailsText(room) : roomContextDetailsText(room);
if (contextDetails) {
return <div className="mx_SpotlightDialog_result_details">
{ contextDetails }
</div>;
}
return null;
};

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch";
import classNames from "classnames";
import { capitalize, sum } from "lodash";
import { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch";
import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
import { IPublicRoomsChunkRoom, MatrixClient, RoomMember, RoomType } from "matrix-js-sdk/src/matrix";
import { Room } from "matrix-js-sdk/src/models/room";
@ -50,6 +50,7 @@ import { useDebouncedCallback } from "../../../../hooks/spotlight/useDebouncedCa
import { useRecentSearches } from "../../../../hooks/spotlight/useRecentSearches";
import { useProfileInfo } from "../../../../hooks/useProfileInfo";
import { usePublicRoomDirectory } from "../../../../hooks/usePublicRoomDirectory";
import { useFeatureEnabled } from "../../../../hooks/useSettings";
import { useSpaceResults } from "../../../../hooks/useSpaceResults";
import { useUserDirectory } from "../../../../hooks/useUserDirectory";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
@ -63,6 +64,7 @@ import SdkConfig from "../../../../SdkConfig";
import { SettingLevel } from "../../../../settings/SettingLevel";
import SettingsStore from "../../../../settings/SettingsStore";
import { BreadcrumbsStore } from "../../../../stores/BreadcrumbsStore";
import { RoomNotificationState } from "../../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../../stores/notifications/RoomNotificationStateStore";
import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import { RoomViewStore } from "../../../../stores/RoomViewStore";
@ -78,6 +80,7 @@ import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar";
import { SearchResultAvatar } from "../../avatars/SearchResultAvatar";
import { NetworkDropdown } from "../../directory/NetworkDropdown";
import AccessibleButton from "../../elements/AccessibleButton";
import LabelledCheckbox from "../../elements/LabelledCheckbox";
import Spinner from "../../elements/Spinner";
import NotificationBadge from "../../rooms/NotificationBadge";
import BaseDialog from "../BaseDialog";
@ -85,10 +88,8 @@ import FeedbackDialog from "../FeedbackDialog";
import { IDialogProps } from "../IDialogProps";
import { Option } from "./Option";
import { PublicRoomResultDetails } from "./PublicRoomResultDetails";
import { RoomResultDetails } from "./RoomResultDetails";
import { RoomContextDetails } from "../../rooms/RoomContextDetails";
import { TooltipOption } from "./TooltipOption";
import LabelledCheckbox from "../../elements/LabelledCheckbox";
import { useFeatureEnabled } from "../../../../hooks/useSettings";
const MAX_RECENT_SEARCHES = 10;
const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
@ -259,6 +260,22 @@ const findVisibleRoomMembers = (cli: MatrixClient, filterDMs = true) => {
).filter(it => it.userId !== cli.getUserId());
};
const roomAriaUnreadLabel = (room: Room, notification: RoomNotificationState): string | undefined => {
if (notification.hasMentions) {
return _t("%(count)s unread messages including mentions.", {
count: notification.count,
});
} else if (notification.hasUnreadCount) {
return _t("%(count)s unread messages.", {
count: notification.count,
});
} else if (notification.isUnread) {
return _t("Unread messages.");
} else {
return undefined;
}
};
interface IDirectoryOpts {
limit: number;
query: string;
@ -523,6 +540,12 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
if (trimmedQuery || filter !== null) {
const resultMapper = (result: Result): JSX.Element => {
if (isRoomResult(result)) {
const notification = RoomNotificationStateStore.instance.getRoomState(result.room);
const unreadLabel = roomAriaUnreadLabel(result.room, notification);
const ariaProperties = {
"aria-label": unreadLabel ? `${result.room.name} ${unreadLabel}` : result.room.name,
"aria-details": `mx_SpotlightDialog_button_result_${result.room.roomId}_details`,
};
return (
<Option
id={`mx_SpotlightDialog_button_result_${result.room.roomId}`}
@ -530,11 +553,20 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
onClick={(ev) => {
viewRoom(result.room.roomId, true, ev?.type !== "click");
}}
{...ariaProperties}
>
<DecoratedRoomAvatar room={result.room} avatarSize={AVATAR_SIZE} tooltipProps={{ tabIndex: -1 }} />
<DecoratedRoomAvatar
room={result.room}
avatarSize={AVATAR_SIZE}
tooltipProps={{ tabIndex: -1 }}
/>
{ result.room.name }
<NotificationBadge notification={RoomNotificationStateStore.instance.getRoomState(result.room)} />
<RoomResultDetails room={result.room} />
<NotificationBadge notification={notification} />
<RoomContextDetails
id={`mx_SpotlightDialog_button_result_${result.room.roomId}_details`}
className="mx_SpotlightDialog_result_details"
room={result.room}
/>
</Option>
);
}
@ -547,10 +579,17 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
startDm(cli, [result.member]);
onFinished();
}}
aria-label={result.member instanceof RoomMember
? result.member.rawDisplayName
: result.member.name}
aria-describedby={`mx_SpotlightDialog_button_result_${result.member.userId}_details`}
>
<SearchResultAvatar user={result.member} size={AVATAR_SIZE} />
{ result.member instanceof RoomMember ? result.member.rawDisplayName : result.member.name }
<div className="mx_SpotlightDialog_result_details">
<div
id={`mx_SpotlightDialog_button_result_${result.member.userId}_details`}
className="mx_SpotlightDialog_result_details"
>
{ result.member.userId }
</div>
</Option>
@ -575,6 +614,9 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
>
{ _t(clientRoom ? "View" : "Join") }
</AccessibleButton>}
aria-labelledby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_name`}
aria-describedby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`}
aria-details={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_details`}
>
<BaseAvatar
className="mx_SearchResultAvatar"
@ -586,7 +628,12 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
width={AVATAR_SIZE}
height={AVATAR_SIZE}
/>
<PublicRoomResultDetails room={result.publicRoom} />
<PublicRoomResultDetails
room={result.publicRoom}
labelId={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_name`}
descriptionId={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`}
detailsId={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_details`}
/>
</Option>
);
}
@ -608,8 +655,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
let peopleSection: JSX.Element;
if (results[Section.People].length) {
peopleSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<h4>{ _t("Recent Conversations") }</h4>
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_people">
<h4 id="mx_SpotlightDialog_section_people">
{ _t("Recent Conversations") }
</h4>
<div>
{ results[Section.People].slice(0, SECTION_LIMIT).map(resultMapper) }
</div>
@ -620,8 +672,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
let suggestionsSection: JSX.Element;
if (results[Section.Suggestions].length && filter === Filter.People) {
suggestionsSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<h4>{ _t("Suggestions") }</h4>
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_suggestions">
<h4 id="mx_SpotlightDialog_section_suggestions">
{ _t("Suggestions") }
</h4>
<div>
{ results[Section.Suggestions].slice(0, SECTION_LIMIT).map(resultMapper) }
</div>
@ -632,8 +689,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
let roomsSection: JSX.Element;
if (results[Section.Rooms].length) {
roomsSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<h4>{ _t("Rooms") }</h4>
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_rooms">
<h4 id="mx_SpotlightDialog_section_rooms">
{ _t("Rooms") }
</h4>
<div>
{ results[Section.Rooms].slice(0, SECTION_LIMIT).map(resultMapper) }
</div>
@ -644,8 +706,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
let spacesSection: JSX.Element;
if (results[Section.Spaces].length) {
spacesSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<h4>{ _t("Spaces you're in") }</h4>
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_spaces">
<h4 id="mx_SpotlightDialog_section_spaces">
{ _t("Spaces you're in") }
</h4>
<div>
{ results[Section.Spaces].slice(0, SECTION_LIMIT).map(resultMapper) }
</div>
@ -656,9 +723,14 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
let publicRoomsSection: JSX.Element;
if (filter === Filter.PublicRooms) {
publicRoomsSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_publicRooms">
<div className="mx_SpotlightDialog_sectionHeader">
<h4>{ _t("Suggestions") }</h4>
<h4 id="mx_SpotlightDialog_section_publicRooms">
{ _t("Suggestions") }
</h4>
<div className="mx_SpotlightDialog_options">
{ exploringPublicSpacesEnabled && <>
<LabelledCheckbox
@ -692,8 +764,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
let spaceRoomsSection: JSX.Element;
if (spaceResults.length && activeSpace && filter === null) {
spaceRoomsSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<h4>{ _t("Other rooms in %(spaceName)s", { spaceName: activeSpace.name }) }</h4>
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_spaceRooms">
<h4 id="mx_SpotlightDialog_section_spaceRooms">
{ _t("Other rooms in %(spaceName)s", { spaceName: activeSpace.name }) }
</h4>
<div>
{ spaceResults.slice(0, SECTION_LIMIT).map((room: IHierarchyRoom): JSX.Element => (
<Option
@ -807,8 +884,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
let groupChatSection: JSX.Element;
if (filter === Filter.People) {
groupChatSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
<h4>{ _t('Other options') }</h4>
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches"
role="group"
aria-labelledby="mx_SpotlightDialog_section_groupChat">
<h4 id="mx_SpotlightDialog_section_groupChat">
{ _t('Other options') }
</h4>
<Option
id="mx_SpotlightDialog_button_startGroupChat"
className="mx_SpotlightDialog_startGroupChat"
@ -823,8 +905,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
let messageSearchSection: JSX.Element;
if (filter === null) {
messageSearchSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
<h4>{ _t("Other searches") }</h4>
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches"
role="group"
aria-labelledby="mx_SpotlightDialog_section_messageSearch">
<h4 id="mx_SpotlightDialog_section_messageSearch">
{ _t("Other searches") }
</h4>
<div className="mx_SpotlightDialog_otherSearches_messageSearchText">
{ _t(
"To search messages, look for this icon at the top of a room <icon/>",
@ -859,36 +946,59 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
// Firefox sometimes makes this element focusable due to overflow,
// so force it out of tab order by default.
tabIndex={-1}
aria-labelledby="mx_SpotlightDialog_section_recentSearches"
>
<h4>
<h4 id="mx_SpotlightDialog_section_recentSearches">
{ _t("Recent searches") }
<AccessibleButton kind="link" onClick={clearRecentSearches}>
{ _t("Clear") }
</AccessibleButton>
</h4>
<div>
{ recentSearches.map(room => (
<Option
id={`mx_SpotlightDialog_button_recentSearch_${room.roomId}`}
key={room.roomId}
onClick={(ev) => {
viewRoom(room.roomId, true, ev?.type !== "click");
}}
>
<DecoratedRoomAvatar room={room} avatarSize={AVATAR_SIZE} tooltipProps={{ tabIndex: -1 }} />
{ room.name }
<NotificationBadge notification={RoomNotificationStateStore.instance.getRoomState(room)} />
<RoomResultDetails room={room} />
</Option>
)) }
{ recentSearches.map(room => {
const notification = RoomNotificationStateStore.instance.getRoomState(room);
const unreadLabel = roomAriaUnreadLabel(room, notification);
const ariaProperties = {
"aria-label": unreadLabel ? `${room.name} ${unreadLabel}` : room.name,
"aria-details": `mx_SpotlightDialog_button_recentSearch_${room.roomId}_details`,
};
return (
<Option
id={`mx_SpotlightDialog_button_recentSearch_${room.roomId}`}
key={room.roomId}
onClick={(ev) => {
viewRoom(room.roomId, true, ev?.type !== "click");
}}
{...ariaProperties}
>
<DecoratedRoomAvatar
room={room}
avatarSize={AVATAR_SIZE}
tooltipProps={{ tabIndex: -1 }}
/>
{ room.name }
<NotificationBadge notification={notification} />
<RoomContextDetails
id={`mx_SpotlightDialog_button_recentSearch_${room.roomId}_details`}
className="mx_SpotlightDialog_result_details"
room={room}
/>
</Option>
);
}) }
</div>
</div>
);
}
content = <>
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_recentlyViewed" role="group">
<h4>{ _t("Recently viewed") }</h4>
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_recentlyViewed"
role="group"
aria-labelledby="mx_SpotlightDialog_section_recentlyViewed">
<h4 id="mx_SpotlightDialog_section_recentlyViewed">
{ _t("Recently viewed") }
</h4>
<div>
{ BreadcrumbsStore.instance.rooms
.filter(r => r.roomId !== RoomViewStore.instance.getRoomId())