Repository: SwitchbackTech/compass
Pull request #343 (2025-04-09 17:37:04)

✨ Feat: EventForm better shortcuts and tooltips
## Description
Closes https://github.com/SwitchbackTech/compass/issues/183

This PR mainly implements the following
- Tooltips for the event form's delete button and move to sidebar button
- Shortcut for the event form's move to sidebar button (META + shift + <)

## Videos/Screenshots

#### Before
![image](https://github.com/user-attachments/assets/be7364fc-6a35-4446-8036-49c78c18fc18)
![image](https://github.com/user-attachments/assets/4f4fbdf5-3873-48c2-bf25-37e797b9c217)
![image](https://github.com/user-attachments/assets/58d2797d-4c31-463b-94df-107262981a5c)


#### After
![image](https://github.com/user-attachments/assets/82d3add1-8b38-4282-8c93-69c72628863b)
![image](https://github.com/user-attachments/assets/054572a3-fd80-4166-92d6-3ca22b393a36)
![image](https://github.com/user-attachments/assets/c086f928-3d21-415d-9203-4e47aec2976a)



Patch:
diff --git a/packages/web/src/common/utils/device.util.ts b/packages/web/src/common/utils/device.util.ts
new file mode 100644
index 00000000..e0413f70
--- /dev/null
+++ b/packages/web/src/common/utils/device.util.ts
@@ -0,0 +1,16 @@
+export enum DesktopOS {
+  Linux = "linux",
+  MacOS = "mac_os",
+  Windows = "windows",
+  Unknown = "unknown",
+}
+
+export const getDesktopOS = (): DesktopOS | undefined => {
+  const userAgent = window.navigator.userAgent;
+
+  if (userAgent.indexOf("Win") !== -1) return DesktopOS.Windows;
+  else if (userAgent.indexOf("Mac") !== -1) return DesktopOS.MacOS;
+  else if (userAgent.indexOf("Linux") !== -1) return DesktopOS.Linux;
+
+  return DesktopOS.Unknown;
+};
diff --git a/packages/web/src/components/Tooltip/Tooltip.tsx b/packages/web/src/components/Tooltip/Tooltip.tsx
index 876886cc..f94252e4 100644
--- a/packages/web/src/components/Tooltip/Tooltip.tsx
+++ b/packages/web/src/components/Tooltip/Tooltip.tsx
@@ -78,7 +78,7 @@ export const TooltipContent = forwardRef<
             position: context.strategy,
             top: context.y ?? 0,
             visibility: context.x == null ? "hidden" : "visible",
-            zIndex: ZIndex.LAYER_1,
+            zIndex: ZIndex.LAYER_3,
             ...props.style,
           }}
           {...context.getFloatingProps(props)}
diff --git a/packages/web/src/components/Tooltip/TooltipWrapper.tsx b/packages/web/src/components/Tooltip/TooltipWrapper.tsx
index 3594c634..4621fd86 100644
--- a/packages/web/src/components/Tooltip/TooltipWrapper.tsx
+++ b/packages/web/src/components/Tooltip/TooltipWrapper.tsx
@@ -14,7 +14,7 @@ export interface Props {
   children: ReactNode;
   description?: string;
   onClick: () => void;
-  shortcut?: string;
+  shortcut?: string | ReactNode;
 }
 
 export const TooltipWrapper: React.FC<Props> = ({
@@ -41,7 +41,11 @@ export const TooltipWrapper: React.FC<Props> = ({
           {description && <TooltipDescription description={description} />}
           {shortcut && (
             <StyledShortcutTip>
-              <Text size="m">{shortcut}</Text>
+              {typeof shortcut === "string" ? (
+                <Text size="m">{shortcut}</Text>
+              ) : (
+                shortcut
+              )}
             </StyledShortcutTip>
           )}
         </Flex>
diff --git a/packages/web/src/views/Calendar/components/Draft/grid/GridDraft.tsx b/packages/web/src/views/Calendar/components/Draft/grid/GridDraft.tsx
index 5044d282..cd9a904c 100644
--- a/packages/web/src/views/Calendar/components/Draft/grid/GridDraft.tsx
+++ b/packages/web/src/views/Calendar/components/Draft/grid/GridDraft.tsx
@@ -6,7 +6,7 @@ import { Schema_GridEvent } from "@web/common/types/web.event.types";
 import { useGridEventMouseDown } from "@web/views/Calendar/hooks/grid/useGridEventMouseDown";
 import { Measurements_Grid } from "@web/views/Calendar/hooks/grid/useGridLayout";
 import { WeekProps } from "@web/views/Calendar/hooks/useWeek";
-import { EventForm } from "@web/views/Forms/EventForm";
+import { EventForm } from "@web/views/Forms/EventForm/EventForm";
 import { StyledFloatContainer } from "@web/views/Forms/SomedayEventForm/styled";
 import { GridEvent } from "../../Event/Grid";
 import { useDraftContext } from "../context/useDraftContext";
diff --git a/packages/web/src/views/Forms/EventForm/DeleteButton.tsx b/packages/web/src/views/Forms/EventForm/DeleteButton.tsx
new file mode 100644
index 00000000..04cc37c2
--- /dev/null
+++ b/packages/web/src/views/Forms/EventForm/DeleteButton.tsx
@@ -0,0 +1,14 @@
+import React from "react";
+import { Trash } from "@phosphor-icons/react";
+import IconButton from "@web/components/IconButton/IconButton";
+import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper";
+
+export const DeleteButton = ({ onClick }: { onClick: () => void }) => {
+  return (
+    <TooltipWrapper onClick={onClick} shortcut="DEL" description="Delete Event">
+      <IconButton aria-label="Delete Event [DEL]" type="button">
+        <Trash />
+      </IconButton>
+    </TooltipWrapper>
+  );
+};
diff --git a/packages/web/src/views/Forms/EventForm/EventForm.tsx b/packages/web/src/views/Forms/EventForm/EventForm.tsx
index c1db64ae..7a9efb55 100644
--- a/packages/web/src/views/Forms/EventForm/EventForm.tsx
+++ b/packages/web/src/views/Forms/EventForm/EventForm.tsx
@@ -1,14 +1,14 @@
 import dayjs from "dayjs";
 import React, {
   KeyboardEvent,
-  KeyboardEventHandler,
   useCallback,
   useEffect,
   useRef,
   useState,
 } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
+import { OptionsOrDependencyArray } from "react-hotkeys-hook/dist/types";
 import { Key } from "ts-key-enum";
-import { Trash } from "@phosphor-icons/react";
 import { Priorities } from "@core/constants/core.constants";
 import { ID_EVENT_FORM } from "@web/common/constants/web.constants";
 import {
@@ -18,9 +18,9 @@ import {
 import { SelectOption } from "@web/common/types/component.types";
 import { getCategory } from "@web/common/utils/event.util";
 import { mapToBackend } from "@web/common/utils/web.date.util";
-import IconButton from "@web/components/IconButton/IconButton";
-import { StyledMigrateArrowInForm } from "@web/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled";
 import { DateControlsSection } from "@web/views/Forms/EventForm/DateControlsSection";
+import { DeleteButton } from "@web/views/Forms/EventForm/DeleteButton";
+import { MoveToSidebarButton } from "@web/views/Forms/EventForm/MoveToSidebarButton";
 import { getFormDates } from "./DateControlsSection/DateTimeSection/form.datetime.util";
 import { PrioritySection } from "./PrioritySection";
 import { SaveSection } from "./SaveSection";
@@ -32,6 +32,10 @@ import {
 } from "./styled";
 import { FormProps, SetEventFormField } from "./types";
 
+const hotkeysOptions: OptionsOrDependencyArray = {
+  enableOnFormTags: ["input"],
+};
+
 export const EventForm: React.FC<FormProps> = ({
   event,
   onClose: _onClose,
@@ -169,33 +173,6 @@ export const EventForm: React.FC<FormProps> = ({
     setEvent({ ...event, ...field });
   };
 
-  const onFormKeyDown: KeyboardEventHandler<HTMLFormElement> = (e) => {
-    if (e.key === Key.Backspace || e.key == Key.Delete) {
-      if (isDraft) {
-        onClose();
-        return;
-      }
-
-      const confirmed = window.confirm(
-        `Delete ${event.title || "this event"}?`,
-      );
-      if (confirmed) {
-        onDeleteForm();
-        return;
-      }
-    }
-
-    const shouldIgnore = isShiftKeyPressed || e.key !== Key.Enter;
-    if (shouldIgnore) {
-      return;
-    }
-
-    e.preventDefault();
-    e.stopPropagation();
-
-    onSubmitForm();
-  };
-
   const dateTimeSectionProps = {
     bgColor: priorityColor,
     displayEndDate,
@@ -226,12 +203,46 @@ export const EventForm: React.FC<FormProps> = ({
     endTime,
   };
 
+  useHotkeys(
+    "meta+shift+<",
+    () => {
+      onConvert?.();
+    },
+    hotkeysOptions,
+  );
+
+  useHotkeys(
+    "delete",
+    () => {
+      if (isDraft) {
+        onClose();
+        return;
+      }
+
+      const confirmed = window.confirm(
+        `Delete ${event.title || "this event"}?`,
+      );
+
+      if (confirmed) {
+        onDeleteForm();
+      }
+    },
+    hotkeysOptions,
+  );
+
+  useHotkeys(
+    "enter",
+    () => {
+      onSubmitForm();
+    },
+    hotkeysOptions,
+  );
+
   return (
     <StyledEventForm
       {...props}
       isOpen={isFormOpen}
       name={ID_EVENT_FORM}
-      onKeyDown={onFormKeyDown}
       onMouseUp={() => {
         if (isStartDatePickerOpen) {
           setIsStartDatePickerOpen(false);
@@ -249,24 +260,13 @@ export const EventForm: React.FC<FormProps> = ({
     >
       <StyledIconRow>
         {!isDraft && (
-          <StyledMigrateArrowInForm
-            onClick={(e) => {
-              e.stopPropagation();
+          <MoveToSidebarButton
+            onClick={() => {
               onConvert?.();
             }}
-            role="button"
-            title="Move to sidebar"
-          >
-            {"<"}
-          </StyledMigrateArrowInForm>
+          />
         )}
-        <IconButton
-          onClick={onDeleteForm}
-          aria-label="Delete Event"
-          type="button"
-        >
-          <Trash />
-        </IconButton>
+        <DeleteButton onClick={onDeleteForm} />
       </StyledIconRow>
 
       <StyledTitle
diff --git a/packages/web/src/views/Forms/EventForm/MoveToSidebarButton.tsx b/packages/web/src/views/Forms/EventForm/MoveToSidebarButton.tsx
new file mode 100644
index 00000000..71e3fed2
--- /dev/null
+++ b/packages/web/src/views/Forms/EventForm/MoveToSidebarButton.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import { Command, WindowsLogo } from "@phosphor-icons/react";
+import { DesktopOS, getDesktopOS } from "@web/common/utils/device.util";
+import { Text } from "@web/components/Text";
+import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper";
+import { StyledMigrateArrowInForm } from "@web/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled";
+
+export const MoveToSidebarButton = ({ onClick }: { onClick: () => void }) => {
+  const getShortcut = () => {
+    let isMacOS = false;
+
+    const desktopOS = getDesktopOS();
+    if (desktopOS === DesktopOS.MacOS) {
+      isMacOS = true;
+    }
+
+    return (
+      <Text size="m" style={{ display: "flex", alignItems: "center" }}>
+        {isMacOS ? <Command size={16} /> : <WindowsLogo size={16} />} + SHIFT +
+        {"<"}
+      </Text>
+    );
+  };
+
+  return (
+    <TooltipWrapper
+      onClick={onClick}
+      description="Move to sidebar"
+      shortcut={getShortcut()}
+    >
+      <StyledMigrateArrowInForm role="button">{"<"}</StyledMigrateArrowInForm>
+    </TooltipWrapper>
+  );
+};
diff --git a/packages/web/src/views/Forms/SomedayEventForm/SomedayEventForm.tsx b/packages/web/src/views/Forms/SomedayEventForm/SomedayEventForm.tsx
index 0d3d6deb..4416037a 100644
--- a/packages/web/src/views/Forms/SomedayEventForm/SomedayEventForm.tsx
+++ b/packages/web/src/views/Forms/SomedayEventForm/SomedayEventForm.tsx
@@ -2,12 +2,11 @@ import React, { KeyboardEvent, MouseEvent, useRef } from "react";
 import { toast } from "react-toastify";
 import "react-toastify/dist/ReactToastify.css";
 import { Key } from "ts-key-enum";
-import { Trash } from "@phosphor-icons/react";
 import { ID_SOMEDAY_EVENT_FORM } from "@web/common/constants/web.constants";
 import { colorByPriority } from "@web/common/styles/theme.util";
-import IconButton from "@web/components/IconButton/IconButton";
 import { getSomedayEventsSlice } from "@web/ducks/events/slices/someday.slice";
 import { useAppDispatch } from "@web/store/store.hooks";
+import { DeleteButton } from "@web/views/Forms/EventForm/DeleteButton";
 import { PrioritySection } from "@web/views/Forms/EventForm/PrioritySection";
 import { SaveSection } from "@web/views/Forms/EventForm/SaveSection";
 import {
@@ -122,13 +121,7 @@ export const SomedayEventForm: React.FC<FormProps> = ({
       role="form"
     >
       <StyledIconRow>
-        <IconButton
-          onClick={onDelete}
-          aria-label="Delete Someday Event"
-          type="button"
-        >
-          <Trash />
-        </IconButton>
+        <DeleteButton onClick={onDelete} />
       </StyledIconRow>
 
       <StyledTitle


Test patch:
diff --git a/packages/web/src/views/Calendar/Calendar.form.test.tsx b/packages/web/src/views/Calendar/Calendar.form.test.tsx
index 8a9914cf..a7dcd025 100644
--- a/packages/web/src/views/Calendar/Calendar.form.test.tsx
+++ b/packages/web/src/views/Calendar/Calendar.form.test.tsx
@@ -39,7 +39,7 @@ describe("Event Form", () => {
     await act(async () => {
       await user.click(
         within(form).getByRole("button", {
-          name: /delete someday event/i,
+          name: /delete event/i,
         }),
       );
     });
diff --git a/packages/web/src/views/Forms/EventForm/EventForm.test.tsx b/packages/web/src/views/Forms/EventForm/EventForm.test.tsx
index 87826552..b6b5f410 100644
--- a/packages/web/src/views/Forms/EventForm/EventForm.test.tsx
+++ b/packages/web/src/views/Forms/EventForm/EventForm.test.tsx
@@ -43,6 +43,48 @@ test("start date picker opens and then closes when clicking the end input", asyn
   expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
 });
 
+test("should call onConvert when meta+shift+< keyboard shortcut is used", async () => {
+  const sampleEvent: Schema_Event = {
+    _id: "event123",
+    title: "Test Event for Hotkey",
+    startDate: "2025-04-10",
+    endDate: "2025-04-10",
+    isAllDay: false,
+  };
+  const mockOnClose = jest.fn();
+  const mockOnConvert = jest.fn();
+  const mockOnSubmit = jest.fn();
+  const mockSetEvent = jest.fn();
+  const mockOnDelete = jest.fn();
+
+  render(
+    <div>
+      <EventForm
+        event={sampleEvent}
+        onClose={mockOnClose}
+        onConvert={mockOnConvert}
+        onSubmit={mockOnSubmit}
+        setEvent={mockSetEvent}
+        onDelete={mockOnDelete}
+      />
+    </div>,
+  );
+
+  // Ensure the form is rendered (optional, good sanity check)
+  expect(screen.getByRole("form")).toBeInTheDocument();
+
+  await act(async () => {
+    // Simulate pressing Meta, then Shift, then '<', then releasing them
+    await userEvent.keyboard("{Meta>}{Shift>}{<}{/Shift}{/Meta}");
+  });
+
+  expect(mockOnConvert).toHaveBeenCalledTimes(1);
+
+  expect(mockOnClose).not.toHaveBeenCalled();
+  expect(mockOnSubmit).not.toHaveBeenCalled();
+  expect(mockOnDelete).not.toHaveBeenCalled();
+});
+
 const _clickStartInput = async () => {
   const startDateInput = screen.getByRole("textbox", {
     name: /pick start date/i,