From 0d8832416beffd3dfd99aeb0fc607c33beed51a3 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 22 Sep 2023 21:22:23 +0000 Subject: [PATCH 01/12] chore: add generic ref support for useClickable --- site/src/hooks/useClickable.ts | 35 +++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/site/src/hooks/useClickable.ts b/site/src/hooks/useClickable.ts index 80644efd06a54..ddb22636b8c79 100644 --- a/site/src/hooks/useClickable.ts +++ b/site/src/hooks/useClickable.ts @@ -1,20 +1,45 @@ -import { KeyboardEvent } from "react"; +import { + type MouseEventHandler, + type KeyboardEvent, + 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; + onClick: MouseEventHandler; onKeyDown: (event: KeyboardEvent) => void; } -export const useClickable = (onClick: () => void): UseClickableResult => { +export const useClickable = < + // T doesn't have a default type to make it more obvious that the hook expects + // a type argument in order to work at all + T extends ClickableElement, +>( + onClick: MouseEventHandler, +): UseClickableResult => { + const ref = useRef(null); + return { + ref, tabIndex: 0, role: "button", onClick, onKeyDown: (event: KeyboardEvent) => { if (event.key === "Enter") { - onClick(); + // Can't call onClick directly because onClick needs to work with an + // event, and mouse events + keyboard events aren't compatible; wouldn't + // have a value to pass in. Have to use a ref to simulate a click + ref.current?.click(); } }, }; From 0f629342a386a269a2a7fee1776371a2c963c28b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 22 Sep 2023 21:23:35 +0000 Subject: [PATCH 02/12] chore: update useClickable call sites to use type parameter --- site/src/components/CopyableValue/CopyableValue.tsx | 2 +- site/src/components/FileUpload/FileUpload.tsx | 3 ++- site/src/pages/WorkspacePage/BuildRow.tsx | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) 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/pages/WorkspacePage/BuildRow.tsx b/site/src/pages/WorkspacePage/BuildRow.tsx index e67c1213e58cf..64c61b7c36a31 100644 --- a/site/src/pages/WorkspacePage/BuildRow.tsx +++ b/site/src/pages/WorkspacePage/BuildRow.tsx @@ -26,7 +26,7 @@ export const BuildRow: React.FC = ({ build }) => { const styles = useStyles(); const initiatedBy = getDisplayWorkspaceBuildInitiatedBy(build); const navigate = useNavigate(); - const clickableProps = useClickable(() => + const clickableProps = useClickable(() => navigate(`builds/${build.build_number}`), ); From f90d9ffaa86bf33d7f62cd2cd9c65a7f9b24562e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 22 Sep 2023 21:35:14 +0000 Subject: [PATCH 03/12] chore: update useClickableTableRow implementation --- site/src/hooks/useClickableTableRow.ts | 30 +++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/site/src/hooks/useClickableTableRow.ts b/site/src/hooks/useClickableTableRow.ts index 09a9366a9af6d..7582d998bbc24 100644 --- a/site/src/hooks/useClickableTableRow.ts +++ b/site/src/hooks/useClickableTableRow.ts @@ -1,19 +1,29 @@ +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; + }; + +type TableRowOnClickProps = { + [Key in keyof UseClickableTableRowResult as Key extends `on${string}Click` + ? Key + : never]: UseClickableTableRowResult[Key]; +}; -export const useClickableTableRow = ( - onClick: () => void, -): UseClickableTableRowResult => { +export const useClickableTableRow = ({ + onClick, + ...optionalOnClickProps +}: TableRowOnClickProps): UseClickableTableRowResult => { const styles = useStyles(); - const clickable = useClickable(onClick); + const clickableProps = useClickable(onClick); return { - ...clickable, + ...clickableProps, + ...optionalOnClickProps, className: styles.row, hover: true, }; From bd4200462153cb65d7d6aa36b7c205513accb891 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 22 Sep 2023 21:36:28 +0000 Subject: [PATCH 04/12] chore: update other components using useClickableTableRow --- .../pages/TemplatePage/TemplateVersionsPage/VersionRow.tsx | 5 +++-- site/src/pages/TemplatesPage/TemplatesPageView.tsx | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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 ( Date: Fri, 22 Sep 2023 21:37:49 +0000 Subject: [PATCH 05/12] feat: add middle-click and cmd-click support for rows --- .../pages/WorkspacesPage/WorkspacesTable.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 259708a3e11e4..5b7ae477a15ef 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -250,15 +250,33 @@ 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({ + onAuxClick: (event) => { + const userClickedMiddleButton = event.button === 1; + if (userClickedMiddleButton) { + openLinkInNewTab(); + } + }, + onClick: (event) => { + const shouldOpenInNewTab = + event.ctrlKey || event.shiftKey || event.metaKey; + + if (shouldOpenInNewTab) { + openLinkInNewTab(); + } else { + navigate(workspacePageLink); + } + }, }); return ( checked ? theme.palette.action.hover : undefined, From 78709a06c2dc1a73ccf36152fc15dc6b68d83b90 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 22 Sep 2023 21:59:58 +0000 Subject: [PATCH 06/12] refactor: rename variable for clarity --- site/src/pages/WorkspacesPage/WorkspacesTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 5b7ae477a15ef..b28bcc31505ee 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -256,8 +256,8 @@ const WorkspacesRow: FC<{ const clickableProps = useClickableTableRow({ onAuxClick: (event) => { - const userClickedMiddleButton = event.button === 1; - if (userClickedMiddleButton) { + const userClickedMiddleMouseButton = event.button === 1; + if (userClickedMiddleMouseButton) { openLinkInNewTab(); } }, From 486313ebf1a83b19f14a79bec5c3bb078114ac7f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 22 Sep 2023 22:03:54 +0000 Subject: [PATCH 07/12] docs: add comment for clarity --- site/src/pages/WorkspacesPage/WorkspacesTable.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index b28bcc31505ee..d02fa01b49c54 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -256,12 +256,13 @@ const WorkspacesRow: FC<{ const clickableProps = useClickableTableRow({ onAuxClick: (event) => { - const userClickedMiddleMouseButton = event.button === 1; - if (userClickedMiddleMouseButton) { + const isMiddleMouseButton = event.button === 1; + if (isMiddleMouseButton) { openLinkInNewTab(); } }, onClick: (event) => { + // Order of booleans actually matters here for Windows-Mac compatibility const shouldOpenInNewTab = event.ctrlKey || event.shiftKey || event.metaKey; From 04c1b5dfa10c1e5526b53b9b4d1f9b171653d81b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sat, 23 Sep 2023 15:22:51 +0000 Subject: [PATCH 08/12] chore: add more click logic and comments --- site/src/hooks/useClickable.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/site/src/hooks/useClickable.ts b/site/src/hooks/useClickable.ts index ddb22636b8c79..713e9633c901d 100644 --- a/site/src/hooks/useClickable.ts +++ b/site/src/hooks/useClickable.ts @@ -1,6 +1,6 @@ import { + type KeyboardEventHandler, type MouseEventHandler, - type KeyboardEvent, type RefObject, useRef, } from "react"; @@ -17,14 +17,21 @@ export interface UseClickableResult< tabIndex: 0; role: "button"; onClick: MouseEventHandler; - onKeyDown: (event: KeyboardEvent) => void; + onKeyDown: KeyboardEventHandler; } +/** + * 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 to make it more obvious that the hook expects // a type argument in order to work at all 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); @@ -34,12 +41,16 @@ export const useClickable = < tabIndex: 0, role: "button", onClick, - onKeyDown: (event: KeyboardEvent) => { - if (event.key === "Enter") { + + // Most interactive elements already have this logic baked in automatically, + // but you explicitly have to add it for non-interactive elements + onKeyDown: (event) => { + if (event.key === "Enter" || event.key === "Space") { // Can't call onClick directly because onClick needs to work with an // event, and mouse events + keyboard events aren't compatible; wouldn't // have a value to pass in. Have to use a ref to simulate a click ref.current?.click(); + event.stopPropagation(); } }, }; From c86b6e8855c7b823212951e17032ede2a1e431a4 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 24 Sep 2023 03:08:03 +0000 Subject: [PATCH 09/12] refactor: clean up useClickableTableRow --- site/src/hooks/useClickableTableRow.ts | 30 +++++++++++++++---- .../pages/WorkspacesPage/WorkspacesTable.tsx | 13 ++++---- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/site/src/hooks/useClickableTableRow.ts b/site/src/hooks/useClickableTableRow.ts index 7582d998bbc24..673cf78028f31 100644 --- a/site/src/hooks/useClickableTableRow.ts +++ b/site/src/hooks/useClickableTableRow.ts @@ -1,3 +1,4 @@ +import { type MouseEventHandler } from "react"; import { type TableRowProps } from "@mui/material/TableRow"; import { makeStyles } from "@mui/styles"; import { useClickable, type UseClickableResult } from "./useClickable"; @@ -6,26 +7,43 @@ type UseClickableTableRowResult = UseClickableResult & TableRowProps & { className: string; hover: true; + onAuxClick: MouseEventHandler; }; -type TableRowOnClickProps = { - [Key in keyof UseClickableTableRowResult as Key extends `on${string}Click` +// 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, - ...optionalOnClickProps -}: TableRowOnClickProps): UseClickableTableRowResult => { + onAuxClick: externalOnAuxClick, + onDoubleClick, + onMiddleClick, +}: UseClickableTableRowConfig): UseClickableTableRowResult => { const styles = useStyles(); - const clickableProps = useClickable(onClick); + const clickableProps = useClickable(onClick); return { ...clickableProps, - ...optionalOnClickProps, 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/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index d02fa01b49c54..c23c923a1803f 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -255,16 +255,13 @@ const WorkspacesRow: FC<{ const openLinkInNewTab = () => window.open(workspacePageLink, "_blank"); const clickableProps = useClickableTableRow({ - onAuxClick: (event) => { - const isMiddleMouseButton = event.button === 1; - if (isMiddleMouseButton) { - openLinkInNewTab(); - } - }, + onMiddleClick: openLinkInNewTab, onClick: (event) => { - // Order of booleans actually matters here for Windows-Mac compatibility + // 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.ctrlKey || event.shiftKey || event.metaKey; + event.shiftKey || event.metaKey || event.ctrlKey; if (shouldOpenInNewTab) { openLinkInNewTab(); From 8ce3b5c0a1418e93e2c2f3812abf0f5172eb7a4b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sun, 24 Sep 2023 03:18:52 +0000 Subject: [PATCH 10/12] docs: rewrite comments for clarity --- site/src/hooks/useClickable.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/site/src/hooks/useClickable.ts b/site/src/hooks/useClickable.ts index 713e9633c901d..a04dfffba5ab8 100644 --- a/site/src/hooks/useClickable.ts +++ b/site/src/hooks/useClickable.ts @@ -25,8 +25,8 @@ export interface UseClickableResult< * don't traditionally have support for them. */ export const useClickable = < - // T doesn't have a default type to make it more obvious that the hook expects - // a type argument in order to work at all + // 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 @@ -42,13 +42,12 @@ export const useClickable = < role: "button", onClick, - // Most interactive elements already have this logic baked in automatically, - // but you explicitly have to add it for non-interactive elements + // 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 === "Space") { - // Can't call onClick directly because onClick needs to work with an - // event, and mouse events + keyboard events aren't compatible; wouldn't - // have a value to pass in. Have to use a ref to simulate a click + // 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(); } From c7e160392637f38a4cf1189a7d5b2e5bf0a91103 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 25 Sep 2023 13:41:59 +0000 Subject: [PATCH 11/12] fix: update TimelineEntry to accept forwarded ref --- .../src/components/Timeline/TimelineEntry.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) 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: { From ae689954edf2f7178845baadff6103e77d3f3b26 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 25 Sep 2023 13:42:26 +0000 Subject: [PATCH 12/12] fix: fix keyboard event logic to respond to spaces properly --- site/src/hooks/useClickable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/hooks/useClickable.ts b/site/src/hooks/useClickable.ts index a04dfffba5ab8..f9d065cabe94b 100644 --- a/site/src/hooks/useClickable.ts +++ b/site/src/hooks/useClickable.ts @@ -45,7 +45,7 @@ export const useClickable = < // 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 === "Space") { + 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();