diff --git a/README.md b/README.md
index 1b600fc49..2992b0b52 100644
--- a/README.md
+++ b/README.md
@@ -205,6 +205,32 @@ const calendarConfig: CalendarConfig = {
- TaskListHeader: `React.FC<{ headerHeight: number; rowWidth: string; fontFamily: string; fontSize: string;}>;`
- TaskListTable: `React.FC<{ rowHeight: number; rowWidth: string; fontFamily: string; fontSize: string; locale: string; tasks: Task[]; selectedTaskId: string; setSelectedTask: (taskId: string) => void; }>;`
+### TaskList 列の表示制御(visibleFields)
+
+TaskList の表示列は `visibleFields` で指定します。`DEFAULT_VISIBLE_FIELDS` に progress 列は含まれないため、**進捗列は opt-in** です。
+
+```typescript
+import { DEFAULT_VISIBLE_FIELDS, VisibleField } from "@levelcaptech/gantt-task-react-custom";
+
+const visibleFields: VisibleField[] = [
+ "name",
+ "start",
+ "end",
+ "progress",
+ ...DEFAULT_VISIBLE_FIELDS.filter(field =>
+ !["name", "start", "end"].includes(field)
+ ),
+];
+
+;
+```
+
+progress セルの編集では、入力値は 5 の倍数に丸められ、`onCellCommit` には `"0"〜"100"` の文字列が通知されます。
+
### Task
| パラメーター名 | 型 | 説明 |
diff --git a/example/src/App.tsx b/example/src/App.tsx
index 2c929b468..46d41bcc8 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -100,6 +100,15 @@ const JapaneseTooltip: React.FC<{
type TaskFieldValue = Task[VisibleField];
+// 表示順と重複除外に使う基準の列(進捗列を含む定義は以下で構築する)
+const BASE_VISIBLE_FIELDS: VisibleField[] = ["name", "start", "end"];
+
+const VISIBLE_FIELDS_WITH_PROGRESS: VisibleField[] = [
+ ...BASE_VISIBLE_FIELDS,
+ "progress",
+ ...DEFAULT_VISIBLE_FIELDS.filter(field => !BASE_VISIBLE_FIELDS.includes(field)),
+];
+
const resolveCellCommitValue = (
columnId: VisibleField,
value: string,
@@ -114,7 +123,8 @@ const resolveCellCommitValue = (
return Number.isNaN(parsedDate.getTime()) ? fallbackValue : parsedDate;
}
case "plannedEffort":
- case "actualEffort": {
+ case "actualEffort":
+ case "progress": {
const parsedNumber = Number(value);
return Number.isNaN(parsedNumber) ? fallbackValue : parsedNumber;
}
@@ -265,7 +275,7 @@ const App = () => {
locale="ja-JP"
calendar={calendarConfig}
TooltipContent={JapaneseTooltip}
- visibleFields={DEFAULT_VISIBLE_FIELDS}
+ visibleFields={VISIBLE_FIELDS_WITH_PROGRESS}
onTaskUpdate={handleTaskUpdate}
onCellCommit={handleCellCommit}
effortDisplayUnit={effortUnit}
@@ -287,7 +297,7 @@ const App = () => {
locale="ja-JP"
calendar={calendarConfig}
TooltipContent={JapaneseTooltip}
- visibleFields={DEFAULT_VISIBLE_FIELDS}
+ visibleFields={VISIBLE_FIELDS_WITH_PROGRESS}
onTaskUpdate={handleTaskUpdate}
onCellCommit={handleCellCommit}
effortDisplayUnit={effortUnit}
diff --git a/src/components/task-list/overlay-editor.tsx b/src/components/task-list/overlay-editor.tsx
index 6b93d6dab..994568996 100644
--- a/src/components/task-list/overlay-editor.tsx
+++ b/src/components/task-list/overlay-editor.tsx
@@ -47,6 +47,7 @@ const resolveOverlayInputType = (
return "date";
case "plannedEffort":
case "actualEffort":
+ case "progress":
return "number";
case "process":
case "status":
@@ -116,6 +117,13 @@ export const OverlayEditor: React.FC = ({
}
return [];
}, [editingState.columnId]);
+ const numberInputAttributes = useMemo>(
+ () =>
+ editingState.columnId === "progress"
+ ? { min: 0, max: 100, step: 5 }
+ : {},
+ [editingState.columnId]
+ );
const portalRoot = useMemo(() => {
if (typeof document === "undefined") {
@@ -416,6 +424,7 @@ export const OverlayEditor: React.FC = ({
defaultValue={defaultValueRef.current}
style={{ height: "100%" }}
ref={handleInputElementRef}
+ {...(inputType === "number" ? numberInputAttributes : {})}
readOnly={editingState.pending}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
diff --git a/src/components/task-list/task-list-header.tsx b/src/components/task-list/task-list-header.tsx
index bb8bc3440..6fe50b746 100644
--- a/src/components/task-list/task-list-header.tsx
+++ b/src/components/task-list/task-list-header.tsx
@@ -46,6 +46,7 @@ export const TaskListHeaderDefault: React.FC<{
name: "タスク名",
start: "開始日",
end: "終了日",
+ progress: "進捗",
process: "工程",
assignee: "担当者",
plannedStart: "予定開始",
diff --git a/src/components/task-list/task-list-table.tsx b/src/components/task-list/task-list-table.tsx
index efcbdb0e8..fa547d3a3 100644
--- a/src/components/task-list/task-list-table.tsx
+++ b/src/components/task-list/task-list-table.tsx
@@ -5,6 +5,7 @@ import { getDefaultWidth, TaskListEditingStateContext } from "./task-list";
import {
formatDate,
formatEffort,
+ formatProgress,
getStatusBadgeText,
getStatusColor,
normalizeProcess,
@@ -57,6 +58,7 @@ export const TaskListTableDefault: React.FC<{
"name",
"start",
"end",
+ "progress",
"process",
"assignee",
"plannedStart",
@@ -273,6 +275,8 @@ export const TaskListTableDefault: React.FC<{
return {formatDate(t.start)};
case "end":
return {formatDate(t.end)};
+ case "progress":
+ return {formatProgress(t.progress)};
case "process":
return {normalizeProcess(t.process)};
case "assignee":
diff --git a/src/components/task-list/task-list.tsx b/src/components/task-list/task-list.tsx
index f02c8bf32..67d3689fc 100644
--- a/src/components/task-list/task-list.tsx
+++ b/src/components/task-list/task-list.tsx
@@ -22,6 +22,7 @@ import {
import {
formatDate,
parseDateFromInput,
+ parseProgressInput,
sanitizeEffortInput,
} from "../../helpers/task-helper";
import { ParsedTime, parseTimeString } from "../../helpers/time-helper";
@@ -306,6 +307,16 @@ export const TaskList: React.FC = ({
const rowId = editingState.rowId;
const columnId = editingState.columnId;
const task = tasks.find(row => row.id === rowId);
+ const resolveProgressCommit = () => {
+ if (columnId !== "progress") {
+ return null;
+ }
+ const parsedValue = parseProgressInput(value);
+ if (parsedValue === null) {
+ return { invalid: true, normalizedValue: null };
+ }
+ return { invalid: false, normalizedValue: `${parsedValue}` };
+ };
const resolveActualsCommit = () => {
if (!task) {
return null;
@@ -364,6 +375,21 @@ export const TaskList: React.FC = ({
updatedFields: Object.keys(updatedFields).length > 0 ? updatedFields : null,
};
};
+ const progressCommit = resolveProgressCommit();
+ if (progressCommit?.invalid) {
+ setEditingState(prev => {
+ if (
+ prev.mode !== "editing" ||
+ prev.pending ||
+ prev.rowId !== rowId ||
+ prev.columnId !== columnId
+ ) {
+ return prev;
+ }
+ return { ...prev, errorMessage: "0〜100 の数値を入力してください" };
+ });
+ return;
+ }
const actualsCommit = resolveActualsCommit();
setEditingState(prev => {
if (
@@ -377,7 +403,8 @@ export const TaskList: React.FC = ({
return { ...prev, pending: true, errorMessage: null };
});
try {
- const commitValue = actualsCommit?.normalizedValue ?? value;
+ const commitValue =
+ progressCommit?.normalizedValue ?? actualsCommit?.normalizedValue ?? value;
await onCellCommit({ rowId, columnId, value: commitValue, trigger });
if (actualsCommit?.updatedFields && onUpdateTask) {
onUpdateTask(rowId, actualsCommit.updatedFields);
diff --git a/src/helpers/task-helper.ts b/src/helpers/task-helper.ts
index 83f6c7cb5..acb940960 100644
--- a/src/helpers/task-helper.ts
+++ b/src/helpers/task-helper.ts
@@ -76,6 +76,34 @@ export const sanitizeEffortInput = (value: string) => {
return parsed;
};
+const clampProgress = (value: number) => Math.min(100, Math.max(0, value));
+
+/** 表示用に 0〜100 の範囲へクランプする(5刻み丸めは行わない) */
+export const normalizeProgress = (progress?: number) => {
+ if (progress === undefined || !Number.isFinite(progress)) {
+ return null;
+ }
+ return clampProgress(progress);
+};
+
+export const formatProgress = (progress?: number): string => {
+ const normalized = normalizeProgress(progress);
+ return normalized === null ? "" : `${normalized}`;
+};
+
+/** commit 用に 5 刻みへ丸めたうえで 0〜100 にクランプする */
+export const parseProgressInput = (value: string) => {
+ if (value.trim() === "") {
+ return null;
+ }
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) {
+ return null;
+ }
+ const rounded = Math.round(parsed / 5) * 5;
+ return clampProgress(rounded);
+};
+
const DEFAULT_TASK_PROCESS: TaskProcess = (
TASK_PROCESS_OPTIONS.includes("その他")
? "その他"
diff --git a/src/test/overlay-editor.test.tsx b/src/test/overlay-editor.test.tsx
index 5d074a379..0f8e07593 100644
--- a/src/test/overlay-editor.test.tsx
+++ b/src/test/overlay-editor.test.tsx
@@ -164,6 +164,7 @@ describe("OverlayEditor", () => {
["name", "INPUT", "text", "タスク名"],
["start", "INPUT", "date", "2026-01-01"],
["plannedEffort", "INPUT", "number", 8],
+ ["progress", "INPUT", "number", 45],
["process", "SELECT", "", "レビュー"],
["status", "SELECT", "", "完了"],
])(
@@ -211,6 +212,47 @@ describe("OverlayEditor", () => {
}
);
+ it("sets progress input constraints", async () => {
+ const rectSpy = jest
+ .spyOn(HTMLElement.prototype, "getBoundingClientRect")
+ .mockReturnValue(rect as DOMRect);
+ const rafSpy = jest
+ .spyOn(window, "requestAnimationFrame")
+ .mockImplementation(callback => {
+ callback(0);
+ return 1;
+ });
+ const { taskListRef, headerRef, bodyRef } = createRefs();
+
+ render(
+
+ );
+
+ const overlayInput = await screen.findByTestId("overlay-editor-input");
+
+ expect(overlayInput).toHaveAttribute("min", "0");
+ expect(overlayInput).toHaveAttribute("max", "100");
+ expect(overlayInput).toHaveAttribute("step", "5");
+
+ rectSpy.mockRestore();
+ rafSpy.mockRestore();
+ });
+
it("focuses the input when editing starts", async () => {
const rectSpy = jest
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
diff --git a/src/test/task-helper.test.tsx b/src/test/task-helper.test.tsx
index 2f93f3345..d89b47fc7 100644
--- a/src/test/task-helper.test.tsx
+++ b/src/test/task-helper.test.tsx
@@ -3,6 +3,8 @@ import {
parseDateFromInput,
formatEffort,
sanitizeEffortInput,
+ formatProgress,
+ parseProgressInput,
normalizeProcess,
normalizeStatus,
getStatusBadgeText,
@@ -67,6 +69,28 @@ describe("task-helper sanitizeEffortInput", () => {
});
});
+describe("task-helper progress helpers", () => {
+ it("formats progress with clamp only", () => {
+ expect(formatProgress(42)).toBe("42");
+ expect(formatProgress(101)).toBe("100");
+ expect(formatProgress(-3)).toBe("0");
+ });
+
+ it("returns empty string when progress is invalid", () => {
+ expect(formatProgress(Number.NaN)).toBe("");
+ });
+
+ it("parses progress input with rounding", () => {
+ expect(parseProgressInput("47")).toBe(45);
+ expect(parseProgressInput("100")).toBe(100);
+ });
+
+ it("rejects invalid progress input", () => {
+ expect(parseProgressInput("")).toBeNull();
+ expect(parseProgressInput("abc")).toBeNull();
+ });
+});
+
describe("task-helper normalize helpers", () => {
it("normalizes process to defined options", () => {
expect(normalizeProcess("設計")).toBe("設計");
diff --git a/src/test/task-list-commit.test.tsx b/src/test/task-list-commit.test.tsx
index 486af7ed3..50f9a22c9 100644
--- a/src/test/task-list-commit.test.tsx
+++ b/src/test/task-list-commit.test.tsx
@@ -28,6 +28,12 @@ const MockTaskListTable: React.FC = () => {
>
Start Effort
+
Task 1
@@ -37,6 +43,9 @@ const MockTaskListTable: React.FC = () => {
1
+
+ 50
+
);
};
@@ -54,7 +63,8 @@ const createTask = (overrides: Partial = {}): Task => ({
const renderTaskList = (
onCellCommit: jest.Mock,
onUpdateTask?: jest.Mock,
- tasks: Task[] = [createTask()]
+ tasks: Task[] = [createTask()],
+ visibleFields: VisibleField[] = ["name"]
) => {
render(
()}
@@ -194,4 +204,43 @@ describe("TaskList onCellCommit", () => {
expect((update.start as Date).getHours()).toBe(13);
expect((update.start as Date).getMinutes()).toBe(30);
});
+
+ it("normalizes progress commit to 5-step and clamps to 100", async () => {
+ const onCellCommit = jest.fn().mockResolvedValue(undefined);
+ renderTaskList(onCellCommit, undefined, [createTask({ progress: 50 })], [
+ "name",
+ "progress",
+ ]);
+
+ fireEvent.click(screen.getByTestId("start-edit-progress"));
+ const input = await screen.findByTestId("overlay-editor-input");
+ fireEvent.change(input, { target: { value: "103" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ await waitFor(() => expect(onCellCommit).toHaveBeenCalledTimes(1));
+ const commitPayload = onCellCommit.mock.calls[0][0];
+ expect(commitPayload.columnId).toBe("progress");
+ expect(commitPayload.value).toBe("100");
+ });
+
+ it("shows error when progress input is invalid", async () => {
+ const onCellCommit = jest.fn().mockResolvedValue(undefined);
+ renderTaskList(onCellCommit, undefined, [createTask({ progress: 50 })], [
+ "name",
+ "progress",
+ ]);
+
+ fireEvent.click(screen.getByTestId("start-edit-progress"));
+ const input = await screen.findByTestId("overlay-editor-input");
+ fireEvent.change(input, { target: { value: "" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ await waitFor(() =>
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "0〜100 の数値を入力してください"
+ )
+ );
+ expect(onCellCommit).not.toHaveBeenCalled();
+ expect(screen.getByTestId("overlay-editor-input")).toBeInTheDocument();
+ });
});
diff --git a/src/test/task-list-table-editing.test.tsx b/src/test/task-list-table-editing.test.tsx
index 6a6769f58..53c02c12e 100644
--- a/src/test/task-list-table-editing.test.tsx
+++ b/src/test/task-list-table-editing.test.tsx
@@ -299,6 +299,7 @@ describe("TaskListTable cell display", () => {
createMockTask("task-1", "Task 1", {
start: new Date(2026, 1, 1),
end: new Date(2026, 1, 10),
+ progress: 42,
process: "開発",
assignee: "田中太郎",
plannedStart: new Date(2026, 1, 1),
@@ -315,6 +316,7 @@ describe("TaskListTable cell display", () => {
"name",
"start",
"end",
+ "progress",
"process",
"assignee",
"plannedStart",
@@ -334,6 +336,7 @@ describe("TaskListTable cell display", () => {
// start and plannedStart share the same date for this fixture
expect(screen.getAllByText("2026-02-01")).toHaveLength(2);
expect(screen.getByText("2026-02-10")).toBeInTheDocument();
+ expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByText("開発")).toBeInTheDocument();
expect(screen.getByText("田中太郎")).toBeInTheDocument();
expect(screen.getByText("2026-02-15")).toBeInTheDocument();
@@ -350,6 +353,17 @@ describe("TaskListTable cell display", () => {
expect(screen.queryAllByRole("spinbutton")).toHaveLength(0);
});
+ it("does not render progress column when not included in visibleFields", () => {
+ render(
+
+ );
+
+ expect(document.querySelector('[data-column-id="progress"]')).toBeNull();
+ });
+
it("keeps edit triggers available for overlay editing", () => {
const context = createEditingContext("selected", "task-1", "name");
diff --git a/src/types/public-types.ts b/src/types/public-types.ts
index 854b91ed8..6e092cdb8 100644
--- a/src/types/public-types.ts
+++ b/src/types/public-types.ts
@@ -7,6 +7,7 @@ export type VisibleField =
| "name"
| "start"
| "end"
+ | "progress"
| "process"
| "assignee"
| "plannedStart"