diff --git a/res/css/views/dialogs/_ForwardDialog.pcss b/res/css/views/dialogs/_ForwardDialog.pcss index e6c322a77c..69c9bafc89 100644 --- a/res/css/views/dialogs/_ForwardDialog.pcss +++ b/res/css/views/dialogs/_ForwardDialog.pcss @@ -96,7 +96,8 @@ limitations under the License. padding: 6px; border-radius: 8px; - &:hover { + &:hover, + &.mx_ForwardList_entry_active { background-color: $spacePanel-bg-color; } diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 5f3901a391..9a2a855242 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -175,6 +175,8 @@ interface IProps { handleHomeEnd?: boolean; handleUpDown?: boolean; handleLeftRight?: boolean; + handleInputFields?: boolean; + scrollIntoView?: boolean | ScrollIntoViewOptions; children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void; onDragEndHandler(): void }): ReactNode; onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch): void; } @@ -212,6 +214,8 @@ export const RovingTabIndexProvider: React.FC = ({ handleUpDown, handleLeftRight, handleLoop, + handleInputFields, + scrollIntoView, onKeyDown, }) => { const [state, dispatch] = useReducer>(reducer, { @@ -234,7 +238,7 @@ export const RovingTabIndexProvider: React.FC = ({ let focusRef: RefObject | undefined; // Don't interfere with input default keydown behaviour // but allow people to move focus from it with Tab. - if (checkInputableElement(ev.target as HTMLElement)) { + if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) { switch (action) { case KeyBindingAction.Tab: handled = true; @@ -311,9 +315,21 @@ export const RovingTabIndexProvider: React.FC = ({ ref: focusRef, }, }); + if (scrollIntoView) { + focusRef.current?.scrollIntoView(scrollIntoView); + } } }, - [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight, handleLoop], + [ + context, + onKeyDown, + handleHomeEnd, + handleUpDown, + handleLeftRight, + handleLoop, + handleInputFields, + scrollIntoView, + ], ); const onDragEndHandler = useCallback(() => { diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index a21acd7b71..fe73738629 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -57,6 +57,15 @@ import { isLocationEvent } from "../../../utils/EventUtils"; import { isSelfLocation, locationEventGeoUri } from "../../../utils/location"; import { RoomContextDetails } from "../rooms/RoomContextDetails"; import { filterBoolean } from "../../../utils/arrays"; +import { + IState, + RovingTabIndexContext, + RovingTabIndexProvider, + Type, + useRovingTabIndex, +} from "../../../accessibility/RovingTabIndex"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; const AVATAR_SIZE = 30; @@ -87,6 +96,7 @@ enum SendState { const Entry: React.FC = ({ room, type, content, matrixClient: cli, onFinished }) => { const [sendState, setSendState] = useState(SendState.CanSend); + const [onFocus, isActive, ref] = useRovingTabIndex(); const jumpToRoom = (ev: ButtonEvent): void => { dis.dispatch({ @@ -134,16 +144,30 @@ const Entry: React.FC = ({ room, type, content, matrixClient: cli, icon = ; } + const id = `mx_ForwardDialog_entry_${room.roomId}`; return ( -
+
- - {room.name} + + + {room.name} + = ({ room, type, content, matrixClient: cli, disabled={disabled} title={title} alignment={Alignment.Top} + tabIndex={isActive ? 0 : -1} + id={`${id}_send`} >
{_t("forward|send_label")}
{icon} @@ -270,6 +296,26 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr ); } + const onKeyDown = (ev: React.KeyboardEvent, state: IState): void => { + let handled = true; + + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.Enter: { + state.activeRef?.current?.querySelector(".mx_ForwardList_sendButton")?.click(); + break; + } + + default: + handled = false; + } + + if (handled) { + ev.preventDefault(); + ev.stopPropagation(); + } + }; + return ( = ({ matrixClient: cli, event, permalinkCr />

-
- - - {rooms.length > 0 ? ( -
- - rooms - .slice(start, end) - .map((room) => ( - - )) - } - getChildCount={() => rooms.length} - /> -
- ) : ( - {_t("common|no_results")} - )} -
-
+ + {({ onKeyDownHandler }) => ( +
+ + {(context) => ( + { + setQuery(query); + setImmediate(() => { + const ref = context.state.refs[0]; + if (ref) { + context.dispatch({ + type: Type.SetFocus, + payload: { ref }, + }); + ref.current?.scrollIntoView?.({ + block: "nearest", + }); + } + }); + }} + autoFocus={true} + onKeyDown={onKeyDownHandler} + aria-activedescendant={context.state.activeRef?.current?.id} + aria-owns="mx_ForwardDialog_resultsList" + /> + )} + + + {rooms.length > 0 ? ( +
+ + rooms + .slice(start, end) + .map((room) => ( + + )) + } + getChildCount={() => rooms.length} + /> +
+ ) : ( + {_t("common|no_results")} + )} +
+
+ )} +
); }; diff --git a/src/components/views/elements/TruncatedList.tsx b/src/components/views/elements/TruncatedList.tsx index 074df5bfb2..4c6979832d 100644 --- a/src/components/views/elements/TruncatedList.tsx +++ b/src/components/views/elements/TruncatedList.tsx @@ -36,6 +36,7 @@ interface IProps { // This will be inserted after the children. createOverflowElement: (overflowCount: number, totalCount: number) => React.ReactNode; children?: ReactNode; + id?: string; } export default class TruncatedList extends React.Component { @@ -86,7 +87,7 @@ export default class TruncatedList extends React.Component { const childNodes = this.getChildren(0, upperBound); return ( -
+
{childNodes} {overflowNode}
diff --git a/test/accessibility/RovingTabIndex-test.tsx b/test/accessibility/RovingTabIndex-test.tsx index 4a2e67fece..c2d5fbf0a8 100644 --- a/test/accessibility/RovingTabIndex-test.tsx +++ b/test/accessibility/RovingTabIndex-test.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { HTMLAttributes } from "react"; import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { IState, @@ -364,4 +365,61 @@ describe("RovingTabIndex", () => { }); }); }); + + describe("handles arrow keys", () => { + it("should handle up/down arrow keys work when handleUpDown=true", async () => { + const { container } = render( + + {({ onKeyDownHandler }) => ( +
+ {button1} + {button2} + {button3} +
+ )} +
, + ); + + container.querySelectorAll("button")[0].focus(); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + + await userEvent.keyboard("[ArrowDown]"); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); + + await userEvent.keyboard("[ArrowDown]"); + checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); + + await userEvent.keyboard("[ArrowUp]"); + checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); + + await userEvent.keyboard("[ArrowUp]"); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + + // Does not loop without + await userEvent.keyboard("[ArrowUp]"); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + }); + + it("should call scrollIntoView if specified", async () => { + const { container } = render( + + {({ onKeyDownHandler }) => ( +
+ {button1} + {button2} + {button3} +
+ )} +
, + ); + + container.querySelectorAll("button")[0].focus(); + checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); + + const button = container.querySelectorAll("button")[1]; + const mock = jest.spyOn(button, "scrollIntoView"); + await userEvent.keyboard("[ArrowDown]"); + expect(mock).toHaveBeenCalled(); + }); + }); }); diff --git a/test/components/views/dialogs/ForwardDialog-test.tsx b/test/components/views/dialogs/ForwardDialog-test.tsx index 1147a8ac93..6206087ab8 100644 --- a/test/components/views/dialogs/ForwardDialog-test.tsx +++ b/test/components/views/dialogs/ForwardDialog-test.tsx @@ -24,7 +24,7 @@ import { M_TIMESTAMP, M_TEXT, } from "matrix-js-sdk/src/matrix"; -import { act, fireEvent, getByTestId, render, RenderResult, screen } from "@testing-library/react"; +import { act, fireEvent, getByTestId, render, RenderResult, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -44,6 +44,13 @@ import { import { TILE_SERVER_WK_KEY } from "../../../../src/utils/WellKnownUtils"; import SettingsStore from "../../../../src/settings/SettingsStore"; +// mock offsetParent +Object.defineProperty(HTMLElement.prototype, "offsetParent", { + get() { + return this.parentNode; + }, +}); + describe("ForwardDialog", () => { const sourceRoom = "!111111111111111111:example.org"; const aliceId = "@alice:example.org"; @@ -128,6 +135,37 @@ describe("ForwardDialog", () => { expect(container.querySelectorAll(".mx_ForwardList_entry")).toHaveLength(3); }); + it("should be navigable using arrow keys", async () => { + const { container } = mountForwardDialog(); + + const searchBox = getByTestId(container, "searchbox-input"); + searchBox.focus(); + await waitFor(() => + expect(container.querySelectorAll(".mx_ForwardList_entry")[0]).toHaveClass("mx_ForwardList_entry_active"), + ); + + await userEvent.keyboard("[ArrowDown]"); + await waitFor(() => + expect(container.querySelectorAll(".mx_ForwardList_entry")[1]).toHaveClass("mx_ForwardList_entry_active"), + ); + + await userEvent.keyboard("[ArrowDown]"); + await waitFor(() => + expect(container.querySelectorAll(".mx_ForwardList_entry")[2]).toHaveClass("mx_ForwardList_entry_active"), + ); + + await userEvent.keyboard("[ArrowUp]"); + await waitFor(() => + expect(container.querySelectorAll(".mx_ForwardList_entry")[1]).toHaveClass("mx_ForwardList_entry_active"), + ); + + await userEvent.keyboard("[Enter]"); + expect(mockClient.sendEvent).toHaveBeenCalledWith("A", "m.room.message", { + body: "Hello world!", + msgtype: "m.text", + }); + }); + it("tracks message sending progress across multiple rooms", async () => { mockPlatformPeg(); const { container } = mountForwardDialog();