mirror of https://github.com/coder/coder.git
fix: add tests and improve accessibility for useClickable (#12218)
This commit is contained in:
parent
a827185b6d
commit
1d254f4680
|
@ -114,6 +114,7 @@
|
|||
"Signup",
|
||||
"slogtest",
|
||||
"sourcemapped",
|
||||
"spinbutton",
|
||||
"Srcs",
|
||||
"stdbuf",
|
||||
"stretchr",
|
||||
|
|
|
@ -61,14 +61,11 @@ export const FileUpload: FC<FileUploadProps> = ({
|
|||
extension,
|
||||
fileTypeRequired,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const tarDrop = useFileDrop(onUpload, fileTypeRequired);
|
||||
|
||||
const clickable = useClickable<HTMLDivElement>(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.click();
|
||||
}
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const clickable = useClickable<HTMLDivElement>(
|
||||
() => inputRef.current?.click(),
|
||||
);
|
||||
|
||||
if (!isUploading && file) {
|
||||
return (
|
||||
|
|
|
@ -10,44 +10,62 @@ type ClickableElement = {
|
|||
click: () => void;
|
||||
};
|
||||
|
||||
export interface UseClickableResult<
|
||||
T extends ClickableElement = ClickableElement,
|
||||
> {
|
||||
ref: RefObject<T>;
|
||||
/**
|
||||
* May be worth adding support for the 'spinbutton' role at some point, but that
|
||||
* will change the structure of the return result in a big way. Better to wait
|
||||
* until we actually need it.
|
||||
*
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/}
|
||||
*/
|
||||
export type ClickableAriaRole = "button" | "switch";
|
||||
|
||||
export type UseClickableResult<
|
||||
TElement extends ClickableElement = ClickableElement,
|
||||
TRole extends ClickableAriaRole = ClickableAriaRole,
|
||||
> = Readonly<{
|
||||
ref: RefObject<TElement>;
|
||||
tabIndex: 0;
|
||||
role: "button";
|
||||
onClick: MouseEventHandler<T>;
|
||||
onKeyDown: KeyboardEventHandler<T>;
|
||||
}
|
||||
role: TRole;
|
||||
onClick: MouseEventHandler<TElement>;
|
||||
onKeyDown: KeyboardEventHandler<TElement>;
|
||||
onKeyUp: KeyboardEventHandler<TElement>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Exposes props to add basic click/interactive behavior to HTML elements that
|
||||
* don't traditionally have support for them.
|
||||
* Exposes props that let you turn traditionally non-interactive elements into
|
||||
* buttons.
|
||||
*/
|
||||
export const useClickable = <
|
||||
// T doesn't have a default type on purpose; the hook should error out if it
|
||||
// doesn't have an explicit type, or a type it can infer from onClick
|
||||
T extends ClickableElement,
|
||||
TElement extends ClickableElement,
|
||||
TRole extends ClickableAriaRole = ClickableAriaRole,
|
||||
>(
|
||||
// Even though onClick isn't used in any of the internal calculations, it's
|
||||
// still a required argument, just to make sure that useClickable can't
|
||||
// accidentally be called in a component without also defining click behavior
|
||||
onClick: MouseEventHandler<T>,
|
||||
): UseClickableResult<T> => {
|
||||
const ref = useRef<T>(null);
|
||||
onClick: MouseEventHandler<TElement>,
|
||||
role?: TRole,
|
||||
): UseClickableResult<TElement, TRole> => {
|
||||
const ref = useRef<TElement>(null);
|
||||
|
||||
return {
|
||||
ref,
|
||||
tabIndex: 0,
|
||||
role: "button",
|
||||
onClick,
|
||||
tabIndex: 0,
|
||||
role: (role ?? "button") as TRole,
|
||||
|
||||
// Most interactive elements automatically make Space/Enter trigger onClick
|
||||
// callbacks, but you explicitly have to add it for non-interactive elements
|
||||
/*
|
||||
* Native buttons are programmed to handle both space and enter, but they're
|
||||
* each handled via different event handlers.
|
||||
*
|
||||
* 99% of the time, you shouldn't be able to tell the difference, but one
|
||||
* edge case behavior is that holding down Enter will continually fire
|
||||
* events, while holding down Space won't fire anything until you let go.
|
||||
*/
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
// Can't call onClick from here because onKeydown's keyboard event isn't
|
||||
// compatible with mouse events. Have to use a ref to simulate a click
|
||||
if (event.key === "Enter") {
|
||||
ref.current?.click();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
onKeyUp: (event) => {
|
||||
if (event.key === " ") {
|
||||
ref.current?.click();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
import {
|
||||
type ElementType,
|
||||
type FC,
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
|
||||
import { type ClickableAriaRole, useClickable } from "./useClickable";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
/**
|
||||
* Since the point of the hook is to take a traditionally non-interactive HTML
|
||||
* element and make it interactive, it made the most sense to test against an
|
||||
* element directly.
|
||||
*/
|
||||
type NonNativeButtonProps<TElement extends HTMLElement = HTMLElement> =
|
||||
Readonly<
|
||||
PropsWithChildren<{
|
||||
as?: Exclude<ElementType, "button">;
|
||||
role?: ClickableAriaRole;
|
||||
onInteraction: MouseEventHandler<TElement>;
|
||||
}>
|
||||
>;
|
||||
|
||||
const NonNativeButton: FC<NonNativeButtonProps<HTMLElement>> = ({
|
||||
as,
|
||||
onInteraction,
|
||||
children,
|
||||
role = "button",
|
||||
}) => {
|
||||
const clickableProps = useClickable(onInteraction, role);
|
||||
const Component = as ?? "div";
|
||||
return <Component {...clickableProps}>{children}</Component>;
|
||||
};
|
||||
|
||||
describe(useClickable.name, () => {
|
||||
it("Always defaults to role 'button'", () => {
|
||||
render(<NonNativeButton onInteraction={jest.fn()} />);
|
||||
expect(() => screen.getByRole("button")).not.toThrow();
|
||||
});
|
||||
|
||||
it("Overrides the native role of any element that receives the hook result (be very careful with this behavior)", () => {
|
||||
const anchorText = "I'm a button that's secretly a link!";
|
||||
render(
|
||||
<NonNativeButton as="a" role="button" onInteraction={jest.fn()}>
|
||||
{anchorText}
|
||||
</NonNativeButton>,
|
||||
);
|
||||
|
||||
const linkButton = screen.getByRole("button", {
|
||||
name: anchorText,
|
||||
});
|
||||
|
||||
expect(linkButton).toBeInstanceOf(HTMLAnchorElement);
|
||||
});
|
||||
|
||||
it("Always returns out the same role override received via arguments", () => {
|
||||
const placeholderCallback = jest.fn();
|
||||
const roles = [
|
||||
"button",
|
||||
"switch",
|
||||
] as const satisfies readonly ClickableAriaRole[];
|
||||
|
||||
for (const role of roles) {
|
||||
const { unmount } = render(
|
||||
<NonNativeButton role={role} onInteraction={placeholderCallback} />,
|
||||
);
|
||||
|
||||
expect(() => screen.getByRole(role)).not.toThrow();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("Allows an element to receive keyboard focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
render(<NonNativeButton role="button" onInteraction={mockCallback} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<>
|
||||
<button>Native button</button>
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
await user.keyboard("[Tab][Tab]");
|
||||
await user.keyboard(" ");
|
||||
expect(mockCallback).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Allows an element to respond to clicks and Space/Enter, following all rules for native Button element interactions", async () => {
|
||||
const mockCallback = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<NonNativeButton role="button" onInteraction={mockCallback} />);
|
||||
|
||||
await user.click(document.body);
|
||||
await user.keyboard(" ");
|
||||
await user.keyboard("[Enter]");
|
||||
expect(mockCallback).not.toBeCalled();
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
await user.keyboard(" ");
|
||||
await user.keyboard("[Enter]");
|
||||
expect(mockCallback).toBeCalledTimes(3);
|
||||
});
|
||||
|
||||
it("Will keep firing events if the Enter key is held down", async () => {
|
||||
const mockCallback = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<NonNativeButton role="button" onInteraction={mockCallback} />);
|
||||
|
||||
// Focus over to element, hold down Enter for specified keydown period
|
||||
// (count determined by browser/library), and then release the Enter key
|
||||
const keydownCount = 5;
|
||||
await user.keyboard(`[Tab]{Enter>${keydownCount}}{/Enter}`);
|
||||
expect(mockCallback).toBeCalledTimes(keydownCount);
|
||||
});
|
||||
|
||||
it("Will NOT keep firing events if the Space key is held down", async () => {
|
||||
const mockCallback = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<NonNativeButton role="button" onInteraction={mockCallback} />);
|
||||
|
||||
// Focus over to element, and then hold down Space for 100 keydown cycles
|
||||
await user.keyboard("[Tab]{ >100}");
|
||||
expect(mockCallback).not.toBeCalled();
|
||||
|
||||
// Then explicitly release the space bar
|
||||
await user.keyboard(`{/ }`);
|
||||
expect(mockCallback).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test("If focus is lost while Space is held down, then releasing the key will do nothing", async () => {
|
||||
const mockCallback = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<NonNativeButton role="button" onInteraction={mockCallback} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<>
|
||||
{children}
|
||||
<button>Native button</button>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
// Focus over to element, hold down Space for an indefinite amount of time,
|
||||
// move focus away from element, and then release Space
|
||||
await user.keyboard("[Tab]{ >}[Tab]{/ }");
|
||||
expect(mockCallback).not.toBeCalled();
|
||||
});
|
||||
});
|
|
@ -1,9 +1,31 @@
|
|||
import { useTheme, type CSSObject } from "@emotion/react";
|
||||
/**
|
||||
* @file 2024-02-19 - MES - Sadly, even though this hook aims to make elements
|
||||
* more accessible, it's doing the opposite right now. Per axe audits, the
|
||||
* current implementation will create a bunch of critical-level accessibility
|
||||
* violations:
|
||||
*
|
||||
* 1. Nesting interactive elements (e.g., workspace table rows having checkboxes
|
||||
* inside them)
|
||||
* 2. Overriding the native element's role (in this case, turning a native table
|
||||
* row into a button, which means that screen readers lose the ability to
|
||||
* announce the row's data as part of a larger table)
|
||||
*
|
||||
* It might not make sense to test this hook until the underlying design
|
||||
* problems are fixed.
|
||||
*/
|
||||
import { type MouseEventHandler } from "react";
|
||||
import { type TableRowProps } from "@mui/material/TableRow";
|
||||
import { useClickable, type UseClickableResult } from "./useClickable";
|
||||
import { type CSSObject, useTheme } from "@emotion/react";
|
||||
|
||||
type UseClickableTableRowResult = UseClickableResult<HTMLTableRowElement> &
|
||||
import {
|
||||
type ClickableAriaRole,
|
||||
type UseClickableResult,
|
||||
useClickable,
|
||||
} from "./useClickable";
|
||||
import { type TableRowProps } from "@mui/material/TableRow";
|
||||
|
||||
type UseClickableTableRowResult<
|
||||
TRole extends ClickableAriaRole = ClickableAriaRole,
|
||||
> = UseClickableResult<HTMLTableRowElement, TRole> &
|
||||
TableRowProps & {
|
||||
css: CSSObject;
|
||||
hover: true;
|
||||
|
@ -11,24 +33,28 @@ type UseClickableTableRowResult = UseClickableResult<HTMLTableRowElement> &
|
|||
};
|
||||
|
||||
// Awkward type definition (the hover preview in VS Code isn't great, either),
|
||||
// but this basically takes all click props from TableRowProps, but makes
|
||||
// onClick required, and adds an optional onMiddleClick
|
||||
type UseClickableTableRowConfig = {
|
||||
// but this basically extracts all click props from TableRowProps, but makes
|
||||
// onClick required, and adds additional optional props (notably onMiddleClick)
|
||||
type UseClickableTableRowConfig<TRole extends ClickableAriaRole> = {
|
||||
[Key in keyof TableRowProps as Key extends `on${string}Click`
|
||||
? Key
|
||||
: never]: UseClickableTableRowResult[Key];
|
||||
: never]: UseClickableTableRowResult<TRole>[Key];
|
||||
} & {
|
||||
role?: TRole;
|
||||
onClick: MouseEventHandler<HTMLTableRowElement>;
|
||||
onMiddleClick?: MouseEventHandler<HTMLTableRowElement>;
|
||||
};
|
||||
|
||||
export const useClickableTableRow = ({
|
||||
export const useClickableTableRow = <
|
||||
TRole extends ClickableAriaRole = ClickableAriaRole,
|
||||
>({
|
||||
role,
|
||||
onClick,
|
||||
onAuxClick: externalOnAuxClick,
|
||||
onDoubleClick,
|
||||
onMiddleClick,
|
||||
}: UseClickableTableRowConfig): UseClickableTableRowResult => {
|
||||
const clickableProps = useClickable(onClick);
|
||||
onAuxClick: externalOnAuxClick,
|
||||
}: UseClickableTableRowConfig<TRole>): UseClickableTableRowResult<TRole> => {
|
||||
const clickableProps = useClickable(onClick, (role ?? "button") as TRole);
|
||||
const theme = useTheme();
|
||||
|
||||
return {
|
||||
|
@ -49,12 +75,14 @@ export const useClickableTableRow = ({
|
|||
hover: true,
|
||||
onDoubleClick,
|
||||
onAuxClick: (event) => {
|
||||
// Regardless of which callback gets called, the hook won't stop the event
|
||||
// from bubbling further up the DOM
|
||||
const isMiddleMouseButton = event.button === 1;
|
||||
if (isMiddleMouseButton) {
|
||||
onMiddleClick?.(event);
|
||||
} else {
|
||||
externalOnAuxClick?.(event);
|
||||
}
|
||||
|
||||
externalOnAuxClick?.(event);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -255,7 +255,6 @@ const WorkspacesRow: FC<WorkspacesRowProps> = ({
|
|||
|
||||
const workspacePageLink = `/@${workspace.owner_name}/${workspace.name}`;
|
||||
const openLinkInNewTab = () => window.open(workspacePageLink, "_blank");
|
||||
|
||||
const clickableProps = useClickableTableRow({
|
||||
onMiddleClick: openLinkInNewTab,
|
||||
onClick: (event) => {
|
||||
|
@ -272,7 +271,9 @@ const WorkspacesRow: FC<WorkspacesRowProps> = ({
|
|||
}
|
||||
},
|
||||
});
|
||||
|
||||
const bgColor = checked ? theme.palette.action.hover : undefined;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
{...clickableProps}
|
||||
|
|
Loading…
Reference in New Issue