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:
7
.changeset/truncate-board-titles.md
Normal file
7
.changeset/truncate-board-titles.md
Normal 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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user