diff --git a/site/src/components/CopyableValue/CopyableValue.tsx b/site/src/components/CopyableValue/CopyableValue.tsx index c099632a10716..f375bf64e4aaa 100644 --- a/site/src/components/CopyableValue/CopyableValue.tsx +++ b/site/src/components/CopyableValue/CopyableValue.tsx @@ -15,7 +15,7 @@ export const CopyableValue: FC = ({ ...props }) => { const { isCopied, copy } = useClipboard(value); - const clickableProps = useClickable(copy); + const clickableProps = useClickable(copy); const styles = useStyles(); return ( diff --git a/site/src/components/FileUpload/FileUpload.tsx b/site/src/components/FileUpload/FileUpload.tsx index cfcfa534d3792..e3e439e1b712e 100644 --- a/site/src/components/FileUpload/FileUpload.tsx +++ b/site/src/components/FileUpload/FileUpload.tsx @@ -65,7 +65,8 @@ export const FileUpload: FC = ({ const styles = useStyles(); const inputRef = useRef(null); const tarDrop = useFileDrop(onUpload, fileTypeRequired); - const clickable = useClickable(() => { + + const clickable = useClickable(() => { if (inputRef.current) { inputRef.current.click(); } diff --git a/site/src/components/Timeline/TimelineEntry.tsx b/site/src/components/Timeline/TimelineEntry.tsx index 2f8acf8a09da0..40985af99a1fe 100644 --- a/site/src/components/Timeline/TimelineEntry.tsx +++ b/site/src/components/Timeline/TimelineEntry.tsx @@ -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): JSX.Element => { +export const TimelineEntry = forwardRef(function TimelineEntry( + { children, clickable = true, ...props }: TimelineEntryProps, + ref?: React.ForwardedRef, +) { const styles = useStyles(); + return ( ); -}; +}); const useStyles = makeStyles((theme) => ({ clickable: { diff --git a/site/src/hooks/useClickable.ts b/site/src/hooks/useClickable.ts index 80644efd06a54..f9d065cabe94b 100644 --- a/site/src/hooks/useClickable.ts +++ b/site/src/hooks/useClickable.ts @@ -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; tabIndex: 0; role: "button"; - onClick: () => void; - onKeyDown: (event: KeyboardEvent) => void; + onClick: MouseEventHandler; + onKeyDown: KeyboardEventHandler; } -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, +): UseClickableResult => { + const ref = useRef(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(); } }, }; diff --git a/site/src/hooks/useClickableTableRow.ts b/site/src/hooks/useClickableTableRow.ts index 09a9366a9af6d..673cf78028f31 100644 --- a/site/src/hooks/useClickableTableRow.ts +++ b/site/src/hooks/useClickableTableRow.ts @@ -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 & + TableRowProps & { + className: string; + hover: true; + onAuxClick: MouseEventHandler; + }; + +// 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; + onMiddleClick?: MouseEventHandler; +}; -export const useClickableTableRow = ( - onClick: () => void, -): UseClickableTableRowResult => { +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); + }, }; }; diff --git a/site/src/pages/TemplatePage/TemplateVersionsPage/VersionRow.tsx b/site/src/pages/TemplatePage/TemplateVersionsPage/VersionRow.tsx index 0f29d902dcd45..11462e8141b1d 100644 --- a/site/src/pages/TemplatePage/TemplateVersionsPage/VersionRow.tsx +++ b/site/src/pages/TemplatePage/TemplateVersionsPage/VersionRow.tsx @@ -27,8 +27,9 @@ export const VersionRow: React.FC = ({ }) => { const styles = useStyles(); const navigate = useNavigate(); - const clickableProps = useClickableTableRow(() => { - navigate(version.name); + + const clickableProps = useClickableTableRow({ + onClick: () => navigate(version.name), }); return ( diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index a9d5c15726c53..426d46902bdd0 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -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 ( = ({ build }) => { const styles = useStyles(); const initiatedBy = getDisplayWorkspaceBuildInitiatedBy(build); const navigate = useNavigate(); - const clickableProps = useClickable(() => + const clickableProps = useClickable(() => navigate(`builds/${build.build_number}`), ); diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 259708a3e11e4..c23c923a1803f 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -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 ( checked ? theme.palette.action.hover : undefined,