Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit d9d5387

Browse files
authored
Fix roving tab index getting confused after dragging space order (#10901)
* Fix roving tab index getting confused after dragging space order * Fix roving tab index for drag reordering * delint * Add test * Make types happier * Remove snapshot
1 parent 2da199c commit d9d5387

File tree

3 files changed

+150
-33
lines changed

3 files changed

+150
-33
lines changed

src/accessibility/RovingTabIndex.tsx

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,40 @@ export enum Type {
7878
Register = "REGISTER",
7979
Unregister = "UNREGISTER",
8080
SetFocus = "SET_FOCUS",
81+
Update = "UPDATE",
8182
}
8283

8384
export interface IAction {
84-
type: Type;
85+
type: Exclude<Type, Type.Update>;
8586
payload: {
8687
ref: Ref;
8788
};
8889
}
8990

90-
export const reducer: Reducer<IState, IAction> = (state: IState, action: IAction) => {
91+
interface UpdateAction {
92+
type: Type.Update;
93+
payload?: undefined;
94+
}
95+
96+
type Action = IAction | UpdateAction;
97+
98+
const refSorter = (a: Ref, b: Ref): number => {
99+
if (a === b) {
100+
return 0;
101+
}
102+
103+
const position = a.current!.compareDocumentPosition(b.current!);
104+
105+
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
106+
return -1;
107+
} else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
108+
return 1;
109+
} else {
110+
return 0;
111+
}
112+
};
113+
114+
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
91115
switch (action.type) {
92116
case Type.Register: {
93117
if (!state.activeRef) {
@@ -97,21 +121,7 @@ export const reducer: Reducer<IState, IAction> = (state: IState, action: IAction
97121

98122
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
99123
state.refs.push(action.payload.ref);
100-
state.refs.sort((a, b) => {
101-
if (a === b) {
102-
return 0;
103-
}
104-
105-
const position = a.current!.compareDocumentPosition(b.current!);
106-
107-
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
108-
return -1;
109-
} else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
110-
return 1;
111-
} else {
112-
return 0;
113-
}
114-
});
124+
state.refs.sort(refSorter);
115125

116126
return { ...state };
117127
}
@@ -150,6 +160,11 @@ export const reducer: Reducer<IState, IAction> = (state: IState, action: IAction
150160
return { ...state };
151161
}
152162

163+
case Type.Update: {
164+
state.refs.sort(refSorter);
165+
return { ...state };
166+
}
167+
153168
default:
154169
return state;
155170
}
@@ -160,7 +175,7 @@ interface IProps {
160175
handleHomeEnd?: boolean;
161176
handleUpDown?: boolean;
162177
handleLeftRight?: boolean;
163-
children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode;
178+
children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void; onDragEndHandler(): void }): ReactNode;
164179
onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
165180
}
166181

@@ -199,7 +214,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
199214
handleLoop,
200215
onKeyDown,
201216
}) => {
202-
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
217+
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
203218
refs: [],
204219
});
205220

@@ -301,9 +316,15 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
301316
[context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight, handleLoop],
302317
);
303318

319+
const onDragEndHandler = useCallback(() => {
320+
dispatch({
321+
type: Type.Update,
322+
});
323+
}, []);
324+
304325
return (
305326
<RovingTabIndexContext.Provider value={context}>
306-
{children({ onKeyDownHandler })}
327+
{children({ onKeyDownHandler, onDragEndHandler })}
307328
</RovingTabIndexContext.Provider>
308329
);
309330
};

src/components/views/spaces/SpacePanel.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(
330330
);
331331

