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

Improve Forward Dialog a11y by switching to roving tab index interactions #12306

Merged
merged 4 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion res/css/views/dialogs/_ForwardDialog.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ limitations under the License.
padding: 6px;
border-radius: 8px;

&:hover {
&:hover,
&.mx_ForwardList_entry_active {
background-color: $spacePanel-bg-color;
}

Expand Down
20 changes: 18 additions & 2 deletions src/accessibility/RovingTabIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAction>): void;
}
Expand Down Expand Up @@ -212,6 +214,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
handleUpDown,
handleLeftRight,
handleLoop,
handleInputFields,
scrollIntoView,
onKeyDown,
}) => {
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
Expand All @@ -234,7 +238,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
let focusRef: RefObject<HTMLElement> | 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;
Expand Down Expand Up @@ -311,9 +315,21 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
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(() => {
Expand Down
155 changes: 116 additions & 39 deletions src/components/views/dialogs/ForwardDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -87,6 +96,7 @@ enum SendState {

const Entry: React.FC<IEntryProps> = ({ room, type, content, matrixClient: cli, onFinished }) => {
const [sendState, setSendState] = useState<SendState>(SendState.CanSend);
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLDivElement>();

const jumpToRoom = (ev: ButtonEvent): void => {
dis.dispatch<ViewRoomPayload>({
Expand Down Expand Up @@ -134,16 +144,30 @@ const Entry: React.FC<IEntryProps> = ({ room, type, content, matrixClient: cli,
icon = <NotificationBadge notification={StaticNotificationState.RED_EXCLAMATION} />;
}

const id = `mx_ForwardDialog_entry_${room.roomId}`;
return (
<div className="mx_ForwardList_entry">
<div
className={classnames("mx_ForwardList_entry", {
mx_ForwardList_entry_active: isActive,
})}
aria-labelledby={`${id}_name`}
aria-describedby={`${id}_send`}
role="listitem"
ref={ref}
onFocus={onFocus}
id={id}
>
<AccessibleTooltipButton
className="mx_ForwardList_roomButton"
onClick={jumpToRoom}
title={_t("forward|open_room")}
alignment={Alignment.Top}
tabIndex={isActive ? 0 : -1}
>
<DecoratedRoomAvatar room={room} size="32px" />
<span className="mx_ForwardList_entry_name">{room.name}</span>
<DecoratedRoomAvatar room={room} size="32px" tooltipProps={{ tabIndex: isActive ? 0 : -1 }} />
<span className="mx_ForwardList_entry_name" id={`${id}_name`}>
{room.name}
</span>
<RoomContextDetails component="span" className="mx_ForwardList_entry_detail" room={room} />
</AccessibleTooltipButton>
<AccessibleTooltipButton
Expand All @@ -153,6 +177,8 @@ const Entry: React.FC<IEntryProps> = ({ room, type, content, matrixClient: cli,
disabled={disabled}
title={title}
alignment={Alignment.Top}
tabIndex={isActive ? 0 : -1}
id={`${id}_send`}
>
<div className="mx_ForwardList_sendLabel">{_t("forward|send_label")}</div>
{icon}
Expand Down Expand Up @@ -270,6 +296,26 @@ const ForwardDialog: React.FC<IProps> = ({ 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<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click();
break;
}

default:
handled = false;
}

if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
};

return (
<BaseDialog
title={_t("common|forward_message")}
Expand All @@ -293,42 +339,73 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
/>
</div>
<hr />
<div className="mx_ForwardList" id="mx_ForwardList">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("forward|filter_placeholder")}
onSearch={setQuery}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ForwardList_content">
{rooms.length > 0 ? (
<div className="mx_ForwardList_results">
<TruncatedList
className="mx_ForwardList_resultsList"
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) =>
rooms
.slice(start, end)
.map((room) => (
<Entry
key={room.roomId}
room={room}
type={type}
content={content}
matrixClient={cli}
onFinished={onFinished}
/>
))
}
getChildCount={() => rooms.length}
/>
</div>
) : (
<span className="mx_ForwardList_noResults">{_t("common|no_results")}</span>
)}
</AutoHideScrollbar>
</div>
<RovingTabIndexProvider
handleUpDown
handleInputFields
onKeyDown={onKeyDown}
scrollIntoView={{ block: "center" }}
>
{({ onKeyDownHandler }) => (
<div className="mx_ForwardList" id="mx_ForwardList">
<RovingTabIndexContext.Consumer>
{(context) => (
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("forward|filter_placeholder")}
onSearch={(query: string): void => {
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"
/>
)}
</RovingTabIndexContext.Consumer>
<AutoHideScrollbar className="mx_ForwardList_content">
{rooms.length > 0 ? (
<div className="mx_ForwardList_results">
<TruncatedList
id="mx_ForwardDialog_resultsList"
className="mx_ForwardList_resultsList"
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) =>
rooms
.slice(start, end)
.map((room) => (
<Entry
key={room.roomId}
room={room}
type={type}
content={content}
matrixClient={cli}
onFinished={onFinished}
/>
))
}
getChildCount={() => rooms.length}
/>
</div>
) : (
<span className="mx_ForwardList_noResults">{_t("common|no_results")}</span>
)}
</AutoHideScrollbar>
</div>
)}
</RovingTabIndexProvider>
</BaseDialog>
);
};
Expand Down
3 changes: 2 additions & 1 deletion src/components/views/elements/TruncatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IProps> {
Expand Down Expand Up @@ -86,7 +87,7 @@ export default class TruncatedList extends React.Component<IProps> {
const childNodes = this.getChildren(0, upperBound);

return (
<div className={this.props.className}>
<div className={this.props.className} role="list" id={this.props.id}>
{childNodes}
{overflowNode}
</div>
Expand Down
58 changes: 58 additions & 0 deletions test/accessibility/RovingTabIndex-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
<RovingTabIndexProvider handleUpDown>
{({ onKeyDownHandler }) => (
<div onKeyDown={onKeyDownHandler}>
{button1}
{button2}
{button3}
</div>
)}
</RovingTabIndexProvider>,
);

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(
<RovingTabIndexProvider handleUpDown scrollIntoView>
{({ onKeyDownHandler }) => (
<div onKeyDown={onKeyDownHandler}>
{button1}
{button2}
{button3}
</div>
)}
</RovingTabIndexProvider>,
);

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();
});
});
});
Loading
Loading