feat(KB-653): add board title truncation with tooltip

- Truncate long board titles to 140 characters with ellipsis in TaskCard\n- Add hover tooltip to display full title when truncated\n- Add comprehensive test cases for 140 character truncation edge cases\n- Include changeset for board title truncation fix
This commit is contained in:
gsxdsm
2026-03-31 22:42:09 -07:00
parent d4165a4164
commit 602cea0817
3 changed files with 166 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@gsxdsm/fusion": patch
---
Truncate board titles to 140 characters with ellipsis and tooltip
Task card titles in the board view are now truncated to a maximum of 140 characters. When a title exceeds this limit, an ellipsis (…) is appended and the full title is shown in a tooltip on hover. This prevents overly long titles from breaking the board layout while preserving full accessibility.

View File

@@ -637,9 +637,16 @@ function TaskCardComponent({
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
</div>
)}
<div className="card-title">
{task.title || task.description || task.id}
</div>
{/* Truncate title/description to 140 chars with ellipsis; full text in tooltip */}
{(() => {
const displayText = task.title || task.description || task.id;
const truncatedText = truncate(displayText, 140);
return (
<div className="card-title" title={displayText}>
{truncatedText}
</div>
);
})()}
{task.steps.length > 0 && (() => {
const completedSteps = task.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
const totalSteps = task.steps.length;
@@ -735,5 +742,9 @@ function TaskCardComponent({
const TOUCH_MOVE_THRESHOLD = 10; // pixels
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "…" : s;
}
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
TaskCard.displayName = "TaskCard";

View File

@@ -2564,3 +2564,148 @@ describe("TaskCard title display", () => {
expect(cardTitle).toBeDefined();
});
});
/**
* Tests for TaskCard title truncation to 140 characters.
*/
describe("TaskCard title truncation", () => {
const noopToast = vi.fn();
const makeTask = (overrides: Partial<Task> = {}): Task => ({
id: "FN-001",
description: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
} as Task);
it("displays short title unchanged (under 140 characters)", () => {
const shortTitle = "This is a short title";
const task = makeTask({ title: shortTitle });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const cardTitle = screen.getByText(shortTitle);
expect(cardTitle).toBeDefined();
expect(cardTitle.textContent).toBe(shortTitle);
});
it("displays title exactly 140 characters unchanged", () => {
const exactTitle = "A".repeat(140);
const task = makeTask({ title: exactTitle });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const cardTitle = screen.getByText(exactTitle);
expect(cardTitle).toBeDefined();
expect(cardTitle.textContent?.length).toBe(140);
expect(cardTitle.textContent).toBe(exactTitle);
});
it("truncates title over 140 characters with ellipsis", () => {
const longTitle = "B".repeat(150);
const task = makeTask({ title: longTitle });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
// Should show truncated text with ellipsis (140 chars + "…")
const expectedTruncated = "B".repeat(140) + "…";
const cardTitle = screen.getByText(expectedTruncated);
expect(cardTitle).toBeDefined();
expect(cardTitle.textContent?.length).toBe(141); // 140 + ellipsis
});
it("truncates description fallback when no title and description is over 140 chars", () => {
const longDescription = "C".repeat(200);
const task = makeTask({ title: undefined, description: longDescription });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
// Should show truncated description with ellipsis
const expectedTruncated = "C".repeat(140) + "…";
const cardTitle = screen.getByText(expectedTruncated);
expect(cardTitle).toBeDefined();
expect(cardTitle.textContent?.length).toBe(141);
});
it("includes full untruncated text in title attribute for tooltip", () => {
const longTitle = "D".repeat(200);
const task = makeTask({ title: longTitle });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
// The title attribute should contain the full untruncated text
const cardTitleElement = document.querySelector(".card-title");
expect(cardTitleElement).toBeDefined();
expect(cardTitleElement?.getAttribute("title")).toBe(longTitle);
});
it("includes full description in title attribute when using description fallback", () => {
const longDescription = "E".repeat(200);
const task = makeTask({ title: undefined, description: longDescription });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const cardTitleElement = document.querySelector(".card-title");
expect(cardTitleElement).toBeDefined();
expect(cardTitleElement?.getAttribute("title")).toBe(longDescription);
});
it("includes task id in title attribute when falling back to id", () => {
const task = makeTask({ title: undefined, description: "" });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const cardTitleElement = document.querySelector(".card-title");
expect(cardTitleElement).toBeDefined();
expect(cardTitleElement?.getAttribute("title")).toBe("FN-001");
});
});