feat(FN-3343): add Escape key keyboard handling to modals
This merge delivers v0.17.2 and ships FN-3343 keyboard handling across four dashboard modals (NewTaskModal, SubtaskBreakdownModal, MilestoneSliceInterviewModal, SettingsModal) with mobile CSS overrides, plus keyboard style regression tests for each modal and a minor test alignment for the onboarding Fusion-Task-Id: FN-3343
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef, type CSSProperties } from "react";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface MilestoneSliceInterviewModalProps {
|
||||
@@ -74,7 +76,18 @@ export function MilestoneSliceInterviewModal({
|
||||
projectId,
|
||||
resumeSessionId,
|
||||
}: MilestoneSliceInterviewModalProps) {
|
||||
const viewportMode = useViewportMode();
|
||||
useMobileScrollLock(isOpen);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
enabled: viewportMode === "mobile",
|
||||
});
|
||||
const keyboardStyle: CSSProperties = keyboardOpen
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
|
||||
@@ -451,7 +464,7 @@ export function MilestoneSliceInterviewModal({
|
||||
aria-modal="true"
|
||||
data-testid="milestone-slice-interview-modal"
|
||||
>
|
||||
<div className="modal modal-lg planning-modal">
|
||||
<div className="modal modal-lg planning-modal" style={keyboardStyle}>
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<Sparkles size={20} className="icon-triage" />
|
||||
|
||||
@@ -345,6 +345,13 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal.new-task-modal[style*="--keyboard-overlap"] {
|
||||
height: var(--vv-height, 100dvh);
|
||||
max-height: var(--vv-height, 100dvh);
|
||||
transform: translateY(var(--vv-offset-top, 0px));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.new-task-modal .modal-body {
|
||||
max-height: unset;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -10,8 +10,10 @@ import { useSetupReadiness } from "../hooks/useSetupReadiness";
|
||||
import { SetupWarningBanner } from "./SetupWarningBanner";
|
||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
|
||||
interface NewTaskModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -26,7 +28,18 @@ interface NewTaskModalProps {
|
||||
|
||||
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
||||
const { confirm } = useConfirm();
|
||||
const viewportMode = useViewportMode();
|
||||
useMobileScrollLock(isOpen);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
enabled: viewportMode === "mobile",
|
||||
});
|
||||
const keyboardStyle: React.CSSProperties = keyboardOpen
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as React.CSSProperties)
|
||||
: {};
|
||||
const [description, setDescription] = useState("");
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
@@ -418,6 +431,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
<div
|
||||
className="modal modal-lg new-task-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={keyboardStyle}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>New Task</h3>
|
||||
|
||||
@@ -109,6 +109,13 @@
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.modal.settings-modal[style*="--keyboard-overlap"] {
|
||||
height: var(--vv-height, 100dvh);
|
||||
max-height: var(--vv-height, 100dvh);
|
||||
transform: translateY(var(--vv-offset-top, 0px));
|
||||
will-change: transform;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Settings Layout === */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type CSSProperties, type MouseEvent } from "react";
|
||||
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
THINKING_LEVELS,
|
||||
@@ -40,8 +40,10 @@ import { CustomProvidersSection } from "./CustomProvidersSection";
|
||||
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
||||
|
||||
@@ -353,7 +355,18 @@ export function SettingsModal({
|
||||
onReopenOnboarding,
|
||||
}: SettingsModalProps) {
|
||||
const { confirm } = useConfirm();
|
||||
const viewportMode = useViewportMode();
|
||||
useMobileScrollLock(true);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
enabled: viewportMode === "mobile",
|
||||
});
|
||||
const keyboardStyle: CSSProperties = keyboardOpen
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const settingsContentRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:settings-modal-size");
|
||||
@@ -5302,7 +5315,7 @@ export function SettingsModal({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg settings-modal" ref={modalRef}>
|
||||
<div className="modal modal-lg settings-modal" ref={modalRef} style={keyboardStyle}>
|
||||
<div className="modal-header">
|
||||
<div className="settings-modal-heading">
|
||||
<h3>Settings</h3>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -22,7 +22,9 @@ import { ConversationHistory } from "./ConversationHistory";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface SubtaskBreakdownModalProps {
|
||||
@@ -73,7 +75,18 @@ function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
|
||||
}
|
||||
|
||||
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId }: SubtaskBreakdownModalProps) {
|
||||
const viewportMode = useViewportMode();
|
||||
useMobileScrollLock(isOpen);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
enabled: viewportMode === "mobile",
|
||||
});
|
||||
const keyboardStyle: CSSProperties = keyboardOpen
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
|
||||
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
|
||||
@@ -571,7 +584,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && void handleClose()} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg planning-modal">
|
||||
<div className="modal modal-lg planning-modal" style={keyboardStyle}>
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<ListTree size={20} className="icon-triage" />
|
||||
|
||||
@@ -59,6 +59,14 @@ vi.mock("../../utils/getSessionTabId", () => ({
|
||||
getSessionTabId: vi.fn(() => "test-tab-id"),
|
||||
}));
|
||||
|
||||
const mockUseMobileKeyboard = vi.fn();
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
useViewportMode: () => "mobile",
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <span data-testid="x-icon">X</span>,
|
||||
Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className}>Loader</span>,
|
||||
@@ -102,6 +110,12 @@ describe("MilestoneSliceInterviewModal", () => {
|
||||
mockFetchAiSession.mockReset();
|
||||
mockParseConversationHistory.mockReset();
|
||||
mockParseConversationHistory.mockReturnValue([]);
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: false,
|
||||
keyboardOverlap: 0,
|
||||
viewportHeight: null,
|
||||
viewportOffsetTop: 0,
|
||||
});
|
||||
|
||||
// Setup stream handlers capture
|
||||
mockConnectMilestoneInterviewStream.mockImplementation((sessionId, projectId, handlers) => {
|
||||
@@ -235,7 +249,32 @@ describe("MilestoneSliceInterviewModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state after clicking Start Interview", async () => {
|
||||
it("applies keyboard CSS variables to planning modal when keyboard is open", () => {
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: true,
|
||||
keyboardOverlap: 250,
|
||||
viewportHeight: 400,
|
||||
viewportOffsetTop: 50,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MilestoneSliceInterviewModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onApplied={vi.fn()}
|
||||
targetType="milestone"
|
||||
targetId="milestone-1"
|
||||
targetTitle="Milestone 1"
|
||||
/>,
|
||||
);
|
||||
const modal = container.querySelector(".planning-modal");
|
||||
|
||||
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
|
||||
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
|
||||
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
|
||||
});
|
||||
|
||||
it("shows loading state after clicking Start Interview", async () => {
|
||||
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
|
||||
mockConnectMilestoneInterviewStream.mockReturnValue({
|
||||
close: vi.fn(),
|
||||
|
||||
@@ -41,6 +41,15 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: mockConfirm }),
|
||||
}));
|
||||
|
||||
const mockUseMobileKeyboard = vi.fn();
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
useViewportMode: () => "mobile",
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
return {
|
||||
id,
|
||||
@@ -77,6 +86,37 @@ describe("NewTaskModal", () => {
|
||||
vi.clearAllMocks();
|
||||
mockConfirm.mockReset();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: false,
|
||||
keyboardOverlap: 0,
|
||||
viewportHeight: null,
|
||||
viewportOffsetTop: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies keyboard CSS variables when mobile keyboard is open", () => {
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: true,
|
||||
keyboardOverlap: 250,
|
||||
viewportHeight: 400,
|
||||
viewportOffsetTop: 50,
|
||||
});
|
||||
|
||||
const { container } = renderNewTaskModal();
|
||||
const modal = container.querySelector(".new-task-modal");
|
||||
|
||||
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
|
||||
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
|
||||
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
|
||||
expect(modal?.getAttribute("style")).toContain("--vv-offset-top: 50px");
|
||||
});
|
||||
|
||||
it("does not apply keyboard CSS variables when keyboard is closed", () => {
|
||||
const { container } = renderNewTaskModal();
|
||||
const modal = container.querySelector(".new-task-modal");
|
||||
|
||||
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
|
||||
expect(modal?.getAttribute("style") ?? "").not.toContain("--keyboard-overlap");
|
||||
});
|
||||
|
||||
it("renders all form fields when open", async () => {
|
||||
|
||||
@@ -107,6 +107,14 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
|
||||
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
|
||||
}));
|
||||
|
||||
const mockUseMobileKeyboard = vi.fn();
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
useViewportMode: () => "mobile",
|
||||
}));
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("lucide-react")>();
|
||||
return {
|
||||
@@ -191,6 +199,23 @@ const MODEL_FIXTURE = [
|
||||
];
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
it("applies keyboard CSS variables when mobile keyboard is open", async () => {
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: true,
|
||||
keyboardOverlap: 250,
|
||||
viewportHeight: 400,
|
||||
viewportOffsetTop: 50,
|
||||
});
|
||||
|
||||
const { container } = renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
const modal = container.querySelector(".settings-modal");
|
||||
|
||||
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
|
||||
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
|
||||
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
|
||||
});
|
||||
|
||||
it("renders section headings with the shared settings-section-heading class", async () => {
|
||||
const { container } = renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
@@ -207,6 +232,12 @@ describe("SettingsModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: false,
|
||||
keyboardOverlap: 0,
|
||||
viewportHeight: null,
|
||||
viewportOffsetTop: 0,
|
||||
});
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
|
||||
@@ -38,6 +38,15 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: mockConfirm }),
|
||||
}));
|
||||
|
||||
const mockUseMobileKeyboard = vi.fn();
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
useViewportMode: () => "mobile",
|
||||
}));
|
||||
|
||||
const SAMPLE_SUBTASKS = [
|
||||
{ id: "subtask-1", title: "First", description: "Do first", suggestedSize: "S" as const, dependsOn: [] },
|
||||
{ id: "subtask-2", title: "Second", description: "Do second", suggestedSize: "M" as const, dependsOn: ["subtask-1"] },
|
||||
@@ -80,6 +89,12 @@ describe("SubtaskBreakdownModal", () => {
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockConfirm.mockReset();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: false,
|
||||
keyboardOverlap: 0,
|
||||
viewportHeight: null,
|
||||
viewportOffsetTop: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -97,6 +112,23 @@ describe("SubtaskBreakdownModal", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("applies keyboard CSS variables to planning modal when keyboard is open", async () => {
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: true,
|
||||
keyboardOverlap: 250,
|
||||
viewportHeight: 400,
|
||||
viewportOffsetTop: 50,
|
||||
});
|
||||
|
||||
const { container } = renderModal();
|
||||
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalled());
|
||||
const modal = container.querySelector(".planning-modal");
|
||||
|
||||
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
|
||||
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
|
||||
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
|
||||
});
|
||||
|
||||
it("shows generating state after auto-start", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalledWith("Build a complex feature", undefined));
|
||||
|
||||
Reference in New Issue
Block a user