FN-6900: keep Git Manager tabs visible on mobile
Keep the Git Manager section tab strip usable in mobile modal layouts. - Prevent the mobile Git Manager sidebar from shrinking away and preserve horizontal tab scrolling. - Use token-sized tab touch targets and remove duplicate breakpoint rules that could override the canonical mobile layout. - Cover mobile tab rendering, section switching, and CSS breakpoint invariants in dashboard tests. Files changed: packages/dashboard/app/components/ScriptsModal.css | 53 ++------- .../components/__tests__/GitManagerModal.test.tsx | 118 +++++++++++++++++++-- .../__tests__/core-modals-mobile.test.tsx | 7 +- 3 files changed, 126 insertions(+), 52 deletions(-) Fusion-Task-Id: FN-6900 Fusion-Task-Lineage: e9506053-c80d-49ea-bb80-34db51dd50f4
This commit is contained in:
@@ -3755,14 +3755,20 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy
|
||||
FNXC:GitManager 2026-06-21-11:27:
|
||||
The mobile global `* { touch-action: pan-y; }` lock from FN-6365 blocks horizontal swipes unless the actual overflowing scroller opts back into pan-x.
|
||||
The Git Manager section toolbar must keep all fixed section tabs reachable on touch viewports, so mirror the FN-6450 tab-strip treatment and prevent tab compression (FN-6857).
|
||||
|
||||
FNXC:GitManager 2026-06-21-18:00:
|
||||
FN-6900 requires the Git Manager section tabs to remain visible and switchable on touch viewports. In the mobile column layout, the content pane is the flexible sibling, so the tab strip must be `flex: 0 0 auto` with a token-sized minimum height instead of shrinking to a zero-height row.
|
||||
*/
|
||||
.gm-sidebar {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
min-width: 0;
|
||||
min-height: calc(var(--space-2xl) + var(--space-md));
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
touch-action: pan-x pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
@@ -3777,8 +3783,9 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-left: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 10px;
|
||||
min-width: 56px;
|
||||
font-size: var(--font-size-xs);
|
||||
min-width: calc(var(--space-2xl) + var(--space-xl));
|
||||
min-height: calc(var(--space-xl) + var(--space-sm));
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -4206,49 +4213,11 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Git manager: align breakpoint behavior with global 768px mobile modal rules */
|
||||
.gm-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gm-sidebar {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
touch-action: pan-x pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.gm-nav-item {
|
||||
flex: 0 0 auto;
|
||||
border-left: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.gm-nav-item.active {
|
||||
border-left-color: transparent;
|
||||
border-bottom-color: var(--todo);
|
||||
}
|
||||
|
||||
/* Git manager: extend the canonical mobile rules above with safe-area and panel scroll behavior only. */
|
||||
.gm-content {
|
||||
min-height: 200px;
|
||||
padding: var(--space-md);
|
||||
padding-bottom: max(var(--space-md), env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.gm-status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.gm-panel {
|
||||
flex: none;
|
||||
min-height: auto;
|
||||
|
||||
@@ -120,6 +120,41 @@ function expectLatestCallStartsWith(mockFn: { mock: { calls: unknown[][] } }, ..
|
||||
expect(mockFn.mock.calls.at(-1)?.slice(0, expectedArgs.length)).toEqual(expectedArgs);
|
||||
}
|
||||
|
||||
function getMediaBlocks(css: string, pattern: RegExp): string[] {
|
||||
const matches = [...css.matchAll(pattern)];
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
|
||||
return matches.map((match) => {
|
||||
const start = match.index!;
|
||||
const open = css.indexOf("{", start);
|
||||
let depth = 1;
|
||||
let i = open + 1;
|
||||
while (i < css.length && depth > 0) {
|
||||
if (css[i] === "{") depth++;
|
||||
else if (css[i] === "}") depth--;
|
||||
i++;
|
||||
}
|
||||
return css.slice(start, i);
|
||||
});
|
||||
}
|
||||
|
||||
function getRuleBlocks(css: string, selector: string): string[] {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return [...css.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, "g"))]
|
||||
.map((match) => match[1]);
|
||||
}
|
||||
|
||||
const gitManagerSectionLabels = [
|
||||
"Status",
|
||||
"Changes",
|
||||
"Commits",
|
||||
"Branches",
|
||||
"Worktrees",
|
||||
"Stashes",
|
||||
"Recovery",
|
||||
"Remotes",
|
||||
];
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
@@ -305,14 +340,59 @@ describe("GitManagerModal", () => {
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /status/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /changes/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /commits/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /branches/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /worktrees/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /stashes/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /recovery/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /remotes/i })).toBeInTheDocument();
|
||||
for (const label of gitManagerSectionLabels) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null status and no file changes", null, []],
|
||||
["populated status and populated file changes", {
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: true,
|
||||
ahead: 1,
|
||||
behind: 0,
|
||||
}, [
|
||||
{ file: "src/app.ts", status: "modified", staged: false },
|
||||
{ file: "src/index.ts", status: "added", staged: true },
|
||||
]],
|
||||
])("renders the mobile tablist and all static section tabs with %s", async (_name, statusResult, fileChangeResult) => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
(fetchGitStatus as any).mockResolvedValue(statusResult);
|
||||
(fetchFileChanges as any).mockResolvedValue(fileChangeResult);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
|
||||
const tablist = await screen.findByRole("tablist", { name: /git manager sections/i });
|
||||
const tabs = within(tablist).getAllByRole("tab");
|
||||
expect(tabs).toHaveLength(gitManagerSectionLabels.length);
|
||||
for (const label of gitManagerSectionLabels) {
|
||||
expect(within(tablist).getByRole("tab", { name: label })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("switches sections from the tab strip on mobile", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
|
||||
const tablist = await screen.findByRole("tablist", { name: /git manager sections/i });
|
||||
const statusTab = within(tablist).getByRole("tab", { name: "Status" });
|
||||
const branchesTab = within(tablist).getByRole("tab", { name: "Branches" });
|
||||
expect(statusTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await userEvent.click(branchesTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(branchesTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(statusTab).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3321,5 +3401,27 @@ describe("GitManagerModal", () => {
|
||||
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.gm-file-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?flex-wrap:\s*wrap;/);
|
||||
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.gm-file-section\s*\{[\s\S]*?max-width:\s*100%;/);
|
||||
});
|
||||
|
||||
it("keeps the mobile Git Manager tab strip non-shrinking at 768px and 720px breakpoints", () => {
|
||||
const css = loadAllAppCss();
|
||||
const mobile768 = getMediaBlocks(css, /@media[^{]*\(max-width:\s*768px\)[^{]*\{/g).join("\n");
|
||||
const mobile720 = getMediaBlocks(css, /@media[^{]*\(max-width:\s*720px\)[^{]*\{/g).join("\n");
|
||||
|
||||
const sidebarRules = getRuleBlocks(mobile768, ".gm-sidebar");
|
||||
expect(sidebarRules).toHaveLength(1);
|
||||
expect(sidebarRules[0]).toContain("flex: 0 0 auto;");
|
||||
expect(sidebarRules[0]).toContain("min-height: calc(var(--space-2xl) + var(--space-md));");
|
||||
expect(sidebarRules[0]).toContain("overflow-x: auto;");
|
||||
expect(sidebarRules[0]).toContain("overflow-y: hidden;");
|
||||
expect(sidebarRules[0]).toContain("touch-action: pan-x pan-y;");
|
||||
|
||||
const navItemRules = getRuleBlocks(mobile768, ".gm-nav-item");
|
||||
expect(navItemRules).toHaveLength(1);
|
||||
expect(navItemRules[0]).toContain("flex: 0 0 auto;");
|
||||
expect(navItemRules[0]).toContain("min-height: calc(var(--space-xl) + var(--space-sm));");
|
||||
|
||||
expect(mobile720).not.toContain(".gm-sidebar");
|
||||
expect(mobile720).not.toContain(".gm-nav-item");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,7 +243,10 @@ describe("core modals mobile css coverage", () => {
|
||||
const sidebarRules = getRuleBlocks(mobileBlock, ".gm-sidebar");
|
||||
expect(sidebarRules.length).toBeGreaterThan(0);
|
||||
for (const sidebarRule of sidebarRules) {
|
||||
expect(sidebarRule).toContain("flex: 0 0 auto;");
|
||||
expect(sidebarRule).toContain("min-height: calc(var(--space-2xl) + var(--space-md));");
|
||||
expect(sidebarRule).toContain("overflow-x: auto;");
|
||||
expect(sidebarRule).toContain("overflow-y: hidden;");
|
||||
expect(sidebarRule).toContain("touch-action: pan-x pan-y;");
|
||||
expect(sidebarRule).toContain("-webkit-overflow-scrolling: touch;");
|
||||
}
|
||||
@@ -255,12 +258,12 @@ describe("core modals mobile css coverage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("GitManagerModal: nav items keep 36px touch target on mobile", () => {
|
||||
it("GitManagerModal: nav items keep a token-sized touch target on mobile", () => {
|
||||
const css = loadAllAppCss();
|
||||
const mobileBlock = getMainMobileBlock(css);
|
||||
|
||||
expect(mobileBlock).toContain(".gm-nav-item {");
|
||||
expect(mobileBlock).toContain("min-height: 36px;");
|
||||
expect(mobileBlock).toContain("min-height: calc(var(--space-xl) + var(--space-sm));");
|
||||
});
|
||||
|
||||
it("GitManagerModal: panel allows content scrolling on mobile", () => {
|
||||
|
||||
Reference in New Issue
Block a user