FN-8786: make task titles directly expandable

Make overflowed task titles directly expandable with an accessible title control.

- Replace the standalone Show more/Show less row with a title-owned button.
- Add focus styling while preserving title clamping and summarization behavior.
- Cover title expansion, keyboard access, fallbacks, and responsive styling.

Files changed:
 .../dashboard/app/components/TaskDetailModal.css   |  35 ++---
 .../dashboard/app/components/TaskDetailModal.tsx   |  26 ++--
 .../__tests__/TaskDetailModal.rendering.test.tsx   | 150 +++++++++++----------
 3 files changed, 112 insertions(+), 99 deletions(-)

Fusion-Task-Id: FN-8786

Fusion-Task-Lineage: 351a9f33-42c0-4a4f-a34a-94aa726f2436

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-04 11:13:07 -07:00
parent 1d1a7fedab
commit 2af01dd27d
3 changed files with 112 additions and 99 deletions

View File

@@ -226,6 +226,24 @@ Summarize-as-title is an in-field affordance, not a separate full-width row: it
min-width: 0;
}
.detail-title-control {
display: block;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: inherit;
cursor: pointer;
}
.detail-title-control:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
border-radius: var(--radius-sm);
}
.detail-summarize-title-btn {
display: inline-flex;
align-items: center;
@@ -257,22 +275,6 @@ Summarize-as-title is an in-field affordance, not a separate full-width row: it
cursor: not-allowed;
}
.detail-description-toggle {
display: block;
background: none;
border: none;
color: var(--text-dim);
font-size: 13px;
padding: 2px 0;
margin-bottom: var(--space-md);
cursor: pointer;
text-align: left;
}
.detail-description-toggle:hover {
color: var(--text);
}
/*
FNXC:TaskDetailMeta 2026-06-13-17:32:
The task-detail modal metadata must keep priority, execution mode, provenance, PR context, and timestamps in one horizontal row that wraps as needed instead of stacking each child as a separate line.
@@ -1576,7 +1578,6 @@ Planner Chat should not open expanded by default. When the user expands it, the
min-height: 0;
}
.task-detail-content--planner-chat-expanded .detail-description-toggle,
.task-detail-content--planner-chat-expanded .detail-near-duplicate-banner,
.task-detail-content--planner-chat-expanded .detail-plan-approval-banner,
.task-detail-content--planner-chat-expanded .task-fields-section,

View File

@@ -4732,11 +4732,27 @@ export function TaskDetailContent({
Summarize-as-title renders inline with the title inside .detail-heading-row and is positioned (CSS) to the far bottom-right as an in-field affordance, not a separate full-width row. Markup order is preserved; only layout changed.
*/}
<div className="detail-heading-row">
{/*
FNXC:TaskDetailTitle 2026-08-04-18:00:
An overflowing task-detail title is its own sole expansion control. Keep the semantic button inside the h2 so pointer, touch, and keyboard activation share one accessible target; the separate Show more/Show less row must not return.
*/}
<h2
ref={titleRef}
className={`detail-title${descriptionExpanded ? "" : " detail-title--collapsed"}`}
>
{displayTitleText}
{titleOverflows || descriptionExpanded ? (
<button
type="button"
className="detail-title-control"
aria-expanded={descriptionExpanded}
aria-label={descriptionExpanded
? t("taskDetail.title.collapse", "Collapse task title")
: t("taskDetail.title.expand", "Expand task title")}
onClick={() => setDescriptionExpanded((expanded) => !expanded)}
>
{displayTitleText}
</button>
) : displayTitleText}
</h2>
{showSummarizeTitleButton && (
<button
@@ -4751,14 +4767,6 @@ export function TaskDetailContent({
</button>
)}
</div>
{(titleOverflows || descriptionExpanded) && (
<button
className="detail-description-toggle"
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
>
{descriptionExpanded ? t("taskDetail.description.showLess", "Show less") : t("taskDetail.description.showMore", "Show more")}
</button>
)}
</>
{customFieldDefs && customFieldDefs.length > 0 ? (
<TaskFieldsSection

View File

@@ -2094,6 +2094,12 @@ describe("TaskDetailModal", () => {
/>,
);
const expectNoStandaloneTitleToggle = () => {
expect(document.querySelector(".detail-description-toggle")).toBeNull();
expect(screen.queryByRole("button", { name: "Show more" })).toBeNull();
expect(screen.queryByRole("button", { name: "Show less" })).toBeNull();
};
beforeEach(() => {
setTitleLayout({ scrollHeight: 120, clientHeight: 40 });
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
@@ -2123,9 +2129,13 @@ describe("TaskDetailModal", () => {
}
});
it("collapses long triage title by default with Show more button and expands on demand", async () => {
it("toggles a long triage title directly without a standalone affordance", async () => {
/*
FNXC:TaskDetailTitle 2026-08-04-18:00:
Every TaskDetailModal lifecycle column and title fallback shares this definition header. An overflowed title alone owns expansion for pointer, touch, and keyboard users; short and fallback headings retain no empty control or legacy Show more/Show less shell, while Summarize remains separate.
*/
const longTitle = "Triage title ".repeat(25);
const { container } = renderDetail({
renderDetail({
column: "triage",
title: longTitle,
description: "Triage planning context",
@@ -2134,28 +2144,45 @@ describe("TaskDetailModal", () => {
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe(longTitle);
expect(h2).toHaveClass("detail-title--collapsed");
const toggle = await screen.findByRole("button", { name: "Show more" });
expect(toggle).toHaveClass("detail-description-toggle");
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
expect(titleControl).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
await userEvent.click(toggle);
await userEvent.click(titleControl);
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Show less" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Collapse task title" })).toHaveAttribute("aria-expanded", "true");
expectNoStandaloneTitleToggle();
await userEvent.click(screen.getByRole("button", { name: "Collapse task title" }));
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("collapses long triage description by default when title is missing", async () => {
it("supports keyboard activation through the title control", async () => {
renderDetail({ title: "Keyboard title ".repeat(25) });
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
titleControl.focus();
await userEvent.keyboard("{Enter}");
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Collapse task title" })).toHaveAttribute("aria-expanded", "true");
expectNoStandaloneTitleToggle();
});
it("collapses a long triage description fallback by default when title is missing", async () => {
const longDescription = "Triage description ".repeat(20);
const { container } = renderDetail({
column: "triage",
title: undefined,
description: longDescription,
});
renderDetail({ column: "triage", title: undefined, description: longDescription });
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe(longDescription);
expect(h2).toHaveClass("detail-title--collapsed");
expect(await screen.findByRole("button", { name: "Show more" })).toHaveClass("detail-description-toggle");
expect(await screen.findByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("uses the title, description, and id fallback chain for the clamped heading", async () => {
@@ -2178,39 +2205,40 @@ describe("TaskDetailModal", () => {
renderDetail({ title: "Title wins", description: "Description loses" });
expect(document.querySelector("h2.detail-title")?.textContent).toBe("Title wins");
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Expand task title" })).toBeInTheDocument();
expectNoStandaloneTitleToggle();
cleanup();
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
renderDetail({ title: undefined, description: "Description fallback" });
expect(document.querySelector("h2.detail-title")?.textContent).toBe("Description fallback");
expect(document.querySelector(".detail-description-toggle")).toBeNull();
expect(screen.queryByRole("button", { name: "Expand task title" })).toBeNull();
expectNoStandaloneTitleToggle();
cleanup();
renderDetail({ id: "FN-FALLBACK", title: undefined, description: undefined });
expect(document.querySelector("h2.detail-title")?.textContent).toBe("FN-FALLBACK");
expect(document.querySelector(".detail-description-toggle")).toBeNull();
expect(screen.queryByRole("button", { name: "Expand task title" })).toBeNull();
expectNoStandaloneTitleToggle();
});
it.each(["todo", "in-progress", "in-review", "done", "archived"] as const)(
"collapses overflowing non-triage %s title by default",
"collapses overflowing non-triage %s titles with a title-owned control",
async (column) => {
const longTitle = `${column} title `.repeat(25);
const { container } = renderDetail({
column,
title: longTitle,
});
renderDetail({ column, title: longTitle });
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe(longTitle);
expect(h2).toHaveClass("detail-title--collapsed");
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
},
);
it("does not render an empty toggle shell when the title fits within two lines", () => {
it("does not render an empty title control when the title fits within two lines", () => {
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
const { container } = renderDetail({
renderDetail({
title: "Short title",
description: "This is a longer description that is not shown as the heading while title is present",
});
@@ -2218,41 +2246,17 @@ describe("TaskDetailModal", () => {
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("Short title");
expect(h2).toHaveClass("detail-title--collapsed");
expect(document.querySelector(".detail-description-toggle")).toBeNull();
});
it("collapses again when Show less is clicked", async () => {
const longDescription = "C".repeat(250);
const { container } = renderDetail({
title: undefined,
description: longDescription,
});
const toggle = await screen.findByRole("button", { name: "Show more" });
await userEvent.click(toggle);
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longDescription);
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
await userEvent.click(screen.getByRole("button", { name: "Show less" }));
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe(longDescription);
expect(h2).toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Show more" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Expand task title" })).toBeNull();
expectNoStandaloneTitleToggle();
});
it("resets to collapsed when switching from a non-triage task to a triage task", async () => {
const todoDescription = "G".repeat(250);
const triageDescription = "H".repeat(250);
const { container, rerender } = render(
const { rerender } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
id: "FN-TODO",
column: "todo",
title: undefined,
description: todoDescription,
})}
task={makeTask({ id: "FN-TODO", column: "todo", title: undefined, description: todoDescription })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
@@ -2262,18 +2266,13 @@ describe("TaskDetailModal", () => {
/>,
);
await userEvent.click(await screen.findByRole("button", { name: "Show more" }));
await userEvent.click(await screen.findByRole("button", { name: "Expand task title" }));
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
rerender(
<TaskDetailModal
initialTab="definition"
task={makeTask({
id: "FN-TRIAGE",
column: "triage",
title: undefined,
description: triageDescription,
})}
task={makeTask({ id: "FN-TRIAGE", column: "triage", title: undefined, description: triageDescription })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
@@ -2287,39 +2286,40 @@ describe("TaskDetailModal", () => {
expect(document.querySelector("h2.detail-title")?.textContent).toBe(triageDescription);
});
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Show more" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("keeps the editing title form unaffected by the read-only clamp", async () => {
const longTitle = "Editable title ".repeat(25);
const { container } = renderDetail({
column: "todo",
title: longTitle,
description: "Editable description",
});
renderDetail({ column: "todo", title: longTitle, description: "Editable description" });
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Expand task title" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Edit task" }));
expect(document.querySelector("h2.detail-title")).toBeNull();
expect(document.querySelector(".detail-description-toggle")).toBeNull();
expectNoStandaloneTitleToggle();
expect(screen.getByLabelText("Title")).toHaveValue(longTitle);
});
it("keeps the summarize-title affordance aligned next to the clamped title", async () => {
const { container } = renderDetail({
it("keeps the summarize-title affordance distinct from title expansion", async () => {
renderDetail({
column: "todo",
title: "Summarize me ".repeat(25),
description: "Description available for summarization",
});
expect(document.querySelector(".detail-heading-row h2.detail-title--collapsed")).toBeInTheDocument();
expect(screen.getByTestId("summarize-title-btn")).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
const summarizeButton = screen.getByTestId("summarize-title-btn");
expect(summarizeButton).not.toBe(titleControl);
await userEvent.click(summarizeButton);
expect(titleControl).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("keeps the clamp available in chat-expanded layout", async () => {
const { container } = render(
render(
<TaskDetailContent
task={makeTask({
column: "todo",
@@ -2339,7 +2339,8 @@ describe("TaskDetailModal", () => {
expect(document.querySelector(".task-detail-content--chat-expanded")).toBeInTheDocument();
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Expand task title" })).toBeInTheDocument();
expectNoStandaloneTitleToggle();
});
it("has desktop and mobile CSS rules that preserve the two-line title clamp", () => {
@@ -2347,8 +2348,11 @@ describe("TaskDetailModal", () => {
expect(css).toContain(".detail-title--collapsed");
expectBaseRule(css, ".detail-title--collapsed", "-webkit-line-clamp: 2");
expectBaseRule(css, ".detail-title--collapsed", "line-clamp: 2");
expectBaseRule(css, ".detail-title-control", "width: 100%");
expectBaseRule(css, ".detail-title-control:focus-visible", "box-shadow: var(--focus-ring-strong)");
expect(css).toContain("@media (max-width: 768px)");
expectBaseRule(css, ".detail-title", "font-size: 16px");
expect(css).not.toContain(".detail-description-toggle");
});
});