diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index bdaada0d15..a73658d916 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -86,11 +86,15 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations .mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton { // Cheaty way to return the occupied space to the filter input + flex-basis: 0; margin: 0; width: 0; - // Don't forget to hide the masked ::before icon - visibility: hidden; + // Don't forget to hide the masked ::before icon, + // using display:none or visibility:hidden would break accessibility + &::before { + content: none; + } } .mx_LeftPanel2_exploreButton { diff --git a/src/RoomNotifsTypes.ts b/src/RoomNotifsTypes.ts new file mode 100644 index 0000000000..0e7093e434 --- /dev/null +++ b/src/RoomNotifsTypes.ts @@ -0,0 +1,24 @@ +/* +Copyright 2020 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 { + ALL_MESSAGES, + ALL_MESSAGES_LOUD, + MENTIONS_ONLY, + MUTE, +} from "./RoomNotifs"; + +export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE; diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js index e43b0d1431..bda194ddd0 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.js @@ -23,6 +23,8 @@ import classNames from 'classnames'; import {Key} from "../../Keyboard"; import * as sdk from "../../index"; import AccessibleButton from "../views/elements/AccessibleButton"; +import StyledCheckbox from "../views/elements/StyledCheckbox"; +import StyledRadioButton from "../views/elements/StyledRadioButton"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -421,6 +423,54 @@ MenuItemCheckbox.propTypes = { onClick: PropTypes.func.isRequired, }; +// Semantic component for representing a styled role=menuitemcheckbox +export const StyledMenuItemCheckbox = ({children, label, onChange, onClose, checked, disabled=false, ...props}) => { + const onKeyDown = (e) => { + if (e.key === Key.ENTER || e.key === Key.SPACE) { + e.stopPropagation(); + e.preventDefault(); + onChange(); + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + if (e.key === Key.ENTER) { + onClose(); + } + } + }; + const onKeyUp = (e) => { + // prevent the input default handler as we handle it on keydown to match + // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html + if (e.key === Key.SPACE || e.key === Key.ENTER) { + e.stopPropagation(); + e.preventDefault(); + } + }; + return ( + <StyledCheckbox + {...props} + role="menuitemcheckbox" + aria-checked={checked} + checked={checked} + aria-disabled={disabled} + tabIndex={-1} + aria-label={label} + onChange={onChange} + onKeyDown={onKeyDown} + onKeyUp={onKeyUp} + > + { children } + </StyledCheckbox> + ); +}; +StyledMenuItemCheckbox.propTypes = { + ...StyledCheckbox.propTypes, + label: PropTypes.string, // optional + checked: PropTypes.bool.isRequired, + disabled: PropTypes.bool, // optional + className: PropTypes.string, // optional + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER +}; + // Semantic component for representing a role=menuitemradio export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -439,6 +489,54 @@ MenuItemRadio.propTypes = { onClick: PropTypes.func.isRequired, }; +// Semantic component for representing a styled role=menuitemradio +export const StyledMenuItemRadio = ({children, label, onChange, onClose, checked=false, disabled=false, ...props}) => { + const onKeyDown = (e) => { + if (e.key === Key.ENTER || e.key === Key.SPACE) { + e.stopPropagation(); + e.preventDefault(); + onChange(); + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + if (e.key === Key.ENTER) { + onClose(); + } + } + }; + const onKeyUp = (e) => { + // prevent the input default handler as we handle it on keydown to match + // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html + if (e.key === Key.SPACE || e.key === Key.ENTER) { + e.stopPropagation(); + e.preventDefault(); + } + }; + return ( + <StyledRadioButton + {...props} + role="menuitemradio" + aria-checked={checked} + checked={checked} + aria-disabled={disabled} + tabIndex={-1} + aria-label={label} + onChange={onChange} + onKeyDown={onKeyDown} + onKeyUp={onKeyUp} + > + { children } + </StyledRadioButton> + ); +}; +StyledMenuItemRadio.propTypes = { + ...StyledMenuItemRadio.propTypes, + label: PropTypes.string, // optional + checked: PropTypes.bool.isRequired, + disabled: PropTypes.bool, // optional + className: PropTypes.string, // optional + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER +}; + // Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset export const toRightOf = (elementRect, chevronOffset=12) => { const left = elementRect.right + window.pageXOffset + 3; diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 6c790f3318..4b954d7843 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -56,6 +56,15 @@ interface IState { showTagPanel: boolean; } +// List of CSS classes which should be included in keyboard navigation within the room list +const cssClasses = [ + "mx_RoomSearch_input", + "mx_RoomSearch_icon", // minimized <RoomSearch /> + "mx_RoomSublist2_headerText", + "mx_RoomTile2", + "mx_RoomSublist2_showNButton", +]; + export default class LeftPanel2 extends React.Component<IProps, IState> { private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private tagPanelWatcherRef: string; @@ -212,10 +221,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> { if (element) { classes = element.classList; } - } while (element && !( - classes.contains("mx_RoomTile2") || - classes.contains("mx_RoomSublist2_headerText") || - classes.contains("mx_RoomSearch_input"))); + } while (element && !cssClasses.some(c => classes.contains(c))); if (element) { element.focus(); @@ -246,7 +252,12 @@ export default class LeftPanel2 extends React.Component<IProps, IState> { private renderSearchExplore(): React.ReactNode { return ( - <div className="mx_LeftPanel2_filterContainer" onFocus={this.onFocus} onBlur={this.onBlur}> + <div + className="mx_LeftPanel2_filterContainer" + onFocus={this.onFocus} + onBlur={this.onBlur} + onKeyDown={this.onKeyDown} + > <RoomSearch onQueryUpdate={this.onSearch} isMinimized={this.props.isMinimized} @@ -256,7 +267,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> { // TODO fix the accessibility of this: https://github.com/vector-im/riot-web/issues/14180 className="mx_LeftPanel2_exploreButton" onClick={this.onExplore} - alt={_t("Explore rooms")} + title={_t("Explore rooms")} /> </div> ); diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 7ed2acf276..15f3bd5b54 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -149,7 +149,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> { let clearButton = ( <AccessibleButton tabIndex={-1} - className='mx_RoomSearch_clearButton' + title={_t("Clear filter")} + className="mx_RoomSearch_clearButton" onClick={this.clearInput} /> ); @@ -157,8 +158,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> { if (this.props.isMinimized) { icon = ( <AccessibleButton - tabIndex={-1} - className='mx_RoomSearch_icon' + title={_t("Search rooms")} + className="mx_RoomSearch_icon" onClick={this.openSearch} /> ); diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 5955a046a4..221ec07439 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -329,7 +329,7 @@ export default class UserMenu extends React.Component<IProps, IState> { className={classes} onClick={this.onOpenMenuClick} inputRef={this.buttonRef} - label={_t("Account settings")} + label={_t("User menu")} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} > diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index 508691e5fd..53e8d0072b 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -132,7 +132,7 @@ const BaseAvatar = (props) => { ); } else { return ( - <span className="mx_BaseAvatar" ref={inputRef} {...otherProps}> + <span className="mx_BaseAvatar" ref={inputRef} {...otherProps} role="presentation"> { textNode } { imgNode } </span> diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx index 91bef0fc3d..0d7c3b8ec1 100644 --- a/src/components/views/rooms/RoomList2.tsx +++ b/src/components/views/rooms/RoomList2.tsx @@ -274,9 +274,6 @@ export default class RoomList2 extends React.Component<IProps, IState> { className="mx_RoomList2" role="tree" aria-label={_t("Rooms")} - // Firefox sometimes makes this element focusable due to - // overflow:scroll;, so force it out of tab order. - tabIndex={-1} >{sublists}</div> )} </RovingTabIndexProvider> diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 90ca8b2d4b..18f8f4e2f6 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -26,8 +26,12 @@ import AccessibleButton from "../../views/elements/AccessibleButton"; import RoomTile2 from "./RoomTile2"; import { ResizableBox, ResizeCallbackData } from "react-resizable"; import { ListLayout } from "../../../stores/room-list/ListLayout"; -import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; -import StyledRadioButton from "../elements/StyledRadioButton"; +import { + ContextMenu, + ContextMenuButton, + StyledMenuItemCheckbox, + StyledMenuItemRadio, +} from "../../structures/ContextMenu"; import RoomListStore from "../../../stores/room-list/RoomListStore2"; import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; @@ -62,7 +66,7 @@ interface IProps { onAddRoom?: () => void; addRoomLabel: string; isInvite: boolean; - layout: ListLayout; + layout?: ListLayout; isMinimized: boolean; tagId: TagID; onResize: () => void; @@ -136,11 +140,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { }; private onShowAllClick = () => { + // TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180 this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.forceUpdate(); // because the layout doesn't trigger a re-render }; private onShowLessClick = () => { + // TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180 this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; this.forceUpdate(); // because the layout doesn't trigger a re-render }; @@ -203,6 +209,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { dis.dispatch({ action: 'view_room', room_id: room.roomId, + show_room_tile: true, // to make sure the room gets scrolled into view }); } }; @@ -323,22 +330,24 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { <hr /> <div> <div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div> - <StyledCheckbox + <StyledMenuItemCheckbox + onClose={this.onCloseMenu} onChange={this.onUnreadFirstChanged} checked={isUnreadFirst} > {_t("Always show first")} - </StyledCheckbox> + </StyledMenuItemCheckbox> </div> <hr /> <div> <div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div> - <StyledCheckbox + <StyledMenuItemCheckbox + onClose={this.onCloseMenu} onChange={this.onMessagePreviewChanged} checked={this.props.layout.showPreviews} > {_t("Message preview")} - </StyledCheckbox> + </StyledMenuItemCheckbox> </div> </React.Fragment> ); @@ -354,20 +363,22 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { <div className="mx_RoomSublist2_contextMenu"> <div> <div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div> - <StyledRadioButton + <StyledMenuItemRadio + onClose={this.onCloseMenu} onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)} checked={!isAlphabetical} name={`mx_${this.props.tagId}_sortBy`} > {_t("Activity")} - </StyledRadioButton> - <StyledRadioButton + </StyledMenuItemRadio> + <StyledMenuItemRadio + onClose={this.onCloseMenu} onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)} checked={isAlphabetical} name={`mx_${this.props.tagId}_sortBy`} > {_t("A-Z")} - </StyledRadioButton> + </StyledMenuItemRadio> </div> {otherSections} </div> @@ -390,16 +401,22 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { private renderHeader(): React.ReactElement { return ( - <RovingTabIndexWrapper> + <RovingTabIndexWrapper inputRef={this.headerButton}> {({onFocus, isActive, ref}) => { const tabIndex = isActive ? 0 : -1; + let ariaLabel = _t("Jump to first unread room."); + if (this.props.tagId === DefaultTagID.Invite) { + ariaLabel = _t("Jump to first invite."); + } + const badge = ( <NotificationBadge forceCount={true} notification={this.state.notificationState} onClick={this.onBadgeClick} tabIndex={tabIndex} + aria-label={ariaLabel} /> ); @@ -440,7 +457,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { // doesn't become sticky. // The same applies to the notification badge. return ( - <div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus}> + <div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus} aria-label={this.props.label}> <div className="mx_RoomSublist2_stickable"> <AccessibleButton onFocus={onFocus} @@ -448,6 +465,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { tabIndex={tabIndex} className="mx_RoomSublist2_headerText" role="treeitem" + aria-expanded={!this.props.layout || !this.props.layout.isCollapsed} aria-level={1} onClick={this.onHeaderClick} onContextMenu={this.onContextMenu} @@ -503,12 +521,12 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { ); if (this.props.isMinimized) showMoreText = null; showNButton = ( - <div onClick={this.onShowAllClick} className={showMoreBtnClasses}> + <AccessibleButton onClick={this.onShowAllClick} className={showMoreBtnClasses} tabIndex={-1}> <span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'> {/* set by CSS masking */} </span> {showMoreText} - </div> + </AccessibleButton> ); } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) { // we have all tiles visible - add a button to show less @@ -518,13 +536,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { </span> ); if (this.props.isMinimized) showLessText = null; + // TODO Roving tab index / treeitem?: https://github.com/vector-im/riot-web/issues/14180 showNButton = ( - <div onClick={this.onShowLessClick} className={showMoreBtnClasses}> + <AccessibleButton onClick={this.onShowLessClick} className={showMoreBtnClasses} tabIndex={-1}> <span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'> {/* set by CSS masking */} </span> {showLessText} - </div> + </AccessibleButton> ); } diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 4af9ef7966..c6cd401803 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -26,17 +26,30 @@ import dis from '../../../dispatcher/dispatcher'; import { Key } from "../../../Keyboard"; import ActiveRoomObserver from "../../../ActiveRoomObserver"; import { _t } from "../../../languageHandler"; -import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu"; +import { + ContextMenu, + ContextMenuButton, + MenuItemRadio, + MenuItemCheckbox, + MenuItem, +} from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs"; +import { + getRoomNotifsState, + setRoomNotifsState, + ALL_MESSAGES, + ALL_MESSAGES_LOUD, + MENTIONS_ONLY, + MUTE, +} from "../../../RoomNotifs"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { setRoomNotifsState } from "../../../RoomNotifs"; import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; import { INotificationState } from "../../../stores/notifications/INotificationState"; import NotificationBadge from "./NotificationBadge"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { Volume } from "../../../RoomNotifsTypes"; // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 @@ -68,6 +81,8 @@ interface IState { generalMenuPosition: PartialDOMRect; } +const messagePreviewId = (roomId: string) => `mx_RoomTile2_messagePreview_${roomId}`; + const contextMenuBelow = (elementRect: PartialDOMRect) => { // align the context menu's icons with the icon which opened the context menu const left = elementRect.left + window.pageXOffset - 9; @@ -123,6 +138,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> { return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite; } + private get showMessagePreview(): boolean { + return !this.props.isMinimized && this.props.showMessagePreview; + } + public componentWillUnmount() { if (this.props.room) { ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); @@ -195,6 +214,11 @@ export default class RoomTile2 extends React.Component<IProps, IState> { // TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211 // TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210 + + if ((ev as React.KeyboardEvent).key === Key.ENTER) { + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + this.setState({generalMenuPosition: null}); // hide the menu + } }; private onLeaveRoomClick = (ev: ButtonEvent) => { @@ -219,11 +243,13 @@ export default class RoomTile2 extends React.Component<IProps, IState> { this.setState({generalMenuPosition: null}); // hide the menu }; - private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) { + private async saveNotifState(ev: ButtonEvent, newState: Volume) { ev.preventDefault(); ev.stopPropagation(); if (MatrixClientPeg.get().isGuest()) return; + // get key before we go async and React discards the nativeEvent + const key = (ev as React.KeyboardEvent).key; try { // TODO add local echo - https://github.com/vector-im/riot-web/issues/14280 await setRoomNotifsState(this.props.room.roomId, newState); @@ -233,7 +259,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> { console.error(error); } - this.setState({notificationsMenuPosition: null}); // Close the context menu + if (key === Key.ENTER) { + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + this.setState({notificationsMenuPosition: null}); // hide the menu + } } private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES); @@ -322,20 +351,24 @@ export default class RoomTile2 extends React.Component<IProps, IState> { <ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}> <div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"> <div className="mx_IconizedContextMenu_optionList"> - <AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}> + <MenuItemCheckbox + onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)} + active={false} // TODO: https://github.com/vector-im/riot-web/issues/14283 + label={_t("Favourite")} + > <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" /> <span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span> - </AccessibleButton> - <AccessibleButton onClick={this.onOpenRoomSettings}> + </MenuItemCheckbox> + <MenuItem onClick={this.onOpenRoomSettings} label={_t("Settings")}> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" /> <span className="mx_IconizedContextMenu_label">{_t("Settings")}</span> - </AccessibleButton> + </MenuItem> </div> <div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow"> - <AccessibleButton onClick={this.onLeaveRoomClick}> + <MenuItem onClick={this.onLeaveRoomClick} label={_t("Leave Room")}> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" /> <span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span> - </AccessibleButton> + </MenuItem> </div> </div> </ContextMenu> @@ -375,8 +408,9 @@ export default class RoomTile2 extends React.Component<IProps, IState> { let badge: React.ReactNode; if (!this.props.isMinimized) { + // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below badge = ( - <div className="mx_RoomTile2_badgeContainer"> + <div className="mx_RoomTile2_badgeContainer" aria-hidden="true"> <NotificationBadge notification={this.state.notificationState} forceCount={false} @@ -392,24 +426,25 @@ export default class RoomTile2 extends React.Component<IProps, IState> { name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon let messagePreview = null; - if (this.props.showMessagePreview && !this.props.isMinimized) { + if (this.showMessagePreview) { // The preview store heavily caches this info, so should be safe to hammer. const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag); // Only show the preview if there is one to show. if (text) { messagePreview = ( - <div className="mx_RoomTile2_messagePreview"> + <div className="mx_RoomTile2_messagePreview" id={messagePreviewId(this.props.room.roomId)}> {text} </div> ); } } + const notificationColor = this.state.notificationState.color; const nameClasses = classNames({ "mx_RoomTile2_name": true, "mx_RoomTile2_nameWithPreview": !!messagePreview, - "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold, + "mx_RoomTile2_nameHasUnreadEvents": notificationColor >= NotificationColor.Bold, }); let nameContainer = ( @@ -422,6 +457,27 @@ export default class RoomTile2 extends React.Component<IProps, IState> { ); if (this.props.isMinimized) nameContainer = null; + let ariaLabel = name; + // The following labels are written in such a fashion to increase screen reader efficiency (speed). + if (this.props.tag === DefaultTagID.Invite) { + // append nothing + } else if (notificationColor >= NotificationColor.Red) { + ariaLabel += " " + _t("%(count)s unread messages including mentions.", { + count: this.state.notificationState.count, + }); + } else if (notificationColor >= NotificationColor.Grey) { + ariaLabel += " " + _t("%(count)s unread messages.", { + count: this.state.notificationState.count, + }); + } else if (notificationColor >= NotificationColor.Bold) { + ariaLabel += " " + _t("Unread messages."); + } + + let ariaDescribedBy: string; + if (this.showMessagePreview) { + ariaDescribedBy = messagePreviewId(this.props.room.roomId); + } + return ( <React.Fragment> <RovingTabIndexWrapper> @@ -434,8 +490,11 @@ export default class RoomTile2 extends React.Component<IProps, IState> { onMouseEnter={this.onTileMouseEnter} onMouseLeave={this.onTileMouseLeave} onClick={this.onTileClick} - role="treeitem" onContextMenu={this.onContextMenu} + role="treeitem" + aria-label={ariaLabel} + aria-selected={this.state.selected} + aria-describedby={ariaDescribedBy} > {roomAvatar} {nameContainer} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b551805184..f16fb38f86 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1208,6 +1208,8 @@ "Activity": "Activity", "A-Z": "A-Z", "List options": "List options", + "Jump to first unread room.": "Jump to first unread room.", + "Jump to first invite.": "Jump to first invite.", "Add room": "Add room", "Show %(count)s more|other": "Show %(count)s more", "Show %(count)s more|one": "Show %(count)s more", @@ -2089,6 +2091,8 @@ "Find a room…": "Find a room…", "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)", "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", + "Clear filter": "Clear filter", + "Search rooms": "Search rooms", "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.", "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.", @@ -2100,8 +2104,6 @@ "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "Active call": "Active call", "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?", - "Jump to first unread room.": "Jump to first unread room.", - "Jump to first invite.": "Jump to first invite.", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", "Search failed": "Search failed", @@ -2116,7 +2118,6 @@ "Click to mute video": "Click to mute video", "Click to unmute audio": "Click to unmute audio", "Click to mute audio": "Click to mute audio", - "Clear filter": "Clear filter", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Failed to load timeline position": "Failed to load timeline position", @@ -2131,7 +2132,7 @@ "All settings": "All settings", "Archived rooms": "Archived rooms", "Feedback": "Feedback", - "Account settings": "Account settings", + "User menu": "User menu", "Could not load user profile": "Could not load user profile", "Verify this login": "Verify this login", "Session verified": "Session verified",