Implement roving tab index context based magic thing and demo on LeftPanel
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
23633abc10
commit
5252cf4c45
7 changed files with 242 additions and 25 deletions
|
@ -142,10 +142,11 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
// toggle menuButton and badge on hover/menu displayed
|
||||
// toggle menuButton and badge on menu displayed
|
||||
.mx_RoomTile_menuDisplayed,
|
||||
// or on keyboard focus of room tile
|
||||
.mx_RoomTile.focus-visible:focus-within,
|
||||
.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:focus-within,
|
||||
// or on pointer hover
|
||||
.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover {
|
||||
.mx_RoomTile_menuButton {
|
||||
display: block;
|
||||
|
|
|
@ -129,9 +129,6 @@ const LeftPanel = createReactClass({
|
|||
if (!this.focusedElement) return;
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.TAB:
|
||||
this._onMoveFocus(ev, ev.shiftKey);
|
||||
break;
|
||||
case Key.ARROW_UP:
|
||||
this._onMoveFocus(ev, true, true);
|
||||
break;
|
||||
|
|
|
@ -31,6 +31,7 @@ import PropTypes from 'prop-types';
|
|||
import RoomTile from "../views/rooms/RoomTile";
|
||||
import LazyRenderList from "../views/elements/LazyRenderList";
|
||||
import {_t} from "../../languageHandler";
|
||||
import {RovingTabIndex, RovingTabIndexGroup} from "../../contexts/RovingTabIndexContext";
|
||||
|
||||
// turn this on for drop & drag console debugging galore
|
||||
const debug = false;
|
||||
|
@ -272,20 +273,32 @@ export default class RoomSubList extends React.PureComponent {
|
|||
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
||||
if (subListNotifCount > 0) {
|
||||
badge = (
|
||||
<AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
|
||||
<RovingTabIndex
|
||||
component={AccessibleButton}
|
||||
useInputRef
|
||||
className={badgeClasses}
|
||||
onClick={this._onNotifBadgeClick}
|
||||
aria-label={_t("Jump to first unread room.")}
|
||||
>
|
||||
<div>
|
||||
{ FormattingUtils.formatCount(subListNotifCount) }
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</RovingTabIndex>
|
||||
);
|
||||
} else if (this.props.isInvite && this.props.list.length) {
|
||||
// no notifications but highlight anyway because this is an invite badge
|
||||
badge = (
|
||||
<AccessibleButton className={badgeClasses} onClick={this._onInviteBadgeClick} aria-label={_t("Jump to first invite.")}>
|
||||
<RovingTabIndex
|
||||
component={AccessibleButton}
|
||||
useInputRef
|
||||
className={badgeClasses}
|
||||
onClick={this._onInviteBadgeClick}
|
||||
aria-label={_t("Jump to first invite.")}
|
||||
>
|
||||
<div>
|
||||
{ this.props.list.length }
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</RovingTabIndex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -308,7 +321,9 @@ export default class RoomSubList extends React.PureComponent {
|
|||
let addRoomButton;
|
||||
if (this.props.onAddRoom) {
|
||||
addRoomButton = (
|
||||
<AccessibleTooltipButton
|
||||
<RovingTabIndex
|
||||
component={AccessibleTooltipButton}
|
||||
useInputRef
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSubList_addRoom"
|
||||
title={this.props.addRoomLabel || _t("Add room")}
|
||||
|
@ -327,12 +342,13 @@ export default class RoomSubList extends React.PureComponent {
|
|||
chevron = (<div className={chevronClasses} />);
|
||||
}
|
||||
|
||||
return (
|
||||
return <RovingTabIndexGroup>
|
||||
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
|
||||
<AccessibleButton
|
||||
<RovingTabIndex
|
||||
component={AccessibleButton}
|
||||
useInputRef
|
||||
onClick={this.onClick}
|
||||
className="mx_RoomSubList_label"
|
||||
tabIndex={0}
|
||||
aria-expanded={!isCollapsed}
|
||||
inputRef={this._headerButton}
|
||||
role="treeitem"
|
||||
|
@ -341,11 +357,11 @@ export default class RoomSubList extends React.PureComponent {
|
|||
{ chevron }
|
||||
<span>{this.props.label}</span>
|
||||
{ incomingCall }
|
||||
</AccessibleButton>
|
||||
</RovingTabIndex>
|
||||
{ badge }
|
||||
{ addRoomButton }
|
||||
</div>
|
||||
);
|
||||
</RovingTabIndexGroup>;
|
||||
}
|
||||
|
||||
checkOverflow = () => {
|
||||
|
|
|
@ -26,6 +26,7 @@ import classNames from 'classnames';
|
|||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {RovingTabIndex, RovingTabIndexGroup} from "../../../contexts/RovingTabIndexContext";
|
||||
|
||||
// XXX this class copies a lot from RoomTile.js
|
||||
export default createReactClass({
|
||||
|
@ -138,14 +139,16 @@ export default createReactClass({
|
|||
|
||||
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
|
||||
const badge = (
|
||||
<ContextMenuButton
|
||||
<RovingTabIndex
|
||||
component={ContextMenuButton}
|
||||
useInputRef
|
||||
className={badgeClasses}
|
||||
onClick={this.onContextMenuButtonClick}
|
||||
label={_t("Options")}
|
||||
isExpanded={isMenuDisplayed}
|
||||
>
|
||||
{ badgeContent }
|
||||
</ContextMenuButton>
|
||||
</RovingTabIndex>
|
||||
);
|
||||
|
||||
let tooltip;
|
||||
|
@ -170,8 +173,10 @@ export default createReactClass({
|
|||
);
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<AccessibleButton
|
||||
return <RovingTabIndexGroup>
|
||||
<RovingTabIndex
|
||||
component={AccessibleButton}
|
||||
useInputRef
|
||||
className={classes}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
|
@ -186,9 +191,9 @@ export default createReactClass({
|
|||
{ badge }
|
||||
</div>
|
||||
{ tooltip }
|
||||
</AccessibleButton>
|
||||
</RovingTabIndex>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
</RovingTabIndexGroup>;
|
||||
},
|
||||
});
|
||||
|
|
|
@ -41,6 +41,7 @@ import ResizeHandle from '../elements/ResizeHandle';
|
|||
|
||||
import {Resizer} from '../../../resizer';
|
||||
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
||||
import {RovingTabIndexContextWrapper} from "../../../contexts/RovingTabIndexContext";
|
||||
const HIDE_CONFERENCE_CHANS = true;
|
||||
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
||||
const HOVER_MOVE_TIMEOUT = 1000;
|
||||
|
@ -788,7 +789,9 @@ module.exports = createReactClass({
|
|||
onMouseMove={this.onMouseMove}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{ subListComponents }
|
||||
<RovingTabIndexContextWrapper>
|
||||
{ subListComponents }
|
||||
</RovingTabIndexContextWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -32,6 +32,7 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver';
|
|||
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {RovingTabIndex} from "../../../contexts/RovingTabIndexContext";
|
||||
|
||||
module.exports = createReactClass({
|
||||
displayName: 'RoomTile',
|
||||
|
@ -432,8 +433,9 @@ module.exports = createReactClass({
|
|||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<AccessibleButton
|
||||
tabIndex="0"
|
||||
<RovingTabIndex
|
||||
component={AccessibleButton}
|
||||
useInputRef
|
||||
className={classes}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
|
@ -461,7 +463,7 @@ module.exports = createReactClass({
|
|||
</div>
|
||||
{ /* { incomingCallBox } */ }
|
||||
{ tooltip }
|
||||
</AccessibleButton>
|
||||
</RovingTabIndex>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
|
|
193
src/contexts/RovingTabIndexContext.js
Normal file
193
src/contexts/RovingTabIndexContext.js
Normal file
|
@ -0,0 +1,193 @@
|
|||
/*
|
||||
*
|
||||
* Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
*
|
||||
* 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 React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useReducer,
|
||||
} from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import {Key} from "../Keyboard";
|
||||
|
||||
const DOCUMENT_POSITION_PRECEDING = 2;
|
||||
const ANY = Symbol();
|
||||
|
||||
const RovingTabIndexContext = createContext({
|
||||
state: {
|
||||
activeRef: null,
|
||||
refs: [],
|
||||
},
|
||||
dispatch: () => {},
|
||||
});
|
||||
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
||||
|
||||
// TODO use a TypeScript type here
|
||||
const types = {
|
||||
REGISTER: "REGISTER",
|
||||
UNREGISTER: "UNREGISTER",
|
||||
SET_FOCUS: "SET_FOCUS",
|
||||
};
|
||||
|
||||
const reducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case types.REGISTER: {
|
||||
if (state.refs.length === 0) {
|
||||
return {
|
||||
...state,
|
||||
activeRef: action.payload.ref,
|
||||
refs: [action.payload.ref],
|
||||
};
|
||||
}
|
||||
|
||||
if (state.refs.includes(action.payload.ref)) {
|
||||
return state; // already in refs, this should not happen
|
||||
}
|
||||
|
||||
let newIndex = state.refs.findIndex(ref => {
|
||||
return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING;
|
||||
});
|
||||
|
||||
if (newIndex < 0) {
|
||||
newIndex = state.refs.length; // append to the end
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
refs: [
|
||||
...state.refs.slice(0, newIndex),
|
||||
action.payload.ref,
|
||||
...state.refs.slice(newIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
case types.UNREGISTER: {
|
||||
const refs = state.refs.filter(r => r !== action.payload.ref); // keep all other refs
|
||||
|
||||
if (refs.length === state.refs.length) {
|
||||
return state; // already removed, this should not happen
|
||||
}
|
||||
|
||||
if (state.activeRef === action.payload.ref) { // we just removed the active ref, need to replace it
|
||||
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
|
||||
return {
|
||||
...state,
|
||||
activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
|
||||
refs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
refs,
|
||||
};
|
||||
}
|
||||
case types.SET_FOCUS: {
|
||||
return {
|
||||
...state,
|
||||
activeRef: action.payload.ref,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const RovingTabIndexContextWrapper = ({children}) => {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
activeRef: null,
|
||||
refs: [],
|
||||
});
|
||||
|
||||
const context = useMemo(() => ({state, dispatch}), [state]);
|
||||
|
||||
return <RovingTabIndexContext.Provider value={context}>
|
||||
{children}
|
||||
</RovingTabIndexContext.Provider>;
|
||||
};
|
||||
|
||||
export const useRovingTabIndex = () => {
|
||||
const ref = useRef(null);
|
||||
const context = useContext(RovingTabIndexContext);
|
||||
|
||||
// setup/teardown
|
||||
// add ref to the context
|
||||
useLayoutEffect(() => {
|
||||
context.dispatch({
|
||||
type: types.REGISTER,
|
||||
payload: {ref},
|
||||
});
|
||||
return () => {
|
||||
context.dispatch({
|
||||
type: types.UNREGISTER,
|
||||
payload: {ref},
|
||||
});
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
context.dispatch({
|
||||
type: types.SET_FOCUS,
|
||||
payload: {ref},
|
||||
});
|
||||
}, [ref, context]);
|
||||
const isActive = context.state.activeRef === ref || context.state.activeRef === ANY;
|
||||
return [onFocus, isActive, ref];
|
||||
};
|
||||
|
||||
export const RovingTabIndexGroup = ({children}) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
|
||||
// fake reducer dispatch to catch SET_FOCUS calls and pass them to parent as a focus of the group
|
||||
const dispatch = useCallback(({type}) => {
|
||||
if (type === types.SET_FOCUS) {
|
||||
onFocus();
|
||||
}
|
||||
}, [onFocus]);
|
||||
|
||||
const context = useMemo(() => ({
|
||||
state: {activeRef: isActive ? ANY : undefined},
|
||||
dispatch,
|
||||
}), [isActive, dispatch]);
|
||||
|
||||
return <div ref={ref}>
|
||||
<RovingTabIndexContext.Provider value={context}>
|
||||
{children}
|
||||
</RovingTabIndexContext.Provider>
|
||||
</div>;
|
||||
};
|
||||
|
||||
// Wraps a given element to attach it to the roving context, props onFocus and tabIndex overridden
|
||||
export const RovingTabIndex = ({component: E, useInputRef, ...props}) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
const refProps = {};
|
||||
if (useInputRef) {
|
||||
refProps.inputRef = ref;
|
||||
} else {
|
||||
refProps.ref = ref;
|
||||
}
|
||||
return <E {...props} {...refProps} onFocus={onFocus} tabIndex={isActive ? 0 : -1} />;
|
||||
};
|
||||
RovingTabIndex.propTypes = {
|
||||
component: PropTypes.elementType.isRequired,
|
||||
useInputRef: PropTypes.bool, // whether to pass inputRef instead of ref like for AccessibleButton
|
||||
};
|
||||
|
Loading…
Reference in a new issue