Merge pull request #4882 from matrix-org/t3chguy/room-list/6
First step towards a11y in the new room list
This commit is contained in:
commit
599cc9c9ed
7 changed files with 207 additions and 53 deletions
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<IProps, IState> {
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private tagPanelWatcherRef: string;
|
||||
private focusedElement = null;
|
||||
|
||||
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
|
||||
|
||||
|
@ -150,6 +152,69 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
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:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.onMoveFocus(ev.key === Key.ARROW_UP);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private onMoveFocus = (up: boolean) => {
|
||||
let element = this.focusedElement;
|
||||
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
let classes: DOMTokenList;
|
||||
|
||||
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) {
|
||||
element.focus();
|
||||
this.focusedElement = element;
|
||||
}
|
||||
};
|
||||
|
||||
private renderHeader(): React.ReactNode {
|
||||
let breadcrumbs;
|
||||
if (this.state.showBreadcrumbs) {
|
||||
|
@ -170,11 +235,15 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
|
||||
private renderSearchExplore(): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_LeftPanel2_filterContainer">
|
||||
<RoomSearch onQueryUpdate={this.onSearch} isMinimized={this.props.isMinimized} />
|
||||
<div className="mx_LeftPanel2_filterContainer" onFocus={this.onFocus} onBlur={this.onBlur}>
|
||||
<RoomSearch
|
||||
onQueryUpdate={this.onSearch}
|
||||
isMinimized={this.props.isMinimized}
|
||||
onVerticalArrow={this.onKeyDown}
|
||||
/>
|
||||
<AccessibleButton
|
||||
tabIndex={-1}
|
||||
className='mx_LeftPanel2_exploreButton'
|
||||
// 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")}
|
||||
/>
|
||||
|
@ -189,14 +258,13 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
</div>
|
||||
);
|
||||
|
||||
// TODO: Determine what these onWhatever handlers do: https://github.com/vector-im/riot-web/issues/14180
|
||||
const roomList = <RoomList2
|
||||
onKeyDown={() => {/*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 +291,12 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
className={roomListClasses}
|
||||
onScroll={this.onScroll}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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<IProps, IState> {
|
|||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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<IMenuButtonProps> = ({iconClassName, label, onClick}) => {
|
||||
return <MenuItem label={label} onClick={onClick}>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{label}</span>
|
||||
</MenuItem>;
|
||||
};
|
||||
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private themeWatcherRef: string;
|
||||
|
@ -102,8 +115,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
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) => {
|
||||
|
@ -209,10 +225,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
let homeButton = null;
|
||||
if (this.hasHomePage) {
|
||||
homeButton = (
|
||||
<AccessibleButton onClick={this.onHomeClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" />
|
||||
<span>{_t("Home")}</span>
|
||||
</AccessibleButton>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconHome"
|
||||
label={_t("Home")}
|
||||
onClick={this.onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -249,32 +266,38 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
{hostingLink}
|
||||
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
|
||||
{homeButton}
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Notification settings")}</span>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Security & privacy")}</span>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("All settings")}</span>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this.onShowArchived}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Archived rooms")}</span>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this.onProvideFeedback}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Feedback")}</span>
|
||||
</AccessibleButton>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconBell"
|
||||
label={_t("Notification settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconLock"
|
||||
label={_t("Security & privacy")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("All settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconArchive"
|
||||
label={_t("Archived rooms")}
|
||||
onClick={this.onShowArchived}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconMessage"
|
||||
label={_t("Feedback")}
|
||||
onClick={this.onProvideFeedback}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList mx_UserMenu_contextMenu_redRow">
|
||||
<AccessibleButton onClick={this.onSignOutClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Sign out")}</span>
|
||||
</AccessibleButton>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("Sign out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
|
|
|
@ -37,6 +37,7 @@ import NotificationBadge from "./NotificationBadge";
|
|||
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
|
||||
import Tooltip from "../elements/Tooltip";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
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
|
||||
|
@ -82,6 +83,9 @@ interface IState {
|
|||
}
|
||||
|
||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef<HTMLDivElement>();
|
||||
private sublistRef = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
|
@ -217,8 +221,52 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
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();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -337,7 +385,6 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
return (
|
||||
<RovingTabIndexWrapper>
|
||||
{({onFocus, isActive, ref}) => {
|
||||
// TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180
|
||||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
const badge = (
|
||||
|
@ -386,13 +433,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
// doesn't become sticky.
|
||||
// The same applies to the notification badge.
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className='mx_RoomSublist2_stickable'>
|
||||
<div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus}>
|
||||
<div className="mx_RoomSublist2_stickable">
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className={"mx_RoomSublist2_headerText"}
|
||||
className="mx_RoomSublist2_headerText"
|
||||
role="treeitem"
|
||||
aria-level={1}
|
||||
onClick={this.onHeaderClick}
|
||||
|
@ -520,12 +567,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
// TODO: onKeyDown support: https://github.com/vector-im/riot-web/issues/14180
|
||||
return (
|
||||
<div
|
||||
ref={this.sublistRef}
|
||||
className={classes}
|
||||
role="group"
|
||||
aria-label={this.props.label}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
{this.renderHeader()}
|
||||
{content}
|
||||
|
|
|
@ -241,7 +241,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
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 (MatrixClientPeg.get().isGuest() || !this.showContextMenu) {
|
||||
// the menu makes no sense in these cases so do not show one
|
||||
return null;
|
||||
|
@ -304,6 +304,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
onClick={this.onNotificationsMenuOpenClick}
|
||||
label={_t("Notification options")}
|
||||
isExpanded={!!this.state.notificationsMenuPosition}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
|
@ -439,7 +440,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
{roomAvatar}
|
||||
{nameContainer}
|
||||
{badge}
|
||||
{this.renderNotificationsMenu()}
|
||||
{this.renderNotificationsMenu(isActive)}
|
||||
{this.renderGeneralMenu()}
|
||||
</AccessibleButton>
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue