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,21 +168,50 @@ 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:
handled = true; if (handleHomeEnd) {
// move focus to first item handled = true;
if (context.state.refs.length > 0) { // move focus to first item
context.state.refs[0].current.focus(); if (context.state.refs.length > 0) {
context.state.refs[0].current.focus();
}
} }
break; break;
case Key.END: case Key.END:
handled = true; if (handleHomeEnd) {
// move focus to last item handled = true;
if (context.state.refs.length > 0) { // move focus to last item
context.state.refs[context.state.refs.length - 1].current.focus(); if (context.state.refs.length > 0) {
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,176 +473,191 @@ 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>;
} }
let content; const onKeyDown = (ev: KeyboardEvent, state: IState) => {
if (roomsMap) { if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length; state.refs[0]?.current?.focus();
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr;
if (numSpaces > 1) {
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
} else if (numSpaces > 0) {
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
} else {
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
} }
};
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = !selectedRelations.length || removing || saving;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).delete(childId);
if (parentChildMap.get(parentId).size > 0) {
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
} else {
parentChildMap.delete(parentId);
}
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results;
if (roomsMap.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
relations={parentChildMap}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
}}
/>
{ children && <hr /> }
</>;
} else {
results = <div className="mx_SpaceRoomDirectory_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
</div> }
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
{ results }
{ children }
</AutoHideScrollbar>
</>;
} else {
content = <Spinner />;
}
// TODO loading state/error state // TODO loading state/error state
return <> return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
<SearchBox { ({ onKeyDownHandler }) => {
className="mx_textinput_icon mx_textinput_search" let content;
placeholder={_t("Search names and descriptions")} if (roomsMap) {
onSearch={setQuery} const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
autoFocus={true} const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
initialValue={initialText}
/>
{ content } let countsStr;
</>; if (numSpaces > 1) {
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
} else if (numSpaces > 0) {
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
} else {
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
}
let manageButtons;
if (space.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, userId)
) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = !selectedRelations.length || removing || saving;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).delete(childId);
if (parentChildMap.get(parentId).size > 0) {
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
} else {
parentChildMap.delete(parentId);
}
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results;
if (roomsMap.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
relations={parentChildMap}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
}}
/>
{ children && <hr /> }
</>;
} else {
results = <div className="mx_SpaceRoomDirectory_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
</div> }
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list" onKeyDown={onKeyDownHandler} role="tree">
{ results }
{ children }
</AutoHideScrollbar>
</>;
} else {
content = <Spinner />;
}
return <>
<SearchBox
className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
{ 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}