332332
const SpacePanel: React.FC = () => {
333+
const [dragging, setDragging] = useState(false);
333334
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
334335
const ref = useRef<HTMLDivElement>(null);
335336
useLayoutEffect(() => {
@@ -344,14 +345,19 @@ const SpacePanel: React.FC = () => {
344345
});
345346

346347
return (
347-
<DragDropContext
348-
onDragEnd={(result) => {
349-
if (!result.destination) return; // dropped outside the list
350-
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
351-
}}
352-
>
353-
<RovingTabIndexProvider handleHomeEnd handleUpDown>
354-
{({ onKeyDownHandler }) => (
348+
<RovingTabIndexProvider handleHomeEnd handleUpDown={!dragging}>
349+
{({ onKeyDownHandler, onDragEndHandler }) => (
350+
<DragDropContext
351+
onDragStart={() => {
352+
setDragging(true);
353+
}}
354+
onDragEnd={(result) => {
355+
setDragging(false);
356+
if (!result.destination) return; // dropped outside the list
357+
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
358+
onDragEndHandler();
359+
}}
360+
>
355361
<div
356362
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
357363
onKeyDown={onKeyDownHandler}
@@ -395,9 +401,9 @@ const SpacePanel: React.FC = () => {
395401

396402
<QuickSettingsButton isPanelCollapsed={isPanelCollapsed} />
397403
</div>
398-
)}
399-
</RovingTabIndexProvider>
400-
</DragDropContext>
404+
</DragDropContext>
405+
)}
406+
</RovingTabIndexProvider>
401407
);
402408
};
403409

test/components/views/spaces/SpacePanel-test.tsx

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import React from "react";
18-
import { render, screen, fireEvent } from "@testing-library/react";
18+
import { render, screen, fireEvent, act } from "@testing-library/react";
1919
import { mocked } from "jest-mock";
2020
import { MatrixClient } from "matrix-js-sdk/src/matrix";
2121

@@ -24,8 +24,71 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
2424
import { MetaSpace, SpaceKey } from "../../../../src/stores/spaces";
2525
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
2626
import { UIComponent } from "../../../../src/settings/UIFeature";
27-
import { wrapInSdkContext } from "../../../test-utils";
27+
import { mkStubRoom, wrapInSdkContext } from "../../../test-utils";
2828
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
29+
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
30+
import DMRoomMap from "../../../../src/utils/DMRoomMap";
31+
32+
// DND test utilities based on
33+
// https://github.com/colinrobertbrooks/react-beautiful-dnd-test-utils/issues/18#issuecomment-1373388693
34+
enum Keys {
35+
SPACE = 32,
36+
ARROW_LEFT = 37,
37+
ARROW_UP = 38,
38+
ARROW_RIGHT = 39,
39+
ARROW_DOWN = 40,
40+
}
41+
42+
enum DragDirection {
43+
LEFT = Keys.ARROW_LEFT,
44+
UP = Keys.ARROW_UP,
45+
RIGHT = Keys.ARROW_RIGHT,
46+
DOWN = Keys.ARROW_DOWN,
47+
}
48+
49+
// taken from https://github.com/hello-pangea/dnd/blob/main/test/unit/integration/util/controls.ts#L20
50+
const createTransitionEndEvent = (): Event => {
51+
const event = new Event("transitionend", {
52+
bubbles: true,
53+
cancelable: true,
54+
}) as TransitionEvent;
55+
56+
// cheating and adding property to event as
57+
// TransitionEvent constructor does not exist.
58+
// This is needed because of the following check
59+
// https://github.com/atlassian/react-beautiful-dnd/blob/master/src/view/draggable/draggable.jsx#L130
60+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
61+
(event as any).propertyName = "transform";
62+
63+
return event;
64+
};
65+
66+
const pickUp = async (element: HTMLElement) => {
67+
fireEvent.keyDown(element, {
68+
keyCode: Keys.SPACE,
69+
});
70+
await screen.findByText(/You have lifted an item/i);
71+
72+
act(() => {
73+
jest.runOnlyPendingTimers();
74+
});
75+
};
76+
77+
const move = async (element: HTMLElement, direction: DragDirection) => {
78+
fireEvent.keyDown(element, {
79+
keyCode: direction,
80+
});
81+
await screen.findByText(/(You have moved the item | has been combined with)/i);
82+
};
83+
84+
const drop = async (element: HTMLElement) => {
85+
fireEvent.keyDown(element, {
86+
keyCode: Keys.SPACE,
87+
});
88+
fireEvent(element.parentElement!, createTransitionEndEvent());
89+
90+
await screen.findByText(/You have dropped the item/i);
91+
};
2992

3093
jest.mock("../../../../src/stores/spaces/SpaceStore", () => {
3194
// eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -35,6 +98,10 @@ jest.mock("../../../../src/stores/spaces/SpaceStore", () => {
3598
enabledMetaSpaces: MetaSpace[] = [];
3699
spacePanelSpaces: string[] = [];
37100
activeSpace: SpaceKey = "!space1";
101+
getChildSpaces = () => [];
102+
getNotificationState = () => null;
103+
setActiveSpace = jest.fn();
104+
moveRootSpace = jest.fn();
38105
}
39106
return {
40107
instance: new MockSpaceStore(),
@@ -49,8 +116,12 @@ describe("<SpacePanel />", () => {
49116
const mockClient = {
50117
getUserId: jest.fn().mockReturnValue("@test:test"),
51118
getSafeUserId: jest.fn().mockReturnValue("@test:test"),
119+
mxcUrlToHttp: jest.fn(),
120+
getRoom: jest.fn(),
52121
isGuest: jest.fn(),
53122
getAccountData: jest.fn(),
123+
on: jest.fn(),
124+
removeListener: jest.fn(),
54125
} as unknown as MatrixClient;
55126
const SpacePanel = wrapInSdkContext(UnwrappedSpacePanel, SdkContextClass.instance);
56127

@@ -81,4 +152,23 @@ describe("<SpacePanel />", () => {
81152
screen.getByTestId("create-space-button");
82153
});
83154
});
155+
156+
it("should allow rearranging via drag and drop", async () => {
157+
(SpaceStore.instance.spacePanelSpaces as any) = [
158+
mkStubRoom("!room1:server", "Room 1", mockClient),
159+
mkStubRoom("!room2:server", "Room 2", mockClient),
160+
mkStubRoom("!room3:server", "Room 3", mockClient),
161+
];
162+
DMRoomMap.makeShared();
163+
jest.useFakeTimers();
164+
165+
const { getByLabelText } = render(<SpacePanel />);
166+
167+
const room1 = getByLabelText("Room 1");
168+
await pickUp(room1);
169+
await move(room1, DragDirection.DOWN);
170+
await drop(room1);
171+
172+
expect(SpaceStore.instance.moveRootSpace).toHaveBeenCalledWith(0, 1);
173+
});
84174
});

0 commit comments

Comments
 (0)