feat: add middle click support for workspace rows (#9834)

* chore: add generic ref support for useClickable

* chore: update useClickable call sites to use type parameter

* chore: update useClickableTableRow implementation

* chore: update other components using useClickableTableRow

* feat: add middle-click and cmd-click support for rows

* refactor: rename variable for clarity

* docs: add comment for clarity

* chore: add more click logic and comments

* refactor: clean up useClickableTableRow

* docs: rewrite comments for clarity

* fix: update TimelineEntry to accept forwarded ref

* fix: fix keyboard event logic to respond to spaces properly
This commit is contained in:
Michael Smith
2023-09-25 11:32:33 -04:00
committed by GitHub
parent 415818035c
commit 3757005e82
9 changed files with 122 additions and 39 deletions
@@ -15,7 +15,7 @@ export const CopyableValue: FC<CopyableValueProps> = ({
...props
}) => {
const { isCopied, copy } = useClipboard(value);
const clickableProps = useClickable(copy);
const clickableProps = useClickable<HTMLSpanElement>(copy);
const styles = useStyles();
return (
@@ -65,7 +65,8 @@ export const FileUpload: FC<FileUploadProps> = ({
const styles = useStyles();
const inputRef = useRef<HTMLInputElement>(null);
const tarDrop = useFileDrop(onUpload, fileTypeRequired);
const clickable = useClickable(() => {
const clickable = useClickable<HTMLDivElement>(() => {
if (inputRef.current) {
inputRef.current.click();
}
+13 -10
View File
@@ -1,20 +1,23 @@
import { makeStyles } from "@mui/styles";
import TableRow, { TableRowProps } from "@mui/material/TableRow";
import { PropsWithChildren } from "react";
import { type PropsWithChildren, forwardRef } from "react";
import { combineClasses } from "utils/combineClasses";
interface TimelineEntryProps {
clickable?: boolean;
}
type TimelineEntryProps = PropsWithChildren<
TableRowProps & {
clickable?: boolean;
}
>;
export const TimelineEntry = ({
children,
clickable = true,
...props
}: PropsWithChildren<TimelineEntryProps & TableRowProps>): JSX.Element => {
export const TimelineEntry = forwardRef(function TimelineEntry(
{ children, clickable = true, ...props }: TimelineEntryProps,
ref?: React.ForwardedRef<HTMLTableRowElement>,
) {
const styles = useStyles();
return (
<TableRow
ref={ref}
className={combineClasses({
[styles.timelineEntry]: true,
[styles.clickable]: clickable,
@@ -24,7 +27,7 @@ export const TimelineEntry = ({
{children}
</TableRow>
);
};
});
const useStyles = makeStyles((theme) => ({
clickable: {
+43 -8
View File
@@ -1,20 +1,55 @@
import { KeyboardEvent } from "react";
import {
type KeyboardEventHandler,
type MouseEventHandler,
type RefObject,
useRef,
} from "react";
export interface UseClickableResult {
// Literally any object (ideally an HTMLElement) that has a .click method
type ClickableElement = {
click: () => void;
};
export interface UseClickableResult<
T extends ClickableElement = ClickableElement,
> {
ref: RefObject<T>;
tabIndex: 0;
role: "button";
onClick: () => void;
onKeyDown: (event: KeyboardEvent) => void;
onClick: MouseEventHandler<T>;
onKeyDown: KeyboardEventHandler<T>;
}
export const useClickable = (onClick: () => void): UseClickableResult => {
/**
* Exposes props to add basic click/interactive behavior to HTML elements that
* don't traditionally have support for them.
*/
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,
>(
// 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);
return {
ref,
tabIndex: 0,
role: "button",
onClick,
onKeyDown: (event: KeyboardEvent) => {
if (event.key === "Enter") {
onClick();
// Most interactive elements automatically make Space/Enter trigger onClick
// callbacks, but you explicitly have to add it for non-interactive elements
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
ref.current?.click();
event.stopPropagation();
}
},
};
+38 -10
View File
@@ -1,21 +1,49 @@
import { type MouseEventHandler } from "react";
import { type TableRowProps } from "@mui/material/TableRow";
import { makeStyles } from "@mui/styles";
import { useClickable, UseClickableResult } from "./useClickable";
import { useClickable, type UseClickableResult } from "./useClickable";
interface UseClickableTableRowResult extends UseClickableResult {
className: string;
hover: true;
}
type UseClickableTableRowResult = UseClickableResult<HTMLTableRowElement> &
TableRowProps & {
className: string;
hover: true;
onAuxClick: MouseEventHandler<HTMLTableRowElement>;
};
export const useClickableTableRow = (
onClick: () => void,
): UseClickableTableRowResult => {
// 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 = {
[Key in keyof TableRowProps as Key extends `on${string}Click`
? Key
: never]: UseClickableTableRowResult[Key];
} & {
onClick: MouseEventHandler<HTMLTableRowElement>;
onMiddleClick?: MouseEventHandler<HTMLTableRowElement>;
};
export const useClickableTableRow = ({
onClick,
onAuxClick: externalOnAuxClick,
onDoubleClick,
onMiddleClick,
}: UseClickableTableRowConfig): UseClickableTableRowResult => {
const styles = useStyles();
const clickable = useClickable(onClick);
const clickableProps = useClickable(onClick);
return {
...clickable,
...clickableProps,
className: styles.row,
hover: true,
onDoubleClick,
onAuxClick: (event) => {
const isMiddleMouseButton = event.button === 1;
if (isMiddleMouseButton) {
onMiddleClick?.(event);
}
externalOnAuxClick?.(event);
},
};
};
@@ -27,8 +27,9 @@ export const VersionRow: React.FC<VersionRowProps> = ({
}) => {
const styles = useStyles();
const navigate = useNavigate();
const clickableProps = useClickableTableRow(() => {
navigate(version.name);
const clickableProps = useClickableTableRow({
onClick: () => navigate(version.name),
});
return (
@@ -83,10 +83,9 @@ const TemplateRow: FC<{ template: Template }> = ({ template }) => {
const hasIcon = template.icon && template.icon !== "";
const navigate = useNavigate();
const styles = useStyles();
const { className: clickableClassName, ...clickableRow } =
useClickableTableRow(() => {
navigate(templatePageLink);
});
useClickableTableRow({ onClick: () => navigate(templatePageLink) });
return (
<TableRow
+1 -1
View File
@@ -26,7 +26,7 @@ export const BuildRow: React.FC<BuildRowProps> = ({ build }) => {
const styles = useStyles();
const initiatedBy = getDisplayWorkspaceBuildInitiatedBy(build);
const navigate = useNavigate();
const clickableProps = useClickable(() =>
const clickableProps = useClickable<HTMLTableRowElement>(() =>
navigate(`builds/${build.build_number}`),
);
@@ -250,15 +250,31 @@ const WorkspacesRow: FC<{
checked: boolean;
}> = ({ workspace, children, checked }) => {
const navigate = useNavigate();
const workspacePageLink = `/@${workspace.owner_name}/${workspace.name}`;
const clickable = useClickableTableRow(() => {
navigate(workspacePageLink);
const openLinkInNewTab = () => window.open(workspacePageLink, "_blank");
const clickableProps = useClickableTableRow({
onMiddleClick: openLinkInNewTab,
onClick: (event) => {
// Order of booleans actually matters here for Windows-Mac compatibility;
// meta key is Cmd on Macs, but on Windows, it's either the Windows key,
// or the key does nothing at all (depends on the browser)
const shouldOpenInNewTab =
event.shiftKey || event.metaKey || event.ctrlKey;
if (shouldOpenInNewTab) {
openLinkInNewTab();
} else {
navigate(workspacePageLink);
}
},
});
return (
<TableRow
{...clickableProps}
data-testid={`workspace-${workspace.id}`}
{...clickable}
sx={{
backgroundColor: (theme) =>
checked ? theme.palette.action.hover : undefined,