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 1/4] 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()} } From 9b0c711837727c0134609c45793877aa4bf3cc25 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 3 Jul 2020 14:34:43 +0100 Subject: [PATCH 2/4] Make the UserMenu more accessible Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/UserMenu.tsx | 87 ++++++++++++++++---------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index ff828e0da7..90a6a7d699 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -21,7 +21,7 @@ import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { createRef } from "react"; import { _t } from "../../languageHandler"; -import {ContextMenu, ContextMenuButton} from "./ContextMenu"; +import {ContextMenu, ContextMenuButton, MenuItem} from "./ContextMenu"; import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; @@ -30,7 +30,7 @@ import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import {getCustomTheme} from "../../theme"; import {getHostingLink} from "../../utils/HostingLink"; -import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; +import {ButtonEvent} from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import {getHomePageUrl} from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; @@ -50,6 +50,19 @@ interface IState { isDarkTheme: boolean; } +interface IMenuButtonProps { + iconClassName: string; + label: string; + onClick(ev: ButtonEvent); +} + +const MenuButton: React.FC = ({iconClassName, label, onClick}) => { + return + + {label} + ; +}; + export default class UserMenu extends React.Component { private dispatcherRef: string; private themeWatcherRef: string; @@ -102,8 +115,11 @@ export default class UserMenu extends React.Component { private onAction = (ev: ActionPayload) => { if (ev.action !== Action.ToggleUserMenu) return; // not interested - // For accessibility - if (this.buttonRef.current) this.buttonRef.current.click(); + if (this.state.contextMenuPosition) { + this.setState({contextMenuPosition: null}); + } else { + if (this.buttonRef.current) this.buttonRef.current.click(); + } }; private onOpenMenuClick = (ev: InputEvent) => { @@ -206,10 +222,11 @@ export default class UserMenu extends React.Component { let homeButton = null; if (this.hasHomePage) { homeButton = ( - - - {_t("Home")} - + ); } @@ -246,32 +263,38 @@ export default class UserMenu extends React.Component { {hostingLink}
{homeButton} - this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> - - {_t("Notification settings")} - - this.onSettingsOpen(e, USER_SECURITY_TAB)}> - - {_t("Security & privacy")} - - this.onSettingsOpen(e, null)}> - - {_t("All settings")} - - - - {_t("Archived rooms")} - - - - {_t("Feedback")} - + this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)} + /> + this.onSettingsOpen(e, USER_SECURITY_TAB)} + /> + this.onSettingsOpen(e, null)} + /> + +
- - - {_t("Sign out")} - +
From 47ee00ec5da64235692971acd68dd3d9c14ed038 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 3 Jul 2020 14:43:02 +0100 Subject: [PATCH 3/4] Make explore button at all accessible Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/LeftPanel2.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 0cf52039fe..f5a946f964 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -250,8 +250,8 @@ export default class LeftPanel2 extends React.Component { onVerticalArrow={this.onKeyDown} /> From c8a93e9dd708f2af4fb0d6aa4c4c93f12ed8ea8d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 3 Jul 2020 14:49:25 +0100 Subject: [PATCH 4/4] clean-up Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/LeftPanel2.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index f5a946f964..23a9e74646 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -166,20 +166,18 @@ export default class LeftPanel2 extends React.Component { switch (ev.key) { case Key.ARROW_UP: case Key.ARROW_DOWN: - this.onMoveFocus(ev, ev.key === Key.ARROW_UP); + ev.stopPropagation(); + ev.preventDefault(); + this.onMoveFocus(ev.key === Key.ARROW_UP); break; } }; - private onMoveFocus = (ev: React.KeyboardEvent, up: boolean) => { + private onMoveFocus = (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; + let classes: DOMTokenList; do { const child = up ? element.lastElementChild : element.firstElementChild; @@ -212,14 +210,8 @@ export default class LeftPanel2 extends React.Component { 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(); } };