From 289f40ce296bf34866abcd1b6785cc011119c980 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 2 Jul 2020 22:21:10 +0100 Subject: [PATCH] First step towards a11y in the new room list Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/views/rooms/_RoomSublist2.scss | 1 + res/css/views/rooms/_RoomTile2.scss | 9 +- src/components/structures/LeftPanel2.tsx | 97 +++++++++++++++++++-- src/components/structures/RoomSearch.tsx | 3 + src/components/views/rooms/RoomSublist2.tsx | 62 +++++++++++-- src/components/views/rooms/RoomTile2.tsx | 5 +- 6 files changed, 158 insertions(+), 19 deletions(-) diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index ffb96cf600..43d1b3c7b3 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -278,6 +278,7 @@ limitations under the License. } &.mx_RoomSublist2_hasMenuOpen, + &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:focus-within, &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover { .mx_RoomSublist2_menuButton { visibility: visible; diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index ba8d315d5a..1fd32d3555 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -24,7 +24,10 @@ limitations under the License. // The tile is also a flexbox row itself display: flex; - &.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen { + &.mx_RoomTile2_selected, + &:hover, + &:focus-within, + &.mx_RoomTile2_hasMenuOpen { background-color: $roomtile2-selected-bg-color; border-radius: 32px; } @@ -132,7 +135,9 @@ limitations under the License. } &:not(.mx_RoomTile2_minimized) { - &:hover, &.mx_RoomTile2_hasMenuOpen { + &:hover, + &:focus-within, + &.mx_RoomTile2_hasMenuOpen { // Hide the badge container on hover because it'll be a menu button .mx_RoomTile2_badgeContainer { width: 0; diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 83d5b9e138..0cf52039fe 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -30,7 +30,8 @@ import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import ResizeNotifier from "../../utils/ResizeNotifier"; import SettingsStore from "../../settings/SettingsStore"; -import RoomListStore, { RoomListStore2, LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2"; +import {Key} from "../../Keyboard"; // 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 @@ -57,6 +58,7 @@ interface IState { export default class LeftPanel2 extends React.Component { private listContainerRef: React.RefObject = createRef(); private tagPanelWatcherRef: string; + private focusedElement = null; // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 @@ -150,6 +152,77 @@ export default class LeftPanel2 extends React.Component { this.handleStickyHeaders(this.listContainerRef.current); }; + private onFocus = (ev: React.FocusEvent) => { + this.focusedElement = ev.target; + }; + + private onBlur = () => { + this.focusedElement = null; + }; + + private onKeyDown = (ev: React.KeyboardEvent) => { + if (!this.focusedElement) return; + + switch (ev.key) { + case Key.ARROW_UP: + case Key.ARROW_DOWN: + this.onMoveFocus(ev, ev.key === Key.ARROW_UP); + break; + } + }; + + private onMoveFocus = (ev: React.KeyboardEvent, up: boolean) => { + let element = this.focusedElement; + + // unclear why this isn't needed + // var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending; + // this.focusDirection = up; + + let descending = false; // are we currently descending or ascending through the DOM tree? + let classes; + + do { + const child = up ? element.lastElementChild : element.firstElementChild; + const sibling = up ? element.previousElementSibling : element.nextElementSibling; + + if (descending) { + if (child) { + element = child; + } else if (sibling) { + element = sibling; + } else { + descending = false; + element = element.parentElement; + } + } else { + if (sibling) { + element = sibling; + descending = true; + } else { + element = element.parentElement; + } + } + + if (element) { + classes = element.classList; + } + } while (element && !( + classes.contains("mx_RoomTile2") || + classes.contains("mx_RoomSublist2_headerText") || + classes.contains("mx_RoomSearch_input"))); + + if (element) { + ev.stopPropagation(); + ev.preventDefault(); + element.focus(); + this.focusedElement = element; + } else { + // if navigation is via up/down arrow-keys, trap in the widget so it doesn't send to composer + ev.stopPropagation(); + ev.preventDefault(); + } + }; + private renderHeader(): React.ReactNode { let breadcrumbs; if (this.state.showBreadcrumbs) { @@ -170,8 +243,12 @@ export default class LeftPanel2 extends React.Component { private renderSearchExplore(): React.ReactNode { return ( -
- +
+ {
); - // TODO: Determine what these onWhatever handlers do: https://github.com/vector-im/riot-web/issues/14180 const roomList = {/*TODO*/}} + onKeyDown={this.onKeyDown} resizeNotifier={null} collapsed={false} searchFilter={this.state.searchFilter} - onFocus={() => {/*TODO*/}} - onBlur={() => {/*TODO*/}} + onFocus={this.onFocus} + onBlur={this.onBlur} isMinimized={this.props.isMinimized} />; @@ -223,7 +299,12 @@ export default class LeftPanel2 extends React.Component { className={roomListClasses} onScroll={this.onScroll} ref={this.listContainerRef} - >{roomList}
+ // Firefox sometimes makes this element focusable due to + // overflow:scroll;, so force it out of tab order. + tabIndex={-1} + > + {roomList} + ); diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 8e64353954..7ed2acf276 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -38,6 +38,7 @@ import { Action } from "../../dispatcher/actions"; interface IProps { onQueryUpdate: (newQuery: string) => void; isMinimized: boolean; + onVerticalArrow(ev: React.KeyboardEvent); } interface IState { @@ -111,6 +112,8 @@ export default class RoomSearch extends React.PureComponent { if (ev.key === Key.ESCAPE) { this.clearInput(); defaultDispatcher.fire(Action.FocusComposer); + } else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) { + this.props.onVerticalArrow(ev); } }; diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 547aa5e67e..c1f2a9ac79 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -35,6 +35,7 @@ import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import dis from "../../../dispatcher/dispatcher"; import NotificationBadge from "./NotificationBadge"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; +import { Key } from "../../../Keyboard"; // 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 @@ -80,6 +81,9 @@ interface IState { } export default class RoomSublist2 extends React.Component { + private headerButton = createRef(); + private sublistRef = createRef(); + constructor(props: IProps) { super(props); @@ -215,8 +219,52 @@ export default class RoomSublist2 extends React.Component { sublist.scrollIntoView({behavior: 'smooth'}); } else { // on screen - toggle collapse - this.props.layout.isCollapsed = !this.props.layout.isCollapsed; - this.forceUpdate(); // because the layout doesn't trigger an update + this.toggleCollapsed(); + } + }; + + private toggleCollapsed = () => { + this.props.layout.isCollapsed = !this.props.layout.isCollapsed; + this.forceUpdate(); // because the layout doesn't trigger an update + }; + + private onHeaderKeyDown = (ev: React.KeyboardEvent) => { + const isCollapsed = this.props.layout && this.props.layout.isCollapsed; + switch (ev.key) { + case Key.ARROW_LEFT: + ev.stopPropagation(); + if (!isCollapsed) { + // On ARROW_LEFT collapse the room sublist if it isn't already + this.toggleCollapsed(); + } + break; + case Key.ARROW_RIGHT: { + ev.stopPropagation(); + if (isCollapsed) { + // On ARROW_RIGHT expand the room sublist if it isn't already + this.toggleCollapsed(); + } else if (this.sublistRef.current) { + // otherwise focus the first room + const element = this.sublistRef.current.querySelector(".mx_RoomTile2") as HTMLDivElement; + if (element) { + element.focus(); + } + } + break; + } + } + }; + + private onKeyDown = (ev: React.KeyboardEvent) => { + switch (ev.key) { + // On ARROW_LEFT go to the sublist header + case Key.ARROW_LEFT: + ev.stopPropagation(); + this.headerButton.current.focus(); + break; + // Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer + case Key.ARROW_RIGHT: + ev.stopPropagation(); } }; @@ -335,7 +383,6 @@ export default class RoomSublist2 extends React.Component { return ( {({onFocus, isActive, ref}) => { - // TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180 const tabIndex = isActive ? 0 : -1; const badge = ( @@ -382,13 +429,13 @@ export default class RoomSublist2 extends React.Component { // doesn't become sticky. // The same applies to the notification badge. return ( -
-
+
+
{ ); } - // TODO: onKeyDown support: https://github.com/vector-im/riot-web/issues/14180 return (
{this.renderHeader()} {content} diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 92c69e54e7..a85ebe7dd3 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -235,7 +235,7 @@ export default class RoomTile2 extends React.Component { private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY); private onClickMute = ev => this.saveNotifState(ev, MUTE); - private renderNotificationsMenu(): React.ReactElement { + private renderNotificationsMenu(isActive: boolean): React.ReactElement { if (this.props.isMinimized || MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Invite) { // the menu makes no sense in these cases so do not show one return null; @@ -296,6 +296,7 @@ export default class RoomTile2 extends React.Component { onClick={this.onNotificationsMenuOpenClick} label={_t("Notification options")} isExpanded={!!this.state.notificationsMenuPosition} + tabIndex={isActive ? 0 : -1} /> {contextMenu} @@ -434,7 +435,7 @@ export default class RoomTile2 extends React.Component { {roomAvatar} {nameContainer} {badge} - {this.renderNotificationsMenu()} + {this.renderNotificationsMenu(isActive)} {this.renderGeneralMenu()} }