diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 5be82be0af9..6290d3f8a82 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -14,6 +14,7 @@ const dialogState = vi.hoisted(() => ({ })); const dialogContentState = vi.hoisted(() => ({ + onEscapeKeyDown: null as null | ((event: KeyboardEvent) => void), onPointerDownOutside: null as null | ((event: { detail: { originalEvent: { target: EventTarget | null } }; preventDefault: () => void; @@ -193,14 +194,15 @@ vi.mock("@/components/ui/dialog", () => ({ DialogContent: ({ children, showCloseButton: _showCloseButton, - onEscapeKeyDown: _onEscapeKeyDown, + onEscapeKeyDown, onPointerDownOutside, ...props }: ComponentProps<"div"> & { showCloseButton?: boolean; - onEscapeKeyDown?: (event: unknown) => void; + onEscapeKeyDown?: (event: KeyboardEvent) => void; onPointerDownOutside?: (event: unknown) => void; }) => { + dialogContentState.onEscapeKeyDown = onEscapeKeyDown ?? null; dialogContentState.onPointerDownOutside = onPointerDownOutside as typeof dialogContentState.onPointerDownOutside; return
{children}
; }, @@ -315,6 +317,7 @@ describe("NewIssueDialog", () => { dialogState.newIssueOpen = true; dialogState.newIssueDefaults = {}; dialogState.closeNewIssue.mockReset(); + dialogContentState.onEscapeKeyDown = null; dialogContentState.onPointerDownOutside = null; toastState.pushToast.mockReset(); mockIssuesApi.create.mockReset(); @@ -849,7 +852,7 @@ describe("NewIssueDialog", () => { await act(async () => { modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, - code: "Period", + code: "", key: ".", metaKey: true, })); @@ -879,6 +882,63 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("cycles work modes when iOS reports cmd-period as Escape", async () => { + const { root } = renderDialog(container); + await flush(); + + const modeChip = () => container.querySelector("[data-issue-work-mode-chip]"); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard"); + expect(dialogContentState.onEscapeKeyDown).not.toBeNull(); + + const commandPeriodAsEscape = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + metaKey: true, + }); + await act(async () => { + dialogContentState.onEscapeKeyDown?.(commandPeriodAsEscape); + }); + + expect(commandPeriodAsEscape.defaultPrevented).toBe(true); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + expect(dialogState.closeNewIssue).not.toHaveBeenCalled(); + + const plainEscape = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + }); + await act(async () => { + dialogContentState.onEscapeKeyDown?.(plainEscape); + }); + + expect(plainEscape.defaultPrevented).toBe(false); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + + act(() => root.unmount()); + }); + + it("cycles work modes with ctrl-period", async () => { + const { root } = renderDialog(container); + await flush(); + + const modeChip = () => container.querySelector("[data-issue-work-mode-chip]"); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard"); + + await act(async () => { + modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + code: "Period", + key: ".", + ctrlKey: true, + })); + }); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + + act(() => root.unmount()); + }); + it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => { dialogState.newIssueDefaults = { parentId: "issue-1", diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index bcbda2d457d..96f0d938576 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -268,6 +268,15 @@ function defaultExecutionWorkspaceModeForIssueDefaults( : defaultExecutionWorkspaceModeForProject(project); } +function isWorkModePeriodShortcut(e: Pick) { + const isPeriod = e.code === "Period" || e.key === "."; + return (e.metaKey || e.ctrlKey) && isPeriod; +} + +function isWorkModeEscapeShortcut(e: Pick) { + return (e.metaKey || e.ctrlKey) && e.key === "Escape"; +} + const IssueTitleTextarea = memo(function IssueTitleTextarea({ value, pending, @@ -1006,7 +1015,7 @@ export function NewIssueDialog() { } function handleKeyDown(e: React.KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.code === "Period") { + if (isWorkModePeriodShortcut(e)) { e.preventDefault(); setWorkMode((current) => nextWorkMode(current)); return; @@ -1236,6 +1245,15 @@ export function NewIssueDialog() { )} onKeyDown={handleKeyDown} onEscapeKeyDown={(event) => { + if (event.defaultPrevented) return; + // iOS Safari maps command-period to Escape for hardware keyboards. + // Treat modifier-Escape as the same mode-cycle shortcut so the + // dialog does not dismiss before the shortcut can run. + if (isWorkModeEscapeShortcut(event)) { + event.preventDefault(); + setWorkMode((current) => nextWorkMode(current)); + return; + } if (createIssue.isPending) { event.preventDefault(); }