Make space hierarchy a treeview

This commit is contained in:
Michael Telatynski 2021-08-09 10:29:55 +01:00
parent 6fddfe0d59
commit 09f20bcda7
4 changed files with 289 additions and 184 deletions

View file

@ -150,13 +150,14 @@ const reducer = (state: IState, action: IAction) => {
interface IProps { interface IProps {
handleHomeEnd?: boolean; handleHomeEnd?: boolean;
handleUpDown?: boolean;
children(renderProps: { children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent); onKeyDownHandler(ev: React.KeyboardEvent);
}); });
onKeyDown?(ev: React.KeyboardEvent, state: IState); onKeyDown?(ev: React.KeyboardEvent, state: IState);
} }
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, onKeyDown }) => { export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, { const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
activeRef: null, activeRef: null,
refs: [], refs: [],
@ -167,22 +168,51 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
const onKeyDownHandler = useCallback((ev) => { const onKeyDownHandler = useCallback((ev) => {
let handled = false; let handled = false;
// Don't interfere with input default keydown behaviour // Don't interfere with input default keydown behaviour
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
// check if we actually have any items // check if we actually have any items
switch (ev.key) { switch (ev.key) {
case Key.HOME: case Key.HOME:
if (handleHomeEnd) {
handled = true; handled = true;
// move focus to first item // move focus to first item
if (context.state.refs.length > 0) { if (context.state.refs.length > 0) {
context.state.refs[0].current.focus(); context.state.refs[0].current.focus();
} }
}
break; break;
case Key.END: case Key.END:
if (handleHomeEnd) {
handled = true; handled = true;
// move focus to last item // move focus to last item
if (context.state.refs.length > 0) { if (context.state.refs.length > 0) {
context.state.refs[context.state.refs.length - 1].current.focus(); context.state.refs[context.state.refs.length - 1].current.focus();
} }
}
break;
case Key.ARROW_UP:
if (handleUpDown) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
if (idx > 1) {
context.state.refs[idx - 1].current.focus();
}
}
}
break;
case Key.ARROW_DOWN:
if (handleUpDown) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
if (idx < context.state.refs.length - 1) {
context.state.refs[idx + 1].current.focus();
}
}
}
break; break;
} }
} }
@ -193,7 +223,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
} else if (onKeyDown) { } else if (onKeyDown) {
return onKeyDown(ev, context.state); return onKeyDown(ev, context.state);
} }
}, [context.state, onKeyDown, handleHomeEnd]); }, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
return <RovingTabIndexContext.Provider value={context}> return <RovingTabIndexContext.Provider value={context}>
{ children({ onKeyDownHandler }) } { children({ onKeyDownHandler }) }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ReactNode, useMemo, useState } from "react"; import React, { ReactNode, KeyboardEvent, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces"; import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
@ -46,6 +46,8 @@ import { getDisplayAliasForAliasSet } from "../../Rooms";
import { useDispatcher } from "../../hooks/useDispatcher"; import { useDispatcher } from "../../hooks/useDispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
interface IHierarchyProps { interface IHierarchyProps {
space: Room; space: Room;
@ -80,6 +82,7 @@ const Tile: React.FC<ITileProps> = ({
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true); const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
const onPreviewClick = (ev: ButtonEvent) => { const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
@ -94,11 +97,21 @@ const Tile: React.FC<ITileProps> = ({
let button; let button;
if (joinedRoom) { if (joinedRoom) {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline"> button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") } { _t("View") }
</AccessibleButton>; </AccessibleButton>;
} else if (onJoinClick) { } else if (onJoinClick) {
button = <AccessibleButton onClick={onJoinClick} kind="primary"> button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") } { _t("Join") }
</AccessibleButton>; </AccessibleButton>;
} }
@ -106,13 +119,13 @@ const Tile: React.FC<ITileProps> = ({
let checkbox; let checkbox;
if (onToggleClick) { if (onToggleClick) {
if (hasPermissions) { if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />; checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else { } else {
checkbox = <TextWithTooltip checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")} tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }} onClick={ev => { ev.stopPropagation(); }}
> >
<StyledCheckbox disabled={true} /> <StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>; </TextWithTooltip>;
} }
} }
@ -185,19 +198,65 @@ const Tile: React.FC<ITileProps> = ({
toggleShowChildren(); toggleShowChildren();
}} }}
/>; />;
if (showChildren) { if (showChildren) {
childSection = <div className="mx_SpaceRoomDirectory_subspace_children"> const onChildrenKeyDown = (e) => {
if (e.key === Key.ARROW_LEFT) {
e.preventDefault();
e.stopPropagation();
ref.current?.focus();
}
};
childSection = <div
className="mx_SpaceRoomDirectory_subspace_children"
onKeyDown={onChildrenKeyDown}
role="group"
>
{ children } { children }
</div>; </div>;
} }
} }
const onKeyDown = children ? (e) => {
let handled = false;
switch (e.key) {
case Key.ARROW_LEFT:
if (showChildren) {
handled = true;
toggleShowChildren();
}
break;
case Key.ARROW_RIGHT:
handled = true;
if (showChildren) {
(ref.current?.nextElementSibling?.firstElementChild as HTMLElement)?.focus();
} else {
toggleShowChildren();
}
break;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
} : undefined;
return <> return <>
<AccessibleButton <AccessibleButton
className={classNames("mx_SpaceRoomDirectory_roomTile", { className={classNames("mx_SpaceRoomDirectory_roomTile", {
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space, mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
})} })}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick} onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
aria-expanded={children ? showChildren : undefined}
role="treeitem"
> >
{ content } { content }
{ childToggle } { childToggle }
@ -414,6 +473,15 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>; return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
} }
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
state.refs[0]?.current?.focus();
}
};
// TODO loading state/error state
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content; let content;
if (roomsMap) { if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length; const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
@ -429,9 +497,13 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
} }
let manageButtons; let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { if (space.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, userId)
) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
}); });
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
@ -563,7 +635,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
{ error && <div className="mx_SpaceRoomDirectory_error"> { error && <div className="mx_SpaceRoomDirectory_error">
{ error } { error }
</div> } </div> }
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list"> <AutoHideScrollbar className="mx_SpaceRoomDirectory_list" onKeyDown={onKeyDownHandler} role="tree">
{ results } { results }
{ children } { children }
</AutoHideScrollbar> </AutoHideScrollbar>
@ -572,18 +644,20 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
content = <Spinner />; content = <Spinner />;
} }
// TODO loading state/error state
return <> return <>
<SearchBox <SearchBox
className="mx_textinput_icon mx_textinput_search" className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")} placeholder={_t("Search names and descriptions")}
onSearch={setQuery} onSearch={setQuery}
autoFocus={true} autoFocus={true}
initialValue={initialText} initialValue={initialText}
onKeyDown={onKeyDownHandler}
/> />
{ content } { content }
</>; </>;
} }
</RovingTabIndexProvider>;
}; };
interface IProps { interface IProps {

View file

@ -272,6 +272,7 @@ const SpacePanel = () => {
<ul <ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })} className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
role="tree"
> >
<Droppable droppableId="top-level-spaces"> <Droppable droppableId="top-level-spaces">
{ (provided, snapshot) => ( { (provided, snapshot) => (

View file

@ -328,7 +328,7 @@ const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
isNested, isNested,
parents, parents,
}) => { }) => {
return <ul className="mx_SpaceTreeLevel"> return <ul className="mx_SpaceTreeLevel" role="group">
{ spaces.map(s => { { spaces.map(s => {
return (<SpaceItem return (<SpaceItem
key={s.roomId} key={s.roomId}