feat(FN-766): add three-tier responsive header with tablet compact mode
- Add tablet compact-header breakpoint (769px–1024px) with condensed layout - Implement responsive Header component with mobile/tablet/desktop modes - Add tablet-header-controls test suite (373 lines) for compact mode behavior - Update existing Header and mobile-header-controls tests for new responsive tiers - Refactor server routes and styles.css for three-tier responsive support - Document responsive header architecture in README
This commit is contained in:
@@ -52,12 +52,12 @@ AI-guided interactive planning for creating well-specified tasks from high-level
|
||||
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks
|
||||
|
||||
### Responsive Header
|
||||
The dashboard header adapts to small screens to remain usable without wrapping or dropping controls:
|
||||
The dashboard header adapts across three responsive tiers to remain usable without wrapping or dropping controls:
|
||||
|
||||
- **Mobile Overflow Menu**: On screens narrower than 768px, lower-priority actions (GitHub Import, Planning, Settings, and optionally Usage) move into an accessible overflow menu triggered by a "More actions" button. The menu closes on outside click, Escape key, or after selecting an action.
|
||||
- **Collapsible Board Search**: On mobile board view, the search input collapses to an icon button. Tapping the icon expands a focused search field. If a search query is already active, the search stays expanded until cleared or explicitly closed so active filters remain visible.
|
||||
- **Always-Visible Controls**: View toggle (Board/List), Terminal, Pause, and Stop buttons remain inline on mobile for immediate access.
|
||||
- **Keyboard Accessible**: All mobile controls expose proper ARIA attributes (aria-expanded, aria-haspopup, aria-label) and support keyboard navigation.
|
||||
- **Mobile (≤768px)**: Lower-priority actions (GitHub Import, Planning, Settings, and optionally Usage) move into an accessible overflow menu triggered by a "More actions" button. The menu closes on outside click, Escape key, or after selecting an action. The board search input collapses to an icon button; tapping it expands a focused search field that stays visible while a query is active. The project selector and back button are hidden to save space. View toggle (Board/List), Terminal, Pause, and Stop buttons remain inline for immediate access.
|
||||
- **Tablet (769px–1024px)**: The header uses a compact layout that keeps the view toggle, search input, and both engine controls (Pause/Resume scheduling and Stop/Start AI engine) inline at all times. Lower-priority utility actions (GitHub Import, Planning, Settings, Usage) move into the overflow menu so the engine controls never disappear. The project selector and back button are hidden on tablet.
|
||||
- **Desktop (>1024px)**: Full header with all controls and the project selector inline. No overflow menu.
|
||||
- **Keyboard Accessible**: All controls across tiers expose proper ARIA attributes (aria-expanded, aria-haspopup, aria-label) and support keyboard navigation.
|
||||
|
||||
### Mobile Task Entry
|
||||
Task entry inputs (the quick entry box in the Triage column and the New Task modal's description field) are sized to prevent browser zoom-on-focus on iOS Safari. On mobile viewports (≤768px), these inputs use a minimum 16px font size, which keeps the viewport stable when users focus the fields.
|
||||
|
||||
@@ -9,10 +9,12 @@ import { resolve } from "path";
|
||||
* header selectors and rules for:
|
||||
* - Collapsed search trigger (.mobile-search-trigger)
|
||||
* - Expanded mobile search panel (.mobile-search-expanded)
|
||||
* - Overflow menu trigger (.mobile-overflow-trigger)
|
||||
* - Compact overflow trigger (.compact-overflow-trigger)
|
||||
* - Overflow menu popover (.mobile-overflow-menu, .mobile-overflow-item)
|
||||
*
|
||||
* The mobile styles are located within @media (max-width: 768px) blocks.
|
||||
* The overflow trigger and menu styles are defined at the top level (shared
|
||||
* by mobile and tablet viewports), while the mobile search styles remain
|
||||
* inside @media (max-width: 768px) blocks.
|
||||
*/
|
||||
|
||||
describe("mobile-header-controls.css", () => {
|
||||
@@ -57,17 +59,17 @@ describe("mobile-header-controls.css", () => {
|
||||
expect(mobileCss).toContain(".mobile-search-expanded");
|
||||
});
|
||||
|
||||
it("has mobile overflow trigger styles", () => {
|
||||
expect(mobileCss).toContain(".mobile-overflow-trigger");
|
||||
it("has compact overflow trigger styles at top level (shared mobile/tablet)", () => {
|
||||
expect(cssContent).toContain(".compact-overflow-trigger");
|
||||
});
|
||||
|
||||
it("has mobile overflow menu styles", () => {
|
||||
expect(mobileCss).toContain(".mobile-overflow-menu");
|
||||
expect(mobileCss).toContain(".mobile-overflow-item");
|
||||
it("has overflow menu styles at top level (shared mobile/tablet)", () => {
|
||||
expect(cssContent).toContain(".mobile-overflow-menu");
|
||||
expect(cssContent).toContain(".mobile-overflow-item");
|
||||
});
|
||||
|
||||
it("has mobile overflow menu item hover states", () => {
|
||||
expect(mobileCss).toMatch(/\.mobile-overflow-item:hover/);
|
||||
it("has overflow menu item hover states", () => {
|
||||
expect(cssContent).toMatch(/\.mobile-overflow-item:hover/);
|
||||
});
|
||||
|
||||
it("does not contain obsolete mobile header search wrap rules", () => {
|
||||
|
||||
373
packages/dashboard/app/__tests__/tablet-header-controls.test.tsx
Normal file
373
packages/dashboard/app/__tests__/tablet-header-controls.test.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { Header } from "../components/Header";
|
||||
|
||||
/**
|
||||
* Tablet header controls test suite.
|
||||
*
|
||||
* Verifies that the tablet viewport tier (769px–1024px) renders the
|
||||
* header with engine controls inline while moving lower-priority actions
|
||||
* into the overflow menu.
|
||||
*/
|
||||
|
||||
type ViewportTier = "mobile" | "tablet" | "desktop";
|
||||
|
||||
const mockMatchMedia = (tier: ViewportTier) => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
let matches = false;
|
||||
if (tier === "mobile" && query.includes("max-width: 768px")) {
|
||||
matches = true;
|
||||
} else if (tier === "tablet" && query.includes("769px") && query.includes("1024px")) {
|
||||
matches = true;
|
||||
}
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function renderTabletHeader(props = {}) {
|
||||
mockMatchMedia("tablet");
|
||||
return render(
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={noop}
|
||||
onToggleEnginePause={noop}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDesktopHeader(props = {}) {
|
||||
mockMatchMedia("desktop");
|
||||
return render(
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={noop}
|
||||
onToggleEnginePause={noop}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("tablet header controls", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Engine controls stay inline on tablet ──────────────────────
|
||||
|
||||
it("renders pause scheduling button inline on tablet", () => {
|
||||
renderTabletHeader();
|
||||
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders stop AI engine button inline on tablet", () => {
|
||||
renderTabletHeader();
|
||||
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders view toggle inline on tablet", () => {
|
||||
renderTabletHeader({ onChangeView: noop });
|
||||
expect(screen.getByTitle("Board view")).toBeDefined();
|
||||
expect(screen.getByTitle("List view")).toBeDefined();
|
||||
expect(screen.getByTitle("Agents view")).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Lower-priority actions move to overflow on tablet ──────────
|
||||
|
||||
it("does not render settings inline on tablet", () => {
|
||||
renderTabletHeader();
|
||||
// Settings should be in overflow, not inline
|
||||
expect(screen.queryByTitle("Settings")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render import from GitHub inline on tablet", () => {
|
||||
renderTabletHeader();
|
||||
expect(screen.queryByTitle("Import from GitHub")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render planning button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenPlanning: noop });
|
||||
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render terminal button inline on tablet", () => {
|
||||
renderTabletHeader({ onToggleTerminal: noop });
|
||||
expect(screen.queryByTitle("Open Terminal")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render schedules button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenSchedules: noop });
|
||||
expect(screen.queryByTitle("Scheduled tasks")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render usage button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenUsage: noop });
|
||||
expect(screen.queryByTitle("View usage")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render activity log button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenActivityLog: noop });
|
||||
expect(screen.queryByTitle("View Activity Log")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render files button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenFiles: noop });
|
||||
expect(screen.queryByTitle("Browse files")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render git manager button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenGitManager: noop });
|
||||
expect(screen.queryByTitle("Git Manager")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render workflow steps button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenWorkflowSteps: noop });
|
||||
expect(screen.queryByTitle("Workflow Steps")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render missions button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenMissions: noop });
|
||||
expect(screen.queryByTitle("Mission Manager")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Overflow menu on tablet ────────────────────────────────────
|
||||
|
||||
it("renders overflow menu trigger on tablet", () => {
|
||||
renderTabletHeader();
|
||||
expect(screen.getByTitle("More header actions")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains settings on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Settings")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains planning on tablet", () => {
|
||||
renderTabletHeader({ onOpenPlanning: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-planning-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains GitHub import on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Import from GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains terminal on tablet", () => {
|
||||
renderTabletHeader({ onToggleTerminal: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-terminal-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains scheduled tasks on tablet", () => {
|
||||
renderTabletHeader({ onOpenSchedules: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Scheduled Tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains usage on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenUsage: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-usage-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains activity log on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenActivityLog: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-activity-log-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains files on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenFiles: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-files-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains git manager on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenGitManager: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-git-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains workflow steps on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenWorkflowSteps: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-workflow-steps-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains missions on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenMissions: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-missions-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Overflow menu callbacks work on tablet ─────────────────────
|
||||
|
||||
it("calls onOpenSettings from overflow menu on tablet", () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
renderTabletHeader({ onOpenSettings });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
expect(onOpenSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onToggleTerminal from overflow menu on tablet", () => {
|
||||
const onToggleTerminal = vi.fn();
|
||||
renderTabletHeader({ onToggleTerminal });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-terminal-btn"));
|
||||
expect(onToggleTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenPlanning from overflow menu on tablet", () => {
|
||||
const onOpenPlanning = vi.fn();
|
||||
renderTabletHeader({ onOpenPlanning });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-planning-btn"));
|
||||
expect(onOpenPlanning).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenUsage from overflow menu on tablet", () => {
|
||||
const onOpenUsage = vi.fn();
|
||||
renderTabletHeader({ onOpenUsage });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-usage-btn"));
|
||||
expect(onOpenUsage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes overflow menu after selecting an action on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes overflow menu on outside click on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes overflow menu on Escape key on tablet", () => {
|
||||
renderTabletHeader();
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Search on tablet ───────────────────────────────────────────
|
||||
|
||||
it("renders desktop-style search input on tablet (not mobile search trigger)", () => {
|
||||
renderTabletHeader({ onSearchChange: noop, view: "board" });
|
||||
// Tablet uses the desktop-style inline search, not the mobile trigger
|
||||
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
|
||||
expect(screen.queryByTitle("Open search")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render search when view is not board on tablet", () => {
|
||||
renderTabletHeader({ onSearchChange: noop, view: "list" });
|
||||
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Project selector hidden on tablet ──────────────────────────
|
||||
|
||||
it("does not render project selector on tablet", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
|
||||
];
|
||||
renderTabletHeader({
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject: noop,
|
||||
onViewAllProjects: noop,
|
||||
});
|
||||
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render back-to-projects button on tablet", () => {
|
||||
renderTabletHeader({
|
||||
currentProject: { id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
onViewAllProjects: noop,
|
||||
});
|
||||
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows switch project in overflow menu when multiple projects on tablet", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
|
||||
];
|
||||
const onViewAllProjects = vi.fn();
|
||||
renderTabletHeader({
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onViewAllProjects,
|
||||
});
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
const btn = screen.getByTestId("overflow-project-selector-btn");
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn.textContent).toContain("Project One");
|
||||
});
|
||||
|
||||
// ── Desktop still shows everything inline ──────────────────────
|
||||
|
||||
describe("desktop regression (contrasted with tablet)", () => {
|
||||
it("renders settings inline on desktop", () => {
|
||||
renderDesktopHeader();
|
||||
expect(screen.getByTitle("Settings")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders import from GitHub inline on desktop", () => {
|
||||
renderDesktopHeader();
|
||||
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders terminal inline on desktop", () => {
|
||||
renderDesktopHeader({ onToggleTerminal: noop });
|
||||
expect(screen.getByTitle("Open Terminal")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render overflow menu trigger on desktop", () => {
|
||||
renderDesktopHeader();
|
||||
expect(screen.queryByTitle("More header actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders project selector on desktop with multiple projects", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
|
||||
];
|
||||
renderDesktopHeader({
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject: noop,
|
||||
onViewAllProjects: noop,
|
||||
});
|
||||
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,23 +4,34 @@ import { Header } from "./Header";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// Helper to mock mobile/desktop viewport
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
// Helper to mock mobile/tablet/desktop viewport
|
||||
type ViewportTier = "mobile" | "tablet" | "desktop";
|
||||
|
||||
function mockMatchMedia(tier: ViewportTier) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: matches && query.includes("max-width: 768px"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
let matches = false;
|
||||
if (tier === "mobile" && query.includes("max-width: 768px")) {
|
||||
matches = true;
|
||||
} else if (tier === "tablet" && query.includes("769px") && query.includes("1024px")) {
|
||||
matches = true;
|
||||
}
|
||||
// desktop: neither mobile nor tablet query matches
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function renderHeader(props = {}, isMobile = false) {
|
||||
mockMatchMedia(isMobile);
|
||||
function renderHeader(props = {}, tier: ViewportTier = "desktop") {
|
||||
mockMatchMedia(tier);
|
||||
return render(
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
@@ -114,24 +125,24 @@ describe("Header", () => {
|
||||
|
||||
describe("terminal button", () => {
|
||||
it("renders terminal button with correct title on desktop", () => {
|
||||
renderHeader({ onToggleTerminal: noop }, false);
|
||||
renderHeader({ onToggleTerminal: noop }, "desktop");
|
||||
expect(screen.getByTitle("Open Terminal")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render terminal button inline on mobile", () => {
|
||||
renderHeader({ onToggleTerminal: noop }, true);
|
||||
renderHeader({ onToggleTerminal: noop }, "mobile");
|
||||
expect(screen.queryByTitle("Open Terminal")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onToggleTerminal when terminal button is clicked", () => {
|
||||
const onToggleTerminal = vi.fn();
|
||||
renderHeader({ onToggleTerminal }, false);
|
||||
renderHeader({ onToggleTerminal }, "desktop");
|
||||
fireEvent.click(screen.getByTitle("Open Terminal"));
|
||||
expect(onToggleTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is always enabled regardless of task state", () => {
|
||||
renderHeader({ onToggleTerminal: noop }, false);
|
||||
renderHeader({ onToggleTerminal: noop }, "desktop");
|
||||
const btn = screen.getByTitle("Open Terminal");
|
||||
expect(btn.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
@@ -139,36 +150,36 @@ describe("Header", () => {
|
||||
|
||||
describe("files button", () => {
|
||||
it("renders files button on desktop when handler is provided", () => {
|
||||
renderHeader({ onOpenFiles: vi.fn() }, false);
|
||||
renderHeader({ onOpenFiles: vi.fn() }, "desktop");
|
||||
expect(screen.getByTitle("Browse files")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render files button on desktop when handler is omitted", () => {
|
||||
renderHeader({}, false);
|
||||
renderHeader({}, "desktop");
|
||||
expect(screen.queryByTitle("Browse files")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenFiles when desktop files button is clicked", () => {
|
||||
const onOpenFiles = vi.fn();
|
||||
renderHeader({ onOpenFiles }, false);
|
||||
renderHeader({ onOpenFiles }, "desktop");
|
||||
fireEvent.click(screen.getByTitle("Browse files"));
|
||||
expect(onOpenFiles).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies active class when files modal is open", () => {
|
||||
renderHeader({ onOpenFiles: vi.fn(), filesOpen: true }, false);
|
||||
renderHeader({ onOpenFiles: vi.fn(), filesOpen: true }, "desktop");
|
||||
expect(screen.getByTitle("Browse files").className).toContain("btn-icon--active");
|
||||
});
|
||||
|
||||
it("shows files action in mobile overflow menu", () => {
|
||||
renderHeader({ onOpenFiles: vi.fn() }, true);
|
||||
renderHeader({ onOpenFiles: vi.fn() }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-files-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenFiles from mobile overflow menu", () => {
|
||||
const onOpenFiles = vi.fn();
|
||||
renderHeader({ onOpenFiles }, true);
|
||||
renderHeader({ onOpenFiles }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-files-btn"));
|
||||
expect(onOpenFiles).toHaveBeenCalled();
|
||||
@@ -213,42 +224,42 @@ describe("Header", () => {
|
||||
|
||||
describe("usage button", () => {
|
||||
it("does not render usage button when onOpenUsage is not provided", () => {
|
||||
renderHeader({}, false);
|
||||
renderHeader({}, "desktop");
|
||||
expect(screen.queryByTitle("View usage")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render usage button when onOpenUsage is not provided on mobile", () => {
|
||||
renderHeader({}, true);
|
||||
renderHeader({}, "mobile");
|
||||
expect(screen.queryByTitle("View usage")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders usage button with correct title when onOpenUsage is provided on desktop", () => {
|
||||
renderHeader({ onOpenUsage: vi.fn() }, false);
|
||||
renderHeader({ onOpenUsage: vi.fn() }, "desktop");
|
||||
expect(screen.getByTitle("View usage")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render usage button inline on mobile when onOpenUsage is provided", () => {
|
||||
renderHeader({ onOpenUsage: vi.fn() }, true);
|
||||
renderHeader({ onOpenUsage: vi.fn() }, "mobile");
|
||||
// Button should NOT be inline on mobile (it's in overflow menu)
|
||||
expect(screen.queryByTitle("View usage")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows usage in overflow menu on mobile", () => {
|
||||
renderHeader({ onOpenUsage: vi.fn() }, true);
|
||||
renderHeader({ onOpenUsage: vi.fn() }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-usage-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenUsage when usage button is clicked on desktop", () => {
|
||||
const onOpenUsage = vi.fn();
|
||||
renderHeader({ onOpenUsage }, false);
|
||||
renderHeader({ onOpenUsage }, "desktop");
|
||||
fireEvent.click(screen.getByTitle("View usage"));
|
||||
expect(onOpenUsage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenUsage when usage button in overflow menu is clicked", () => {
|
||||
const onOpenUsage = vi.fn();
|
||||
renderHeader({ onOpenUsage }, true);
|
||||
renderHeader({ onOpenUsage }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-usage-btn"));
|
||||
expect(onOpenUsage).toHaveBeenCalled();
|
||||
@@ -257,42 +268,42 @@ describe("Header", () => {
|
||||
|
||||
describe("activity log button", () => {
|
||||
it("does not render activity log button when onOpenActivityLog is not provided", () => {
|
||||
renderHeader({}, false);
|
||||
renderHeader({}, "desktop");
|
||||
expect(screen.queryByTitle("View Activity Log")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render activity log button when onOpenActivityLog is not provided on mobile", () => {
|
||||
renderHeader({}, true);
|
||||
renderHeader({}, "mobile");
|
||||
expect(screen.queryByTitle("View Activity Log")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders activity log button with correct title when onOpenActivityLog is provided on desktop", () => {
|
||||
renderHeader({ onOpenActivityLog: vi.fn() }, false);
|
||||
renderHeader({ onOpenActivityLog: vi.fn() }, "desktop");
|
||||
expect(screen.getByTitle("View Activity Log")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render activity log button inline on mobile when onOpenActivityLog is provided", () => {
|
||||
renderHeader({ onOpenActivityLog: vi.fn() }, true);
|
||||
renderHeader({ onOpenActivityLog: vi.fn() }, "mobile");
|
||||
// Button should NOT be inline on mobile (it's in overflow menu)
|
||||
expect(screen.queryByTitle("View Activity Log")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows activity log in overflow menu on mobile", () => {
|
||||
renderHeader({ onOpenActivityLog: vi.fn() }, true);
|
||||
renderHeader({ onOpenActivityLog: vi.fn() }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-activity-log-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenActivityLog when activity log button is clicked on desktop", () => {
|
||||
const onOpenActivityLog = vi.fn();
|
||||
renderHeader({ onOpenActivityLog }, false);
|
||||
renderHeader({ onOpenActivityLog }, "desktop");
|
||||
fireEvent.click(screen.getByTitle("View Activity Log"));
|
||||
expect(onOpenActivityLog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenActivityLog when activity log button in overflow menu is clicked", () => {
|
||||
const onOpenActivityLog = vi.fn();
|
||||
renderHeader({ onOpenActivityLog }, true);
|
||||
renderHeader({ onOpenActivityLog }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-activity-log-btn"));
|
||||
expect(onOpenActivityLog).toHaveBeenCalled();
|
||||
@@ -301,80 +312,80 @@ describe("Header", () => {
|
||||
|
||||
describe("planning button", () => {
|
||||
it("renders planning button with correct title on desktop", () => {
|
||||
renderHeader({ onOpenPlanning: vi.fn() }, false);
|
||||
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
|
||||
expect(screen.getByTitle("Create a task with AI planning")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render planning button inline on mobile", () => {
|
||||
renderHeader({ onOpenPlanning: vi.fn() }, true);
|
||||
renderHeader({ onOpenPlanning: vi.fn() }, "mobile");
|
||||
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenPlanning when planning button is clicked", () => {
|
||||
const onOpenPlanning = vi.fn();
|
||||
renderHeader({ onOpenPlanning }, false);
|
||||
renderHeader({ onOpenPlanning }, "desktop");
|
||||
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
|
||||
expect(onOpenPlanning).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has correct data-testid for testing on desktop", () => {
|
||||
renderHeader({ onOpenPlanning: vi.fn() }, false);
|
||||
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
|
||||
expect(screen.getByTestId("planning-btn")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile overflow menu", () => {
|
||||
it("renders overflow trigger on mobile", () => {
|
||||
renderHeader({}, true);
|
||||
renderHeader({}, "mobile");
|
||||
expect(screen.getByTitle("More header actions")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render overflow trigger on desktop", () => {
|
||||
renderHeader({}, false);
|
||||
renderHeader({}, "desktop");
|
||||
expect(screen.queryByTitle("More header actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows terminal in overflow menu on mobile", () => {
|
||||
renderHeader({ onToggleTerminal: noop }, true);
|
||||
renderHeader({ onToggleTerminal: noop }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-terminal-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows GitHub import in overflow menu on mobile", () => {
|
||||
renderHeader({}, true);
|
||||
renderHeader({}, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Import from GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows planning in overflow menu on mobile", () => {
|
||||
renderHeader({ onOpenPlanning: noop }, true);
|
||||
renderHeader({ onOpenPlanning: noop }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-planning-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows settings in overflow menu on mobile", () => {
|
||||
renderHeader({}, true);
|
||||
renderHeader({}, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Settings")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onToggleTerminal when overflow terminal button is clicked", () => {
|
||||
const onToggleTerminal = vi.fn();
|
||||
renderHeader({ onToggleTerminal }, true);
|
||||
renderHeader({ onToggleTerminal }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-terminal-btn"));
|
||||
expect(onToggleTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows scripts in overflow menu on mobile", () => {
|
||||
renderHeader({ onOpenScripts: noop }, true);
|
||||
renderHeader({ onOpenScripts: noop }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-scripts-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenScripts from mobile overflow menu", () => {
|
||||
const onOpenScripts = vi.fn();
|
||||
renderHeader({ onOpenScripts }, true);
|
||||
renderHeader({ onOpenScripts }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-scripts-btn"));
|
||||
expect(onOpenScripts).toHaveBeenCalled();
|
||||
@@ -431,36 +442,36 @@ describe("Header", () => {
|
||||
|
||||
describe("schedules button", () => {
|
||||
it("renders schedules button on desktop", () => {
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, false);
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, "desktop");
|
||||
expect(screen.getByTitle("Scheduled tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render schedules button inline on mobile", () => {
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, true);
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, "mobile");
|
||||
expect(screen.queryByTitle("Scheduled tasks")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenSchedules when schedules button is clicked", () => {
|
||||
const onOpenSchedules = vi.fn();
|
||||
renderHeader({ onOpenSchedules }, false);
|
||||
renderHeader({ onOpenSchedules }, "desktop");
|
||||
fireEvent.click(screen.getByTitle("Scheduled tasks"));
|
||||
expect(onOpenSchedules).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has correct data-testid for testing on desktop", () => {
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, false);
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, "desktop");
|
||||
expect(screen.getByTestId("schedules-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes scheduled tasks in overflow menu on mobile", () => {
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, true);
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Scheduled Tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenSchedules from mobile overflow menu", () => {
|
||||
const onOpenSchedules = vi.fn();
|
||||
renderHeader({ onOpenSchedules }, true);
|
||||
renderHeader({ onOpenSchedules }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-schedules-btn"));
|
||||
expect(onOpenSchedules).toHaveBeenCalled();
|
||||
@@ -495,12 +506,12 @@ describe("Header", () => {
|
||||
});
|
||||
|
||||
it("does not render header-back-button on mobile when no currentProject", () => {
|
||||
const { container } = renderHeader({}, true);
|
||||
const { container } = renderHeader({}, "mobile");
|
||||
expect(container.querySelector(".header-back-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("mobile overflow menu closes when clicking outside", () => {
|
||||
renderHeader({ onOpenFiles: vi.fn() }, true);
|
||||
renderHeader({ onOpenFiles: vi.fn() }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
|
||||
@@ -510,7 +521,7 @@ describe("Header", () => {
|
||||
});
|
||||
|
||||
it("mobile overflow menu closes on Escape key", () => {
|
||||
renderHeader({ onOpenFiles: vi.fn() }, true);
|
||||
renderHeader({ onOpenFiles: vi.fn() }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
|
||||
@@ -519,7 +530,7 @@ describe("Header", () => {
|
||||
});
|
||||
|
||||
it("mobile overflow trigger has correct accessibility attributes", () => {
|
||||
renderHeader({}, true);
|
||||
renderHeader({}, "mobile");
|
||||
const trigger = screen.getByTitle("More header actions");
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
@@ -529,7 +540,7 @@ describe("Header", () => {
|
||||
});
|
||||
|
||||
it("hides logo-sub on mobile via CSS", () => {
|
||||
renderHeader({}, true);
|
||||
renderHeader({}, "mobile");
|
||||
// The "tasks" element no longer exists - it was removed
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,21 +51,43 @@ export interface HeaderProps {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia("(max-width: 768px)").matches;
|
||||
});
|
||||
type ViewportMode = "mobile" | "tablet" | "desktop";
|
||||
|
||||
function getViewportMode(): ViewportMode {
|
||||
if (typeof window === "undefined") return "desktop";
|
||||
if (window.matchMedia("(max-width: 768px)").matches) return "mobile";
|
||||
if (window.matchMedia("(min-width: 769px) and (max-width: 1024px)").matches) return "tablet";
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
function useViewportMode(): ViewportMode {
|
||||
const [mode, setMode] = useState<ViewportMode>(getViewportMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mediaQuery = window.matchMedia("(max-width: 768px)");
|
||||
const handleChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
|
||||
const mobileQuery = window.matchMedia("(max-width: 768px)");
|
||||
const tabletQuery = window.matchMedia("(min-width: 769px) and (max-width: 1024px)");
|
||||
|
||||
const updateMode = () => {
|
||||
if (mobileQuery.matches) {
|
||||
setMode("mobile");
|
||||
} else if (tabletQuery.matches) {
|
||||
setMode("tablet");
|
||||
} else {
|
||||
setMode("desktop");
|
||||
}
|
||||
};
|
||||
|
||||
mobileQuery.addEventListener("change", updateMode);
|
||||
tabletQuery.addEventListener("change", updateMode);
|
||||
return () => {
|
||||
mobileQuery.removeEventListener("change", updateMode);
|
||||
tabletQuery.removeEventListener("change", updateMode);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
return mode;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
@@ -97,7 +119,10 @@ export function Header({
|
||||
onViewAllProjects,
|
||||
projectId,
|
||||
}: HeaderProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const mode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
const isTablet = mode === "tablet";
|
||||
const isCompact = isMobile || isTablet;
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
|
||||
const overflowButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -172,7 +197,7 @@ export function Header({
|
||||
<h1 className="logo">Fusion</h1>
|
||||
|
||||
{/* Project Selector - shown when 2+ projects on desktop only */}
|
||||
{!isMobile && projects.length > 1 && (
|
||||
{!isCompact && projects.length > 1 && (
|
||||
<div className="header-project-selector">
|
||||
<ProjectSelector
|
||||
projects={projects}
|
||||
@@ -186,7 +211,7 @@ export function Header({
|
||||
)}
|
||||
|
||||
{/* Back to All Projects button when viewing a specific project (desktop only) */}
|
||||
{!isMobile && currentProject && onViewAllProjects && (
|
||||
{!isCompact && currentProject && onViewAllProjects && (
|
||||
<button
|
||||
className="header-back-button"
|
||||
onClick={onViewAllProjects}
|
||||
@@ -295,28 +320,28 @@ export function Header({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage button - desktop only (moved to overflow on mobile) */}
|
||||
{!isMobile && onOpenUsage && (
|
||||
{/* Usage button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenUsage && (
|
||||
<button className="btn-icon" onClick={onOpenUsage} title="View usage">
|
||||
<Activity size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Activity Log button - desktop only (moved to overflow on mobile) */}
|
||||
{!isMobile && onOpenActivityLog && (
|
||||
{/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenActivityLog && (
|
||||
<button className="btn-icon" onClick={onOpenActivityLog} title="View Activity Log">
|
||||
<History size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Desktop actions */}
|
||||
{!isMobile && (
|
||||
{!isCompact && (
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||
<GitHubLogo size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
{!isCompact && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenPlanning}
|
||||
@@ -327,8 +352,8 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Schedules button - desktop only (moved to overflow on mobile) */}
|
||||
{!isMobile && (
|
||||
{/* Schedules button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenSchedules}
|
||||
@@ -339,8 +364,8 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Terminal button - desktop only (moved to overflow on mobile) */}
|
||||
{!isMobile && (
|
||||
{/* Terminal button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && (
|
||||
<button
|
||||
className="btn-icon btn-icon--terminal"
|
||||
onClick={onToggleTerminal}
|
||||
@@ -351,8 +376,8 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Files button - desktop only */}
|
||||
{!isMobile && onOpenFiles && (
|
||||
{/* Files button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenFiles && (
|
||||
<button
|
||||
className={`btn-icon${filesOpen ? " btn-icon--active" : ""}`}
|
||||
onClick={onOpenFiles}
|
||||
@@ -363,8 +388,8 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Git Manager button - desktop only */}
|
||||
{!isMobile && onOpenGitManager && (
|
||||
{/* Git Manager button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenGitManager && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenGitManager}
|
||||
@@ -375,8 +400,8 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Workflow Steps - desktop only */}
|
||||
{!isMobile && onOpenWorkflowSteps && (
|
||||
{/* Workflow Steps - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenWorkflowSteps && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenWorkflowSteps}
|
||||
@@ -387,8 +412,8 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Missions - desktop only */}
|
||||
{!isMobile && onOpenMissions && (
|
||||
{/* Missions - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenMissions && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenMissions}
|
||||
@@ -399,8 +424,8 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scripts - desktop only */}
|
||||
{!isMobile && onOpenScripts && onRunScript && (
|
||||
{/* Scripts - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenScripts && onRunScript && (
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={onOpenScripts}
|
||||
onRunScript={onRunScript}
|
||||
@@ -408,8 +433,8 @@ export function Header({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings - always inline on desktop */}
|
||||
{!isMobile && (
|
||||
{/* Settings - always inline on desktop only (overflow on mobile/tablet) */}
|
||||
{!isCompact && (
|
||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
@@ -434,11 +459,11 @@ export function Header({
|
||||
{globalPaused ? <Play size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
|
||||
{/* Mobile overflow menu trigger */}
|
||||
{isMobile && (
|
||||
{/* Compact overflow menu trigger (mobile + tablet) */}
|
||||
{isCompact && (
|
||||
<button
|
||||
ref={overflowButtonRef}
|
||||
className="btn-icon mobile-overflow-trigger"
|
||||
className="btn-icon compact-overflow-trigger"
|
||||
onClick={handleOverflowToggle}
|
||||
title="More header actions"
|
||||
aria-label="More header actions"
|
||||
@@ -449,8 +474,8 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile overflow menu */}
|
||||
{isMobile && isOverflowMenuOpen && (
|
||||
{/* Compact overflow menu (mobile + tablet) */}
|
||||
{isCompact && isOverflowMenuOpen && (
|
||||
<div
|
||||
ref={overflowMenuRef}
|
||||
className="mobile-overflow-menu"
|
||||
|
||||
@@ -2,24 +2,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { Header } from "../Header";
|
||||
|
||||
// Mock matchMedia for mobile/desktop viewport tests
|
||||
const mockMatchMedia = (matches: boolean) => {
|
||||
// Mock matchMedia for mobile/tablet/desktop viewport tests
|
||||
type ViewportTier = "mobile" | "tablet" | "desktop";
|
||||
|
||||
const mockMatchMedia = (tier: ViewportTier) => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
let matches = false;
|
||||
if (tier === "mobile" && query.includes("max-width: 768px")) {
|
||||
matches = true;
|
||||
} else if (tier === "tablet" && query.includes("769px") && query.includes("1024px")) {
|
||||
matches = true;
|
||||
}
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
describe("Header", () => {
|
||||
beforeEach(() => {
|
||||
// Default to desktop viewport
|
||||
mockMatchMedia(false);
|
||||
mockMatchMedia("desktop");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -330,7 +340,7 @@ describe("Header", () => {
|
||||
|
||||
describe("mobile viewport", () => {
|
||||
beforeEach(() => {
|
||||
mockMatchMedia(true); // Mobile viewport
|
||||
mockMatchMedia("mobile"); // Mobile viewport
|
||||
});
|
||||
|
||||
it("renders mobile search trigger instead of inline search on mobile", () => {
|
||||
|
||||
@@ -470,6 +470,55 @@ body {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* === Compact Overflow Menu (mobile + tablet) === */
|
||||
/* Styles for the overflow menu used on mobile and tablet viewports.
|
||||
Visibility is controlled by React rendering, not CSS display. */
|
||||
.compact-overflow-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobile-overflow-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 12px;
|
||||
min-width: 220px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
padding: var(--space-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mobile-overflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-overflow-item:hover {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.mobile-overflow-item svg {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
@@ -4634,52 +4683,6 @@ body {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.mobile-overflow-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobile-overflow-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 12px;
|
||||
min-width: 220px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
padding: var(--space-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mobile-overflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-overflow-item:hover {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.mobile-overflow-item svg {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Modal: full-screen on mobile */
|
||||
.modal-overlay {
|
||||
padding-top: 0;
|
||||
@@ -4808,6 +4811,29 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* === Tablet Responsive Tier (769px–1024px) === */
|
||||
/* On tablet-width screens the header uses a compact layout:
|
||||
view toggle and engine controls stay inline while lower-priority
|
||||
actions move into the overflow menu. */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
/* Reduce header padding slightly to reclaim horizontal space */
|
||||
.header {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
/* Hide project selector and back button on tablet (controlled by React rendering too) */
|
||||
.header-project-selector,
|
||||
.header-back-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Compact search: narrower than desktop */
|
||||
.header-search {
|
||||
max-width: 200px;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === GitHub Import Modal ===
|
||||
Note: Some hardcoded colors below (e.g. #238636, #2ea043, #f0883e, #1f6feb)
|
||||
are intentional GitHub brand colors and should NOT be replaced with design tokens. */
|
||||
|
||||
Reference in New Issue
Block a user