@art-tools/react-gantt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artem Makatera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,626 @@
1
+ # @art-tools/react-gantt
2
+
3
+ A high-performance, composable Gantt chart component library for React. It renders
4
+ thousands of tasks smoothly (rows **and** columns are virtualized on both axes),
5
+ ships a full undo/redo transaction model, cascading dependency scheduling, and a
6
+ MUI-style slot system for deep customization.
7
+
8
+ - **Composable** — drop in the all-in-one `<Gantt />`, or assemble
9
+ `<GanttProvider>` + `<TaskList>` + `<GanttGrid>` yourself.
10
+ - **Interactive** — drag to move/resize bars, draw and delete dependency links,
11
+ edit progress, expand/collapse hierarchy.
12
+ - **Fast** — windowed rendering with overscan, an incremental resolve cache, and
13
+ purpose-scoped React contexts so hot updates don't re-render stable subtrees.
14
+
15
+ ---
16
+
17
+ ## Table of contents
18
+
19
+ - [Install](#install)
20
+ - [Quick start](#quick-start)
21
+ - [Composable API](#composable-api)
22
+ - [`GanttProps` reference](#ganttprops-reference)
23
+ - [Data model](#data-model)
24
+ - [Working time (calendars)](#working-time-calendars)
25
+ - [Task bars](#task-bars)
26
+ - [Dependencies & scheduling](#dependencies--scheduling)
27
+ - [Columns](#columns)
28
+ - [Read-only](#read-only)
29
+ - [Imperative API](#imperative-api)
30
+ - [Accessibility](#accessibility)
31
+ - [Slots & theming](#slots--theming)
32
+ - [Architecture (for contributors)](#architecture-for-contributors)
33
+ - [Roadmap](#roadmap)
34
+
35
+ ---
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pnpm add @art-tools/react-gantt
41
+ ```
42
+
43
+ Peer dependencies: `react` and `react-dom` (`^18 || ^19`). The only runtime
44
+ dependency is [`clsx`](https://github.com/lukeed/clsx).
45
+
46
+ Import the stylesheet once, near your app root:
47
+
48
+ ```ts
49
+ import "@art-tools/react-gantt/style.css";
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Quick start
55
+
56
+ ```tsx
57
+ import { Gantt, type GanttTask } from "@art-tools/react-gantt";
58
+ import "@art-tools/react-gantt/style.css";
59
+
60
+ // Build dates with the (year, monthIndex, day) constructor, never an ISO string —
61
+ // `new Date("2023-01-10")` parses as UTC midnight while the geometry reads local
62
+ // civil instants. `endDate` is EXCLUSIVE: "Install Apache" occupies Jan 10 alone.
63
+ const tasks: GanttTask[] = [
64
+ {
65
+ id: 1000,
66
+ name: "Launch Cloud Platform",
67
+ startDate: new Date(2023, 0, 10),
68
+ endDate: new Date(2023, 0, 22),
69
+ type: "summary",
70
+ },
71
+ {
72
+ id: 1,
73
+ name: "Setup web server",
74
+ startDate: new Date(2023, 0, 10),
75
+ endDate: new Date(2023, 0, 14),
76
+ progress: 33,
77
+ parentId: 1000,
78
+ },
79
+ {
80
+ id: 11,
81
+ name: "Install Apache",
82
+ startDate: new Date(2023, 0, 10),
83
+ endDate: new Date(2023, 0, 11),
84
+ progress: 50,
85
+ parentId: 1,
86
+ },
87
+ {
88
+ id: 12,
89
+ name: "Configure firewall",
90
+ startDate: new Date(2023, 0, 10),
91
+ endDate: new Date(2023, 0, 12),
92
+ progress: 50,
93
+ parentId: 1,
94
+ },
95
+ ];
96
+
97
+ export function App() {
98
+ return <Gantt tasks={tasks} height={500} colWidth={60} rowHeight={40} />;
99
+ }
100
+ ```
101
+
102
+ `height` is **required** — when set, rows scroll vertically inside it while the
103
+ calendar header stays pinned.
104
+
105
+ > The `tasks` prop is a **stable seed**: pass it once and never feed a resolved list
106
+ > back into it. All create/update/delete/edit go through the internal change log via
107
+ > the [imperative API](#imperative-api) and action columns, keeping undo/redo intact.
108
+ > See [`docs/data-structures.md`](./docs/data-structures.md) for the full model.
109
+
110
+ A complete, interactive example (10,000 tasks, custom slots, edit modal, undo/redo)
111
+ lives in [`apps/playground/src/App.tsx`](../../apps/playground/src/App.tsx).
112
+
113
+ ---
114
+
115
+ ## Composable API
116
+
117
+ `<Gantt>` is a thin wrapper that wires the provider and lays out a resizable split
118
+ view (task list pane + calendar/grid). For full layout control, compose the pieces
119
+ directly:
120
+
121
+ ```tsx
122
+ import { GanttProvider, TaskList, GanttGrid } from "@art-tools/react-gantt";
123
+
124
+ <GanttProvider tasks={tasks} height={500}>
125
+ <TaskList />
126
+ <GanttGrid />
127
+ </GanttProvider>;
128
+ ```
129
+
130
+ Set `hideTaskList` on `<Gantt>` to render only the calendar/grid (no task-list pane,
131
+ no splitter).
132
+
133
+ Exported components: `Gantt`, `GanttProvider`, `GanttGrid`, `TaskList`, `TaskBar`,
134
+ `ProjectBar`, `MilestoneBar`, `Calendar`.
135
+
136
+ ---
137
+
138
+ ## `GanttProps` reference
139
+
140
+ Defined in [`src/types.ts`](./src/types.ts).
141
+
142
+ ### Data
143
+
144
+ | Prop | Type | Description |
145
+ | --------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
146
+ | `tasks` | `GanttTask[]` | **Required.** Stable seed list (see the note in Quick start). |
147
+ | `dependencies` | `TaskDependency[]` | Links between tasks (FS/FF/SS/SF, optional lag). |
148
+ | `columns` | `ColumnDef[]` | Task-list columns. Falls back to built-in default columns. |
149
+ | `readOnly` | `boolean` | Remove every editing affordance — see [Read-only](#read-only). |
150
+ | `calendar` | `GanttCalendar` | Working-time definition. Supplying it opts into working-time scheduling — see [Working time](#working-time-calendars). |
151
+ | `snapToWorking` | `boolean` | Default `true`. `false` keeps non-working shading but leaves dates untouched. |
152
+ | `durationUnit` | `"day" \| "hour" \| "minute"` | How an input `duration` is interpreted and displayed. Default `"day"`. |
153
+
154
+ ### Layout
155
+
156
+ | Prop | Type | Description |
157
+ | ---------------------- | --------- | ---------------------------------------------------------------------------------------------- |
158
+ | `height` | `number` | **Required.** Total component height in px; enables the pinned header + vertical scroll. |
159
+ | `rowHeight` | `number` | Row height in px. |
160
+ | `colWidth` | `number` | Width of one day column in px. |
161
+ | `scales` | `Scale[]` | Calendar header rows (defaults to month + day — see [`DEFAULT_SCALES`](./src/core/scales.ts)). |
162
+ | `padDays` | `number` | Extra day columns padded before/after the task date range. |
163
+ | `defaultTaskListWidth` | `number` | Initial width of the task-list pane. |
164
+ | `hideTaskList` | `boolean` | Render only the calendar/grid. |
165
+
166
+ ### Callbacks
167
+
168
+ | Prop | Signature | Fired when |
169
+ | -------------------- | ------------------------------- | --------------------------------------------------------------- |
170
+ | `onTaskClick` | `(task) => void` | A row/bar is selected. |
171
+ | `onDependencyCreate` | `(dep: TaskDependency) => void` | A link is drawn between two tasks. |
172
+ | `onDependencyDelete` | `(dep: TaskDependency) => void` | A link is deleted. |
173
+ | `onTaskCreate` | `(task, afterId?) => void` | A task is created. |
174
+ | `onTaskDelete` | `(id: Id) => void` | A task is deleted. |
175
+ | `onTaskEdit` | `(task) => void` | A column's edit action fires (e.g. the actions-column pencil). |
176
+ | `onTasksChange` | `(tasks: GanttTask[]) => void` | The resolved list changes (after create/delete/edit/undo/redo). |
177
+
178
+ ### Other
179
+
180
+ | Prop | Type | Description |
181
+ | ----------------- | ------------------------ | ----------------------------------------------------------------------------------- |
182
+ | `apiRef` | `React.Ref<GanttHandle>` | The [imperative API](#imperative-api) handle. |
183
+ | `taskList` | `GanttTaskListSlots` | Slot overrides for the task-list pane (`treeCell`, `header`). |
184
+ | `bars` | `GanttBarsSlots` | Slot overrides for timeline bars and their handles. |
185
+ | `dependencySlots` | `GanttDependenciesSlots` | Slot overrides for dependency links (named to avoid colliding with `dependencies`). |
186
+ | `timeline` | `GanttTimelineSlots` | Slot overrides for the calendar/grid chrome. |
187
+ | `labels` | `GanttLabels` | Overrides for the [accessible strings](#accessibility). Pass a stable object. |
188
+
189
+ ---
190
+
191
+ ## Data model
192
+
193
+ ```ts
194
+ interface GanttTask {
195
+ id: Id; // string | number
196
+ name: string;
197
+ startDate: Date;
198
+ endDate?: Date; // EXCLUSIVE — the instant work stops
199
+ duration?: number;
200
+ progress?: number; // 0–100
201
+ type?: "task" | "milestone" | "summary"; // default: "task"
202
+ parentId?: Id | null; // null/undefined = root
203
+ }
204
+ ```
205
+
206
+ - **`Id`** — `string | number`.
207
+ - **`endDate` is exclusive** — it is the instant work _stops_, not the last day
208
+ worked. A task running Monday through Friday is
209
+ `{ startDate: Mon, endDate: Sat }`, and a 9-to-5 Friday task is
210
+ `Fri 09:00 → Fri 17:00`. This is what makes interval arithmetic work without
211
+ scattered ±1 day corrections. To show a user the inclusive last day, use
212
+ `api.format.endDate(task)` inside a column, or the exported `displayEndDate`
213
+ / `endInstantFromDisplayDate` helpers when bridging a date input.
214
+ - **`duration`** — interpreted in the chart's `durationUnit` and, when a
215
+ `calendar` is set, counted in _working_ time. The library never writes this
216
+ field back; it derives dates from it and leaves your data alone.
217
+ - **`type`** — `"task"` (default), `"milestone"` (a diamond at `startDate`), or
218
+ `"summary"` (a parent whose dates and progress roll up from its children). See
219
+ [Task bars](#task-bars).
220
+ - **Hierarchy** — established via `parentId`. Roots have no `parentId`.
221
+
222
+ Dependencies are a separate array:
223
+
224
+ ```ts
225
+ type TaskDependencyType = "FS" | "FF" | "SS" | "SF";
226
+
227
+ type TaskDependency = {
228
+ from: Id;
229
+ to: Id;
230
+ type: TaskDependencyType;
231
+ lag?: number; // in `durationUnit`s; WORKING time when a calendar is set
232
+ };
233
+ ```
234
+
235
+ For the full seed → change-log → resolved-list pipeline (transactions, cursor,
236
+ roll-up), see [`docs/data-structures.md`](./docs/data-structures.md).
237
+
238
+ ---
239
+
240
+ ## Working time (calendars)
241
+
242
+ By default the chart schedules in plain linear time: weekends are shaded but a
243
+ five-day task dragged onto a Thursday simply ends on Monday. Pass a `calendar` to
244
+ make non-working time real — for the scheduler, for drag, and for the dependency
245
+ cascade.
246
+
247
+ ```tsx
248
+ <Gantt
249
+ tasks={tasks}
250
+ height={480}
251
+ calendar={{
252
+ hours: ["8:00-12:00", "13:00-17:00"], // lunch is the gap between ranges
253
+ days: {
254
+ 0: false,
255
+ 6: false, // weekends off (0 = Sunday)
256
+ 5: ["8:00-12:00"], // short Friday
257
+ },
258
+ dates: {
259
+ "2026-01-01": false, // holiday
260
+ "2026-01-10": ["9:00-13:00"], // half day
261
+ },
262
+ }}
263
+ />
264
+ ```
265
+
266
+ Three scopes resolve in the order **`dates` → `days` → `hours`**, so a specific
267
+ date beats a weekday rule, which beats the global default.
268
+
269
+ | Prop | Meaning |
270
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------- |
271
+ | `calendar` | The working-time definition. Supplying it _is_ the opt-in. Safe to write inline — it is keyed by content, not identity. |
272
+ | `snapToWorking` | Default `true`. Set `false` to keep the shading but leave dates untouched. |
273
+ | `durationUnit` | `"day"` (default), `"hour"`, or `"minute"` — how an input `duration` is read and displayed. |
274
+
275
+ Things worth knowing before you rely on it:
276
+
277
+ - **Omitting `hours` means whole days, not business hours.** A calendar that only
278
+ marks weekends off stays day-granular, so `duration: 3` is still three whole
279
+ days rather than three 8-hour shifts.
280
+ - **A day off is just a day with no hours** (`false`), so working _days_ are the
281
+ degenerate case of working _time_ — there is no separate concept.
282
+ - **One `durationUnit: "day"` is the week's longest working day.** With
283
+ `{ hours: ["8:00-17:00"] }` that is 9 hours. Adding a single longer weekday
284
+ therefore redefines "a day" for the whole chart.
285
+ - **Your data is never rewritten.** Snapping applies only to dates the library
286
+ authors — drag commits and cascade results. A task you author ending on a
287
+ Sunday renders where you put it until it is first edited.
288
+ - **Moves preserve working time, resizes set it.** Drag a three-working-day task
289
+ onto a Thursday and it stays three working days, growing visually across the
290
+ weekend. Drag its edge onto a Sunday and it settles back onto Friday.
291
+ - **Non-working time is shaded, not compressed.** The time axis stays linear.
292
+ Slot consumers get `isNonWorking` and `nonWorkingReason`
293
+ (`"weekend" | "holiday" | "offHours"`) on the grid-column and calendar-cell
294
+ ownerStates.
295
+ - **Known gap:** the actions column's "add after" button creates a task without
296
+ snapping it, because a column's `render` has no access to the calendar.
297
+
298
+ The reasoning behind each of these — including what was rejected — is recorded in
299
+ [`docs/adr/`](../../docs/adr/README.md).
300
+
301
+ ## Task bars
302
+
303
+ `Bar` ([`src/components/bars/common/Bar.tsx`](./src/components/bars/common/Bar.tsx))
304
+ computes each row's pixel geometry and dispatches on `type`:
305
+
306
+ - **`task`** → `TaskBar` — draggable, resizable, with a progress fill and label.
307
+ - **`milestone`** → `MilestoneBar` — a diamond marker, movable.
308
+ - **`summary`** → `ProjectBar` — a rolled-up parent bar, movable.
309
+
310
+ **Summary roll-up:** only `summary`-typed parents roll up
311
+ (`getParentTaskData` in [`src/core/prepareData.ts`](./src/core/prepareData.ts)):
312
+ `startDate` = min child start, `endDate` = max child end, `progress` = weighted mean
313
+ of non-milestone children. A parent of any other `type` is left exactly as authored.
314
+
315
+ > Note: the `summary` bar is still implemented by the component/file named
316
+ > `ProjectBar`, and its theme variables are `--am-gantt-project-*` (the `deaf0b7`
317
+ > rename covered the public task `type` value only).
318
+
319
+ ---
320
+
321
+ ## Dependencies & scheduling
322
+
323
+ Hover a bar to reveal start/end **connector handles**
324
+ ([`ConnectorHandles`](./src/components/bars/common/ConnectorHandles.tsx)); drag from
325
+ one bar's handle to another to create a link. The start/end handle combination maps
326
+ to the four dependency types (`HANDLE_TO_TYPE` in
327
+ [`src/hooks/useDependencyDrag.ts`](./src/hooks/useDependencyDrag.ts)):
328
+ Finish-to-Start, Finish-to-Finish, Start-to-Start, Start-to-Finish. Links render as
329
+ SVG polylines with arrowheads, a hit-area for selection, a delete button, and a lag
330
+ label ([`DependencyLinks`](./src/components/dependency-links/DependencyLinks.tsx));
331
+ they're culled to the visible rect.
332
+
333
+ **Cascading reschedule (ASAP):** when a task moves,
334
+ [`scheduleDependents`](./src/core/scheduling.ts) walks the dependency graph and
335
+ realigns successors according to each relationship type and its `lag`. The move and
336
+ all cascaded updates are committed as **one transaction**, so a drag-plus-cascade
337
+ undoes in a single step.
338
+
339
+ ---
340
+
341
+ ## Columns
342
+
343
+ ```ts
344
+ interface ColumnDef<T extends GanttTask = GanttTask> {
345
+ key: string;
346
+ header: string;
347
+ width?: number;
348
+ render: (task: T, api: ColumnApi) => React.ReactNode;
349
+ isTreeColumn?: boolean; // renders the indent + expand/collapse toggle
350
+ }
351
+ ```
352
+
353
+ Every `render` receives a `ColumnApi` as its second argument — the full
354
+ [imperative handle](#imperative-api) plus `editTask(task)` (which fires the
355
+ consumer's `onTaskEdit`). This is how columns build inline actions:
356
+
357
+ ```tsx
358
+ const columns: ColumnDef[] = [
359
+ { key: "name", header: "Name", isTreeColumn: true, render: (t) => t.name },
360
+ {
361
+ key: "actions",
362
+ header: "",
363
+ render: (task, api) => (
364
+ <>
365
+ <button onClick={() => api.editTask(task)}>✎</button>
366
+ <button onClick={() => api.deleteTask(task.id)}>✖</button>
367
+ </>
368
+ ),
369
+ },
370
+ ];
371
+ ```
372
+
373
+ When `columns` is omitted, `TaskList` renders `DEFAULT_COLUMNS`
374
+ ([`src/components/taskList/TaskListHeader.tsx`](./src/components/taskList/TaskListHeader.tsx)):
375
+ an `__action` column (edit / add-after / delete), a `__name` tree column, `__start`,
376
+ `__end`, and `__progress`. Columns are resizable via a header divider (widths are
377
+ tracked as session-local overrides).
378
+
379
+ ---
380
+
381
+ ## Read-only
382
+
383
+ ```tsx
384
+ <Gantt tasks={tasks} height={500} readOnly />
385
+ ```
386
+
387
+ `readOnly` removes every editing affordance rather than disabling one:
388
+
389
+ | Gone | Kept |
390
+ | ---------------------------------------------- | --------------------------------------------------------- |
391
+ | Bar move, resize, progress drag | Row/bar selection, `onTaskClick` |
392
+ | Dependency connector handles | Dependency links themselves (drawn as usual) |
393
+ | Link selection + its `×` / Delete-key deletion | Expand/collapse, scroll, zoom, column resize |
394
+ | The built-in `__action` column | The `__name` / `__start` / `__end` / `__progress` columns |
395
+
396
+ Nothing is rendered-but-inert: each affordance only exists when its handlers are
397
+ wired, so there is no grabbable dead element and no disabled styling.
398
+
399
+ `apiRef` keeps working — `readOnly` is about the pointer, not the data ([ADR-021](../../docs/adr/021-readonly-removes-affordances-not-the-api.md)).
400
+ Drive an otherwise-frozen chart from your own toolbar:
401
+
402
+ ```tsx
403
+ <Gantt tasks={tasks} height={500} readOnly apiRef={ref} />;
404
+ ref.current?.updateTask(id, { progress: 80 }); // still applies
405
+ ```
406
+
407
+ Custom `columns` are yours to gate — `render` receives `api.readOnly`:
408
+
409
+ ```tsx
410
+ render: (task, api) =>
411
+ api.readOnly ? null : <button onClick={() => api.editTask(task)}>✎</button>,
412
+ ```
413
+
414
+ `GanttProvider` takes the same prop, so the [composable API](#composable-api) behaves
415
+ identically.
416
+
417
+ ---
418
+
419
+ ## Imperative API
420
+
421
+ Pass an `apiRef` to reach the `GanttHandle`:
422
+
423
+ ```tsx
424
+ const ref = useRef<GanttHandle>(null);
425
+ <Gantt apiRef={ref} tasks={tasks} height={500} />;
426
+
427
+ ref.current?.createTask(task, afterId); // afterId omitted → append at end
428
+ ref.current?.updateTask(id, { progress: 80 });
429
+ ref.current?.deleteTask(id);
430
+ ref.current?.undo();
431
+ ref.current?.redo();
432
+ ref.current?.revealTask(id); // vertical; add { horizontal: true } for the bar
433
+ ref.current?.revealTask(id, { horizontal: true }); // expands collapsed ancestors first
434
+ ```
435
+
436
+ `updateTask` takes a `TaskPatch` (`name`, `startDate`, `endDate`, `progress`); only
437
+ the provided fields change. `createTask` runs synchronously (`flushSync`) so the new
438
+ task is visible to consumers immediately.
439
+
440
+ ---
441
+
442
+ ## Slots & theming
443
+
444
+ ### CSS custom properties
445
+
446
+ The default look is driven by `--am-gantt-*` variables in
447
+ [`src/index.css`](./src/index.css). Override them in your own CSS to retheme:
448
+
449
+ ```css
450
+ :root {
451
+ --am-gantt-task-bg: #0ba5ff; /* task bar fill */
452
+ --am-gantt-project-bg: #16a34a; /* summary bar fill */
453
+ --am-gantt-milestone-bg: #f59e0b; /* milestone diamond */
454
+ --am-gantt-calendar-header-bg: #f8fafc;
455
+ --am-gantt-calendar-weekend-bg: #f1f5f9;
456
+ /* …plus task colors, border radii, resizer sizing, z-indices */
457
+ }
458
+ ```
459
+
460
+ ### Slots
461
+
462
+ Every customizable component follows the MUI `{ slots, slotProps }` pattern with an
463
+ `ownerState` function form. `slots` swaps the underlying element/component; `slotProps`
464
+ merges props onto the library's defaults — `className` is `clsx`-merged, `style` is
465
+ shallow-merged, and any other prop the consumer sets wins
466
+ ([`mergeSlotProps`](./src/core/slots.ts)).
467
+
468
+ The `<Gantt>` props group slots into four buckets: **`taskList`** (`treeCell`,
469
+ `header`), **`bars`** (`taskBar`, `projectBar`, `milestoneBar`, progress, resizer,
470
+ connector handles), **`dependencySlots`** (links, preview), and **`timeline`**
471
+ (calendar rows, grid columns, grid, resize handle).
472
+
473
+ ```tsx
474
+ // Swap the tree-cell expand button for a custom component, and set its glyph
475
+ // from ownerState. Keep the config object referentially stable (module-level or
476
+ // memoized) — rows are memoized, so a fresh object each render re-renders them all.
477
+ const taskListSlots: GanttTaskListSlots = {
478
+ treeCell: {
479
+ slots: { expandButton: RoundToggle },
480
+ slotProps: {
481
+ expandButton: ({ isExpanded }) => ({ children: isExpanded ? "−" : "+" }),
482
+ },
483
+ },
484
+ };
485
+
486
+ // Restyle task bars via the `root` slot — merged, not replaced.
487
+ const barSlots: GanttBarsSlots = {
488
+ taskBar: { slotProps: { root: { style: { borderRadius: 8 } } } },
489
+ };
490
+
491
+ <Gantt tasks={tasks} height={500} taskList={taskListSlots} bars={barSlots} />;
492
+ ```
493
+
494
+ Per-component slot types (`*Slots`, `*SlotProps`, `*SlotConfig`, `*OwnerState`) are
495
+ all exported from the package entry.
496
+
497
+ ---
498
+
499
+ ## Accessibility
500
+
501
+ The chart is fully readable by a screen reader in browse / table-navigation mode. It is
502
+ **not yet keyboard-operable** — see [Roadmap](#1-keyboard-navigation).
503
+
504
+ ### Structure
505
+
506
+ The two panes are exposed as two widgets under one labelled `group`:
507
+
508
+ | Element | Role & state |
509
+ | -------------- | -------------------------------------------------------------------------------------------------------- |
510
+ | Widget root | `group` + `aria-label` (`labels.gantt`) |
511
+ | Task-list pane | `treegrid` + `aria-label`, `aria-rowcount`, `aria-colcount` |
512
+ | Header row | `row` `aria-rowindex="1"`, cells `columnheader` + `aria-colindex` |
513
+ | Task row | `row` + `aria-rowindex`, `aria-level`, `aria-expanded`, `aria-posinset`, `aria-setsize`, `aria-selected` |
514
+ | Task cells | `gridcell`, or `rowheader` for the tree column; each with `aria-colindex` |
515
+ | Timeline pane | `grid` + `aria-label`, `aria-rowcount`, `aria-colcount` (one column per date) |
516
+ | Calendar row | `row` + `aria-rowindex`, cells `columnheader` + `aria-colindex`/`aria-colspan` |
517
+ | Bar | `gridcell` + `aria-label`, `aria-colindex`/`aria-colspan` for its span on the date axis |
518
+
519
+ Both panes are virtualized, so `aria-rowcount` reports the **full** list while only a
520
+ window is in the DOM, and every `aria-rowindex` is absolute. Counts and indices are
521
+ 1-based, and the timeline's task rows are offset by its calendar header rows.
522
+
523
+ Two deliberate choices are worth knowing about:
524
+
525
+ - **A bar is one announcement.** Its `aria-label` carries name, type, dates and progress
526
+ (`"Design phase, summary, 3 Mar 2026 to 12 Mar 2026, 40% complete"`), and its inner
527
+ subtree is `aria-hidden`. So a bar reads as one coherent unit instead of a pile of
528
+ nested `div`s — and the timeline stays usable on its own when `hideTaskList` is set.
529
+ - **Pointer-only affordances are hidden.** Bar move, resize, progress and dependency
530
+ creation are mouse-only drags, so the connector handles, resize grips, progress grip
531
+ and dependency-link layer are `aria-hidden` with `tabIndex={-1}`. They are not
532
+ advertised as controls that no key can activate. Tab visits only the tree expand
533
+ toggles and whatever buttons your columns render. Restore any of them through
534
+ `slotProps` if you wire up your own keyboard handling.
535
+
536
+ Calendar headers announce the full period rather than the abbreviated visible text
537
+ (`"31 December 2021"`, not `"31"`). Override per scale with `Scale.ariaFormat`.
538
+
539
+ ### Labels
540
+
541
+ Every accessible string is overridable — pass a **stable** (memoized) object, since rows
542
+ and bars are memoized:
543
+
544
+ ```tsx
545
+ const labels = useMemo(
546
+ () => ({
547
+ gantt: "Projektplan",
548
+ taskList: "Aufgabenliste",
549
+ timeline: "Zeitachse",
550
+ expand: "Aufklappen",
551
+ collapse: "Zuklappen",
552
+ editTask: (task) => `${task.name} bearbeiten`,
553
+ bar: (task, { progress }) => `${task.name}, ${progress}% erledigt`,
554
+ }),
555
+ [],
556
+ );
557
+
558
+ <Gantt tasks={tasks} height={400} labels={labels} />;
559
+ ```
560
+
561
+ Omitted keys keep their English defaults. `ColumnDef.render` receives the resolved set as
562
+ `api.labels`, for naming controls a custom column renders.
563
+
564
+ ### Focus ring
565
+
566
+ The one keyboard-focusable control the library owns (the tree expand toggle) draws a
567
+ focus ring on `:focus-visible`, themeable via `--am-gantt-focus-ring-color`, `-width`
568
+ and `-offset`.
569
+
570
+ ---
571
+
572
+ ## Architecture (for contributors)
573
+
574
+ - **Frequency-split contexts** —
575
+ [`src/context/GanttContext.tsx`](./src/context/GanttContext.tsx) deliberately splits
576
+ state into ~9 purpose-scoped contexts/hooks by update frequency (`useGanttConfig`,
577
+ `useGanttTaskState`, `useGanttTaskActions`, `useGanttSelectedId`, `useGanttScroll`,
578
+ `useGanttViewport`, `useGanttDependency`, `useGanttDragActive`,
579
+ `useGanttDependencyDrag`), so a high-frequency update (e.g. drag coordinates)
580
+ doesn't re-render stable consumers. Grid-side slot groups flow through a separate
581
+ [`GanttSlotsProvider`](./src/context/GanttSlotsContext.tsx); the `taskList` group is
582
+ prop-drilled.
583
+ - **Mutation model** — [`src/hooks/useTaskList.ts`](./src/hooks/useTaskList.ts) owns an
584
+ insertion-ordered `ChangeLog` of transactions with a cursor for undo/redo. Each user
585
+ action is one transaction. An incremental resolve cache
586
+ (`resolveCommittedTasksCached` in [`src/core/prepareData.ts`](./src/core/prepareData.ts))
587
+ backed by an [LRU cache](./src/core/lruCache.ts) keeps resolution fast regardless of
588
+ history length.
589
+ - **Virtualization** — [`src/core/virtualize.ts`](./src/core/virtualize.ts)
590
+ (`rangeFromOffset`) windows both rows and date columns with overscan (see
591
+ [`src/core/constants.ts`](./src/core/constants.ts)); applied in `Grid` and `TaskList`,
592
+ and dependency links are culled to the visible rect.
593
+ - **Scroll sync** — [`useScrollSync`](./src/hooks/useScrollSync.ts) keeps the list and
594
+ grid aligned vertically; [`useScrollToTask`](./src/hooks/useScrollToTask.ts) +
595
+ [`core/scroll.ts`](./src/core/scroll.ts) handle reveal-into-view.
596
+
597
+ ### Styling & build
598
+
599
+ - **CSS Modules** (`*.module.css`) co-located with each component; theming via the
600
+ `--am-gantt-*` custom properties in `index.css`.
601
+ - **Build** — Vite library mode emits ESM (`index.mjs`) + CJS (`index.cjs`) with
602
+ `.d.ts` (via `vite-plugin-dts`) and a single `style.css`; `react`/`react-dom` are
603
+ externalized.
604
+ - **Tests** — Vitest + `@testing-library/react` (jsdom) under `src/tests/` and
605
+ `test/`; benchmarks (`vitest bench`) live in the playground.
606
+
607
+ ---
608
+
609
+ ## Roadmap
610
+
611
+ Proposed future features. These are **not yet implemented** — they capture gaps in the
612
+ current design and a sketch of how each would hook in.
613
+
614
+ ### 1. Critical path
615
+
616
+ The scheduling engine already builds the dependency graph
617
+ (`buildDependencyGraph` in [`src/core/scheduling.ts`](./src/core/scheduling.ts)).
618
+ Propose layering CPM (critical path method) analysis on top — compute the longest
619
+ zero-slack chain, expose a `highlightCriticalPath` option, and add slot hooks / an
620
+ `ownerState` flag so critical bars and links can be styled distinctly.
621
+
622
+ ### 2. Export / print
623
+
624
+ Propose export of the chart to PNG/SVG/PDF, plus a print-friendly render mode that
625
+ temporarily disables virtualization and renders the full extent so browser print
626
+ captures every row.