mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user