First step towards a11y in the new room list

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2020-07-02 22:21:10 +01:00
parent 0549ef83db
commit 289f40ce29
6 changed files with 158 additions and 19 deletions

View file

@ -278,6 +278,7 @@ limitations under the License.
} }
&.mx_RoomSublist2_hasMenuOpen, &.mx_RoomSublist2_hasMenuOpen,
&:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:focus-within,
&:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover { &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover {
.mx_RoomSublist2_menuButton { .mx_RoomSublist2_menuButton {
visibility: visible; visibility: visible;

View file

@ -24,7 +24,10 @@ limitations under the License.
// The tile is also a flexbox row itself // The tile is also a flexbox row itself
display: flex; display: flex;
&.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen { &.mx_RoomTile2_selected,
&:hover,
&:focus-within,
&.mx_RoomTile2_hasMenuOpen {
background-color: $roomtile2-selected-bg-color; background-color: $roomtile2-selected-bg-color;
border-radius: 32px; border-radius: 32px;
} }
@ -132,7 +135,9 @@ limitations under the License.
} }
&:not(.mx_RoomTile2_minimized) { &: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 // Hide the badge container on hover because it'll be a menu button
.mx_RoomTile2_badgeContainer { .mx_RoomTile2_badgeContainer {
width: 0; width: 0;

View file

@ -30,7 +30,8 @@ import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore"; 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: 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 // 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<IProps, IState> { export default class LeftPanel2 extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string; private tagPanelWatcherRef: string;
private focusedElement = null;
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
@ -150,6 +152,77 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
this.handleStickyHeaders(this.listContainerRef.current); 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 { private renderHeader(): React.ReactNode {
let breadcrumbs; let breadcrumbs;
if (this.state.showBreadcrumbs) { if (this.state.showBreadcrumbs) {
@ -170,8 +243,12 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
private renderSearchExplore(): React.ReactNode { private renderSearchExplore(): React.ReactNode {
return ( return (
<div className="mx_LeftPanel2_filterContainer"> <div className="mx_LeftPanel2_filterContainer" onFocus={this.onFocus} onBlur={this.onBlur}>
<RoomSearch onQueryUpdate={this.onSearch} isMinimized={this.props.isMinimized} /> <RoomSearch
onQueryUpdate={this.onSearch}
isMinimized={this.props.isMinimized}
onVerticalArrow={this.onKeyDown}
/>
<AccessibleButton <AccessibleButton
tabIndex={-1} tabIndex={-1}
className='mx_LeftPanel2_exploreButton' className='mx_LeftPanel2_exploreButton'
@ -189,14 +266,13 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
</div> </div>
); );
// TODO: Determine what these onWhatever handlers do: https://github.com/vector-im/riot-web/issues/14180
const roomList = <RoomList2 const roomList = <RoomList2
onKeyDown={() => {/*TODO*/}} onKeyDown={this.onKeyDown}
resizeNotifier={null} resizeNotifier={null}
collapsed={false} collapsed={false}
searchFilter={this.state.searchFilter} searchFilter={this.state.searchFilter}
onFocus={() => {/*TODO*/}} onFocus={this.onFocus}
onBlur={() => {/*TODO*/}} onBlur={this.onBlur}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
/>; />;
@ -223,7 +299,12 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
className={roomListClasses} className={roomListClasses}
onScroll={this.onScroll} onScroll={this.onScroll}
ref={this.listContainerRef} ref={this.listContainerRef}
>{roomList}</div> // Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
{roomList}
</div>
</aside> </aside>
</div> </div>
); );

View file

@ -38,6 +38,7 @@ import { Action } from "../../dispatcher/actions";
interface IProps { interface IProps {
onQueryUpdate: (newQuery: string) => void; onQueryUpdate: (newQuery: string) => void;
isMinimized: boolean; isMinimized: boolean;
onVerticalArrow(ev: React.KeyboardEvent);
} }
interface IState { interface IState {
@ -111,6 +112,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
if (ev.key === Key.ESCAPE) { if (ev.key === Key.ESCAPE) {
this.clearInput(); this.clearInput();
defaultDispatcher.fire(Action.FocusComposer); defaultDispatcher.fire(Action.FocusComposer);
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
this.props.onVerticalArrow(ev);
} }
}; };

View file

@ -35,6 +35,7 @@ import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; 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: 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 // 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<IProps, IState> { export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>();
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -215,8 +219,52 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
sublist.scrollIntoView({behavior: 'smooth'}); sublist.scrollIntoView({behavior: 'smooth'});
} else { } else {
// on screen - toggle collapse // on screen - toggle collapse
this.toggleCollapsed();
}
};
private toggleCollapsed = () => {
this.props.layout.isCollapsed = !this.props.layout.isCollapsed; this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
this.forceUpdate(); // because the layout doesn't trigger an update 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<IProps, IState> {
return ( return (
<RovingTabIndexWrapper> <RovingTabIndexWrapper>
{({onFocus, isActive, ref}) => { {({onFocus, isActive, ref}) => {
// TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180
const tabIndex = isActive ? 0 : -1; const tabIndex = isActive ? 0 : -1;
const badge = ( const badge = (
@ -382,13 +429,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// doesn't become sticky. // doesn't become sticky.
// The same applies to the notification badge. // The same applies to the notification badge.
return ( return (
<div className={classes}> <div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus}>
<div className='mx_RoomSublist2_stickable'> <div className="mx_RoomSublist2_stickable">
<AccessibleButton <AccessibleButton
onFocus={onFocus} onFocus={onFocus}
inputRef={ref} inputRef={ref}
tabIndex={tabIndex} tabIndex={tabIndex}
className={"mx_RoomSublist2_headerText"} className="mx_RoomSublist2_headerText"
role="treeitem" role="treeitem"
aria-level={1} aria-level={1}
onClick={this.onHeaderClick} onClick={this.onHeaderClick}
@ -516,12 +563,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
); );
} }
// TODO: onKeyDown support: https://github.com/vector-im/riot-web/issues/14180
return ( return (
<div <div
ref={this.sublistRef}
className={classes} className={classes}
role="group" role="group"
aria-label={this.props.label} aria-label={this.props.label}
onKeyDown={this.onKeyDown}
> >
{this.renderHeader()} {this.renderHeader()}
{content} {content}

View file

@ -235,7 +235,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY); private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY);
private onClickMute = ev => this.saveNotifState(ev, MUTE); 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) { 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 // the menu makes no sense in these cases so do not show one
return null; return null;
@ -296,6 +296,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
onClick={this.onNotificationsMenuOpenClick} onClick={this.onNotificationsMenuOpenClick}
label={_t("Notification options")} label={_t("Notification options")}
isExpanded={!!this.state.notificationsMenuPosition} isExpanded={!!this.state.notificationsMenuPosition}
tabIndex={isActive ? 0 : -1}
/> />
{contextMenu} {contextMenu}
</React.Fragment> </React.Fragment>
@ -434,7 +435,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
{roomAvatar} {roomAvatar}
{nameContainer} {nameContainer}
{badge} {badge}
{this.renderNotificationsMenu()} {this.renderNotificationsMenu(isActive)}
{this.renderGeneralMenu()} {this.renderGeneralMenu()}
</AccessibleButton> </AccessibleButton>
} }