Skip to content

Commit 3757005

Browse files
authored
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
1 parent 4158180 commit 3757005

File tree

9 files changed

+122
-39
lines changed

9 files changed

+122
-39
lines changed

site/src/components/CopyableValue/CopyableValue.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const CopyableValue: FC<CopyableValueProps> = ({
1515
...props
1616
}) => {
1717
const { isCopied, copy } = useClipboard(value);
18-
const clickableProps = useClickable(copy);
18+
const clickableProps = useClickable<HTMLSpanElement>(copy);
1919
const styles = useStyles();
2020

2121
return (

site/src/components/FileUpload/FileUpload.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export const FileUpload: FC<FileUploadProps> = ({
6565
const styles = useStyles();
6666
const inputRef = useRef<HTMLInputElement>(null);
6767
const tarDrop = useFileDrop(onUpload, fileTypeRequired);
68-
const clickable = useClickable(() => {
68+
69+
const clickable = useClickable<HTMLDivElement>(() => {
6970
if (inputRef.current) {
7071
inputRef.current.click();
7172
}

site/src/components/Timeline/TimelineEntry.tsx

+13-10
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { makeStyles } from "@mui/styles";
22
import TableRow, { TableRowProps } from "@mui/material/TableRow";
3-
import { PropsWithChildren } from "react";
3+
import { type PropsWithChildren, forwardRef } from "react";
44
import { combineClasses } from "utils/combineClasses";
55

6-
interface TimelineEntryProps {
7-
clickable?: boolean;
8-
}
6+
type TimelineEntryProps = PropsWithChildren<
7+
TableRowProps & {
8+
clickable?: boolean;
9+
}
10+
>;
911

10-
export const TimelineEntry = ({
11-
children,
12-
clickable = true,
13-
...props
14-
}: PropsWithChildren<TimelineEntryProps & TableRowProps>): JSX.Element => {
12+
export const TimelineEntry = forwardRef(function TimelineEntry(
13+
{ children, clickable = true, ...props }: TimelineEntryProps,
14+
ref?: React.ForwardedRef<HTMLTableRowElement>,
15+
) {
1516
const styles = useStyles();
17+
1618
return (
1719
<TableRow
20+
ref={ref}
1821
className={combineClasses({
1922
[styles.timelineEntry]: true,
2023
[styles.clickable]: clickable,
@@ -24,7 +27,7 @@ export const TimelineEntry = ({
2427
{children}
2528
</TableRow>
2629
);
27-
};
30+
});
2831

2932
const useStyles = makeStyles((theme) => ({
3033
clickable: {

site/src/hooks/useClickable.ts

+43-8
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,55 @@
1-
import { KeyboardEvent } from "react";
1+
import {
2+
type KeyboardEventHandler,
3+
type MouseEventHandler,
4+
type RefObject,
5+
useRef,
6+
} from "react";
27

3-
export interface UseClickableResult {
8+
// Literally any object (ideally an HTMLElement) that has a .click method
9+
type ClickableElement = {
10+
click: () => void;
11+
};
12+
13+
export interface UseClickableResult<
14+
T extends ClickableElement = ClickableElement,
15+
> {
16+
ref: RefObject<T>;
417
tabIndex: 0;
518
role: "button";
6-
onClick: () => void;
7-
onKeyDown: (event: KeyboardEvent) => void;
19+
onClick: MouseEventHandler<T>;
20+
onKeyDown: KeyboardEventHandler<T>;
821
}
922

10-
export const useClickable = (onClick: () => void): UseClickableResult => {
23+
/**
24+
* Exposes props to add basic click/interactive behavior to HTML elements that
25+
* don't traditionally have support for them.
26+
*/
27+
export const useClickable = <
28+
// T doesn't have a default type on purpose; the hook should error out if it
29+
// doesn't have an explicit type, or a type it can infer from onClick
30+
T extends ClickableElement,
31+
>(
32+
// Even though onClick isn't used in any of the internal calculations, it's
33+
// still a required argument, just to make sure that useClickable can't
34+
// accidentally be called in a component without also defining click behavior
35+
onClick: MouseEventHandler<T>,
36+
): UseClickableResult<T> => {
37+
const ref = useRef<T>(null);
38+
1139
return {
40+
ref,
1241
tabIndex: 0,
1342
role: "button",
1443
onClick,
15-
onKeyDown: (event: KeyboardEvent) => {
16-
if (event.key === "Enter") {
17-
onClick();
44+
45+
// Most interactive elements automatically make Space/Enter trigger onClick
46+
// callbacks, but you explicitly have to add it for non-interactive elements
47+
onKeyDown: (event) => {
48+
if (event.key === "Enter" || event.key === " ") {
49+
// Can't call onClick from here because onKeydown's keyboard event isn't
50+
// compatible with mouse events. Have to use a ref to simulate a click
51+
ref.current?.click();
52+
event.stopPropagation();
1853
}
1954
},
2055
};

site/src/hooks/useClickableTableRow.ts

+38-10
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,49 @@
1+
import { type MouseEventHandler } from "react";
2+
import { type TableRowProps } from "@mui/material/TableRow";
13
import { makeStyles } from "@mui/styles";
2-
import { useClickable, UseClickableResult } from "./useClickable";
4+
import { useClickable, type UseClickableResult } from "./useClickable";
35

4-
interface UseClickableTableRowResult extends UseClickableResult {
5-
className: string;
6-
hover: true;
7-
}
6+
type UseClickableTableRowResult = UseClickableResult<HTMLTableRowElement> &
7+
TableRowProps & {
8+
className: string;
9+
hover: true;
10+
onAuxClick: MouseEventHandler<HTMLTableRowElement>;
11+
};
12+
13+
// Awkward type definition (the hover preview in VS Code isn't great, either),
14+
// but this basically takes all click props from TableRowProps, but makes
15+
// onClick required, and adds an optional onMiddleClick
16+
type UseClickableTableRowConfig = {
17+
[Key in keyof TableRowProps as Key extends `on${string}Click`
18+
? Key
19+
: never]: UseClickableTableRowResult[Key];
20+
} & {
21+
onClick: MouseEventHandler<HTMLTableRowElement>;
22+
onMiddleClick?: MouseEventHandler<HTMLTableRowElement>;
23+
};
824

9-
export const useClickableTableRow = (
10-
onClick: () => void,
11-
): UseClickableTableRowResult => {
25+
export const useClickableTableRow = ({
26+
onClick,
27+
onAuxClick: externalOnAuxClick,
28+
onDoubleClick,
29+
onMiddleClick,
30+
}: UseClickableTableRowConfig): UseClickableTableRowResult => {
1231
const styles = useStyles();
13-
const clickable = useClickable(onClick);
32+
const clickableProps = useClickable(onClick);
1433

1534
return {
16-
...clickable,
35+
...clickableProps,
1736
className: styles.row,
1837
hover: true,
38+
onDoubleClick,
39+
onAuxClick: (event) => {
40+
const isMiddleMouseButton = event.button === 1;
41+
if (isMiddleMouseButton) {
42+
onMiddleClick?.(event);
43+
}
44+
45+
externalOnAuxClick?.(event);
46+
},
1947
};
2048
};
2149

site/src/pages/TemplatePage/TemplateVersionsPage/VersionRow.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ export const VersionRow: React.FC<VersionRowProps> = ({
2727
}) => {
2828
const styles = useStyles();
2929
const navigate = useNavigate();
30-
const clickableProps = useClickableTableRow(() => {
31-
navigate(version.name);
30+
31+
const clickableProps = useClickableTableRow({
32+
onClick: () => navigate(version.name),
3233
});
3334

3435
return (

site/src/pages/TemplatesPage/TemplatesPageView.tsx

+2-3
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,9 @@ const TemplateRow: FC<{ template: Template }> = ({ template }) => {
8383
const hasIcon = template.icon && template.icon !== "";
8484
const navigate = useNavigate();
8585
const styles = useStyles();
86+
8687
const { className: clickableClassName, ...clickableRow } =
87-
useClickableTableRow(() => {
88-
navigate(templatePageLink);
89-
});
88+
useClickableTableRow({ onClick: () => navigate(templatePageLink) });
9089

9190
return (
9291
<TableRow

site/src/pages/WorkspacePage/BuildRow.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const BuildRow: React.FC<BuildRowProps> = ({ build }) => {
2626
const styles = useStyles();
2727
const initiatedBy = getDisplayWorkspaceBuildInitiatedBy(build);
2828
const navigate = useNavigate();
29-
const clickableProps = useClickable(() =>
29+
const clickableProps = useClickable<HTMLTableRowElement>(() =>
3030
navigate(`builds/${build.build_number}`),
3131
);
3232

site/src/pages/WorkspacesPage/WorkspacesTable.tsx

+19-3
Original file line numberDiff line numberDiff line change
@@ -250,15 +250,31 @@ const WorkspacesRow: FC<{
250250
checked: boolean;
251251
}> = ({ workspace, children, checked }) => {
252252
const navigate = useNavigate();
253+
253254
const workspacePageLink = `/@${workspace.owner_name}/${workspace.name}`;
254-
const clickable = useClickableTableRow(() => {
255-
navigate(workspacePageLink);
255+
const openLinkInNewTab = () => window.open(workspacePageLink, "_blank");
256+
257+
const clickableProps = useClickableTableRow({
258+
onMiddleClick: openLinkInNewTab,
259+
onClick: (event) => {
260+
// Order of booleans actually matters here for Windows-Mac compatibility;
261+
// meta key is Cmd on Macs, but on Windows, it's either the Windows key,
262+
// or the key does nothing at all (depends on the browser)
263+
const shouldOpenInNewTab =
264+
event.shiftKey || event.metaKey || event.ctrlKey;
265+
266+
if (shouldOpenInNewTab) {
267+
openLinkInNewTab();
268+
} else {
269+
navigate(workspacePageLink);
270+
}
271+
},
256272
});
257273

258274
return (
259275
<TableRow
276+
{...clickableProps}
260277
data-testid={`workspace-${workspace.id}`}
261-
{...clickable}
262278
sx={{
263279
backgroundColor: (theme) =>
264280
checked ? theme.palette.action.hover : undefined,

0 commit comments

Comments
 (0)