FN-8492: keep mobile task footer actions on one row
Keep task-detail footer actions compact and readable across mobile widths. - Prevent mobile footer controls from wrapping while preserving shrink behavior. - Add ellipsis-safe labels for actions and review controls. - Cover standard and in-review footer layouts with responsive tests. - Add a patch changeset for the mobile footer fix. Files changed: .../fn-8492-mobile-task-footer-single-row.md | 7 ++ .../dashboard/app/components/TaskDetailModal.css | 74 ++++++++++++++++- .../dashboard/app/components/TaskDetailModal.tsx | 8 +- ...etailModal.responsive-and-dependencies.test.tsx | 94 +++++++++++++++++++++- 4 files changed, 175 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-8492 Fusion-Task-Lineage: dae7a449-5022-4bfe-a879-880349341594 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8492-mobile-task-footer-single-row.md
Normal file
7
.changeset/fn-8492-mobile-task-footer-single-row.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep task detail footer actions on a single row on mobile.
|
||||
category: fix
|
||||
dev: Mobile TaskDetailModal `.modal-actions` nowrap + tokenized compression so Actions/Move/Merge fit without overflow (FN-8492).
|
||||
@@ -3394,16 +3394,82 @@ Live and Feed Activity expansion overlays the content instead of reserving a too
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
/*
|
||||
FNXC:TaskDetailModalResponsive 2026-07-22-00:00:
|
||||
FN-8492 requires the task-detail footer to keep Actions, Move, and the
|
||||
Merge/review control on one row from 320 CSS px through the 768px mobile
|
||||
breakpoint without horizontal overflow. `nowrap` alone only trades wrapping
|
||||
for overflow, so scoped tokenized packing, flex shrink guards, and label
|
||||
ellipsis let every text-bearing footer control yield while upward menus stay
|
||||
unclipped.
|
||||
*/
|
||||
.task-detail-content .modal-actions {
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-md) calc(var(--space-md) + var(--space-xs) / 2) calc(var(--space-md) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.modal-actions .btn {
|
||||
.task-detail-content .modal-actions-spacer {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-detail-content .detail-actions-dropdown,
|
||||
.task-detail-content .detail-move-dropdown {
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.task-detail-content .detail-move-dropdown {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.task-detail-content .detail-move-actions-in-review {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.task-detail-content .detail-move-actions-in-review > div,
|
||||
.task-detail-content .detail-move-actions-in-review > .btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-detail-content .modal-actions .btn {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding-inline: var(--space-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-detail-content .detail-move-btn {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.task-detail-content .detail-footer-button-label,
|
||||
.task-detail-content .detail-move-btn__label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-detail-content .detail-move-btn__arrow,
|
||||
.task-detail-content .modal-actions .btn > svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mobile dropdown menus — position above action bar, constrained to viewport */
|
||||
.detail-actions-menu,
|
||||
.detail-move-menu {
|
||||
|
||||
@@ -6334,7 +6334,9 @@ export function TaskDetailContent({
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showActionsMenu}
|
||||
>
|
||||
{t("taskDetail.actions.menuBtn", "Actions")}
|
||||
<span className="detail-footer-button-label">
|
||||
{t("taskDetail.actions.menuBtn", "Actions")}
|
||||
</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showActionsMenu && (
|
||||
@@ -6407,7 +6409,9 @@ export function TaskDetailContent({
|
||||
onClick={reviewAction.onSelect}
|
||||
disabled={reviewAction.disabled}
|
||||
>
|
||||
{reviewAction.label}
|
||||
<span className="detail-footer-button-label">
|
||||
{reviewAction.label}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -886,6 +886,96 @@ describe("TaskDetailModal", () => {
|
||||
expect((container.querySelector(".detail-tabs") as HTMLElement).style.borderBottom).toBe("");
|
||||
});
|
||||
|
||||
it("keeps every mobile task footer control shrinkable on one row from 320 CSS px through 768px", () => {
|
||||
const css = readDashboardStylesSource();
|
||||
const mobileBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".task-detail-content .modal-actions");
|
||||
const footerBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .modal-actions");
|
||||
const spacerBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .modal-actions-spacer");
|
||||
const inReviewBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .detail-move-actions-in-review");
|
||||
const buttonBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .modal-actions .btn");
|
||||
const labelBlock = getExactCssRuleBlock(
|
||||
mobileBlock,
|
||||
".task-detail-content .detail-footer-button-label,\n .task-detail-content .detail-move-btn__label",
|
||||
);
|
||||
const dropdownBlock = getExactCssRuleBlock(
|
||||
mobileBlock,
|
||||
".task-detail-content .detail-actions-dropdown,\n .task-detail-content .detail-move-dropdown",
|
||||
);
|
||||
const expandedChatBlock = getExactCssRuleBlock(css, ".task-detail-content--chat-expanded .modal-actions");
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailModalResponsive 2026-07-22-00:00:
|
||||
FN-8492's supported mobile fit contract is 320 CSS px through 768px. Keep
|
||||
these source assertions together: `nowrap` without shrink guards and
|
||||
text ellipsis would merely turn the original second row into overflow.
|
||||
*/
|
||||
expect(footerBlock).toContain("flex-wrap: nowrap;");
|
||||
expect(footerBlock).not.toContain("flex-wrap: wrap;");
|
||||
expect(footerBlock).toContain("align-items: center;");
|
||||
expect(footerBlock).toContain("gap: var(--space-xs);");
|
||||
expect(spacerBlock).toContain("flex: 1 1 0;");
|
||||
expect(spacerBlock).toContain("min-width: 0;");
|
||||
expect(dropdownBlock).toContain("min-width: 0;");
|
||||
expect(dropdownBlock).toContain("flex-shrink: 1;");
|
||||
expect(inReviewBlock).toContain("display: flex;");
|
||||
expect(inReviewBlock).toContain("flex-wrap: nowrap;");
|
||||
expect(inReviewBlock).toContain("min-width: 0;");
|
||||
expect(inReviewBlock).toContain("gap: var(--space-xs);");
|
||||
expect(buttonBlock).toContain("flex: 0 1 auto;");
|
||||
expect(buttonBlock).toContain("min-width: 0;");
|
||||
expect(buttonBlock).toContain("padding-inline: var(--space-xs);");
|
||||
expect(buttonBlock).toContain("white-space: nowrap;");
|
||||
expect(buttonBlock).toContain("overflow: hidden;");
|
||||
expect(buttonBlock).toContain("text-overflow: ellipsis;");
|
||||
expect(labelBlock).toContain("min-width: 0;");
|
||||
expect(labelBlock).toContain("white-space: nowrap;");
|
||||
expect(labelBlock).toContain("overflow: hidden;");
|
||||
expect(labelBlock).toContain("text-overflow: ellipsis;");
|
||||
expect(expandedChatBlock).toContain("display: none;");
|
||||
expect(css).toMatch(/\.task-detail-content--planner-chat-expanded \.modal-actions,[\s\S]*?\{\s*display:\s*none;/);
|
||||
});
|
||||
|
||||
it("keeps dense in-review and standard task controls in their shared footer", () => {
|
||||
const { container, unmount } = render(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
task={makeTask({ column: "in-review" as Column })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
const inReviewFooter = container.querySelector(".modal-actions");
|
||||
|
||||
expect(inReviewFooter).toBeTruthy();
|
||||
expect(inReviewFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true);
|
||||
expect(inReviewFooter?.querySelector(".detail-move-btn")).toBeTruthy();
|
||||
expect(inReviewFooter?.contains(screen.getByRole("button", { name: "Merge & Close" }))).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
const standard = render(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
task={makeTask({ column: "in-progress" as Column })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
const standardFooter = standard.container.querySelector(".modal-actions");
|
||||
|
||||
expect(standardFooter).toBeTruthy();
|
||||
expect(standardFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true);
|
||||
expect(standardFooter?.querySelector(".detail-move-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("modal-actions contains Delete and Pause buttons for non-done tasks (via Actions dropdown)", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
@@ -1678,7 +1768,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByText("Awaiting PR checks") as HTMLButtonElement;
|
||||
const button = screen.getByText("Awaiting PR checks").closest("button") as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(screen.queryByText("Merge & Close")).toBeNull();
|
||||
});
|
||||
@@ -1697,7 +1787,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByText("Creating PR…") as HTMLButtonElement;
|
||||
const button = screen.getByText("Creating PR…").closest("button") as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(screen.queryByText("Merge & Close")).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user